Merge pull request 'Milestone 47 G: mesh-delivery's part of the one release' (#214) from feat/47-G-join12 into main

This commit was merged in pull request #214.
This commit is contained in:
2026-11-13 13:08:58 +00:00
1371 changed files with 125697 additions and 25686 deletions
+6
View File
@@ -2,3 +2,9 @@ vendor/
# A compiled program never enters a commit (issue 1577): `go build .` in a command directory leaves one there.
modules/*/cmd/*/mesh-*
!modules/*/cmd/*/mesh-*.go
# `go build .` at a module's root leaves its program there too (the view's, met a second time).
modules/*/mesh-*
# The delivery SDK's commands (design 47 G §464), built in their own directory or at the SDK's root.
/modules/mesh-delivery-sdk/cmd/seatgen/seatgen
/modules/mesh-delivery-sdk/seatgen/seatgen
/modules/mesh-delivery-sdk/mesh-pack
@@ -1,34 +0,0 @@
package meshcatalogclient
import (
"strings"
"testing"
)
// The key part holds only bus key characters and parsing gives the fingerprint back; a src1 or malformed value is refused,
// naming it.
func TestFingerprintKeyPart(t *testing.T) {
fp := SourcePrefix2 + strings.Repeat("ab", 32)
part, err := KeyPart(fp)
if err != nil {
t.Fatal(err)
}
if len(part) != 64 || strings.Trim(part, "0123456789abcdef") != "" {
t.Errorf("key part %q is not 64 lowercase hex characters", part)
}
back, err := ParseKeyPart(part)
if err != nil || back != fp {
t.Errorf("parse(%q) = %q, %v; want %q", part, back, err, fp)
}
src1 := "src1:" + strings.Repeat("c", 64)
for _, bad := range []string{src1, "src2:", "src2:xyz", "", "src2:" + strings.Repeat("A", 64), "src2:" + strings.Repeat("a", 63)} {
if _, err := KeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
t.Errorf("KeyPart(%q) = %v, want a refusal naming it", bad, err)
}
}
for _, bad := range []string{src1, "src2:" + strings.Repeat("a", 64), strings.Repeat("a", 63), strings.Repeat("A", 64), "", "zz"} {
if _, err := ParseKeyPart(bad); err == nil || !strings.Contains(err.Error(), "\""+bad+"\"") {
t.Errorf("ParseKeyPart(%q) = %v, want a refusal naming it", bad, err)
}
}
}
@@ -1,8 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient
go 1.26.0
require (
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
)
@@ -1,8 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient
go 1.26.0
require (
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
)
@@ -1,8 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient
go 1.26.0
require (
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
)
@@ -1,18 +0,0 @@
package meshdeliveryclient
// RemovedPrefix opens the description of a verb release 6 removed from the seat mesh-delivery (novox/hq ADR 0539 clause 17):
// the definition keeps it as an optional row with no input, so the holder of before and the holder of release 6 both hold
// the seat, and a caller is answered by name with its replacement.
const RemovedPrefix = "Removed in R6 (ADR 0539): refused by name, naming its replacement: "
// RemovedVerbs are the seat's verbs release 6 removed, each with its replacement in words: with no plan the gate keeps the
// merge's states and derives nothing after the merge. A new map each call.
func RemovedVerbs() map[string]string {
return map[string]string{
"times": "the delivery view (nox-delivery): each machine's want and have, and since when",
"what-if": "the merge check's composition, which composes every machine a change touches",
"release": "the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view",
"stop": "a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)",
"retire-history": "nothing to retire: the gate keeps no delivery after its merge",
}
}
@@ -1,8 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient
go 1.26.0
require (
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
)
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry
go 1.26.0
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry
go 1.26.0
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshartifactsweep
go 1.26.0
-717
View File
@@ -1,717 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshcatalog is the contract of the seat "mesh-catalog", defined whole (seats/mesh-catalog/seat.json): The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4).
package meshcatalog
import (
"bytes"
"encoding/json"
"errors"
"strings"
"time"
)
// Seat is the seat's name.
const Seat = "mesh-catalog"
const (
VerbModules = "modules"
VerbModule = "module"
VerbProvides = "provides"
VerbDependents = "dependents"
VerbSeatDefinitions = "seat-definitions"
)
const (
EventSeatDefinitionChanged = "seat-definition-changed"
EventSeatDefinitionRemoved = "seat-definition-removed"
EventManifestAccepted = "manifest-accepted"
EventManifestRefused = "manifest-refused"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbModules, VerbModule, VerbProvides, VerbDependents, VerbSeatDefinitions}
}
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventSeatDefinitionChanged, EventSeatDefinitionRemoved, EventManifestAccepted, EventManifestRefused}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
const (
Family = "mesh-catalog.events"
FamilyMaxAgeDays = 7
)
// SubjectSeatDefinitionChanged is where seat-definition-changed is published.
const SubjectSeatDefinitionChanged = "mesh.seat.mesh-catalog.event.seat-definition-changed"
// SubjectSeatDefinitionRemoved is where seat-definition-removed is published.
const SubjectSeatDefinitionRemoved = "mesh.seat.mesh-catalog.event.seat-definition-removed"
// SubjectManifestAccepted is where manifest-accepted is published.
const SubjectManifestAccepted = "mesh.seat.mesh-catalog.event.manifest-accepted"
// SubjectManifestRefused is where manifest-refused is published.
const SubjectManifestRefused = "mesh.seat.mesh-catalog.event.manifest-refused"
const (
StateManifests = "manifests"
StateModules = "modules"
StateCandidates = "candidates"
StateVerdicts = "verdicts"
StatePredefined = "predefined"
StateConfigCandidates = "config-candidates"
StatePackages = "packages"
)
// ManifestsByDigestKey is the key of one value of the state manifests under the form "<digest>".
func ManifestsByDigestKey(digest string) string { return digest }
// ParseManifestsByDigestKey reads the parts of a key of the form "<digest>", in the order of the form; the last result is false for a key of another form.
func ParseManifestsByDigestKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeManifestsByDigestValue reads one value of the state manifests under the form "<digest>", refusing a field ManifestsByDigestValue does not know and any text after the value.
func DecodeManifestsByDigestValue(raw []byte) (ManifestsByDigestValue, error) {
var v ManifestsByDigestValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// ModulesByModuleKey is the key of one value of the state modules under the form "<module>".
func ModulesByModuleKey(module string) string { return module }
// ParseModulesByModuleKey reads the parts of a key of the form "<module>", in the order of the form; the last result is false for a key of another form.
func ParseModulesByModuleKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeModulesByModuleValue reads one value of the state modules under the form "<module>", refusing a field ModulesByModuleValue does not know and any text after the value.
func DecodeModulesByModuleValue(raw []byte) (ModulesByModuleValue, error) {
var v ModulesByModuleValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// CandidatesByGroupKey is the key of one value of the state candidates under the form "<group>".
func CandidatesByGroupKey(group string) string { return group }
// ParseCandidatesByGroupKey reads the parts of a key of the form "<group>", in the order of the form; the last result is false for a key of another form.
func ParseCandidatesByGroupKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeCandidatesByGroupValue reads one value of the state candidates under the form "<group>", refusing a field CandidatesByGroupValue does not know and any text after the value.
func DecodeCandidatesByGroupValue(raw []byte) (CandidatesByGroupValue, error) {
var v CandidatesByGroupValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// VerdictsByDigestKey is the key of one value of the state verdicts under the form "<digest>".
func VerdictsByDigestKey(digest string) string { return digest }
// ParseVerdictsByDigestKey reads the parts of a key of the form "<digest>", in the order of the form; the last result is false for a key of another form.
func ParseVerdictsByDigestKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeVerdictsByDigestValue reads one value of the state verdicts under the form "<digest>", refusing a field VerdictsByDigestValue does not know and any text after the value.
func DecodeVerdictsByDigestValue(raw []byte) (VerdictsByDigestValue, error) {
var v VerdictsByDigestValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// PredefinedByListKey is the key of one value of the state predefined under the form "list".
func PredefinedByListKey() string { return "list" }
// ParsePredefinedByListKey reads the parts of a key of the form "list", in the order of the form; the last result is false for a key of another form.
func ParsePredefinedByListKey(key string) bool {
rest := key
if !strings.HasPrefix(rest, "list") {
return false
}
rest = rest[4:]
if rest != "" {
return false
}
return true
}
// DecodePredefinedByListValue reads one value of the state predefined under the form "list", refusing a field PredefinedByListValue does not know and any text after the value.
func DecodePredefinedByListValue(raw []byte) (PredefinedByListValue, error) {
var v PredefinedByListValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// ConfigCandidatesByPullKey is the key of one value of the state config-candidates under the form "<pull>".
func ConfigCandidatesByPullKey(pull string) string { return pull }
// ParseConfigCandidatesByPullKey reads the parts of a key of the form "<pull>", in the order of the form; the last result is false for a key of another form.
func ParseConfigCandidatesByPullKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeConfigCandidatesByPullValue reads one value of the state config-candidates under the form "<pull>", refusing a field ConfigCandidatesByPullValue does not know and any text after the value.
func DecodeConfigCandidatesByPullValue(raw []byte) (ConfigCandidatesByPullValue, error) {
var v ConfigCandidatesByPullValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// PackagesByModuleKey is the key of one value of the state packages under the form "<module>".
func PackagesByModuleKey(module string) string { return module }
// ParsePackagesByModuleKey reads the parts of a key of the form "<module>", in the order of the form; the last result is false for a key of another form.
func ParsePackagesByModuleKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodePackagesByModuleValue reads one value of the state packages under the form "<module>", refusing a field PackagesByModuleValue does not know and any text after the value.
func DecodePackagesByModuleValue(raw []byte) (PackagesByModuleValue, error) {
var v PackagesByModuleValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// PackagesByPackageSeriesKey is the key of one value of the state packages under the form "<package>.<series>".
func PackagesByPackageSeriesKey(packagePart, series string) string { return packagePart + "." + series }
// ParsePackagesByPackageSeriesKey reads the parts of a key of the form "<package>.<series>", in the order of the form; the last result is false for a key of another form.
func ParsePackagesByPackageSeriesKey(key string) (string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", false
}
rest = after0
if rest == "" {
return "", "", false
}
v2 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v0, v2, true
}
// DecodePackagesByPackageSeriesValue reads one value of the state packages under the form "<package>.<series>", refusing a field PackagesByPackageSeriesValue does not know and any text after the value.
func DecodePackagesByPackageSeriesValue(raw []byte) (PackagesByPackageSeriesValue, error) {
var v PackagesByPackageSeriesValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// ToolchainUsed is a toolchain a fingerprint was computed with (ADR 0405 clause 7).
type ToolchainUsed struct {
Language string `json:"language"`
// the build module's recipe version
Recipe string `json:"recipe"`
// the installed toolchain's version
Version string `json:"version"`
}
// ModulesInput is what modules takes.
type ModulesInput struct {
}
// ModulesAnswerModulesItem is a type of the contract.
type ModulesAnswerModulesItem struct {
// the merge commit it was last changed at
Commit string `json:"commit"`
// its fingerprint (src2: and 64 hex digits); absent when it has none
Fp string `json:"fp,omitempty"`
Module string `json:"module"`
// why it has none
None string `json:"none,omitempty"`
// the module's directory in its repository, '.' for a root
Path string `json:"path,omitempty"`
Repository string `json:"repository"`
}
// ModulesAnswer is what modules answers.
type ModulesAnswer struct {
Modules []ModulesAnswerModulesItem `json:"modules"`
}
// ModuleInput is what module takes.
type ModuleInput struct {
// the module's name
Module string `json:"module"`
}
// ModuleAnswerModule is a type of the contract.
type ModuleAnswerModule struct {
// the merge commit it was last changed at
Commit string `json:"commit"`
// its fingerprint (src2: and 64 hex digits); absent when it has none
Fp string `json:"fp,omitempty"`
// the module's manifest as the trunk holds it
Manifest map[string]any `json:"manifest,omitempty"`
Module string `json:"module"`
// why it has none
None string `json:"none,omitempty"`
// the module's directory in its repository, '.' for a root
Path string `json:"path,omitempty"`
Repository string `json:"repository"`
}
// ModuleAnswer is what module answers.
type ModuleAnswer struct {
Module ModuleAnswerModule `json:"module"`
}
// ProvidesInput is what provides takes.
type ProvidesInput struct {
// the provision, such as postgres-database
Provision string `json:"provision"`
}
// ProvidesAnswer is what provides answers.
type ProvidesAnswer = map[string]any
// DependentsInput is what dependents takes.
type DependentsInput struct {
// the module's name
Module string `json:"module"`
}
// DependentsAnswer is what dependents answers.
type DependentsAnswer = map[string]any
// SeatDefinitionsInput is what seat-definitions takes.
type SeatDefinitionsInput struct {
// the seat's name; none lists every trunk seat
Seat string `json:"seat,omitempty"`
}
// SeatDefinitionsAnswerEntry is a type of the contract.
type SeatDefinitionsAnswerEntry struct {
// the definition, resolved with the parts it includes
Definition map[string]any `json:"definition,omitempty"`
Digest string `json:"digest"`
Seat string `json:"seat"`
// trunk
Source string `json:"source"`
// why the definition could not be read, when it could not
Unparsed string `json:"unparsed,omitempty"`
}
// SeatDefinitionsAnswerSeatsItem is a type of the contract.
type SeatDefinitionsAnswerSeatsItem struct {
Digest string `json:"digest"`
Seat string `json:"seat"`
// trunk
Source string `json:"source"`
}
// SeatDefinitionsAnswer is what seat-definitions answers.
type SeatDefinitionsAnswer struct {
Entry *SeatDefinitionsAnswerEntry `json:"entry,omitempty"`
Found bool `json:"found,omitempty"`
Revision int64 `json:"revision,omitempty"`
Seat string `json:"seat,omitempty"`
Seats []SeatDefinitionsAnswerSeatsItem `json:"seats,omitempty"`
}
// SeatDefinitionChangedEvent is the body of the event seat-definition-changed.
type SeatDefinitionChangedEvent struct {
Digest string `json:"digest"`
Seat string `json:"seat"`
// trunk
Source string `json:"source"`
}
// SeatDefinitionRemovedEvent is the body of the event seat-definition-removed.
type SeatDefinitionRemovedEvent struct {
Seat string `json:"seat"`
}
// ManifestAcceptedEventBusStateItem is a type of the contract.
type ManifestAcceptedEventBusStateItem struct {
Configuration map[string]any `json:"configuration,omitempty"`
Name string `json:"name"`
Options map[string]any `json:"options,omitempty"`
Storage string `json:"storage"`
Template string `json:"template,omitempty"`
Types []map[string]any `json:"types,omitempty"`
}
// ManifestAcceptedEvent is the body of the event manifest-accepted.
type ManifestAcceptedEvent struct {
BusState []ManifestAcceptedEventBusStateItem `json:"bus-state"`
Digest string `json:"digest"`
Judge string `json:"judge"`
Module string `json:"module"`
// the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it
Revision int64 `json:"revision,omitempty"`
V int64 `json:"v"`
}
// ManifestRefusedEvent is the body of the event manifest-refused.
type ManifestRefusedEvent struct {
Digest string `json:"digest"`
Judge string `json:"judge"`
Module string `json:"module"`
Problems []string `json:"problems"`
// the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it
Revision int64 `json:"revision,omitempty"`
V int64 `json:"v"`
}
// ManifestsByDigestValue is one value of the state manifests under the key <digest>.
type ManifestsByDigestValue struct {
// the manifest, UTF-8 JSON text
Manifest string `json:"manifest"`
Module string `json:"module"`
// the layout version
V int64 `json:"v,omitempty"`
}
// ModulesByModuleValueInputs2 is a type of the contract.
type ModulesByModuleValueInputs2 struct {
Bases []string `json:"bases,omitempty"`
Builder string `json:"builder,omitempty"`
Contexts map[string]string `json:"contexts,omitempty"`
ImportForm string `json:"import-form,omitempty"`
Imports []string `json:"imports,omitempty"`
Locks map[string]string `json:"locks,omitempty"`
Packages []string `json:"packages,omitempty"`
Paths map[string]string `json:"paths,omitempty"`
Tree string `json:"tree"`
}
// ModulesByModuleValue is one value of the state modules under the key <module>.
type ModulesByModuleValue struct {
Commit string `json:"commit"`
// the old-form fingerprint
Fp string `json:"fp,omitempty"`
FpSrc int64 `json:"fp-src,omitempty"`
// src2: fingerprint
Fp2 string `json:"fp2,omitempty"`
Inputs2 *ModulesByModuleValueInputs2 `json:"inputs2,omitempty"`
// manifest digest
Manifest string `json:"manifest,omitempty"`
Needs []string `json:"needs,omitempty"`
None string `json:"none,omitempty"`
// why it has none
None2 string `json:"none2,omitempty"`
Packages map[string]string `json:"packages,omitempty"`
Path string `json:"path,omitempty"`
Paths map[string][]string `json:"paths,omitempty"`
Problems []string `json:"problems,omitempty"`
Repository string `json:"repository"`
// the source revision
Src int64 `json:"src,omitempty"`
// by language: the toolchain FP2 was computed with
Toolchains map[string]ToolchainUsed `json:"toolchains,omitempty"`
Unknown []string `json:"unknown,omitempty"`
// the layout version
V int64 `json:"v,omitempty"`
Verdict string `json:"verdict,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// CandidatesByGroupValuePullsItemFpsValue is a type of the contract.
type CandidatesByGroupValuePullsItemFpsValue struct {
Fp2 string `json:"fp2,omitempty"`
Manifest string `json:"manifest,omitempty"`
None2 string `json:"none2,omitempty"`
Packages map[string]string `json:"packages,omitempty"`
Paths map[string]string `json:"paths,omitempty"`
}
// CandidatesByGroupValuePullsItem is a type of the contract.
type CandidatesByGroupValuePullsItem struct {
Fps map[string]CandidatesByGroupValuePullsItemFpsValue `json:"fps,omitempty"`
Head string `json:"head"`
Modules []string `json:"modules"`
Number int64 `json:"number"`
Repository string `json:"repository"`
}
// CandidatesByGroupValue is one value of the state candidates under the key <group>.
type CandidatesByGroupValue struct {
// the source revision it is read on
Base int64 `json:"base"`
Fpset string `json:"fpset,omitempty"`
Group string `json:"group"`
Heads string `json:"heads"`
Pulls []CandidatesByGroupValuePullsItem `json:"pulls"`
// the layout version
V int64 `json:"v,omitempty"`
}
// VerdictsByDigestValue is one value of the state verdicts under the key <digest>.
type VerdictsByDigestValue struct {
// when (RFC 3339 time)
At *time.Time `json:"at,omitempty"`
Judge string `json:"judge,omitempty"`
Module string `json:"module"`
Problems []string `json:"problems,omitempty"`
State string `json:"state"`
Unknown []string `json:"unknown,omitempty"`
// the layout version
V int64 `json:"v,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
// PredefinedByListValue is one value of the state predefined under the key list.
type PredefinedByListValue struct {
// when (RFC 3339 time)
At *time.Time `json:"at,omitempty"`
Commit string `json:"commit"`
// the foundation's predefined list as kept
List any `json:"list"`
// the layout version
V int64 `json:"v,omitempty"`
}
// ConfigCandidatesByPullValueDeclaresValueValue is a type of the contract.
type ConfigCandidatesByPullValueDeclaresValueValue struct {
// the default
Default any `json:"default"`
Description string `json:"description,omitempty"`
// the allowed values
Enum any `json:"enum,omitempty"`
Kind string `json:"kind"`
// a declared bound; 0 is a bound, absent is none
Maximum *float64 `json:"maximum,omitempty"`
// a declared bound; 0 is a bound, absent is none
Minimum *float64 `json:"minimum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Predefined string `json:"predefined,omitempty"`
Required bool `json:"required,omitempty"`
Runtime bool `json:"runtime,omitempty"`
Trusted bool `json:"trusted,omitempty"`
Type string `json:"type,omitempty"`
Why string `json:"why"`
}
// ConfigCandidatesByPullValue is one value of the state config-candidates under the key <pull>.
type ConfigCandidatesByPullValue struct {
// when (RFC 3339 time)
At *time.Time `json:"at,omitempty"`
// module to key to declaration
Declares map[string]map[string]ConfigCandidatesByPullValueDeclaresValueValue `json:"declares"`
Head string `json:"head"`
Pull string `json:"pull"`
// the layout version
V int64 `json:"v,omitempty"`
}
// PackagesByModuleValuePackagesItem is a type of the contract.
type PackagesByModuleValuePackagesItem struct {
Digest string `json:"digest"`
Package string `json:"package"`
}
// PackagesByModuleValue is one value of the state packages under the key <module>.
type PackagesByModuleValue struct {
// when (RFC 3339 time)
At *time.Time `json:"at,omitempty"`
Commit string `json:"commit"`
Dir string `json:"dir,omitempty"`
// the Go module path
Module string `json:"module"`
Packages []PackagesByModuleValuePackagesItem `json:"packages"`
Repository string `json:"repository"`
// digest of go.mod and go.sum
Requires string `json:"requires,omitempty"`
// the layout version
V int64 `json:"v,omitempty"`
}
// PackagesByPackageSeriesValueNewestContentsPackagesItem is a type of the contract.
type PackagesByPackageSeriesValueNewestContentsPackagesItem struct {
Digest string `json:"digest"`
Package string `json:"package"`
}
// PackagesByPackageSeriesValueNewestContents is a type of the contract.
type PackagesByPackageSeriesValueNewestContents struct {
// when (RFC 3339 time)
At *time.Time `json:"at,omitempty"`
Commit string `json:"commit"`
Dir string `json:"dir,omitempty"`
// the Go module path
Module string `json:"module"`
Packages []PackagesByPackageSeriesValueNewestContentsPackagesItem `json:"packages"`
Repository string `json:"repository"`
// digest of go.mod and go.sum
Requires string `json:"requires,omitempty"`
// the layout version
V int64 `json:"v,omitempty"`
}
// PackagesByPackageSeriesValueNewest is a type of the contract.
type PackagesByPackageSeriesValueNewest struct {
Commit string `json:"commit,omitempty"`
Contents *PackagesByPackageSeriesValueNewestContents `json:"contents,omitempty"`
Digest string `json:"digest,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Version string `json:"version"`
}
// PackagesByPackageSeriesValueNumberedContentsPackagesItem is a type of the contract.
type PackagesByPackageSeriesValueNumberedContentsPackagesItem struct {
Digest string `json:"digest"`
Package string `json:"package"`
}
// PackagesByPackageSeriesValueNumberedContents is a type of the contract.
type PackagesByPackageSeriesValueNumberedContents struct {
// when (RFC 3339 time)
At *time.Time `json:"at,omitempty"`
Commit string `json:"commit"`
Dir string `json:"dir,omitempty"`
// the Go module path
Module string `json:"module"`
Packages []PackagesByPackageSeriesValueNumberedContentsPackagesItem `json:"packages"`
Repository string `json:"repository"`
// digest of go.mod and go.sum
Requires string `json:"requires,omitempty"`
// the layout version
V int64 `json:"v,omitempty"`
}
// PackagesByPackageSeriesValueNumbered is a type of the contract.
type PackagesByPackageSeriesValueNumbered struct {
Commit string `json:"commit,omitempty"`
Contents *PackagesByPackageSeriesValueNumberedContents `json:"contents,omitempty"`
Digest string `json:"digest,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Version string `json:"version"`
}
// PackagesByPackageSeriesValue is one value of the state packages under the key <package>.<series>.
type PackagesByPackageSeriesValue struct {
// when (RFC 3339 time)
At *time.Time `json:"at,omitempty"`
GoModule string `json:"go-module,omitempty"`
Name string `json:"name"`
Newest *PackagesByPackageSeriesValueNewest `json:"newest,omitempty"`
Numbered *PackagesByPackageSeriesValueNumbered `json:"numbered,omitempty"`
Package string `json:"package,omitempty"`
Seat string `json:"seat,omitempty"`
Series string `json:"series"`
// the layout version
V int64 `json:"v,omitempty"`
}
@@ -1,82 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package meshcatalog
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/mesh-catalog/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "a manifest (xclip, read through mesh-catalog.module)", subject: "", given: "{\"manifest\":\"{\\\"build\\\":{\\\"artifacts\\\":[{\\\"binary\\\":\\\"xclip-tools\\\",\\\"from\\\":\\\"cmd/xclip-tools\\\",\\\"kind\\\":\\\"bundle\\\",\\\"language\\\":\\\"go\\\",\\\"loads\\\":[\\\"xclip-tools\\\"],\\\"name\\\":\\\"tools\\\",\\\"system\\\":\\\"arch\\\"}]},\\\"capabilities\\\":[\\\"package-manager\\\"],\\\"module\\\":\\\"xclip\\\",\\\"resources\\\":[{\\\"id\\\":\\\"package\\\",\\\"package\\\":\\\"xclip\\\",\\\"type\\\":\\\"package\\\"}],\\\"tools\\\":[\\\"xclip_copy\\\",\\\"xclip_paste\\\",\\\"xclip_targets\\\",\\\"xclip_session\\\"],\\\"version\\\":\\\"1\\\"}\",\"module\":\"xclip\",\"v\":1}", wire: "{\"manifest\":\"{\\\"build\\\":{\\\"artifacts\\\":[{\\\"binary\\\":\\\"xclip-tools\\\",\\\"from\\\":\\\"cmd/xclip-tools\\\",\\\"kind\\\":\\\"bundle\\\",\\\"language\\\":\\\"go\\\",\\\"loads\\\":[\\\"xclip-tools\\\"],\\\"name\\\":\\\"tools\\\",\\\"system\\\":\\\"arch\\\"}]},\\\"capabilities\\\":[\\\"package-manager\\\"],\\\"module\\\":\\\"xclip\\\",\\\"resources\\\":[{\\\"id\\\":\\\"package\\\",\\\"package\\\":\\\"xclip\\\",\\\"type\\\":\\\"package\\\"}],\\\"tools\\\":[\\\"xclip_copy\\\",\\\"xclip_paste\\\",\\\"xclip_targets\\\",\\\"xclip_session\\\"],\\\"version\\\":\\\"1\\\"}\",\"module\":\"xclip\",\"v\":1}",
decode: func(b []byte) (any, error) { var v ManifestsByDigestValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a module (xclip, read through mesh-catalog.modules; the inputs and toolchains no tool reads are not here)", subject: "", given: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"v\":1}", wire: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"v\":1}",
decode: func(b []byte) (any, error) { var v ModulesByModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a candidate", subject: "", given: "{\"base\":3,\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"modules\":[\"nats\"],\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}", wire: "{\"base\":3,\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"modules\":[\"nats\"],\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}",
decode: func(b []byte) (any, error) { var v CandidatesByGroupValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a refused verdict (nats, read through the indexer's verdicts tool)", subject: "", given: "{\"at\":\"2026-11-04T18:51:12.614583047+01:00\",\"judge\":\"exe:661e6fb424d7ffea\",\"module\":\"nats\",\"problems\":[\"this is not a module manifest: a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: json: unknown field \\\"read-only\\\" (read without state[2].read-only)\"],\"state\":\"refused\"}", wire: "{\"at\":\"2026-11-04T18:51:12.614583047+01:00\",\"judge\":\"exe:661e6fb424d7ffea\",\"module\":\"nats\",\"problems\":[\"this is not a module manifest: a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: json: unknown field \\\"read-only\\\" (read without state[2].read-only)\"],\"state\":\"refused\"}",
decode: func(b []byte) (any, error) { var v VerdictsByDigestValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "the predefined list", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"list\":{\"streams\":[]},\"v\":1}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"list\":{\"streams\":[]},\"v\":1}",
decode: func(b []byte) (any, error) { var v PredefinedByListValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a pull request's configuration", subject: "", given: "{\"declares\":{\"nats\":{\"limit\":{\"default\":1,\"kind\":\"setting\",\"why\":\"a bound\"}}},\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"pull\":\"novox/mesh-catalog#1\",\"v\":1}", wire: "{\"declares\":{\"nats\":{\"limit\":{\"default\":1,\"kind\":\"setting\",\"why\":\"a bound\"}}},\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"pull\":\"novox/mesh-catalog#1\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v ConfigCandidatesByPullValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a pull request's configuration state whose bounds are declared 0 (a zero is a bound, not none)", subject: "", given: "{\"declares\":{\"nats\":{\"limit\":{\"default\":0,\"kind\":\"setting\",\"maximum\":0,\"minimum\":0,\"why\":\"a bound\"}}},\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"pull\":\"novox/mesh-catalog#1\",\"v\":1}", wire: "{\"declares\":{\"nats\":{\"limit\":{\"default\":0,\"kind\":\"setting\",\"maximum\":0,\"minimum\":0,\"why\":\"a bound\"}}},\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"pull\":\"novox/mesh-catalog#1\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v ConfigCandidatesByPullValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a published Go module", subject: "", given: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"module\":\"git.novox.be/novox/mesh-sdk/go\",\"packages\":[{\"digest\":\"ab\",\"package\":\"git.novox.be/novox/mesh-sdk/go/keys\"}],\"repository\":\"novox/mesh-sdk\",\"v\":1}", wire: "{\"commit\":\"cccccccccccccccccccccccccccccccccccccccc\",\"module\":\"git.novox.be/novox/mesh-sdk/go\",\"packages\":[{\"digest\":\"ab\",\"package\":\"git.novox.be/novox/mesh-sdk/go/keys\"}],\"repository\":\"novox/mesh-sdk\",\"v\":1}",
decode: func(b []byte) (any, error) { var v PackagesByModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a package series", subject: "", given: "{\"name\":\"go-keys\",\"series\":\"0.1\",\"v\":1}", wire: "{\"name\":\"go-keys\",\"series\":\"0.1\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v PackagesByPackageSeriesValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a module with its toolchain", subject: "", given: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1}", wire: "{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp2\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1}",
decode: func(b []byte) (any, error) { var v ModulesByModuleValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "the modules verb (captured: the first three modules)", subject: "", given: "{\"modules\":[{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:5e4a1b4ad4ea6175c05e4655ce58aeb957547149d4c9805eb27636efd50c1c87\",\"module\":\"adwaita\",\"path\":\"modules/adwaita\",\"repository\":\"novox/mesh-catalog\"},{\"commit\":\"b112adcecc41d18f54190cbd2fc864199b70c518\",\"fp\":\"src2:7dd0db8e98224f46121a5ae05c0b9c5a430eff531a5120aa40b1a59237853a3f\",\"module\":\"amqp-email-forwarder\",\"path\":\".\",\"repository\":\"jschoubben/amqp-email-forwarder\"},{\"commit\":\"dd458cb155f5f5fa3ed824a23728e444be5b156a\",\"fp\":\"src2:a98f8152f0a837dd5ba883a15efaf110ba88100193157e4bdb1a93ba55209371\",\"module\":\"artifact-store-tools\",\"path\":\"modules/artifact-store-tools\",\"repository\":\"novox/mesh-catalog\"}]}", wire: "{\"modules\":[{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:5e4a1b4ad4ea6175c05e4655ce58aeb957547149d4c9805eb27636efd50c1c87\",\"module\":\"adwaita\",\"path\":\"modules/adwaita\",\"repository\":\"novox/mesh-catalog\"},{\"commit\":\"b112adcecc41d18f54190cbd2fc864199b70c518\",\"fp\":\"src2:7dd0db8e98224f46121a5ae05c0b9c5a430eff531a5120aa40b1a59237853a3f\",\"module\":\"amqp-email-forwarder\",\"path\":\".\",\"repository\":\"jschoubben/amqp-email-forwarder\"},{\"commit\":\"dd458cb155f5f5fa3ed824a23728e444be5b156a\",\"fp\":\"src2:a98f8152f0a837dd5ba883a15efaf110ba88100193157e4bdb1a93ba55209371\",\"module\":\"artifact-store-tools\",\"path\":\"modules/artifact-store-tools\",\"repository\":\"novox/mesh-catalog\"}]}",
decode: func(b []byte) (any, error) { var v ModulesAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "the module verb (captured: xclip)", subject: "", given: "{\"module\":{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"manifest\":{\"build\":{\"artifacts\":[{\"binary\":\"xclip-tools\",\"from\":\"cmd/xclip-tools\",\"kind\":\"bundle\",\"language\":\"go\",\"loads\":[\"xclip-tools\"],\"name\":\"tools\",\"system\":\"arch\"}]},\"capabilities\":[\"package-manager\"],\"module\":\"xclip\",\"resources\":[{\"id\":\"package\",\"package\":\"xclip\",\"type\":\"package\"}],\"tools\":[\"xclip_copy\",\"xclip_paste\",\"xclip_targets\",\"xclip_session\"],\"version\":\"1\"},\"module\":\"xclip\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\"}}", wire: "{\"module\":{\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"manifest\":{\"build\":{\"artifacts\":[{\"binary\":\"xclip-tools\",\"from\":\"cmd/xclip-tools\",\"kind\":\"bundle\",\"language\":\"go\",\"loads\":[\"xclip-tools\"],\"name\":\"tools\",\"system\":\"arch\"}]},\"capabilities\":[\"package-manager\"],\"module\":\"xclip\",\"resources\":[{\"id\":\"package\",\"package\":\"xclip\",\"type\":\"package\"}],\"tools\":[\"xclip_copy\",\"xclip_paste\",\"xclip_targets\",\"xclip_session\"],\"version\":\"1\"},\"module\":\"xclip\",\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\"}}",
decode: func(b []byte) (any, error) { var v ModuleAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog
go 1.26.0
-455
View File
@@ -1,455 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshdelivery is the contract of the seat "mesh-delivery", defined whole (seats/mesh-delivery/seat.json): The delivery gate: reads and steers deliveries, merges and the checks that decide them; the merge is made on the operator's Approve once both checks passed.
package meshdelivery
import (
"bytes"
"encoding/json"
"errors"
"strings"
"time"
)
// Seat is the seat's name.
const Seat = "mesh-delivery"
const (
VerbDeliveries = "deliveries"
VerbTimes = "times"
VerbShow = "show"
VerbGroups = "groups"
VerbWhatIf = "what-if"
VerbChecks = "checks"
VerbTable = "table"
VerbStalled = "stalled"
VerbRecheck = "recheck"
VerbRelease = "release"
VerbStop = "stop"
VerbClose = "close"
VerbRetireHistory = "retire-history"
VerbSettle = "settle"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbDeliveries, VerbTimes, VerbShow, VerbGroups, VerbWhatIf, VerbChecks, VerbTable, VerbStalled, VerbRecheck, VerbRelease, VerbStop, VerbClose, VerbRetireHistory, VerbSettle}
}
const (
StateSource = "source"
StateMerges = "merges"
StateAsks = "asks"
StateOwed = "owed"
StateTurns = "turns"
)
// SourceByMainKey is the key of one value of the state source under the form "main".
func SourceByMainKey() string { return "main" }
// ParseSourceByMainKey reads the parts of a key of the form "main", in the order of the form; the last result is false for a key of another form.
func ParseSourceByMainKey(key string) bool {
rest := key
if !strings.HasPrefix(rest, "main") {
return false
}
rest = rest[4:]
if rest != "" {
return false
}
return true
}
// DecodeSourceByMainValue reads one value of the state source under the form "main", refusing a field SourceByMainValue does not know and any text after the value.
func DecodeSourceByMainValue(raw []byte) (SourceByMainValue, error) {
var v SourceByMainValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// MergesByGroupKey is the key of one value of the state merges under the form "<group>".
func MergesByGroupKey(group string) string { return group }
// ParseMergesByGroupKey reads the parts of a key of the form "<group>", in the order of the form; the last result is false for a key of another form.
func ParseMergesByGroupKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeMergesByGroupValue reads one value of the state merges under the form "<group>", refusing a field MergesByGroupValue does not know and any text after the value.
func DecodeMergesByGroupValue(raw []byte) (MergesByGroupValue, error) {
var v MergesByGroupValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// AsksByMergeGroupHeadsKey is the key of one value of the state asks under the form "merge.<group>.<heads>".
func AsksByMergeGroupHeadsKey(group, heads string) string { return "merge." + group + "." + heads }
// ParseAsksByMergeGroupHeadsKey reads the parts of a key of the form "merge.<group>.<heads>", in the order of the form; the last result is false for a key of another form.
func ParseAsksByMergeGroupHeadsKey(key string) (string, string, bool) {
rest := key
if !strings.HasPrefix(rest, "merge.") {
return "", "", false
}
rest = rest[6:]
v1, after1, found1 := strings.Cut(rest, ".")
if !found1 || v1 == "" {
return "", "", false
}
rest = after1
if rest == "" {
return "", "", false
}
v3 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v1, v3, true
}
// DecodeAsksByMergeGroupHeadsValue reads one value of the state asks under the form "merge.<group>.<heads>", refusing a field AsksByMergeGroupHeadsValue does not know and any text after the value.
func DecodeAsksByMergeGroupHeadsValue(raw []byte) (AsksByMergeGroupHeadsValue, error) {
var v AsksByMergeGroupHeadsValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// OwedByRepositoryNumberHeadActKey is the key of one value of the state owed under the form "<repository>.<number>.<head>.<act>".
func OwedByRepositoryNumberHeadActKey(repository, number, head, act string) string {
return repository + "." + number + "." + head + "." + act
}
// ParseOwedByRepositoryNumberHeadActKey reads the parts of a key of the form "<repository>.<number>.<head>.<act>", in the order of the form; the last result is false for a key of another form.
func ParseOwedByRepositoryNumberHeadActKey(key string) (string, string, string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", "", "", false
}
rest = after0
v2, after2, found2 := strings.Cut(rest, ".")
if !found2 || v2 == "" {
return "", "", "", "", false
}
rest = after2
v4, after4, found4 := strings.Cut(rest, ".")
if !found4 || v4 == "" {
return "", "", "", "", false
}
rest = after4
if rest == "" {
return "", "", "", "", false
}
v6 := rest
rest = ""
if rest != "" {
return "", "", "", "", false
}
return v0, v2, v4, v6, true
}
// DecodeOwedByRepositoryNumberHeadActValue reads one value of the state owed under the form "<repository>.<number>.<head>.<act>", refusing a field OwedByRepositoryNumberHeadActValue does not know and any text after the value.
func DecodeOwedByRepositoryNumberHeadActValue(raw []byte) (OwedByRepositoryNumberHeadActValue, error) {
var v OwedByRepositoryNumberHeadActValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// TurnsByRepositoryKey is the key of one value of the state turns under the form "<repository>".
func TurnsByRepositoryKey(repository string) string { return repository }
// ParseTurnsByRepositoryKey reads the parts of a key of the form "<repository>", in the order of the form; the last result is false for a key of another form.
func ParseTurnsByRepositoryKey(key string) (string, bool) {
rest := key
if rest == "" {
return "", false
}
v0 := rest
rest = ""
if rest != "" {
return "", false
}
return v0, true
}
// DecodeTurnsByRepositoryValue reads one value of the state turns under the form "<repository>", refusing a field TurnsByRepositoryValue does not know and any text after the value.
func DecodeTurnsByRepositoryValue(raw []byte) (TurnsByRepositoryValue, error) {
var v TurnsByRepositoryValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// DeliveriesInput is what deliveries takes.
type DeliveriesInput struct {
// "true": the final ones of the last thirty days too
All string `json:"all,omitempty"`
// a group's id (its branch name)
Group string `json:"group,omitempty"`
// owner/repository
Repository string `json:"repository,omitempty"`
// one state, e.g. delivering or held
State string `json:"state,omitempty"`
}
// The values of DeliveriesInput's all.
const (
DeliveriesInputAllTrue = "true"
DeliveriesInputAllFalse = "false"
)
// DeliveriesAnswer is what deliveries answers.
type DeliveriesAnswer = map[string]any
// TimesInput is what times takes.
type TimesInput struct {
}
// TimesAnswer is what times answers.
type TimesAnswer = map[string]any
// ShowInput is what show takes.
type ShowInput struct {
// a delivery's id, or a group's
ID string `json:"id"`
}
// ShowAnswer is what show answers.
type ShowAnswer = map[string]any
// GroupsInput is what groups takes.
type GroupsInput struct {
// "true": the groups that ended too
All string `json:"all,omitempty"`
}
// The values of GroupsInput's all.
const (
GroupsInputAllTrue = "true"
GroupsInputAllFalse = "false"
)
// GroupsAnswer is what groups answers.
type GroupsAnswer = map[string]any
// WhatIfInput is what what-if takes.
type WhatIfInput struct {
}
// WhatIfAnswer is what what-if answers.
type WhatIfAnswer = map[string]any
// ChecksInput is what checks takes.
type ChecksInput struct {
// a commit's sha, or the start of one, instead of a pull request
Commit string `json:"commit,omitempty"`
// a pull request's number: its head is read
Number string `json:"number,omitempty"`
// owner/repository
Repository string `json:"repository"`
}
// ChecksAnswer is what checks answers.
type ChecksAnswer = map[string]any
// TableInput is what table takes.
type TableInput struct {
}
// TableAnswer is what table answers.
type TableAnswer = map[string]any
// StalledInput is what stalled takes.
type StalledInput struct {
}
// StalledAnswer is what stalled answers.
type StalledAnswer = map[string]any
// RecheckInput is what recheck takes.
type RecheckInput struct {
// the delivery's id
ID string `json:"id"`
// why, kept with the transition
Why string `json:"why"`
}
// RecheckAnswer is what recheck answers.
type RecheckAnswer = map[string]any
// ReleaseInput is what release takes.
type ReleaseInput struct {
}
// ReleaseAnswer is what release answers.
type ReleaseAnswer = map[string]any
// StopInput is what stop takes.
type StopInput struct {
}
// StopAnswer is what stop answers.
type StopAnswer = map[string]any
// CloseInput is what close takes.
type CloseInput struct {
// the delivery's id
ID string `json:"id"`
// the condition it answers
Why string `json:"why"`
}
// CloseAnswer is what close answers.
type CloseAnswer = map[string]any
// RetireHistoryInput is what retire-history takes.
type RetireHistoryInput struct {
}
// RetireHistoryAnswer is what retire-history answers.
type RetireHistoryAnswer = map[string]any
// SettleInput is what settle takes.
type SettleInput struct {
// the held deliveries' ids (owner/repository@commit)
Ids []string `json:"ids"`
// why they may be settled, shown to the operator
Why string `json:"why"`
}
// SettleAnswer is what settle answers.
type SettleAnswer = map[string]any
// SourceByMainValue is one value of the state source under the key main.
type SourceByMainValue struct {
// the group that moved it
Group string `json:"group"`
// repository to the merged head, a full commit
Heads map[string]string `json:"heads"`
// the layout version
V int64 `json:"v,omitempty"`
}
// MergesByGroupValuePullsItem is a type of the contract.
type MergesByGroupValuePullsItem struct {
Commit string `json:"commit,omitempty"`
Head string `json:"head"`
Number int64 `json:"number"`
Repository string `json:"repository"`
// merging, merged or refused
State string `json:"state"`
Words string `json:"words,omitempty"`
}
// MergesByGroupValue is one value of the state merges under the key <group>.
type MergesByGroupValue struct {
// <repository>@<number> to the approved head
Approved map[string]string `json:"approved,omitempty"`
// the ask key
Ask string `json:"ask"`
Group string `json:"group"`
Pulls []MergesByGroupValuePullsItem `json:"pulls"`
// the layout version
V int64 `json:"v,omitempty"`
}
// AsksByMergeGroupHeadsValueHeadsItem is a type of the contract.
type AsksByMergeGroupHeadsValueHeadsItem struct {
// the full commit
Head string `json:"head"`
Number int64 `json:"number"`
// <owner>/<name>
Repository string `json:"repository"`
}
// AsksByMergeGroupHeadsValue is one value of the state asks under the key merge.<group>.<heads>.
type AsksByMergeGroupHeadsValue struct {
// the operator-channel ask as raised
Ask any `json:"ask"`
// the number of times it was asked
Asking int64 `json:"asking,omitempty"`
Group string `json:"group"`
Heads []AsksByMergeGroupHeadsValueHeadsItem `json:"heads"`
// the layout version
V int64 `json:"v,omitempty"`
}
// OwedByRepositoryNumberHeadActValue is one value of the state owed under the key <repository>.<number>.<head>.<act>.
type OwedByRepositoryNumberHeadActValue struct {
// status=<context>, view, note or comment.<seq>
Act string `json:"act"`
Context string `json:"context,omitempty"`
Ref string `json:"ref,omitempty"`
State string `json:"state,omitempty"`
Target string `json:"target,omitempty"`
Text string `json:"text,omitempty"`
// the layout version
V int64 `json:"v,omitempty"`
}
// TurnsByRepositoryValueWaitingItem is a type of the contract.
type TurnsByRepositoryValueWaitingItem struct {
// when (RFC 3339 time)
Approved time.Time `json:"approved"`
Ask string `json:"ask,omitempty"`
Group string `json:"group"`
Heads string `json:"heads"`
// when (RFC 3339 time)
Since time.Time `json:"since"`
Step string `json:"step,omitempty"`
WaitsFor string `json:"waits-for,omitempty"`
// when (RFC 3339 time)
WaitsSince *time.Time `json:"waits-since,omitempty"`
}
// TurnsByRepositoryValue is one value of the state turns under the key <repository>.
type TurnsByRepositoryValue struct {
Repository string `json:"repository"`
// the layout version
V int64 `json:"v,omitempty"`
Waiting []TurnsByRepositoryValueWaitingItem `json:"waiting"`
}
@@ -1,64 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package meshdelivery
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/mesh-delivery/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "the source revision", subject: "", given: "{\"group\":\"feat/registry\",\"heads\":{\"novox/mesh-sdk\":\"cccccccccccccccccccccccccccccccccccccccc\"},\"v\":1}", wire: "{\"group\":\"feat/registry\",\"heads\":{\"novox/mesh-sdk\":\"cccccccccccccccccccccccccccccccccccccccc\"},\"v\":1}",
decode: func(b []byte) (any, error) { var v SourceByMainValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a merge", subject: "", given: "{\"ask\":\"merge.feat=registry.0123456789ab\",\"group\":\"feat/registry\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"number\":1,\"repository\":\"novox/mesh-sdk\",\"state\":\"merging\"}],\"v\":1}", wire: "{\"ask\":\"merge.feat=registry.0123456789ab\",\"group\":\"feat/registry\",\"pulls\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"number\":1,\"repository\":\"novox/mesh-sdk\",\"state\":\"merging\"}],\"v\":1}",
decode: func(b []byte) (any, error) { var v MergesByGroupValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "an ask", subject: "", given: "{\"ask\":{\"headline\":\"merge\"},\"group\":\"feat/registry\",\"heads\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}", wire: "{\"ask\":{\"headline\":\"merge\"},\"group\":\"feat/registry\",\"heads\":[{\"head\":\"cccccccccccccccccccccccccccccccccccccccc\",\"number\":1,\"repository\":\"novox/mesh-sdk\"}],\"v\":1}",
decode: func(b []byte) (any, error) {
var v AsksByMergeGroupHeadsValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "an act owed", subject: "", given: "{\"act\":\"view\",\"v\":1}", wire: "{\"act\":\"view\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v OwedByRepositoryNumberHeadActValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a turn", subject: "", given: "{\"repository\":\"novox/mesh-sdk\",\"v\":1,\"waiting\":[{\"approved\":\"2026-11-05T10:00:00Z\",\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"since\":\"2026-11-05T10:01:00Z\"}]}", wire: "{\"repository\":\"novox/mesh-sdk\",\"v\":1,\"waiting\":[{\"approved\":\"2026-11-05T10:00:00Z\",\"group\":\"feat/registry\",\"heads\":\"0123456789ab\",\"since\":\"2026-11-05T10:01:00Z\"}]}",
decode: func(b []byte) (any, error) { var v TurnsByRepositoryValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery
go 1.26.0
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker
go 1.26.0
-337
View File
@@ -1,337 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// Package meshwants is the contract of the seat "mesh-wants", defined whole (seats/mesh-wants/seat.json): The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539).
package meshwants
import (
"bytes"
"encoding/json"
"errors"
"strings"
)
// Seat is the seat's name.
const Seat = "mesh-wants"
const (
VerbUpgrade = "upgrade"
VerbBus = "bus"
)
const (
EventPolicyChanged = "policy-changed"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
func Verbs() []string {
return []string{VerbUpgrade, VerbBus}
}
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventPolicyChanged}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
const (
Family = "mesh-wants.events"
FamilyMaxAgeDays = 7
)
// SubjectPolicyChanged is where policy-changed is published.
const SubjectPolicyChanged = "mesh.seat.mesh-wants.event.policy-changed"
const (
StateWants = "wants"
StateDifferences = "differences"
StateCompositions = "compositions"
)
// WantsByWantNodeModuleKey is the key of one value of the state wants under the form "want.<node>.<module>".
func WantsByWantNodeModuleKey(node, module string) string { return "want." + node + "." + module }
// ParseWantsByWantNodeModuleKey reads the parts of a key of the form "want.<node>.<module>", in the order of the form; the last result is false for a key of another form.
func ParseWantsByWantNodeModuleKey(key string) (string, string, bool) {
rest := key
if !strings.HasPrefix(rest, "want.") {
return "", "", false
}
rest = rest[5:]
v1, after1, found1 := strings.Cut(rest, ".")
if !found1 || v1 == "" {
return "", "", false
}
rest = after1
if rest == "" {
return "", "", false
}
v3 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v1, v3, true
}
// DecodeWantsByWantNodeModuleValue reads one value of the state wants under the form "want.<node>.<module>", refusing a field WantsByWantNodeModuleValue does not know and any text after the value.
func DecodeWantsByWantNodeModuleValue(raw []byte) (WantsByWantNodeModuleValue, error) {
var v WantsByWantNodeModuleValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// WantsByWantNodeNodeKey is the key of one value of the state wants under the form "want.<node>.node".
func WantsByWantNodeNodeKey(node string) string { return "want." + node + ".node" }
// ParseWantsByWantNodeNodeKey reads the parts of a key of the form "want.<node>.node", in the order of the form; the last result is false for a key of another form.
func ParseWantsByWantNodeNodeKey(key string) (string, bool) {
rest := key
if !strings.HasPrefix(rest, "want.") {
return "", false
}
rest = rest[5:]
v1, after1, found1 := strings.Cut(rest, ".node")
if !found1 || v1 == "" {
return "", false
}
rest = after1
if rest != "" {
return "", false
}
return v1, true
}
// DecodeWantsByWantNodeNodeValue reads one value of the state wants under the form "want.<node>.node", refusing a field WantsByWantNodeNodeValue does not know and any text after the value.
func DecodeWantsByWantNodeNodeValue(raw []byte) (WantsByWantNodeNodeValue, error) {
var v WantsByWantNodeNodeValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// WantsByHeldModuleFingerprintKey is the key of one value of the state wants under the form "held.<module>.<fingerprint>".
func WantsByHeldModuleFingerprintKey(module, fingerprint string) string {
return "held." + module + "." + fingerprint
}
// ParseWantsByHeldModuleFingerprintKey reads the parts of a key of the form "held.<module>.<fingerprint>", in the order of the form; the last result is false for a key of another form.
func ParseWantsByHeldModuleFingerprintKey(key string) (string, string, bool) {
rest := key
if !strings.HasPrefix(rest, "held.") {
return "", "", false
}
rest = rest[5:]
v1, after1, found1 := strings.Cut(rest, ".")
if !found1 || v1 == "" {
return "", "", false
}
rest = after1
if rest == "" {
return "", "", false
}
v3 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v1, v3, true
}
// DecodeWantsByHeldModuleFingerprintValue reads one value of the state wants under the form "held.<module>.<fingerprint>", refusing a field WantsByHeldModuleFingerprintValue does not know and any text after the value.
func DecodeWantsByHeldModuleFingerprintValue(raw []byte) (WantsByHeldModuleFingerprintValue, error) {
var v WantsByHeldModuleFingerprintValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// DifferencesByNodeModuleKey is the key of one value of the state differences under the form "<node>.<module>".
func DifferencesByNodeModuleKey(node, module string) string { return node + "." + module }
// ParseDifferencesByNodeModuleKey reads the parts of a key of the form "<node>.<module>", in the order of the form; the last result is false for a key of another form.
func ParseDifferencesByNodeModuleKey(key string) (string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", false
}
rest = after0
if rest == "" {
return "", "", false
}
v2 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v0, v2, true
}
// DecodeDifferencesByNodeModuleValue reads one value of the state differences under the form "<node>.<module>", refusing a field DifferencesByNodeModuleValue does not know and any text after the value.
func DecodeDifferencesByNodeModuleValue(raw []byte) (DifferencesByNodeModuleValue, error) {
var v DifferencesByNodeModuleValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// CompositionsByGroupHeadsKey is the key of one value of the state compositions under the form "<group>.<heads>".
func CompositionsByGroupHeadsKey(group, heads string) string { return group + "." + heads }
// ParseCompositionsByGroupHeadsKey reads the parts of a key of the form "<group>.<heads>", in the order of the form; the last result is false for a key of another form.
func ParseCompositionsByGroupHeadsKey(key string) (string, string, bool) {
rest := key
v0, after0, found0 := strings.Cut(rest, ".")
if !found0 || v0 == "" {
return "", "", false
}
rest = after0
if rest == "" {
return "", "", false
}
v2 := rest
rest = ""
if rest != "" {
return "", "", false
}
return v0, v2, true
}
// DecodeCompositionsByGroupHeadsValue reads one value of the state compositions under the form "<group>.<heads>", refusing a field CompositionsByGroupHeadsValue does not know and any text after the value.
func DecodeCompositionsByGroupHeadsValue(raw []byte) (CompositionsByGroupHeadsValue, error) {
var v CompositionsByGroupHeadsValue
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&v); err != nil {
return v, err
}
if dec.More() {
return v, errors.New("text after the value")
}
return v, nil
}
// UpgradeInput is what upgrade takes.
type UpgradeInput struct {
// "true": each node's own composition, by its digest, and the assigner's findings
Compare string `json:"compare,omitempty"`
// one module
Module string `json:"module,omitempty"`
// with module: roll, together, record, or default to take a person's choice back
Policy string `json:"policy,omitempty"`
// with policy: why, required and kept
Why string `json:"why,omitempty"`
}
// The values of UpgradeInput's compare.
const (
UpgradeInputCompareTrue = "true"
UpgradeInputCompareFalse = "false"
)
// UpgradeAnswer is what upgrade answers.
type UpgradeAnswer = map[string]any
// BusInput is what bus takes.
type BusInput struct {
}
// BusAnswer is what bus answers.
type BusAnswer = map[string]any
// PolicyChangedEvent is the body of the event policy-changed.
type PolicyChangedEvent = map[string]any
// WantsByWantNodeModuleValue is one value of the state wants under the key want.<node>.<module>.
type WantsByWantNodeModuleValue struct {
// again or skip
Backup string `json:"backup,omitempty"`
Composed string `json:"composed,omitempty"`
Fp string `json:"fp,omitempty"`
Hold bool `json:"hold,omitempty"`
Newest string `json:"newest,omitempty"`
Put bool `json:"put,omitempty"`
Release string `json:"release,omitempty"`
Removed bool `json:"removed,omitempty"`
// the rule that holds the node back, 1 to 10
Rule int64 `json:"rule,omitempty"`
Self bool `json:"self,omitempty"`
V int64 `json:"v,omitempty"`
Words string `json:"words,omitempty"`
Yes string `json:"yes,omitempty"`
}
// The values of WantsByWantNodeModuleValue's backup.
const (
WantsByWantNodeModuleValueBackupAgain = "again"
WantsByWantNodeModuleValueBackupSkip = "skip"
)
// WantsByWantNodeNodeValue is one value of the state wants under the key want.<node>.node.
type WantsByWantNodeNodeValue struct {
Composed string `json:"composed"`
Self bool `json:"self,omitempty"`
V int64 `json:"v,omitempty"`
Words string `json:"words,omitempty"`
}
// WantsByHeldModuleFingerprintValue is one value of the state wants under the key held.<module>.<fingerprint>.
type WantsByHeldModuleFingerprintValue struct {
Node string `json:"node"`
V int64 `json:"v,omitempty"`
Words string `json:"words,omitempty"`
}
// DifferencesByNodeModuleValue is one value of the state differences under the key <node>.<module>.
type DifferencesByNodeModuleValue struct {
Rule int64 `json:"rule,omitempty"`
Sent string `json:"sent,omitempty"`
SentBuild string `json:"sent-build,omitempty"`
Unexplained bool `json:"unexplained,omitempty"`
V int64 `json:"v,omitempty"`
Want string `json:"want,omitempty"`
WantRemoved bool `json:"want-removed,omitempty"`
Words string `json:"words,omitempty"`
}
// CompositionsByGroupHeadsValue is one value of the state compositions under the key <group>.<heads>.
type CompositionsByGroupHeadsValue struct {
Group string `json:"group"`
// 12 lowercase hex digits
Heads string `json:"heads"`
Machine string `json:"machine,omitempty"`
Machines []string `json:"machines,omitempty"`
Sdk string `json:"sdk,omitempty"`
// pass, fail or waits
State string `json:"state"`
// milliseconds
Took int64 `json:"took,omitempty"`
V int64 `json:"v,omitempty"`
Warnings []string `json:"warnings,omitempty"`
Words string `json:"words,omitempty"`
}
@@ -1,76 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package meshwants
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/mesh-wants/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "a want", subject: "", given: "{\"fp\":\"ab12\",\"rule\":3,\"v\":1}", wire: "{\"fp\":\"ab12\",\"rule\":3,\"v\":1}",
decode: func(b []byte) (any, error) {
var v WantsByWantNodeModuleValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a node's own want", subject: "", given: "{\"composed\":\"sha256:aa\",\"self\":true,\"v\":1}", wire: "{\"composed\":\"sha256:aa\",\"self\":true,\"v\":1}",
decode: func(b []byte) (any, error) {
var v WantsByWantNodeNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a held fingerprint", subject: "", given: "{\"node\":\"ace\",\"v\":1}", wire: "{\"node\":\"ace\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v WantsByHeldModuleFingerprintValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a difference", subject: "", given: "{\"unexplained\":true,\"v\":1,\"want\":\"ab12\"}", wire: "{\"unexplained\":true,\"v\":1,\"want\":\"ab12\"}",
decode: func(b []byte) (any, error) {
var v DifferencesByNodeModuleValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a composition", subject: "", given: "{\"group\":\"g1\",\"heads\":\"0123456789ab\",\"state\":\"pass\",\"v\":1}", wire: "{\"group\":\"g1\",\"heads\":\"0123456789ab\",\"state\":\"pass\",\"v\":1}",
decode: func(b []byte) (any, error) {
var v CompositionsByGroupHeadsValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants
go 1.26.0
@@ -1,88 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package nodebuildgo
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/node-build-go/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "a running step", subject: "", given: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}", wire: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a paused node", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}",
decode: func(b []byte) (any, error) {
var v PausesByPausedNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a node's capacity", subject: "", given: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}", wire: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}",
decode: func(b []byte) (any, error) {
var v PausesByCapacityNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a node's toolchain", subject: "", given: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}", wire: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}",
decode: func(b []byte) (any, error) {
var v ToolchainsByNodeLanguageValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a build ask", subject: "mesh.seat.node-build-go.accept.delivery", given: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", wire: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}",
decode: func(b []byte) (any, error) { var v DeliveryAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.accept\\.delivery$")},
{name: "a check ask with its fetch, siblings and levels", subject: "mesh.seat.node-build-go.accept.check", given: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"beside\":[{\"commit\":\"fedcba9876543210fedcba9876543210fedcba98\",\"repository\":\"novox/mesh-sdk\"}],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"fedcba9876543210fedcba9876543210fedcba98\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-catalog\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"2\",\"version\":\"1.26.0\"}},\"id\":\"c3d4\",\"kind\":\"check\",\"language\":\"go\",\"v\":1}", wire: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"beside\":[{\"commit\":\"fedcba9876543210fedcba9876543210fedcba98\",\"repository\":\"novox/mesh-sdk\"}],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"fedcba9876543210fedcba9876543210fedcba98\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-catalog\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"2\",\"version\":\"1.26.0\"}},\"id\":\"c3d4\",\"kind\":\"check\",\"language\":\"go\",\"v\":1}",
decode: func(b []byte) (any, error) { var v CheckAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.accept\\.check$")},
{name: "a finished build step with its artifacts", subject: "", given: "{\"ended\":\"2026-11-05T10:03:00Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"result\":{\"artifacts\":[{\"artifact\":\"go\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"reference\":\"container-image-registry/nats/go@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}],\"imports\":[\"git.novox.be/novox/mesh-sdk/go\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"build\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:03:00Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"result\":{\"artifacts\":[{\"artifact\":\"go\",\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"reference\":\"container-image-registry/nats/go@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"}],\"imports\":[\"git.novox.be/novox/mesh-sdk/go\"],\"toolchain\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"build\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a finished check step with its levels", subject: "", given: "{\"ended\":\"2026-11-05T10:06:00Z\",\"id\":\"c3d4\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"a.go\",\"modules/m/main.go\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"fedcba9876543210fedcba9876543210fedcba98\",\"modules\":[\".\"],\"trees\":{\"modules/m\":{\"locks\":{\"go.sum\":\"c5fc83c01e92404452b986527d239140ccf9a48b88e0c268fbf38c2e1429e9c9\"},\"manifest\":\"ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356\",\"tree\":\"1111111111111111111111111111111111111111\"}},\"workspace\":\"mvii1iwu5414e4\"},\"levels\":[{\"digest\":{\"level\":\"unit\",\"packages\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\"}]},\"kept\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\",\"tests\":12}],\"level\":\"unit\",\"tests\":[{\"package\":\"./modules/b\",\"pass\":true}],\"unmet\":[{\"needs\":[\"docker\"],\"package\":\"./modules/c\"}]}]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:06:00Z\",\"id\":\"c3d4\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"a.go\",\"modules/m/main.go\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"fedcba9876543210fedcba9876543210fedcba98\",\"modules\":[\".\"],\"trees\":{\"modules/m\":{\"locks\":{\"go.sum\":\"c5fc83c01e92404452b986527d239140ccf9a48b88e0c268fbf38c2e1429e9c9\"},\"manifest\":\"ca3d163bab055381827226140568f3bef7eaac187cebd76878e0b63e9e442356\",\"tree\":\"1111111111111111111111111111111111111111\"}},\"workspace\":\"mvii1iwu5414e4\"},\"levels\":[{\"digest\":{\"level\":\"unit\",\"packages\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\"}]},\"kept\":[{\"digest\":\"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"package\":\"./modules/a\",\"tests\":12}],\"level\":\"unit\",\"tests\":[{\"package\":\"./modules/b\",\"pass\":true}],\"unmet\":[{\"needs\":[\"docker\"],\"package\":\"./modules/c\"}]}]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a declined step", subject: "", given: "{\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"declined\":true,\"lacks\":[\"docker\"],\"needs\":[\"docker\"]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"refused\",\"tool\":\"check\",\"v\":1}", wire: "{\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"declined\":true,\"lacks\":[\"docker\"],\"needs\":[\"docker\"]},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"refused\",\"tool\":\"check\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a finished check step whose head does not merge", subject: "", given: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"conflicts\":[\"a.go\",\"sub/b.go\"],\"modules\":[\".\"],\"workspace\":\"mvii1iwz5050eb\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d5\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"conflicts\":[\"a.go\",\"sub/b.go\"],\"modules\":[\".\"],\"workspace\":\"mvii1iwz5050eb\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a run ask of the checking program over two heads", subject: "mesh.seat.node-build-go.accept.check", given: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"id\":\"r5e6\",\"kind\":\"check\",\"language\":\"go\",\"run\":{\"args\":[\"-verdict\"],\"heads\":[{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-delivery\"},{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-sdk\"}],\"input\":{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"size\":7,\"verb\":\"seat:mesh-delivery-checker.input\"},\"program\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"dir\":\"modules/mesh-delivery-checker\",\"package\":\"./cmd/mesh-checking-program\",\"repository\":\"novox/mesh-delivery\"},\"reader\":\"checking program of mesh-delivery head\"},\"v\":1}", wire: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"id\":\"r5e6\",\"kind\":\"check\",\"language\":\"go\",\"run\":{\"args\":[\"-verdict\"],\"heads\":[{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-delivery\"},{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"repository\":\"novox/mesh-sdk\"}],\"input\":{\"digest\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"size\":7,\"verb\":\"seat:mesh-delivery-checker.input\"},\"program\":{\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"dir\":\"modules/mesh-delivery-checker\",\"package\":\"./cmd/mesh-checking-program\",\"repository\":\"novox/mesh-delivery\"},\"reader\":\"checking program of mesh-delivery head\"},\"v\":1}",
decode: func(b []byte) (any, error) { var v CheckAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.accept\\.check$")},
{name: "a finished run step whose program exited non-zero", subject: "", given: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"r5e6\",\"node\":\"ace\",\"result\":{\"built\":true,\"exit\":2,\"key\":\"program-0123456789abcdef\",\"said\":\"the facts name no head\"},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"failed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"r5e6\",\"node\":\"ace\",\"result\":{\"built\":true,\"exit\":2,\"key\":\"program-0123456789abcdef\",\"said\":\"the facts name no head\"},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"failed\",\"tool\":\"check\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a step ended on a transport fault, given again by the bus", subject: "", given: "{\"again-at\":\"2026-11-05T10:01:30Z\",\"ended\":\"2026-11-05T10:00:30Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"passing\":true,\"reason\":\"fetching the commit: kex_exchange_identification: read: Connection reset by peer\",\"run\":1,\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"again\",\"tool\":\"build\",\"v\":1}", wire: "{\"again-at\":\"2026-11-05T10:01:30Z\",\"ended\":\"2026-11-05T10:00:30Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"passing\":true,\"reason\":\"fetching the commit: kex_exchange_identification: read: Connection reset by peer\",\"run\":1,\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"again\",\"tool\":\"build\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "an ask dropped by the caller that no longer waits", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"mesh-delivery-builder\",\"v\":1,\"why\":\"a newer fingerprint supersedes it\"}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"mesh-delivery-builder\",\"v\":1,\"why\":\"a newer fingerprint supersedes it\"}",
decode: func(b []byte) (any, error) { var v PausesByDroppedIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "drop names the ask and why", subject: "", given: "{\"by\":\"mesh-delivery-builder\",\"id\":\"a1b2\",\"why\":\"a newer fingerprint supersedes it\"}", wire: "{\"by\":\"mesh-delivery-builder\",\"id\":\"a1b2\",\"why\":\"a newer fingerprint supersedes it\"}",
decode: func(b []byte) (any, error) { var v DropInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildgo
go 1.26.0
@@ -1,74 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package nodebuildimage
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/node-build-image/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "a running step", subject: "", given: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}", wire: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a paused node", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}",
decode: func(b []byte) (any, error) {
var v PausesByPausedNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a node's capacity", subject: "", given: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}", wire: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}",
decode: func(b []byte) (any, error) {
var v PausesByCapacityNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a node's toolchain", subject: "", given: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}", wire: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}",
decode: func(b []byte) (any, error) {
var v ToolchainsByNodeLanguageValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a build ask", subject: "mesh.seat.node-build-image.accept.delivery", given: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", wire: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}",
decode: func(b []byte) (any, error) { var v DeliveryAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-image\\.accept\\.delivery$")},
{name: "a step ended on a transport fault, given again by the bus", subject: "", given: "{\"again-at\":\"2026-11-05T10:01:30Z\",\"ended\":\"2026-11-05T10:00:30Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"passing\":true,\"reason\":\"fetching the commit: kex_exchange_identification: read: Connection reset by peer\",\"run\":1,\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"again\",\"tool\":\"build\",\"v\":1}", wire: "{\"again-at\":\"2026-11-05T10:01:30Z\",\"ended\":\"2026-11-05T10:00:30Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"passing\":true,\"reason\":\"fetching the commit: kex_exchange_identification: read: Connection reset by peer\",\"run\":1,\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"again\",\"tool\":\"build\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "an ask dropped by the caller that no longer waits", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"mesh-delivery-builder\",\"v\":1,\"why\":\"a newer fingerprint supersedes it\"}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"mesh-delivery-builder\",\"v\":1,\"why\":\"a newer fingerprint supersedes it\"}",
decode: func(b []byte) (any, error) { var v PausesByDroppedIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "drop names the ask and why", subject: "", given: "{\"by\":\"mesh-delivery-builder\",\"id\":\"a1b2\",\"why\":\"a newer fingerprint supersedes it\"}", wire: "{\"by\":\"mesh-delivery-builder\",\"id\":\"a1b2\",\"why\":\"a newer fingerprint supersedes it\"}",
decode: func(b []byte) (any, error) { var v DropInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildimage
go 1.26.0
@@ -1,78 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
package nodebuildnode
import (
"encoding/json"
"reflect"
"regexp"
"testing"
)
// TestFixtures reads every fixture of seats/node-build-node/fixtures: each decodes into its type, a field the type does not
// know is dropped (ADR 0608 §6), and it reads back as its wire says; each subject named is the contract's.
func TestFixtures(t *testing.T) {
cases := []struct {
name, subject, given, wire string
decode func([]byte) (any, error)
subjectIs *regexp.Regexp
}{
{name: "a running step", subject: "", given: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}", wire: "{\"id\":\"a1b2\",\"node\":\"ace\",\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"running\",\"tool\":\"build\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a paused node", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"operator@shanks\",\"paused\":true,\"v\":1}",
decode: func(b []byte) (any, error) {
var v PausesByPausedNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a node's capacity", subject: "", given: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}", wire: "{\"configured\":2,\"effective\":2,\"v\":1,\"why\":\"the configured steps fit the cores\"}",
decode: func(b []byte) (any, error) {
var v PausesByCapacityNodeValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a node's toolchain", subject: "", given: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}", wire: "{\"language\":\"go\",\"recipe\":\"1\",\"v\":1,\"version\":\"1.26.0\"}",
decode: func(b []byte) (any, error) {
var v ToolchainsByNodeLanguageValue
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a build ask", subject: "mesh.seat.node-build-node.accept.delivery", given: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}", wire: "{\"asker\":\"mesh-delivery-builder\",\"build\":{\"artifacts\":[{\"artifact\":\"go\"}],\"module\":\"nats\"},\"id\":\"a1b2\",\"kind\":\"build\",\"language\":\"go\"}",
decode: func(b []byte) (any, error) { var v DeliveryAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-node\\.accept\\.delivery$")},
{name: "a check ask with the group's tagged package", subject: "mesh.seat.node-build-node.accept.check", given: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"package\":{\"name\":\"@novox/mesh-sdk\",\"version\":\"0.1.15-feat-x.1\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-delivery\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"typescript\",\"recipe\":\"1\",\"version\":\"26.12.0\"}},\"id\":\"c3d5\",\"kind\":\"check\",\"language\":\"typescript\",\"v\":1}", wire: "{\"asker\":\"mesh-delivery-checker\",\"at\":\"2026-11-05T10:00:00Z\",\"check\":{\"fetch\":{\"base\":\"89abcdef0123456789abcdef0123456789abcdef\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"package\":{\"name\":\"@novox/mesh-sdk\",\"version\":\"0.1.15-feat-x.1\"},\"ref\":\"refs/pull/7/head\",\"repository\":\"novox/mesh-delivery\"},\"kept\":true,\"levels\":[\"unit\",\"integration\"],\"node-names\":[\"ace\",\"g14\"],\"toolchain\":{\"language\":\"typescript\",\"recipe\":\"1\",\"version\":\"26.12.0\"}},\"id\":\"c3d5\",\"kind\":\"check\",\"language\":\"typescript\",\"v\":1}",
decode: func(b []byte) (any, error) { var v CheckAccept; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-node\\.accept\\.check$")},
{name: "a finished check step of a TypeScript merge preview", subject: "", given: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d6\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"modules/m/package.json\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"0123456789abcdef0123456789abcdef01234567\",\"modules\":[\"modules/m\"],\"trees\":{\"modules/m\":{\"manifest\":\"0581b699d9d05517e69b21f5cedbeb2eee8aba2b205280b561be731594d7feef\",\"tree\":\"0123456789abcdef0123456789abcdef01234567\"}},\"workspace\":\"mvii1sxt979f67\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}", wire: "{\"ended\":\"2026-11-05T10:01:00Z\",\"id\":\"c3d6\",\"node\":\"ace\",\"result\":{\"fetch\":{\"base\":\"abcdef0123456789abcdef0123456789abcdef01\",\"changed\":[\"modules/m/package.json\"],\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"merge\":\"0123456789abcdef0123456789abcdef01234567\",\"modules\":[\"modules/m\"],\"trees\":{\"modules/m\":{\"manifest\":\"0581b699d9d05517e69b21f5cedbeb2eee8aba2b205280b561be731594d7feef\",\"tree\":\"0123456789abcdef0123456789abcdef01234567\"}},\"workspace\":\"mvii1sxt979f67\"}},\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"passed\",\"tool\":\"check\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "a step ended on a transport fault, given again by the bus", subject: "", given: "{\"again-at\":\"2026-11-05T10:01:30Z\",\"ended\":\"2026-11-05T10:00:30Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"passing\":true,\"reason\":\"fetching the commit: kex_exchange_identification: read: Connection reset by peer\",\"run\":1,\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"again\",\"tool\":\"build\",\"v\":1}", wire: "{\"again-at\":\"2026-11-05T10:01:30Z\",\"ended\":\"2026-11-05T10:00:30Z\",\"id\":\"a1b2\",\"node\":\"ace\",\"passing\":true,\"reason\":\"fetching the commit: kex_exchange_identification: read: Connection reset by peer\",\"run\":1,\"started\":\"2026-11-05T10:00:00Z\",\"state\":\"again\",\"tool\":\"build\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepsByNodeIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "an ask dropped by the caller that no longer waits", subject: "", given: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"mesh-delivery-builder\",\"v\":1,\"why\":\"a newer fingerprint supersedes it\"}", wire: "{\"at\":\"2026-11-05T10:00:00Z\",\"by\":\"mesh-delivery-builder\",\"v\":1,\"why\":\"a newer fingerprint supersedes it\"}",
decode: func(b []byte) (any, error) { var v PausesByDroppedIDValue; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "drop names the ask and why", subject: "", given: "{\"by\":\"mesh-delivery-builder\",\"id\":\"a1b2\",\"why\":\"a newer fingerprint supersedes it\"}", wire: "{\"by\":\"mesh-delivery-builder\",\"id\":\"a1b2\",\"why\":\"a newer fingerprint supersedes it\"}",
decode: func(b []byte) (any, error) { var v DropInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
if err != nil {
t.Errorf("%s: does not decode: %v", c.name, err)
continue
}
back, err := json.Marshal(v)
if err != nil {
t.Errorf("%s: does not encode: %v", c.name, err)
continue
}
var got, want any
if err := json.Unmarshal(back, &got); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal([]byte(c.wire), &want); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: reads back as %s, want %s", c.name, back, c.wire)
}
if c.subjectIs != nil && !c.subjectIs.MatchString(c.subject) {
t.Errorf("%s: subject %q is not the contract's %s", c.name, c.subject, c.subjectIs)
}
}
}
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/nodebuildnode
go 1.26.0
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/npmpackageregistry
go 1.26.0
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry
go 1.26.0
-9
View File
@@ -1,9 +0,0 @@
{
"format": 1,
"seat": "git",
"scope": "mesh",
"grain": "seat-list",
"domain": "Delivery",
"decision": "novox/hq ADR 0111",
"delivers": "git"
}
@@ -1,223 +0,0 @@
[
{
"name": "a manifest (xclip, read through mesh-catalog.module)",
"of": "state:manifests/<digest>",
"given": {
"v": 1,
"module": "xclip",
"manifest": "{\"build\":{\"artifacts\":[{\"binary\":\"xclip-tools\",\"from\":\"cmd/xclip-tools\",\"kind\":\"bundle\",\"language\":\"go\",\"loads\":[\"xclip-tools\"],\"name\":\"tools\",\"system\":\"arch\"}]},\"capabilities\":[\"package-manager\"],\"module\":\"xclip\",\"resources\":[{\"id\":\"package\",\"package\":\"xclip\",\"type\":\"package\"}],\"tools\":[\"xclip_copy\",\"xclip_paste\",\"xclip_targets\",\"xclip_session\"],\"version\":\"1\"}"
}
},
{
"name": "a module (xclip, read through mesh-catalog.modules; the inputs and toolchains no tool reads are not here)",
"of": "state:modules/<module>",
"given": {
"v": 1,
"repository": "novox/mesh-catalog",
"commit": "f469eb387a3015569d9b16c66ed418e9dd098f64",
"path": "modules/xclip",
"fp2": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56"
}
},
{
"name": "a candidate",
"of": "state:candidates/<group>",
"given": {
"v": 1,
"group": "feat/registry",
"base": 3,
"heads": "0123456789ab",
"pulls": [
{
"repository": "novox/mesh-sdk",
"number": 1,
"head": "cccccccccccccccccccccccccccccccccccccccc",
"modules": [
"nats"
]
}
]
}
},
{
"name": "a refused verdict (nats, read through the indexer's verdicts tool)",
"of": "state:verdicts/<digest>",
"given": {
"module": "nats",
"state": "refused",
"problems": [
"this is not a module manifest: a state is either a name or {name, history, ttl-seconds, per-machine, key-ttl}: json: unknown field \"read-only\" (read without state[2].read-only)"
],
"judge": "exe:661e6fb424d7ffea",
"at": "2026-11-04T18:51:12.614583047+01:00"
}
},
{
"name": "the predefined list",
"of": "state:predefined/list",
"given": {
"v": 1,
"commit": "cccccccccccccccccccccccccccccccccccccccc",
"list": {
"streams": []
}
}
},
{
"name": "a pull request's configuration",
"of": "state:config-candidates/<pull>",
"given": {
"v": 1,
"pull": "novox/mesh-catalog#1",
"head": "cccccccccccccccccccccccccccccccccccccccc",
"declares": {
"nats": {
"limit": {
"kind": "setting",
"default": 1,
"why": "a bound"
}
}
}
}
},
{
"name": "a pull request's configuration state whose bounds are declared 0 (a zero is a bound, not none)",
"of": "state:config-candidates/<pull>",
"given": {
"v": 1,
"pull": "novox/mesh-catalog#1",
"head": "cccccccccccccccccccccccccccccccccccccccc",
"declares": {
"nats": {
"limit": {
"kind": "setting",
"default": 0,
"why": "a bound",
"minimum": 0,
"maximum": 0
}
}
}
}
},
{
"name": "a published Go module",
"of": "state:packages/<module>",
"given": {
"v": 1,
"module": "git.novox.be/novox/mesh-sdk/go",
"repository": "novox/mesh-sdk",
"commit": "cccccccccccccccccccccccccccccccccccccccc",
"packages": [
{
"package": "git.novox.be/novox/mesh-sdk/go/keys",
"digest": "ab"
}
]
}
},
{
"name": "a package series",
"of": "state:packages/<package>.<series>",
"given": {
"v": 1,
"name": "go-keys",
"series": "0.1"
}
},
{
"name": "a module with its toolchain",
"of": "state:modules/<module>",
"given": {
"v": 1,
"repository": "novox/mesh-catalog",
"commit": "f469eb387a3015569d9b16c66ed418e9dd098f64",
"fp2": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56",
"toolchains": {
"go": {
"language": "go",
"version": "1.26.0",
"recipe": "1"
}
}
}
},
{
"name": "the modules verb (captured: the first three modules)",
"of": "verb:modules.answer",
"given": {
"modules": [
{
"commit": "f469eb387a3015569d9b16c66ed418e9dd098f64",
"fp": "src2:5e4a1b4ad4ea6175c05e4655ce58aeb957547149d4c9805eb27636efd50c1c87",
"module": "adwaita",
"path": "modules/adwaita",
"repository": "novox/mesh-catalog"
},
{
"commit": "b112adcecc41d18f54190cbd2fc864199b70c518",
"fp": "src2:7dd0db8e98224f46121a5ae05c0b9c5a430eff531a5120aa40b1a59237853a3f",
"module": "amqp-email-forwarder",
"path": ".",
"repository": "jschoubben/amqp-email-forwarder"
},
{
"commit": "dd458cb155f5f5fa3ed824a23728e444be5b156a",
"fp": "src2:a98f8152f0a837dd5ba883a15efaf110ba88100193157e4bdb1a93ba55209371",
"module": "artifact-store-tools",
"path": "modules/artifact-store-tools",
"repository": "novox/mesh-catalog"
}
]
}
},
{
"name": "the module verb (captured: xclip)",
"of": "verb:module.answer",
"given": {
"module": {
"commit": "f469eb387a3015569d9b16c66ed418e9dd098f64",
"fp": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56",
"manifest": {
"build": {
"artifacts": [
{
"binary": "xclip-tools",
"from": "cmd/xclip-tools",
"kind": "bundle",
"language": "go",
"loads": [
"xclip-tools"
],
"name": "tools",
"system": "arch"
}
]
},
"capabilities": [
"package-manager"
],
"module": "xclip",
"resources": [
{
"id": "package",
"package": "xclip",
"type": "package"
}
],
"tools": [
"xclip_copy",
"xclip_paste",
"xclip_targets",
"xclip_session"
],
"version": "1"
},
"module": "xclip",
"path": "modules/xclip",
"repository": "novox/mesh-catalog"
}
}
}
]
File diff suppressed because it is too large Load Diff
@@ -1,44 +0,0 @@
[
{
"name": "verdict on the checker's own family",
"of": "holder-event:verdict",
"subject": "mesh.stream.mesh-delivery-checker.event.verdict",
"given": {
"repository": "novox/mesh-contracts",
"pull": 3,
"head": "cccccccccccccccccccccccccccccccccccccccc",
"verdict": "fail",
"out-of-step": [
"go/seats/meshbroker/contract.go"
],
"series": [
{
"module": "mesh-contracts",
"artifact": "go",
"from": "0.1",
"to": "0.2"
}
]
}
},
{
"name": "tag-wanted on the checker's own family",
"of": "holder-event:tag-wanted",
"subject": "mesh.stream.mesh-delivery-checker.event.tag-wanted",
"given": {
"repository": "novox/mesh-sdk",
"pull": 170,
"head": "cccccccccccccccccccccccccccccccccccccccc",
"branch": "feat/registry",
"module": "mesh-sdk",
"package": "go",
"tag": "registry-1966"
}
},
{
"name": "preview.written on the checker's stream",
"of": "holder-event:preview.written",
"subject": "mesh.stream.mesh-delivery-checker.event.preview.written",
"given": {}
}
]
-574
View File
@@ -1,574 +0,0 @@
{
"format": 1,
"seat": "mesh-delivery",
"scope": "mesh",
"grain": "whole",
"domain": "Delivery",
"decision": "novox/hq ADR 0239",
"verbs": [
{
"name": "deliveries",
"description": "Every delivery not final, and those that ended in the last day, one line each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, repository or group.",
"input": {
"properties": {
"all": {
"description": "\"true\": the final ones of the last thirty days too",
"enum": [
"true",
"false"
],
"type": "string"
},
"group": {
"description": "a group's id (its branch name)",
"type": "string"
},
"repository": {
"description": "owner/repository",
"type": "string"
},
"state": {
"description": "one state, e.g. delivering or held",
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "what deliveries answers, as its description says"
}
},
{
"name": "times",
"description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the delivery view (nox-delivery): each machine's want and have, and since when",
"optional": true,
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what times answers, as its description says"
}
},
{
"name": "show",
"description": "One delivery or group whole: its delivery plan (what it builds, what each machine receives, what is not an ordinary send), every transition with when and why, the machine steps of its walk, its group and its order.",
"input": {
"properties": {
"id": {
"description": "a delivery's id, or a group's",
"type": "string"
}
},
"required": [
"id"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "what show answers, as its description says"
}
},
{
"name": "groups",
"description": "Every delivery group: its members in order, why each pair is ordered (declared, built by, version skew, engine before controller, by name), its composed check and its state, derived from its members.",
"input": {
"properties": {
"all": {
"description": "\"true\": the groups that ended too",
"enum": [
"true",
"false"
],
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "what groups answers, as its description says"
}
},
{
"name": "what-if",
"description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the merge check's composition, which composes every machine a change touches",
"optional": true,
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what what-if answers, as its description says"
}
},
{
"name": "checks",
"description": "What the mesh's checks said of a pull request's head or of one commit: each of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, mesh/delivery, …) with its state, description and when it was set; the merge check's full verdict as the controller said it — each layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's protection would let it merge, every required status being success.",
"optional": true,
"input": {
"properties": {
"commit": {
"description": "a commit's sha, or the start of one, instead of a pull request",
"type": "string"
},
"number": {
"description": "a pull request's number: its head is read",
"type": "string"
},
"repository": {
"description": "owner/repository",
"type": "string"
}
},
"required": [
"repository"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "what checks answers, as its description says"
}
},
{
"name": "table",
"description": "The state table every delivery runs by: each transition with its guard, each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what table answers, as its description says"
}
},
{
"name": "stalled",
"description": "Every delivery held past its state's bound, with the transition the table lets healer H2 take for it, or none.",
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what stalled answers, as its description says"
}
},
{
"name": "recheck",
"description": "Check a rejected or ready delivery again: it goes back to proposed and the controller is asked for its check. With why.",
"input": {
"properties": {
"id": {
"description": "the delivery's id",
"type": "string"
},
"why": {
"description": "why, kept with the transition",
"type": "string"
}
},
"required": [
"id",
"why"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "what recheck answers, as its description says"
}
},
{
"name": "release",
"description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: the assigner's asks (hold, restore, backup, record), answered on the phone or in the delivery view",
"optional": true,
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what release answers, as its description says"
}
},
{
"name": "stop",
"description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: a bad fingerprint is held and its canary set back by the assigner (ADR 0429 §6, ADR 0539 clause 18)",
"optional": true,
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what stop answers, as its description says"
}
},
{
"name": "close",
"description": "Healer H2's verb: take the one transition the table names for a delivery held past its bound. Refused for any other.",
"input": {
"properties": {
"id": {
"description": "the delivery's id",
"type": "string"
},
"why": {
"description": "the condition it answers",
"type": "string"
}
},
"required": [
"id",
"why"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "what close answers, as its description says"
}
},
{
"name": "retire-history",
"description": "Removed in R6 (ADR 0539): refused by name, naming its replacement: nothing to retire: the gate keeps no delivery after its merge",
"optional": true,
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what retire-history answers, as its description says"
}
},
{
"name": "settle",
"description": "Ask the operator to settle deliveries held because their merge reached the trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another reason, naming why.",
"optional": true,
"input": {
"additionalProperties": false,
"properties": {
"ids": {
"description": "the held deliveries' ids (owner/repository@commit)",
"items": {
"type": "string"
},
"minItems": 1,
"type": "array"
},
"why": {
"description": "why they may be settled, shown to the operator",
"type": "string"
}
},
"required": [
"ids",
"why"
],
"type": "object"
},
"answer": {
"type": "object",
"description": "what settle answers, as its description says"
}
}
],
"description": "The delivery gate: reads and steers deliveries, merges and the checks that decide them; the merge is made on the operator's Approve once both checks passed.",
"states": [
{
"name": "source",
"template": "state",
"key-forms": [
{
"key": "main",
"value": {
"type": "object",
"properties": {
"v": {
"type": "integer",
"description": "the layout version"
},
"group": {
"type": "string",
"description": "the group that moved it"
},
"heads": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "repository to the merged head, a full commit"
}
},
"required": [
"group",
"heads"
],
"description": "every repository's merged head; its revision is the mesh's source revision"
},
"description": "the one key, written by the holder alone by compare-and-set"
}
],
"description": "the mesh's source revision",
"history": 64
},
{
"name": "merges",
"template": "state",
"key-forms": [
{
"key": "<group>",
"value": {
"type": "object",
"properties": {
"v": {
"type": "integer",
"description": "the layout version"
},
"group": {
"type": "string"
},
"ask": {
"type": "string",
"description": "the ask key"
},
"approved": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "<repository>@<number> to the approved head"
},
"pulls": {
"type": "array",
"items": {
"type": "object",
"properties": {
"repository": {
"type": "string"
},
"number": {
"type": "integer"
},
"head": {
"type": "string"
},
"state": {
"type": "string",
"description": "merging, merged or refused"
},
"commit": {
"type": "string"
},
"words": {
"type": "string"
}
},
"required": [
"repository",
"number",
"head",
"state"
]
}
}
},
"required": [
"group",
"ask",
"pulls"
],
"description": "one group's merge and how far each pull request is"
},
"description": "a group's merge"
}
],
"description": "the merges",
"history": 10
},
{
"name": "asks",
"template": "state",
"key-forms": [
{
"key": "merge.<group>.<heads>",
"value": {
"type": "object",
"properties": {
"v": {
"type": "integer",
"description": "the layout version"
},
"group": {
"type": "string"
},
"heads": {
"type": "array",
"items": {
"type": "object",
"properties": {
"repository": {
"type": "string",
"description": "<owner>/<name>"
},
"number": {
"type": "integer"
},
"head": {
"type": "string",
"description": "the full commit"
}
},
"required": [
"repository",
"number",
"head"
]
}
},
"ask": {
"description": "the operator-channel ask as raised"
},
"asking": {
"type": "integer",
"description": "the number of times it was asked"
}
},
"required": [
"group",
"heads",
"ask"
]
},
"description": "the ask of the operator for one group at one set of heads"
}
],
"description": "the asks of the operator for a merge",
"history": 5
},
{
"name": "owed",
"template": "state",
"key-forms": [
{
"key": "<repository>.<number>.<head>.<act>",
"value": {
"type": "object",
"properties": {
"v": {
"type": "integer",
"description": "the layout version"
},
"act": {
"type": "string",
"description": "status=<context>, view, note or comment.<seq>"
},
"context": {
"type": "string"
},
"target": {
"type": "string"
},
"state": {
"type": "string"
},
"text": {
"type": "string"
},
"ref": {
"type": "string"
}
},
"required": [
"act"
],
"description": "one act owed to the forge, written by its owner and done by the forge bridge"
},
"description": "one act owed to the forge"
}
],
"description": "the acts the gate owes the forge",
"history": 5
},
{
"name": "turns",
"template": "state",
"key-forms": [
{
"key": "<repository>",
"value": {
"type": "object",
"properties": {
"v": {
"type": "integer",
"description": "the layout version"
},
"repository": {
"type": "string"
},
"waiting": {
"type": "array",
"items": {
"type": "object",
"properties": {
"group": {
"type": "string"
},
"heads": {
"type": "string"
},
"ask": {
"type": "string"
},
"approved": {
"type": "string",
"format": "date-time",
"description": "when (RFC 3339 time)"
},
"since": {
"type": "string",
"format": "date-time",
"description": "when (RFC 3339 time)"
},
"waits-for": {
"type": "string"
},
"waits-since": {
"type": "string",
"format": "date-time",
"description": "when (RFC 3339 time)"
},
"step": {
"type": "string"
}
},
"required": [
"group",
"heads",
"approved",
"since"
]
}
}
},
"required": [
"repository",
"waiting"
],
"description": "the merges of a repository in the order they wait for their turn"
},
"description": "the merges waiting on a repository"
}
],
"description": "which merge waits for which",
"history": 5
}
]
}
@@ -1,47 +0,0 @@
[
{
"name": "a want",
"of": "state:wants/want.<node>.<module>",
"given": {
"v": 1,
"fp": "ab12",
"rule": 3
}
},
{
"name": "a node's own want",
"of": "state:wants/want.<node>.node",
"given": {
"v": 1,
"composed": "sha256:aa",
"self": true
}
},
{
"name": "a held fingerprint",
"of": "state:wants/held.<module>.<fingerprint>",
"given": {
"v": 1,
"node": "ace"
}
},
{
"name": "a difference",
"of": "state:differences/<node>.<module>",
"given": {
"v": 1,
"want": "ab12",
"unexplained": true
}
},
{
"name": "a composition",
"of": "state:compositions/<group>.<heads>",
"given": {
"v": 1,
"group": "g1",
"heads": "0123456789ab",
"state": "pass"
}
}
]
-284
View File
@@ -1,284 +0,0 @@
{
"format": 1,
"seat": "mesh-wants",
"scope": "mesh",
"grain": "whole",
"domain": "Delivery",
"decision": "novox/hq ADR 0565, ADR 0634, ADR 0635",
"description": "The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539).",
"verbs": [
{
"name": "upgrade",
"description": "Whether each module's new builds wait for a person: its policy (roll, together or record) and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
"input": {
"properties": {
"compare": {
"description": "\"true\": each node's own composition, by its digest, and the assigner's findings",
"enum": [
"true",
"false"
],
"type": "string"
},
"module": {
"description": "one module",
"type": "string"
},
"policy": {
"description": "with module: roll, together, record, or default to take a person's choice back",
"type": "string"
},
"why": {
"description": "with policy: why, required and kept",
"type": "string"
}
},
"type": "object"
},
"answer": {
"type": "object",
"description": "each module's policy, or the one chosen, or the comparison"
}
},
{
"name": "bus",
"description": "What a bus upgrade would do: the fingerprint of the bus module each node runs against the indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: the step itself is the put on the operator's warrant.",
"input": {
"properties": {},
"type": "object"
},
"answer": {
"type": "object",
"description": "what a bus upgrade would do, read only"
}
}
],
"events": [
{
"name": "policy-changed",
"body": {
"type": "object"
}
}
],
"family": {
"name": "mesh-wants.events",
"max-age-days": 7
},
"states": [
{
"name": "wants",
"template": "state",
"key-forms": [
{
"key": "want.<node>.<module>",
"value": {
"type": "object",
"description": "a node's want for a module",
"properties": {
"v": {
"type": "integer"
},
"fp": {
"type": "string"
},
"composed": {
"type": "string"
},
"removed": {
"type": "boolean"
},
"newest": {
"type": "string"
},
"rule": {
"type": "integer",
"description": "the rule that holds the node back, 1 to 10"
},
"backup": {
"type": "string",
"description": "again or skip",
"enum": [
"again",
"skip"
]
},
"words": {
"type": "string"
},
"put": {
"type": "boolean"
},
"hold": {
"type": "boolean"
},
"yes": {
"type": "string"
},
"release": {
"type": "string"
},
"self": {
"type": "boolean"
}
}
},
"description": "a node's want for a module"
},
{
"key": "want.<node>.node",
"value": {
"type": "object",
"description": "a node's own want",
"properties": {
"v": {
"type": "integer"
},
"composed": {
"type": "string"
},
"words": {
"type": "string"
},
"self": {
"type": "boolean"
}
},
"required": [
"composed"
]
},
"description": "a node's own want, naming the digest of its own composition"
},
{
"key": "held.<module>.<fingerprint>",
"value": {
"type": "object",
"description": "a fingerprint the assigner holds",
"properties": {
"v": {
"type": "integer"
},
"node": {
"type": "string"
},
"words": {
"type": "string"
}
},
"required": [
"node"
]
},
"description": "a module's fingerprint the assigner holds"
}
],
"history": 20,
"description": "what each node should run, and the fingerprints held back"
},
{
"name": "differences",
"template": "state",
"key-forms": [
{
"key": "<node>.<module>",
"value": {
"type": "object",
"description": "the difference between a want and what is sent",
"properties": {
"v": {
"type": "integer"
},
"want": {
"type": "string"
},
"want-removed": {
"type": "boolean"
},
"sent": {
"type": "string"
},
"sent-build": {
"type": "string"
},
"rule": {
"type": "integer"
},
"unexplained": {
"type": "boolean"
},
"words": {
"type": "string"
}
}
},
"description": "a module on a node"
}
],
"history": 64,
"description": "differences between a want and what the node-engine is sent"
},
{
"name": "compositions",
"template": "state",
"key-forms": [
{
"key": "<group>.<heads>",
"value": {
"type": "object",
"description": "the composition verdict of a group's check at its heads",
"properties": {
"v": {
"type": "integer"
},
"group": {
"type": "string"
},
"heads": {
"type": "string",
"description": "12 lowercase hex digits"
},
"state": {
"type": "string",
"description": "pass, fail or waits"
},
"words": {
"type": "string"
},
"sdk": {
"type": "string"
},
"machines": {
"type": "array",
"items": {
"type": "string"
}
},
"machine": {
"type": "string"
},
"warnings": {
"type": "array",
"items": {
"type": "string"
}
},
"took": {
"type": "integer",
"description": "milliseconds"
}
},
"required": [
"group",
"heads",
"state"
]
},
"description": "a group's check at its heads"
}
],
"history": 5,
"description": "composition verdicts of a group's check; the checker reads them"
}
]
}
@@ -1,11 +0,0 @@
[
{
"name": "drop names the ask and why",
"of": "verb:drop.input",
"given": {
"id": "a1b2",
"by": "mesh-delivery-builder",
"why": "a newer fingerprint supersedes it"
}
}
]
@@ -1,90 +0,0 @@
[
{
"name": "a running step",
"of": "state:steps/<node>.<id>",
"given": {
"v": 1,
"id": "a1b2",
"tool": "build",
"node": "ace",
"state": "running",
"started": "2026-11-05T10:00:00Z"
}
},
{
"name": "a paused node",
"of": "state:pauses/paused.<node>",
"given": {
"v": 1,
"paused": true,
"by": "operator@shanks",
"at": "2026-11-05T10:00:00Z"
}
},
{
"name": "a node's capacity",
"of": "state:pauses/capacity.<node>",
"given": {
"v": 1,
"effective": 2,
"configured": 2,
"why": "the configured steps fit the cores"
}
},
{
"name": "a node's toolchain",
"of": "state:toolchains/<node>.<language>",
"given": {
"v": 1,
"language": "go",
"version": "1.26.0",
"recipe": "1"
}
},
{
"name": "a build ask",
"of": "accept:delivery",
"subject": "mesh.seat.node-build-image.accept.delivery",
"given": {
"id": "a1b2",
"kind": "build",
"language": "go",
"asker": "mesh-delivery-builder",
"build": {
"module": "nats",
"artifacts": [
{
"artifact": "go"
}
]
}
}
},
{
"name": "a step ended on a transport fault, given again by the bus",
"of": "state:steps/<node>.<id>",
"given": {
"v": 1,
"id": "a1b2",
"tool": "build",
"node": "ace",
"state": "again",
"started": "2026-11-05T10:00:00Z",
"ended": "2026-11-05T10:00:30Z",
"reason": "fetching the commit: kex_exchange_identification: read: Connection reset by peer",
"run": 1,
"again-at": "2026-11-05T10:01:30Z",
"passing": true
}
},
{
"name": "an ask dropped by the caller that no longer waits",
"of": "state:pauses/dropped.<id>",
"given": {
"v": 1,
"by": "mesh-delivery-builder",
"why": "a newer fingerprint supersedes it",
"at": "2026-11-05T10:00:00Z"
}
}
]
@@ -1,11 +0,0 @@
[
{
"name": "drop names the ask and why",
"of": "verb:drop.input",
"given": {
"id": "a1b2",
"by": "mesh-delivery-builder",
"why": "a newer fingerprint supersedes it"
}
}
]
@@ -1,162 +0,0 @@
[
{
"name": "a running step",
"of": "state:steps/<node>.<id>",
"given": {
"v": 1,
"id": "a1b2",
"tool": "build",
"node": "ace",
"state": "running",
"started": "2026-11-05T10:00:00Z"
}
},
{
"name": "a paused node",
"of": "state:pauses/paused.<node>",
"given": {
"v": 1,
"paused": true,
"by": "operator@shanks",
"at": "2026-11-05T10:00:00Z"
}
},
{
"name": "a node's capacity",
"of": "state:pauses/capacity.<node>",
"given": {
"v": 1,
"effective": 2,
"configured": 2,
"why": "the configured steps fit the cores"
}
},
{
"name": "a node's toolchain",
"of": "state:toolchains/<node>.<language>",
"given": {
"v": 1,
"language": "go",
"version": "1.26.0",
"recipe": "1"
}
},
{
"name": "a build ask",
"of": "accept:delivery",
"subject": "mesh.seat.node-build-node.accept.delivery",
"given": {
"id": "a1b2",
"kind": "build",
"language": "go",
"asker": "mesh-delivery-builder",
"build": {
"module": "nats",
"artifacts": [
{
"artifact": "go"
}
]
}
}
},
{
"name": "a check ask with the group's tagged package",
"of": "accept:check",
"subject": "mesh.seat.node-build-node.accept.check",
"given": {
"v": 1,
"id": "c3d5",
"kind": "check",
"language": "typescript",
"asker": "mesh-delivery-checker",
"at": "2026-11-05T10:00:00Z",
"check": {
"fetch": {
"repository": "novox/mesh-delivery",
"commit": "0123456789abcdef0123456789abcdef01234567",
"ref": "refs/pull/7/head",
"base": "89abcdef0123456789abcdef0123456789abcdef",
"package": {
"name": "@novox/mesh-sdk",
"version": "0.1.15-feat-x.1"
}
},
"levels": [
"unit",
"integration"
],
"kept": true,
"toolchain": {
"language": "typescript",
"version": "26.12.0",
"recipe": "1"
},
"node-names": [
"ace",
"g14"
]
}
}
},
{
"name": "a finished check step of a TypeScript merge preview",
"of": "state:steps/<node>.<id>",
"given": {
"v": 1,
"id": "c3d6",
"tool": "check",
"node": "ace",
"state": "passed",
"started": "2026-11-05T10:00:00Z",
"ended": "2026-11-05T10:01:00Z",
"result": {
"fetch": {
"workspace": "mvii1sxt979f67",
"commit": "0123456789abcdef0123456789abcdef01234567",
"modules": [
"modules/m"
],
"base": "abcdef0123456789abcdef0123456789abcdef01",
"merge": "0123456789abcdef0123456789abcdef01234567",
"changed": [
"modules/m/package.json"
],
"trees": {
"modules/m": {
"tree": "0123456789abcdef0123456789abcdef01234567",
"manifest": "0581b699d9d05517e69b21f5cedbeb2eee8aba2b205280b561be731594d7feef"
}
}
}
}
}
},
{
"name": "a step ended on a transport fault, given again by the bus",
"of": "state:steps/<node>.<id>",
"given": {
"v": 1,
"id": "a1b2",
"tool": "build",
"node": "ace",
"state": "again",
"started": "2026-11-05T10:00:00Z",
"ended": "2026-11-05T10:00:30Z",
"reason": "fetching the commit: kex_exchange_identification: read: Connection reset by peer",
"run": 1,
"again-at": "2026-11-05T10:01:30Z",
"passing": true
}
},
{
"name": "an ask dropped by the caller that no longer waits",
"of": "state:pauses/dropped.<id>",
"given": {
"v": 1,
"by": "mesh-delivery-builder",
"why": "a newer fingerprint supersedes it",
"at": "2026-11-05T10:00:00Z"
}
}
]
@@ -1,11 +0,0 @@
[
{
"name": "drop names the ask and why",
"of": "verb:drop.input",
"given": {
"id": "a1b2",
"by": "mesh-delivery-builder",
"why": "a newer fingerprint supersedes it"
}
}
]
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
-545
View File
@@ -1,545 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// The contract of the seat "mesh-catalog", defined whole (seats/mesh-catalog/seat.json): The catalog of the mesh: every module the trunk holds with its manifest, fingerprint and verdict, the packages published, and the seat list (ADR 0636 §1, §4).
export const seat = "mesh-catalog";
export const verbs = {
modules: "modules",
module: "module",
provides: "provides",
dependents: "dependents",
seatDefinitions: "seat-definitions",
} as const;
export const events = {
seatDefinitionChanged: "seat-definition-changed",
seatDefinitionRemoved: "seat-definition-removed",
manifestAccepted: "manifest-accepted",
manifestRefused: "manifest-refused",
} as const;
export const family = { name: "mesh-catalog.events", maxAgeDays: 7 } as const;
export const seatDefinitionChangedSubject = "mesh.seat.mesh-catalog.event.seat-definition-changed";
export const seatDefinitionRemovedSubject = "mesh.seat.mesh-catalog.event.seat-definition-removed";
export const manifestAcceptedSubject = "mesh.seat.mesh-catalog.event.manifest-accepted";
export const manifestRefusedSubject = "mesh.seat.mesh-catalog.event.manifest-refused";
export const states = {
manifests: "manifests",
modules: "modules",
candidates: "candidates",
verdicts: "verdicts",
predefined: "predefined",
configCandidates: "config-candidates",
packages: "packages",
} as const;
/** The key of one value of the state manifests under the form "<digest>". */
export function manifestsByDigestKey(digest: string): string {
return digest;
}
/** The parts of a key of the form "<digest>", or undefined for a key of another form. */
export function parseManifestsByDigestKey(_key: string): { digest: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const digest = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { digest };
}
/** The key of one value of the state modules under the form "<module>". */
export function modulesByModuleKey(module: string): string {
return module;
}
/** The parts of a key of the form "<module>", or undefined for a key of another form. */
export function parseModulesByModuleKey(_key: string): { module: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const module = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { module };
}
/** The key of one value of the state candidates under the form "<group>". */
export function candidatesByGroupKey(group: string): string {
return group;
}
/** The parts of a key of the form "<group>", or undefined for a key of another form. */
export function parseCandidatesByGroupKey(_key: string): { group: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const group = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { group };
}
/** The key of one value of the state verdicts under the form "<digest>". */
export function verdictsByDigestKey(digest: string): string {
return digest;
}
/** The parts of a key of the form "<digest>", or undefined for a key of another form. */
export function parseVerdictsByDigestKey(_key: string): { digest: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const digest = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { digest };
}
/** The key of one value of the state predefined under the form "list". */
export function predefinedByListKey(): string {
return "list";
}
/** The parts of a key of the form "list", or undefined for a key of another form. */
export function parsePredefinedByListKey(_key: string): { } | undefined {
let _rest = _key;
if (!_rest.startsWith("list")) return undefined;
_rest = _rest.slice(4);
if (_rest !== "") return undefined;
return { };
}
/** The key of one value of the state config-candidates under the form "<pull>". */
export function configCandidatesByPullKey(pull: string): string {
return pull;
}
/** The parts of a key of the form "<pull>", or undefined for a key of another form. */
export function parseConfigCandidatesByPullKey(_key: string): { pull: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const pull = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { pull };
}
/** The key of one value of the state packages under the form "<module>". */
export function packagesByModuleKey(module: string): string {
return module;
}
/** The parts of a key of the form "<module>", or undefined for a key of another form. */
export function parsePackagesByModuleKey(_key: string): { module: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const module = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { module };
}
/** The key of one value of the state packages under the form "<package>.<series>". */
export function packagesByPackageSeriesKey(packagePart: string, series: string): string {
return packagePart + "." + series;
}
/** The parts of a key of the form "<package>.<series>", or undefined for a key of another form. */
export function parsePackagesByPackageSeriesKey(_key: string): { packagePart: string; series: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const packagePart = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const series = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { packagePart, series };
}
/** a toolchain a fingerprint was computed with (ADR 0405 clause 7) */
export interface ToolchainUsed {
"language": string;
/** the build module's recipe version */
"recipe": string;
/** the installed toolchain's version */
"version": string;
}
/** is what modules takes. */
export interface ModulesInput {
}
export interface ModulesAnswerModulesItem {
/** the merge commit it was last changed at */
"commit": string;
/** its fingerprint (src2: and 64 hex digits); absent when it has none */
"fp"?: string;
"module": string;
/** why it has none */
"none"?: string;
/** the module's directory in its repository, '.' for a root */
"path"?: string;
"repository": string;
}
/** is what modules answers. */
export interface ModulesAnswer {
"modules": ModulesAnswerModulesItem[];
}
/** is what module takes. */
export interface ModuleInput {
/** the module's name */
"module": string;
}
export interface ModuleAnswerModule {
/** the merge commit it was last changed at */
"commit": string;
/** its fingerprint (src2: and 64 hex digits); absent when it has none */
"fp"?: string;
/** the module's manifest as the trunk holds it */
"manifest"?: Record<string, unknown>;
"module": string;
/** why it has none */
"none"?: string;
/** the module's directory in its repository, '.' for a root */
"path"?: string;
"repository": string;
}
/** is what module answers. */
export interface ModuleAnswer {
"module": ModuleAnswerModule;
}
/** is what provides takes. */
export interface ProvidesInput {
/** the provision, such as postgres-database */
"provision": string;
}
/** is what provides answers. */
export type ProvidesAnswer = Record<string, unknown>;
/** is what dependents takes. */
export interface DependentsInput {
/** the module's name */
"module": string;
}
/** is what dependents answers. */
export type DependentsAnswer = Record<string, unknown>;
/** is what seat-definitions takes. */
export interface SeatDefinitionsInput {
/** the seat's name; none lists every trunk seat */
"seat"?: string;
}
export interface SeatDefinitionsAnswerEntry {
/** the definition, resolved with the parts it includes */
"definition"?: Record<string, unknown>;
"digest": string;
"seat": string;
/** trunk */
"source": string;
/** why the definition could not be read, when it could not */
"unparsed"?: string;
}
export interface SeatDefinitionsAnswerSeatsItem {
"digest": string;
"seat": string;
/** trunk */
"source": string;
}
/** is what seat-definitions answers. */
export interface SeatDefinitionsAnswer {
"entry"?: SeatDefinitionsAnswerEntry;
"found"?: boolean;
"revision"?: number;
"seat"?: string;
"seats"?: SeatDefinitionsAnswerSeatsItem[];
}
/** is the body of the event seat-definition-changed. */
export interface SeatDefinitionChangedEvent {
"digest": string;
"seat": string;
/** trunk */
"source": string;
}
/** is the body of the event seat-definition-removed. */
export interface SeatDefinitionRemovedEvent {
"seat": string;
}
export interface ManifestAcceptedEventBusStateItem {
"configuration"?: Record<string, unknown>;
"name": string;
"options"?: Record<string, unknown>;
"storage": string;
"template"?: string;
"types"?: Record<string, unknown>[];
}
/** is the body of the event manifest-accepted. */
export interface ManifestAcceptedEvent {
"bus-state": ManifestAcceptedEventBusStateItem[];
"digest": string;
"judge": string;
"module": string;
/** the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it */
"revision"?: number;
"v": number;
}
/** is the body of the event manifest-refused. */
export interface ManifestRefusedEvent {
"digest": string;
"judge": string;
"module": string;
"problems": string[];
/** the indexer's revision of the verdict it says; a reader asks its first view again on a gap in it; zero from an indexer that does not say it */
"revision"?: number;
"v": number;
}
/** is one value of the state manifests under the key <digest>. */
export interface ManifestsByDigestValue {
/** the manifest, UTF-8 JSON text */
"manifest": string;
"module": string;
/** the layout version */
"v"?: number;
}
export interface ModulesByModuleValueInputs2 {
"bases"?: string[];
"builder"?: string;
"contexts"?: Record<string, string>;
"import-form"?: string;
"imports"?: string[];
"locks"?: Record<string, string>;
"packages"?: string[];
"paths"?: Record<string, string>;
"tree": string;
}
/** is one value of the state modules under the key <module>. */
export interface ModulesByModuleValue {
"commit": string;
/** the old-form fingerprint */
"fp"?: string;
"fp-src"?: number;
/** src2: fingerprint */
"fp2"?: string;
"inputs2"?: ModulesByModuleValueInputs2;
/** manifest digest */
"manifest"?: string;
"needs"?: string[];
"none"?: string;
/** why it has none */
"none2"?: string;
"packages"?: Record<string, string>;
"path"?: string;
"paths"?: Record<string, string[]>;
"problems"?: string[];
"repository": string;
/** the source revision */
"src"?: number;
/** by language: the toolchain FP2 was computed with */
"toolchains"?: Record<string, ToolchainUsed>;
"unknown"?: string[];
/** the layout version */
"v"?: number;
"verdict"?: string;
"warnings"?: string[];
}
export interface CandidatesByGroupValuePullsItemFpsValue {
"fp2"?: string;
"manifest"?: string;
"none2"?: string;
"packages"?: Record<string, string>;
"paths"?: Record<string, string>;
}
export interface CandidatesByGroupValuePullsItem {
"fps"?: Record<string, CandidatesByGroupValuePullsItemFpsValue>;
"head": string;
"modules": string[];
"number": number;
"repository": string;
}
/** is one value of the state candidates under the key <group>. */
export interface CandidatesByGroupValue {
/** the source revision it is read on */
"base": number;
"fpset"?: string;
"group": string;
"heads": string;
"pulls": CandidatesByGroupValuePullsItem[];
/** the layout version */
"v"?: number;
}
/** is one value of the state verdicts under the key <digest>. */
export interface VerdictsByDigestValue {
/** when (RFC 3339 time) */
"at"?: string;
"judge"?: string;
"module": string;
"problems"?: string[];
"state": string;
"unknown"?: string[];
/** the layout version */
"v"?: number;
"warnings"?: string[];
}
/** is one value of the state predefined under the key list. */
export interface PredefinedByListValue {
/** when (RFC 3339 time) */
"at"?: string;
"commit": string;
/** the foundation's predefined list as kept */
"list": unknown;
/** the layout version */
"v"?: number;
}
export interface ConfigCandidatesByPullValueDeclaresValueValue {
/** the default */
"default": unknown;
"description"?: string;
/** the allowed values */
"enum"?: unknown;
"kind": string;
/** a declared bound; 0 is a bound, absent is none */
"maximum"?: number | null;
/** a declared bound; 0 is a bound, absent is none */
"minimum"?: number | null;
"pattern"?: string;
"predefined"?: string;
"required"?: boolean;
"runtime"?: boolean;
"trusted"?: boolean;
"type"?: string;
"why": string;
}
/** is one value of the state config-candidates under the key <pull>. */
export interface ConfigCandidatesByPullValue {
/** when (RFC 3339 time) */
"at"?: string;
/** module to key to declaration */
"declares": Record<string, Record<string, ConfigCandidatesByPullValueDeclaresValueValue>>;
"head": string;
"pull": string;
/** the layout version */
"v"?: number;
}
export interface PackagesByModuleValuePackagesItem {
"digest": string;
"package": string;
}
/** is one value of the state packages under the key <module>. */
export interface PackagesByModuleValue {
/** when (RFC 3339 time) */
"at"?: string;
"commit": string;
"dir"?: string;
/** the Go module path */
"module": string;
"packages": PackagesByModuleValuePackagesItem[];
"repository": string;
/** digest of go.mod and go.sum */
"requires"?: string;
/** the layout version */
"v"?: number;
}
export interface PackagesByPackageSeriesValueNewestContentsPackagesItem {
"digest": string;
"package": string;
}
export interface PackagesByPackageSeriesValueNewestContents {
/** when (RFC 3339 time) */
"at"?: string;
"commit": string;
"dir"?: string;
/** the Go module path */
"module": string;
"packages": PackagesByPackageSeriesValueNewestContentsPackagesItem[];
"repository": string;
/** digest of go.mod and go.sum */
"requires"?: string;
/** the layout version */
"v"?: number;
}
export interface PackagesByPackageSeriesValueNewest {
"commit"?: string;
"contents"?: PackagesByPackageSeriesValueNewestContents;
"digest"?: string;
"fingerprint"?: string;
"version": string;
}
export interface PackagesByPackageSeriesValueNumberedContentsPackagesItem {
"digest": string;
"package": string;
}
export interface PackagesByPackageSeriesValueNumberedContents {
/** when (RFC 3339 time) */
"at"?: string;
"commit": string;
"dir"?: string;
/** the Go module path */
"module": string;
"packages": PackagesByPackageSeriesValueNumberedContentsPackagesItem[];
"repository": string;
/** digest of go.mod and go.sum */
"requires"?: string;
/** the layout version */
"v"?: number;
}
export interface PackagesByPackageSeriesValueNumbered {
"commit"?: string;
"contents"?: PackagesByPackageSeriesValueNumberedContents;
"digest"?: string;
"fingerprint"?: string;
"version": string;
}
/** is one value of the state packages under the key <package>.<series>. */
export interface PackagesByPackageSeriesValue {
/** when (RFC 3339 time) */
"at"?: string;
"go-module"?: string;
"name": string;
"newest"?: PackagesByPackageSeriesValueNewest;
"numbered"?: PackagesByPackageSeriesValueNumbered;
"package"?: string;
"seat"?: string;
"series": string;
/** the layout version */
"v"?: number;
}
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -1,338 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// The contract of the seat "mesh-delivery", defined whole (seats/mesh-delivery/seat.json): The delivery gate: reads and steers deliveries, merges and the checks that decide them; the merge is made on the operator's Approve once both checks passed.
export const seat = "mesh-delivery";
export const verbs = {
deliveries: "deliveries",
times: "times",
show: "show",
groups: "groups",
whatIf: "what-if",
checks: "checks",
table: "table",
stalled: "stalled",
recheck: "recheck",
release: "release",
stop: "stop",
close: "close",
retireHistory: "retire-history",
settle: "settle",
} as const;
export const states = {
source: "source",
merges: "merges",
asks: "asks",
owed: "owed",
turns: "turns",
} as const;
/** The key of one value of the state source under the form "main". */
export function sourceByMainKey(): string {
return "main";
}
/** The parts of a key of the form "main", or undefined for a key of another form. */
export function parseSourceByMainKey(_key: string): { } | undefined {
let _rest = _key;
if (!_rest.startsWith("main")) return undefined;
_rest = _rest.slice(4);
if (_rest !== "") return undefined;
return { };
}
/** The key of one value of the state merges under the form "<group>". */
export function mergesByGroupKey(group: string): string {
return group;
}
/** The parts of a key of the form "<group>", or undefined for a key of another form. */
export function parseMergesByGroupKey(_key: string): { group: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const group = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { group };
}
/** The key of one value of the state asks under the form "merge.<group>.<heads>". */
export function asksByMergeGroupHeadsKey(group: string, heads: string): string {
return "merge." + group + "." + heads;
}
/** The parts of a key of the form "merge.<group>.<heads>", or undefined for a key of another form. */
export function parseAsksByMergeGroupHeadsKey(_key: string): { group: string; heads: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("merge.")) return undefined;
_rest = _rest.slice(6);
const _at1 = _rest.indexOf(".");
if (_at1 <= 0) return undefined;
const group = _rest.slice(0, _at1);
_rest = _rest.slice(_at1 + 1);
if (_rest === "") return undefined;
const heads = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { group, heads };
}
/** The key of one value of the state owed under the form "<repository>.<number>.<head>.<act>". */
export function owedByRepositoryNumberHeadActKey(repository: string, number: string, head: string, act: string): string {
return repository + "." + number + "." + head + "." + act;
}
/** The parts of a key of the form "<repository>.<number>.<head>.<act>", or undefined for a key of another form. */
export function parseOwedByRepositoryNumberHeadActKey(_key: string): { repository: string; number: string; head: string; act: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const repository = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
const _at2 = _rest.indexOf(".");
if (_at2 <= 0) return undefined;
const number = _rest.slice(0, _at2);
_rest = _rest.slice(_at2 + 1);
const _at4 = _rest.indexOf(".");
if (_at4 <= 0) return undefined;
const head = _rest.slice(0, _at4);
_rest = _rest.slice(_at4 + 1);
if (_rest === "") return undefined;
const act = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { repository, number, head, act };
}
/** The key of one value of the state turns under the form "<repository>". */
export function turnsByRepositoryKey(repository: string): string {
return repository;
}
/** The parts of a key of the form "<repository>", or undefined for a key of another form. */
export function parseTurnsByRepositoryKey(_key: string): { repository: string } | undefined {
let _rest = _key;
if (_rest === "") return undefined;
const repository = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { repository };
}
/** is what deliveries takes. */
export interface DeliveriesInput {
/** "true": the final ones of the last thirty days too */
"all"?: "true" | "false";
/** a group's id (its branch name) */
"group"?: string;
/** owner/repository */
"repository"?: string;
/** one state, e.g. delivering or held */
"state"?: string;
}
/** is what deliveries answers. */
export type DeliveriesAnswer = Record<string, unknown>;
/** is what times takes. */
export interface TimesInput {
}
/** is what times answers. */
export type TimesAnswer = Record<string, unknown>;
/** is what show takes. */
export interface ShowInput {
/** a delivery's id, or a group's */
"id": string;
}
/** is what show answers. */
export type ShowAnswer = Record<string, unknown>;
/** is what groups takes. */
export interface GroupsInput {
/** "true": the groups that ended too */
"all"?: "true" | "false";
}
/** is what groups answers. */
export type GroupsAnswer = Record<string, unknown>;
/** is what what-if takes. */
export interface WhatIfInput {
}
/** is what what-if answers. */
export type WhatIfAnswer = Record<string, unknown>;
/** is what checks takes. */
export interface ChecksInput {
/** a commit's sha, or the start of one, instead of a pull request */
"commit"?: string;
/** a pull request's number: its head is read */
"number"?: string;
/** owner/repository */
"repository": string;
}
/** is what checks answers. */
export type ChecksAnswer = Record<string, unknown>;
/** is what table takes. */
export interface TableInput {
}
/** is what table answers. */
export type TableAnswer = Record<string, unknown>;
/** is what stalled takes. */
export interface StalledInput {
}
/** is what stalled answers. */
export type StalledAnswer = Record<string, unknown>;
/** is what recheck takes. */
export interface RecheckInput {
/** the delivery's id */
"id": string;
/** why, kept with the transition */
"why": string;
}
/** is what recheck answers. */
export type RecheckAnswer = Record<string, unknown>;
/** is what release takes. */
export interface ReleaseInput {
}
/** is what release answers. */
export type ReleaseAnswer = Record<string, unknown>;
/** is what stop takes. */
export interface StopInput {
}
/** is what stop answers. */
export type StopAnswer = Record<string, unknown>;
/** is what close takes. */
export interface CloseInput {
/** the delivery's id */
"id": string;
/** the condition it answers */
"why": string;
}
/** is what close answers. */
export type CloseAnswer = Record<string, unknown>;
/** is what retire-history takes. */
export interface RetireHistoryInput {
}
/** is what retire-history answers. */
export type RetireHistoryAnswer = Record<string, unknown>;
/** is what settle takes. */
export interface SettleInput {
/** the held deliveries' ids (owner/repository@commit) */
"ids": string[];
/** why they may be settled, shown to the operator */
"why": string;
}
/** is what settle answers. */
export type SettleAnswer = Record<string, unknown>;
/** is one value of the state source under the key main. */
export interface SourceByMainValue {
/** the group that moved it */
"group": string;
/** repository to the merged head, a full commit */
"heads": Record<string, string>;
/** the layout version */
"v"?: number;
}
export interface MergesByGroupValuePullsItem {
"commit"?: string;
"head": string;
"number": number;
"repository": string;
/** merging, merged or refused */
"state": string;
"words"?: string;
}
/** is one value of the state merges under the key <group>. */
export interface MergesByGroupValue {
/** <repository>@<number> to the approved head */
"approved"?: Record<string, string>;
/** the ask key */
"ask": string;
"group": string;
"pulls": MergesByGroupValuePullsItem[];
/** the layout version */
"v"?: number;
}
export interface AsksByMergeGroupHeadsValueHeadsItem {
/** the full commit */
"head": string;
"number": number;
/** <owner>/<name> */
"repository": string;
}
/** is one value of the state asks under the key merge.<group>.<heads>. */
export interface AsksByMergeGroupHeadsValue {
/** the operator-channel ask as raised */
"ask": unknown;
/** the number of times it was asked */
"asking"?: number;
"group": string;
"heads": AsksByMergeGroupHeadsValueHeadsItem[];
/** the layout version */
"v"?: number;
}
/** is one value of the state owed under the key <repository>.<number>.<head>.<act>. */
export interface OwedByRepositoryNumberHeadActValue {
/** status=<context>, view, note or comment.<seq> */
"act": string;
"context"?: string;
"ref"?: string;
"state"?: string;
"target"?: string;
"text"?: string;
/** the layout version */
"v"?: number;
}
export interface TurnsByRepositoryValueWaitingItem {
/** when (RFC 3339 time) */
"approved": string;
"ask"?: string;
"group": string;
"heads": string;
/** when (RFC 3339 time) */
"since": string;
"step"?: string;
"waits-for"?: string;
/** when (RFC 3339 time) */
"waits-since"?: string;
}
/** is one value of the state turns under the key <repository>. */
export interface TurnsByRepositoryValue {
"repository": string;
/** the layout version */
"v"?: number;
"waiting": TurnsByRepositoryValueWaitingItem[];
}
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
-211
View File
@@ -1,211 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// The contract of the seat "mesh-wants", defined whole (seats/mesh-wants/seat.json): The seat that says what each node should run and whether each module's new builds wait for a person (ADR 0565, ADR 0539).
export const seat = "mesh-wants";
export const verbs = {
upgrade: "upgrade",
bus: "bus",
} as const;
export const events = {
policyChanged: "policy-changed",
} as const;
export const family = { name: "mesh-wants.events", maxAgeDays: 7 } as const;
export const policyChangedSubject = "mesh.seat.mesh-wants.event.policy-changed";
export const states = {
wants: "wants",
differences: "differences",
compositions: "compositions",
} as const;
/** The key of one value of the state wants under the form "want.<node>.<module>". */
export function wantsByWantNodeModuleKey(node: string, module: string): string {
return "want." + node + "." + module;
}
/** The parts of a key of the form "want.<node>.<module>", or undefined for a key of another form. */
export function parseWantsByWantNodeModuleKey(_key: string): { node: string; module: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("want.")) return undefined;
_rest = _rest.slice(5);
const _at1 = _rest.indexOf(".");
if (_at1 <= 0) return undefined;
const node = _rest.slice(0, _at1);
_rest = _rest.slice(_at1 + 1);
if (_rest === "") return undefined;
const module = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node, module };
}
/** The key of one value of the state wants under the form "want.<node>.node". */
export function wantsByWantNodeNodeKey(node: string): string {
return "want." + node + ".node";
}
/** The parts of a key of the form "want.<node>.node", or undefined for a key of another form. */
export function parseWantsByWantNodeNodeKey(_key: string): { node: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("want.")) return undefined;
_rest = _rest.slice(5);
const _at1 = _rest.indexOf(".node");
if (_at1 <= 0) return undefined;
const node = _rest.slice(0, _at1);
_rest = _rest.slice(_at1 + 5);
if (_rest !== "") return undefined;
return { node };
}
/** The key of one value of the state wants under the form "held.<module>.<fingerprint>". */
export function wantsByHeldModuleFingerprintKey(module: string, fingerprint: string): string {
return "held." + module + "." + fingerprint;
}
/** The parts of a key of the form "held.<module>.<fingerprint>", or undefined for a key of another form. */
export function parseWantsByHeldModuleFingerprintKey(_key: string): { module: string; fingerprint: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("held.")) return undefined;
_rest = _rest.slice(5);
const _at1 = _rest.indexOf(".");
if (_at1 <= 0) return undefined;
const module = _rest.slice(0, _at1);
_rest = _rest.slice(_at1 + 1);
if (_rest === "") return undefined;
const fingerprint = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { module, fingerprint };
}
/** The key of one value of the state differences under the form "<node>.<module>". */
export function differencesByNodeModuleKey(node: string, module: string): string {
return node + "." + module;
}
/** The parts of a key of the form "<node>.<module>", or undefined for a key of another form. */
export function parseDifferencesByNodeModuleKey(_key: string): { node: string; module: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const node = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const module = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node, module };
}
/** The key of one value of the state compositions under the form "<group>.<heads>". */
export function compositionsByGroupHeadsKey(group: string, heads: string): string {
return group + "." + heads;
}
/** The parts of a key of the form "<group>.<heads>", or undefined for a key of another form. */
export function parseCompositionsByGroupHeadsKey(_key: string): { group: string; heads: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const group = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const heads = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { group, heads };
}
/** is what upgrade takes. */
export interface UpgradeInput {
/** "true": each node's own composition, by its digest, and the assigner's findings */
"compare"?: "true" | "false";
/** one module */
"module"?: string;
/** with module: roll, together, record, or default to take a person's choice back */
"policy"?: string;
/** with policy: why, required and kept */
"why"?: string;
}
/** is what upgrade answers. */
export type UpgradeAnswer = Record<string, unknown>;
/** is what bus takes. */
export interface BusInput {
}
/** is what bus answers. */
export type BusAnswer = Record<string, unknown>;
/** is the body of the event policy-changed. */
export type PolicyChangedEvent = Record<string, unknown>;
/** is one value of the state wants under the key want.<node>.<module>. */
export interface WantsByWantNodeModuleValue {
/** again or skip */
"backup"?: "again" | "skip";
"composed"?: string;
"fp"?: string;
"hold"?: boolean;
"newest"?: string;
"put"?: boolean;
"release"?: string;
"removed"?: boolean;
/** the rule that holds the node back, 1 to 10 */
"rule"?: number;
"self"?: boolean;
"v"?: number;
"words"?: string;
"yes"?: string;
}
/** is one value of the state wants under the key want.<node>.node. */
export interface WantsByWantNodeNodeValue {
"composed": string;
"self"?: boolean;
"v"?: number;
"words"?: string;
}
/** is one value of the state wants under the key held.<module>.<fingerprint>. */
export interface WantsByHeldModuleFingerprintValue {
"node": string;
"v"?: number;
"words"?: string;
}
/** is one value of the state differences under the key <node>.<module>. */
export interface DifferencesByNodeModuleValue {
"rule"?: number;
"sent"?: string;
"sent-build"?: string;
"unexplained"?: boolean;
"v"?: number;
"want"?: string;
"want-removed"?: boolean;
"words"?: string;
}
/** is one value of the state compositions under the key <group>.<heads>. */
export interface CompositionsByGroupHeadsValue {
"group": string;
/** 12 lowercase hex digits */
"heads": string;
"machine"?: string;
"machines"?: string[];
"sdk"?: string;
/** pass, fail or waits */
"state": string;
/** milliseconds */
"took"?: number;
"v"?: number;
"warnings"?: string[];
"words"?: string;
}
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
-191
View File
@@ -1,191 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// The contract of the seat "node-build-go", defined whole (seats/node-build-go/seat.json): The Go build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers.
import type * as buildStep from "./parts/build-step.js";
export const seat = "node-build-go";
export const verbs = {
current: "current",
pause: "pause",
resume: "resume",
drop: "drop",
} as const;
export const accepts = {
delivery: "delivery",
check: "check",
recheck: "recheck",
} as const;
export const deliverySubject = "mesh.seat.node-build-go.accept.delivery";
export const checkSubject = "mesh.seat.node-build-go.accept.check";
export const recheckSubject = "mesh.seat.node-build-go.accept.recheck";
export const states = {
steps: "steps",
pauses: "pauses",
toolchains: "toolchains",
} as const;
/** The key of one value of the state steps under the form "<node>.<id>". */
export function stepsByNodeIDKey(node: string, id: string): string {
return node + "." + id;
}
/** The parts of a key of the form "<node>.<id>", or undefined for a key of another form. */
export function parseStepsByNodeIDKey(_key: string): { node: string; id: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const node = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node, id };
}
/** The key of one value of the state pauses under the form "paused.<node>". */
export function pausesByPausedNodeKey(node: string): string {
return "paused." + node;
}
/** The parts of a key of the form "paused.<node>", or undefined for a key of another form. */
export function parsePausesByPausedNodeKey(_key: string): { node: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("paused.")) return undefined;
_rest = _rest.slice(7);
if (_rest === "") return undefined;
const node = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node };
}
/** The key of one value of the state pauses under the form "dropped.<id>". */
export function pausesByDroppedIDKey(id: string): string {
return "dropped." + id;
}
/** The parts of a key of the form "dropped.<id>", or undefined for a key of another form. */
export function parsePausesByDroppedIDKey(_key: string): { id: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("dropped.")) return undefined;
_rest = _rest.slice(8);
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { id };
}
/** The key of one value of the state pauses under the form "capacity.<node>". */
export function pausesByCapacityNodeKey(node: string): string {
return "capacity." + node;
}
/** The parts of a key of the form "capacity.<node>", or undefined for a key of another form. */
export function parsePausesByCapacityNodeKey(_key: string): { node: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("capacity.")) return undefined;
_rest = _rest.slice(9);
if (_rest === "") return undefined;
const node = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node };
}
/** The key of one value of the state toolchains under the form "<node>.<language>". */
export function toolchainsByNodeLanguageKey(node: string, language: string): string {
return node + "." + language;
}
/** The parts of a key of the form "<node>.<language>", or undefined for a key of another form. */
export function parseToolchainsByNodeLanguageKey(_key: string): { node: string; language: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const node = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const language = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node, language };
}
export type AskArtifact = buildStep.AskArtifact;
export type BuildAnswer = buildStep.BuildAnswer;
export type BuiltArtifact = buildStep.BuiltArtifact;
export type Capacity = buildStep.Capacity;
export type CheckAnswer = buildStep.CheckAnswer;
export type ComposeAnswer = buildStep.ComposeAnswer;
export type CurrentAnswer = buildStep.CurrentAnswer;
export type CurrentInput = buildStep.CurrentInput;
export type Drop = buildStep.Drop;
export type DropAnswer = buildStep.DropAnswer;
export type DropInput = buildStep.DropInput;
export type Fetch = buildStep.Fetch;
export type FetchAnswer = buildStep.FetchAnswer;
export type FetchBesideItem = buildStep.FetchBesideItem;
export type FetchPackage = buildStep.FetchPackage;
export type FetchRefusal = buildStep.FetchRefusal;
export type LevelAnswer = buildStep.LevelAnswer;
export type LevelAnswerDigest = buildStep.LevelAnswerDigest;
export type LevelAnswerDigestNeedsItem = buildStep.LevelAnswerDigestNeedsItem;
export type LevelAnswerDigestPackagesItem = buildStep.LevelAnswerDigestPackagesItem;
export type LevelAnswerKeptItem = buildStep.LevelAnswerKeptItem;
export type LevelAnswerUnmetItem = buildStep.LevelAnswerUnmetItem;
export type ModuleTree = buildStep.ModuleTree;
export type Pause = buildStep.Pause;
export type PauseAnswer = buildStep.PauseAnswer;
export type PauseInput = buildStep.PauseInput;
export type PlacedBeside = buildStep.PlacedBeside;
export type Program = buildStep.Program;
export type ResumeAnswer = buildStep.ResumeAnswer;
export type ResumeInput = buildStep.ResumeInput;
export type RunAnswer = buildStep.RunAnswer;
export type RunHead = buildStep.RunHead;
export type RunInput = buildStep.RunInput;
export type RunJob = buildStep.RunJob;
export type RunProgram = buildStep.RunProgram;
export type Step = buildStep.Step;
export type StepAsk = buildStep.StepAsk;
export type StepAskBuild = buildStep.StepAskBuild;
export type StepAskCheck = buildStep.StepAskCheck;
export type StepAskCompose = buildStep.StepAskCompose;
export type StepResult = buildStep.StepResult;
export type TakenVersion = buildStep.TakenVersion;
export type Toolchain = buildStep.Toolchain;
export type ToolchainUsed = buildStep.ToolchainUsed;
/** is the body of delivery. */
export type DeliveryAccept = StepAsk;
/** is the body of check. */
export type CheckAccept = StepAsk;
/** is the body of recheck. */
export type RecheckAccept = StepAsk;
/** is one value of the state steps under the key <node>.<id>. */
export type StepsByNodeIDValue = Step;
/** is one value of the state pauses under the key paused.<node>. */
export type PausesByPausedNodeValue = Pause;
/** is one value of the state pauses under the key dropped.<id>. */
export type PausesByDroppedIDValue = Drop;
/** is one value of the state pauses under the key capacity.<node>. */
export type PausesByCapacityNodeValue = Capacity;
/** is one value of the state toolchains under the key <node>.<language>. */
export type ToolchainsByNodeLanguageValue = Toolchain;
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -1,191 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// The contract of the seat "node-build-image", defined whole (seats/node-build-image/seat.json): The image build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers.
import type * as buildStep from "./parts/build-step.js";
export const seat = "node-build-image";
export const verbs = {
current: "current",
pause: "pause",
resume: "resume",
drop: "drop",
} as const;
export const accepts = {
delivery: "delivery",
check: "check",
recheck: "recheck",
} as const;
export const deliverySubject = "mesh.seat.node-build-image.accept.delivery";
export const checkSubject = "mesh.seat.node-build-image.accept.check";
export const recheckSubject = "mesh.seat.node-build-image.accept.recheck";
export const states = {
steps: "steps",
pauses: "pauses",
toolchains: "toolchains",
} as const;
/** The key of one value of the state steps under the form "<node>.<id>". */
export function stepsByNodeIDKey(node: string, id: string): string {
return node + "." + id;
}
/** The parts of a key of the form "<node>.<id>", or undefined for a key of another form. */
export function parseStepsByNodeIDKey(_key: string): { node: string; id: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const node = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node, id };
}
/** The key of one value of the state pauses under the form "paused.<node>". */
export function pausesByPausedNodeKey(node: string): string {
return "paused." + node;
}
/** The parts of a key of the form "paused.<node>", or undefined for a key of another form. */
export function parsePausesByPausedNodeKey(_key: string): { node: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("paused.")) return undefined;
_rest = _rest.slice(7);
if (_rest === "") return undefined;
const node = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node };
}
/** The key of one value of the state pauses under the form "dropped.<id>". */
export function pausesByDroppedIDKey(id: string): string {
return "dropped." + id;
}
/** The parts of a key of the form "dropped.<id>", or undefined for a key of another form. */
export function parsePausesByDroppedIDKey(_key: string): { id: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("dropped.")) return undefined;
_rest = _rest.slice(8);
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { id };
}
/** The key of one value of the state pauses under the form "capacity.<node>". */
export function pausesByCapacityNodeKey(node: string): string {
return "capacity." + node;
}
/** The parts of a key of the form "capacity.<node>", or undefined for a key of another form. */
export function parsePausesByCapacityNodeKey(_key: string): { node: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("capacity.")) return undefined;
_rest = _rest.slice(9);
if (_rest === "") return undefined;
const node = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node };
}
/** The key of one value of the state toolchains under the form "<node>.<language>". */
export function toolchainsByNodeLanguageKey(node: string, language: string): string {
return node + "." + language;
}
/** The parts of a key of the form "<node>.<language>", or undefined for a key of another form. */
export function parseToolchainsByNodeLanguageKey(_key: string): { node: string; language: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const node = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const language = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node, language };
}
export type AskArtifact = buildStep.AskArtifact;
export type BuildAnswer = buildStep.BuildAnswer;
export type BuiltArtifact = buildStep.BuiltArtifact;
export type Capacity = buildStep.Capacity;
export type CheckAnswer = buildStep.CheckAnswer;
export type ComposeAnswer = buildStep.ComposeAnswer;
export type CurrentAnswer = buildStep.CurrentAnswer;
export type CurrentInput = buildStep.CurrentInput;
export type Drop = buildStep.Drop;
export type DropAnswer = buildStep.DropAnswer;
export type DropInput = buildStep.DropInput;
export type Fetch = buildStep.Fetch;
export type FetchAnswer = buildStep.FetchAnswer;
export type FetchBesideItem = buildStep.FetchBesideItem;
export type FetchPackage = buildStep.FetchPackage;
export type FetchRefusal = buildStep.FetchRefusal;
export type LevelAnswer = buildStep.LevelAnswer;
export type LevelAnswerDigest = buildStep.LevelAnswerDigest;
export type LevelAnswerDigestNeedsItem = buildStep.LevelAnswerDigestNeedsItem;
export type LevelAnswerDigestPackagesItem = buildStep.LevelAnswerDigestPackagesItem;
export type LevelAnswerKeptItem = buildStep.LevelAnswerKeptItem;
export type LevelAnswerUnmetItem = buildStep.LevelAnswerUnmetItem;
export type ModuleTree = buildStep.ModuleTree;
export type Pause = buildStep.Pause;
export type PauseAnswer = buildStep.PauseAnswer;
export type PauseInput = buildStep.PauseInput;
export type PlacedBeside = buildStep.PlacedBeside;
export type Program = buildStep.Program;
export type ResumeAnswer = buildStep.ResumeAnswer;
export type ResumeInput = buildStep.ResumeInput;
export type RunAnswer = buildStep.RunAnswer;
export type RunHead = buildStep.RunHead;
export type RunInput = buildStep.RunInput;
export type RunJob = buildStep.RunJob;
export type RunProgram = buildStep.RunProgram;
export type Step = buildStep.Step;
export type StepAsk = buildStep.StepAsk;
export type StepAskBuild = buildStep.StepAskBuild;
export type StepAskCheck = buildStep.StepAskCheck;
export type StepAskCompose = buildStep.StepAskCompose;
export type StepResult = buildStep.StepResult;
export type TakenVersion = buildStep.TakenVersion;
export type Toolchain = buildStep.Toolchain;
export type ToolchainUsed = buildStep.ToolchainUsed;
/** is the body of delivery. */
export type DeliveryAccept = StepAsk;
/** is the body of check. */
export type CheckAccept = StepAsk;
/** is the body of recheck. */
export type RecheckAccept = StepAsk;
/** is one value of the state steps under the key <node>.<id>. */
export type StepsByNodeIDValue = Step;
/** is one value of the state pauses under the key paused.<node>. */
export type PausesByPausedNodeValue = Pause;
/** is one value of the state pauses under the key dropped.<id>. */
export type PausesByDroppedIDValue = Drop;
/** is one value of the state pauses under the key capacity.<node>. */
export type PausesByCapacityNodeValue = Capacity;
/** is one value of the state toolchains under the key <node>.<language>. */
export type ToolchainsByNodeLanguageValue = Toolchain;
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -1,191 +0,0 @@
// Code generated by seatgen from the seat definitions; DO NOT EDIT.
// The contract of the seat "node-build-node", defined whole (seats/node-build-node/seat.json): The Node build module on a node: it takes the asks of the builder and the checker, runs them as steps and answers.
import type * as buildStep from "./parts/build-step.js";
export const seat = "node-build-node";
export const verbs = {
current: "current",
pause: "pause",
resume: "resume",
drop: "drop",
} as const;
export const accepts = {
delivery: "delivery",
check: "check",
recheck: "recheck",
} as const;
export const deliverySubject = "mesh.seat.node-build-node.accept.delivery";
export const checkSubject = "mesh.seat.node-build-node.accept.check";
export const recheckSubject = "mesh.seat.node-build-node.accept.recheck";
export const states = {
steps: "steps",
pauses: "pauses",
toolchains: "toolchains",
} as const;
/** The key of one value of the state steps under the form "<node>.<id>". */
export function stepsByNodeIDKey(node: string, id: string): string {
return node + "." + id;
}
/** The parts of a key of the form "<node>.<id>", or undefined for a key of another form. */
export function parseStepsByNodeIDKey(_key: string): { node: string; id: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const node = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node, id };
}
/** The key of one value of the state pauses under the form "paused.<node>". */
export function pausesByPausedNodeKey(node: string): string {
return "paused." + node;
}
/** The parts of a key of the form "paused.<node>", or undefined for a key of another form. */
export function parsePausesByPausedNodeKey(_key: string): { node: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("paused.")) return undefined;
_rest = _rest.slice(7);
if (_rest === "") return undefined;
const node = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node };
}
/** The key of one value of the state pauses under the form "dropped.<id>". */
export function pausesByDroppedIDKey(id: string): string {
return "dropped." + id;
}
/** The parts of a key of the form "dropped.<id>", or undefined for a key of another form. */
export function parsePausesByDroppedIDKey(_key: string): { id: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("dropped.")) return undefined;
_rest = _rest.slice(8);
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { id };
}
/** The key of one value of the state pauses under the form "capacity.<node>". */
export function pausesByCapacityNodeKey(node: string): string {
return "capacity." + node;
}
/** The parts of a key of the form "capacity.<node>", or undefined for a key of another form. */
export function parsePausesByCapacityNodeKey(_key: string): { node: string } | undefined {
let _rest = _key;
if (!_rest.startsWith("capacity.")) return undefined;
_rest = _rest.slice(9);
if (_rest === "") return undefined;
const node = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node };
}
/** The key of one value of the state toolchains under the form "<node>.<language>". */
export function toolchainsByNodeLanguageKey(node: string, language: string): string {
return node + "." + language;
}
/** The parts of a key of the form "<node>.<language>", or undefined for a key of another form. */
export function parseToolchainsByNodeLanguageKey(_key: string): { node: string; language: string } | undefined {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const node = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
if (_rest === "") return undefined;
const language = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { node, language };
}
export type AskArtifact = buildStep.AskArtifact;
export type BuildAnswer = buildStep.BuildAnswer;
export type BuiltArtifact = buildStep.BuiltArtifact;
export type Capacity = buildStep.Capacity;
export type CheckAnswer = buildStep.CheckAnswer;
export type ComposeAnswer = buildStep.ComposeAnswer;
export type CurrentAnswer = buildStep.CurrentAnswer;
export type CurrentInput = buildStep.CurrentInput;
export type Drop = buildStep.Drop;
export type DropAnswer = buildStep.DropAnswer;
export type DropInput = buildStep.DropInput;
export type Fetch = buildStep.Fetch;
export type FetchAnswer = buildStep.FetchAnswer;
export type FetchBesideItem = buildStep.FetchBesideItem;
export type FetchPackage = buildStep.FetchPackage;
export type FetchRefusal = buildStep.FetchRefusal;
export type LevelAnswer = buildStep.LevelAnswer;
export type LevelAnswerDigest = buildStep.LevelAnswerDigest;
export type LevelAnswerDigestNeedsItem = buildStep.LevelAnswerDigestNeedsItem;
export type LevelAnswerDigestPackagesItem = buildStep.LevelAnswerDigestPackagesItem;
export type LevelAnswerKeptItem = buildStep.LevelAnswerKeptItem;
export type LevelAnswerUnmetItem = buildStep.LevelAnswerUnmetItem;
export type ModuleTree = buildStep.ModuleTree;
export type Pause = buildStep.Pause;
export type PauseAnswer = buildStep.PauseAnswer;
export type PauseInput = buildStep.PauseInput;
export type PlacedBeside = buildStep.PlacedBeside;
export type Program = buildStep.Program;
export type ResumeAnswer = buildStep.ResumeAnswer;
export type ResumeInput = buildStep.ResumeInput;
export type RunAnswer = buildStep.RunAnswer;
export type RunHead = buildStep.RunHead;
export type RunInput = buildStep.RunInput;
export type RunJob = buildStep.RunJob;
export type RunProgram = buildStep.RunProgram;
export type Step = buildStep.Step;
export type StepAsk = buildStep.StepAsk;
export type StepAskBuild = buildStep.StepAskBuild;
export type StepAskCheck = buildStep.StepAskCheck;
export type StepAskCompose = buildStep.StepAskCompose;
export type StepResult = buildStep.StepResult;
export type TakenVersion = buildStep.TakenVersion;
export type Toolchain = buildStep.Toolchain;
export type ToolchainUsed = buildStep.ToolchainUsed;
/** is the body of delivery. */
export type DeliveryAccept = StepAsk;
/** is the body of check. */
export type CheckAccept = StepAsk;
/** is the body of recheck. */
export type RecheckAccept = StepAsk;
/** is one value of the state steps under the key <node>.<id>. */
export type StepsByNodeIDValue = Step;
/** is one value of the state pauses under the key paused.<node>. */
export type PausesByPausedNodeValue = Pause;
/** is one value of the state pauses under the key dropped.<id>. */
export type PausesByDroppedIDValue = Drop;
/** is one value of the state pauses under the key capacity.<node>. */
export type PausesByCapacityNodeValue = Capacity;
/** is one value of the state toolchains under the key <node>.<language>. */
export type ToolchainsByNodeLanguageValue = Toolchain;
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}
+48 -12
View File
@@ -16,10 +16,10 @@ import (
"git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/sdk/kept"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/catalogkey"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/kept"
"git.novox.be/novox/mesh-sdk/go/keys"
)
@@ -38,10 +38,16 @@ const (
// `assignments.<proposal id>`. The kind needs no proof, as the bus's put does not: its yes is an Approve, which only a
// channel proving who answered takes and the view never does (ViewAnswerFits).
AskKindAssignments = "assignments"
// AskKindOutside is the kind of the assigner's ask about a moved module whose failing health check has waited past its bound
// on an outside cause (design 47 G §706): go on, or count it as failed. `outside.<node>.<module>.<fingerprint hex>`.
AskKindOutside = "outside"
// AskKindRevert is the kind of the assigner's ask about a held revert, one with no restore point (design 47 G §710): keep it
// held, or start the reverted build on the live data. `revert.<node>.<module>.<fingerprint hex>`, the reverted build's.
AskKindRevert = "revert"
)
// AskKey is an ask's key read whole: its Kind, and the parts its kind has: a merge's Group and heads Hash, a hold's Module
// and fingerprint FP, a restore's and a backup's Node, Module and FP, a record's Node, Module and the digest Composed of the
// and fingerprint FP, a restore's, a backup's, an outside's and a revert's Node, Module and FP, a record's Node, Module and the digest Composed of the
// composition it would allow, the assignments' Proposal.
type AskKey struct {
Proposal string
@@ -60,11 +66,11 @@ func AskNeedsProof(kind string) (bool, error) {
switch kind {
case meshdeliveryclient.AskKindMerge:
return true, nil
case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments:
case AskKindHold, AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside, AskKindRevert:
return false, nil
}
return false, fmt.Errorf("asks: %q is not a kind of ask, %s, %s, %s, %s, %s or %s", kind, meshdeliveryclient.AskKindMerge, AskKindHold,
AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments)
return false, fmt.Errorf("asks: %q is not a kind of ask, %s, %s, %s, %s, %s, %s, %s or %s", kind, meshdeliveryclient.AskKindMerge, AskKindHold,
AskKindRestore, AskKindBackup, AskKindRecord, AskKindAssignments, AskKindOutside, AskKindRevert)
}
// HoldAskKey is the key of the ask about a module's held fingerprint: `hold.<module>.<hex>`.
@@ -89,10 +95,23 @@ func RestoreAskKey(node, module, fingerprint string) (string, error) {
return AskKindRestore + "." + node + "." + key, nil
}
// OutsideAskKey is the key of the ask about a module's failing check on a node that waited past its bound on an outside cause:
// `outside.<node>.<module>.<hex>`.
func OutsideAskKey(node, module, fingerprint string) (string, error) {
key, err := RestoreAskKey(node, module, fingerprint)
if err != nil {
return "", err
}
return AskKindOutside + strings.TrimPrefix(key, AskKindRestore), nil
}
// OutsideAbout is what the outside ask is about: `<node>.<module>.<hex>` (design 47 G §706).
func OutsideAbout(key string) string { return strings.TrimPrefix(key, AskKindOutside+".") }
// BackupAskKey is the key of the ask about a module's failed backup on a node before it applies a fingerprint:
// `backup.<node>.<module>.<hex>`.
func BackupAskKey(node, module, fingerprint string) (string, error) {
part, err := meshcatalogclient.KeyPart(fingerprint)
part, err := catalogkey.KeyPart(fingerprint)
if err != nil {
return "", err
}
@@ -103,6 +122,16 @@ func BackupAskKey(node, module, fingerprint string) (string, error) {
return AskKindBackup + "." + key, nil
}
// RevertAskKey is the key of the ask about a held revert of a module on a node to the reverted build's fingerprint:
// `revert.<node>.<module>.<hex>` (design 47 G §710).
func RevertAskKey(node, module, fingerprint string) (string, error) {
key, err := BackupAskKey(node, module, fingerprint)
if err != nil {
return "", err
}
return AskKindRevert + strings.TrimPrefix(key, AskKindBackup), nil
}
// AssignmentsAskKey is the key of the authorising ask about a proposal of assignments: `assignments.<proposal id>` (ADR 0567 §5).
func AssignmentsAskKey(proposal string) (string, error) {
if err := meshmoduleassignerclient.CheckProposalID(proposal); err != nil {
@@ -124,7 +153,7 @@ func RecordAskKey(node, module, composed string) (string, error) {
return AskKindRecord + "." + have + "." + strings.TrimPrefix(composed, "sha256:"), nil
}
// ParseAnyAskKey reads an ask key of any kind (a merge, hold, restore, backup, record or assignments ask); it refuses an unknown
// ParseAnyAskKey reads an ask key of any kind (a merge, hold, restore, backup, record, assignments, outside or revert ask); it refuses an unknown
// kind and a key whose parts do not fit its kind, naming the key.
func ParseAnyAskKey(key string) (AskKey, error) {
kind, rest, _ := strings.Cut(key, ".")
@@ -157,12 +186,19 @@ func ParseAnyAskKey(key string) (AskKey, error) {
return refused(err)
}
return AskKey{Kind: kind, Node: node, Module: module, FP: fp}, nil
case AskKindBackup:
case AskKindOutside:
k, err := ParseAnyAskKey(AskKindRestore + "." + rest)
if err != nil {
return refused(fmt.Errorf("an outside ask is outside.<node>.<module>.<fingerprint hex>"))
}
k.Kind = kind
return k, nil
case AskKindBackup, AskKindRevert:
node, module, part, isNode, err := nodeengineclient.ParseComposedKey(rest)
if err != nil || isNode {
return refused(fmt.Errorf("a backup ask is backup.<node>.<module>.<fingerprint hex>"))
return refused(fmt.Errorf("a %s ask is %s.<node>.<module>.<fingerprint hex>", kind, kind))
}
fp, err := meshcatalogclient.ParseKeyPart(part)
fp, err := catalogkey.ParseKeyPart(part)
if err != nil {
return refused(err)
}
@@ -19,6 +19,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
backup, _ := BackupAskKey("n1", "novox.be", fp)
assignments, _ := AssignmentsAskKey("p-20261028t183000-1a2b3c")
record, _ := RecordAskKey("n1", "gitea", digest)
revert, _ := RevertAskKey("n1", "nats", fp)
for key, want := range map[string]struct {
got string
back AskKey
@@ -28,6 +29,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
"backup": {backup, AskKey{Kind: "backup", Node: "n1", Module: "novox.be", FP: fp}},
"assignments": {assignments, AskKey{Kind: "assignments", Proposal: "p-20261028t183000-1a2b3c"}},
"record": {record, AskKey{Kind: "record", Node: "n1", Module: "gitea", Composed: digest}},
"revert": {revert, AskKey{Kind: "revert", Node: "n1", Module: "nats", FP: fp}},
} {
back, err := ParseAnyAskKey(want.got)
if err != nil || back != want.back {
@@ -35,7 +37,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
}
}
for got, want := range map[string]string{hold: "hold.gitea." + hex, restore: "restore.n1.gitea." + hex, backup: "backup.n1.novox=2ebe." + hex,
assignments: "assignments.p-20261028t183000-1a2b3c", record: "record.n1.gitea." + strings.Repeat("cd", 32)} {
assignments: "assignments.p-20261028t183000-1a2b3c", record: "record.n1.gitea." + strings.Repeat("cd", 32), revert: "revert.n1.nats." + hex} {
if got != want {
t.Errorf("key %q, want %q", got, want)
}
@@ -45,7 +47,7 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
t.Errorf("a merge ask key: %+v, %v", merge, err)
}
for _, bad := range []string{"", "other.x", "hold.gitea", "hold.gitea.src2:" + hex, "restore.n1", "backup.n1.node", "backup.n1.gitea." + hex[:60],
"assignment.AB", "record.n1.gitea." + hex[:10], "record.n1.node." + hex, "merge.g"} {
"assignment.AB", "record.n1.gitea." + hex[:10], "record.n1.node." + hex, "merge.g", "revert.n1.node", "revert.n1.nats." + hex[:60]} {
if _, err := ParseAnyAskKey(bad); err == nil {
t.Errorf("ParseAnyAskKey(%q) reads a wrong key", bad)
}
@@ -53,8 +55,10 @@ func TestTheAssignersAskKeysKeepTheirFormsAndTheValueItsRules(t *testing.T) {
if proof, err := AskNeedsProof("merge"); err != nil || !proof {
t.Error("a merge ask needs proof")
}
if proof, err := AskNeedsProof("hold"); err != nil || proof {
t.Error("a hold ask needs no proof")
for _, kind := range []string{"hold", "revert"} {
if proof, err := AskNeedsProof(kind); err != nil || proof {
t.Errorf("a %s ask needs no proof", kind)
}
}
if _, err := AskNeedsProof("view"); err == nil {
t.Error("an unknown kind of ask is taken")
+8 -8
View File
@@ -5,19 +5,19 @@ go 1.26.0
require (
git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-sdk/go v0.0.0-00010101000000-000000000000
)
require (
git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-contracts/go/seats/nodeengine v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/containerimageregistryclient v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery v0.0.0-00010101000000-000000000000 // indirect
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/ociartifactregistry v0.0.0-00010101000000-000000000000 // indirect
)
@@ -0,0 +1,225 @@
package main
import (
"context"
"maps"
"slices"
"strings"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
// The waits for an acting part's new version in the steps the assigner holds (design 47 G §266, §268, §276): an entry of the assignment
// graph whose acting edge names composing or placing waits, its want held, until the part's version runs on the edge's node. The wait is
// kept in the state `waits` under `acting.<node>.<module>.<part>`, said as `wait-held` when it starts and `wait-ended` (ran, released,
// replaced) when it ends; `assigner_waits` reads it and `assigner_release` with how `now` lets the entry move with the part's running
// version. A node away starts no wait: its clock starts when the node is present (§268).
// actingOf is the Module.Acting of module: the held acting edges of its entry on a node, those whose wait was not released left out.
func (p *passer) actingOf(module string, released map[string]bool) func(node string) []assign.ActingEdge {
return func(node string) []assign.ActingEdge {
var out []assign.ActingEdge
if p.engineOn(node) == module {
return nil // the node-engine's own want is held by no acting edge, so it is written first (§268, A-323, A-330)
}
for _, e := range p.entryEdges(node, module) {
if acting.Held(e.Step) && !released[acting.Key(node, module, e.Module)+"@"+e.Fp] {
out = append(out, assign.ActingEdge{Node: e.Node, Part: e.Module, Fp: e.Fp, Step: e.Step})
}
}
return out
}
}
// engineOn is the module that holds the seat node-engine on node in the revision the pass computed, read from the acting edges'
// seat (A-330); "" when none is read.
func (p *passer) engineOn(node string) string {
if p.graph == nil {
return ""
}
return assigngraph.Engines(slices.Collect(maps.Values(p.graph.computed.Entries)))[node]
}
// entryEdges are the acting edges of the entry of module on node in the revision the pass computed.
func (p *passer) entryEdges(node, module string) []seat.Edge {
g := p.graph
if g == nil {
return nil
}
for _, ref := range g.computed.Revision.Entries {
if ref.Node == node && ref.Module == module {
var out []seat.Edge
for _, e := range g.computed.Entries[ref.Digest].Edges {
if e.Kind == "acting" {
out = append(out, e)
}
}
return out
}
}
return nil
}
// actingReleased is the waits released by hand and not yet run, by `<key>@<part's fingerprint>`.
func (p *passer) actingReleased() map[string]bool {
out := map[string]bool{}
for key, raw := range p.w.read(buckets.Waits()) {
if w, err := acting.Decode(raw); err == nil && w.Released && (acting.Is(key) || acting.IsOrder(key)) {
out[key+"@"+w.Fp] = true
}
}
return out
}
// actingWaits keeps the acting waits after the pass's wants: it starts the wait of each entry held for a part's version, ends the
// wait of one that ran, was replaced or was released, and says each.
func (p *passer) actingWaits(ctx context.Context, n assign.Nodes, wanted map[string]map[string]meshwants.Want) {
g := p.graph
if g == nil || p.kvs[buckets.Waits()] == nil {
return
}
now := p.clock()
kept := p.w.read(buckets.Waits())
standing := map[string]bool{}
engines := assigngraph.Engines(slices.Collect(maps.Values(g.computed.Entries)))
held := map[string]bool{} // `<node>/<module>` of each entry held for a part's version
for _, ref := range g.computed.Revision.Entries {
entry := g.computed.Entries[ref.Digest]
if w, ok := wanted[ref.Module][ref.Node]; ok && w.Fp == entry.Fp {
continue // the entry's version is wanted already: nothing moves, so nothing waits
}
if engines[ref.Node] == ref.Module {
continue // the node-engine's own want waits for no acting part (§268, A-323, A-330)
}
for _, e := range entry.Edges {
if e.Kind != "acting" || !acting.Held(e.Step) {
continue
}
key := acting.Key(ref.Node, ref.Module, e.Module)
if n.Runs(assign.ActingEdge{Node: e.Node, Part: e.Module, Fp: e.Fp, Step: e.Step}) {
continue
}
standing[key] = true
held[ref.Node+"/"+ref.Module] = true
if raw := kept[key]; raw != nil {
if w, err := acting.Decode(raw); err == nil && w.Entry == entry.Fp && w.Fp == e.Fp {
p.actingReleaseSaid(ctx, key, w)
continue
}
}
if !n.Online[ref.Node] {
continue // a node away holds only itself, and its clock has not started (§268)
}
w := acting.Wait{Step: e.Step, Node: ref.Node, Module: ref.Module, Part: e.Module, PartNode: e.Node, Fp: e.Fp, Entry: entry.Fp,
Since: now, ClockFrom: now, Bound: acting.BoundOf(e).String()}
if err := p.wantsSay(seat.EventWaitHeld, acting.Body(w, assignment.Module()), key+"."+e.Fp+"."+entry.Fp); err != nil {
p.logf("the wait of %s on %s for %s is not said, and is said at the next pass: %v", ref.Module, ref.Node, e.Module, err)
continue
}
p.put(ctx, buckets.Waits(), key, func() ([]byte, error) { return acting.Encode(w) })
}
}
p.actingCycles(g.computed.Entries, engines, held, n)
for key, raw := range kept {
if !acting.Is(key) || standing[key] {
continue
}
w, err := acting.Decode(raw)
if err != nil {
continue
}
how := acting.HowRan
if w.Released {
how = acting.HowReleased
} else if e, ok := p.edgeOf(w); !ok || !n.Runs(e) {
how = acting.HowReplaced
}
if !w.EndSaid {
body := acting.Body(w, assignment.Module())
body.How = how
if err := p.wantsSay(seat.EventWaitEnded, body, key+"."+w.Fp+"."+w.Entry+"."+how); err != nil {
p.logf("the end of the wait of %s on %s for %s is not said, and is said at the next pass: %v", w.Module, w.Node, w.Part, err)
continue
}
}
p.remove(ctx, buckets.Waits(), key)
}
}
// actingReleaseSaid says wait-ended of a wait released by hand, once, and keeps its record so the entry is not held again.
func (p *passer) actingReleaseSaid(ctx context.Context, key string, w acting.Wait) {
if !w.Released || w.EndSaid {
return
}
body := acting.Body(w, assignment.Module())
body.How = acting.HowReleased
body.By = w.By
if err := p.wantsSay(seat.EventWaitEnded, body, key+"."+w.Fp+"."+w.Entry+"."+acting.HowReleased); err != nil {
p.logf("the release of the wait of %s on %s for %s is not said, and is said at the next pass: %v", w.Module, w.Node, w.Part, err)
return
}
w.EndSaid = true
p.put(ctx, buckets.Waits(), key, func() ([]byte, error) { return acting.Encode(w) })
}
// edgeOf is the edge a kept wait stands on, as an acting edge.
func (p *passer) edgeOf(w acting.Wait) (assign.ActingEdge, bool) {
return assign.ActingEdge{Node: w.PartNode, Part: w.Part, Fp: w.Fp, Step: w.Step}, true
}
// actingCycles writes the finding acting-cycle of each held want whose holding edges lie on a cycle no rule breaks, and clears it of
// every other want, a finding a pass before or another process wrote included (design 47 G A-323, A-330). An edge holds while its
// step is the assigner's, its part's version does not run and its wait was not released; the node-engine's own want is held by none.
func (p *passer) actingCycles(entries map[string]seat.AssignmentEntry, engines map[string]string, held map[string]bool, n assign.Nodes) {
if p.findings.Put == nil {
return
}
released := p.actingReleased()
cycles := assigngraph.HeldCycles(slices.Collect(maps.Values(entries)), func(e seat.AssignmentEntry, edge seat.Edge) bool {
return held[e.Node+"/"+e.Module] && engines[e.Node] != e.Module && edge.Kind == "acting" && acting.Held(edge.Step) &&
!released[acting.Key(e.Node, e.Module, edge.Module)+"@"+edge.Fp] && !n.Runs(assign.ActingEdge{Node: edge.Node, Part: edge.Module, Fp: edge.Fp, Step: edge.Step})
})
raised := map[string]bool{}
for _, at := range slices.Sorted(maps.Keys(cycles)) {
node, module, _ := strings.Cut(at, "/")
o := assign.ActingCycleFinding(node, module, cycles[at])
key := mcseatKey(o)
raised[key] = true
if p.said[key+"="+o.Summary] {
continue // written already as it stands
}
if err := p.findings.Put(o.Finding(p.clock())); err != nil {
p.logf("the finding %s is not written: %v", key, err)
continue
}
p.said[key+"="+o.Summary] = true
}
if p.findings.Keys == nil || p.findings.Delete == nil {
return
}
keys, err := p.findings.Keys()
if err != nil {
p.logf("the findings %s are not read, so none is cleared this pass: %v", assign.KindActingCycle, err)
return
}
for _, key := range keys {
if strings.HasSuffix(key, "."+assign.KindActingCycle) && !raised[key] {
if err := p.findings.Delete(key); err != nil {
p.logf("the finding %s is not cleared: %v", key, err)
continue
}
for said := range p.said {
if strings.HasPrefix(said, key+"=") {
delete(p.said, said)
}
}
}
}
}
@@ -0,0 +1,184 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/buckets"
)
// Design 47 G §266, §268, §276 (AS9): an entry with an acting edge in composing waits for the part's version to run on the edge's node;
// the wait is kept, said as wait-held with its bound and clock-from, read by assigner_waits, released by assigner_release with how
// `now` and ended when the version runs.
func TestAnEntryWaitsForItsActingPartAndTheWaitIsKeptSaidAndEnded(t *testing.T) {
now := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
p := newPasser(kvSet{buckets.Waits(): &memKV{values: map[string][]byte{}}}, now)
var events []string
var bodies []meshwants.Wait
p.publishWants = func(event string, body any, id string) error {
events = append(events, event)
bodies = append(bodies, body.(meshwants.Wait))
return nil
}
entry := meshwants.AssignmentEntry{Node: "ace", Module: "nats", Fp: fpTest, Edges: []meshwants.Edge{
{Kind: "acting", Node: "ace", Module: "mesh-host", Fp: "src2:new", Seat: "node-engine", Step: "compose", Bound: "15m"},
{Kind: "acting", Node: "ace", Module: "indexer", Fp: "src2:idx", Seat: "mesh-catalog", Step: "take-in"}}}
p.graph = &graphKeeper{}
p.graph.computed.Revision.Entries = []meshwants.NodeEntryRef{{Node: "ace", Module: "nats", Digest: "d1"}}
p.graph.computed.Entries = map[string]meshwants.AssignmentEntry{"d1": entry}
n := assign.Nodes{Online: map[string]bool{"ace": true}, Running: map[string]map[string]nodeengine.Running{"ace": {"mesh-host": {Fingerprint: "src2:old"}}}}
wanted := map[string]map[string]mwc.Want{"nats": {"ace": {Fp: "src2:older"}}}
ctx := context.Background()
if got := p.actingOf("nats", nil)("ace"); len(got) != 1 || got[0].Part != "mesh-host" {
t.Fatalf("the assigner holds composing only: %+v", got)
}
p.actingWaits(ctx, n, wanted)
p.actingWaits(ctx, n, wanted) // said once
if len(events) != 1 || events[0] != meshwants.EventWaitHeld {
t.Fatalf("events %v", events)
}
b := bodies[0]
if b.Step != "compose" || b.Part != "mesh-host" || b.Fp != "src2:new" || b.Bound != "15m0s" || b.ClockFrom == nil || !b.ClockFrom.Equal(now) || b.Owner == "" {
t.Errorf("wait-held %+v", b)
}
key := acting.Key("ace", "nats", "mesh-host")
if _, err := acting.Decode(p.w.read(buckets.Waits())[key]); err != nil {
t.Fatalf("the wait is kept: %v", err)
}
// a release by hand lets the entry move, and ends the wait once
w, _ := acting.Decode(p.w.read(buckets.Waits())[key])
w.Released = true
raw, _ := acting.Encode(w)
p.w.put(buckets.Waits(), key, raw)
if got := p.actingOf("nats", p.actingReleased())("ace"); len(got) != 0 {
t.Errorf("a released wait still holds: %+v", got)
}
p.actingWaits(ctx, n, wanted)
p.actingWaits(ctx, n, wanted)
if len(events) != 2 || events[1] != meshwants.EventWaitEnded || bodies[1].How != acting.HowReleased {
t.Fatalf("events %v bodies %+v", events, bodies)
}
// the version runs: the record goes, said nothing more
n.Running["ace"]["mesh-host"] = nodeengine.Running{Fingerprint: "src2:new"}
p.actingWaits(ctx, n, wanted)
if len(p.w.read(buckets.Waits())) != 0 || len(events) != 2 {
t.Errorf("kept %v events %v", p.w.read(buckets.Waits()), events)
}
}
func TestAnActingWaitOfANodeAwayStartsNoClockAndAnEntryWantedAlreadyWaitsNot(t *testing.T) {
p := newPasser(kvSet{buckets.Waits(): &memKV{values: map[string][]byte{}}}, time.Now())
p.publishWants = func(string, any, string) error { t.Fatal("said"); return nil }
p.graph = &graphKeeper{}
p.graph.computed.Revision.Entries = []meshwants.NodeEntryRef{{Node: "ace", Module: "nats", Digest: "d1"}}
p.graph.computed.Entries = map[string]meshwants.AssignmentEntry{"d1": {Node: "ace", Module: "nats", Fp: "src2:a", Edges: []meshwants.Edge{
{Kind: "acting", Node: "ace", Module: "mesh-host", Fp: "src2:new", Step: "compose"}}}}
n := assign.Nodes{Online: map[string]bool{}}
p.actingWaits(context.Background(), n, map[string]map[string]mwc.Want{})
n.Online["ace"] = true
p.actingWaits(context.Background(), n, map[string]map[string]mwc.Want{"nats": {"ace": {Fp: "src2:a"}}})
if len(p.w.read(buckets.Waits())) != 0 || strings.Contains(p.graph.computed.Entries["d1"].Fp, "x") {
t.Errorf("kept %v", p.w.read(buckets.Waits()))
}
}
func TestTheToolsReadAndReleaseAnActingWait(t *testing.T) {
var acts []string
s := newPutTools(memStore{waitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveryWaitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveredAgainState: &memKV{values: map[string][]byte{}}}, func(key string, body any) (json.RawMessage, error) {
acts = append(acts, key)
return json.RawMessage(`{"ok":true,"log":"logged"}`), nil
})
w := acting.Wait{Step: "compose", Node: "ace", Module: "nats", Part: "mesh-host", PartNode: "ace", Fp: "src2:new", Entry: "src2:a", Since: time.Now(), ClockFrom: time.Now(), Bound: "30m0s"}
raw, _ := acting.Encode(w)
s.st.put(waitsState, acting.Key("ace", "nats", "mesh-host"), raw)
got, err := s.doWaits(map[string]any{"module": "nats"})
if list := got.(map[string]any)["waits"].([]any); err != nil || len(list) != 1 || list[0].(actingSaid).BoundEnds == "" {
t.Fatalf("assigner_waits = %+v %v", got, err)
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "nats", "how": "now"}, "x"); err == nil {
t.Error("a release with no why is taken")
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "nats", "how": "now", "why": "the new engine is known good"}, "shanks.node-tools"); err != nil || len(acts) == 0 {
t.Fatalf("%v %v", err, acts)
}
raw, _, _, _ = s.st.get(waitsState, acting.Key("ace", "nats", "mesh-host"))
if r, _ := acting.Decode(raw); !r.Released || !strings.Contains(r.By, "known good") {
t.Errorf("wait = %+v", r)
}
}
// Design 47 G §268, A-323, A-330: the assigner and the node-engine on a node are one step: the node-engine's own want waits for no
// acting part, so it moves first, and the node's other wants, the assigner's own included, wait for the new node-engine; a want held
// on a cycle no rule breaks is the finding acting-cycle (scope module, id <node>.<module>) naming each edge's step, cleared by the
// next pass that finds it on none.
func TestTheNodeEngineIsWantedFirstAndAWantHeldOnACycleIsAFinding(t *testing.T) {
p := newPasser(kvSet{buckets.Waits(): &memKV{values: map[string][]byte{}}}, time.Date(2026, 11, 13, 2, 0, 0, 0, time.UTC))
p.publishWants = func(string, any, string) error { return nil }
findings := map[string]mcseat.Finding{}
p.findings = assign.Findings{
Put: func(f mcseat.Finding) error { findings[f.Scope+"."+f.ID+"."+f.Kind] = f; return nil },
Delete: func(key string) error { delete(findings, key); return nil },
Keys: func() ([]string, error) {
var keys []string
for k := range findings {
keys = append(keys, k)
}
return keys, nil
}}
compose := meshwants.Edge{Kind: "acting", Node: "ace", Module: "mesh-host", Fp: "src2:engine", Seat: "node-engine", Step: "compose"}
place := meshwants.Edge{Kind: "acting", Node: "ace", Module: "assigner", Fp: "src2:assigner", Seat: "mesh-wants", Step: "place"}
entries := map[string]meshwants.AssignmentEntry{
"d1": {Node: "ace", Module: "mesh-host", Fp: "src2:engine", Edges: []meshwants.Edge{place}},
"d2": {Node: "ace", Module: "assigner", Fp: "src2:assigner", Edges: []meshwants.Edge{compose}},
"d3": {Node: "ace", Module: "app", Fp: "src2:app", Edges: []meshwants.Edge{compose, place}},
}
p.graph = &graphKeeper{}
for d, e := range entries {
p.graph.computed.Revision.Entries = append(p.graph.computed.Revision.Entries, meshwants.NodeEntryRef{Node: e.Node, Module: e.Module, Digest: d})
}
p.graph.computed.Entries = entries
n := assign.Nodes{Online: map[string]bool{"ace": true}, Running: map[string]map[string]nodeengine.Running{"ace": {
"mesh-host": {Fingerprint: "src2:old"}, "assigner": {Fingerprint: "src2:old"}}}}
wanted := map[string]map[string]mwc.Want{}
if got := p.actingOf("mesh-host", nil)("ace"); len(got) != 0 {
t.Errorf("the node-engine's want is held by %+v, want by no acting edge", got)
}
if got := p.actingOf("assigner", nil)("ace"); len(got) != 1 || got[0].Part != "mesh-host" {
t.Errorf("the assigner's own want is held by %+v, want by the new node-engine", got)
}
p.actingWaits(context.Background(), n, wanted)
kept := p.w.read(buckets.Waits())
if kept[acting.Key("ace", "mesh-host", "assigner")] != nil || kept[acting.Key("ace", "assigner", "mesh-host")] == nil || kept[acting.Key("ace", "app", "mesh-host")] == nil {
t.Errorf("the waits kept: %v", kept)
}
if len(findings) != 0 {
t.Errorf("the cycle the node-engine's want breaks is a finding: %v", findings)
}
// Two parts holding each other across compose and place, neither the node-engine: no rule breaks it.
entries["d4"] = meshwants.AssignmentEntry{Node: "ace", Module: "x", Fp: "src2:x", Edges: []meshwants.Edge{{Kind: "acting", Node: "ace", Module: "y", Fp: "src2:y", Seat: "s-y", Step: "place"}}}
entries["d5"] = meshwants.AssignmentEntry{Node: "ace", Module: "y", Fp: "src2:y", Edges: []meshwants.Edge{{Kind: "acting", Node: "ace", Module: "x", Fp: "src2:x", Seat: "s-x", Step: "compose"}}}
p.graph.computed.Revision.Entries = append(p.graph.computed.Revision.Entries, meshwants.NodeEntryRef{Node: "ace", Module: "x", Digest: "d4"},
meshwants.NodeEntryRef{Node: "ace", Module: "y", Digest: "d5"})
p.actingWaits(context.Background(), n, wanted)
f, ok := findings["module.ace.x.acting-cycle"]
if len(findings) != 2 || !ok || !strings.Contains(f.Summary, "ace/x -[place]-> ace/y -[compose]-> ace/x") {
t.Fatalf("the findings of a cycle no rule breaks: %v", findings)
}
// x's new version runs: y no longer waits, so neither is held on a cycle, and both findings clear.
n.Running["ace"]["x"] = nodeengine.Running{Fingerprint: "src2:x"}
p.actingWaits(context.Background(), n, wanted)
if len(findings) != 0 {
t.Errorf("the findings after the cycle broke: %v", findings)
}
}
@@ -10,8 +10,8 @@ import (
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-teller/tellerevents"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -36,7 +36,7 @@ const (
func askAgainByHand(st store, ask func(string, any) (json.RawMessage, error), key, why, by string, now time.Time) (any, error) {
switch {
case key == "":
return nil, errors.New("name the ask's key, as assigner_read_asks lists it")
return nil, errors.New("name the ask's key, as the tool asks lists it")
case why == "":
return nil, errors.New("a hand ask again says why")
case by == "" || by == stdio.Unproven:
@@ -47,7 +47,7 @@ func askAgainByHand(st store, ask func(string, any) (json.RawMessage, error), ke
return nil, err
}
if !found {
return nil, fmt.Errorf("the assigner holds no ask under %s: assigner_read_asks lists them", key)
return nil, fmt.Errorf("the assigner holds no ask under %s: the tool asks lists them", key)
}
_, routerAsk, err := assign.DecodeAsk(key, current)
if err != nil {
@@ -90,7 +90,7 @@ func askAgainTools(st store, ask func(string, any) (json.RawMessage, error), now
"under key is written again under a new asking, which the teller asks on the operator channel, as when the channel says it " +
"ended without an answer. Refused for an asking the operator answered, a proposal's authorising ask (the seat's verb again " +
"asks it) and a record ask a put asked. Recorded as a hand act.",
Input: schema(map[string]string{"key": "the ask's key, as assigner_read_asks lists it", "why": "why it is asked again"},
Input: schema(map[string]string{"key": "the ask's key, as the tool asks lists it", "why": "why it is asked again"},
[]string{"key", "why"}),
Run: func(a map[string]any) (any, error) {
key, _ := a["key"].(string)
@@ -18,7 +18,6 @@ import (
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
"mesh-delivery-assigner/internal/puts"
)
@@ -49,7 +48,7 @@ func staleOfWorld(w *world) staleOf {
func hearAskEnded(ctx context.Context, js jetstream.JetStream, hub *process.EventHub, asksKV jetstream.KeyValue, stale staleOf, logf func(string, ...any)) error {
return hub.Handle(askEndedSubject, func(m jetstream.Msg) {
var putsKV jetstream.KeyValue
if kv, err := js.KeyValue(ctx, buckets.Puts); err == nil {
if kv, err := js.KeyValue(ctx, buckets.Puts()); err == nil {
putsKV = kv
}
if err := askAgain(ctx, asksKV, putsKV, stale, m.Data(), time.Now(), logf); err != nil {
@@ -119,11 +118,7 @@ func (cw callWait) keep(ctx context.Context, id, callee string, call func() (jso
if said || clock().Sub(since) < mcc.CallUnansweredAfter || cw.heard == nil || !cw.heard() || cw.findings.Put == nil {
continue
}
o := mcc.CallUnanswered(id, callee, "", err.Error(), "assigner."+mcc.KindCallUnanswered, since)
value, eerr := mcc.EncodeFinding(o.Finding(plc.Module, "", clock().UTC()))
if eerr == nil {
eerr = cw.findings.Put(key, value)
}
eerr := cw.findings.Put(assign.CallUnanswered(id, callee, "", err.Error(), "assigner."+mcc.KindCallUnanswered, since).Finding(clock()))
if eerr != nil {
logf("the finding %s is not written: %v", key, eerr)
continue
@@ -166,7 +161,7 @@ func endedAtBind(ctx context.Context, js jetstream.JetStream, asksKV jetstream.K
}
var putsKV jetstream.KeyValue
if js != nil {
if kv, err := js.KeyValue(ctx, buckets.Puts); err == nil {
if kv, err := js.KeyValue(ctx, buckets.Puts()); err == nil {
putsKV = kv
}
}
@@ -0,0 +1,74 @@
package main
import (
"encoding/json"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
// Design 47 G §717: the assigner's asks are the seat mesh-wants' state asks, so every key its asks package writes is one of
// that state's key forms, read back to the same parts, and each value is the seat's Ask.
func TestTheAssignersAsksAreTheSeatMeshWantsAsks(t *testing.T) {
const hex = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const fp, digest = "src2:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
must := func(k string, err error) string {
t.Helper()
if err != nil {
t.Fatal(err)
}
return k
}
three := func(parse func(string) (string, string, string, bool)) func(string) []string {
return func(k string) []string {
if a, b, c, ok := parse(k); ok {
return []string{a, b, c}
}
return nil
}
}
for _, row := range []struct {
key string
parse func(string) []string
want []string
}{
{must(asks.HoldAskKey("web", fp)), func(k string) []string {
if a, b, ok := meshwants.ParseAsksByHoldModuleFingerprintKey(k); ok {
return []string{a, b}
}
return nil
}, []string{"web", hex}},
{must(asks.RestoreAskKey("ace", "web", fp)), three(meshwants.ParseAsksByRestoreNodeModuleFingerprintKey), []string{"ace", "web", hex}},
{must(asks.BackupAskKey("ace", "web", fp)), three(meshwants.ParseAsksByBackupNodeModuleFingerprintKey), []string{"ace", "web", hex}},
{must(asks.OutsideAskKey("ace", "web", fp)), three(meshwants.ParseAsksByOutsideNodeModuleFingerprintKey), []string{"ace", "web", hex}},
{must(asks.RevertAskKey("ace", "web", fp)), three(meshwants.ParseAsksByRevertNodeModuleFingerprintKey), []string{"ace", "web", hex}},
{must(asks.RecordAskKey("ace", "web", digest)), three(meshwants.ParseAsksByRecordNodeModuleDigestKey), []string{"ace", "web", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}},
{must(asks.AssignmentsAskKey("p-20261028t183000-1a2b3c")), func(k string) []string {
if a, ok := meshwants.ParseAsksByAssignmentsProposalKey(k); ok {
return []string{a}
}
return nil
}, []string{"p-20261028t183000-1a2b3c"}},
} {
got := row.parse(row.key)
if len(got) != len(row.want) {
t.Errorf("%s is no key form of mesh-wants' asks: read as %v", row.key, got)
continue
}
for i := range got {
if got[i] != row.want[i] {
t.Errorf("%s reads as %v, want %v", row.key, got, row.want)
break
}
}
}
key := must(asks.HoldAskKey("web", fp))
raw, err := asks.EncodeAsk(key, asks.Ask{Ask: json.RawMessage(`{"id":"x"}`), Asking: 2})
if err != nil {
t.Fatal(err)
}
if v, err := meshwants.DecodeAsksByHoldModuleFingerprintValue(raw); err != nil || v.Asking != 2 {
t.Errorf("the ask %s is not the seat's Ask: %+v, %v", raw, v, err)
}
}
@@ -0,0 +1,170 @@
package main
import (
"context"
"encoding/json"
"fmt"
"sync"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assignment"
)
// What a want names of a build is heard, never read from the builder's state (design 47 G §379, §526, A-94, A-96; ADR 0596 §2): the
// builder's event `build.ended` (a done build's parts by digest, its keeping node and its branch) and the catalog's
// `version-released` (a released version and each part's digest, with the source revision it was built from), each kept as heard.
// builtEnds are the done builds' ends heard, by module and fingerprint (hearBuildEnded).
type builtEnds struct {
mu sync.Mutex
ends map[string]builds.BuildEnded
}
func (b *builtEnds) key(module, fp string) string { return module + "@" + fp }
// put takes one build.ended in.
func (b *builtEnds) put(data []byte) error {
var e builds.BuildEnded
if err := json.Unmarshal(data, &e); err != nil {
return fmt.Errorf("a build.ended that is no end: %w", err)
}
if e.Module == "" || e.Fingerprint == "" {
return fmt.Errorf("a build's end naming no module or fingerprint: %s", data)
}
if e.Outcome == "" { // an end said before A-162 is a done build's
e.Outcome = builds.BuildDone
}
b.mu.Lock()
defer b.mu.Unlock()
if b.ends == nil {
b.ends = map[string]builds.BuildEnded{}
}
b.ends[b.key(e.Module, e.Fingerprint)] = e
return nil
}
// endOf is the end heard of module's build at fp, done or failed (A-162): none while the build has not ended.
func (b *builtEnds) endOf(module, fp string) (builds.BuildEnded, bool) {
b.mu.Lock()
defer b.mu.Unlock()
e, ok := b.ends[b.key(module, fp)]
return e, ok
}
// partsOf is the parts of module's build at fp as its end names them (each artifact's digest by `<module>:<artifact>`), with the
// node that keeps it, for a kept build or a build of another watched branch; false for a build of the trunk not kept, whose
// parts the version's release says (releasedVersions).
func (b *builtEnds) partsOf(module, fp string) (assign.KeptBuild, bool) {
b.mu.Lock()
e, ok := b.ends[b.key(module, fp)]
b.mu.Unlock()
if !ok || (e.KeptOn == "" && e.Branch == "") {
return assign.KeptBuild{}, false
}
parts := map[string]string{}
for name, digest := range e.Made {
parts[module+":"+name] = digest
}
return assign.KeptBuild{Parts: parts, KeptOn: e.KeptOn}, true
}
// releasedVersions are the catalog's version-released heard, by module and source revision.
type releasedVersions struct {
mu sync.Mutex
seen map[string]assign.KeptBuild
// newest is, by module, the last version-released heard of it (delivered.go).
newest map[string]releaseHeard
}
func (r *releasedVersions) key(module string, source int64) string {
return fmt.Sprintf("%s@%d", module, source)
}
// put takes one version-released in.
func (r *releasedVersions) put(data []byte) error {
var e meshcatalog.VersionReleasedEvent
if err := json.Unmarshal(data, &e); err != nil {
return fmt.Errorf("a version-released that is none: %w", err)
}
if e.Module == "" || e.Version == "" {
return fmt.Errorf("a version-released naming no module or version: %s", data)
}
parts := map[string]string{}
for _, a := range e.Artifacts {
parts[e.Module+":"+a.Artifact] = a.Digest
}
r.mu.Lock()
defer r.mu.Unlock()
if r.seen == nil {
r.seen = map[string]assign.KeptBuild{}
}
r.seen[r.key(e.Module, e.Source)] = assign.KeptBuild{Version: e.Version, Parts: parts}
if r.newest == nil {
r.newest = map[string]releaseHeard{}
}
h := releaseHeard{Version: e.Version, Parts: parts, Source: e.Source}
if e.At != nil {
h.At = *e.At
}
r.newest[e.Module] = h // the stream's order is the order of release
return nil
}
// of is the version of module released from the source revision, with its parts' digests; false before it is heard.
func (r *releasedVersions) of(module string, source int64) (assign.KeptBuild, bool) {
r.mu.Lock()
defer r.mu.Unlock()
k, ok := r.seen[r.key(module, source)]
return k, ok
}
// buildsStreamName is the builder's stream of build ends as the bus names it: kept last per subject (one end per module and
// fingerprint, its manifest's `last-per-subject`, design 47 G A-181, §703).
var buildsStreamName = catalogue.StreamBusName(builds.BuildsStream)
// hearBuilds reads the builder's stream of build ends whole at the start and then follows it (design 47 G A-181, §556): what was
// built, and a failed build's error, survive the assigner's restart, and it reads none of the builder's buckets. The read ends before
// it returns, so the first pass knows every build; a stream it cannot hear is said, and no want names a build until the assigner
// starts again.
func hearBuilds(ctx context.Context, js jetstream.JetStream, ends *builtEnds, wake func(), findings assign.Findings, logf func(string, ...any)) {
acct := process.Account{Node: thisNode, Module: assignment.Module()}
nh := notHeardWriter(findings, logf)
cc, err := acct.HearStream(ctx, js, buildsStreamName, func(m jetstream.Msg) {
if err := ends.put(m.Data()); err != nil {
logf("%s is not kept: %v", m.Subject(), err)
}
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
wake()
}, logf)
if err != nil {
nh.NotHeard(buildsStreamName, "what the builder built is not heard until the assigner starts again, so no want names a build's parts or its keeping node and no put goes on", err)
return
}
nh.Heard(buildsStreamName)
go func() {
<-ctx.Done()
cc.Stop()
}()
}
// hearTaken registers the hub's taker of the indexer's take-in.taken, each waking the pass (orderwaits.go, §298, A-260); before
// the hub starts.
func hearTaken(hub *process.EventHub, taken *takenSources, wake func(), logf func(string, ...any)) error {
return hub.Handle(takenSubject, func(m jetstream.Msg) {
if err := taken.put(m.Data()); err != nil {
logf("%s is not kept: %v", m.Subject(), err)
}
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
wake()
})
}
@@ -0,0 +1,110 @@
package main
import (
"encoding/json"
"reflect"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"mesh-delivery-assigner/internal/assign"
)
// Design 47 G §379, §526, A-96: a build.ended heard names a kept build's or another branch's parts by digest (`<module>:<artifact>`) and its
// keeping node; a build of the trunk not kept names none, its parts being the release's.
func TestABuildEndedHeardNamesAKeptBuildsPartsAndItsNode(t *testing.T) {
put := func(b *builtEnds, e builds.BuildEnded) {
raw, _ := json.Marshal(e)
if err := b.put(raw); err != nil {
t.Fatal(err)
}
}
made := map[string]string{"program": "sha256:1"}
var b builtEnds
put(&b, builds.BuildEnded{Module: "node-engine", Fingerprint: "a", Made: made})
if _, ok := b.partsOf("node-engine", "a"); ok {
t.Error("a trunk build not kept names parts")
}
put(&b, builds.BuildEnded{Module: "node-engine", Fingerprint: "b", Made: made, KeptOn: "ace"})
if k, ok := b.partsOf("node-engine", "b"); !ok || k.KeptOn != "ace" || !reflect.DeepEqual(k.Parts, map[string]string{"node-engine:program": "sha256:1"}) {
t.Errorf("kept build = %+v, %v", k, ok)
}
put(&b, builds.BuildEnded{Module: "node-engine", Fingerprint: "c", Made: made, Branch: "cand"})
if k, ok := b.partsOf("node-engine", "c"); !ok || k.KeptOn != "" || len(k.Parts) != 1 {
t.Errorf("branch build = %+v, %v", k, ok)
}
if err := b.put([]byte(`{"module":"x"}`)); err == nil {
t.Error("an end naming no fingerprint is kept")
}
}
// Design 47 G §379, A-96: a version-released heard names the version and the parts' digests of the module's source revision.
func TestAVersionReleasedHeardNamesItsVersionAndPartsBySource(t *testing.T) {
var r releasedVersions
raw, _ := json.Marshal(meshcatalog.VersionReleasedEvent{Module: "mail", Source: 7, Version: "1.2.3", Artifacts: []meshcatalog.VersionArtifact{{Artifact: "image", Digest: "sha256:9"}}})
if err := r.put(raw); err != nil {
t.Fatal(err)
}
want := assign.KeptBuild{Version: "1.2.3", Parts: map[string]string{"mail:image": "sha256:9"}}
if k, ok := r.of("mail", 7); !ok || !reflect.DeepEqual(k, want) {
t.Errorf("released = %+v, %v", k, ok)
}
if _, ok := r.of("mail", 8); ok {
t.Error("another source revision names a version")
}
}
// Design 47 G §379, A-96: the trunk's entry takes its version and parts from the version-released of the kept assignment graph
// revision's source; a kept build from its end; no revision kept, or another fingerprint, names none.
func TestATrunkEntryTakesItsVersionFromTheReleaseOfTheKeptRevisionsSource(t *testing.T) {
w := newWorld()
raw, _ := json.Marshal(meshcatalog.VersionReleasedEvent{Module: "mail", Source: 7, Version: "1.2.3", Artifacts: []meshcatalog.VersionArtifact{{Artifact: "image", Digest: "sha256:9"}}})
if err := w.released.put(raw); err != nil {
t.Fatal(err)
}
endRaw, _ := json.Marshal(builds.BuildEnded{Module: "mail", Fingerprint: "k", Made: map[string]string{"image": "sha256:2"}, KeptOn: "g14"})
if err := w.ends.put(endRaw); err != nil {
t.Fatal(err)
}
p := &passer{w: w, graph: &graphKeeper{ok: true, source: 7}}
named := p.namedBuild("mail", "t")
if k, ok := named("t"); !ok || k.Version != "1.2.3" || k.Parts["mail:image"] != "sha256:9" || k.KeptOn != "" {
t.Errorf("trunk entry = %+v, %v", k, ok)
}
if k, ok := named("k"); !ok || k.KeptOn != "g14" || k.Version != "" {
t.Errorf("kept build = %+v, %v", k, ok)
}
if _, ok := named("other"); ok {
t.Error("another fingerprint names a release")
}
p.graph.source = 8
if _, ok := named("t"); ok {
t.Error("a source with no release names a version")
}
p.graph.ok = false
if _, ok := p.namedBuild("mail", "t")("t"); ok {
t.Error("no revision kept names a release")
}
}
// A-162: the assigner knows a failed build from its `build.ended`: the error whole; a done end or none heard is not failed.
func TestTheStandingOfABuildIsHeardFromItsEnd(t *testing.T) {
w := newWorld()
say := func(e builds.BuildEnded) {
raw, _ := json.Marshal(e)
if err := w.ends.put(raw); err != nil {
t.Fatal(err)
}
}
if failed, _ := w.buildStanding("web", "fp1"); failed {
t.Error("a build nothing was heard of failed")
}
say(builds.BuildEnded{Module: "web", Fingerprint: "fp1", Outcome: builds.BuildFailed, Error: "transport fault: connection"})
if failed, why := w.buildStanding("web", "fp1"); !failed || why != "transport fault: connection" {
t.Errorf("failed end: failed %v why %q", failed, why)
}
say(builds.BuildEnded{Module: "web", Fingerprint: "fp1", Outcome: builds.BuildDone})
if failed, _ := w.buildStanding("web", "fp1"); failed {
t.Error("a later done end leaves the build failed")
}
}
@@ -0,0 +1,24 @@
package main
import (
"encoding/json"
"testing"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
)
// endDone is the builder's `build.ended` of a done build of module at fp heard by w, naming the digest of its manifest (design 47 G
// A-181: what was built is heard, never read from the builder's bucket).
func endDone(t testing.TB, w *world, module, fp, manifest string) {
t.Helper()
data, err := json.Marshal(builds.BuildEnded{Module: module, Fingerprint: fp, Outcome: builds.BuildDone, Manifest: manifest})
if err != nil {
t.Fatal(err)
}
if err := w.ends.put(data); err != nil {
t.Fatal(err)
}
}
// trunkBranch is the first watched branch the tests give the world: the code names none (design 47 G §507).
const trunkBranch = "main"
@@ -0,0 +1,50 @@
package main
import (
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
plc "mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
// addedOf is the `added` of node's candidate (design 47 G A-316, §379, §421): by module, each module the candidate adds to node
// (laid over current) with no want there, the version and parts the want it would write carries, taken from the same code that
// writes a want's (namedBuild, the want's build at the fingerprint of the branch the want's code takes). A module whose build names none is not listed.
func (p *passer) addedOf(node string, laid *mma.Assignments, current map[string]mma.Assignments) map[string]mma.AddedModule {
if laid == nil {
return nil
}
placed := map[string]string{}
for _, m := range laid.Modules {
placed[m.Module] = m.Branch
}
var out map[string]mma.AddedModule
for _, module := range plc.ChangeOf(current[node], *laid).Added {
if key, err := mwc.WantKey(node, module); err != nil || p.w.has(buckets.Wants(), key) {
continue
}
// The branch the want's code takes: the one the candidate's placement names, else the module's default (groups, wants).
branch := p.w.trunkName()
if p.ends.following() {
branch = p.ends.branchOf(module, placed[module])
}
fp := p.w.trunkOf(branch)[module].Fp2
if fp == "" {
continue
}
k, ok := p.namedBuild(module, fp)(fp)
if !ok {
continue
}
if out == nil {
out = map[string]mma.AddedModule{}
}
out[module] = mma.AddedModule{Version: k.Version, Parts: k.Parts}
}
return out
}
// candidateOf is the candidate of proposal id for node: the assignments laid over it, and what it adds (addedOf).
func (p *passer) candidateOf(id, node string, laid *mma.Assignments, current map[string]mma.Assignments) mma.CandidatesByProposalNodeValue {
return mma.CandidatesByProposalNodeValue{Proposal: id, Assignments: laid, Added: p.addedOf(node, laid, current)}
}
@@ -0,0 +1,59 @@
package main
import (
"encoding/json"
"reflect"
"testing"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph"
"mesh-delivery-assigner/internal/buckets"
)
// Design 47 G A-316: the candidate of a node carries, for each module it adds there without a want, the version and parts the
// want it would write carries; a module with a want there, or a build that names none, is not listed.
func TestACandidateThatAddsAModuleCarriesItsParts(t *testing.T) {
w := newWorld()
for _, m := range []string{"mail", "web", "db"} {
w.putModule("main", m, meshcatalog.ModulesByModuleValue{Fp2: "fp-" + m}, graph.Node{}, nil)
}
w.first = "main"
rel, _ := json.Marshal(meshcatalog.VersionReleasedEvent{Module: "mail", Source: 7, Version: "1.2.3", Artifacts: []meshcatalog.VersionArtifact{{Artifact: "image", Digest: "sha256:9"}}})
if err := w.released.put(rel); err != nil {
t.Fatal(err)
}
end, _ := json.Marshal(builds.BuildEnded{Module: "db", Fingerprint: "fp-db", Made: map[string]string{"program": "sha256:3"}, KeptOn: "g14"})
if err := w.ends.put(end); err != nil {
t.Fatal(err)
}
w.put(buckets.Wants(), "want.ace.web", []byte(`{}`))
p := &passer{w: w, graph: &graphKeeper{ok: true, source: 7}}
laid := &mma.Assignments{Node: "ace", Modules: []mma.PlacedModule{{Module: "mail"}, {Module: "web"}, {Module: "db"}, {Module: "old"}}}
current := map[string]mma.Assignments{"ace": {Node: "ace", Modules: []mma.PlacedModule{{Module: "old"}}}}
c := p.candidateOf("p1", "ace", laid, current)
want := map[string]mma.AddedModule{
"mail": {Version: "1.2.3", Parts: map[string]string{"mail:image": "sha256:9"}},
"db": {Parts: map[string]string{"db:program": "sha256:3"}},
}
if c.Proposal != "p1" || c.Assignments != laid || !reflect.DeepEqual(c.Added, want) {
t.Errorf("candidate = %+v, added want %+v", c, want)
}
if got := p.candidateOf("p1", "ace", nil, current); got.Added != nil {
t.Errorf("a candidate taking the key away adds %+v", got.Added)
}
// A placement on a watched branch is added at that branch's fingerprint, as the want's code takes it, not the trunk's.
w.putModule("feat", "mail", meshcatalog.ModulesByModuleValue{Fp2: "fp-feat"}, graph.Node{}, nil)
feat, _ := json.Marshal(builds.BuildEnded{Module: "mail", Fingerprint: "fp-feat", Made: map[string]string{"program": "sha256:4"}, KeptOn: "g14"})
if err := w.ends.put(feat); err != nil {
t.Fatal(err)
}
p.ends = newPipelineEnds(newMemEnds(), func() {}, t.Logf)
p.ends.trunk = w.trunkName
p.ends.on.Store(true)
onFeat := &mma.Assignments{Node: "g14", Modules: []mma.PlacedModule{{Module: "mail", Branch: "feat"}}}
if got, want := p.candidateOf("p2", "g14", onFeat, nil).Added, map[string]mma.AddedModule{"mail": {Parts: map[string]string{"mail:program": "sha256:4"}}}; !reflect.DeepEqual(got, want) {
t.Errorf("a placement on branch feat adds %+v, want %+v", got, want)
}
}
@@ -0,0 +1,113 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"strings"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"mesh-delivery-assigner/internal/assigngraph"
)
// A run's candidate assignment graph (design 47 G §189, §190, A-201, A-235): the candidate `run-<id>`'s catalog graph revision,
// handed by meshcatalogclient's candidates hook, is read whole through the catalog seat's verb revision; the assignment graph is
// computed from it in place of its branch's half, beside every other followed branch's, and kept under candidate.<group>.<heads>;
// the runner's group-ended of the run drops it. It writes no want and says no event; the gate's heads-replaced drops nothing.
// runGroupPrefix is a run on request's candidate group before its id, `run-<id>` (A-160).
const runGroupPrefix = "run-"
// readCandidateHalf reads a candidate's catalog graph revision whole through the seat's verb revision, page by page, with the
// candidate's group and heads and the revision its event names.
func readCandidateHalf(call mcc.Call, ev meshcatalog.CatalogRevisionEvent) (mcc.Half, error) {
half := mcc.Half{Entries: map[string]meshcatalog.CatalogEntry{}}
after := ""
for {
a, err := askCandidatePage(call, meshcatalog.RevisionInput{Branch: ev.Branch, Group: ev.Group, Heads: ev.Heads, Revision: ev.Revision, After: after})
if err != nil {
return half, err
}
if a.Revision == nil || a.Revision.Revision != ev.Revision || a.Revision.Group != ev.Group {
return half, fmt.Errorf("%s answered %+v for the candidate %s's revision %d", mcc.RevisionVerb, a.Revision, ev.Group, ev.Revision)
}
half.Revision = *a.Revision
for _, e := range a.Entries {
half.Entries[e.Module] = e
}
if a.Next == "" {
return half, nil
}
after = a.Next
}
}
// keepCandidate computes and keeps the candidate's assignment graph of a run's catalog-revision; another candidate's (a try's, a
// group's) is passed over.
func (p *passer) keepCandidate(ctx context.Context, call mcc.Call, ev meshcatalog.CatalogRevisionEvent) error {
if !strings.HasPrefix(ev.Group, runGroupPrefix) {
return nil
}
g := p.graph
if g == nil || g.objects == nil || g.halves == nil {
return errors.New("the assigner keeps no assignment graph, so no candidate's is kept")
}
half, err := readCandidateHalf(call, ev)
if err != nil {
return fmt.Errorf("the candidate %s's revision %d is not read, so its assignment graph is not kept: %w", ev.Group, ev.Revision, err)
}
in := p.graphInputs()
in.Halves = maps.Clone(in.Halves)
in.Halves[half.Revision.Branch] = half
c := assigngraph.Compute(in)
for _, u := range c.Uncomputed {
p.logf("the candidate %s's assignment graph leaves out %s", ev.Group, u)
}
rev, fresh, err := assigngraph.KeepCandidate(ctx, g.objects, c, ev.Group, ev.Heads, meshcatalog.EventCatalogRevision, p.clock())
if err != nil {
return fmt.Errorf("the candidate %s's assignment graph is not kept: %w", ev.Group, err)
}
if fresh {
p.logf("the candidate %s's assignment graph revision %d is kept, from its catalog graph revision %d", ev.Group, rev.Revision, ev.Revision)
}
return nil
}
// dropCandidate removes a run's candidate assignment graph on the runner's group-ended of that run (A-235).
func (p *passer) dropCandidate(ctx context.Context, group string) error {
g := p.graph
if g == nil || g.objects == nil {
return nil
}
o, ok := g.objects.(assigngraph.Remover)
if !ok {
return fmt.Errorf("the objects %s remove nothing, so the candidate %s is kept", assigngraph.RevisionsObjects, group)
}
n, err := assigngraph.DropCandidate(ctx, o, group)
if err == nil && n > 0 {
p.logf("the candidate %s's assignment graph is dropped: its run ended", group)
}
return err
}
// askCandidatePage is one page of a candidate's revision as the seat's verb revision answers it.
func askCandidatePage(call mcc.Call, in meshcatalog.RevisionInput) (meshcatalog.RevisionAnswer, error) {
raw, err := call(mcc.RevisionVerb, in)
if err != nil {
return meshcatalog.RevisionAnswer{}, err
}
answer, said, ok := buscalls.Unwrap(raw)
if !ok {
return meshcatalog.RevisionAnswer{}, fmt.Errorf("%s answered: %s", mcc.RevisionVerb, said)
}
var a meshcatalog.RevisionAnswer
if err := json.Unmarshal(answer, &a); err != nil {
return a, fmt.Errorf("%s answered no revision: %w", mcc.RevisionVerb, err)
}
return a, nil
}
@@ -5,7 +5,7 @@ import (
"strings"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/catalogkey"
)
// Every node's published compositions, read only to compare (novox/hq ADR 0593 §8): each node's engine writes its own
@@ -44,7 +44,7 @@ func published(values map[string][]byte) map[string][]publishedRow {
_, module, part, isNode, err := nodeengineclient.ParseComposedKey(key)
var fp string
if err == nil && !isNode {
fp, err = meshcatalogclient.ParseKeyPart(part)
fp, err = catalogkey.ParseKeyPart(part)
}
switch {
case err != nil:
@@ -0,0 +1,165 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strconv"
"sync"
"time"
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The inputs of a composition step (design 47 G §354, A-243, A-252): one request per machine, each carrying that machine's input,
// asked of the machine's node-engine by the seat's verb compose-candidate.
// composeCandidateKey is the seat verb's call key, asked of one node.
const composeCandidateKey = "seat:" + ne.Seat + "." + ne.VerbComposeCandidate
// candidateKeyOf is the key the step's pipeline names its candidate by (A-226): a run on request's `run-<id>`, or for a branch
// pipeline the branch's catalog graph revision `<branch>.<revision>`, the branch escaped as a key part as the pipeline key does.
func candidateKeyOf(run meshwants.StepRun) (string, error) {
switch {
case run.Run != "":
return "run-" + run.Run, nil
case run.Branch == "" || run.Revision <= 0:
return "", errors.New("the step names neither a run on request nor a branch's revision, so no candidate is named")
}
return keys.EscapeName(run.Branch) + "." + strconv.FormatInt(run.Revision, 10), nil
}
// machinesFor are the machines of a step (A-252): the nodes whose placement of the module names the step's branch (a placement
// naming none is the trunk's, trunk the first watched branch); a branch that is not watched takes the nodes placing the module on the trunk. In name order.
func machinesFor(placed map[string]map[string]string, module, branch, trunk string, watched bool) []string {
if branch == "" || !watched {
branch = trunk
}
var out []string
for node, b := range placed[module] {
if b == "" {
b = trunk
}
if b == branch {
out = append(out, node)
}
}
slices.Sort(out)
return out
}
// machinesOf are the machines of a composition step in the world's assignments.
func (w *world) machinesOf(module, branch string) []string {
return machinesFor(w.placedBranches(), module, branch, w.trunkName(), w.holdsBranch(branch))
}
// nodeInput is one machine's input: its bytes, or why the node gave none, or that it is away.
type nodeInput struct {
Node string
Body []byte
// Why is set when the node gave no input: its words, or the call's error.
Why string
// Away is a machine the watchman said away: it is not asked (§64).
Away bool
}
// compositionInputs asks the machines of a step for their inputs.
type compositionInputs struct {
machines func(module, branch string) []string
// call asks one node's seat verb (process.Conn.CallKeptOn).
call func(node, key string, body any) (json.RawMessage, error)
bound time.Duration
}
// gather asks each machine of the step's module and branch for its input with the step's candidate key, and answers every machine in
// name order: one away (away) is not asked; a node that is silent, unready or answers no input has Why set; no machine at all is an
// error. The run keeps each request built from an input under its run ask (compositionRun.machine).
func (c *compositionInputs) gather(ctx context.Context, run meshwants.StepRun, away func(node string) bool) ([]nodeInput, error) {
key, err := candidateKeyOf(run)
if err != nil {
return nil, err
}
nodes := c.machines(run.Module, run.Branch)
if len(nodes) == 0 {
return nil, fmt.Errorf("no node places %s on the branch %q, so the composition step has no machine to compose", run.Module, run.Branch)
}
bound := c.bound
if bound <= 0 {
bound = nec.AnswerBound
}
out := make([]nodeInput, len(nodes))
var wg sync.WaitGroup
for i, node := range nodes {
if away != nil && away(node) {
out[i] = nodeInput{Node: node, Away: true}
continue
}
wg.Add(1)
go func() {
defer wg.Done()
out[i] = c.one(ctx, bound, node, key)
}()
}
wg.Wait()
return out, nil
}
// one asks one node for its input.
func (c *compositionInputs) one(ctx context.Context, bound time.Duration, node, key string) nodeInput {
in := nodeInput{Node: node}
at, cancel := context.WithTimeout(ctx, bound)
defer cancel()
type reply struct {
raw json.RawMessage
err error
}
got := make(chan reply, 1)
go func() {
raw, err := c.call(node, composeCandidateKey, ne.ComposeCandidateInput{Candidate: key, Input: true})
got <- reply{raw, err}
}()
var raw json.RawMessage
select {
case <-at.Done():
in.Why = "no answer within its bound: " + at.Err().Error()
return in
case r := <-got:
if r.err != nil {
in.Why = r.err.Error()
return in
}
raw = r.raw
}
var a ne.ComposeCandidateAnswer
// its numbers as written (json.Number), so the input kept is the engine's own bytes' values
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&a); err != nil {
in.Why = "its answer is not a compose-candidate answer: " + err.Error()
return in
}
switch {
case a.Verdict == nec.Unready:
in.Why = "unready: " + a.Refused
return in
case len(a.Input) == 0:
in.Why = "the engine answered no composition input"
if a.Refused != "" {
in.Why += ": " + a.Refused
}
return in
}
body, err := json.Marshal(a.Input)
if err != nil {
in.Why = "its composition input cannot be encoded again: " + err.Error()
return in
}
in.Body = body
return in
}
@@ -0,0 +1,96 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
// Design 47 G A-226: the candidate a step asks is `run-<id>` for a run on request, else `<branch>.<revision>`.
func TestTheCandidateKeyOfAStep(t *testing.T) {
for _, c := range []struct {
run meshwants.StepRun
want string
fail bool
}{
{run: meshwants.StepRun{Run: "r1", Branch: "main"}, want: "run-r1"},
{run: meshwants.StepRun{Branch: "feat/x", Revision: 42}, want: "feat/x.42"},
{run: meshwants.StepRun{Branch: "main"}, fail: true},
} {
got, err := candidateKeyOf(c.run)
if (err != nil) != c.fail || got != c.want {
t.Errorf("%+v: %q, %v; want %q", c.run, got, err, c.want)
}
}
}
// A-252: the machines of a step are the nodes whose placement names its branch; none named is the trunk; an unwatched branch takes
// the trunk's nodes.
func TestTheMachinesOfAStep(t *testing.T) {
placed := map[string]map[string]string{"m": {"ace": "", "g14": "feat", "novox": "main", "shanks": "feat"}, "other": {"ace": ""}}
for _, c := range []struct {
branch string
watched bool
want string
}{
{"main", true, "ace novox"},
{"feat", true, "g14 shanks"},
{"gone", false, "ace novox"},
{"", true, "ace novox"},
} {
if got := strings.Join(machinesFor(placed, "m", c.branch, trunkBranch, c.watched), " "); got != c.want {
t.Errorf("branch %q watched %v: %q, want %q", c.branch, c.watched, got, c.want)
}
}
if got := machinesFor(placed, "none", "main", trunkBranch, true); len(got) != 0 {
t.Errorf("a module placed nowhere has machines %v", got)
}
}
// §354, A-243, A-252, §64: each machine is asked compose-candidate with the step's key and input true, its numbers as written; a
// silent or unready node says why, and a machine away is not asked.
func TestAStepsInputsAreAskedOfEachMachine(t *testing.T) {
// gather asks the machines side by side, so the record of what each was asked is guarded.
var mu sync.Mutex
asked := map[string]string{}
call := func(node, key string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
mu.Lock()
asked[node] = key + " " + string(raw)
mu.Unlock()
switch node {
case "g14":
return nil, errors.New("no responders")
case "novox":
return json.RawMessage(`{"candidate":"run-r1","node":"novox","verdict":"unready","refused":"starting","modules":[],"own":{},"took":1,"v":1}`), nil
}
return json.RawMessage(`{"candidate":"run-r1","node":"` + node + `","verdict":"composed","input":{"n":12345678901234567890,"node":"` + node + `"},"modules":[],"own":{},"took":1,"v":1}`), nil
}
c := &compositionInputs{machines: func(module, branch string) []string { return []string{"ace", "g14", "novox", "shanks"} }, call: call, bound: time.Second}
got, err := c.gather(context.Background(), meshwants.StepRun{Module: "m", Branch: "main", Run: "r1"}, func(node string) bool { return node == "shanks" })
if err != nil || len(got) != 4 {
t.Fatalf("%+v %v", got, err)
}
if want := `seat:node-engine.compose-candidate {"candidate":"run-r1","input":true}`; asked["ace"] != want {
t.Errorf("asked %q, want %q", asked["ace"], want)
}
if got[0].Node != "ace" || got[0].Why != "" || !strings.Contains(string(got[0].Body), "12345678901234567890") {
t.Errorf("ace: %+v", got[0])
}
if !strings.Contains(got[1].Why, "no responders") || !strings.Contains(got[2].Why, "unready") || got[1].Body != nil || got[2].Body != nil {
t.Errorf("g14 and novox: %+v %+v", got[1], got[2])
}
if _, was := asked["shanks"]; was || !got[3].Away {
t.Errorf("the machine away is asked or not said away: %+v", got[3])
}
none := &compositionInputs{machines: func(string, string) []string { return nil }, call: call}
if _, err := none.gather(context.Background(), meshwants.StepRun{Module: "m", Run: "r1"}, nil); err == nil {
t.Error("a step with no machine is gathered")
}
}
@@ -0,0 +1,506 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
meshcatalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/composer"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// The run of a composition step (design 47 G §354, §63, §64; A-226, A-238, A-240, A-243, A-252, A-254, A-261): one
// composer.Request per machine, depth compose, the one machine and one case `change`, its manifests, verdicts and seats from the
// step's catalog revision read through mesh-catalog's `revision`; one run ask per machine of the seat node-build-go, naming no
// head, its program the node-engine build's catalog entry with the composer's package; the run's end heard on the build seat's
// step-ended of that ask, its answer (a composer.Verdict) read inline from `output`. A machine the watchman said away
// (machine-away) is not composed and passes with a warning (§64).
const (
// runAsker is the assigner's name as an asker of a build seat, its seat's own name: the last token of the subject its run
// asks are put on and the first of their step keys (§467, §481, §600, A-163).
runAsker = meshwants.Seat
// changeCase is the one case each request composes.
changeCase = "change"
// inputVerbKey is the verb a run ask's input is served on: the seat mesh-wants' input (§354).
inputVerbKey = "seat:" + meshwants.Seat + "." + meshwants.VerbInput
// entryVerbKey and seatDefinitionsKey are the mesh-catalog seat verbs the step reads.
entryVerbKey = "seat:" + meshcatalog.Seat + "." + meshcatalog.VerbEntry
seatDefinitionsKey = "seat:" + meshcatalog.Seat + "." + meshcatalog.VerbSeatDefinitions
)
// runEnds hands each build seat's step-ended of an ask to the composition run that waits for it (A-240), by the ask's id.
type runEnds struct {
mu sync.Mutex
waits map[string]chan buildstep.StepEnded
}
// expect is the end of the ask id, once heard; called before the ask is put, so no end is missed.
func (r *runEnds) expect(id string) <-chan buildstep.StepEnded {
r.mu.Lock()
defer r.mu.Unlock()
if r.waits == nil {
r.waits = map[string]chan buildstep.StepEnded{}
}
ch := make(chan buildstep.StepEnded, 1)
r.waits[id] = ch
return ch
}
// forget stops waiting for the ask id.
func (r *runEnds) forget(id string) {
r.mu.Lock()
delete(r.waits, id)
r.mu.Unlock()
}
// heard hands an end to the run that waits for its ask; one nobody waits for is another asker's, left.
func (r *runEnds) heard(e buildstep.StepEnded) {
r.mu.Lock()
ch := r.waits[e.Ask]
delete(r.waits, e.Ask)
r.mu.Unlock()
if ch != nil {
ch <- e
}
}
// awayNodes are the machines the watchman said away (machine-away of the seat mesh-watchdogs, §389) and not back since.
type awayNodes struct {
mu sync.Mutex
away map[string]bool
}
// set keeps machine away (true) or back (false).
func (a *awayNodes) set(machine string, away bool) {
a.mu.Lock()
defer a.mu.Unlock()
if a.away == nil {
a.away = map[string]bool{}
}
if away {
a.away[machine] = true
} else {
delete(a.away, machine)
}
}
// is says machine is away.
func (a *awayNodes) is(machine string) bool {
if a == nil {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
return a.away[machine]
}
// compositionRun runs a composition step.
type compositionRun struct {
inputs *compositionInputs
keeper *inputKeeper
// call asks a seat verb held once for the mesh (process.Conn.CallKept).
call func(key string, body any) (json.RawMessage, error)
// trunk is the trunk's catalog half as the assigner holds it.
trunk func() (mcc.Half, bool)
// running is the last `running` heard of a module on a node (world.runningOf), never the node-engine's bucket.
running func(node, module string) (ne.Running, bool)
away *awayNodes
// put puts a run ask on the build seat's check queue under the assigner's name.
put func(ctx context.Context, a buildstep.StepAsk) error
ends *runEnds
// ask asks a seat verb on one node, for the run's answer kept as the build seat's object (buildstep.StepEnded.ReadOutput).
ask buildstep.AskVerb
now func() time.Time
logf func(string, ...any)
}
// machineEnd is what one machine came to.
type machineEnd struct {
failed, couldNot, warning string
}
// run runs one composition step and answers what it came to: failed when a machine does not compose, could not run when a machine
// could not be composed, else passed with a warning per machine away or not judged.
func (r *compositionRun) run(ctx context.Context, _ string, run meshwants.StepRun) stepOutcome {
half, err := r.revision(run)
if err != nil {
return couldNotRun("the step's catalog revision is not read: " + err.Error())
}
seats, err := r.seats(half.Revision.Definitions)
if err != nil {
return couldNotRun(err.Error())
}
manifests, verdicts, err := caseOf(half)
if err != nil {
return couldNotRun(err.Error())
}
programOf, err := r.programs(half)
if err != nil {
return couldNotRun(err.Error())
}
inputs, err := r.inputs.gather(ctx, run, r.away.is)
if err != nil {
return couldNotRun(err.Error())
}
setPhase(ctx, meshwants.StepProgressEventPhaseRun)
ends := make([]machineEnd, len(inputs))
var wg sync.WaitGroup
for i, in := range inputs {
wg.Add(1)
go func() {
defer wg.Done()
ends[i] = r.machine(ctx, in, buildstep.NewID(), programOf, manifests, verdicts, seats)
}()
}
wg.Wait()
setPhase(ctx, meshwants.StepProgressEventPhaseReport)
var out stepOutcome
var failed, couldNot []string
for _, e := range ends {
failed = appendSome(failed, e.failed)
couldNot = appendSome(couldNot, e.couldNot)
out.Warnings = appendSome(out.Warnings, e.warning)
}
switch {
case len(failed) > 0:
out.Outcome, out.Said = meshwants.StepEndedOutcomeFailed, strings.Join(failed, "; ")
case len(couldNot) > 0:
out.Outcome, out.Said = meshwants.StepEndedOutcomeCouldNotRun, strings.Join(couldNot, "; ")
default:
out.Outcome = meshwants.StepEndedOutcomePassed
}
return out
}
func appendSome(to []string, s string) []string {
if s == "" {
return to
}
return append(to, s)
}
func couldNotRun(said string) stepOutcome {
return stepOutcome{Outcome: meshwants.StepEndedOutcomeCouldNotRun, Said: said}
}
// machine composes one machine: its request kept under its ask, the ask put, its end heard and its verdict read.
func (r *compositionRun) machine(ctx context.Context, in nodeInput, ask string, programOf func(node string) (buildstep.RunProgram, error),
manifests map[string]composer.Manifest, verdicts map[string]json.RawMessage, seats []json.RawMessage) machineEnd {
node := in.Node
switch {
case in.Away:
return machineEnd{warning: node + " is away (the watchman's machine-away), so it is not composed"}
case in.Why != "":
return machineEnd{couldNot: node + " gave no composition input: " + in.Why}
}
program, err := programOf(node)
if err != nil {
return machineEnd{couldNot: node + ": " + err.Error()}
}
req := composer.Request{V: composer.Version, Input: in.Body, Depth: composer.DepthCompose, Machines: []string{node},
Cases: []composer.Case{{Name: changeCase, Manifests: manifests, Verdicts: verdicts}}, Seats: seats}
raw, err := json.Marshal(req)
if err != nil {
return machineEnd{couldNot: node + ": its request cannot be encoded: " + err.Error()}
}
if _, err := r.keeper.keep(ask, raw); err != nil {
r.logf("the composition request of %s is refused: %v", node, err)
return machineEnd{couldNot: node + ": its request is not kept: " + err.Error()}
}
ref, err := buildstep.RunInputOf(inputVerbKey, raw)
if err != nil {
return machineEnd{couldNot: node + ": " + err.Error()}
}
a := buildstep.StepAsk{ID: ask, Kind: buildstep.AskCheck, Language: buildstep.AskGo, Asker: runAsker, At: r.now().UTC(),
Run: &buildstep.RunJob{Program: program, Heads: []buildstep.RunHead{}, Input: ref, Reader: "the composition of " + node + " by the node-engine at " + program.Commit}}
ended := r.ends.expect(ask)
defer r.ends.forget(ask)
if err := r.put(ctx, a); err != nil {
return machineEnd{couldNot: node + ": its run ask " + ask + " is not put: " + err.Error()}
}
var e buildstep.StepEnded
select {
case <-ctx.Done():
return machineEnd{couldNot: node + ": the step stopped before its run ask " + ask + " ended"}
case e = <-ended:
}
if e.Outcome != buildstep.OutcomePassed {
return machineEnd{couldNot: fmt.Sprintf("%s: its run ask %s ended %s on %s: %s", node, ask, e.Outcome, e.Node, e.Said)}
}
answer, err := e.ReadOutput(buildstep.AskGo, r.ask) // the program's last line of standard output (A-254)
if err != nil {
return machineEnd{couldNot: node + ": " + err.Error()}
}
return verdictOf(node, answer)
}
// verdictOf reads the composer's verdict of one machine: not composing is failed, naming why; not judged passes with a warning.
func verdictOf(node string, answer []byte) machineEnd {
var v composer.Verdict
if err := json.Unmarshal(answer, &v); err != nil {
return machineEnd{couldNot: node + ": the composer answered no verdict: " + err.Error()}
}
if v.V != composer.Version {
return machineEnd{couldNot: fmt.Sprintf("%s: the composer answered a verdict of version %d, the assigner reads %d", node, v.V, composer.Version)}
}
for _, c := range v.Cases {
if c.Name != changeCase {
continue
}
m, ok := c.Machines[node]
switch {
case !ok:
return machineEnd{couldNot: node + ": the composer's verdict names no such machine"}
case m.NotJudged != "":
return machineEnd{warning: node + " was not judged: " + m.NotJudged}
case !m.Composes:
return machineEnd{failed: node + " does not compose: " + strings.Join(appendSome(append(slices.Clone(m.Problems), m.Refusals...), m.Reason), "; ")}
}
return machineEnd{}
}
return machineEnd{couldNot: node + ": the composer's verdict has no case " + changeCase}
}
// revision reads the step's catalog revision whole, page by page, through mesh-catalog's `revision` (A-252).
func (r *compositionRun) revision(run meshwants.StepRun) (mcc.Half, error) {
if run.Revision <= 0 {
return mcc.Half{}, errors.New("the step names no catalog revision")
}
half := mcc.Half{Entries: map[string]meshcatalog.CatalogEntry{}}
in := meshcatalog.RevisionInput{Branch: keys.EscapeName(run.Branch), Revision: run.Revision}
for {
raw, err := r.call(mcc.RevisionVerb, in)
if err != nil {
return mcc.Half{}, err
}
body, said, ok := buscalls.Unwrap(raw)
if !ok {
return mcc.Half{}, fmt.Errorf("%s answered: %s", mcc.RevisionVerb, said)
}
var a meshcatalog.RevisionAnswer
if err := json.Unmarshal(body, &a); err != nil {
return mcc.Half{}, fmt.Errorf("%s answered no revision: %w", mcc.RevisionVerb, err)
}
if a.NoneYet || a.Revision == nil || a.Revision.Revision != run.Revision {
return mcc.Half{}, fmt.Errorf("%s answered no revision %d of %q", mcc.RevisionVerb, run.Revision, run.Branch)
}
if in.After == "" {
half.Revision = *a.Revision // the first page names what the revision was read from, its definitions included
}
for _, e := range a.Entries {
half.Entries[e.Module] = e
}
if a.Next == "" {
return half, nil
}
in.After = a.Next
}
}
// seats are the definitions of the revision's (seat, digest) pairs, raw (A-261): the revision's definitions carry no definition
// yet (A-234), so each is mesh-catalog's seat-definitions answer for the seat where the trunk's digest is the revision's; a digest
// neither holds is an error naming the seat and the digest.
func (r *compositionRun) seats(defs []meshcatalog.CatalogRevisionDefinitionsItem) ([]json.RawMessage, error) {
out := make([]json.RawMessage, 0, len(defs))
for _, d := range defs {
raw, err := r.call(seatDefinitionsKey, meshcatalog.SeatDefinitionsInput{Seat: d.Seat})
if err != nil {
return nil, fmt.Errorf("the definition of the seat %s is not read: %w", d.Seat, err)
}
body, said, ok := buscalls.Unwrap(raw)
if !ok {
return nil, fmt.Errorf("the definition of the seat %s is not read: %s", d.Seat, said)
}
var a meshcatalog.SeatDefinitionsAnswer
if err := json.Unmarshal(body, &a); err != nil {
return nil, fmt.Errorf("the definition of the seat %s is not read: %w", d.Seat, err)
}
if a.Entry == nil || a.Entry.Digest != d.Digest || a.Entry.Definition == nil {
return nil, fmt.Errorf("no definition of the seat %s with the digest %s is held: neither the revision nor the trunk holds it", d.Seat, d.Digest)
}
def, err := json.Marshal(a.Entry.Definition)
if err != nil {
return nil, err
}
out = append(out, def)
}
return out, nil
}
// caseOf is the case's manifests and verdicts from the revision's entries: each manifest whole, and the verdict of each one not
// accepted (a module without one is accepted).
func caseOf(half mcc.Half) (map[string]composer.Manifest, map[string]json.RawMessage, error) {
manifests := map[string]composer.Manifest{}
verdicts := map[string]json.RawMessage{}
for name, e := range half.Entries {
if len(e.Manifest) == 0 {
continue
}
raw, err := json.Marshal(e.Manifest)
if err != nil {
return nil, nil, fmt.Errorf("the manifest of %s cannot be encoded: %w", name, err)
}
manifests[name] = composer.Manifest{Raw: raw}
if e.Verdict == "" || e.Verdict == mcc.VerdictAccepted {
continue
}
v := meshcatalog.VerdictsByDigestValue{V: 1, Module: name, State: e.Verdict}
if e.Verdict != mcc.VerdictRefused {
v.State = mcc.VerdictManifestWaits
}
if e.Inputs != nil {
v.Problems, v.Unknown, v.Warnings = e.Inputs.Problems, e.Inputs.Unknown, e.Inputs.Warnings
}
raw, err = json.Marshal(v)
if err != nil {
return nil, nil, err
}
verdicts[name] = raw
}
return manifests, verdicts, nil
}
// nodeEngineEntry is the entry of the module that claims the seat node-engine, or false.
func nodeEngineEntry(entries map[string]meshcatalog.CatalogEntry) (meshcatalog.CatalogEntry, bool) {
for _, name := range slices.Sorted(mapsKeys(entries)) {
for _, s := range entries[name].Serves {
if strings.HasPrefix(s, ne.Seat+".") {
return entries[name], true
}
}
}
return meshcatalog.CatalogEntry{}, false
}
func mapsKeys(m map[string]meshcatalog.CatalogEntry) func(func(string) bool) {
return func(yield func(string) bool) {
for k := range m {
if !yield(k) {
return
}
}
}
}
// programOfEntry is the composer built from a node-engine entry: its repository, commit and directory, the composer's package.
func programOfEntry(e meshcatalog.CatalogEntry) (buildstep.RunProgram, error) {
dir := e.Path
if dir == "" {
dir = "."
}
if e.Repository == "" || e.Commit == "" {
return buildstep.RunProgram{}, fmt.Errorf("the node-engine's entry %s names no repository or commit", e.Fp)
}
return buildstep.RunProgram{Repository: e.Repository, Commit: e.Commit, Dir: dir, Package: composer.Package}, nil
}
// programs answers, per machine, the composer its run builds (A-226, A-243, A-252): where the revision's node-engine entry differs
// from the trunk's, that build for every machine; else the build the machine's node-engine runs, as its `running` says, its entry
// read through mesh-catalog's `entry`.
func (r *compositionRun) programs(half mcc.Half) (func(node string) (buildstep.RunProgram, error), error) {
engine, ok := nodeEngineEntry(half.Entries)
if !ok {
return nil, fmt.Errorf("the catalog revision %d holds no module claiming the seat %s", half.Revision.Revision, ne.Seat)
}
if trunk, ok := r.trunk(); !ok || func() bool { t, ok := nodeEngineEntry(trunk.Entries); return !ok || t.Fp != engine.Fp }() {
p, err := programOfEntry(engine)
return func(string) (buildstep.RunProgram, error) { return p, err }, nil
}
var mu sync.Mutex
byFp := map[string]buildstep.RunProgram{}
return func(node string) (buildstep.RunProgram, error) {
running, ok := r.running(node, engine.Module)
if !ok || running.Fingerprint == "" {
return buildstep.RunProgram{}, fmt.Errorf("no build of %s is heard running there", engine.Module)
}
mu.Lock()
p, ok := byFp[running.Fingerprint]
mu.Unlock()
if ok {
return p, nil
}
raw, err := r.call(entryVerbKey, meshcatalog.EntryInput{Fingerprint: running.Fingerprint})
if err != nil {
return buildstep.RunProgram{}, fmt.Errorf("the entry of the node-engine's build %s is not read: %w", running.Fingerprint, err)
}
body, said, ok := buscalls.Unwrap(raw)
if !ok {
return buildstep.RunProgram{}, fmt.Errorf("the entry of the node-engine's build %s is not read: %s", running.Fingerprint, said)
}
var a meshcatalog.EntryAnswer
if err := json.Unmarshal(body, &a); err != nil {
return buildstep.RunProgram{}, fmt.Errorf("the entry of the node-engine's build %s is not read: %w", running.Fingerprint, err)
}
p, err = programOfEntry(a.Entry)
if err != nil {
return p, err
}
mu.Lock()
byFp[running.Fingerprint] = p
mu.Unlock()
return p, nil
}, nil
}
// heardPresence keeps a machine-away (away true) or machine-back of the seat mesh-watchdogs (§389, §436): the machine it names.
func heardPresence(a *awayNodes, away bool, m jetstream.Msg, logf func(string, ...any)) {
var e struct {
Machine string `json:"machine"`
}
if err := json.Unmarshal(m.Data(), &e); err != nil || e.Machine == "" {
logf("%s holds no machine, left: %v", m.Subject(), err)
} else {
a.set(e.Machine, away)
if away {
logf("%s is away (machine-away): a composition step does not compose it until its machine-back", e.Machine)
} else {
logf("%s is back (machine-back): a composition step composes it again", e.Machine)
}
}
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
}
// trunkOf is the trunk's catalog half the assigner's views hold, trunk naming the first watched branch, or false while none is
// held.
func trunkOf(iv *catalogViews, trunk func() string) func() (mcc.Half, bool) {
return func() (mcc.Half, bool) {
if iv == nil || iv.h == nil {
return mcc.Half{}, false
}
h, ok := iv.h.views()[trunk()]
return h, ok
}
}
// runAskPublisher puts a run ask on the Go build seat's check queue under the assigner's name (`accept.check.assigner`, §600), its
// id the message id, so the stream takes it once.
func runAskPublisher(js jetstream.JetStream) func(ctx context.Context, a buildstep.StepAsk) error {
return func(ctx context.Context, a buildstep.StepAsk) error {
raw, err := buildstep.EncodeStepAsk(a)
if err != nil {
return err
}
subject, err := buildstep.AskSubjectBy(a.Language, a.Kind, a.Asker)
if err != nil {
return err
}
_, err = js.Publish(ctx, subject, raw, jetstream.WithMsgID(a.ID))
return err
}
}
@@ -0,0 +1,176 @@
package main
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
meshcatalog "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/composer"
)
const (
commitE1 = "1111111111111111111111111111111111111111"
commitE2 = "2222222222222222222222222222222222222222"
commitE3 = "3333333333333333333333333333333333333333"
)
// runStand is a composition run over stand-ins of the mesh-catalog seat, the machines' engines, the build seat and its step-ended.
type runStand struct {
mu sync.Mutex
asks map[string]buildstep.StepAsk // by node
requests map[string]composer.Request // by node
entries []string // the fingerprints `entry` was asked
r *compositionRun
}
func newRunStand(t *testing.T, revisionEngine, trunkEngine, seatDigest string) *runStand {
s := &runStand{asks: map[string]buildstep.StepAsk{}, requests: map[string]composer.Request{}}
engine := func(fp, commit string) meshcatalog.CatalogEntry {
return meshcatalog.CatalogEntry{Module: "engine", Fp: fp, Repository: "novox/mesh-host", Commit: commit, Path: ".", Serves: []string{ne.Seat + ".composition"},
Manifest: map[string]any{"module": "engine"}}
}
revisionCommit := commitE1
if revisionEngine != "src2:e1" {
revisionCommit = commitE3
}
pages := map[string]meshcatalog.RevisionAnswer{
"": {Revision: &meshcatalog.CatalogRevision{Revision: 7, Branch: "main", Definitions: []meshcatalog.CatalogRevisionDefinitionsItem{{Seat: "s1", Digest: "d1"}}},
Entries: []meshcatalog.CatalogEntry{engine(revisionEngine, revisionCommit), {Module: "bad", Verdict: mcc.VerdictRefused, Inputs: &meshcatalog.EntryInputs{Problems: []string{"no seat x"}},
Manifest: map[string]any{"module": "bad"}}}, Next: "bad"},
"bad": {Revision: &meshcatalog.CatalogRevision{Revision: 7}, Entries: []meshcatalog.CatalogEntry{{Module: "m", Verdict: mcc.VerdictAccepted, Manifest: map[string]any{"module": "m"}}}},
}
call := func(key string, body any) (json.RawMessage, error) {
raw, _ := json.Marshal(body)
switch key {
case mcc.RevisionVerb:
var in meshcatalog.RevisionInput
_ = json.Unmarshal(raw, &in)
if in.Revision != 7 || in.Branch != "main" {
return nil, errors.New("asked another revision")
}
return json.Marshal(pages[in.After])
case seatDefinitionsKey:
return json.Marshal(meshcatalog.SeatDefinitionsAnswer{Found: true, Entry: &meshcatalog.SeatDefinitionsAnswerEntry{Seat: "s1", Digest: seatDigest, Definition: map[string]any{"seat": "s1"}}})
case entryVerbKey:
var in meshcatalog.EntryInput
_ = json.Unmarshal(raw, &in)
s.mu.Lock()
s.entries = append(s.entries, in.Fingerprint)
s.mu.Unlock()
commit := map[string]string{"src2:e1": commitE1, "src2:e2": commitE2}[in.Fingerprint]
return json.Marshal(meshcatalog.EntryAnswer{Entry: engine(in.Fingerprint, commit)})
}
return nil, errors.New("no such verb " + key)
}
engines := func(node, _ string, _ any) (json.RawMessage, error) {
return json.RawMessage(`{"candidate":"main.7","node":"` + node + `","verdict":"composed","input":{"node":"` + node + `"},"modules":[],"own":{},"took":1,"v":1}`), nil
}
running := map[string]string{"ace": "src2:e1", "g14": "src2:e2"}
away := &awayNodes{}
away.set("shanks", true)
keeper := testKeeper(&objectsMem{objs: map[string][]byte{}}, time.Now)
ends := &runEnds{}
s.r = &compositionRun{
inputs: &compositionInputs{machines: func(string, string) []string { return []string{"ace", "g14", "shanks"} }, call: engines, bound: time.Second},
keeper: keeper, call: call, ends: ends, away: away, now: time.Now, logf: t.Logf,
trunk: func() (mcc.Half, bool) {
return mcc.Half{Entries: map[string]meshcatalog.CatalogEntry{"engine": engine(trunkEngine, commitE1)}}, true
},
running: func(node, module string) (ne.Running, bool) {
fp, ok := running[node]
return ne.Running{Node: node, Module: module, Fingerprint: fp}, ok && module == "engine"
},
put: func(_ context.Context, a buildstep.StepAsk) error {
body, err := keeper.body(a.Run.Input.Digest)
if err != nil {
return err
}
req, err := composer.Decode(body)
if err != nil {
return err
}
node := req.Machines[0]
s.mu.Lock()
s.asks[node], s.requests[node] = a, req
s.mu.Unlock()
m := composer.Machine{Composes: true}
if node == "g14" {
m = composer.Machine{Reason: "two modules own one path", Problems: []string{"/etc/x is m's and bad's"}}
}
v, _ := json.Marshal(composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: changeCase, Machines: map[string]composer.Machine{node: m}}}})
go ends.heard(buildstep.StepEnded{Ask: a.ID, Asker: runAsker, Node: "builder1", Outcome: buildstep.OutcomePassed, Output: string(v)})
return nil
},
}
return s
}
// Design 47 G §354, §63, §64, A-226, A-238, A-240, A-243, A-252, A-254, A-261: one request and one run ask per machine, the request
// depth compose with the one machine and one case `change`, its manifests, verdicts and seats from the step's revision; the
// composer is the node-engine build each machine runs, its entry read through `entry`, or the revision's where it differs from the
// trunk's; the run's verdict is read from its step-ended; a machine away passes with a warning, one not composing fails the step.
func TestTheCompositionStepRunsOneAskPerMachine(t *testing.T) {
step := meshwants.StepRun{Module: "m", Branch: "main", Revision: 7, Step: meshwants.StepRunStepComposition}
s := newRunStand(t, "src2:e1", "src2:e1", "d1")
out := s.r.run(context.Background(), "ask-step", step)
if out.Outcome != meshwants.StepEndedOutcomeFailed || !strings.Contains(out.Said, "g14 does not compose") || !strings.Contains(out.Said, "/etc/x") ||
strings.Contains(out.Said, "ace") || len(out.Warnings) != 1 || !strings.Contains(out.Warnings[0], "shanks is away") {
t.Fatalf("the step came to %+v", out)
}
if len(s.asks) != 2 {
t.Fatalf("asked %d runs, want one per machine not away", len(s.asks))
}
a, req := s.asks["ace"], s.requests["ace"]
if a.Kind != buildstep.AskCheck || a.Language != buildstep.AskGo || a.Asker != runAsker || a.Run == nil || len(a.Run.Heads) != 0 ||
a.Run.Input.Verb != "seat:mesh-wants.input" || a.Run.Program != (buildstep.RunProgram{Repository: "novox/mesh-host", Commit: commitE1, Dir: ".", Package: composer.Package}) {
t.Errorf("the run ask of ace %+v %+v", a, a.Run)
}
if s.asks["g14"].Run.Program.Commit != commitE2 {
t.Errorf("g14's composer is built at %s, want the build its node-engine runs", s.asks["g14"].Run.Program.Commit)
}
if req.Depth != composer.DepthCompose || len(req.Machines) != 1 || req.Machines[0] != "ace" || len(req.Cases) != 1 || req.Cases[0].Name != changeCase ||
string(req.Input) != `{"node":"ace"}` || len(req.Seats) != 1 || string(req.Seats[0]) != `{"seat":"s1"}` {
t.Errorf("the request of ace %+v", req)
}
c := req.Cases[0]
if string(c.Manifests["m"].Raw) != `{"module":"m"}` || len(c.Manifests) != 3 || len(c.Verdicts) != 1 || !strings.Contains(string(c.Verdicts["bad"]), `"refused"`) ||
!strings.Contains(string(c.Verdicts["bad"]), "no seat x") {
t.Errorf("the case %+v %s", c.Manifests, c.Verdicts)
}
if strings.Join(s.entries, " ") == "" {
t.Error("no node-engine entry is read through entry")
}
// The revision's node-engine differs from the trunk's: that build composes every machine, and no entry is asked.
changed := newRunStand(t, "src2:e3", "src2:e1", "d1")
if out := changed.r.run(context.Background(), "ask-step", step); out.Outcome != meshwants.StepEndedOutcomeFailed {
t.Fatalf("the step came to %+v", out)
}
if changed.asks["ace"].Run.Program.Commit != commitE3 || changed.asks["g14"].Run.Program.Commit != commitE3 || len(changed.entries) != 0 {
t.Errorf("the change's node-engine does not compose every machine: %+v %+v %v", changed.asks["ace"].Run.Program, changed.asks["g14"].Run.Program, changed.entries)
}
// A seat digest neither the revision nor the trunk holds: could not run, naming the seat and the digest (A-261).
other := newRunStand(t, "src2:e1", "src2:e1", "d2")
if out := other.r.run(context.Background(), "ask-step", step); out.Outcome != meshwants.StepEndedOutcomeCouldNotRun ||
!strings.Contains(out.Said, "s1") || !strings.Contains(out.Said, "d1") || len(other.asks) != 0 {
t.Errorf("a seat digest not held came to %+v, %d asks", out, len(other.asks))
}
// At the inputs state's cap the ask is refused, naming the store (§354).
full := newRunStand(t, "src2:e1", "src2:e1", "d1")
full.r.keeper.used = func() (int64, error) { return inputsCap, nil }
if out := full.r.run(context.Background(), "ask-step", step); out.Outcome != meshwants.StepEndedOutcomeCouldNotRun ||
!strings.Contains(out.Said, "the state inputs is at its cap") || len(full.asks) != 0 {
t.Errorf("an ask at the cap came to %+v, %d asks", out, len(full.asks))
}
}
@@ -0,0 +1,168 @@
package main
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// The composition step (design 47 G §22, §354, §63): the assigner takes the seat mesh-wants' accepted kind `step`, the
// composition step of one module's pipeline that the runner asks, runs it, and says its end as the seat's event
// step-ended.<node>, the ask's id as the event's id and the event's `id`, which the runner holds the end to.
// stepOutcome is what one composition step came to.
type stepOutcome struct {
Outcome string
Said string
Warnings []string
}
// compositionSteps takes the composition steps and says their ends.
type compositionSteps struct {
node string
// say says one event of the seat mesh-wants (wantsPublisher).
say publisher
// run runs one step and answers what it came to.
run func(ctx context.Context, id string, run meshwants.StepRun) stepOutcome
now func() time.Time
logf func(string, ...any)
// asker is the name the step's progress names as its asker, read when it is said (stepAskerOf).
asker func() string
// ticks is a ticker of every d, its channel and its stop (time.NewTicker); nil is the clock's. A test hands its own.
ticks func(d time.Duration) (<-chan time.Time, func())
}
// stepAskerOf is the asker a composition step's progress names: the one caller the seat mesh-wants' definition lists for its
// accepted kind step, read from the seat list when it is said, since that subject names no asker (design 47 G A-294, which
// corrects A-267). Empty while the seat list holds no definition naming exactly one.
func stepAskerOf(definition func() (seatdef.Definition, bool)) func() string {
return func() string {
d, ok := definition()
if !ok {
return ""
}
var one string
for _, m := range d.Accepts {
if m.Name != meshwants.AcceptStep || m.Callers == nil {
continue
}
for _, e := range m.Callers.List {
name, isSeat := strings.CutPrefix(e, seatdef.CallerSeatPrefix)
if !isSeat || (one != "" && one != name) {
return ""
}
one = name
}
}
return one
}
}
// progressEvery is how often a running composition step says its progress (design 47 G §496, A-262).
const progressEvery = time.Minute
// stepPhase is the phase a running step is in: fetch, build, run or report (§496), set by the run through its context.
type stepPhase struct {
mu sync.Mutex
phase string
}
type phaseKey struct{}
// setPhase sets the phase of the step ctx runs, when it runs one.
func setPhase(ctx context.Context, phase string) {
if p, ok := ctx.Value(phaseKey{}).(*stepPhase); ok {
p.mu.Lock()
p.phase = phase
p.mu.Unlock()
}
}
func (p *stepPhase) get() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.phase
}
// take takes one ask of the accepted kind `step` by its id and body: a step that is no composition step, or an ask with no
// id, is left with a log line; one taken is run in its own goroutine and its end said.
func (c *compositionSteps) take(ctx context.Context, id string, data []byte) bool {
var run meshwants.StepRun
if err := json.Unmarshal(data, &run); err != nil || id == "" || run.Step != meshwants.StepRunStepComposition {
c.logf("a step ask that is no composition step with an id is left: %v", err)
return false
}
go c.runOne(ctx, id, run)
return true
}
// runOne runs one step and says its end.
func (c *compositionSteps) runOne(ctx context.Context, id string, run meshwants.StepRun) {
started := c.now().UTC()
phase := &stepPhase{phase: meshwants.StepProgressEventPhaseFetch}
stop := c.sayProgress(id, phase)
o := c.run(context.WithValue(ctx, phaseKey{}, phase), id, run)
stop()
ended := c.now().UTC()
end := meshwants.StepEnded{V: 1, ID: id, Pipeline: run.Pipeline, Step: meshwants.StepEndedStepComposition, Module: run.Module,
Commit: run.Commit, Fingerprint: run.Fingerprint, Node: c.node, Outcome: o.Outcome, Said: o.Said, Warnings: o.Warnings,
Rerun: run.Rerun, By: run.By, Started: &started, Ended: &ended}
if err := c.say(meshwants.EventStepEnded+"."+c.node, end, id); err != nil {
c.logf("saying the end of the composition step %s: %v", id, err)
}
}
// sayProgress says the step's progress on the seat's step-progress.<node> once a minute until the returned stop is called (design
// 47 G §496, A-262): a ticker inside the running step, as the build seats' step runner says its own beat.
func (c *compositionSteps) sayProgress(id string, phase *stepPhase) (stop func()) {
ticks := c.ticks
if ticks == nil {
ticks = func(d time.Duration) (<-chan time.Time, func()) { t := time.NewTicker(d); return t.C, t.Stop }
}
tick, stopTicks := ticks(progressEvery)
done := make(chan struct{})
ended := make(chan struct{})
go func() {
defer close(ended)
defer stopTicks()
for {
select {
case <-done:
return
case <-tick:
at := c.now().UTC()
p := meshwants.StepProgressEvent{Ask: id, Asker: c.asker(), Phase: phase.get(), At: at}
if err := c.say(meshwants.EventStepProgress+"."+c.node, p, id+".progress."+at.Format(time.RFC3339Nano)); err != nil {
c.logf("saying the progress of the composition step %s: %v", id, err)
}
}
}
}()
return func() { close(done); <-ended }
}
// stepAskID is an ask's id as the asker names it: its event id, else its message id.
func stepAskID(m *nats.Msg) string {
if id := m.Header.Get("x-event-id"); id != "" {
return id
}
return m.Header.Get(nats.MsgIdHdr)
}
// serveCompositionSteps hears the composition step asks on the seat mesh-wants' accept subject.
func serveCompositionSteps(ctx context.Context, conn process.Subscriber, c *compositionSteps) error {
sub, err := conn.Subscribe(meshwants.AcceptSubjectStep, func(m *nats.Msg) { c.take(ctx, stepAskID(m), m.Data) })
if err != nil {
return err
}
go func() { <-ctx.Done(); _ = sub.Drain() }()
return nil
}
@@ -0,0 +1,224 @@
//go:build integration
package main
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/composer"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"mesh-delivery-assigner/internal/buckets"
)
// Design 47 G §354, §22, §63, §64, §389, A-240, A-252, A-254: the assigner started whole takes the runner's composition step of a
// module placed on three machines, one of them away: it asks the two others' node-engines for their inputs, puts one run ask per
// machine on the Go build seat's check queue under its name, its program the node-engine build each machine runs; the stand-in
// build seat reads each request by the seat mesh-wants' verb input in chunks and says its step-ended with the composer's verdict;
// the step's step-ended fails naming the machine that does not compose, warns of the machine away, and no input stays kept.
func TestTheCompositionStepComposesEachMachineThroughTheBuildSeat(t *testing.T) {
b := startBed(t, "")
nc, err := nats.Connect(b.srv.ClientURL())
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
const engineCommit = "1111111111111111111111111111111111111111"
// The assigner's durable on EVENTS hears, beside the bed's, the build seat's step-ended and the watchman's machine-away and
// machine-back, as the grants part derives them from its consumes; what each node runs it hears on its kept stream (the bed's).
if _, err := b.js.CreateOrUpdateConsumer(b.ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner",
FilterSubjects: []string{enginereads.SubjectPrefix + "*", "mesh.mod." + buckets.IndexerModule + ".event.views.>", meshcatalog.SubjectCatalogRevisionAll,
nodebuildgo.SubjectStepEndedAll, meshwatchdogs.SubjectMachineAwayAll, meshwatchdogs.SubjectMachineBackAll}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
for _, node := range []string{"n1", "n2"} {
raw, _ := json.Marshal(nodeengine.Running{V: 1, Node: node, Module: "engine", Fingerprint: "src2:e1", At: time.Now().UTC()})
if _, err := b.js.Publish(b.ctx, nodeengine.SubjectRunning(node, "engine"), raw); err != nil {
t.Fatal(err)
}
}
awaitKept(t, b, nodeengine.StreamRunning, 2)
away, _ := json.Marshal(meshwatchdogs.MachineAwayEvent{V: 1, Machine: "n3", At: time.Now().UTC(), LastHeard: time.Now().UTC()})
if _, err := b.js.Publish(b.ctx, meshwatchdogs.SubjectMachineAway("n3"), away); err != nil {
t.Fatal(err)
}
// The trunk's catalog half holds the module and the node-engine, the module claiming the seat node-engine.
engine := meshcatalog.CatalogEntry{Module: "engine", Fp: "src2:e1", Repository: "novox/mesh-host", Commit: engineCommit, Path: ".",
Serves: []string{nodeengine.Seat + ".composition"}, Manifest: map[string]any{"module": "engine", "version": "0.1"}}
b.cat.mu.Lock()
b.cat.entries[trunkBranch] = map[string]meshcatalog.CatalogEntry{"engine": engine,
"m": {Module: "m", Fp: "src2:m1", Repository: "novox/m", Commit: engineCommit, Path: ".", Manifest: map[string]any{"module": "m", "version": "0.1"}}}
b.cat.rev[trunkBranch] = 3
b.cat.mu.Unlock()
for _, node := range []string{"n1", "n2", "n3"} {
b.place(t, node, "m")
}
// The node-engines of n1 and n2 answer compose-candidate with their inputs; n3 is never asked.
asked := make(chan string, 8)
engines, err := nc.Subscribe("mesh.seat.node-engine.call.compose-candidate.*.*", func(m *nats.Msg) {
node := strings.Split(m.Subject, ".")[5]
asked <- node
raw, _ := json.Marshal(map[string]any{"result": map[string]any{"candidate": "main.3", "node": node, "verdict": "composed", "input": map[string]any{"node": node},
"modules": []any{}, "own": map[string]any{}, "took": 1, "v": 1}, "node": node})
_ = m.Respond(raw)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = engines.Unsubscribe() })
// mesh-catalog's entry answers the node-engine build's entry by its fingerprint.
entries, err := nc.Subscribe("mesh.seat.mesh-catalog.call.entry.*", func(m *nats.Msg) {
raw, _ := json.Marshal(map[string]any{"result": meshcatalog.EntryAnswer{Entry: engine}, "node": "n0"})
_ = m.Respond(raw)
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = entries.Unsubscribe() })
subscribed(t, nc)
// The runner hears the step's end.
ended := make(chan meshwants.StepEnded, 1)
ends, err := nc.Subscribe("mesh.seat.mesh-wants.event.step-ended.n1", func(m *nats.Msg) {
var e meshwants.StepEnded
_ = json.Unmarshal(m.Data, &e)
ended <- e
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ends.Unsubscribe() })
subscribed(t, nc)
from := b.journal.mark()
b.start(t)
b.journal.await(t, from, "the composition step is heard on", "the composition step served", 30*time.Second)
b.journal.await(t, from, "n3 is away (machine-away)", "the machine-away of n3 heard", 30*time.Second)
select {
case <-b.seatListRead: // the seat server admits a caller of input once the seat list is read (A-278)
case <-time.After(30 * time.Second):
t.Fatal("the assigner did not read the seat list within 30 s")
}
step, _ := json.Marshal(meshwants.StepRun{Pipeline: "main.3.m", Step: meshwants.StepRunStepComposition, Module: "m", Branch: trunkBranch,
Revision: 3, Commit: engineCommit, Fingerprint: "src2:m1"})
msg := nats.NewMsg(meshwants.AcceptSubjectStep)
msg.Header.Set("x-event-id", "step-ask-1")
msg.Data = step
if err := nc.PublishMsg(msg); err != nil {
t.Fatal(err)
}
// The stand-in Go build seat takes the run asks put under the assigner's name, reads each request by the verb input and says
// its end with the composer's verdict: n1 composes, n2 does not.
queue, _ := buildstep.AskStream(buildstep.AskGo)
// A-163: the assigner asks under its seat's own name.
subject := "mesh.seat.node-build-go.accept.check.mesh-wants"
worker, err := b.js.CreateOrUpdateConsumer(b.ctx, queue, jetstream.ConsumerConfig{Durable: "stand-in", FilterSubject: subject, AckPolicy: jetstream.AckExplicitPolicy})
if err != nil {
t.Fatal(err)
}
programs := map[string]string{}
for range 2 {
got, err := worker.Next(jetstream.FetchMaxWait(30 * time.Second))
if err != nil {
t.Fatalf("no run ask is put: %v", err)
}
_ = got.Ack()
a, err := buildstep.DecodeStepAsk(got.Data())
if err != nil || a.Run == nil {
t.Fatalf("the run ask %s does not read: %v", got.Data(), err)
}
req, err := composer.Decode(readInput(t, nc, a.Run.Input))
if err != nil || len(req.Machines) != 1 {
t.Fatalf("the request of %s: %+v %v", a.ID, req, err)
}
node := req.Machines[0]
programs[node] = a.Run.Program.Commit
m := composer.Machine{Composes: true}
if node == "n2" {
m = composer.Machine{Reason: "two modules own one path", Problems: []string{"/etc/x"}}
}
v, _ := json.Marshal(composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: changeCase, Machines: map[string]composer.Machine{node: m}}}})
end, _ := json.Marshal(buildstep.StepEnded{V: 1, ID: a.ID, Ask: a.ID, Asker: runAsker, Node: "b1", Outcome: buildstep.OutcomePassed, Output: string(v), At: time.Now().UTC()})
if _, err := b.js.Publish(b.ctx, nodebuildgo.SubjectStepEnded("b1"), end); err != nil {
t.Fatal(err)
}
}
var e meshwants.StepEnded
select {
case e = <-ended:
case <-b.ctx.Done():
t.Fatal("the composition step said no end")
}
if e.ID != "step-ask-1" || e.Outcome != meshwants.StepEndedOutcomeFailed || !strings.Contains(e.Said, "n2 does not compose") ||
strings.Contains(e.Said, "n1") || len(e.Warnings) != 1 || !strings.Contains(e.Warnings[0], "n3 is away") {
t.Fatalf("the step ended %+v", e)
}
if programs["n1"] != engineCommit || programs["n2"] != engineCommit {
t.Errorf("the composers were built at %v, want the node-engine build each machine runs", programs)
}
close(asked)
for node := range asked {
if node == "n3" {
t.Error("the machine away was asked for its input")
}
}
keys, _ := b.buckets[buckets.Inputs()].Keys(b.ctx)
if len(keys) != 0 {
t.Errorf("the inputs stay kept past their step-ended: %v", keys)
}
}
// readInput reads a run's input as a build seat does: the seat mesh-wants' verb input answers its digest and chunk count, then its
// chunks on the call's subjects, each acknowledged.
func readInput(t *testing.T, nc *nats.Conn, in buildstep.RunInput) []byte {
t.Helper()
call := "standin" + strings.Repeat("0", 16)
chunks := buscalls.SeatChunks{Seat: meshwants.Seat, Verb: meshwants.VerbInput, Call: call}
sub, err := nc.SubscribeSync(chunks.Chunks())
if err != nil {
t.Fatal(err)
}
defer sub.Unsubscribe()
body, _ := json.Marshal(meshwants.InputInput{Digest: in.Digest, Call: call})
// On the call's subject naming its caller, the stand-in build seat's account: the seat server admits no unproven caller (A-179).
reply, err := nc.Request("mesh.seat.mesh-wants.call.input.n1~mesh-build-go", body, 10*time.Second)
if err != nil {
t.Fatal(err)
}
raw, said, ok := buscalls.Unwrap(reply.Data)
var a meshwants.InputAnswer
if !ok || json.Unmarshal(raw, &a) != nil || a.Chunks < 1 {
t.Fatalf("the verb input answered %s: %s", reply.Data, said)
}
var out bytes.Buffer
next := func() (string, []byte, error) {
m, err := sub.NextMsg(buscalls.SeatAckWithin)
if err != nil {
return "", nil, err
}
return m.Subject, m.Data, nil
}
ack := func(subject string) error { _, err := nc.Request(subject, nil, 10*time.Second); return err }
if _, err := chunks.Receive(a.Chunks, next, ack, &out); err != nil {
t.Fatal(err)
}
if err := in.Verify(out.Bytes()); err != nil {
t.Fatal(err)
}
return out.Bytes()
}
@@ -0,0 +1,137 @@
package main
import (
"context"
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"sync"
"testing"
"time"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
)
// saidEvents keeps what a publisher says.
type saidEvents struct {
mu sync.Mutex
said map[string]any
ids map[string]string
heard chan string
}
func newSaidEvents() *saidEvents {
return &saidEvents{said: map[string]any{}, ids: map[string]string{}, heard: make(chan string, 8)}
}
func (s *saidEvents) say(event string, body any, id string) error {
s.mu.Lock()
s.said[event], s.ids[event] = body, id
s.mu.Unlock()
s.heard <- event
return nil
}
// AS5 (§22, §354): the assigner takes mesh-wants' accepted kind `step` for the composition step and says the seat's
// step-ended.<node> with the ask's id, what the step was asked at and what it came to; an ask of another step is left.
func TestTheCompositionStepIsTakenAndItsEndSaid(t *testing.T) {
said := newSaidEvents()
at := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
var asked meshwants.StepRun
c := &compositionSteps{node: "n1", say: said.say, now: func() time.Time { return at }, logf: t.Logf,
run: func(_ context.Context, id string, run meshwants.StepRun) stepOutcome {
asked = run
return stepOutcome{Outcome: meshwants.StepEndedOutcomeFailed, Said: "a and b own /etc/x", Warnings: []string{"n3 asleep"}}
}}
other, _ := json.Marshal(map[string]any{"pipeline": "main.4.m", "step": "build", "module": "m"})
if c.take(context.Background(), "ask-0", other) {
t.Fatal("a step ask of another step is taken")
}
body, _ := json.Marshal(meshwants.StepRun{Pipeline: "main.4.m", Step: meshwants.StepRunStepComposition, Module: "m", Branch: "main",
Commit: "c0ffee", Revision: 4, Fingerprint: "fp1", Rerun: true, By: "lead@n1"})
if c.take(context.Background(), "", body) {
t.Fatal("a step ask with no id is taken")
}
if !c.take(context.Background(), "ask-1", body) {
t.Fatal("the composition step is not taken")
}
select {
case <-said.heard:
case <-time.After(5 * time.Second):
t.Fatal("no step-ended is said")
}
event := meshwants.EventStepEnded + ".n1"
end, ok := said.said[event].(meshwants.StepEnded)
if !ok || said.ids[event] != "ask-1" {
t.Fatalf("said %v under %v, want step-ended.n1 under the ask's id", said.said, said.ids)
}
if asked.Module != "m" || end.ID != "ask-1" || end.Pipeline != "main.4.m" || end.Step != meshwants.StepEndedStepComposition ||
end.Module != "m" || end.Commit != "c0ffee" || end.Fingerprint != "fp1" || end.Node != "n1" ||
end.Outcome != meshwants.StepEndedOutcomeFailed || end.Said != "a and b own /etc/x" || len(end.Warnings) != 1 ||
!end.Rerun || end.By != "lead@n1" || end.Started == nil || end.Ended == nil {
t.Fatalf("step-ended %+v", end)
}
}
// Design 47 G §496, A-262: a running composition step says step-progress.<node> on every tick of its ticker, with the ask, its
// asker, the phase the run is in and the time, and says none once it has ended.
func TestARunningCompositionStepSaysItsProgress(t *testing.T) {
said := newSaidEvents()
tick := make(chan time.Time)
stopped := make(chan struct{})
at := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
release := make(chan struct{})
// A-294: the asker is the one caller mesh-wants' definition lists for step, read from the seat list; none while unread
// or while it lists more than one.
runner := seatdef.Definition{Seat: meshwants.Seat, Accepts: []seatdef.Message{{Name: meshwants.AcceptStep,
Callers: &seatdef.Callers{List: []string{"seat:mesh-pipeline-runner"}}}}}
two := seatdef.Definition{Seat: meshwants.Seat, Accepts: []seatdef.Message{{Name: meshwants.AcceptStep,
Callers: &seatdef.Callers{List: []string{"seat:mesh-pipeline-runner", "seat:mesh-catalog"}}}}}
for _, d := range []struct {
def seatdef.Definition
read bool
want string
}{{runner, true, "mesh-pipeline-runner"}, {runner, false, ""}, {two, true, ""}} {
if got := stepAskerOf(func() (seatdef.Definition, bool) { return d.def, d.read })(); got != d.want {
t.Errorf("the asker of %+v (read %v) = %q, want %q", d.def.Accepts, d.read, got, d.want)
}
}
c := &compositionSteps{node: "n1", say: said.say, now: func() time.Time { return at }, logf: t.Logf,
asker: stepAskerOf(func() (seatdef.Definition, bool) { return runner, true }),
ticks: func(d time.Duration) (<-chan time.Time, func()) {
if d != time.Minute {
t.Errorf("the progress ticks every %s, want a minute", d)
}
return tick, func() { close(stopped) }
},
run: func(ctx context.Context, _ string, _ meshwants.StepRun) stepOutcome {
setPhase(ctx, meshwants.StepProgressEventPhaseRun)
<-release
return stepOutcome{Outcome: meshwants.StepEndedOutcomePassed}
}}
body, _ := json.Marshal(meshwants.StepRun{Pipeline: "main.4.m", Step: meshwants.StepRunStepComposition, Module: "m", Branch: "main"})
if !c.take(context.Background(), "ask-1", body) {
t.Fatal("the composition step is not taken")
}
progress := meshwants.EventStepProgress + ".n1"
for range 2 {
tick <- at
if e := <-said.heard; e != progress {
t.Fatalf("said %s on a tick, want %s", e, progress)
}
}
said.mu.Lock()
p, ok := said.said[progress].(meshwants.StepProgressEvent)
said.mu.Unlock()
if !ok || p.Ask != "ask-1" || p.Asker != "mesh-pipeline-runner" || p.Phase != meshwants.StepProgressEventPhaseRun || !p.At.Equal(at) {
t.Fatalf("step-progress %+v", p)
}
close(release)
if e := <-said.heard; e != meshwants.EventStepEnded+".n1" {
t.Fatalf("said %s at the end, want step-ended", e)
}
select {
case <-stopped:
default:
t.Fatal("the ticker is not stopped when the step has ended")
}
}
@@ -0,0 +1,451 @@
package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
wd "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
// A delivery is waited for mesh-wide (design 47 G §376, §389, A-189, A-246). At its start the assigner reads whole, from their first
// message, the streams of the catalog seat's version-released and of its own version-delivered (mesh-sdk process.FromFirst, A-233),
// and the watchman's machine-away and machine-back, never its state; then it hears them. A released version of a module is said
// version-delivered on the seat mesh-wants once, when every present machine placing it on the branch it is released from runs, healthy,
// the want the assigner wrote naming that version; a version no present machine places is not delivered. A dependent whose want
// changes fingerprint waits, by an edge of kind 2, 4 or 6, for its dependency's newest released version not yet said delivered, on
// the branch it is released from; on another branch no version is released and nothing waits. The wait is said wait-held, step
// delivery, bound 60 minutes from the version's release, kept in the state delivery-waits under the dependent's name, and said
// wait-ended when the version is delivered (ran) or released by hand (assigner_release, released).
// deliveryWaitBound is a dependent's delivery wait's bound, from its dependency's version-released.
const deliveryWaitBound = 60 * time.Minute
// Wait-ended's how of a delivery wait.
const (
deliveryWaitRan = "ran"
deliveryWaitReleased = "released"
)
// releaseHeard is one version-released heard: the version, each part's digest, the source revision and when it was said.
type releaseHeard struct {
Version string
Parts map[string]string
Source int64
At time.Time
}
// newestOf is the newest version of module released, the last heard of it in the stream's order.
func (r *releasedVersions) newestOf(module string) (releaseHeard, bool) {
r.mu.Lock()
defer r.mu.Unlock()
h, ok := r.newest[module]
return h, ok
}
// deliveredVersions are the version-delivered said, the assigner's own, by module and version: each is said once.
type deliveredVersions struct {
mu sync.Mutex
said map[string]bool
}
func (d *deliveredVersions) key(module, version string) string { return module + "@" + version }
// put takes one version-delivered in.
func (d *deliveredVersions) put(data []byte) error {
var e meshwants.VersionDeliveredEvent
if err := json.Unmarshal(data, &e); err != nil {
return fmt.Errorf("a version-delivered that is none: %w", err)
}
if e.Module == "" || e.Version == "" {
return fmt.Errorf("a version-delivered naming no module or version: %s", data)
}
d.mark(e.Module, e.Version)
return nil
}
func (d *deliveredVersions) mark(module, version string) {
d.mu.Lock()
defer d.mu.Unlock()
if d.said == nil {
d.said = map[string]bool{}
}
d.said[d.key(module, version)] = true
}
// has says whether version-delivered of module's version was said.
func (d *deliveredVersions) has(module, version string) bool {
d.mu.Lock()
defer d.mu.Unlock()
return d.said[d.key(module, version)]
}
// presenceOf reads one machine-away or machine-back: the machine and when it was said.
func presenceOf(data []byte) (string, time.Time, error) {
var e wd.MachineAwayEvent // machine-back has the same fields
if err := json.Unmarshal(data, &e); err != nil {
return "", time.Time{}, err
}
if e.Machine == "" {
return "", time.Time{}, fmt.Errorf("it names no machine: %s", data)
}
return e.Machine, e.At, nil
}
// hearDelivery reads whole, then follows, the four streams a delivery is judged from: version-released, version-delivered,
// machine-away and machine-back. A stream not heard is said as the assigner's finding, and what it carries is not known until
// the assigner starts again.
func hearDelivery(ctx context.Context, js jetstream.JetStream, w *world, wake func(), findings assign.Findings, logf func(string, ...any)) {
acct := process.Account{Node: thisNode, Module: assignment.Module()}
nh := notHeardWriter(findings, logf)
for _, s := range []struct {
stream, words string
take func([]byte) error
}{
{meshcatalog.StreamVersionReleased, "no want names a released version and no delivery is judged", w.released.put},
{meshwants.StreamVersionDelivered, "a version said delivered before is said again once it is judged", w.delivered.put},
{wd.StreamMachineAway, "no machine is known away, so a delivery waits for a machine that is away", func(data []byte) error {
m, at, err := presenceOf(data)
if err == nil {
w.heardPresence(m, true, at)
}
return err
}},
{wd.StreamMachineBack, "a machine once away is not known back", func(data []byte) error {
m, at, err := presenceOf(data)
if err == nil {
w.heardPresence(m, false, at)
}
return err
}},
} {
take := s.take
cc, err := acct.HearSeatEvents(ctx, js, s.stream, func(m jetstream.Msg) {
if err := take(m.Data()); err != nil {
logf("%s is not kept: %v", m.Subject(), err)
}
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
wake()
}, logf, process.FromFirst())
if err != nil {
nh.NotHeard(s.stream, s.words+" until the assigner starts again", err)
continue
}
nh.Heard(s.stream)
go func() {
<-ctx.Done()
cc.Stop()
}()
}
}
// releaseBranch is the branch module's versions are released from: the first watched branch of its registration (§371).
func (p *passer) releaseBranch(module string) string {
if p.ends.following() {
return p.ends.branchOf(module, "")
}
return p.w.trunkName()
}
// placing is every machine placing module on the branch its versions are released from, sorted.
func (p *passer) placing(module string) []string {
branch := p.releaseBranch(module)
var out []string
for node, placed := range p.w.placedBranches()[module] {
if p.ends.following() {
placed = p.ends.branchOf(module, placed)
} else {
placed = p.w.trunkName() // the trunk-only form: every placement runs the trunk's
}
if placed == branch {
out = append(out, node)
}
}
sort.Strings(out)
return out
}
// deliveryStanding is where module's version stands: the present placing machines not yet running it healthy as its want names
// it, and the placing machines away.
func (p *passer) deliveryStanding(module, version string) (notRunning, away []string, placing int) {
gone := p.w.awayNow()
n := assign.Nodes{Running: p.w.runningNow()}
wanted := p.w.wantsOf()[module]
nodes := p.placing(module)
for _, node := range nodes {
if gone[node] {
away = append(away, node)
continue
}
want, ok := wanted[node]
if !ok || want.Removed || want.Version != version || !n.RunsHealthy(node, module, want.Fp) {
notRunning = append(notRunning, node)
}
}
return notRunning, away, len(nodes)
}
// judgeDelivered says version-delivered of each module's newest released version not said yet, once every present machine placing
// it runs it healthy; judged again at each pass, which each running, machine-away, machine-back and placement change wakes.
func (p *passer) judgeDelivered() {
for _, module := range p.releasedModules() {
rel, _ := p.w.released.newestOf(module)
if p.w.delivered.has(module, rel.Version) {
continue
}
notRunning, away, placing := p.deliveryStanding(module, rel.Version)
if placing == 0 || len(away) == placing || len(notRunning) > 0 {
continue
}
if err := p.sayDelivered(module, rel, away); err != nil {
p.logf("version-delivered of %s %s is not said, and is judged again at the next pass: %v", module, rel.Version, err)
}
}
}
// sayDelivered says version-delivered of module's release rel, with the machines away, and keeps it said.
func (p *passer) sayDelivered(module string, rel releaseHeard, away []string) error {
at := p.clock().UTC()
body := meshwants.VersionDeliveredEvent{V: keys.Version, Module: module, Version: rel.Version, Parts: rel.Parts, Away: away, At: &at}
if err := p.wantsSay(meshwants.EventVersionDelivered, body, fmt.Sprintf("%s.%s.%s.%s", meshwants.EventVersionDelivered, module, rel.Version, at.Format(time.RFC3339Nano))); err != nil {
return err
}
p.w.delivered.mark(module, rel.Version)
return nil
}
// releasedModules is every module a version-released was heard of, sorted.
func (p *passer) releasedModules() []string {
r := &p.w.released
r.mu.Lock()
defer r.mu.Unlock()
out := make([]string, 0, len(r.newest))
for m := range r.newest {
out = append(out, m)
}
sort.Strings(out)
return out
}
// deliveryWait is what a dependent waits for: its dependency, the version and the machines not yet running it.
type deliveryWait struct {
dependency string
rel releaseHeard
notRunning []string
away []string
}
// waitsForDelivery says whether module, whose want on its nodes changes to fp on branch, waits for a dependency's delivery: by
// each edge of kind 2, 4 or 6 into it in branch's catalog half, the dependency's newest released version on branch not yet said
// delivered. A want that changes no fingerprint waits for nothing; on a branch no version is released from, nothing waits.
func (p *passer) waitsForDelivery(module, fp, branch string, nodes []string) (deliveryWait, bool) {
wanted := p.w.wantsOf()[module]
changes := false
for _, node := range nodes {
if w, ok := wanted[node]; !ok || w.Removed || w.Fp != fp {
changes = true
}
}
if !changes || fp == "" {
return deliveryWait{}, false
}
var deps []string
for _, e := range p.w.edgesOf(branch, module) {
if e.Kind != 2 && e.Kind != 4 && e.Kind != 6 {
continue
}
dep, _, _ := strings.Cut(e.From, ":")
if dep != "" && dep != module && !slices.Contains(deps, dep) {
deps = append(deps, dep)
}
}
sort.Strings(deps)
for _, dep := range deps {
if p.releaseBranch(dep) != branch {
continue
}
rel, ok := p.w.released.newestOf(dep)
if !ok || p.w.delivered.has(dep, rel.Version) {
continue
}
notRunning, away, _ := p.deliveryStanding(dep, rel.Version)
return deliveryWait{dependency: dep, rel: rel, notRunning: notRunning, away: away}, true
}
return deliveryWait{}, false
}
// words is a delivery wait in plain words, as assigner_why_not and the status view say it.
func (d deliveryWait) words() string {
if len(d.notRunning) == 0 && len(d.away) > 0 {
return fmt.Sprintf("waiting for %s to be back", strings.Join(d.away, ", "))
}
return fmt.Sprintf("waiting for delivery of %s %s on %s", d.dependency, d.rel.Version, strings.Join(d.notRunning, ", "))
}
// deliveryWaitBody is the wait-held of module's delivery wait d.
func deliveryWaitBody(module, fp string, d deliveryWait, since time.Time) meshwants.Wait {
from := d.rel.At.UTC()
if from.IsZero() {
from = since.UTC()
}
return meshwants.Wait{Step: meshwants.WaitStepDelivery, Owner: meshwants.Seat, Module: module, Version: fp, Part: d.dependency,
On: d.dependency + "@" + d.rel.Version, Machines: d.notRunning, Since: since.UTC(), ClockFrom: &from, Bound: deliveryWaitBound.String(),
Words: d.words()}
}
// deliveryWaiting is one delivery wait the state delivery-waits keeps, by its dependent's name: the wait-held said, and a release
// by hand written by assigner_release.
type deliveryWaiting struct {
meshwants.Wait
// Released is set by assigner_release: the wait ends released and the dependent's want is written.
Released bool `json:"released,omitempty"`
}
// saidDeliveryWaits is every delivery wait the state delivery-waits holds, by module; a value that does not decode is said and left.
func (p *passer) saidDeliveryWaits() map[string]deliveryWaiting {
out := map[string]deliveryWaiting{}
for key, value := range p.w.read(buckets.DeliveryWaits()) {
var w deliveryWaiting
if err := json.Unmarshal(value, &w); err != nil || w.Module == "" {
p.logf("%s %s holds no delivery wait, left: %v", buckets.DeliveryWaits(), key, err)
continue
}
out[w.Module] = w
}
return out
}
// holdForDelivery says whether module's want, changing to fp on branch, is held for a dependency's delivery this pass: it says
// wait-held once and keeps it; a wait released by hand holds nothing; a wait that no longer holds is ended ran.
func (p *passer) holdForDelivery(ctx context.Context, module, fp, branch string, nodes []string) bool {
if p.kvs[buckets.DeliveryWaits()] == nil {
return false
}
key := keys.EscapeName(module)
said, was := p.saidDeliveryWaits()[module]
d, waits := p.waitsForDelivery(module, fp, branch, nodes)
switch {
case was && said.Released:
p.endDeliveryWait(ctx, key, said, deliveryWaitReleased) // the want is written now, so it changes nothing at the next pass
return false
case !waits:
if was {
p.endDeliveryWait(ctx, key, said, deliveryWaitRan)
}
return false
}
body := deliveryWaitBody(module, fp, d, p.clock())
if was && said.On == body.On && said.Version == fp && slices.Equal(said.Machines, body.Machines) {
return true
}
if was {
body.Since = said.Since
}
id := fmt.Sprintf("%s.delivery.%s.%s.%s", key, fp, body.On, strings.Join(body.Machines, ","))
if err := p.wantsSay(meshwants.EventWaitHeld, body, id); err != nil {
p.logf("the delivery wait of %s is not said, and is said at the next pass: %v", module, err)
return true
}
kept := deliveryWaiting{Wait: body}
p.put(ctx, buckets.DeliveryWaits(), key, func() ([]byte, error) { return json.Marshal(kept) })
return true
}
// endDeliveryWait says wait-ended of a delivery wait with how and removes it from the state.
func (p *passer) endDeliveryWait(ctx context.Context, key string, w deliveryWaiting, how string) {
body := w.Wait
body.How = how
if err := p.wantsSay(meshwants.EventWaitEnded, body, key+".delivery."+body.Version+"."+how); err != nil {
p.logf("the end of the delivery wait of %s is not said, and is said at the next pass: %v", w.Module, err)
return
}
p.remove(ctx, buckets.DeliveryWaits(), key)
}
// againAsk is one ask of assigner_delivered_again, kept in the state delivered-again under its module (A-250): the version to say
// delivered again, who asked and why, and, while it does not hold, why not.
type againAsk struct {
Module string `json:"module"`
Version string `json:"version"`
By string `json:"by"`
Why string `json:"why"`
Waits string `json:"waits,omitempty"`
}
// releaseOf is the release heard of module's version.
func (r *releasedVersions) releaseOf(module, version string) (releaseHeard, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if h, ok := r.newest[module]; ok && h.Version == version {
return h, true
}
for key, k := range r.seen {
if strings.HasPrefix(key, module+"@") && k.Version == version {
return releaseHeard{Version: version, Parts: k.Parts}, true
}
}
return releaseHeard{}, false
}
// deliveredAgain takes each ask of assigner_delivered_again, heard at once through the watch of the state delivered-again: it says
// version-delivered again when every present machine placing the module runs that version healthy and removes the ask, else keeps
// it with why it does not hold, which assigner_waits reads.
func (p *passer) deliveredAgain(ctx context.Context) {
if p.kvs[buckets.DeliveredAgain()] == nil {
return
}
for key, value := range p.w.read(buckets.DeliveredAgain()) {
var a againAsk
if err := json.Unmarshal(value, &a); err != nil || a.Version == "" {
p.logf("%s %s holds no ask, left: %v", buckets.DeliveredAgain(), key, err)
continue
}
module := a.Module
if module == "" {
module = key
}
waits := ""
rel, ok := p.w.released.releaseOf(module, a.Version)
notRunning, away, placing := p.deliveryStanding(module, a.Version)
switch {
case !ok:
waits = fmt.Sprintf("no version-released of %s %s is heard", module, a.Version)
case placing == 0:
waits = fmt.Sprintf("no machine places %s", module)
case len(away) == placing:
waits = fmt.Sprintf("every machine placing %s is away: %s", module, strings.Join(away, ", "))
case len(notRunning) > 0:
waits = fmt.Sprintf("%s %s does not run healthy on %s", module, a.Version, strings.Join(notRunning, ", "))
}
if waits == "" {
if err := p.sayDelivered(module, rel, away); err != nil {
p.logf("version-delivered of %s %s asked again is not said, and is asked at the next pass: %v", module, a.Version, err)
continue
}
p.logf("version-delivered of %s %s said again, asked by %s: %s", module, a.Version, a.By, a.Why)
p.remove(ctx, buckets.DeliveredAgain(), key)
continue
}
if a.Waits != waits {
a.Waits = waits
p.put(ctx, buckets.DeliveredAgain(), key, func() ([]byte, error) { return json.Marshal(a) })
}
}
}
@@ -0,0 +1,188 @@
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/buckets"
)
// Design 47 G §376, §389, §498, A-189, A-246: a released version is said version-delivered once, when every present machine placing
// it runs healthy the want naming it, a machine away named and not waited for, none when every placing machine is away; a dependent
// whose want changes fingerprint waits by an edge of kind 2, 4 or 6 for its dependency's newest released version not yet said
// delivered, said wait-held (step delivery, 60 minutes from the release) once and kept in delivery-waits, and wait-ended ran once
// the version is delivered; a kind-1 edge, or a dependency with no version released, waits for nothing; one released by hand ends
// released and its want is written.
func TestADependentWaitsForItsDependencysDelivery(t *testing.T) {
ctx := context.Background()
at := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
waits := &memKV{values: map[string][]byte{}}
again := &memKV{values: map[string][]byte{}}
p := newPasser(kvSet{buckets.Wants(): &memKV{values: map[string][]byte{}}, buckets.DeliveryWaits(): waits, buckets.DeliveredAgain(): again}, at)
p.logf = t.Logf
var events []said
p.publishWants = func(event string, body any, id string) error {
raw, _ := json.Marshal(body)
events = append(events, said{event, id, raw})
return nil
}
p.w.runningHeard = true
placeOn(t, p.w, "ace", "db", "app")
placeOn(t, p.w, "g14", "db")
fp := func(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
want := func(node, module, fp, version string) {
key, _ := mwc.WantKey(node, module)
raw, err := mwc.EncodeWant(mwc.Want{Fp: fp, Version: version, Self: true})
if err != nil {
t.Fatal(err)
}
p.w.put(buckets.Wants(), key, raw)
}
runs := func(node, module, fp string) {
raw, _ := json.Marshal(map[string]any{"v": 1, "node": node, "module": module, "fingerprint": fp, "at": at})
if err := p.w.hearRunning("running", raw); err != nil {
t.Fatal(err)
}
}
source := int64(3)
release := func(module, version string) {
source++
raw, _ := json.Marshal(meshcatalog.VersionReleasedEvent{V: 1, Module: module, Version: version, Source: source, At: &at,
Artifacts: []meshcatalog.VersionArtifact{{Artifact: "program", Digest: "sha256:d"}}})
if err := p.w.released.put(raw); err != nil {
t.Fatal(err)
}
}
saidOf := func(event string) []said {
var out []said
for _, e := range events {
if e.event == event {
out = append(out, e)
}
}
return out
}
want("ace", "app", fp("1"), "")
// No version of db is released: app's new fingerprint waits for nothing.
p.w.putEdges(trunkBranch, "app", []meshcatalog.Edge{{Kind: 4, From: "db:program", To: "app:program"}})
if p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) {
t.Fatal("app waits with no version of db released")
}
release("db", "1.1.0")
want("ace", "db", fp("d"), "1.1.0")
want("g14", "db", fp("d"), "1.1.0")
runs("ace", "db", fp("d"))
// A kind-1 edge orders nothing.
p.w.putEdges(trunkBranch, "app", []meshcatalog.Edge{{Kind: 1, From: "db:program", To: "app:program"}})
if p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) {
t.Fatal("a kind-1 edge holds app")
}
p.w.putEdges(trunkBranch, "app", []meshcatalog.Edge{{Kind: 4, From: "db:program", To: "app:program"}})
// app's want unchanged waits for nothing.
if p.holdForDelivery(ctx, "app", fp("1"), trunkBranch, []string{"ace"}) {
t.Fatal("app's unchanged want waits")
}
// g14 places db and does not run 1.1.0 yet: not delivered, and app's new version waits, said once.
p.judgeDelivered()
if !p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) || !p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) {
t.Fatal("app does not wait for db 1.1.0")
}
if len(saidOf(meshwants.EventVersionDelivered)) != 0 {
t.Fatalf("db is said delivered while g14 does not run it: %+v", events)
}
held := saidOf(meshwants.EventWaitHeld)
if len(held) != 1 {
t.Fatalf("wait-held said %d times, want once", len(held))
}
var w meshwants.Wait
_ = json.Unmarshal(held[0].body, &w)
if w.Step != meshwants.WaitStepDelivery || w.Part != "db" || w.Module != "app" || w.Version != fp("2") || w.Bound != "1h0m0s" ||
w.ClockFrom == nil || !w.ClockFrom.Equal(at) || len(w.Machines) != 1 || w.Machines[0] != "g14" || w.Words != "waiting for delivery of db 1.1.0 on g14" {
t.Errorf("the wait said is %+v", w)
}
if _, ok := waits.values["app"]; !ok {
t.Errorf("the wait is not kept in delivery-waits: %v", waits.values)
}
// g14 away: every present machine runs it, so it is delivered, g14 named away; once only.
p.w.heardPresence("g14", true, at)
p.judgeDelivered()
p.judgeDelivered()
delivered := saidOf(meshwants.EventVersionDelivered)
var d meshwants.VersionDeliveredEvent
if len(delivered) != 1 || json.Unmarshal(delivered[0].body, &d) != nil || d.Module != "db" || d.Version != "1.1.0" ||
len(d.Away) != 1 || d.Away[0] != "g14" || d.Parts["db:program"] != "sha256:d" {
t.Fatalf("version-delivered said %+v", delivered)
}
// The wait ends ran, and app's want goes on.
if p.holdForDelivery(ctx, "app", fp("2"), trunkBranch, []string{"ace"}) {
t.Fatal("app still waits once db 1.1.0 is delivered")
}
ended := saidOf(meshwants.EventWaitEnded)
if len(ended) != 1 || json.Unmarshal(ended[0].body, &w) != nil || w.How != deliveryWaitRan {
t.Errorf("wait-ended said %+v", ended)
}
if _, ok := waits.values["app"]; ok {
t.Error("the ended wait is kept")
}
// A newer release with every placing machine away is not delivered, and its wait says the machines it waits to be back.
release("db", "1.2.0")
p.w.heardPresence("ace", true, at)
p.judgeDelivered()
if len(saidOf(meshwants.EventVersionDelivered)) != 1 {
t.Error("a version is said delivered while every placing machine is away")
}
if !p.holdForDelivery(ctx, "app", fp("3"), trunkBranch, []string{"ace"}) {
t.Fatal("app does not wait for db 1.2.0")
}
held = saidOf(meshwants.EventWaitHeld)
if json.Unmarshal(held[len(held)-1].body, &w) != nil || w.Words != "waiting for ace, g14 to be back" {
t.Errorf("the wait while every machine is away says %q", w.Words)
}
// Released by hand (assigner_release writes it): the wait ends released and app's want is written.
var kept deliveryWaiting
_ = json.Unmarshal(waits.values["app"], &kept)
kept.Released = true
raw, _ := json.Marshal(kept)
p.w.put(buckets.DeliveryWaits(), "app", raw)
if p.holdForDelivery(ctx, "app", fp("3"), trunkBranch, []string{"ace"}) {
t.Fatal("a released wait holds app")
}
ended = saidOf(meshwants.EventWaitEnded)
if json.Unmarshal(ended[len(ended)-1].body, &w) != nil || w.How != deliveryWaitReleased {
t.Errorf("the released wait ended %+v", w)
}
// Presence: the later of machine-away and machine-back holds, whichever stream is read first.
p.w.heardPresence("ace", false, at.Add(time.Minute))
p.w.heardPresence("ace", true, at)
if p.w.awayNow()["ace"] || !p.w.online()["ace"] {
t.Error("an older machine-away read after a newer machine-back leaves ace away")
}
// A-250: an ask of assigner_delivered_again of 1.1.0, which ace (present) runs and g14 (away) does not, is said again and taken
// away; one of 1.2.0, which no machine runs, is kept with why.
ask := func(version string) {
raw, _ := json.Marshal(againAsk{Module: "db", Version: version, By: "op", Why: "the catalog missed it"})
p.w.put(buckets.DeliveredAgain(), "db", raw)
}
before := len(saidOf(meshwants.EventVersionDelivered))
ask("1.1.0")
p.deliveredAgain(ctx)
if len(saidOf(meshwants.EventVersionDelivered)) != before+1 || p.w.has(buckets.DeliveredAgain(), "db") {
t.Errorf("the ask of 1.1.0 said %d version-delivered and is kept %v", len(saidOf(meshwants.EventVersionDelivered))-before, p.w.has(buckets.DeliveredAgain(), "db"))
}
ask("1.2.0")
p.deliveredAgain(ctx)
var a againAsk
if json.Unmarshal(p.w.value(buckets.DeliveredAgain(), "db"), &a) != nil || a.Waits != "db 1.2.0 does not run healthy on ace" ||
len(saidOf(meshwants.EventVersionDelivered)) != before+1 {
t.Errorf("the ask of 1.2.0 = %+v", a)
}
}
@@ -0,0 +1,82 @@
package main
import (
"context"
"encoding/json"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/busstate"
"mesh-delivery-assigner/internal/assigngraph"
)
// runtimeObjects is the kept data assignment-revisions as the bundle reaches it through its runtime (the old objects
// section of a foundation part, design 47 lines 1362-1366).
type runtimeObjects struct{ o busstate.Objects }
func (r runtimeObjects) Put(_ context.Context, name string, data []byte) error {
_, err := r.o.Put(name, data, "")
return err
}
func (r runtimeObjects) Get(_ context.Context, name string) ([]byte, error) {
d, err := r.o.Get(name)
if err != nil {
return nil, err
}
if d.Info.Name == "" || d.Info.Deleted {
return nil, assigngraph.ErrNoObject
}
return d.Data, nil
}
func (r runtimeObjects) Names(context.Context) ([]string, error) {
infos, err := r.o.List()
if err != nil {
return nil, err
}
names := []string{}
for _, i := range infos {
if !i.Deleted {
names = append(names, i.Name)
}
}
return names, nil
}
// graphVerbs are the seat mesh-wants' verbs revision, entry and difference (design 47 lines 1365-1366: an assignment graph
// revision is served by the seat's verbs), each reading only the kept revisions.
func graphVerbs(o assigngraph.Objects) map[string]func(map[string]any) (any, error) {
return map[string]func(map[string]any) (any, error){
meshwants.VerbRevision: func(a map[string]any) (any, error) {
var in meshwants.RevisionInput
if err := decodeArgs(a, &in); err != nil {
return nil, err
}
return assigngraph.Revision(context.Background(), o, in)
},
meshwants.VerbEntry: func(a map[string]any) (any, error) {
var in meshwants.EntryInput
if err := decodeArgs(a, &in); err != nil {
return nil, err
}
return assigngraph.Entry(context.Background(), o, in)
},
meshwants.VerbDifference: func(a map[string]any) (any, error) {
var in meshwants.DifferenceInput
if err := decodeArgs(a, &in); err != nil {
return nil, err
}
return assigngraph.Difference(context.Background(), o, in)
},
}
}
// decodeArgs reads a verb's arguments into its input type; a caller's own fields beside them are the runtime's.
func decodeArgs(a map[string]any, into any) error {
raw, err := json.Marshal(a)
if err != nil {
return err
}
return json.Unmarshal(raw, into)
}
@@ -0,0 +1,82 @@
package main
import (
"context"
"fmt"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/assigngraph"
)
// A store filling is said before it is full (design 47 G §181, in the assigner's part): the assignment graph revisions' object store
// says `filling` of the seat mesh-wants when it passes 80 % of its cap, and `full` when the next revision does not fit: the assigner then keeps
// its last revision and writes no new want, and says `room` when the store is back below 80 % and goes on. Nothing is dropped to make room.
// fillOf is the store's fill now: its bytes and cap, when the bucket says them and has a cap.
func (g *graphKeeper) fillOf(ctx context.Context) (used, capacity int64, ok bool) {
f, is := g.objects.(assigngraph.Filler)
if !is {
return 0, 0, false
}
used, capacity, err := f.Fill(ctx)
return used, capacity, err == nil && capacity > 0
}
func (p *passer) sayFill(event, about string, used, capacity int64, refused string) {
at := p.clock().UTC()
body := meshwants.Fill{Store: assigngraph.RevisionsObjects, Bytes: used, Cap: capacity, Refused: refused, At: &at}
if err := p.wantsSay(event, body, event+"-"+about); err != nil {
p.logf("%s of %s is not said: %v", event, assigngraph.RevisionsObjects, err)
}
}
// fillAtStart judges the store once at the process's first pass, when no event is pending: a store past 80 % says filling (full when at
// its cap), so a fill reached while the process was down is said too.
func (p *passer) fillAtStart(ctx context.Context) {
g := p.graph
used, capacity, ok := g.fillOf(ctx)
if !ok {
return
}
switch {
case used >= capacity:
g.full = true
p.sayFill(meshwants.EventFull, "start", used, capacity, "the next revision")
case used*5 >= capacity*4:
p.sayFill(meshwants.EventFilling, "start", used, capacity, "")
}
}
// fullBefore says the next revision does not fit its store, and keeps nothing: it says `full` once, and `room` once the store is below
// 80 % again. It answers true while the store is full.
func (p *passer) fullBefore(ctx context.Context, c assigngraph.Computed) bool {
g := p.graph
used, capacity, ok := g.fillOf(ctx)
if !ok {
return false
}
if g.full && used*5 < capacity*4 {
g.full = false
p.sayFill(meshwants.EventRoom, fmt.Sprint(used), used, capacity, "")
}
if used+assigngraph.NewBytes(ctx, g.objects, c) <= capacity {
g.full = false
return false
}
if !g.full {
g.full = true
p.sayFill(meshwants.EventFull, fmt.Sprint(used), used, capacity, "the next revision")
}
p.logf("the assignment graph revision is not kept: %s is full (%d of %d bytes), the last revision stays and no want is written until there is room", assigngraph.RevisionsObjects, used, capacity)
return true
}
// fillingAfter says `filling` when keeping a revision took the store past 80 % of its cap.
func (p *passer) fillingAfter(ctx context.Context, before int64) {
g := p.graph
used, capacity, ok := g.fillOf(ctx)
if ok && before*5 < capacity*4 && used*5 >= capacity*4 {
p.sayFill(meshwants.EventFilling, fmt.Sprint(used), used, capacity, "")
}
}
@@ -0,0 +1,83 @@
package main
import (
"context"
"testing"
"time"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/assigngraph"
)
// filled is an object bucket with a fill: the bytes it holds count beside what is stored.
type filled struct {
objs map[string][]byte
base int64
cap int64
}
func (f *filled) Put(_ context.Context, name string, data []byte) error {
f.objs[name] = data
return nil
}
func (f *filled) Get(_ context.Context, name string) ([]byte, error) {
if d, ok := f.objs[name]; ok {
return d, nil
}
return nil, assigngraph.ErrNoObject
}
func (f *filled) Names(context.Context) ([]string, error) {
out := []string{}
for n := range f.objs {
out = append(out, n)
}
return out, nil
}
func (f *filled) Fill(context.Context) (int64, int64, error) {
used := f.base
for _, d := range f.objs {
used += int64(len(d))
}
return used, f.cap, nil
}
// Design 47 G §181 (AS13): the assignment graph revisions' store says filling past 80 % of its cap, refuses a revision that does not fit
// with `full` (the last revision stays, no want is written), and says `room` below 80 % again; at the start a store already past 80 % is said.
func TestTheAssignmentGraphStoreSaysFillingFullAndRoom(t *testing.T) {
ctx := context.Background()
p := newPasser(kvSet{}, time.Now())
var events []string
p.publishWants = func(event string, body any, id string) error {
if _, ok := body.(meshwants.Fill); ok {
events = append(events, event)
}
return nil
}
o := &filled{objs: map[string][]byte{}, base: 500, cap: 1000}
p.graph = &graphKeeper{objects: o, halves: func() map[string]mcc.Half { return nil }}
p.keepGraph(ctx) // the first pass judges at the start: 500 of 1000 is below 80 %
if len(events) != 0 {
t.Fatalf("events %v", events)
}
o.base = 700
p.graph.started = false
p.keepGraph(ctx)
if len(events) != 1 || events[0] != meshwants.EventFilling {
t.Fatalf("at the start past 80 %%: %v", events)
}
c := assigngraph.Computed{Revision: meshwants.AssignmentRevision{Entries: []meshwants.NodeEntryRef{}}}
o.base = 990
if !p.fullBefore(ctx, c) || !p.graph.full || events[len(events)-1] != meshwants.EventFull {
t.Fatalf("a revision that does not fit: events %v", events)
}
p.fullBefore(ctx, c)
if n := len(events); n != 2 {
t.Errorf("full is said once: %v", events)
}
o.base = 100
if p.fullBefore(ctx, c) || p.graph.full || events[len(events)-1] != meshwants.EventRoom {
t.Errorf("room: events %v", events)
}
}
@@ -0,0 +1,192 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"maps"
"slices"
"strings"
"time"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/keys"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assigngraph"
)
// graphKeeper is the pass's assignment graph (design 47 G §292, A-67): per catalog revision taken in it computes the revision
// from the catalog halves it follows, the placements, the first branch of each registration and each node's capability reading,
// keeps it in the objects assignment-revisions and says the seat mesh-wants' event assignment-revision; the wants of the pass
// that follows are written from the revision it holds.
type graphKeeper struct {
objects assigngraph.Objects
// halves is every followed branch's catalog graph revision held now.
halves func() map[string]mcc.Half
// last is the revision last computed, so a pass whose inputs did not change keeps and says nothing.
last meshwants.AssignmentRevision
has bool
// kept is the revision the wants follow, and kept's source the trunk's source revision it carries; ok is false until one is kept.
kept meshwants.AssignmentRevision
source int64
ok bool
said string
// cycled is the cycles last said (assigngraph.Cycles).
cycled string
// full says the next revision did not fit the store, so no want is written (graphfill.go); started says the first pass judged the fill.
full, started bool
// pins removes a revision that ceased to be current once nothing pins it (pins.go); nil keeps every revision.
pins *pinKeeper
// computed is the revision computed by the last pass, with its entries: the edges the wants wait on (actingwaits.go).
computed assigngraph.Computed
}
// keepGraph computes the revision of this pass and keeps it when the pass's inputs differ from the last computed.
func (p *passer) keepGraph(ctx context.Context) {
g := p.graph
if g == nil || g.objects == nil || g.halves == nil {
return
}
c := assigngraph.Compute(p.graphInputs())
g.computed = c
if p.ends != nil {
p.ends.setRunner(assigngraph.Runner(slices.Collect(maps.Values(c.Entries)))) // the runner's member of a group goes first (A-334)
}
for _, u := range c.Uncomputed {
if g.said != u {
g.said = u
p.logf("the assignment graph leaves out %s", u)
}
}
if cycles := strings.Join(c.Cycles, "; "); cycles != g.cycled {
g.cycled = cycles
if cycles != "" {
p.logf("the assignment graph holds a cycle among the acting waits, within one step or across steps: %s", cycles)
}
}
if !g.started {
g.started = true
p.fillAtStart(ctx)
}
if g.has && g.ok && assigngraph.Same(g.last, c.Revision) {
return
}
used, _, _ := g.fillOf(ctx)
if p.fullBefore(ctx, c) {
return
}
rev, fresh, err := assigngraph.Keep(ctx, g.objects, c, meshwants.EventAssignmentRevision, p.clock())
if err != nil {
p.logf("the assignment graph revision is not kept, so no want is said as wanted until it is: %v", err)
return
}
g.last, g.has, g.kept, g.ok = c.Revision, true, rev, true
g.source = 0
for _, cat := range rev.Catalogs {
if cat.Branch == p.w.trunkName() {
g.source = cat.Source
}
}
if !fresh {
return
}
p.fillingAfter(ctx, used)
if g.pins != nil {
g.pins.judge() // the revision before ceased to be current
}
id := fmt.Sprintf("%s-%s-%d", meshwants.EventAssignmentRevision, rev.Kind, rev.Revision)
if err := p.wantsSay(meshwants.EventAssignmentRevision, assigngraph.Event(rev), id); err != nil {
p.logf("the assignment revision %d is kept but not said: %v", rev.Revision, err)
}
}
// graphInputs are what the pass's assignment graph is computed from: every followed branch's catalog half, the placements, each
// node's capability reading and the first branch of each registration.
func (p *passer) graphInputs() assigngraph.Inputs {
in := assigngraph.Inputs{Halves: p.graph.halves(), Placements: p.w.assignments(), Capabilities: p.capabilityDigests()}
// The seat definitions read through the seat mesh-catalog's verb seat-definitions say which seat delivers a requirement
// (design 47 G §708).
if r := p.seats.Reader(); r != nil {
in.Definitions = r.Definitions()
}
// With no pipeline ends kept, the wants are the trunk-only form (pass.go): a placement naming no branch runs the trunk's.
first := p.w.trunkName()
in.FirstBranch = func(string) string { return first }
if p.ends != nil {
in.FirstBranch = func(module string) string { return p.ends.branchOf(module, "") }
}
return in
}
// capabilityDigests is each node's capability reading, by node: the statement its engine made of what it reads, and the
// capabilities its tool runner states with the fingerprint of the runner that announced them, as the node's record carries
// them (design 46 approval mark §6, lead answer A-203), the digest over all of it.
func (p *passer) capabilityDigests() []meshwants.AssignmentRevisionCapabilitiesItem {
records := p.w.records()
known := p.w.knownNodes()
slices.Sort(known)
var out []meshwants.AssignmentRevisionCapabilitiesItem
for _, node := range known {
read := p.reads != nil && p.reads.Known(node)
rec, recorded := records[node]
if !read && !recorded {
continue
}
var reads []string
if read {
reads = slices.Clone(p.reads.Reads([]string{node})[node])
slices.Sort(reads)
}
item := meshwants.AssignmentRevisionCapabilitiesItem{Node: node, Readings: slices.Clone(rec.RunnerCapabilities),
Fingerprint: rec.RunnerFingerprint}
slices.Sort(item.Readings)
raw, _ := json.Marshal(struct {
Reads []string `json:"reads"`
Readings []string `json:"readings"`
Fingerprint string `json:"fingerprint"`
}{reads, item.Readings, item.Fingerprint})
sum := sha256.Sum256(raw)
item.Digest = "sha256:" + hex.EncodeToString(sum[:])
out = append(out, item)
}
return out
}
// sayWanted says `wanted` for a want written or removed now, from the revision the pass keeps (A-61, A-67); with none kept, it is
// said in the log that it was not said. fp is the want's, and for a removal the fingerprint last wanted there.
func (p *passer) sayWanted(node, module, fp string, removed bool) {
if p.publishWants == nil {
return
}
g := p.graph
if g == nil || !g.ok {
p.logf("the want of %s on %s is written, but no assignment graph revision is kept, so wanted is not said", module, node)
return
}
at := p.clock().UTC()
body := meshwants.WantedEvent{V: keys.Version, Node: node, Module: module, Fp: fp, Removed: removed, Revision: g.kept.Revision, Source: g.source, At: &at}
event := strings.TrimPrefix(meshwants.SubjectWanted(keys.EscapeName(node), keys.EscapeName(module)), "mesh.seat."+meshwants.Seat+".event.")
id := fmt.Sprintf("wanted.%s.%s.%s.%t.%d.%s", keys.EscapeName(node), keys.EscapeName(module), fp, removed, g.kept.Revision, at.Format(time.RFC3339Nano))
if err := p.wantsSay(event, body, id); err != nil {
p.logf("wanted of %s on %s is not said: %v", module, node, err)
}
}
// namedBuild answers what a want of module at fp names of its build (design 47 G §379, §526, A-96): a kept build's or another watched
// branch's parts and keeping node from the builder's `build.ended`; for the trunk's entry (fp is the module's, trunkFP), the released
// version and the parts of the `version-released` whose source is the one the kept assignment graph revision was computed from (A-67).
func (p *passer) namedBuild(module, trunkFP string) func(fp string) (assign.KeptBuild, bool) {
return func(fp string) (assign.KeptBuild, bool) {
if k, ok := p.w.ends.partsOf(module, fp); ok {
return k, true
}
if fp != trunkFP || p.graph == nil || !p.graph.ok {
return assign.KeptBuild{}, false
}
return p.w.released.of(module, p.graph.source)
}
}
@@ -0,0 +1,134 @@
package main
import (
"context"
"encoding/json"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/buckets"
)
// said is one event the passer said on the seat mesh-wants.
type said struct {
event, id string
body json.RawMessage
}
// Design 47 G A-67, §292: the pass keeps an assignment graph revision per catalog revision taken in, before it writes any want, and
// says assignment-revision; A-61, §693: a want written or removed is said `wanted` on the subject of its node and module in the same
// write, from the kept revision and its catalog's source, a put that wrote nothing says nothing, and with no revision kept none is said.
func TestTheWantsAreSaidWantedFromTheKeptAssignmentGraphRevision(t *testing.T) {
ctx := context.Background()
at := time.Date(2026, 11, 12, 9, 0, 0, 0, time.UTC)
kv := &memKV{values: map[string][]byte{}}
p := newPasser(kvSet{buckets.Wants(): kv}, at)
p.logf = t.Logf
var events []said
p.publishWants = func(event string, body any, id string) error {
raw, _ := json.Marshal(body)
events = append(events, said{event, id, raw})
return nil
}
placeOn(t, p.w, "ace", "web")
// Design 46 approval mark §6: a node's reading keeps the fingerprint of the runner that announced it, as its record says.
rec, _ := json.Marshal(nodeengine.HaveByNodeNodeValue{V: 1, Record: &nodeengine.NodeRecord{V: 1, Node: "ace",
RunnerCapabilities: []string{"bus-state-calls", "approval-mark/1"}, RunnerFingerprint: "sha256:runner"}})
p.w.put(buckets.Have, "ace.node", rec)
if c := p.capabilityDigests(); len(c) != 1 || c[0].Node != "ace" || c[0].Fingerprint != "sha256:runner" ||
!slices.Equal(c[0].Readings, []string{"approval-mark/1", "bus-state-calls"}) || !strings.HasPrefix(c[0].Digest, "sha256:") {
t.Errorf("the capability readings are %+v", c)
}
half := mcc.Half{Revision: meshcatalog.CatalogRevision{Branch: trunkBranch, Kind: "trunk", Revision: 4, Source: 9,
Entries: []meshcatalog.EntryRef{{Module: "web", Digest: "sha256:" + strings.Repeat("a", 64)}}},
Entries: map[string]meshcatalog.CatalogEntry{"web": {Module: "web", Fp: fpTest}}}
objects := memObjects{}
p.graph = &graphKeeper{objects: objects, halves: func() map[string]mcc.Half { return map[string]mcc.Half{trunkBranch: half} }}
want := mwc.Want{Fp: fpTest, Self: true}
p.putWant(ctx, "ace", "web", want) // no revision kept yet: written, and not said
if len(events) != 0 || kv.puts != 1 {
t.Fatalf("with no revision kept: %d events, %d puts", len(events), kv.puts)
}
key, _ := mwc.WantKey("ace", "web")
delete(p.w.read(buckets.Wants()), key) // the world forgets the want, as before the first pass
kv.puts = 0
p.w.put(buckets.Wants(), key, nil)
p.keepGraph(ctx)
if len(events) != 1 || events[0].event != meshwants.EventAssignmentRevision || len(objects) == 0 {
t.Fatalf("the pass kept %d objects and said %+v", len(objects), events)
}
p.keepGraph(ctx) // the same inputs: nothing kept, nothing said
if len(events) != 1 {
t.Fatalf("a pass of the same inputs said %d events", len(events))
}
p.putWant(ctx, "ace", "web", want)
p.putWant(ctx, "ace", "web", want) // unchanged: not written, not said
if len(events) != 2 || kv.puts != 1 {
t.Fatalf("a want put twice: %d events, %d puts", len(events), kv.puts)
}
if wantEvent := "wanted.ace.web"; events[1].event != wantEvent {
t.Errorf("wanted said on %q, want %q", events[1].event, wantEvent)
}
var w meshwants.WantedEvent
if err := json.Unmarshal(events[1].body, &w); err != nil || w.Node != "ace" || w.Module != "web" || w.Fp != fpTest || w.Removed || w.Revision != 1 || w.Source != 9 {
t.Errorf("wanted = %+v (%v)", w, err)
}
p.putWant(ctx, "ace", "web", mwc.Want{Removed: true})
if err := json.Unmarshal(events[len(events)-1].body, &w); err != nil || len(events) != 3 || !w.Removed || w.Fp != fpTest {
t.Errorf("a removing want said %+v (%v), %d events", w, err, len(events))
}
// A-201, A-235: a run's candidate catalog revision, read whole through the seat's verb revision, keeps the run's assignment
// graph under candidate.<group>.<heads> in place of its branch's half; it says nothing and writes no want, a try's candidate
// keeps none, and the run's group-ended drops it.
before, puts := len(events), kv.puts
ev := meshcatalog.CatalogRevisionEvent{Kind: "candidate", Branch: trunkBranch, Group: "run-7", Heads: "h1", Revision: 5}
var asked []meshcatalog.RevisionInput
call := func(key string, body any) (json.RawMessage, error) {
in := body.(meshcatalog.RevisionInput)
asked = append(asked, in)
rev := meshcatalog.CatalogRevision{Branch: trunkBranch, Kind: "candidate", Group: "run-7", Heads: "h1", Revision: 5, Source: 9,
Entries: []meshcatalog.EntryRef{{Module: "web", Digest: "sha256:" + strings.Repeat("b", 64)}}}
a := meshcatalog.RevisionAnswer{Revision: &rev, Entries: []meshcatalog.CatalogEntry{{Module: "web", Fp: "src2:w2"}}}
return json.Marshal(a)
}
if err := p.keepCandidate(ctx, call, meshcatalog.CatalogRevisionEvent{Kind: "candidate", Branch: trunkBranch, Group: "try-3", Heads: "h", Revision: 2}); err != nil || len(asked) != 0 {
t.Errorf("a try's candidate asked %v: %v", asked, err)
}
if err := p.keepCandidate(ctx, call, ev); err != nil {
t.Fatal(err)
}
if len(asked) != 1 || asked[0].Group != "run-7" || asked[0].Heads != "h1" || asked[0].Revision != 5 {
t.Errorf("the candidate's revision was asked as %+v", asked)
}
a, err := assigngraph.Revision(ctx, objects, meshwants.RevisionInput{Group: "run-7"})
if err != nil || a.Revision.Kind != "candidate" || len(a.Revision.Catalogs) != 1 || a.Revision.Catalogs[0].Revision != 5 {
t.Errorf("the run's assignment graph is %+v: %v", a.Revision, err)
}
if e, err := assigngraph.Entry(ctx, objects, meshwants.EntryInput{Group: "run-7", Node: "ace", Module: "web"}); err != nil || e.Entry.Fp != "src2:w2" {
t.Errorf("the run's entry of web is %+v: %v", e.Entry, err)
}
if len(events) != before || kv.puts != puts {
t.Errorf("a candidate said %d events and wrote %d wants", len(events)-before, kv.puts-puts)
}
if err := p.dropCandidate(ctx, "run-7"); err != nil {
t.Fatal(err)
}
if _, ok := objects[assigngraph.CandidateObject("run-7", "h1")]; ok {
t.Error("the run's group-ended left its candidate kept")
}
if _, err := assigngraph.Revision(ctx, objects, meshwants.RevisionInput{}); err != nil {
t.Errorf("dropping the run's candidate dropped trunk: %v", err)
}
}
@@ -0,0 +1,163 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"slices"
"sync"
"time"
mcc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/views"
)
// halves follows the catalog half of each watched branch through the seat mesh-catalog (design 47 G §14, §184, §195, §507, §532): the
// branch follower of meshcatalogclient, adopted on the module's views reader (A-26), follows the branches the registrations name
// and, before the first registrations, the branches the gate's verb `source` answers and its event `source-moved` brings; each
// branch's view asks the verb `revision`, hears each `catalog-revision` of that branch and raises and clears the follower's findings;
// each entry is taken into the world as the value and the graph node the rules read. Of the view the assigner reads only the catalog
// half, never its own output back (§195).
type halves struct {
w *world
call mcc.Call
reader *views.ProcessReader
wake func()
logf func(string, ...any)
ctx context.Context
branches *mcc.Branches
mu sync.Mutex
// settled is told once when the first watched branch's first view is answered, for the first pass to wait on.
settled chan<- struct{}
told bool
}
// follow makes the branch follower on the reader; the reader's start asks the first views.
func (h *halves) follow() error {
b, err := mcc.FollowBranchesProcess(h.reader, h.call)
if err != nil {
return err
}
b.Logf = h.logf
b.Changed = func(branch, module string, entry json.RawMessage, deleted bool, _ time.Time) {
h.changed(branch, module, entry, deleted)
}
b.Adopted = h.adopted
h.mu.Lock()
h.branches = b
h.mu.Unlock()
return nil
}
// first is the first watched branch followed, the trunk's: "" while none is.
func (h *halves) first() string {
h.mu.Lock()
b := h.branches
h.mu.Unlock()
if b == nil {
return ""
}
if f := b.Followed(); len(f) > 0 {
return f[0]
}
return ""
}
// adopted is told of each branch whose view the follower adopted: the first branch is the trunk's, and the first pass waits for its
// first view.
func (h *halves) adopted(branch string, c *mcc.Catalog) {
first := h.first()
if first == "" {
return
}
h.w.setTrunk(first)
h.wake()
if branch != first || h.settled == nil {
return
}
h.mu.Lock()
told := h.told
h.told = true
h.mu.Unlock()
if told {
return
}
go func() {
select {
case <-c.Ready():
h.settled <- struct{}{}
case <-h.ctx.Done():
}
}()
}
// changed takes one entry of branch's half into the world, or a module taken out of it.
func (h *halves) changed(branch, module string, raw json.RawMessage, deleted bool) {
defer h.wake()
if deleted {
h.w.dropModule(branch, module)
return
}
var e meshcatalog.CatalogEntry
if err := json.Unmarshal(raw, &e); err != nil {
h.logf("the entry of %s on branch %q does not read: %v", module, branch, err)
return
}
v, n, m := fromEntry(e)
h.w.putModule(branch, module, v, n, m)
h.w.putEdges(branch, module, e.Edges)
}
// fromEntry is a catalog entry as the value and the graph node the rules read, and its manifest parsed once under the digest of
// its words.
func fromEntry(e meshcatalog.CatalogEntry) (meshcatalog.ModulesByModuleValue, graph.Node, *parsed) {
v := meshcatalog.ModulesByModuleValue{Commit: e.Commit, Fp2: e.Fp, Needs: e.Needs, None2: e.None, Packages: e.Packages,
Path: e.Path, Repository: e.Repository, Verdict: e.Verdict}
n := graph.Node{FP: e.Fp, None: e.None, Repository: e.Repository, Path: e.Path, Bases: e.Bases, Provides: e.Provides, Requires: e.Requires,
Awaits: e.Awaits, Invokes: e.Invokes, Serves: e.Serves, LeftOut: e.LeftOut}
for p := range e.Packages {
n.Packages = append(n.Packages, p)
}
slices.Sort(n.Packages)
if len(e.Manifest) == 0 {
return v, n, nil
}
raw, err := json.Marshal(e.Manifest)
if err != nil {
return v, n, nil
}
sum := sha256.Sum256(raw)
v.Manifest = "sha256:" + hex.EncodeToString(sum[:])
man, err := catalogue.Decode(raw)
if err != nil {
return v, n, &parsed{module: e.Module, err: "it does not decode: " + err.Error()}
}
n.Serves = graph.ServesOf(man)
// The entry's requires name the optional ones too; the node means those required, not optionally (A-229).
n.Requires = slices.DeleteFunc(slices.Clone(n.Requires), man.IsOptional)
return v, n, &parsed{module: e.Module, manifest: man}
}
// views is the catalog graph revision held now of each followed branch whose first view is answered.
func (h *halves) views() map[string]mcc.Half {
h.mu.Lock()
b := h.branches
h.mu.Unlock()
out := map[string]mcc.Half{}
if b == nil {
return out
}
for _, branch := range b.Followed() {
if c, ok := b.Catalog(branch); ok {
if half, ok := c.View(); ok {
out[branch] = half
}
}
}
return out
}
@@ -0,0 +1,377 @@
package main
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
)
// A step's input by its digest (design 47 G §354, §712, A-237, A-243): the assigner keeps the input of each composition step
// request it asked a build seat to run in its state `inputs`, one key `<time>.<digest>` per input, the value the input's bytes in
// base64 with the state's object store `inputs-objects` holding one over a key's limit (mesh-sdk stateobjects), and the seat
// mesh-wants' verb `input` answers one by its digest in chunks, to the holders of the build seats alone (the verb's callers, §338).
// An input is deleted when the build seat's step-ended of its ask is heard, or a day after its write, whichever is first, the day
// judged at each new ask over at most 20 of the oldest keys. `assigner_inputs` reads them. This mirrors the indexer's
// (mesh-delivery-indexer internal/index/run_inputs.go): the two are separate modules' code, owed to mesh-sdk.
// inputsTool is the tool that reads the kept inputs.
const inputsTool = "assigner_inputs"
const (
// inputsKeptFor is how long an input is kept at the longest.
inputsKeptFor = 24 * time.Hour
// inputsSwept is how many of the oldest keys each new ask judges.
inputsSwept = 20
inputsTime = "20060102T150405Z"
// inputsAskPrefix opens the keys that name an input by the id of the ask it was kept for: ask.<id>, the id escaped as a name,
// holding the input's own key, so the build seat's step-ended of that ask finds it.
inputsAskPrefix = "ask."
)
// digestOf is an input's digest as the ask names it: its sha256 in hex.
func digestOf(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) }
// inputsCap is the state inputs' cap (design 47 G §354's kept-data row: 64 MiB, the manifest's max-mib): at it, an ask is refused.
const inputsCap = 64 << 20
// inputKeeper keeps the inputs over the state and its object store.
type inputKeeper struct {
vals stateobjects.Values
now func() time.Time
logf func(string, ...any)
// used is how many bytes the state and its object store hold; nil judges no cap.
used func() (int64, error)
}
// keep keeps the exact bytes of the input of the ask under their write time and sha256, after sweeping the keys more than a day old
// among the 20 oldest, and names it by the ask's id; it answers the digest. The same bytes written again in the same second are one key.
func (k *inputKeeper) keep(ask string, body []byte) (string, error) {
if ask == "" {
return "", errors.New("name the ask the input is kept for")
}
if err := k.sweep(); err != nil {
k.logf("inputs: the keys older than a day are not all deleted: %v", err)
}
if k.used != nil {
used, err := k.used()
if err != nil {
return "", fmt.Errorf("the fill of the state %s is not read, so no input is kept: %w", meshwants.StateInputs, err)
}
if used+int64(len(body)) > inputsCap {
return "", fmt.Errorf("the state %s is at its cap of %d MiB (%d bytes kept), so the ask's input of %d bytes is refused",
meshwants.StateInputs, inputsCap>>20, used, len(body))
}
}
digest := digestOf(body)
key := meshwants.InputsByTimeDigestKey(k.now().UTC().Format(inputsTime), digest)
if err := k.vals.Put(key, base64.StdEncoding.EncodeToString(body)); err != nil && !errors.Is(err, stateobjects.ErrChanged) {
return "", err
}
if err := k.vals.KV.Create(inputsAskPrefix+keys.EscapeName(ask), []byte(key)); err != nil && !errors.Is(err, stateobjects.ErrChanged) {
return "", err
}
return digest, nil
}
// ended deletes the input kept for an ask when the build seat's step-ended of that ask is heard; an ask that kept none, or whose
// input a sweep deleted, changes nothing.
func (k *inputKeeper) ended(ask string) error {
if ask == "" {
return nil
}
askKey := inputsAskPrefix + keys.EscapeName(ask)
e, err := k.vals.KV.Get(askKey)
if err != nil || e == nil {
return err
}
if err := k.vals.KV.Delete(string(e.Value)); err != nil {
return err
}
return k.vals.KV.Delete(askKey)
}
// sweep deletes, among the 20 oldest keys, those written more than a day ago, and a name by ask whose input is gone.
func (k *inputKeeper) sweep() error {
all, err := k.vals.KV.Keys()
if err != nil {
return err
}
var errs []error
var inputs, asks []string
for _, key := range all {
if strings.HasPrefix(key, inputsAskPrefix) {
asks = append(asks, key)
} else {
inputs = append(inputs, key)
}
}
slices.Sort(inputs) // the write time leads the key, so the oldest come first
if len(inputs) > inputsSwept {
inputs = inputs[:inputsSwept]
}
for _, key := range inputs {
t, _, ok := meshwants.ParseInputsByTimeDigestKey(key)
at, perr := time.Parse(inputsTime, t)
if !ok || perr != nil || k.now().Sub(at) < inputsKeptFor {
continue
}
if err := k.vals.KV.Delete(key); err != nil {
errs = append(errs, err)
}
}
for _, key := range asks {
if e, err := k.vals.KV.Get(key); err == nil && e != nil {
if gone, err := k.vals.KV.Get(string(e.Value)); err == nil && gone == nil {
if err := k.vals.KV.Delete(key); err != nil {
errs = append(errs, err)
}
}
}
}
return errors.Join(errs...)
}
// keyOf is the key of the input of the digest, or none.
func (k *inputKeeper) keyOf(digest string) (string, error) {
all, err := k.vals.KV.Keys()
if err != nil {
return "", err
}
slices.Sort(all)
for _, key := range all {
if _, d, ok := meshwants.ParseInputsByTimeDigestKey(key); ok && d == digest && !strings.HasPrefix(key, inputsAskPrefix) {
return key, nil
}
}
return "", nil
}
// body is the exact bytes of the input of the digest, checked against it, whatever its size.
func (k *inputKeeper) body(digest string) ([]byte, error) {
if len(digest) != 64 {
return nil, errors.New("input takes the input's sha256, 64 hex digits")
}
key, err := k.keyOf(digest)
if err != nil {
return nil, fmt.Errorf("the state %s cannot be read: %w", meshwants.StateInputs, err)
}
if key == "" {
return nil, fmt.Errorf("the assigner keeps no input with the digest %s", digest)
}
var text string
found, err := k.vals.Get(key, &text)
if err != nil {
return nil, fmt.Errorf("the input %s cannot be read: %w", key, err)
}
if !found {
return nil, fmt.Errorf("the assigner keeps no input with the digest %s", digest)
}
b, err := base64.StdEncoding.DecodeString(text)
if err != nil || digestOf(b) != digest {
return nil, fmt.Errorf("the input %s does not hold the bytes its digest names", key)
}
return b, nil
}
var callToken = regexp.MustCompile(`^[A-Za-z0-9_-]{16,64}$`)
// verb answers the seat's verb input (marked chunks): the digest and how many chunks carry the bytes, then sends them in chunks of at
// most buscalls.SeatChunkBytes on the call's chunk subjects, each acknowledged before the next (A-237, A-139). The input is of any
// size. A caller that does not acknowledge within `within` ends the send, said in logf.
func (k *inputKeeper) verb(line buscalls.ChunkLine, within time.Duration) process.SeatHandler {
return func(_ string, raw json.RawMessage) (any, error) {
var in meshwants.InputInput
if err := json.Unmarshal(raw, &in); err != nil {
return nil, fmt.Errorf("the arguments are not JSON: %w", err)
}
if !callToken.MatchString(in.Call) {
return nil, errors.New("call is one token of 16 to 64 letters, digits, - or _: 128 random bits the caller chooses")
}
digest := strings.TrimPrefix(strings.TrimSpace(in.Digest), "sha256:")
body, err := k.body(digest)
if err != nil {
return nil, err
}
send := buscalls.SeatChunks{Seat: meshwants.Seat, Verb: meshwants.VerbInput, Call: in.Call}
total := int64((len(body) + buscalls.SeatChunkBytes - 1) / buscalls.SeatChunkBytes)
// the send subscribes to the acknowledgements itself and ends when it has sent every chunk or one is not acknowledged
go func() {
if err := send.Send(line, body, within, k.logf); err != nil {
k.logf("the send %s of the input %s: %v", in.Call, digest, err)
}
}()
return meshwants.InputAnswer{Digest: digest, Chunks: total}, nil
}
}
// inputObjects is the object store beside the state inputs (`inputs-objects`, a state's own, §712), opened when first used: until
// the grants part has made it every call is process.ErrNotYet.
type inputObjects struct {
js jetstream.JetStream
module string
mu sync.Mutex
bus stateobjects.Objects
}
func (o *inputObjects) open() (stateobjects.Objects, error) {
o.mu.Lock()
defer o.mu.Unlock()
if o.bus != nil {
return o.bus, nil
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
store, err := o.js.ObjectStore(ctx, catalogue.BusName(o.module, catalogue.StateObjectsName(meshwants.StateInputs)))
if errors.Is(err, jetstream.ErrBucketNotFound) || errors.Is(err, jetstream.ErrStreamNotFound) {
return nil, fmt.Errorf("the object store of the state inputs is not on the bus yet: %w", process.ErrNotYet)
}
if err != nil {
return nil, fmt.Errorf("the object store of the state inputs is not open: %w", err)
}
o.bus = stateobjects.BusObjects{Store: store}
return o.bus, nil
}
func (o *inputObjects) MostBytes() int { return 0 }
func (o *inputObjects) Put(name string, data []byte) error {
b, err := o.open()
if err != nil {
return err
}
return b.Put(name, data)
}
func (o *inputObjects) Get(name string) ([]byte, error) {
b, err := o.open()
if err != nil {
return nil, err
}
return b.Get(name)
}
func (o *inputObjects) Delete(name string) error {
b, err := o.open()
if err != nil {
return err
}
return b.Delete(name)
}
func (o *inputObjects) List() ([]stateobjects.Object, error) {
b, err := o.open()
if err != nil {
return nil, err
}
return b.List()
}
// inputsOver is the inputs the program keeps, over its bucket and its object store beside it; nil when the bucket is not opened.
func inputsOver(kv jetstream.KeyValue, js jetstream.JetStream, module string, logf func(string, ...any)) *inputKeeper {
if kv == nil {
return nil
}
objects := &inputObjects{js: js, module: module}
v := stateobjects.Values{KV: stateobjects.BusKV{KV: kv}, Objects: objects, Store: catalogue.StateObjectsName(meshwants.StateInputs)}
used := func() (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
st, err := kv.Status(ctx)
if err != nil {
return 0, err
}
n := int64(st.Bytes())
objs, err := objects.List()
if err != nil && !errors.Is(err, process.ErrNotYet) {
return 0, err
}
for _, o := range objs {
n += o.Size
}
return n, nil
}
return &inputKeeper{vals: v, now: time.Now, logf: logf, used: used}
}
// inputSaid is one kept input as assigner_inputs lists it.
type inputSaid struct {
Key string `json:"key"`
Time string `json:"time"`
Digest string `json:"digest"`
Size int `json:"size"`
Input string `json:"input,omitempty"`
}
// inputsRead is the tool assigner_inputs over the kept inputs: every kept input with its size, or the one named by digest whole.
func inputsRead(k *inputKeeper) stdio.Tool {
return stdio.Tool{Name: inputsTool, Description: "The inputs the assigner keeps for the composition steps it asked a build seat to run (design 47 G §354): each " +
"with its write time, digest and size, or one by `digest` whole (base64). Read-only.",
Input: schema(map[string]string{"digest": "one input's sha256"}, nil), Run: func(args map[string]any) (any, error) {
want := arg(args, "digest")
all, err := k.vals.KV.Keys()
if err != nil {
return nil, fmt.Errorf("the inputs cannot be listed: %w", err)
}
slices.Sort(all)
out := []inputSaid{}
for _, key := range all {
t, d, ok := meshwants.ParseInputsByTimeDigestKey(key)
if !ok || strings.HasPrefix(key, inputsAskPrefix) || (want != "" && d != want) {
continue
}
var text string
if found, err := k.vals.Get(key, &text); err != nil || !found {
continue
}
said := inputSaid{Key: key, Time: t, Digest: d, Size: base64.StdEncoding.DecodedLen(len(text))}
if b, err := base64.StdEncoding.DecodeString(text); err == nil {
said.Size = len(b)
}
if want != "" {
said.Input = text
}
out = append(out, said)
}
return map[string]any{"inputs": out}, nil
}}
}
// heardStepEnded deletes the input kept for the ask a build seat's step-ended names (design 47 G §354), and hands the end to the
// composition run that waits for that ask (A-240); one whose input cannot be deleted is given again by the bus.
func heardStepEnded(k *inputKeeper, ends *runEnds, m jetstream.Msg, logf func(string, ...any)) {
var e buildstep.StepEnded
if err := json.Unmarshal(m.Data(), &e); err != nil {
logf("%s holds what is no step-ended, left: %v", m.Subject(), err)
_ = m.Ack()
return
}
if err := k.ended(e.Ask); err != nil {
logf("inputs: the input of the ask %s could not be deleted, so the bus gives it again: %v", e.Ask, err)
_ = m.Nak()
return
}
if ends != nil {
ends.heard(e)
}
_ = m.Ack()
}
@@ -0,0 +1,231 @@
package main
import (
"bytes"
"crypto/rand"
"encoding/json"
"errors"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/stateobjects"
)
type kvMem struct {
mu sync.Mutex
vals map[string][]byte
revs map[string]uint64
}
func newKVMem() *kvMem { return &kvMem{vals: map[string][]byte{}, revs: map[string]uint64{}} }
func (k *kvMem) Get(key string) (*stateobjects.Entry, error) {
k.mu.Lock()
defer k.mu.Unlock()
v, ok := k.vals[key]
if !ok {
return nil, nil
}
return &stateobjects.Entry{Value: v, Revision: k.revs[key]}, nil
}
func (k *kvMem) Create(key string, v []byte) error {
k.mu.Lock()
defer k.mu.Unlock()
if _, ok := k.vals[key]; ok {
return stateobjects.ErrChanged
}
k.vals[key], k.revs[key] = v, 1
return nil
}
func (k *kvMem) Update(key string, v []byte, rev uint64) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.revs[key] != rev {
return stateobjects.ErrChanged
}
k.vals[key], k.revs[key] = v, rev+1
return nil
}
func (k *kvMem) Delete(key string) error {
k.mu.Lock()
defer k.mu.Unlock()
delete(k.vals, key)
return nil
}
func (k *kvMem) Keys() ([]string, error) {
k.mu.Lock()
defer k.mu.Unlock()
var out []string
for key := range k.vals {
out = append(out, key)
}
sort.Strings(out)
return out, nil
}
type objectsMem struct {
mu sync.Mutex
objs map[string][]byte
}
func (*objectsMem) MostBytes() int { return 0 }
func (o *objectsMem) Put(name string, data []byte) error {
o.mu.Lock()
defer o.mu.Unlock()
o.objs[name] = data
return nil
}
func (o *objectsMem) Get(name string) ([]byte, error) {
o.mu.Lock()
defer o.mu.Unlock()
d, ok := o.objs[name]
if !ok {
return nil, errors.New("no such object")
}
return d, nil
}
func (o *objectsMem) Delete(string) error { return nil }
func (o *objectsMem) List() ([]stateobjects.Object, error) { return nil, nil }
// lineMem is a core bus in memory: a chunk published is kept, and its acknowledgement heard at once.
type lineMem struct {
mu sync.Mutex
hear nats.MsgHandler
chunks map[string][]byte
}
func (l *lineMem) Publish(subject string, data []byte) error {
l.mu.Lock()
l.chunks[subject] = data
h := l.hear
l.mu.Unlock()
var c buscalls.SeatChunk
if err := json.Unmarshal(data, &c); err != nil {
return err
}
go h(&nats.Msg{Subject: buscalls.SeatChunks{Seat: meshwants.Seat, Verb: meshwants.VerbInput, Call: "0123456789abcdef"}.Ack(c.N)})
return nil
}
func (l *lineMem) Subscribe(_ string, fn nats.MsgHandler) (*nats.Subscription, error) {
l.mu.Lock()
l.hear = fn
l.mu.Unlock()
return nil, nil
}
func testKeeper(objs *objectsMem, now func() time.Time) *inputKeeper {
return &inputKeeper{vals: stateobjects.Values{KV: newKVMem(), Objects: objs, Store: "inputs-objects"}, now: now, logf: func(string, ...any) {}}
}
// Design 47 G §354, §712, A-237: an input of any size is kept as an object beside the state, and the seat mesh-wants' verb input
// answers it in chunks of at most 512 KiB, each acknowledged before the next.
func TestAnInputOverOneMiBIsKeptAsAnObjectAndAnsweredInChunks(t *testing.T) {
objs := &objectsMem{objs: map[string][]byte{}}
k := testKeeper(objs, time.Now)
body := make([]byte, 3<<20+17)
if _, err := rand.Read(body); err != nil {
t.Fatal(err)
}
digest, err := k.keep("ask-big", body)
if err != nil {
t.Fatal(err)
}
if len(objs.objs) != 1 {
t.Fatalf("a large input is one object beside the state, got %d", len(objs.objs))
}
if got, err := k.body(digest); err != nil || !bytes.Equal(got, body) {
t.Fatalf("the input read back: %d bytes, %v", len(got), err)
}
line := &lineMem{chunks: map[string][]byte{}}
verb := k.verb(line, time.Second)
raw, _ := json.Marshal(meshwants.InputInput{Digest: digest, Call: "0123456789abcdef"})
out, err := verb("", raw)
if err != nil {
t.Fatal(err)
}
ans := out.(meshwants.InputAnswer)
want := int64((len(body) + buscalls.SeatChunkBytes - 1) / buscalls.SeatChunkBytes)
if ans.Digest != digest || ans.Chunks != want || want < 6 {
t.Fatalf("the answer %+v, want %d chunks", ans, want)
}
send := buscalls.SeatChunks{Seat: meshwants.Seat, Verb: meshwants.VerbInput, Call: "0123456789abcdef"}
var got []byte
deadline := time.Now().Add(5 * time.Second)
for n := int64(1); n <= want; n++ {
for {
line.mu.Lock()
data, ok := line.chunks[send.Chunk(n)]
line.mu.Unlock()
if ok {
var c buscalls.SeatChunk
if err := json.Unmarshal(data, &c); err != nil || c.N != n || c.Of != want {
t.Fatalf("chunk %d: %+v, %v", n, c, err)
}
got = append(got, c.Data...)
break
}
if time.Now().After(deadline) {
t.Fatalf("chunk %d of %d never sent", n, want)
}
time.Sleep(time.Millisecond)
}
}
if !bytes.Equal(got, body) {
t.Error("the chunks do not carry the input's bytes")
}
if _, err := verb("", json.RawMessage(`{"digest":"`+strings.Repeat("b", 64)+`","call":"0123456789abcdef"}`)); err == nil {
t.Error("an input never kept is answered")
}
if _, err := verb("", json.RawMessage(`{"digest":"`+digest+`","call":"x"}`)); err == nil {
t.Error("a call of no token is answered")
}
// assigner_inputs lists it with its size and gives one whole by digest
list, err := inputsRead(k).Run(map[string]any{})
if err != nil || len(list.(map[string]any)["inputs"].([]inputSaid)) != 1 || list.(map[string]any)["inputs"].([]inputSaid)[0].Size != len(body) {
t.Errorf("assigner_inputs = %+v %v", list, err)
}
}
// §354: an input is deleted when the build seat's step-ended of its ask is heard, or a day after its write, judged at each new ask.
func TestAnInputIsDeletedAtItsStepEndedOrAfterADay(t *testing.T) {
now := time.Date(2026, 11, 12, 10, 0, 0, 0, time.UTC)
k := testKeeper(&objectsMem{objs: map[string][]byte{}}, func() time.Time { return now })
d1, err := k.keep("ask/one", []byte(`{"heads":"abc"}`))
if err != nil {
t.Fatal(err)
}
if _, err := k.body(d1); err != nil {
t.Fatalf("a kept input is not read: %v", err)
}
if err := k.ended("ask/other"); err != nil {
t.Fatal(err)
}
if _, err := k.body(d1); err != nil {
t.Fatalf("the step-ended of another ask deleted it: %v", err)
}
if err := k.ended("ask/one"); err != nil {
t.Fatal(err)
}
if _, err := k.body(d1); err == nil {
t.Error("the input of an ended ask is still answered")
}
if keys, _ := k.vals.KV.Keys(); len(keys) != 0 {
t.Errorf("keys left after the step-ended: %v", keys)
}
d2, _ := k.keep("ask/two", []byte(`{"heads":"def"}`))
now = now.Add(25 * time.Hour)
if _, err := k.keep("ask/three", []byte(`{"heads":"ghi"}`)); err != nil {
t.Fatal(err)
}
if _, err := k.body(d2); err == nil {
t.Error("an input a day old is still kept at the next ask")
}
}
File diff suppressed because it is too large Load Diff
@@ -8,16 +8,20 @@ package main
import (
"context"
"errors"
"fmt"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"io"
"log"
"maps"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
@@ -25,9 +29,12 @@ import (
"github.com/nats-io/nats.go/jetstream"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
mcc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seatlist"
@@ -61,13 +68,8 @@ func main() {
// holds the two lists equal), so the controller's broker grants exactly these.
var readBuckets = []string{
buckets.Grants,
buckets.Answers,
buckets.Builds,
buckets.Presence,
buckets.Have,
buckets.Provisions,
buckets.Source,
buckets.Merges,
}
// readFamilies are the states kept one bucket per node the assigner reads, each named in its manifest's reads: every
@@ -76,22 +78,29 @@ var readFamilies = []string{buckets.ComposedRead}
// ownBuckets are the assigner's own states, as its manifest declares them: the seat mesh-module-assigner's three among them
// (ADR 0567 §1), named after the assigner as every module state is.
var ownBuckets = []string{buckets.Asks, buckets.Differences,
buckets.Puts, buckets.Wants, plc.AssignmentsBucket, plc.ProposalsBucket,
plc.CandidatesBucket, plc.PulsesBucket, assign.FindingsBucket, assign.PoliciesBucket, plc.SeatHoldersBucket,
engineReadsBucket, retiredBucket, copiesBucket}
func ownBuckets() []string {
return []string{buckets.Asks(), buckets.Differences(),
buckets.Puts(), buckets.Wants(), plc.AssignmentsBucket(), plc.ProposalsBucket(),
plc.CandidatesBucket(), plc.PulsesBucket(), assign.FindingsBucket(), assign.PoliciesBucket(), plc.SeatHoldersBucket(),
engineReadsBucket(), retiredBucket(), copiesBucket(), writtenBucket(), buckets.Waits(), buckets.PutWaits(), buckets.DeliveryWaits(), buckets.DeliveredAgain(), buckets.Inputs(), pinsBucket()}
}
// engineReadsState is the assigner's state of what each node's engine states it reads (ADR 0618 §3), one key `<node>`, as
// the verb manifest-reads last answered it or the fallback read it: what assigner_engine_reads lists and asks again.
const engineReadsState = "engine-reads"
// engineReadsBucket is that state's bucket.
const engineReadsBucket = assignment.Module + "_" + engineReadsState
func engineReadsBucket() string { return assignment.Module() + "_" + engineReadsState }
// writtenBucket is the bucket of the state written (pipelines.go): the pipeline ends the wants follow.
func writtenBucket() string { return assignment.Module() + "_" + writtenState }
// seatBuckets are the seat's states, new with it: until the controller has made them from the manifest and the bus grants
// them, the assigner starts without them and binds each once the bus says it was created (issue 1790's kind).
var seatBuckets = []string{plc.AssignmentsBucket, plc.ProposalsBucket, plc.CandidatesBucket,
plc.PulsesBucket, copiesBucket}
func seatBuckets() []string {
return []string{plc.AssignmentsBucket(), plc.ProposalsBucket(), plc.CandidatesBucket(),
plc.PulsesBucket(), copiesBucket()}
}
// keptStates is what the assigner keeps and who may read it (ADR 0495 §2-§3): its wants by the watchman and the view
// (and the node agent from release 6), its differences by the view, its asks by the view
@@ -107,7 +116,7 @@ var keptStates = []announce.State{
{Name: "differences", Readable: true},
{Name: "puts", Readable: true},
{Name: "wants", Readable: true},
{Name: mcc.FindingsState, Readable: true},
{Name: assign.FindingsState, Readable: true},
{Name: buckets.PoliciesState, Readable: true},
{Name: mma.StateSeatHolders, Readable: true},
{Name: views.MarksState, Readable: true}, // what its views family said each state up to (views.go)
@@ -115,6 +124,13 @@ var keptStates = []announce.State{
{Name: engineReadsState, Readable: true},
{Name: retiredState}, // read only by the copy into assignments (migrate.go)
{Name: copiesState, Readable: true},
{Name: writtenState, Readable: true},
{Name: buckets.WaitsState, Readable: true},
{Name: buckets.PutWaitsState, Readable: true}, // the put waits said (putwaits.go, design 47 G A-211)
{Name: buckets.DeliveryWaitsState, Readable: true}, // the delivery waits said (delivered.go, design 47 G A-246)
{Name: buckets.DeliveredAgainState, Readable: true}, // the asks of assigner_delivered_again (delivered.go, design 47 G A-250)
{Name: buckets.InputsState, Readable: true}, // read by the tool process alone: the verb input and assigner_inputs (design 47 G §354)
{Name: pinsState, Readable: true}, // the records pinning each assignment graph revision (design 47 G §579)
}
// kvSet is every bucket the assigner opened, by name.
@@ -126,18 +142,18 @@ type kvSet map[string]jetstream.KeyValue
// without it and takes no put further until it starts again (ADR 0546).
func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any)) (kvSet, error) {
out := kvSet{}
for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) {
for _, name := range append(append([]string{}, readBuckets...), ownBuckets()...) {
kv, err := js.KeyValue(ctx, name)
if name == buckets.Puts && err != nil {
if name == buckets.Puts() && err != nil {
logf("the bucket %s cannot be opened (%v): no put is taken further until the assigner starts again", name, err)
continue
}
if slices.Contains(seatBuckets, name) && err != nil {
if slices.Contains(seatBuckets(), name) && err != nil {
// The seat's states come in the same delivery as this assigner (ADR 0567 §1): without assignments no module is placed.
logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err)
continue
}
if name == plc.SeatHoldersBucket && err != nil {
if name == plc.SeatHoldersBucket() && err != nil {
// The seat's state, new with ADR 0571 §4: bound once the bus says it was made; until then nobody is told who
// holds which seat here, and the controller's seats still answer.
logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err)
@@ -149,31 +165,56 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err)
continue
}
if name == assign.PoliciesBucket && err != nil {
if name == assign.PoliciesBucket() && err != nil {
// The seat mesh-wants' state, new with it (ADR 0565 §7): bound once the bus says it was made; until then no
// person's policy is read, so the controller's beside each composition holds, as before.
logf("the bucket %s cannot be opened (%v): it is bound once the bus says it was made", name, err)
continue
}
if name == assign.FindingsBucket && err != nil {
if name == assign.FindingsBucket() && err != nil {
// New with health leaving the controller (issue 1952 WM6): without it the conditions are said straight to the
// router and kept as no finding.
logf("the bucket %s cannot be opened (%v): the assigner's conditions are said straight to the router until it starts again", name, err)
continue
}
if (name == buckets.Source || name == buckets.Merges) && err != nil {
// The gate's source and merges (ADR 0601 §1): without them no release is known to carry a build, so every move of
// a record module is asked of a person, as before.
logf("the bucket %s cannot be opened (%v): no release's Approve answers a record ask until it is bound", name, err)
continue
}
if name == engineReadsBucket && err != nil {
if name == engineReadsBucket() && err != nil {
// New with ADR 0618 §3: without it the statements the verb answers are kept in memory only, and
// assigner_engine_reads lists nothing.
logf("the bucket %s cannot be opened (%v): what each node's engine reads is kept in memory only until it starts again", name, err)
continue
}
if name == retiredBucket && err != nil {
if name == buckets.Inputs() && err != nil {
// New with the verb input (design 47 G §354): only the tool process reads it, so the program starts without it.
logf("the bucket %s cannot be opened (%v): it is read by the tool process alone", name, err)
continue
}
if name == pinsBucket() && err != nil {
// New with the pins (design 47 G §579): without it no pin is kept and no revision is removed.
logf("the bucket %s cannot be opened (%v): no pin is kept and no revision removed until it starts again", name, err)
continue
}
if name == buckets.Waits() && err != nil {
// New with the wait on an outside cause (design 47 G §706): without it no wait is made, and a failing check is judged
// as before.
logf("the bucket %s cannot be opened (%v): no outside wait is kept until the assigner starts again", name, err)
continue
}
if name == buckets.DeliveredAgain() && err != nil {
// New with A-250: without it assigner_delivered_again is not taken until the assigner starts again.
logf("the bucket %s cannot be opened (%v): no version is said delivered again until the assigner starts again", name, err)
continue
}
if name == buckets.DeliveryWaits() && err != nil {
// New with A-246: without it no dependent waits for its dependency's delivery until the assigner starts again.
logf("the bucket %s cannot be opened (%v): no delivery wait is kept until the assigner starts again", name, err)
continue
}
if name == buckets.PutWaits() && err != nil {
// New with A-211: without it no put's wait is said until the assigner starts again.
logf("the bucket %s cannot be opened (%v): no put's wait is said until the assigner starts again", name, err)
continue
}
if name == retiredBucket() && err != nil {
// The retired state is read only for the copy into `assignments` (migrate.go): a mesh made after the switch has
// none, and nothing is copied.
logf("the bucket %s cannot be opened (%v): nothing is copied from it", name, err)
@@ -196,11 +237,11 @@ func open(ctx context.Context, js process.BucketOpener, logf func(string, ...any
// states and those new with a delivery of their own, bound once made, and the two that choose the grants' source.
func startsWithout(name string) bool {
switch name {
case plc.SeatHoldersBucket, buckets.Provisions, assign.PoliciesBucket, assign.FindingsBucket,
buckets.Puts, buckets.Source, buckets.Merges, engineReadsBucket, retiredBucket:
case plc.SeatHoldersBucket(), buckets.Provisions, assign.PoliciesBucket(), assign.FindingsBucket(),
buckets.Puts(), engineReadsBucket(), retiredBucket(), writtenBucket(), buckets.Waits(), buckets.PutWaits(), buckets.DeliveryWaits(), buckets.DeliveredAgain(), buckets.Inputs(), pinsBucket():
return true
}
return slices.Contains(seatBuckets, name) || name == buckets.Grants
return slices.Contains(seatBuckets(), name) || name == buckets.Grants
}
// run is the program: it returns when ctx ends or the bus cannot be used.
@@ -210,6 +251,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err != nil {
return err
}
plc.SetModule(account.Module) // the assigner names its own states from its credential's module (A-296, §428)
conn, js, err := account.Dial()
if err != nil {
return err
@@ -231,11 +273,14 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}
thisNode = account.Node
logger.Printf("started on the account %s, reading %d buckets", account.User, len(kvs))
findings := findingsOf(ctx, kvs, logger.Printf)
findings, writer := findingsOf(ctx, account, conn, js, said, kvs, logger.Printf)
if writer != nil {
defer writer.Close()
}
// Each change of the states its readers read is said on its views family (ADR 0615 §5, ADR 0619 §2); a watch of it
// that ends stops the program, as a watch of what a pass reads does.
viewErrs := make(chan error, len(viewedStates))
startViews(ctx, js, conn, account, kvs, findings, viewErrs, logger.Printf)
viewErrs := make(chan error, len(viewedStates()))
startViews(ctx, js, conn, account, kvs, wireOf(writer), viewErrs, logger.Printf)
go func() {
select {
case <-ctx.Done():
@@ -244,27 +289,65 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
conn.Close()
}
}()
iv := &indexerViews{js: js, account: account, call: said.Call, findings: findings, logf: logger.Printf,
status: process.AwaitBucket(ctx, js, conn, firstViewsBucket, "assigner_first_views reads nothing and assigner_first_view_again is not heard", logger.Printf)}
return follow(ctx, js, conn, kvs, findings.Say(sayVia(ctx, js)), findings, emitting{said}, iv, logger)
iv := &catalogViews{js: js, account: account, call: said.Call, writer: wireOf(writer), logf: logger.Printf,
status: process.AwaitBucket(ctx, js, conn, firstViewsBucket(), "assigner_first_views reads nothing and assigner_first_view_again is not heard", logger.Printf)}
// The inputs of the composition steps it asks a build seat to run: the seat mesh-wants' verb input is answered here in chunks
// of the core connection, of any size (design 47 G §354, §712, A-237).
inputs := inputsOver(kvs[buckets.Inputs()], js, account.Module, logger.Printf)
// The seat list (seatlist.go), followed in follow over the connection and the hub: the verb input's callers are checked
// against the seat's definition it holds (A-186).
gate := seatlist.NewPart("assigner", seatListAgainCallers)
if inputs != nil {
stopInput, err := said.AnswerSeat(meshwants.Seat, seatlist.Definition(gate.Reader, meshwants.Seat), map[string]process.SeatHandler{meshwants.VerbInput: inputs.verb(conn, buscalls.SeatAckWithin)})
if err != nil {
return fmt.Errorf("answering the verb input: %w", err)
}
defer stopInput()
}
return follow(ctx, js, conn, kvs, findings.Say(sayVia(ctx, js)), findings, emitting{said}, iv, inputs, gate, logger, getenv)
}
// findingsOf is where the assigner's said conditions go as findings (issue 1952 WM6): its findings bucket, which the
// condition keeper carries (novox/hq ADR 0590 §3). Without it the conditions are said straight to the router.
func findingsOf(ctx context.Context, kvs kvSet, logf func(string, ...any)) assign.Findings {
func findingsOf(ctx context.Context, account process.Account, conn *nats.Conn, js jetstream.JetStream, said *process.Conn, kvs kvSet, logf func(string, ...any)) (assign.Findings, *heldWriter) {
var f assign.Findings
if kv := kvs[assign.FindingsBucket]; kv != nil {
f.Put = func(key string, value []byte) error { _, err := kv.Put(ctx, key, value); return err }
f.Delete = func(key string) error { return kv.Delete(ctx, key) }
f.Keys = func() ([]string, error) {
keys, err := kv.Keys(ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return keys, err
}
if kvs[assign.FindingsBucket()] == nil {
return f, nil
}
return f
state := said.State(assign.FindingsState)
w, err := views.NewHeldWriter(views.WriterConfig[mcseat.Finding, mcseat.FindingCleared, mcseat.FindingsStated, mcseat.FindingsAskedEvent]{
Module: account.Module, State: state, Node: account.Node, Asked: mcseat.SubjectFindingsAsked, JS: js, Conn: conn, Logf: logf})
if err != nil {
logf("the assigner's finding writer cannot be made, so its findings are not written: %v", err)
return f, nil
}
// A writer that cannot start (the stream of its asks is not there yet) is said; its findings are then kept in the
// state alone and said by the next start (as the node-engine's is).
if err := w.Start(ctx); err != nil {
logf("the finding writer cannot start, its findings are kept in the state alone: %v", err)
}
f.Put = func(x mcseat.Finding) error { return w.Write(ctx, x) }
f.Delete = func(key string) error {
scope, rest, _ := strings.Cut(key, ".")
i := strings.LastIndex(rest, ".")
if i < 0 {
return fmt.Errorf("%q is no finding's key", key)
}
return w.Clear(ctx, scope, rest[:i], rest[i+1:], "the assigner no longer observes it")
}
f.Keys = state.Keys
return f, w
}
// heldWriter is the assigner's finding writer: its findings are raised as they happen, so the writer holds them (design 47 G A-29).
type heldWriter = views.HeldWriter[mcseat.Finding, mcseat.FindingCleared, mcseat.FindingsStated, mcseat.FindingsAskedEvent]
// wireOf is w as the views parts' finding writer; nil when the assigner has none, so no part writes through a nil writer.
func wireOf(w *heldWriter) views.WireWriter {
if w == nil {
return nil
}
return w
}
// thisNode is the node this process runs on, as its credential names it: said with each of the seat's events.
@@ -275,34 +358,43 @@ type emitting struct{ *process.Conn }
func (e emitting) Emit(eventType string, body any) error { return e.Conn.Emit(eventType, body) }
// optionalEnds is what stops when the watch of a bucket that never gates the first pass ends.
func optionalEnds() map[string]string {
return map[string]string{buckets.Puts(): "no put is taken further", buckets.Answers: "no ask is answered"}
}
// watched are the buckets whose every value a pass reads; the manifests are read by digest when a pass needs one, and
// watched only for keys, so a manifest that arrives wakes a composition that waits for it.
var watched = []string{
buckets.Grants,
buckets.Answers,
buckets.Builds,
plc.AssignmentsBucket,
plc.ProposalsBucket,
plc.CandidatesBucket,
plc.PulsesBucket,
copiesBucket,
buckets.Presence,
buckets.Have,
buckets.Asks,
buckets.Differences,
buckets.Wants,
assign.PoliciesBucket,
buckets.Provisions,
plc.SeatHoldersBucket,
buckets.Source,
buckets.Merges,
engineReadsBucket,
func watched() []string {
return []string{
buckets.Grants,
plc.AssignmentsBucket(),
plc.ProposalsBucket(),
plc.CandidatesBucket(),
plc.PulsesBucket(),
copiesBucket(),
buckets.Have,
buckets.Asks(),
buckets.Differences(),
buckets.Wants(),
assign.PoliciesBucket(),
buckets.Provisions,
plc.SeatHoldersBucket(),
engineReadsBucket(),
writtenBucket(),
buckets.Waits(),
buckets.PutWaits(),
buckets.DeliveryWaits(),
buckets.DeliveredAgain(), // its watch wakes a pass at once on an ask of assigner_delivered_again (A-250)
}
}
// follow watches every bucket a pass reads and makes a pass at start, once every watch handed what stands, and after
// each change; changes that come during a pass make one more. It returns when ctx ends or a watch ends.
func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, kvs kvSet, say func(oc.Condition) error, findings assign.Findings, bus caller, iv *indexerViews, logger *log.Logger) error {
func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, kvs kvSet, say func(oc.Condition) error, findings assign.Findings, bus caller, iv *catalogViews, inputs *inputKeeper, gate *seatlist.Part, logger *log.Logger, getenv func(string) string) error {
w := newWorld()
// The callers' seat entries are resolved from the assigner's own seat-holders as it last read them (A-298).
gate.Holders(seatlist.HoldersIn(func() (map[string][]byte, error) { return w.read(plc.SeatHoldersBucket()), nil }))
dirty := make(chan struct{}, 1)
wake := func() {
select {
@@ -310,8 +402,8 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
default:
}
}
errs := make(chan error, len(watched)+2)
settled := make(chan struct{}, len(watched)+2)
errs := make(chan error, len(watched())+2)
settled := make(chan struct{}, len(watched())+2)
// What each node's engine reads (ADR 0618 §3, §4): asked through the seat's verb, kept in the state engine-reads, read
// from the node's record where the engine does not answer (the fallback, said in the journal).
reads := &enginereads.Keeper{Fallback: w.recordReads, Changed: wake, Logf: logger.Printf}
@@ -319,7 +411,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
reads.Call = bus.CallKeptOn // a kept call, called again through a transport fault (ADR 0668 §1)
reads.Once = bus.CallOn // its fault keeps the fallback at once while the kept call asks on (ADR 0668 §3)
}
if kv := kvs[engineReadsBucket]; kv != nil {
if kv := kvs[engineReadsBucket()]; kv != nil {
reads.Put = func(node string, v []byte) error { _, err := kv.Put(ctx, node, v); return err }
reads.Delete = func(node string) error { return kv.Delete(ctx, node) }
}
@@ -329,7 +421,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
return fmt.Errorf("the bucket %s cannot be watched: %w", name, err)
}
// The puts' watch never stops the wants (ADR 0546): when it ends, the puts are no longer heard, said in the journal.
optional := name == buckets.Puts
optional := name == buckets.Puts() || name == buckets.Answers
go func() {
defer kw.Stop()
for {
@@ -340,7 +432,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
case e, ok := <-kw.Updates():
if !ok {
if optional {
logger.Printf("the watch of %s ended: no put is taken further until the assigner starts again", name)
logger.Printf("the watch of %s ended: %s until the assigner starts again", name, optionalEnds()[name])
return
}
errs <- fmt.Errorf("the watch of %s ended", name)
@@ -356,7 +448,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
if e.Operation() == jetstream.KeyValuePut {
value = e.Value()
}
if name == engineReadsBucket {
if name == engineReadsBucket() {
reads.Heard(e.Key(), value, value == nil) // it wakes a pass when a statement changed
continue
}
@@ -369,10 +461,10 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
}
// The puts' watch is not waited for before the first pass: a watch of them that ends before it handed what stands only
// stops the puts, never the wants.
if kvs[buckets.Puts] != nil {
if err := watch(buckets.Puts); err != nil {
if kvs[buckets.Puts()] != nil {
if err := watch(buckets.Puts()); err != nil {
logger.Printf("%v: no put is taken further until it is opened again", err)
delete(kvs, buckets.Puts)
delete(kvs, buckets.Puts())
}
}
// The puts' bucket is made by the controller from the manifest's states at its start (ADR 0201), and the assigner's
@@ -381,34 +473,34 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
// further, without a restart.
var triedPuts time.Time
reopenPuts := func(p *passer) {
if kvs[buckets.Puts] != nil || time.Since(triedPuts) < PutsReopenEvery {
if kvs[buckets.Puts()] != nil || time.Since(triedPuts) < PutsReopenEvery {
return
}
triedPuts = time.Now()
kv, err := js.KeyValue(ctx, buckets.Puts)
kv, err := js.KeyValue(ctx, buckets.Puts())
if err != nil {
return
}
kvs[buckets.Puts] = kv
if err := watch(buckets.Puts); err != nil {
kvs[buckets.Puts()] = kv
if err := watch(buckets.Puts()); err != nil {
logger.Printf("%v: no put is taken further until it is opened again", err)
delete(kvs, buckets.Puts)
delete(kvs, buckets.Puts())
return
}
if bus != nil {
p.acts = newPutActs(bus, wake, logger.Printf)
}
logger.Printf("the bucket %s is open: puts are taken further", buckets.Puts)
logger.Printf("the bucket %s is open: puts are taken further", buckets.Puts())
}
started := 0
for _, name := range watched {
for _, name := range watched() {
if kvs[name] == nil && startsWithout(name) {
continue // not on the bus at this start (open said so)
}
started++
var opts []jetstream.WatchOpt
if name == buckets.Have || name == buckets.Source {
// what each node ran healthy before, for a set-back; and the release behind each source revision (ADR 0601 §1)
if name == buckets.Have {
// what each node ran healthy before, for a set-back
opts = append(opts, jetstream.IncludeHistory())
}
if err := watch(name, opts...); err != nil {
@@ -420,15 +512,36 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
hub := process.NewEventHub(logger.Printf)
// The seat list (seatlist.go), read over the connection and heard on the hub, given to catalogue at every change, and a
// pass made then: registered before the hub starts.
gate := &seatGate{}
if bus != nil {
gate.follow(seatlist.ProcessBus(bus.Call, hub), wake, logger.Printf)
gate.Follow(seatlist.ProcessBus(bus.Call, hub), wake, logger.Printf)
}
// The build seat's step-ended of an ask deletes the input kept for it (design 47 G §354) and ends the composition run that waits
// for it (A-240); the watchman's machine-away and machine-back say which machines a composition step does not compose (§64,
// §389). Registered before the hub starts.
ends, away := &runEnds{}, &awayNodes{}
if inputs != nil {
if err := hub.Handle(nodebuildgo.SubjectStepEndedAll, func(m jetstream.Msg) { heardStepEnded(inputs, ends, m, logger.Printf) }); err != nil {
return err
}
}
for subject, isAway := range map[string]bool{meshwatchdogs.SubjectMachineAwayAll: true, meshwatchdogs.SubjectMachineBackAll: false} {
if err := hub.Handle(subject, func(m jetstream.Msg) { heardPresence(away, isAway, m, logger.Printf) }); err != nil {
logger.Printf("%s is not heard, so a composition step asks a machine that sleeps: %v", subject, err)
}
}
// The indexer's manifests, modules and module graph, through its first views and views family, never its buckets
// (DLV-F2r, ADR 0631): the first pass waits for the three answers, as it waits for every watch to hand what stands.
if iv != nil {
iv.hub = hub
started += iv.start(ctx, w, wake, settled)
// The teller's answers' picture is watched as a bucket is, but never gates the first pass: a teller that does not
// answer its first view only leaves the asks unanswered, said by the reader's finding (A-175).
if iv.answers != nil {
kvs[buckets.Answers] = iv.answers
if err := watch(buckets.Answers); err != nil {
logger.Printf("%v: no ask is answered until the assigner starts again", err)
}
}
}
// The seat's states arrive with the same delivery as this assigner (ADR 0567 §1): one not on the bus at this start is
// bound once the bus says it was created, and watched from then on, with no restart (issue 1790's kind). Until then no
@@ -437,15 +550,15 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
name string
kv jetstream.KeyValue
}
assignmentsAppeared := make(chan appeared, len(seatBuckets)+2)
for _, name := range seatBuckets {
assignmentsAppeared := make(chan appeared, len(seatBuckets())+2)
for _, name := range seatBuckets() {
if kvs[name] == nil && js != nil {
process.AwaitBucket(ctx, js, nc, name, "the seat mesh-module-assigner waits for it",
logger.Printf).Ready(func(kv jetstream.KeyValue) { assignmentsAppeared <- appeared{name, kv} })
}
}
// And the two that choose where grants are read from, with the same delivery as the grants part that writes one.
for _, name := range []string{buckets.Grants, assign.PoliciesBucket, buckets.Provisions, plc.SeatHoldersBucket} {
for _, name := range []string{buckets.Grants, assign.PoliciesBucket(), buckets.Provisions, plc.SeatHoldersBucket()} {
if kvs[name] == nil && js != nil {
name := name
process.AwaitBucket(ctx, js, nc, name, "until then the bucket is not read",
@@ -475,21 +588,102 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
}
p := &passer{kvs: kvs, w: w, logf: logger.Printf, say: say, findings: findings, said: map[string]bool{}, answeredAt: map[string]uint64{},
reads: reads, seats: gate}
// The wait on an outside cause (outside.go, design 47 G §706): the keeper's three events say when to judge again, and the
// seat mesh-wants' wait-held and wait-ended are said; registered before the hub starts.
if bus != nil && kvs[buckets.Waits()] != nil {
bound, settle, err := outsideSettings(getenv)
if err != nil {
return err
}
p.outside = newOutsideActs(bus.CallKept, wake, logger.Printf, bound, settle)
for _, subject := range []string{mcseat.SubjectConditionRaised, mcseat.SubjectConditionChanged, mcseat.SubjectConditionCleared} {
subject := subject
if err := hub.Handle(subject, func(m jetstream.Msg) {
p.outside.Heard(m.Subject(), m.Data())
if err := m.Ack(); err != nil {
logger.Printf("acknowledging %s: %v", m.Subject(), err)
}
}); err != nil {
logger.Printf("%s is not heard, so a wait on an outside cause is judged again only when its check changes: %v", subject, err)
}
}
p.outside.readStart()
}
if js != nil {
p.publishWants = wantsPublisher(ctx, js, thisNode)
if iv != nil && iv.h != nil {
objects := newBusObjects(js, process.Account{Node: thisNode, Module: assignment.Module()}, assigngraph.RevisionsObjects)
p.graph = &graphKeeper{objects: objects, halves: iv.h.views}
keep := func(ctx context.Context, ev meshcatalog.CatalogRevisionEvent) error {
return p.keepCandidate(ctx, iv.call, ev)
}
iv.candidates.Store(&keep)
// A held revert's running entry and its want's, asked of the seat mesh-catalog's verb entry (design 47 G §710, A-217).
p.entries = meshcatalogclient.NewEntries(iv.h.call)
p.entries.Changed = func(meshcatalogclient.EntryRow) { wake() }
// The pins of the revisions (pins.go, design 47 G §579): the gate's pinned, freed, heads-replaced and pins-stated
// on the hub, registered before it starts.
if kv := kvs[pinsBucket()]; kv != nil {
k := &pinKeeper{ctx: ctx, st: kvEnds{ctx, kv}, objects: objects, inUse: inUseOf(w), findings: findings, now: time.Now, logf: logger.Printf}
if err := k.made(); err != nil {
logger.Printf("the state %s is not marked made, so no revision is removed until it is: %v", pinsState, err)
}
if err := hearPins(hub, k); err != nil {
logger.Printf("a pin is not heard, so no revision is removed until the assigner starts again: %v", err)
} else {
p.graph.pins = k
k.watchUnpins(kv)
}
}
}
}
// The node-engine seat's manifest-reads-changed, for one node (ADR 0618 §4): that node is asked again, with no timer.
// Heard on the assigner's durable consumer on EVENTS, which the grants part makes from the manifest's consumes; where it
// is not there yet, a node is asked again only when it comes back online.
if js != nil {
// The teller's ask-ended (issue 1966): an ask ended at the channel without an answer is written again; registered
// before hearReadsChanged starts the hub.
if err := hearAskEnded(ctx, js, hub, kvs[buckets.Asks], staleOfWorld(w), logger.Printf); err != nil {
if err := hearAskEnded(ctx, js, hub, kvs[buckets.Asks()], staleOfWorld(w), logger.Printf); err != nil {
logger.Printf("%s is not heard, so an ask that ends at the operator channel without an answer is not asked again until the assigner starts again: %v", askEndedSubject, err)
}
readEndedAtBind(ctx, js, hub, kvs[buckets.Asks], staleOfWorld(w), bus, callWait{heard: func() bool { return w.seatHeard(oc.Seat) }, findings: findings}, logger.Printf)
hearReadsChanged(ctx, js, hub, reads, findings, gate.eventsLive, logger.Printf)
readEndedAtBind(ctx, js, hub, kvs[buckets.Asks()], staleOfWorld(w), bus, callWait{heard: func() bool { return w.seatHeard(oc.Seat) }, findings: findings}, logger.Printf)
if kv := kvs[writtenBucket()]; kv != nil {
// The runner's pipeline-ended and group-ended (design 47 G §29): kept in the state written, which the wants follow
// once the first registrations are written (§95, §353).
p.ends = newPipelineEnds(kvEnds{ctx, kv}, wake, logger.Printf)
p.ends.trunk = w.trunkName
p.ends.runEnded = func(group string) error { return p.dropCandidate(ctx, group) }
err := hearEnds(hub, p.ends)
if err == nil && bus != nil {
err = followRegistrations(ctx, hub, bus.Call, p.ends)
}
if err != nil {
logger.Printf("a pipeline's end or a registration is not heard, so the wants keep the trunk-only form until the assigner starts again: %v", err)
}
}
hearDelivery(ctx, js, w, wake, findings, logger.Printf) // version-released, version-delivered, machine-away and machine-back
hearBuilds(ctx, js, &w.ends, wake, findings, logger.Printf)
if err := hearTaken(hub, &w.taken, wake, logger.Printf); err != nil {
logger.Printf("the indexer's take-ins are not heard, so an entry held by an order edge waits until the assigner starts again: %v", err)
}
hearReadsChanged(ctx, js, hub, reads, findings, gate.EventsLive, logger.Printf)
hearRunning(ctx, js, w, wake, findings, logger.Printf)
}
if bus != nil && kvs[buckets.Puts] != nil {
if bus != nil && kvs[buckets.Puts()] != nil {
p.acts = newPutActs(bus, wake, logger.Printf)
}
// The composition step (design 47 G §22, §354): the seat mesh-wants' accepted kind step, run by one run ask per machine.
if bus != nil && js != nil && inputs != nil && p.publishWants != nil {
run := &compositionRun{inputs: &compositionInputs{machines: w.machinesOf, call: bus.CallKeptOn}, keeper: inputs, call: bus.CallKept,
trunk: trunkOf(iv, w.trunkName), running: w.runningOf, away: away, put: runAskPublisher(js), ends: ends, ask: bus.CallFile, now: time.Now, logf: logger.Printf}
steps := &compositionSteps{node: thisNode, say: p.publishWants, run: run.run, now: time.Now, logf: logger.Printf,
asker: stepAskerOf(seatlist.Definition(gate.Reader, meshwants.Seat))}
if err := serveCompositionSteps(ctx, nc, steps); err != nil {
logger.Printf("the composition step is not heard, so no composition step runs until the assigner starts again: %v", err)
} else {
logger.Printf("the composition step is heard on %s", meshwants.AcceptSubjectStep)
}
}
if bus != nil {
p.seat = newSeatActs(bus, wake, logger.Printf)
}
@@ -514,7 +708,7 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
// be said before this credential may hear it: every pass, itself started by a watched change, binds each one the
// bus now holds, and says the seat waits while one is missing.
var unbound []string
for _, name := range seatBuckets {
for _, name := range seatBuckets() {
if kvs[name] != nil || js == nil {
continue
}
@@ -539,7 +733,7 @@ var PutsReopenEvery = time.Minute
// (ADR 0437 clause 9).
func sayVia(ctx context.Context, js jetstream.JetStream) func(oc.Condition) error {
return func(c oc.Condition) error {
subject, err := occ.ConditionSubject(assignment.Module)
subject, err := occ.ConditionSubject(assignment.Module())
if err != nil {
return err
}
@@ -579,7 +773,7 @@ func readsChangedHandler(reads *enginereads.Keeper, logf func(string, ...any)) f
// consumer on EVENTS, and asks the node it names again; the hub also hands the indexer's views to the followers of
// indexerViews, and starts once both are registered.
func hearReadsChanged(ctx context.Context, js jetstream.JetStream, hub *process.EventHub, reads *enginereads.Keeper, findings assign.Findings, live func(), logf func(string, ...any)) {
account := process.Account{Node: thisNode, Module: assignment.Module}
account := process.Account{Node: thisNode, Module: assignment.Module()}
w := notHeardWriter(findings, logf)
handle := readsChangedHandler(reads, logf)
err := hub.Handle(enginereads.SubjectPrefix+"*", handle)
@@ -595,7 +789,7 @@ func hearReadsChanged(ctx context.Context, js jetstream.JetStream, hub *process.
}
w.Heard(process.EventsStream)
if live != nil {
live() // the seat list, unread while the seat was silent, is read now (seatGate.eventsLive)
live() // the seat list, unread while the seat was silent, is read now (seatlist.Part.EventsLive)
}
go func() {
<-ctx.Done()
@@ -1,6 +1,9 @@
package main
import (
"encoding/json"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
@@ -25,14 +28,14 @@ import (
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seatlist"
)
// 1297/B2a: the buckets the program opens are exactly the manifest's reads and states, as the broker names them
// (`<module>.<state>` read as the bucket `<module>_<state>`), so it is granted what it reads and reads nothing else.
func TestOpensWhatItsManifestNames(t *testing.T) {
// Review round 2: the assigner's views emitter writes its finding view-unsaid (Emitter.Findings set) when it keeps findings.
findings := assign.Findings{Put: func(string, []byte) error { return nil }, Delete: func(string) error { return nil }}
if viewsEmitter(nil, process.Account{}, nil, findings, nil, t.Logf).Findings == nil {
if viewsEmitter(nil, process.Account{}, nil, &heldWriter{}, t.Logf).Findings == nil {
t.Error("the assigner's views emitter writes no view-unsaid finding")
}
// 1297/B2a: without a credential the program does not start, and says why.
@@ -66,7 +69,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
for _, pair := range []struct {
what string
code, want []string
}{{"reads", readBuckets, reads}, {"states", append(slices.Clone(ownBuckets), marksBucket, firstViewsBucket), states}} { // the marks are bound by the views family (views.go)
}{{"reads", readBuckets, reads}, {"states", append(slices.Clone(ownBuckets()), marksBucket(), firstViewsBucket()), states}} { // the marks are bound by the views family (views.go)
got, want := slices.Sorted(slices.Values(pair.code)), slices.Sorted(slices.Values(pair.want))
if !reflect.DeepEqual(got, want) {
t.Errorf("the program's %s are %v, the manifest's %v", pair.what, got, want)
@@ -76,7 +79,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
for _, s := range keptStates {
kept = append(kept, m.Module+"_"+s.Name)
}
if got, want := slices.Sorted(slices.Values(kept)), slices.Sorted(slices.Values(append(slices.Clone(ownBuckets), marksBucket, firstViewsBucket))); !reflect.DeepEqual(got, want) {
if got, want := slices.Sorted(slices.Values(kept)), slices.Sorted(slices.Values(append(slices.Clone(ownBuckets()), marksBucket(), firstViewsBucket()))); !reflect.DeepEqual(got, want) {
t.Errorf("the program announces %v, keeps %v", got, want)
}
// ADR 0546: whatever keeps the puts' bucket from opening (not there, not granted, no answer in time), the assigner opens
@@ -87,17 +90,17 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
starts bool
said string
}{
{buckets.Puts, jetstream.ErrBucketNotFound, true, "no put is taken further"},
{buckets.Puts, errors.New("nats: permissions violation for subscription to \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_puts\""), true, "no put is taken further"},
{buckets.Puts, context.DeadlineExceeded, true, "no put is taken further"},
{buckets.Wants, errors.New("nats: permissions violation"), false, ""},
{buckets.Puts(), jetstream.ErrBucketNotFound, true, "no put is taken further"},
{buckets.Puts(), errors.New("nats: permissions violation for subscription to \"$JS.API.STREAM.INFO.KV_mesh-delivery-assigner_puts\""), true, "no put is taken further"},
{buckets.Puts(), context.DeadlineExceeded, true, "no put is taken further"},
{buckets.Wants(), errors.New("nats: permissions violation"), false, ""},
// Issue 774: the grants part's grants not there do not stop the start.
{buckets.Grants, jetstream.ErrBucketNotFound, true, "no grant is read"},
} {
var logged string
kvs, err := open(context.Background(), refusingJS{missing: c.missing, err: c.err}, func(f string, a ...any) { logged += fmt.Sprintf(f, a...) })
switch {
case c.starts && (err != nil || kvs[c.missing] != nil || len(kvs) != len(readBuckets)+len(ownBuckets)-1 || !strings.Contains(logged, c.said)):
case c.starts && (err != nil || kvs[c.missing] != nil || len(kvs) != len(readBuckets)+len(ownBuckets())-1 || !strings.Contains(logged, c.said)):
t.Errorf("%s refused with %v: the start answered %v, %d buckets, logged %q", c.missing, c.err, err, len(kvs), logged)
case !c.starts && (err == nil || !strings.Contains(err.Error(), "grant of "+c.missing)): // 1945: named with its grant
t.Errorf("%s refused with %v: the start answered %v", c.missing, c.err, err)
@@ -109,7 +112,7 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
defer cancel()
wrote := make(chan string, 8)
kvs := kvSet{}
for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) {
for _, name := range append(append([]string{}, readBuckets...), ownBuckets()...) {
kvs[name] = &watchKV{memKV: &memKV{values: map[string][]byte{}}}
}
placed, err := mmac.AssignmentsValues(mma.Assignments{Node: "ace"})
@@ -117,12 +120,12 @@ func TestOpensWhatItsManifestNames(t *testing.T) {
t.Fatal(err)
}
for key, raw := range placed {
kv := kvs[plc.AssignmentsBucket].(*watchKV)
kv := kvs[plc.AssignmentsBucket()].(*watchKV)
kv.entries = append(kv.entries, kvEntry{key: key, value: raw})
}
kvs[buckets.Puts].(*watchKV).closed = true
kvs[buckets.Wants].(*watchKV).wrote = wrote
go follow(ctx, nil, nil, kvs, func(oc.Condition) error { return nil }, assign.Findings{}, nil, nil, log.New(io.Discard, "", 0))
kvs[buckets.Puts()].(*watchKV).closed = true
kvs[buckets.Wants()].(*watchKV).wrote = wrote
go follow(ctx, nil, nil, kvs, func(oc.Condition) error { return nil }, assign.Findings{}, nil, nil, nil, seatlist.NewPart("assigner", seatListAgainCallers), log.New(io.Discard, "", 0), func(string) string { return "" })
select {
case key := <-wrote:
if key != "want.ace.node" {
@@ -197,3 +200,12 @@ func (e kvEntry) Created() time.Time { return time.Time{} }
func (w *watchKV) Keys(context.Context, ...jetstream.WatchOpt) ([]string, error) {
return nil, jetstream.ErrNoKeysFound
}
// putInto is an assign.Findings Put that keeps each finding in into, as the finding writer would, by its key and its words.
func putInto(into map[string][]byte) func(mcseat.Finding) error {
return func(f mcseat.Finding) error {
raw, err := json.Marshal(f)
into[views.FindingKey(f.Scope, f.ID, f.Kind)] = raw
return err
}
}
@@ -18,10 +18,9 @@ import (
// The state this seat's assignments were kept in before ADR 0641 gave it its name. The assigner reads it here and nowhere
// else: at its start it copies each key into `assignments` (copyRetired) and keeps no reader of it afterwards. The
// manifest still declares the state, so the assigner's account may read it for the copy; a later release drops it.
const (
retiredState = "placement"
retiredBucket = plc.Module + "_" + retiredState
)
const retiredState = "placement"
func retiredBucket() string { return plc.Module() + "_" + retiredState }
// CopiedEvent is the module's event saying the copy was made, once: mesh.mod.mesh-delivery-assigner.event.assignments-copied.
const CopiedEvent = "assignments-copied"
@@ -37,10 +36,9 @@ type copiedSaid struct {
// copiesState is the assigner's own state of its copies, read by its tool assigner_read_copies, and copiesBucket its bucket.
// The retired state gets nothing written: readers of other modules still parse every key in it.
const (
copiesState = "copies"
copiesBucket = plc.Module + "_" + copiesState
)
const copiesState = "copies"
func copiesBucket() string { return plc.Module() + "_" + copiesState }
// copiedMarker is the key, in the state copies, that says the copy ended: its time and the keys it copied. It is written
// after the last key, and the copy is made at every start until it is there and never after, so a copy cut short by a
@@ -63,12 +61,12 @@ func (p *passer) copyRetired(ctx context.Context) bool {
if p.copied {
return true
}
old, next, copies := p.kvs[retiredBucket], p.kvs[plc.AssignmentsBucket], p.kvs[copiesBucket]
old, next, copies := p.kvs[retiredBucket()], p.kvs[plc.AssignmentsBucket()], p.kvs[copiesBucket()]
if next == nil || copies == nil {
return false
}
if old == nil {
p.logf("assignments: the retired state %s cannot be read, so nothing is copied from it", retiredBucket)
p.logf("assignments: the retired state %s cannot be read, so nothing is copied from it", retiredBucket())
p.copied = true
return true
}
@@ -77,12 +75,12 @@ func (p *passer) copyRetired(ctx context.Context) bool {
p.copied = true // the copy ended at an earlier start: nothing is copied again
return true
case !errors.Is(err, jetstream.ErrKeyNotFound):
p.logf("assignments: the marker %s of %s cannot be read, and the copy is tried again at the next pass: %v", copiedMarker, copiesBucket, err)
p.logf("assignments: the marker %s of %s cannot be read, and the copy is tried again at the next pass: %v", copiedMarker, copiesBucket(), err)
return false
}
names, err := old.Keys(ctx)
if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) {
p.logf("assignments: the keys of %s cannot be listed, and are tried again at the next pass: %v", retiredBucket, err)
p.logf("assignments: the keys of %s cannot be listed, and are tried again at the next pass: %v", retiredBucket(), err)
return false
}
// A node's module keys before its node key: the node key is the commit the readers wait for.
@@ -120,13 +118,13 @@ func (p *passer) copyRetired(ctx context.Context) bool {
return false
default:
said.Copied++
p.w.putEntry(plc.AssignmentsBucket, key, entry.Value(), rev, p.clock())
p.w.putEntry(plc.AssignmentsBucket(), key, entry.Value(), rev, p.clock())
}
}
}
mark, _ := json.Marshal(copiedMark{At: p.clock(), Copied: said.Copied + said.Present})
if _, err := copies.Put(ctx, copiedMarker, mark); err != nil {
p.logf("assignments: the end of the copy is not written as %s in %s, and the copy is tried again at the next pass: %v", copiedMarker, copiesBucket, err)
p.logf("assignments: the end of the copy is not written as %s in %s, and the copy is tried again at the next pass: %v", copiedMarker, copiesBucket(), err)
return false
}
p.copied = true
@@ -134,7 +132,7 @@ func (p *passer) copyRetired(ctx context.Context) bool {
return true // the retired state holds nothing
}
p.logf("assignments: copied %d keys of %s into %s (%d already there kept as they are, %d of the form placement.<node> not copied, %d fitting no form left)",
said.Copied, retiredBucket, plc.AssignmentsBucket, said.Present, said.Dropped, said.Skipped)
said.Copied, retiredBucket(), plc.AssignmentsBucket(), said.Present, said.Dropped, said.Skipped)
if p.seat != nil {
if err := p.seat.bus.Emit(CopiedEvent, said); err != nil {
p.logf("assignments: the copy is not said as an event: %v", err)
@@ -0,0 +1,118 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"sync"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-assigner/internal/assigngraph"
)
// busObjects is the assigner's own object bucket assignment-revisions in the old `objects` section (novox/hq ADR 0619 §2),
// which the grants part creates, as the passer's process reaches it: opened at its first use and kept; until the bucket exists
// every call is an error saying so, and the next call opens it again (design 47 G A-67, modelled on the indexer's busObjects).
type busObjects struct {
js jetstream.JetStream
module string
local string
mu sync.Mutex
o jetstream.ObjectStore
}
// newBusObjects is the object bucket of this local name of the module the account runs as.
func newBusObjects(js jetstream.JetStream, cred process.Account, local string) *busObjects {
return &busObjects{js: js, module: cred.Module, local: local}
}
func (b *busObjects) open(ctx context.Context) (jetstream.ObjectStore, error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.o != nil {
return b.o, nil
}
o, err := b.js.ObjectStore(ctx, catalogue.ObjectsBucket(b.module, b.local))
if errors.Is(err, jetstream.ErrBucketNotFound) || errors.Is(err, jetstream.ErrStreamNotFound) {
return nil, fmt.Errorf("the object bucket %s is not on the bus yet (the grants part creates it from the manifest's objects): %w", b.local, process.ErrNotYet)
}
if err != nil {
return nil, fmt.Errorf("the object bucket %s is not open: %w", b.local, err)
}
b.o = o
return o, nil
}
func (b *busObjects) Put(ctx context.Context, name string, data []byte) error {
o, err := b.open(ctx)
if err != nil {
return err
}
_, err = o.Put(ctx, jetstream.ObjectMeta{Name: name}, bytes.NewReader(data))
return err
}
func (b *busObjects) Get(ctx context.Context, name string) ([]byte, error) {
o, err := b.open(ctx)
if err != nil {
return nil, err
}
data, err := o.GetBytes(ctx, name)
if errors.Is(err, jetstream.ErrObjectNotFound) {
return nil, assigngraph.ErrNoObject
}
return data, err
}
func (b *busObjects) Names(ctx context.Context) ([]string, error) {
o, err := b.open(ctx)
if err != nil {
return nil, err
}
infos, err := o.List(ctx)
if errors.Is(err, jetstream.ErrNoObjectsFound) {
return []string{}, nil
}
if err != nil {
return nil, err
}
names := make([]string, 0, len(infos))
for _, i := range infos {
names = append(names, i.Name)
}
return names, nil
}
// Delete removes the object of that name; one not held is no error (design 47 G §579: a revision nothing pins is removed).
func (b *busObjects) Delete(ctx context.Context, name string) error {
o, err := b.open(ctx)
if err != nil {
return err
}
if err := o.Delete(ctx, name); err != nil && !errors.Is(err, jetstream.ErrObjectNotFound) {
return err
}
return nil
}
// Fill is the bytes the bucket holds and its cap (assigngraph.Filler).
func (b *busObjects) Fill(ctx context.Context) (int64, int64, error) {
o, err := b.open(ctx)
if err != nil {
return 0, 0, err
}
st, err := o.Status(ctx)
if err != nil {
return 0, 0, err
}
info, err := b.js.Stream(ctx, "OBJ_"+st.Bucket())
if err != nil {
return int64(st.Size()), 0, err
}
return int64(st.Size()), info.CachedInfo().Config.MaxBytes, nil
}
@@ -0,0 +1,296 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strconv"
"sync"
"git.novox.be/novox/mesh-contracts/go/seats/meshbroker"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assigngraph"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
// The three order edges of a delivery that crosses a form change (design 47 G §298, A-245, A-258, A-259, A-260). Each is an acting wait of the
// assigner, kept in the state `waits` under `order.<node>.<module>.<part>` beside the acting waits, in the step placing, said as wait-held
// when a want is held for it and wait-ended (ran, released, replaced) when it moves; assigner_waits reads them and assigner_release with
// how `now` releases them, as it does an acting wait. The holders of the seats are read from the seat holders the assignments compose
// (assignment.SeatHoldersOf), never named:
// - the node-backup holder's and the bus module's (the holder of the seat mesh-broker) new versions run before a module whose kept data's
// form changes is moved, since the restore point needs both (OrderBackup);
// - at a revert a node's node-engine goes back last on that node, after every module it restores there runs the reverted build, which it
// starts only once it has said `restored` (OrderEngineLast);
// - the reverted assigner and gate are wanted only once the indexer's take-in.taken of the trunk branch names a source revision at least
// the trunk's head, the highest `src` of the trunk branch's module values (OrderTakenIn, A-260).
// takenSubject is the indexer's event take-in.taken on its own stream, as the manifest's consumes names it.
var takenSubject = process.ModuleStreamSubject(buckets.IndexerModule, "take-in.taken")
// takenSources are the newest source revisions a take-in.taken named, by watched branch, as heard (hearBuildsAndReleases).
type takenSources struct {
mu sync.Mutex
own map[string]int64
}
// put takes one take-in.taken in. A hand drop, and a taken with no source revision (one said before G), take nothing in.
func (t *takenSources) put(data []byte) error {
var e meshcatalog.TakeInTaken
if err := json.Unmarshal(data, &e); err != nil {
return fmt.Errorf("a take-in.taken that is none: %w", err)
}
if e.Dropped || e.Source == 0 || e.Branch == "" {
return nil
}
t.mu.Lock()
defer t.mu.Unlock()
if t.own == nil {
t.own = map[string]int64{}
}
t.own[e.Branch] = max(t.own[e.Branch], e.Source)
return nil
}
// of is the newest source revision of branch taken in; 0 before one is heard.
func (t *takenSources) of(branch string) int64 {
t.mu.Lock()
defer t.mu.Unlock()
return t.own[branch]
}
// orderEdge is one order edge held on module's want on node: part's version fp must run on partNode (OrderBackup, OrderEngineLast), or the
// trunk's source revision source be taken in (OrderTakenIn).
type orderEdge struct {
node, module, part, partNode, fp, entry, order string
source int64
}
func (e orderEdge) key() string { return acting.OrderKey(e.node, e.module, e.part) }
func (e orderEdge) acting() assign.ActingEdge {
return assign.ActingEdge{Node: e.partNode, Part: e.part, Fp: e.fp, Step: seatdef.StepPlace}
}
// orderRuns says an order edge is met: its part runs the version, or the source revision is taken in.
func (p *passer) orderRuns(n assign.Nodes, e orderEdge) bool {
if e.order == acting.OrderTakenIn {
return p.w.taken.of(p.w.trunkName()) >= e.source
}
return n.Runs(e.acting())
}
// seatHolders are the holders of each seat as the assignments compose them, by seat name.
func (p *passer) holdersBySeat() map[string][]mma.SeatHoldersHoldersItem {
out := map[string][]mma.SeatHoldersHoldersItem{}
for _, sh := range assignment.SeatHoldersOf(p.w.assignments()) {
out[sh.Seat] = append(out[sh.Seat], sh.Holders...)
}
return out
}
// orderEdgesOf computes §298's edges on the wants of module m on each of its nodes, into p.orders. wanted is m's wants by node; trunk the
// trunk's module values. A node that has no running build heard, runs m at its trunk version already, or is wanted that version already
// has none: nothing moves. It answers, by node, whether an edge still stands that cannot be recorded as a wait because the catalog entry
// it reads is not answered yet (asked, and its answer wakes a pass): the want is held meanwhile, never written across a form change unread.
func (p *passer) orderEdgesOf(ctx context.Context, m assign.Module, wanted map[string]mwc.Want, trunk map[string]meshcatalog.ModulesByModuleValue) map[string]bool {
unread := map[string]bool{}
p.orderVisited[m.Name] = true
if m.FP == "" || p.graph == nil || p.entries == nil {
return unread
}
var holders map[string][]mma.SeatHoldersHoldersItem
for _, node := range m.Nodes {
if w, ok := wanted[node]; ok && !w.Removed && w.Fp == m.FP {
continue // the version is wanted already: nothing moves, so nothing waits
}
run, heard := p.w.runningOf(node, m.Name)
if !heard || run.Stopped || run.Fingerprint == "" || run.Fingerprint == m.FP {
continue
}
if holders == nil {
holders = p.holdersBySeat()
}
held := func(seatName string) bool {
for _, h := range holders[seatName] {
if h.Module == m.Name && h.Node == node {
return true
}
}
return false
}
add := func(e orderEdge) {
e.node, e.module, e.entry = node, m.Name, m.FP
p.orders[e.key()] = e
}
// 1. a kept data's form changes: the restore point needs the new node-backup holder and bus module.
changes, known := p.formChange(ctx, m.Name, run.Fingerprint, m.FP)
if !known {
unread[node] = true
}
if len(changes) > 0 {
for seatName, onNode := range map[string]bool{catalogue.BackupSeat: true, meshbroker.Seat: false} {
for _, h := range holders[seatName] {
if h.Module == m.Name || (onNode && h.Node != node) || trunk[h.Module].Fp2 == "" {
continue
}
add(orderEdge{part: h.Module, partNode: h.Node, fp: trunk[h.Module].Fp2, order: acting.OrderBackup})
}
}
}
if !held(nodeengine.Seat) && !held(mma.Seat) && !held(meshdelivery.Seat) {
continue
}
reverted, err := assigngraph.Reverted(ctx, p.graph.objects, node, m.Name, run.Fingerprint, m.FP)
if err != nil {
p.logf("whether the want of %s on %s at %s is a revert is not read: %v", m.Name, node, m.FP, err)
continue
}
if !reverted {
continue
}
// 2. at a revert the node-engine goes back last on its node: after every module that is set back there across a form change runs.
if held(nodeengine.Seat) {
for module, byNode := range p.w.wantsOf() {
// What it is set back to is its trunk version, which the rules write as its want in this pass.
target := trunk[module].Fp2
if w, ok := byNode[node]; module == m.Name || !ok || w.Removed || target == "" {
continue
}
xrun, heard := p.w.runningOf(node, module)
if !heard || xrun.Stopped || xrun.Fingerprint == "" || xrun.Fingerprint == target {
continue
}
if back, err := assigngraph.Reverted(ctx, p.graph.objects, node, module, xrun.Fingerprint, target); err != nil || !back {
continue
}
xchanges, known := p.formChange(ctx, module, xrun.Fingerprint, target)
if !known {
unread[node] = true
}
if len(xchanges) > 0 {
add(orderEdge{part: module, partNode: node, fp: target, order: acting.OrderEngineLast})
}
}
}
// 3. the reverted assigner and gate wait for the indexer to take in the trunk's head.
if held(mma.Seat) || held(meshdelivery.Seat) {
var head int64
for _, v := range p.w.trunkOf(p.w.trunkName()) {
head = max(head, v.Src)
}
if head > 0 {
add(orderEdge{part: buckets.IndexerModule, fp: strconv.FormatInt(head, 10), source: head, order: acting.OrderTakenIn})
}
}
}
return unread
}
// orderActing is the order edges of module on node that still hold its want, as acting edges: those not met, and not released by hand. A
// node whose catalog entry is not read yet holds its want too, on a stand-in edge no part runs.
func (p *passer) orderActing(module string, released map[string]bool, unread map[string]bool, n func() assign.Nodes) func(node string) []assign.ActingEdge {
return func(node string) []assign.ActingEdge {
var out []assign.ActingEdge
nodes := n()
for _, e := range p.orders {
if e.module == module && e.node == node && !released[e.key()+"@"+e.fp] && !p.orderRuns(nodes, e) {
out = append(out, assign.ActingEdge{Node: e.partNode, Part: e.part, Fp: e.fp, Step: seatdef.StepPlace})
}
}
if unread[node] {
out = append(out, assign.ActingEdge{Node: node, Part: "the catalog entry", Step: seatdef.StepPlace})
}
return out
}
}
// orderWaits keeps the order waits after the pass's wants, as actingWaits keeps the acting waits: it starts the wait of each edge not met,
// ends the wait of one that ran, was replaced or was released, and says each.
func (p *passer) orderWaits(ctx context.Context, n assign.Nodes, wanted map[string]map[string]mwc.Want) {
if p.kvs[buckets.Waits()] == nil {
return
}
now := p.clock()
kept := p.w.read(buckets.Waits())
trunk := p.w.trunk()
if p.orderSaid == nil {
p.orderSaid = map[string]bool{}
}
standing := map[string]bool{}
for key, e := range p.orders {
if w, ok := wanted[e.module][e.node]; ok && !w.Removed && w.Fp == e.entry {
continue
}
if p.orderRuns(n, e) {
continue
}
standing[key] = true
if raw := kept[key]; raw != nil {
if w, err := acting.Decode(raw); err == nil && w.Entry == e.entry && w.Fp == e.fp {
if !w.Released && !p.orderSaid[key] {
// A wait still held after a restart is said again, as wait-held (A-260).
if err := p.wantsSay(seat.EventWaitHeld, acting.Body(w, assignment.Module()), key+"."+e.fp+"."+e.entry+".again"); err != nil {
p.logf("the wait of %s on %s for %s is not said again, and is said at the next pass: %v", e.module, e.node, e.part, err)
} else {
p.orderSaid[key] = true
}
}
p.actingReleaseSaid(ctx, key, w)
continue
}
}
if !n.Online[e.node] {
continue // a node away holds only itself, and its clock has not started (§268)
}
w := acting.Wait{Step: seatdef.StepPlace, Node: e.node, Module: e.module, Part: e.part, PartNode: e.partNode, Fp: e.fp, Entry: e.entry, Order: e.order,
Since: now, ClockFrom: now, Bound: acting.BoundOf(seat.Edge{Step: seatdef.StepPlace}).String()}
if err := p.wantsSay(seat.EventWaitHeld, acting.Body(w, assignment.Module()), key+"."+e.fp+"."+e.entry); err != nil {
p.logf("the wait of %s on %s for %s is not said, and is said at the next pass: %v", e.module, e.node, e.part, err)
continue
}
p.put(ctx, buckets.Waits(), key, func() ([]byte, error) { return acting.Encode(w) })
p.orderSaid[key] = true
}
for key, raw := range kept {
if !acting.IsOrder(key) || standing[key] {
continue
}
w, err := acting.Decode(raw)
if err != nil {
continue
}
if _, there := trunk[w.Module]; there && !p.orderVisited[w.Module] {
continue // the module was not looked at in this pass (no pipeline passed for it yet): its wait stands as kept
}
how := acting.HowRan
switch e, there := p.orders[key]; {
case w.Released:
how = acting.HowReleased
case there && e.fp != w.Fp, !there && wanted[w.Module][w.Node].Fp != w.Entry:
how = acting.HowReplaced
}
if !w.EndSaid {
body := acting.Body(w, assignment.Module())
body.How = how
if err := p.wantsSay(seat.EventWaitEnded, body, key+"."+w.Fp+"."+w.Entry+"."+how); err != nil {
p.logf("the end of the wait of %s on %s for %s is not said, and is said at the next pass: %v", w.Module, w.Node, w.Part, err)
continue
}
}
p.remove(ctx, buckets.Waits(), key)
delete(p.orderSaid, key)
}
}
@@ -0,0 +1,351 @@
package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/meshbroker"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshcatalogclient"
mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshdelivery"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/assigngraph"
plc "mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
)
// orderFP is a fingerprint of the form the trunk keeps, made of one letter.
func orderFP(c string) string {
return meshcatalogclient.SourcePrefix2 + strings.Repeat(fmt.Sprintf("%x", c[0]&15), 64)
}
// orderMod is one module of an order test: the fingerprint it runs on ace and the one the trunk holds, the form of its kept data at each
// fingerprint (0: it keeps none), the seats it holds and the source revision of its trunk value.
type orderMod struct {
name string
run, trunk string
forms map[string]int
holds []string
src int64
}
type orderFixture struct {
t *testing.T
p *passer
wants *memKV
events []string
bodies []meshwants.Wait
answers chan struct{}
}
// newOrderFixture is a pass on node ace, which composes its own modules, with each module running its fingerprint healthy, built and
// granted at the trunk's, an older want, the catalog's entries answered and each held seat as the assignments say it. history lists the
// fingerprints of each module by assignment graph revision, oldest first (the objects Reverted reads).
func newOrderFixture(t *testing.T, mods []orderMod, history ...map[string]string) *orderFixture {
t.Helper()
ctx := context.Background()
f := &orderFixture{t: t, wants: &memKV{values: map[string][]byte{}}, answers: make(chan struct{}, 64)}
f.p = newPasser(kvSet{buckets.Wants(): f.wants, buckets.Asks(): &memKV{values: map[string][]byte{}}, buckets.Waits(): &memKV{values: map[string][]byte{}}}, time.Now())
p := f.p
p.logf = t.Logf
p.say = func(oc.Condition) error { return nil }
p.publishWants = func(event string, body any, id string) error {
f.events = append(f.events, event)
f.bodies = append(f.bodies, body.(meshwants.Wait))
return nil
}
digest := "sha256:" + strings.Repeat("c", 64)
pl := mma.Assignments{Node: "ace"}
forms := map[string]int{} // by fingerprint: each module's own
modules := []string{}
for _, m := range mods {
modules = append(modules, m.name)
pl.Modules = append(pl.Modules, mma.PlacedModule{Module: m.name})
for _, seat := range m.holds {
scope := "node"
if seat != catalogue.BackupSeat && seat != nodeengine.Seat {
scope = "mesh"
}
pl.Holds = append(pl.Holds, mma.Holding{Claim: seat, Module: m.name, Scope: scope})
}
for fp, form := range m.forms {
forms[fp] = form
}
mdigest := fmt.Sprintf("%064x", len(modules))
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: m.trunk, Manifest: mdigest, Repository: "novox/" + m.name, Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath, Src: m.src})
putModuleRaw(p.w, trunkBranch, m.name, mod)
p.w.manifests[mdigest] = parsed{module: m.name, manifest: catalogue.Manifest{Module: m.name}}
endDone(t, p.w, m.name, m.trunk, mdigest)
gkey, _ := grantsstate.PartGrantKey(m.name, m.trunk)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
p.w.put(buckets.Grants, gkey, grant)
f.runs(m.name, m.run)
wkey, _ := mwc.WantKey("ace", m.name)
old, _ := mwc.EncodeWant(mwc.Want{Fp: m.run, Self: true})
p.w.put(buckets.Wants(), wkey, old)
f.wants.values[wkey] = old
}
composes(t, p.w, "ace", digest)
values, err := mmac.AssignmentsValues(pl)
if err != nil {
t.Fatal(err)
}
for key, raw := range values {
p.w.put(plc.AssignmentsBucket(), key, raw)
}
p.w.heardPresence("ace", false, time.Now()) // ace is online: its last presence event heard is machine-back
objects := memObjects{}
for i, at := range history {
half := meshcatalogclient.Half{Revision: meshcatalog.CatalogRevision{Branch: trunkBranch, Kind: "trunk", Revision: int64(i + 1), Source: int64(i + 1)},
Entries: map[string]meshcatalog.CatalogEntry{}}
for _, module := range modules {
digest := fmt.Sprintf("sha256:%064x", (i+1)*1000+len(half.Entries))
half.Revision.Entries = append(half.Revision.Entries, meshcatalog.EntryRef{Module: module, Digest: digest})
half.Entries[module] = meshcatalog.CatalogEntry{Module: module, Fp: at[module]}
}
placed := mma.Assignments{Node: "ace"}
for _, module := range modules {
placed.Modules = append(placed.Modules, mma.PlacedModule{Module: module})
}
in := assigngraph.Inputs{Halves: map[string]meshcatalogclient.Half{trunkBranch: half}, FirstBranch: func(string) string { return trunkBranch },
Placements: map[string]mma.Assignments{"ace": placed}}
if _, _, err := assigngraph.Keep(ctx, objects, assigngraph.Compute(in), "test", time.Now()); err != nil {
t.Fatal(err)
}
}
p.graph = &graphKeeper{objects: objects}
p.entries = meshcatalogclient.NewEntries(func(_ string, body any) (json.RawMessage, error) {
in := body.(meshcatalog.EntryInput)
entry := meshcatalog.CatalogEntry{Module: "m", Fp: in.Fingerprint, Manifest: map[string]any{"module": "m"}}
if form := forms[in.Fingerprint]; form > 0 {
entry.Manifest = map[string]any{"module": "m", "state": []any{map[string]any{"name": "sessions", "form": form}}}
}
return json.Marshal(meshcatalog.EntryAnswer{Entry: entry})
})
p.entries.Changed = func(meshcatalogclient.EntryRow) { f.answers <- struct{}{} }
return f
}
// runs says module runs fp healthy on ace, heard as the node-engine seat's running and in the have.
func (f *orderFixture) runs(module, fp string) {
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: "sha256:" + strings.Repeat("c", 64), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
f.p.w.put(buckets.Have, "ace."+module, have)
f.p.w.running["ace."+module] = nodeengine.Running{Node: "ace", Module: module, Fingerprint: fp}
}
// pass makes a pass, and after it every catalog entry it asked is answered.
func (f *orderFixture) pass() {
f.t.Helper()
f.p.wants(context.Background())
asked := 0
for _, row := range f.p.entries.Rows() {
if row.State == meshcatalogclient.EntryAsked {
asked++
}
}
for range asked {
select {
case <-f.answers:
case <-time.After(10 * time.Second): // a bound on a failure only: the answers come at once
f.t.Fatal("an entry asked was not answered")
}
}
}
// wantAt is the fingerprint module is wanted at on ace.
func (f *orderFixture) wantAt(module string) string {
f.t.Helper()
key, _ := mwc.WantKey("ace", module)
w, err := mwc.DecodeWant(f.wants.values[key])
if err != nil {
f.t.Fatal(err)
}
return w.Fp
}
func (f *orderFixture) waits() []string {
var out []string
for key := range f.p.w.read(buckets.Waits()) {
if acting.IsOrder(key) {
out = append(out, key)
}
}
return out
}
// Design 47 G §298 first edge, A-258, A-259: a module whose kept data's form changes is not moved until the new version of the node-backup
// holder and of the holder of the seat mesh-broker run; the wait is kept under `order.` in the state waits, said as wait-held, and ends
// ran (wait-ended) when the holders run; a module whose kept data keeps its form is moved at once.
func TestAModuleWhoseKeptDataChangesFormWaitsForTheNewBackupHolderAndTheBusModule(t *testing.T) {
f := newOrderFixture(t, []orderMod{
{name: "web", run: orderFP("a"), trunk: orderFP("b"), forms: map[string]int{orderFP("a"): 1, orderFP("b"): 2}},
{name: "plain", run: orderFP("c"), trunk: orderFP("d"), forms: map[string]int{orderFP("c"): 1, orderFP("d"): 1}},
{name: "backup", run: orderFP("e"), trunk: orderFP("f"), holds: []string{catalogue.BackupSeat}},
{name: "bus", run: orderFP("g"), trunk: orderFP("h"), holds: []string{meshbroker.Seat}},
})
f.pass() // the entries are asked: no want crosses a form change unread
if got := f.wantAt("web"); got != orderFP("a") {
t.Fatalf("web is wanted at %s before the entries are read", got)
}
f.pass()
if got := f.wantAt("web"); got != orderFP("a") {
t.Fatalf("web moved across a form change at %s while the new node-backup holder and bus module do not run", got)
}
if got := f.wantAt("plain"); got != orderFP("d") {
t.Errorf("a module whose kept data keeps its form is wanted at %s, not moved", got)
}
if len(f.waits()) != 2 || len(f.events) != 2 || f.events[0] != meshwants.EventWaitHeld || f.bodies[0].Step != seatdef.StepPlace || f.bodies[0].Module != "web" {
t.Fatalf("waits %v events %v bodies %+v: want two wait-held in the step place, `order.ace.web.backup` and `order.ace.web.bus`", f.waits(), f.events, f.bodies)
}
if _, err := acting.Decode(f.p.w.read(buckets.Waits())[acting.OrderKey("ace", "web", "backup")]); err != nil {
t.Fatalf("the wait is not kept under its key: %v", err)
}
f.pass()
if len(f.events) != 2 {
t.Fatalf("a pass that changed nothing said %v", f.events)
}
f.runs("backup", orderFP("f"))
f.pass()
if got := f.wantAt("web"); got != orderFP("a") || len(f.events) != 3 || f.events[2] != meshwants.EventWaitEnded || f.bodies[2].How != acting.HowRan {
t.Fatalf("with the bus module still old: web at %s, events %v", got, f.events)
}
f.runs("bus", orderFP("h"))
f.pass()
f.pass()
if got := f.wantAt("web"); got != orderFP("b") || len(f.waits()) != 0 || len(f.events) != 4 {
t.Fatalf("with both running: web at %s, waits %v, events %v", got, f.waits(), f.events)
}
}
// §298 first edge, the hand: assigner_waits reads an order wait and assigner_release with how `now` lets the module move.
func TestAnOrderWaitIsReadAndReleasedByTheExistingTools(t *testing.T) {
f := newOrderFixture(t, []orderMod{
{name: "web", run: orderFP("a"), trunk: orderFP("b"), forms: map[string]int{orderFP("a"): 1, orderFP("b"): 2}},
{name: "backup", run: orderFP("e"), trunk: orderFP("f"), holds: []string{catalogue.BackupSeat}},
})
f.pass()
f.pass()
var acts []string
s := newPutTools(memStore{waitsState: f.p.kvs[buckets.Waits()].(*memKV), buckets.DeliveryWaitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveredAgainState: &memKV{values: map[string][]byte{}}}, func(key string, body any) (json.RawMessage, error) {
acts = append(acts, key)
return json.RawMessage(`{"ok":true,"log":"logged"}`), nil
})
got, err := s.doWaits(map[string]any{"module": "web"})
if list := got.(map[string]any)["waits"].([]any); err != nil || len(list) != 1 || list[0].(actingSaid).Key != "order.ace.web.backup" {
t.Fatalf("assigner_waits = %+v %v", got, err)
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "web", "how": "now", "why": "the restore point is taken by hand"}, "shanks.node-tools"); err != nil || len(acts) == 0 {
t.Fatalf("%v %v", err, acts)
}
f.p.w.put(buckets.Waits(), "order.ace.web.backup", f.p.kvs[buckets.Waits()].(*memKV).values["order.ace.web.backup"])
f.pass()
f.pass()
if got := f.wantAt("web"); got != orderFP("b") || f.bodies[len(f.bodies)-1].How != acting.HowReleased {
t.Fatalf("after the release: web at %s, last %+v", got, f.bodies[len(f.bodies)-1])
}
}
// §298 second edge, A-259: at a revert the node-engine goes back last on its node, after every module that is set back there across a
// form change runs the reverted build.
func TestAtARevertTheNodeEngineGoesBackLast(t *testing.T) {
old, now := map[string]string{"web": orderFP("a"), "engine": orderFP("c")}, map[string]string{"web": orderFP("b"), "engine": orderFP("d")}
f := newOrderFixture(t, []orderMod{
{name: "web", run: orderFP("b"), trunk: orderFP("a"), forms: map[string]int{orderFP("a"): 1, orderFP("b"): 2}},
{name: "engine", run: orderFP("d"), trunk: orderFP("c"), holds: []string{nodeengine.Seat}},
}, old, now)
f.pass()
f.pass()
if f.wantAt("web") != orderFP("a") {
t.Fatalf("web is wanted at %s: the module set back is not held", f.wantAt("web"))
}
if got := f.wantAt("engine"); got != orderFP("d") {
t.Fatalf("the node-engine is set back to %s before web runs its reverted build", got)
}
if w := f.waits(); len(w) != 1 || w[0] != "order.ace.engine.web" || f.bodies[len(f.bodies)-1].Module != "engine" {
t.Fatalf("waits %v bodies %+v", w, f.bodies)
}
f.runs("web", orderFP("a"))
f.pass()
f.pass()
if got := f.wantAt("engine"); got != orderFP("c") || len(f.waits()) != 0 {
t.Fatalf("with web back: the node-engine is wanted at %s, waits %v", got, f.waits())
}
}
// §298 third edge, A-259, A-260: the reverted assigner and gate are wanted only once the indexer's take-in.taken of the trunk branch names a
// source revision at least the trunk's head (the highest src of the trunk's module values).
func TestTheRevertedAssignerAndGateWaitForTheIndexerToTakeInTheTrunksHead(t *testing.T) {
old, now := map[string]string{"asg": orderFP("a"), "gate": orderFP("c"), "idx": orderFP("e")}, map[string]string{"asg": orderFP("b"), "gate": orderFP("d"), "idx": orderFP("f")}
f := newOrderFixture(t, []orderMod{
{name: "asg", run: orderFP("b"), trunk: orderFP("a"), holds: []string{mma.Seat}, src: 6},
{name: "gate", run: orderFP("d"), trunk: orderFP("c"), holds: []string{meshdelivery.Seat}, src: 7},
{name: "idx", run: orderFP("e"), trunk: orderFP("e"), src: 7},
}, old, now)
f.pass()
f.pass()
if f.wantAt("asg") != orderFP("b") || f.wantAt("gate") != orderFP("d") {
t.Fatalf("the reverted assigner (%s) and gate (%s) are wanted before the head is taken in", f.wantAt("asg"), f.wantAt("gate"))
}
if len(f.waits()) != 2 || f.bodies[0].Part != "mesh-delivery-indexer" {
t.Fatalf("waits %v bodies %+v", f.waits(), f.bodies)
}
taken := func(branch string, source int64) {
raw, _ := json.Marshal(meshcatalog.TakeInTaken{Kind: "taken", Key: "m.1", Branch: branch, Source: source})
if err := f.p.w.taken.put(raw); err != nil {
t.Fatal(err)
}
}
taken("beta", 9) // another branch, and
taken(trunkBranch, 6)
f.pass()
if f.wantAt("asg") != orderFP("b") {
t.Fatal("a source revision before the trunk's head was taken in, and the assigner was wanted")
}
taken(trunkBranch, 7)
f.pass()
f.pass()
if f.wantAt("asg") != orderFP("a") || f.wantAt("gate") != orderFP("c") || len(f.waits()) != 0 {
t.Fatalf("after the head is taken in: asg %s gate %s waits %v", f.wantAt("asg"), f.wantAt("gate"), f.waits())
}
}
// A-260: an order wait still held after a restart is said again as wait-held, by a pass that remembers nothing, and ends at the next
// take-in.taken that names the trunk's head.
func TestAnOrderWaitHeldAfterARestartIsSaidAgain(t *testing.T) {
old, now := map[string]string{"asg": orderFP("a")}, map[string]string{"asg": orderFP("b")}
f := newOrderFixture(t, []orderMod{{name: "asg", run: orderFP("b"), trunk: orderFP("a"), holds: []string{mma.Seat}, src: 7}}, old, now)
f.pass()
f.pass()
if len(f.events) != 1 {
t.Fatalf("events %v", f.events)
}
f.p.orderSaid = nil // a process started again
f.pass()
if len(f.events) != 2 || f.events[1] != meshwants.EventWaitHeld || len(f.waits()) != 1 {
t.Fatalf("events %v waits %v", f.events, f.waits())
}
raw, _ := json.Marshal(meshcatalog.TakeInTaken{Kind: "taken", Key: "m.1", Branch: trunkBranch, Source: 8})
if err := f.p.w.taken.put(raw); err != nil {
t.Fatal(err)
}
f.pass()
f.pass()
if f.wantAt("asg") != orderFP("a") || len(f.waits()) != 0 || f.events[len(f.events)-1] != meshwants.EventWaitEnded {
t.Fatalf("asg at %s, waits %v, events %v", f.wantAt("asg"), f.waits(), f.events)
}
}
@@ -0,0 +1,499 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/catalogkey"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
"mesh-delivery-assigner/internal/outside"
)
// The wait on an outside cause (design 47 G §706, ADR 0677): the assigner judges a node's health after a want moves, and a moved
// module's failing check on a machine whose open conditions name an outside cause is held as a wait (internal/outside has the
// rules). The conditions come only through the seat mesh-conditions: its verb `conditions`, read at each judging as a kept
// call, and its events, which only say when to judge again.
// conditionsVerb is the seat verb the keeper answers `conditions` by.
const conditionsVerb = "seat:" + mcseat.Seat + "." + mcseat.VerbConditions
// wantsPublisher says one of the seat mesh-wants' events, `wait-held` and `wait-ended` among them, under a message id the bus
// keeps one of.
func wantsPublisher(ctx context.Context, js jetstream.JetStream, node string) publisher {
return func(event string, body any, id string) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
msg := nats.NewMsg(seatdef.EventSubject(meshwants.Seat, event))
msg.Header.Set("x-event-id", id)
msg.Header.Set("x-source", assignment.Module())
msg.Header.Set("x-node", node)
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = data
_, err = js.PublishMsg(ctx, msg, jetstream.WithMsgID(id))
return err
}
}
// outsideActs keeps, while the process runs, what the judging of outside waits needs beside the kept waits: the conditions read
// for each machine (one answer is one judging), the machines a heard event says to judge again, the machines whose own network
// is held open, and each check's last signature.
type outsideActs struct {
mu sync.Mutex
call func(key string, body any) (json.RawMessage, error) // a kept call
wake func()
logf func(string, ...any)
// bound and settle are the settings outside-wait-minutes and outside-settle-seconds.
bound, settle time.Duration
// reading is a read in flight by machine, answered the answers it brought, not yet judged by.
reading map[string]bool
answered map[string][]outside.Cond
// dirty is a machine whose waits are judged again, cleared a wait's key whose cause's clearing was heard.
dirty map[string]bool
cleared map[string]bool
// sigs is each check's last signature, by node.module.
sigs map[string]string
// own is the machines whose own network is open; ownRead says the read at the start was answered.
own map[string]bool
ownRead bool
// causes is, by condition key, the nodes whose waits stand on it, as the last pass kept them.
causes map[string][]string
// started says the first pass judged every wait again.
started bool
}
func newOutsideActs(call func(string, any) (json.RawMessage, error), wake func(), logf func(string, ...any), bound, settle time.Duration) *outsideActs {
if bound <= 0 {
bound = outside.DefaultBound
}
if settle <= 0 {
settle = outside.DefaultSettle
}
return &outsideActs{call: call, wake: wake, logf: logf, bound: bound, settle: settle, reading: map[string]bool{}, answered: map[string][]outside.Cond{},
dirty: map[string]bool{}, cleared: map[string]bool{}, sigs: map[string]string{}, own: map[string]bool{}, causes: map[string][]string{}}
}
// outsideSettings are the bounds of the wait, the settings outside-wait-minutes and outside-settle-seconds (design 47 G §633, §706),
// each read at the start from the file the manifest declares with the content ${config:<key>}, which the environment names
// (A-112, as the builder reads its own); zero for one whose file is missing or empty, which is its default, and an error
// naming the file for a content that is no positive whole number.
func outsideSettings(getenv func(string) string) (bound, settle time.Duration, err error) {
minutes, err := settingFile(getenv("MESH_ASSIGNER_OUTSIDE_WAIT_FILE"))
if err != nil {
return 0, 0, err
}
seconds, err := settingFile(getenv("MESH_ASSIGNER_OUTSIDE_SETTLE_FILE"))
if err != nil {
return 0, 0, err
}
return time.Duration(minutes) * time.Minute, time.Duration(seconds) * time.Second, nil
}
// settingFile reads one setting's file: 0 for no path, no file or an empty one.
func settingFile(path string) (int, error) {
if path == "" {
return 0, nil
}
raw, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return 0, nil
}
if err != nil {
return 0, err
}
v := strings.TrimSpace(string(raw))
if v == "" {
return 0, nil
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return 0, fmt.Errorf("%s holds %q, which is not a positive whole number", path, v)
}
return n, nil
}
// readStart reads the open conditions of every machine once at the start, for the machines whose own network is held.
func (o *outsideActs) readStart() {
go func() {
answer, err := o.call(conditionsVerb, map[string]any{"scope": "machine"})
if err != nil {
o.logf("the open conditions are not read at the start (%v): no machine is held for its own network until its event is heard", err)
return
}
conds, err := outside.ParseConditions(answer)
if err != nil {
o.logf("%v", err)
return
}
o.mu.Lock()
for m := range outside.OwnNetwork(conds) {
o.own[m] = true
}
o.ownRead = true
o.mu.Unlock()
o.wake()
}()
}
// Heard is one of the seat's events condition-raised, -changed or -cleared: it says which machines to judge again and keeps the
// machines whose own network is open; nothing is kept of the condition.
func (o *outsideActs) Heard(subject string, body []byte) {
var e mcseat.ConditionEvent
if err := json.Unmarshal(body, &e); err != nil || e.Key == "" {
o.logf("%s holds what is not a condition's event, and is dropped: %v", subject, err)
return
}
cleared := strings.HasSuffix(subject, "."+mcseat.EventConditionCleared)
o.mu.Lock()
if p := strings.Split(e.Key, "."); len(p) == 3 && p[0] == "machine" && p[2] == "network" {
o.own[p[1]] = !cleared
}
if cleared {
for _, node := range o.causes[e.Key] {
o.dirty[node] = true
o.cleared[node+"|"+e.Key] = true
}
} else if e.Subject != nil {
o.dirty[e.Subject.Machine] = true
for _, m := range e.Subject.Also {
o.dirty[m] = true
}
}
o.mu.Unlock()
o.wake()
}
// Held says whether machine's own network is open: no want moves to it.
func (o *outsideActs) Held(machine string) bool {
if o == nil {
return false
}
o.mu.Lock()
defer o.mu.Unlock()
return o.own[machine]
}
// take is the conditions read for machine, consumed (one answer is one judging), or false while the kept call is unanswered,
// which it starts when none is in flight.
func (o *outsideActs) take(machine string) ([]outside.Cond, bool) {
o.mu.Lock()
defer o.mu.Unlock()
if c, ok := o.answered[machine]; ok {
delete(o.answered, machine)
return c, true
}
if o.reading[machine] {
return nil, false
}
o.reading[machine] = true
go func() {
answer, err := o.call(conditionsVerb, map[string]any{"scope": "machine", "machine": machine})
var conds []outside.Cond
if err == nil {
conds, err = outside.ParseConditions(answer)
}
o.mu.Lock()
o.reading[machine] = false
if err != nil {
o.logf("the conditions of %s are not read (%v): the move waits on %s", machine, err, outside.OnUnread)
} else {
o.answered[machine] = conds
o.dirty[machine] = true // the kept call answered: the move is judged by it
}
o.mu.Unlock()
if err == nil {
o.wake()
}
}()
return nil, false
}
// outsideReview is the judging of module's check at fp on each of its nodes (§706): it returns the nodes as the rules read them,
// the have of a waiting move no failure (its health unknown) and a go-on's counted healthy, and keeps the waits, says their
// events, finding and ask. moved says the delivery moved owner to node.
func (p *passer) outsideReview(ctx context.Context, module, fp string, nodes []string, n assign.Nodes, keeperSelf bool, moved func(owner, node string) bool) assign.Nodes {
o := p.outside
if o == nil || fp == "" || p.kvs[buckets.Waits()] == nil {
return n
}
hex, err := catalogkey.KeyPart(fp)
if err != nil {
return n
}
now := p.clock()
kept := p.w.read(buckets.Waits())
override := map[string]string{}
// A wait of another fingerprint of this module is replaced by the newer want.
for key, raw := range kept {
if w, err := outside.Decode(raw); err == nil && w.Module == module && w.FP != fp {
p.outsideEnd(ctx, key, w, outside.HowReplaced, now)
}
}
for _, node := range nodes {
key := outside.Key(node, module, hex)
var prev *outside.Wait
if raw := kept[key]; raw != nil {
if w, err := outside.Decode(raw); err == nil {
prev = &w
}
}
h, ok := n.Haves[node][module]
sig := fmt.Sprintf("%s|%s|%s", h.Fp, h.Phase, h.Health)
o.mu.Lock()
first := !o.started
changed := o.sigs[node+"."+module] != sig
o.sigs[node+"."+module] = sig
dirty := o.dirty[node]
cleared := prev != nil && prev.Cause != "" && o.cleared[node+"|"+prev.Cause]
o.mu.Unlock()
failing, healthy := ok && h.Fp == fp && assign.Failed(h), n.RunsHealthy(node, module, fp)
if !failing && !healthy && prev == nil {
continue
}
if prev != nil && p.outsideAnswered(prev, node, module, fp) {
dirty = true
}
due := prev != nil && (prev.Release != "" || (prev.Settling && !now.Before(prev.SettleEnds)))
var d outside.Decision
if prev != nil && !(first || changed || dirty || due) {
// nothing to judge by: the wait stands as kept; the bound's ask is made when it has run
d = outside.Decision{Wait: prev, Waiting: prev.Standing() || prev.Settling, Own: prev.Decided == outside.HowFailed || prev.Decided == outside.HowOwnFailure,
Counted: prev.Decided == outside.HowGoOn}
if prev.Standing() && !now.Before(prev.ClockFrom.Add(o.bound)) {
d.Ask = true
}
} else {
f := outside.Facts{Now: now, Failing: failing, Healthy: healthy, Node: node, KeeperSelf: keeperSelf, Bound: o.bound, Settle: o.settle,
Moved: func(owner string) bool { return moved(owner, node) }, CauseCleared: cleared}
if failing && !keeperSelf {
f.Conds, f.Read = o.take(node)
}
d = outside.Judge(prev, node, module, fp, f)
o.mu.Lock()
delete(o.dirty, node)
if prev != nil {
delete(o.cleared, node+"|"+prev.Cause)
}
o.mu.Unlock()
}
switch {
case d.Waiting:
override[node] = nodeengineclient.HealthUnknown
case d.Counted:
override[node] = nodeengineclient.HealthOK
}
p.outsideApply(ctx, key, hex, node, module, fp, prev, d, now)
}
o.mu.Lock()
o.started = true
o.mu.Unlock()
if len(override) == 0 {
return n
}
out := n
out.Haves = map[string]map[string]nodeengine.HaveByNodeModuleValue{}
for node, byModule := range n.Haves {
out.Haves[node] = map[string]nodeengine.HaveByNodeModuleValue{}
for m, h := range byModule {
out.Haves[node][m] = h
}
}
for node, health := range override {
h := out.Haves[node][module]
h.Health = health
if health == nodeengineclient.HealthOK {
h.Phase = nodeengineclient.PhaseRunning
}
out.Haves[node][module] = h
}
return out
}
// outsideAnswered reads the operator's answer to the wait's ask, from the teller's record of it under the ask's key (answeredWith,
// §700's path as the assigner reads an ask's answer), and notes it on the wait as a release; it says whether it did.
func (p *passer) outsideAnswered(w *outside.Wait, node, module, fp string) bool {
if w.Ask == "" || w.Release != "" {
return false
}
key, err := asks.OutsideAskKey(node, module, fp)
if err != nil {
return false
}
for _, how := range []string{assign.OptionGoOn, assign.OptionFailed} {
if p.answeredWith(key, how) {
w.Release, w.By, w.Why = how, "the operator", "the answer to the ask "+w.Ask
return true
}
}
return false
}
// outsideApply keeps what a judging came to: the wait's record, its events, its finding and its ask.
func (p *passer) outsideApply(ctx context.Context, key, hex, node, module, fp string, prev *outside.Wait, d outside.Decision, now time.Time) {
o := p.outside
switch {
case d.End != "":
p.outsideEnd(ctx, key, *prev, d.End, now)
if d.Wait != nil {
p.keepWait(ctx, key, *d.Wait)
}
return
case d.Wait == nil:
if prev != nil {
p.outsideEnd(ctx, key, *prev, "", now)
}
return
}
w := *d.Wait
if d.Held {
body := p.outsideBody(w)
if err := p.wantsSay(meshwants.EventWaitHeld, body, key+"."+w.On+"."+w.ClockFrom.UTC().Format(time.RFC3339Nano)); err != nil {
p.logf("the wait of %s on %s is not said, and is said at the next pass: %v", module, node, err)
return
}
p.outsideFinding(w, true)
}
if d.Ask && w.Ask == "" {
if id := p.outsideAsk(ctx, node, module, fp, w, now); id != "" {
w.Ask = id
}
}
if w.Standing() || w.Settling || w.Decided != "" {
o.mu.Lock()
if w.Cause != "" && w.Standing() {
if !containsString(o.causes[w.Cause], node) {
o.causes[w.Cause] = append(o.causes[w.Cause], node)
}
}
o.mu.Unlock()
if w.Settling {
p.wakeAt(w.SettleEnds, now)
} else if w.Standing() {
p.wakeAt(w.ClockFrom.Add(o.bound), now)
}
p.keepWait(ctx, key, w)
}
}
func containsString(l []string, s string) bool {
for _, x := range l {
if x == s {
return true
}
}
return false
}
// keepWait writes the wait's record.
func (p *passer) keepWait(ctx context.Context, key string, w outside.Wait) {
p.put(ctx, buckets.Waits(), key, func() ([]byte, error) { return outside.Encode(w) })
}
func (p *passer) outsideBody(w outside.Wait) meshwants.Wait {
since, from := w.Since, w.ClockFrom
return meshwants.Wait{Step: meshwants.WaitStepOutside, Owner: assignment.Module(), Node: w.Node, Module: w.Module, Fingerprint: w.FP, On: w.On,
Since: since, ClockFrom: &from, Bound: w.Bound,
Words: fmt.Sprintf("%s on %s waits on %s", w.Module, w.Node, w.On)}
}
// outsideEnd ends a wait: its wait-ended with how (none for a settle, which was never said), its finding and ask taken away, and
// its record deleted unless the end decided it (the caller keeps that record).
func (p *passer) outsideEnd(ctx context.Context, key string, w outside.Wait, how string, now time.Time) {
if w.Standing() && how != "" {
body := p.outsideBody(w)
body.How = how
if err := p.wantsSay(meshwants.EventWaitEnded, body, key+"."+how+"."+now.UTC().Format(time.RFC3339Nano)); err != nil {
p.logf("the end of the wait of %s on %s is not said, and is said at the next pass: %v", w.Module, w.Node, err)
return
}
}
p.outsideFinding(w, false)
if ask, err := asks.OutsideAskKey(w.Node, w.Module, w.FP); err == nil {
p.remove(ctx, buckets.Asks(), ask)
}
if p.outside != nil && w.Cause != "" {
p.outside.mu.Lock()
p.outside.causes[w.Cause] = removeString(p.outside.causes[w.Cause], w.Node)
p.outside.mu.Unlock()
}
p.remove(ctx, buckets.Waits(), key)
}
func removeString(l []string, s string) []string {
var out []string
for _, x := range l {
if x != s {
out = append(out, x)
}
}
return out
}
// outsideFinding writes the finding delivery-waits-outside, or clears it.
func (p *passer) outsideFinding(w outside.Wait, raised bool) {
if p.findings.Put == nil {
return
}
o := assign.WaitsOutsideFinding(w.Node, w.Module, w.Cause, w.On, p.clock())
if raised {
if err := p.findings.Put(o.Finding(p.clock())); err != nil {
p.logf("the finding %s is not written: %v", "delivery."+o.ID, err)
}
return
}
if p.findings.Delete != nil {
if err := p.findings.Delete(mcseatKey(o)); err != nil {
p.logf("the finding %s is not cleared: %v", "delivery."+o.ID, err)
}
}
}
func mcseatKey(o assign.Observed) string {
return views.FindingKey(o.Scope, assign.KeyID(o.ID), o.Kind)
}
// outsideAsk asks the operator at the wait's bound, once, and returns the asking's id.
func (p *passer) outsideAsk(ctx context.Context, node, module, fp string, w outside.Wait, now time.Time) string {
key, err := asks.OutsideAskKey(node, module, fp)
if err != nil {
return ""
}
asking := assign.AskingSeconds(now)
a := assign.OutsideAsk(key, asks.OutsideAbout(key), node, module, fp, w.On, w.ClockFrom, asking, now)
p.put(ctx, buckets.Asks(), key, func() ([]byte, error) { return assign.EncodeAsk(key, a) })
if ask, err := assign.AskOf(a); err == nil {
return ask.ID
}
return ""
}
// wakeAt makes a pass when t comes: the settle's end and the bound are the waits' own times, the one wake (no polling).
func (p *passer) wakeAt(t, now time.Time) {
if p.outside == nil || p.outside.wake == nil {
return
}
time.AfterFunc(max(t.Sub(now), 0)+time.Millisecond, p.outside.wake)
}
@@ -0,0 +1,333 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
mdc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshdeliveryclient"
meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/buckets"
"mesh-delivery-assigner/internal/outside"
)
// The whole of the wait on an outside cause in the pass (design 47 G §706): a fixture keeper answering `conditions` and saying
// its events, a failing check of nats on ace, and the events, finding, ask and record the pass leaves.
type outsideRig struct {
t *testing.T
p *passer
now time.Time
conds []map[string]any
unread bool
events []string
bodies []meshwants.Wait
found map[string]bool
woke chan struct{}
health string
}
func newOutsideRig(t *testing.T) *outsideRig {
r := &outsideRig{t: t, now: time.Date(2026, 11, 11, 10, 0, 0, 0, time.UTC), found: map[string]bool{}, woke: make(chan struct{}, 16), health: nodeengineclient.HealthFailed}
r.p = newPasser(kvSet{buckets.Waits(): &memKV{values: map[string][]byte{}}, buckets.Asks(): &memKV{values: map[string][]byte{}}}, r.now)
r.p.now = func() time.Time { return r.now }
r.p.w.put(buckets.Asks(), "x", nil)
r.p.outside = newOutsideActs(func(key string, body any) (json.RawMessage, error) {
if r.unread {
return nil, context.DeadlineExceeded
}
return json.Marshal(map[string]any{"conditions": r.conds})
}, func() { r.woke <- struct{}{} }, func(string, ...any) {}, 30*time.Minute, 90*time.Second)
r.p.publishWants = func(event string, body any, id string) error {
r.events = append(r.events, event)
r.bodies = append(r.bodies, body.(meshwants.Wait))
return nil
}
r.p.findings.Put = func(f mcseat.Finding) error { r.found[f.Scope+"."+f.ID+"."+f.Kind] = true; return nil }
r.p.findings.Delete = func(key string) error { delete(r.found, key); return nil }
return r
}
func (r *outsideRig) cond(key string, also ...string) {
r.conds = append(r.conds, map[string]any{"key": key, "subject": map[string]any{"machine": strings.Split(key, ".")[1], "also": also}})
}
// review is one pass's judging of nats on ace at the fingerprint, with the read answered at once when one is started.
func (r *outsideRig) review(keeperSelf bool, moved func(string, string) bool) assign.Nodes {
r.t.Helper()
n := assign.Nodes{Online: map[string]bool{"ace": true}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{
"ace": {"nats": {Fp: fpTest, Phase: nodeengineclient.PhaseRunning, Health: r.health}}}}
healthy := r.health == nodeengineclient.HealthOK
n.Running = map[string]map[string]nodeengine.Running{"ace": {"nats": {Node: "ace", Module: "nats", Fingerprint: fpTest, Healthy: &healthy}}}
if moved == nil {
moved = func(string, string) bool { return false }
}
out := r.p.outsideReview(context.Background(), "nats", fpTest, []string{"ace"}, n, keeperSelf, moved)
select {
case <-r.woke: // the read was answered: the next pass judges by it
out = r.p.outsideReview(context.Background(), "nats", fpTest, []string{"ace"}, n, keeperSelf, moved)
case <-time.After(50 * time.Millisecond):
}
return out
}
func (r *outsideRig) wait() outside.Wait {
r.t.Helper()
for _, v := range r.p.w.read(buckets.Waits()) {
w, err := outside.Decode(v)
if err != nil {
r.t.Fatal(err)
}
return w
}
return outside.Wait{}
}
func TestTheWaitOnAnOutsideCause(t *testing.T) {
healthOf := func(n assign.Nodes) string { return n.Haves["ace"]["nats"].Health }
t.Run("a cause listing the machine is a wait said at once, ended cleared when it clears healthy", func(t *testing.T) {
r := newOutsideRig(t)
r.cond("machine.hub.unreachable", "ace")
n := r.review(false, nil)
if healthOf(n) == nodeengineclient.HealthFailed || len(r.events) != 1 || r.events[0] != meshwants.EventWaitHeld {
t.Fatalf("health %s events %v", healthOf(n), r.events)
}
b := r.bodies[0]
if b.Step != meshwants.WaitStepOutside || b.On != "unreachable:hub" || b.Bound != "30m0s" || b.ClockFrom == nil || !b.ClockFrom.Equal(r.now) || b.Node != "ace" || b.Module != "nats" {
t.Errorf("wait-held %+v", b)
}
if !r.found["delivery.ace.nats.delivery-waits-outside"] {
t.Errorf("findings %v", r.found)
}
// the cause clears and the module runs healthy
r.conds, r.health, r.now = nil, nodeengineclient.HealthOK, r.now.Add(time.Minute)
r.p.outside.Heard("mesh.seat.mesh-conditions.event.condition-cleared", []byte(`{"key":"machine.hub.unreachable","event":"cleared","change":"cleared","at":"2026-11-11T10:01:00Z","subject":{"id":"hub","scope":"machine","machine":"hub","also":["ace"]}}`))
<-r.woke
r.review(false, nil)
if len(r.events) != 2 || r.events[1] != meshwants.EventWaitEnded || r.bodies[1].How != outside.HowCleared || len(r.found) != 0 || len(r.p.w.read(buckets.Waits())) != 0 {
t.Errorf("events %v bodies %+v findings %v", r.events, r.bodies, r.found)
}
})
t.Run("a cause not listing the machine settles, then the failure is the delivery's", func(t *testing.T) {
r := newOutsideRig(t)
r.cond("machine.hub.unreachable", "g14")
n := r.review(false, nil)
if healthOf(n) == nodeengineclient.HealthFailed || len(r.events) != 0 || !r.wait().Settling {
t.Fatalf("settle: health %s events %v wait %+v", healthOf(n), r.events, r.wait())
}
r.now = r.now.Add(91 * time.Second)
if n := r.review(false, nil); healthOf(n) != nodeengineclient.HealthFailed {
t.Errorf("past the settle the check still counts as failing: %s", healthOf(n))
}
})
t.Run("a cause turning into another keeps the wait and its clock; a rewritten naming a moved module fails at once", func(t *testing.T) {
r := newOutsideRig(t)
r.cond("machine.hub.unreachable", "ace")
r.review(false, nil)
r.conds = nil
r.cond("machine.hub.network", "ace")
r.now = r.now.Add(5 * time.Minute)
r.p.outside.Heard("mesh.seat.mesh-conditions.event.condition-raised", []byte(`{"key":"machine.hub.network","subject":{"id":"hub","scope":"machine","machine":"hub","also":["ace"]}}`))
<-r.woke
r.review(false, nil)
last := len(r.bodies) - 1
if len(r.events) != 2 || r.events[last] != meshwants.EventWaitHeld || r.bodies[last].On != "network:hub" || !r.bodies[last].ClockFrom.Equal(*r.bodies[0].ClockFrom) {
t.Errorf("events %v bodies %+v", r.events, r.bodies)
}
r2 := newOutsideRig(t)
r2.cond("machine.ace.nats.rewritten")
n := r2.review(false, func(owner, node string) bool { return owner == "nats" })
if healthOf(n) == nodeengineclient.HealthFailed || !r2.wait().Settling {
t.Errorf("a rewritten of the moved module is its failure: %s %+v", healthOf(n), r2.wait())
}
})
t.Run("the keeper silent waits on conditions-unread, and its own module is judged with no read", func(t *testing.T) {
r := newOutsideRig(t)
r.unread = true
r.review(false, nil)
r.now = r.now.Add(90 * time.Second) // the settle has run with no answer
r.review(false, nil)
if len(r.bodies) != 1 || r.bodies[0].On != outside.OnUnread {
t.Fatalf("bodies %+v", r.bodies)
}
r2 := newOutsideRig(t)
r2.cond("machine.hub.unreachable", "ace")
if n := r2.review(true, nil); healthOf(n) != nodeengineclient.HealthFailed || len(r2.events) != 0 {
t.Errorf("the keeper's own failing check waits: %s %v", healthOf(n), r2.events)
}
})
t.Run("past the bound the operator is asked; Go on counts it healthy and Count as failed holds it, each said ended", func(t *testing.T) {
for how, want := range map[string]string{assign.OptionGoOn: nodeengineclient.HealthOK, assign.OptionFailed: nodeengineclient.HealthFailed} {
r := newOutsideRig(t)
r.cond("machine.hub.silent", "ace")
r.review(false, nil)
r.now = r.now.Add(31 * time.Minute)
r.p.outside.Heard("mesh.seat.mesh-conditions.event.condition-changed", []byte(`{"key":"machine.hub.silent","subject":{"id":"hub","scope":"machine","machine":"hub","also":["ace"]}}`))
<-r.woke
r.review(false, nil)
key, _ := asks.OutsideAskKey("ace", "nats", fpTest)
raw := r.p.w.value(buckets.Asks(), key)
if raw == nil {
t.Fatalf("%s: no ask at %s", how, key)
}
_, ask, err := assign.DecodeAsk(key, raw)
if err != nil || ask.About != "ace.nats."+strings.TrimPrefix(key[strings.LastIndex(key, ".")+1:], "") || len(ask.Options) != 2 {
t.Fatalf("ask %+v %v", ask, err)
}
v, _ := mdc.EncodeAnswer(mdc.Answer{Option: how, Warrant: ask.ID + "@2026-11-11T10:32:00Z", Words: "the operator"})
r.p.w.put(buckets.Answers, key, v)
n := r.review(false, nil)
if r.events[len(r.events)-1] != meshwants.EventWaitEnded || r.bodies[len(r.bodies)-1].How != how || healthOf(n) != want {
t.Errorf("%s: events %v last %+v health %s", how, r.events, r.bodies[len(r.bodies)-1], healthOf(n))
}
if r.p.w.value(buckets.Asks(), key) != nil || len(r.found) != 0 {
t.Errorf("%s: the ask or the finding stands after the end", how)
}
// a check that goes on failing is not waited on again
r.now = r.now.Add(time.Minute)
r.p.outside.Heard("mesh.seat.mesh-conditions.event.condition-raised", []byte(`{"key":"machine.hub.silent","subject":{"id":"hub","scope":"machine","machine":"hub","also":["ace"]}}`))
<-r.woke
before := len(r.events)
r.review(false, nil)
if len(r.events) != before {
t.Errorf("%s: a decided check is waited on again", how)
}
}
})
t.Run("a newer want ends the wait replaced, and a restart judges it again", func(t *testing.T) {
r := newOutsideRig(t)
r.cond("machine.hub.unreachable", "ace")
r.review(false, nil)
n := assign.Nodes{Online: map[string]bool{"ace": true}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{}}
r.p.outsideReview(context.Background(), "nats", "src2:"+strings.Repeat("c", 64), []string{"ace"}, n, false, func(string, string) bool { return false })
if r.events[len(r.events)-1] != meshwants.EventWaitEnded || r.bodies[len(r.bodies)-1].How != outside.HowReplaced {
t.Errorf("events %v", r.events)
}
})
t.Run("its bounds are read at the start from the files the manifest declares (A-112)", func(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return p
}
env := map[string]string{"MESH_ASSIGNER_OUTSIDE_WAIT_FILE": write("wait", "45\n"), "MESH_ASSIGNER_OUTSIDE_SETTLE_FILE": write("settle", "7")}
if bound, settle, err := outsideSettings(func(k string) string { return env[k] }); err != nil || bound != 45*time.Minute || settle != 7*time.Second {
t.Errorf("bound %v settle %v err %v, want 45m and 7s", bound, settle, err)
}
env["MESH_ASSIGNER_OUTSIDE_WAIT_FILE"], env["MESH_ASSIGNER_OUTSIDE_SETTLE_FILE"] = filepath.Join(dir, "missing"), write("empty", "")
if bound, settle, err := outsideSettings(func(k string) string { return env[k] }); err != nil || bound != 0 || settle != 0 {
t.Errorf("bound %v settle %v err %v, want both defaults", bound, settle, err)
}
env["MESH_ASSIGNER_OUTSIDE_SETTLE_FILE"] = write("bad", "soon")
if _, _, err := outsideSettings(func(k string) string { return env[k] }); err == nil || !strings.Contains(err.Error(), "bad") {
t.Errorf("err %v, want one naming the file", err)
}
})
}
func TestAMachineWhoseOwnNetworkIsOpenGetsNoWant(t *testing.T) {
m := assign.Module{Name: "nats", FP: fpTest, Built: true, Nodes: []string{"ace"}, MachineHeld: func(node string) bool { return node == "ace" }}
ok, rule, why := assign.Holds(m, "ace", assign.Nodes{Online: map[string]bool{"ace": true}})
if ok || rule == 0 || !strings.Contains(why, "machine.ace.network") {
t.Errorf("holds = %v %d %q", ok, rule, why)
}
o := newOutsideActs(nil, func() {}, func(string, ...any) {}, 0, 0)
o.Heard("mesh.seat.mesh-conditions.event.condition-raised", []byte(`{"key":"machine.ace.network","subject":{"id":"ace","scope":"machine","machine":"ace"}}`))
if !o.Held("ace") || o.Held("g14") {
t.Error("the own network condition's event does not hold the machine")
}
o.Heard("mesh.seat.mesh-conditions.event.condition-cleared", []byte(`{"key":"machine.ace.network","subject":{"id":"ace","scope":"machine","machine":"ace"}}`))
if o.Held("ace") {
t.Error("the machine is held after its condition cleared")
}
}
func TestTheToolsReadAndReleaseAnOutsideWait(t *testing.T) {
r := newOutsideRig(t)
r.cond("machine.hub.unreachable", "ace")
r.review(false, nil)
var acts []string
s := newPutTools(memStore{waitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveryWaitsState: &memKV{values: map[string][]byte{}}, buckets.DeliveredAgainState: &memKV{values: map[string][]byte{}}}, func(key string, body any) (json.RawMessage, error) {
acts = append(acts, key)
return json.RawMessage(`{"ok":true,"log":"logged"}`), nil
})
for k, v := range r.p.w.read(buckets.Waits()) {
s.st.put(waitsState, k, v)
}
got, err := s.doWaits(map[string]any{"module": "nats"})
if err != nil || len(got.(map[string]any)["waits"].([]any)) != 1 {
t.Fatalf("assigner_waits = %+v %v", got, err)
}
if w := got.(map[string]any)["waits"].([]any)[0].(waitSaid); w.On != "unreachable:hub" || w.BoundEnds == "" {
t.Errorf("wait = %+v", w)
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "nats", "how": "sideways", "why": "x"}, "shanks.node-tools"); err == nil {
t.Error("an unknown how is taken")
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "nats", "how": "go-on"}, "shanks.node-tools"); err == nil || len(acts) != 0 {
t.Error("a release with no why is taken")
}
if _, err := s.doRelease(map[string]any{"node": "ace", "module": "nats", "how": "go-on", "why": "the hub is being rebuilt"}, "shanks.node-tools"); err != nil {
t.Fatal(err)
}
if len(acts) == 0 || !strings.Contains(acts[0], "hand-act") {
t.Errorf("the hand act is not recorded first: %v", acts)
}
keys, _ := s.st.keys(waitsState)
raw, _, _, _ := s.st.get(waitsState, keys[0])
if w, _ := outside.Decode(raw); w.Release != "go-on" || !strings.Contains(w.By, "the hub is being rebuilt") {
t.Errorf("wait = %+v", w)
}
// the process takes the release at its next pass: ended go-on, counted healthy
r.p.w.put(buckets.Waits(), keys[0], raw)
n := r.review(false, nil)
if r.bodies[len(r.bodies)-1].How != "go-on" || n.Haves["ace"]["nats"].Health != nodeengineclient.HealthOK {
t.Errorf("last %+v health %s", r.bodies[len(r.bodies)-1], n.Haves["ace"]["nats"].Health)
}
// A-246: a dependent's delivery wait is listed beside them, and released by its module and step delivery, its hand act first.
dw, _ := json.Marshal(deliveryWaiting{Wait: meshwants.Wait{Step: meshwants.WaitStepDelivery, Module: "app", Part: "db", On: "db@1.1.0", Machines: []string{"g14"}}})
s.st.put(buckets.DeliveryWaitsState, "app", dw)
got, err = s.doWaits(map[string]any{"node": "g14"})
if d := got.(map[string]any)["delivery"].([]deliveryWaiting); err != nil || len(d) != 1 || d[0].Module != "app" {
t.Errorf("the delivery waits listed = %+v %v", got, err)
}
if _, err := s.doRelease(map[string]any{"module": "app", "step": "build", "why": "x"}, "shanks.node-tools"); err == nil {
t.Error("a release of another step is taken")
}
if _, err := s.doRelease(map[string]any{"module": "web", "step": "delivery", "why": "x"}, "shanks.node-tools"); err == nil {
t.Error("a release of a module waiting for no delivery is taken")
}
before := len(acts)
if _, err := s.doRelease(map[string]any{"module": "app", "step": "delivery", "why": "db is fine as it runs"}, "shanks.node-tools"); err != nil {
t.Fatal(err)
}
raw, _, _, _ = s.st.get(buckets.DeliveryWaitsState, "app")
var released deliveryWaiting
if json.Unmarshal(raw, &released) != nil || !released.Released || !strings.Contains(released.By, "db is fine") || len(acts) != before+1 {
t.Errorf("the delivery wait after its release = %+v, hand acts %v", released, acts[before:])
}
// A-250: assigner_delivered_again records its hand act, writes the ask and answers asked; assigner_waits lists it.
if _, err := s.doDeliveredAgain(map[string]any{"module": "db", "version": "1.1.0"}, "op"); err == nil {
t.Error("an ask with no why is taken")
}
ans, err := s.doDeliveredAgain(map[string]any{"module": "db", "version": "1.1.0", "why": "the catalog missed it"}, "op")
if err != nil || ans.(map[string]any)["asked"] != "db" || len(acts) != before+2 {
t.Fatalf("assigner_delivered_again = %+v %v", ans, err)
}
got, _ = s.doWaits(map[string]any{})
if a := got.(map[string]any)["delivered-again"].([]againAsk); len(a) != 1 || a[0].Version != "1.1.0" || a[0].By != "op" {
t.Errorf("the asks listed = %+v", a)
}
}
@@ -0,0 +1,300 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/keys"
"mesh-delivery-assigner/internal/acting"
"mesh-delivery-assigner/internal/buckets"
"mesh-delivery-assigner/internal/outside"
)
// The tools of the waits on an outside cause (design 47 G §706, §276's row *delivery on an outside cause*): `assigner_waits` reads
// each, the settle included, and `assigner_release` with `how` takes one. The tool writes the release into the wait's record and the
// assigner's process ends the wait at its next pass, saying wait-ended with that how.
// toolWaits is the tool that reads the outside waits.
const toolWaits = "assigner_waits"
// waitsState is the assigner's state of waits, as the runtime names a module's own state.
const waitsState = buckets.WaitsState
// waitSaid is one wait as assigner_waits answers it: its record and where it stands.
type waitSaid struct {
Key string `json:"key"`
outside.Wait
// Settling's end is in the record; Ends is when the bound runs from clock-from, empty for a settle.
BoundEnds string `json:"bound-ends,omitempty"`
}
// actingSaid is one wait for an acting part's new version as assigner_waits answers it (actingwaits.go).
type actingSaid struct {
Key string `json:"key"`
acting.Wait
BoundEnds string `json:"bound-ends,omitempty"`
}
// doWaits is the tool assigner_waits.
func (s *putTools) doWaits(args map[string]any) (any, error) {
keys, err := s.st.keys(waitsState)
if err != nil {
return nil, fmt.Errorf("the waits cannot be listed: %w", err)
}
sort.Strings(keys)
out := []any{}
for _, key := range keys {
raw, _, found, err := s.st.get(waitsState, key)
if err != nil || !found {
continue
}
if acting.Is(key) || acting.IsOrder(key) {
if a, err := acting.Decode(raw); err == nil && (arg(args, "node") == "" || a.Node == arg(args, "node")) && (arg(args, "module") == "" || a.Module == arg(args, "module")) {
said := actingSaid{Key: key, Wait: a}
if d, err := time.ParseDuration(a.Bound); err == nil && !a.Released {
said.BoundEnds = a.ClockFrom.Add(d).UTC().Format("2006-01-02T15:04:05Z")
}
out = append(out, said)
}
continue
}
w, err := outside.Decode(raw)
if err != nil || (arg(args, "node") != "" && w.Node != arg(args, "node")) || (arg(args, "module") != "" && w.Module != arg(args, "module")) {
continue
}
said := waitSaid{Key: key, Wait: w}
if w.Standing() {
if d, err := time.ParseDuration(w.Bound); err == nil {
said.BoundEnds = w.ClockFrom.Add(d).UTC().Format("2006-01-02T15:04:05Z")
}
}
out = append(out, said)
}
// And each dependent's wait for its dependency's delivery (design 47 G §376, A-246), from the state delivery-waits.
delivery := []deliveryWaiting{}
dkeys, err := s.st.keys(buckets.DeliveryWaitsState)
if err != nil {
return nil, fmt.Errorf("the delivery waits cannot be listed: %w", err)
}
sort.Strings(dkeys)
for _, key := range dkeys {
raw, _, found, err := s.st.get(buckets.DeliveryWaitsState, key)
if err != nil || !found {
continue
}
var w deliveryWaiting
if json.Unmarshal(raw, &w) != nil || (arg(args, "module") != "" && w.Module != arg(args, "module")) ||
(arg(args, "node") != "" && !slices.Contains(w.Machines, arg(args, "node"))) {
continue
}
delivery = append(delivery, w)
}
// And each ask of assigner_delivered_again not yet holding, with why (A-250).
again := []againAsk{}
akeys, err := s.st.keys(buckets.DeliveredAgainState)
if err != nil {
return nil, fmt.Errorf("the asks to say a version delivered again cannot be listed: %w", err)
}
sort.Strings(akeys)
for _, key := range akeys {
raw, _, found, err := s.st.get(buckets.DeliveredAgainState, key)
var a againAsk
if err != nil || !found || json.Unmarshal(raw, &a) != nil || (arg(args, "module") != "" && a.Module != arg(args, "module")) {
continue
}
again = append(again, a)
}
return map[string]any{"waits": out, "delivery": delivery, "delivered-again": again}, nil
}
// toolDeliveredAgain is the tool that asks version-delivered said again for one version (design 47 G §376, A-250).
const toolDeliveredAgain = "assigner_delivered_again"
// doDeliveredAgain is assigner_delivered_again: the hand act is recorded first, then the ask is written into the state
// delivered-again, whose watch the assigner's process hears at once; it answers that it is asked.
func (s *putTools) doDeliveredAgain(args map[string]any, by string) (any, error) {
module, version, why := arg(args, "module"), arg(args, "version"), arg(args, "why")
if module == "" || version == "" || why == "" {
return nil, errors.New("assigner_delivered_again names the module, its version and why: it is kept in the hand-act log")
}
rec := meshhandactsclient.Recorder{Ask: s.ask}
taken, err := rec.Record(meshhandactsclient.Request{What: fmt.Sprintf("assigner_delivered_again %s %s", module, version), Why: why,
Cause: "version-delivered", Verb: "mesh-wants.assigner_delivered_again"})
if err != nil {
return nil, fmt.Errorf("the hand act could not be recorded, so nothing was asked: %w", err)
}
data, err := json.Marshal(againAsk{Module: module, Version: version, By: by, Why: why})
if err != nil {
return nil, err
}
if err := s.st.put(buckets.DeliveredAgainState, keys.EscapeName(module), data); err != nil {
return nil, fmt.Errorf("the ask of %s %s is not written: %w", module, version, err)
}
return map[string]any{"asked": module, "version": version, "hand-act": taken.Log,
"follow": "the assigner says version-delivered again once every present machine placing it runs it healthy; assigner_waits says why it does not yet"}, nil
}
// deliveredAgainTool is assigner_delivered_again as the runtime serves it.
func (s *putTools) deliveredAgainTool() stdio.Tool {
return stdio.Tool{Name: toolDeliveredAgain, Description: "Say version-delivered again for one version of a module, for the operator and the healers " +
"(design 47 G §376): written in the hand-act log first, then asked of the assigner, which says it once every present machine placing " +
"the module runs that version healthy; until then assigner_waits lists the ask with why it does not hold. Answers that it is asked.",
Input: schema(map[string]string{"module": "the module", "version": "its MAJOR.MINOR.PATCH", "why": "why, kept in the hand-act log"},
[]string{"module", "version", "why"}),
Run: func(a map[string]any) (any, error) { return s.doDeliveredAgain(a, stdio.CallerOf(a)) }}
}
// doReleaseDelivery is assigner_release with step delivery: the hand act is recorded first, then the release is written on the
// module's delivery wait, which the assigner's process ends released at its next pass, writing the module's want.
func (s *putTools) doReleaseDelivery(args map[string]any, by string) (any, error) {
module, why := arg(args, "module"), arg(args, "why")
if why == "" {
return nil, errors.New("release says why: it is kept in the hand-act log")
}
key := keys.EscapeName(module)
raw, _, found, err := s.st.get(buckets.DeliveryWaitsState, key)
if err != nil {
return nil, fmt.Errorf("the delivery wait of %s is not read: %w", module, err)
}
var w deliveryWaiting
if !found || json.Unmarshal(raw, &w) != nil || w.Released {
return nil, fmt.Errorf("%s waits for no delivery: assigner_waits lists the delivery waits", module)
}
rec := meshhandactsclient.Recorder{Ask: s.ask}
taken, err := rec.Record(meshhandactsclient.Request{What: fmt.Sprintf("assigner_release %s step delivery (waits on %s)", module, w.On), Why: why,
Cause: "delivery-wait", Verb: "mesh-wants.assigner_release"})
if err != nil {
return nil, fmt.Errorf("the hand act could not be recorded, so nothing was released: %w", err)
}
w.Released, w.By = true, by+": "+why
data, err := json.Marshal(w)
if err != nil {
return nil, err
}
if err := s.st.put(buckets.DeliveryWaitsState, key, data); err != nil {
return nil, fmt.Errorf("the release of %s's delivery wait is not written: %w", module, err)
}
return map[string]any{"released": module, "step": meshwants.WaitStepDelivery, "by": by, "hand-act": taken.Log}, nil
}
// doReleaseWait is assigner_release with how: the hand act is recorded first, then the release is written on the wait of node and
// module, which the assigner's process takes at its next pass.
func (s *putTools) doReleaseWait(args map[string]any, by string) (any, error) {
node, module, how, why := arg(args, "node"), arg(args, "module"), arg(args, "how"), arg(args, "why")
if how == acting.HowNow {
return s.doReleaseActing(node, module, why, by)
}
if !slices.Contains([]string{outside.HowGoOn, outside.HowFailed, outside.HowRejudge}, how) {
return nil, fmt.Errorf("how is %s, %s or %s, not %q", outside.HowGoOn, outside.HowFailed, outside.HowRejudge, how)
}
if why == "" {
return nil, errors.New("release says why: it is kept in the hand-act log")
}
keys, err := s.st.keys(waitsState)
if err != nil {
return nil, fmt.Errorf("the waits cannot be listed: %w", err)
}
prefix := node + "." + module + "."
var key string
var w outside.Wait
for _, k := range keys {
if !strings.HasPrefix(k, prefix) {
continue
}
raw, _, found, err := s.st.get(waitsState, k)
if err != nil || !found {
continue
}
if c, err := outside.Decode(raw); err == nil && c.Decided == "" {
key, w = k, c
}
}
if key == "" {
return nil, fmt.Errorf("%s holds no wait of %s on an outside cause: assigner_waits lists them", node, module)
}
rec := meshhandactsclient.Recorder{Ask: s.ask}
taken, err := rec.Record(meshhandactsclient.Request{What: fmt.Sprintf("assigner_release %s %s %s", node, module, how), Why: why,
Cause: "outside-wait", Verb: "mesh-wants.assigner_release"})
if err != nil {
return nil, fmt.Errorf("the hand act could not be recorded, so nothing was released: %w", err)
}
w.Release, w.By, w.Why = how, by+": "+why, why
data, err := outside.Encode(w)
if err != nil {
return nil, err
}
if err := s.st.put(waitsState, key, data); err != nil {
return nil, fmt.Errorf("the release of %s on %s is not written: %w", module, node, err)
}
return map[string]any{"released": key, "how": how, "by": by, "hand-act": taken.Log}, nil
}
// doReleaseActing is assigner_release with how `now`: the entry of module on node moves with its acting parts' running versions. The
// hand act is recorded first; the release is written on each standing wait of the entry, and the process says wait-ended at its next pass.
func (s *putTools) doReleaseActing(node, module, why, by string) (any, error) {
if why == "" {
return nil, errors.New("release says why: it is kept in the hand-act log")
}
keys, err := s.st.keys(waitsState)
if err != nil {
return nil, fmt.Errorf("the waits cannot be listed: %w", err)
}
type one struct {
key string
w acting.Wait
}
var held []one
prefix, orderPrefix := acting.Prefix+node+"."+module+".", acting.OrderPrefix+node+"."+module+"."
for _, k := range keys {
if !strings.HasPrefix(k, prefix) && !strings.HasPrefix(k, orderPrefix) {
continue
}
raw, _, found, err := s.st.get(waitsState, k)
if err != nil || !found {
continue
}
if w, err := acting.Decode(raw); err == nil && !w.Released {
held = append(held, one{k, w})
}
}
if len(held) == 0 {
return nil, fmt.Errorf("%s holds no wait of %s for an acting part: assigner_waits lists them", node, module)
}
rec := meshhandactsclient.Recorder{Ask: s.ask}
taken, err := rec.Record(meshhandactsclient.Request{What: fmt.Sprintf("assigner_release %s %s %s", node, module, acting.HowNow), Why: why,
Cause: "acting-wait", Verb: "mesh-wants.assigner_release"})
if err != nil {
return nil, fmt.Errorf("the hand act could not be recorded, so nothing was released: %w", err)
}
released := []string{}
for _, h := range held {
h.w.Released, h.w.By, h.w.Why = true, by+": "+why, why
data, err := acting.Encode(h.w)
if err != nil {
return nil, err
}
if err := s.st.put(waitsState, h.key, data); err != nil {
return nil, fmt.Errorf("the release of %s on %s is not written: %w", module, node, err)
}
released = append(released, h.key)
}
return map[string]any{"released": released, "how": acting.HowNow, "by": by, "hand-act": taken.Log}, nil
}
// waitsTool is assigner_waits as the runtime serves it.
func (s *putTools) waitsTool() stdio.Tool {
return stdio.Tool{Name: toolWaits, Description: "Every wait of the delivery and where it stands: an entry waiting for an acting part's new version in composing or placing (§266, §268), with its part, version, step, clock-from and bound; and a moved module's failing health check on a " +
"machine whose open conditions name a cause outside the delivery (design 47 G §706), with its module, machine, fingerprint, the " +
"condition's key, what it stands `on`, its clock-from, bound, its ask and its state; a settle shows as settling with its end. Narrowed by " +
"machine or module. Read-only.",
Input: schema(map[string]string{"node": "one machine", "module": "one module"}, nil), Run: s.doWaits}
}

Some files were not shown because too many files have changed in this diff Show More