Merge branch 'feat/47-G-build' into feat/47-G-build-indexer

This commit is contained in:
2026-11-11 14:03:01 +01:00
189 changed files with 7957 additions and 3006 deletions
@@ -0,0 +1,242 @@
package meshcatalogclient
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"strconv"
"strings"
"sync"
"time"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/buscalls"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/views"
)
// RevisionVerb is the seat verb a part takes its first view of the catalog half by, as its manifest's invokes names it.
const RevisionVerb = "seat:" + seat.Seat + "." + seat.VerbRevision
// The kinds a catalog graph revision is of.
const (
KindTrunk = "trunk"
KindCandidate = "candidate"
)
// Half is one catalog graph revision whole: the revision with its entries by module.
type Half struct {
Revision seat.CatalogRevision
Entries map[string]seat.CatalogEntry
}
// ViewName is how the catalog half's revision is named in a part's first-view status (views.Status.View, WaitsFor).
func ViewName(revision int64) string { return strconv.FormatInt(revision, 10) }
// catalogCreated stands for the view's Created in its follower: a revision view is never made again, so one fixed time,
// never zero, lets the follower tell a revision heard from one it holds.
var catalogCreated = time.Unix(0, 0).UTC()
// Catalog is a part's view of the catalog half of one watched branch (design 47 G §358, §479): a first view of the
// branch's newest trunk revision asked of the seat's verb `revision`, then kept from its event `catalog-revision` of that
// branch, built on go/views' Follower, so the runtime's tools `<module>_first_views` and `<module>_first_view_again` read
// and steer it as every first view. The view is named `mesh-catalog.catalog-revisions.<branch>`, so a part that watches two
// branches makes two Catalogs, two views. A `catalog-revision` the view missed (a revision heard that it does not hold)
// asks `revision` once more, said in the log (§196, ADR 0615 §5), and is waited for, said in the status and past
// views.WaitsBound raised as view-waits.
type Catalog struct {
Follower *views.Follower
// Branch is the watched branch the view is of, as the caller names it.
Branch string
call Call
mu sync.Mutex
// held is the half the view holds; paging the one being read, page by page, of one revision.
held *Half
paging *Half
// waits is the revision waited for, 0 when none.
waits int64
}
// NewCatalog is a Catalog of branch asking the seat through call. Nothing is asked before Start.
func NewCatalog(call Call, branch string) (*Catalog, error) {
if err := keys.CheckName("branch", branch); err != nil {
return nil, err
}
c := &Catalog{call: call, Branch: branch}
c.Follower = &views.Follower{Owner: seat.Seat, Tool: RevisionVerb, State: views.BranchState(seat.StateCatalogRevisions, branch), Ask: c.ask}
return c, nil
}
// WireCatalog is a Catalog of branch adopted by a stdio part's views.Reader: the reader writes its conditions and its
// view-waits finding, reads it as `mesh-catalog.catalog-revisions.<branch>` (a key a module, its value the module's
// entry), lists and asks it again in its first-view tools, and at its Start subscribes the seat's event catalog-revision
// (the part's manifest consumes it) into Hear and asks the first view. changed, when not nil, is the Follower's Changed.
// The part declares the seat's verb revision in invokes. Call it once per watched branch.
func WireCatalog(r *views.Reader, call Call, branch string, changed func(module string, entry json.RawMessage, deleted bool, at time.Time)) (*Catalog, error) {
c, err := NewCatalog(call, branch)
if err != nil {
return nil, err
}
c.Follower.Changed = changed
if err := r.Adopt(c.Follower, seat.Seat, seat.EventCatalogRevision, c.Hear); err != nil {
return nil, err
}
return c, nil
}
// WireCatalogProcess is WireCatalog for a process's views.ProcessReader: its picture
// (`mesh-catalog_catalog-revisions.<branch>`) reads the half as a read-only bucket, and the seat's event catalog-revision
// on the module's durable consumer goes to Hear.
func WireCatalogProcess(r *views.ProcessReader, call Call, branch string, changed func(module string, entry json.RawMessage, deleted bool, at time.Time)) (*Catalog, *views.Picture, error) {
c, err := NewCatalog(call, branch)
if err != nil {
return nil, nil, err
}
c.Follower.Changed = changed
p, err := r.Adopt(c.Follower, seat.Seat, seat.EventCatalogRevision, c.Hear)
if err != nil {
return nil, nil, err
}
return c, p, nil
}
// Start asks the first view, the branch's newest trunk revision, until it is answered or ctx ends.
func (c *Catalog) Start(ctx context.Context) error { return c.Follower.Start(ctx) }
// Ready is closed once the first view is answered.
func (c *Catalog) Ready() <-chan struct{} { return c.Follower.Ready() }
// View is the half the view holds; false while it has none, when the part acts on nothing new.
func (c *Catalog) View() (Half, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.held == nil {
return Half{}, false
}
return Half{Revision: c.held.Revision, Entries: maps.Clone(c.held.Entries)}, true
}
// Entry is one module's entry in the half the view holds.
func (c *Catalog) Entry(module string) (seat.CatalogEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.held == nil {
return seat.CatalogEntry{}, false
}
e, ok := c.held.Entries[module]
return e, ok
}
// Hear takes one `catalog-revision` event, as the part's durable consumer of the seat's events hands it. A candidate's
// revision and another branch's move nothing here; a trunk revision of this branch the view holds, or one older, is passed
// over; a newer one is a revision the view missed: waited for, said in the log, and asked again on its own goroutine, so
// the consumer is never held while the holder is away. Data that is no such event is an error.
func (c *Catalog) Hear(ctx context.Context, data []byte) error {
var ev seat.CatalogRevisionEvent
if err := json.Unmarshal(data, &ev); err != nil || ev.Revision <= 0 {
return fmt.Errorf("meshcatalogclient: not a %s event: %s", seat.EventCatalogRevision, clipBytes(data))
}
if ev.Kind != KindTrunk || ev.Branch != keys.EscapeName(c.Branch) {
return nil
}
c.mu.Lock()
held := int64(0)
if c.held != nil {
held = c.held.Revision.Revision
}
if ev.Revision > c.waits {
c.waits = ev.Revision
}
c.mu.Unlock()
if ev.Revision <= held {
return nil
}
// The follower keeps the newest revision heard, so an ask that was running when it was heard is asked once more.
c.Follower.Apply(views.Event{Owner: seat.Seat, State: c.Follower.State, Revision: uint64(ev.Revision), Created: catalogCreated, Gap: true})
c.Follower.WaitFor(ctx, ViewName(ev.Revision))
if c.Follower.Logf != nil {
c.Follower.Logf("meshcatalogclient: catalog revision %d of %s said, the view holds %d: %s is asked once more", ev.Revision, c.Branch, held, RevisionVerb)
}
go func() {
_ = c.Follower.Gap(ctx, fmt.Sprintf("catalog revision %d of %s said, the view holds %d", ev.Revision, c.Branch, held))
}()
return nil
}
// ask is the Follower's Ask: one page of the branch's newest trunk revision, the next pages of the same revision; once
// whole, it is the half the view holds.
func (c *Catalog) ask(_ context.Context, after string) (views.Answer, error) {
in := seat.RevisionInput{After: after, Branch: keys.EscapeName(c.Branch)}
c.mu.Lock()
if after != "" && c.paging != nil {
in.Revision = c.paging.Revision.Revision
}
c.mu.Unlock()
a, err := askRevision(c.call, in)
if errors.Is(err, errNoneYet) {
// A branch with no revision yet is ready and empty, and nothing waits for it (§479, §495).
return views.Answer{V: 1, Owner: seat.Seat, State: c.Follower.State, Created: catalogCreated, Values: map[string]json.RawMessage{}, At: time.Now()}, nil
}
if err != nil {
return views.Answer{}, err
}
if a.Revision.Kind != KindTrunk {
return views.Answer{}, fmt.Errorf("%s answered a %s revision for the trunk", RevisionVerb, a.Revision.Kind)
}
c.mu.Lock()
if after == "" || c.paging == nil {
c.paging = &Half{Revision: a.Revision, Entries: map[string]seat.CatalogEntry{}}
} else if c.paging.Revision.Revision != a.Revision.Revision {
was := c.paging.Revision.Revision
c.paging = nil
c.mu.Unlock()
return views.Answer{}, fmt.Errorf("%s answered revision %d for a page of revision %d", RevisionVerb, a.Revision.Revision, was)
}
values := map[string]json.RawMessage{}
for _, e := range a.Entries {
c.paging.Entries[e.Module] = e
raw, _ := json.Marshal(e)
values[e.Module] = raw
}
rev := a.Revision.Revision
holds := false
if a.Next == "" {
c.held, c.paging = c.paging, nil
if c.waits <= rev {
c.waits, holds = 0, true
}
}
c.mu.Unlock()
if holds {
c.Follower.Holds(ViewName(rev))
}
return views.Answer{V: 1, Owner: seat.Seat, State: c.Follower.State, Revision: uint64(rev), Created: catalogCreated, Values: values, Next: a.Next, At: time.Now()}, nil
}
// errNoneYet is the seat's answer `none yet`: the branch has no revision yet.
var errNoneYet = errors.New("none yet")
func askRevision(call Call, in seat.RevisionInput) (seat.RevisionAnswer, error) {
raw, err := call(RevisionVerb, in)
if err != nil {
if strings.Contains(err.Error(), views.NoneYet) {
return seat.RevisionAnswer{}, errNoneYet
}
return seat.RevisionAnswer{}, err
}
answer, said, ok := buscalls.Unwrap(raw)
if !ok {
if strings.Contains(said, views.NoneYet) {
return seat.RevisionAnswer{}, errNoneYet
}
return seat.RevisionAnswer{}, fmt.Errorf("%s answered: %s", RevisionVerb, said)
}
var a seat.RevisionAnswer
if err := json.Unmarshal(answer, &a); err != nil || a.Revision.Revision <= 0 {
return seat.RevisionAnswer{}, fmt.Errorf("%s answered no revision: %s", RevisionVerb, clipBytes(answer))
}
return a, nil
}
@@ -0,0 +1,164 @@
package meshcatalogclient
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/views"
)
// fakeRevisions answers the verb revision per branch from the newest revision each branch holds, and counts each ask.
type fakeRevisions struct {
mu sync.Mutex
newest map[string]int64 // by escaped branch
asks map[string]int
branches []string
}
func (f *fakeRevisions) call(key string, body any) (json.RawMessage, error) {
in := body.(seat.RevisionInput)
f.mu.Lock()
defer f.mu.Unlock()
rev, ok := f.newest[in.Branch]
if ok && rev == 0 {
return nil, fmt.Errorf("revision of %s: none yet", in.Branch)
}
if !ok {
return nil, fmt.Errorf("asked for the branch %q", in.Branch)
}
f.asks[in.Branch]++
return json.Marshal(seat.RevisionAnswer{
Revision: seat.CatalogRevision{Revision: rev, Kind: KindTrunk, Branch: in.Branch},
Entries: []seat.CatalogEntry{{Module: fmt.Sprintf("m-of-%s-%d", in.Branch, rev)}},
})
}
func (f *fakeRevisions) set(branch string, rev int64) {
f.mu.Lock()
defer f.mu.Unlock()
f.newest[keys.EscapeName(branch)] = rev
}
func (f *fakeRevisions) asked(branch string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.asks[keys.EscapeName(branch)]
}
func revisionEvent(branch, kind string, rev int64) []byte {
b, _ := json.Marshal(seat.CatalogRevisionEvent{V: 1, Branch: keys.EscapeName(branch), Kind: kind, Revision: rev})
return b
}
// CT10 (design 47 G §358, §479, §196): the catalog half is a view per watched branch, named by its caller: two branches
// give two views, each named `mesh-catalog.catalog-revisions.<branch>`, each following only its own branch's
// `catalog-revision`; a revision the view missed asks `revision` once more, said in the log.
func TestCatalogIsAViewPerWatchedBranch(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
f := &fakeRevisions{newest: map[string]int64{"main": 3, keys.EscapeName("release/1"): 7}, asks: map[string]int{}}
var logMu sync.Mutex
var logged []string
logf := func(format string, args ...any) {
logMu.Lock()
defer logMu.Unlock()
logged = append(logged, fmt.Sprintf(format, args...))
}
main, err := NewCatalog(f.call, "main")
if err != nil {
t.Fatal(err)
}
rel, err := NewCatalog(f.call, "release/1")
if err != nil {
t.Fatal(err)
}
for _, c := range []*Catalog{main, rel} {
c.Follower.Wait = time.Nanosecond
c.Follower.Logf = logf
}
if main.Follower.State == rel.Follower.State || main.Follower.State != "catalog-revisions.main" || rel.Follower.State != views.BranchState(seat.StateCatalogRevisions, "release/1") {
t.Fatalf("views are named %q and %q", main.Follower.State, rel.Follower.State)
}
for _, c := range []*Catalog{main, rel} {
if err := c.Start(ctx); err != nil {
t.Fatal(err)
}
<-c.Ready()
}
if h, ok := main.View(); !ok || h.Revision.Revision != 3 || len(h.Entries) != 1 {
t.Fatalf("main holds %+v, %v", h, ok)
}
if h, ok := rel.View(); !ok || h.Revision.Revision != 7 {
t.Fatalf("release/1 holds %+v, %v", h, ok)
}
// An event of the other branch, a candidate's and one the view holds ask nothing.
for _, data := range [][]byte{revisionEvent("release/1", KindTrunk, 8), revisionEvent("main", KindCandidate, 4), revisionEvent("main", KindTrunk, 3)} {
if err := main.Hear(ctx, data); err != nil {
t.Fatal(err)
}
}
if n := f.asked("main"); n != 1 {
t.Errorf("main asked %d times, want only its first view", n)
}
// A missed revision asks `revision` once more, and is said.
f.set("main", 4)
if err := main.Hear(ctx, revisionEvent("main", KindTrunk, 4)); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
for {
if h, _ := main.View(); h.Revision.Revision == 4 {
break
}
if time.Now().After(deadline) {
t.Fatal("main never held revision 4")
}
time.Sleep(time.Millisecond)
}
if n := f.asked("release/1"); n != 1 {
t.Errorf("release/1 asked %d times after main's revision", n)
}
logMu.Lock()
said := false
for _, l := range logged {
if strings.Contains(l, "catalog revision 4 of main said") {
said = true
}
}
logMu.Unlock()
if !said {
t.Errorf("the gap was not said in the log: %q", logged)
}
// A branch with no revision yet is ready and empty, and nothing waits for it.
f.set("new", 0)
none, err := NewCatalog(f.call, "new")
if err != nil {
t.Fatal(err)
}
if err := none.Start(ctx); err != nil {
t.Fatal(err)
}
<-none.Ready()
if _, ok := none.View(); ok || none.Follower.Status().WaitsFor != "" {
t.Errorf("a branch with no revision yet holds a view or waits: %+v", none.Follower.Status())
}
for _, data := range []string{`{}`, `not json`} {
if err := main.Hear(ctx, []byte(data)); err == nil {
t.Errorf("Hear(%s) took it", data)
}
}
if _, err := NewCatalog(f.call, ""); err == nil {
t.Error("a Catalog of no branch was made")
}
}
@@ -142,6 +142,37 @@ func TestGateKeysAsGoDeliveryHadThem(t *testing.T) {
t.Errorf("%q reads as a switched act's id", other)
}
}
// A switched act's id carries its state, key and revision, and reads back; any other id is not one.
for _, c := range []struct {
state, key string
rev uint64
id string
}{
{SwitchedOwed, "novox.app.7.0123abcd.status=mesh/delivery", 4, "switched.owed.4.novox.app.7.0123abcd.status=mesh/delivery"},
{SwitchedMerges, "release-g", 12, "switched.merges.12.release-g"},
} {
id, err := SwitchedActID(c.state, c.key, c.rev)
if err != nil || id != c.id {
t.Errorf("SwitchedActID(%q, %q, %d) = %q, %v; want %q", c.state, c.key, c.rev, id, err, c.id)
}
if s, k, r, ok := ParseSwitchedActID(id); !ok || s != c.state || k != c.key || r != c.rev {
t.Errorf("ParseSwitchedActID(%q) = %q %q %d %v", id, s, k, r, ok)
}
}
for _, bad := range []struct {
state, key string
rev uint64
}{{"turns", "k", 1}, {SwitchedOwed, "", 1}, {SwitchedOwed, "a@b", 1}, {SwitchedMerges, "k", 0}} {
if id, err := SwitchedActID(bad.state, bad.key, bad.rev); err == nil {
t.Errorf("SwitchedActID(%+v) = %q, no error", bad, id)
}
}
for _, other := range []string{"novox.app.7.0123abcd.view@1700000000", "switched.turns.1.k", "switched.owed.x.k",
"switched.owed.0.k", "switched.owed.3", "switched.owed.3.k@1"} {
if _, _, _, ok := ParseSwitchedActID(other); ok {
t.Errorf("%q reads as a switched act's id", other)
}
}
}
func TestGateValuesRefuseWhatTheirRulesRefuse(t *testing.T) {
+14 -4
View File
@@ -77,11 +77,19 @@ type PackagePublished struct {
Version string `json:"version"`
}
// ToolchainAgreedDigestsItem is a type of the contract.
type ToolchainAgreedDigestsItem struct {
// the toolchain part's digest
Digest string `json:"digest"`
// the nodes reporting that digest
Nodes []string `json:"nodes"`
}
// ToolchainAgreed is the agreed toolchain of a language changed (design 47 G section 376, ADR 0584 section 1).
type ToolchainAgreed struct {
At *time.Time `json:"at,omitempty"`
// the toolchain part's digest
Digest string `json:"digest"`
// each digest the nodes reporting the agreed version report, as it stood when the version was agreed, with those nodes (design 47 G section 472, section 495)
Digests []ToolchainAgreedDigestsItem `json:"digests"`
// the language whose agreed toolchain changed
Language string `json:"language"`
V int64 `json:"v"`
@@ -91,8 +99,10 @@ type ToolchainAgreed struct {
// ReservedInput is what reserved takes.
type ReservedInput struct {
// the module
Module string `json:"module"`
// the module; give this or name
Module string `json:"module,omitempty"`
// a package's or an image's name in its ecosystem (the Go module path, the npm name, the image name), in place of module; a name no entry carries, or more than one entry carries, is answered error naming it (design 47 G section 478, section 491)
Name string `json:"name,omitempty"`
// the version, when asked for a particular one
Version string `json:"version,omitempty"`
}
@@ -19,10 +19,12 @@ func TestFixtures(t *testing.T) {
}{
{name: "a package published with the dependencies the build resolved", subject: "mesh.seat.mesh-builds.event.package-published", given: "{\"artifact\":\"mesh-sdk:go\",\"at\":\"2026-11-10T10:00:00Z\",\"branch\":\"main\",\"dependencies\":[{\"artifact\":\"mesh-contracts:go-nodeengine\",\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"kind\":1,\"version\":\"0.1.4\"},{\"artifact\":\"mesh-build-go:toolchain\",\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"kind\":3,\"version\":\"1.26.0\"}],\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"module\":\"mesh-sdk\",\"source\":3589,\"v\":1,\"version\":\"0.1.15\"}", wire: "{\"artifact\":\"mesh-sdk:go\",\"at\":\"2026-11-10T10:00:00Z\",\"branch\":\"main\",\"dependencies\":[{\"artifact\":\"mesh-contracts:go-nodeengine\",\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"kind\":1,\"version\":\"0.1.4\"},{\"artifact\":\"mesh-build-go:toolchain\",\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"kind\":3,\"version\":\"1.26.0\"}],\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"module\":\"mesh-sdk\",\"source\":3589,\"v\":1,\"version\":\"0.1.15\"}",
decode: func(b []byte) (any, error) { var v PackagePublishedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-builds\\.event\\.package-published$")},
{name: "a toolchain agreed", subject: "mesh.seat.mesh-builds.event.toolchain-agreed", given: "{\"at\":\"2026-11-10T10:00:00Z\",\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"language\":\"go\",\"v\":1,\"version\":\"1.26.0\"}", wire: "{\"at\":\"2026-11-10T10:00:00Z\",\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"language\":\"go\",\"v\":1,\"version\":\"1.26.0\"}",
{name: "a toolchain agreed", subject: "mesh.seat.mesh-builds.event.toolchain-agreed", given: "{\"at\":\"2026-11-10T10:00:00Z\",\"digests\":[{\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"nodes\":[\"ace\",\"g14\"]},{\"digest\":\"sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\",\"nodes\":[\"novox\"]}],\"language\":\"go\",\"v\":1,\"version\":\"1.26.0\"}", wire: "{\"at\":\"2026-11-10T10:00:00Z\",\"digests\":[{\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"nodes\":[\"ace\",\"g14\"]},{\"digest\":\"sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210\",\"nodes\":[\"novox\"]}],\"language\":\"go\",\"v\":1,\"version\":\"1.26.0\"}",
decode: func(b []byte) (any, error) { var v ToolchainAgreedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-builds\\.event\\.toolchain-agreed$")},
{name: "reserved asks a module", subject: "", given: "{\"module\":\"mesh-sdk\"}", wire: "{\"module\":\"mesh-sdk\"}",
decode: func(b []byte) (any, error) { var v ReservedInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "reserved asks a package by its name", subject: "", given: "{\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"version\":\"0.1.15\"}", wire: "{\"name\":\"git.novox.be/novox/mesh-sdk/go\",\"version\":\"0.1.15\"}",
decode: func(b []byte) (any, error) { var v ReservedInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "reserved answers the version and each artifact's digest", subject: "", given: "{\"artifacts\":[{\"artifact\":\"mesh-sdk:go\",\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"},{\"artifact\":\"mesh-sdk:ts\"}],\"module\":\"mesh-sdk\",\"version\":\"0.1.15\"}", wire: "{\"artifacts\":[{\"artifact\":\"mesh-sdk:go\",\"digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"},{\"artifact\":\"mesh-sdk:ts\"}],\"module\":\"mesh-sdk\",\"version\":\"0.1.15\"}",
decode: func(b []byte) (any, error) { var v ReservedAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
+3 -1
View File
@@ -736,7 +736,9 @@ type CatalogEntry struct {
Main string `json:"main,omitempty"`
// the validated manifest whole, kept as an object by the entry's digest, so no size limit applies; read by anyone but the indexer through the entry, never as a manifest (design 47 G section 443, section 442)
Manifest map[string]any `json:"manifest"`
Module string `json:"module"`
// the sha256: digest of the manifest's bytes as the forge answered them, set by the indexer at take-in and never computed again from the manifest whole; the module's value and the manifests key are this digest (design 47 G section 506)
ManifestDigest string `json:"manifest-digest,omitempty"`
Module string `json:"module"`
// the manifest reads a node-engine must state to compose it, its templates' forms included, as the forge bridge read them at the merge (ADR 0614 §4, ADR 0632 §2)
Needs []string `json:"needs,omitempty"`
// why it has none
@@ -85,7 +85,7 @@ func TestFixtures(t *testing.T) {
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a catalog graph entry (xclip)", subject: "", given: "{\"bases\":[\"arch\"],\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"inputs\":{\"fp-src\":41,\"from\":{\"bases\":[\"arch\"],\"locks\":{\"go.sum\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"},\"packages\":[\"git.novox.be/novox/mesh-sdk/go@v0.0.0-20261101000000-f469eb387a30\"],\"tree\":\"4b825dc642cb6eb9a060e54bf8d69288fbee4904\"},\"src\":41,\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}}},\"invokes\":[\"node-files.read\"],\"manifest\":{\"module\":\"xclip\",\"version\":\"1\"},\"module\":\"xclip\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"v0.0.0-20261101000000-f469eb387a30\"},\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"module\":\"mesh-build-go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1,\"verdict\":\"accepted\"}", wire: "{\"bases\":[\"arch\"],\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"inputs\":{\"fp-src\":41,\"from\":{\"bases\":[\"arch\"],\"locks\":{\"go.sum\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"},\"packages\":[\"git.novox.be/novox/mesh-sdk/go@v0.0.0-20261101000000-f469eb387a30\"],\"tree\":\"4b825dc642cb6eb9a060e54bf8d69288fbee4904\"},\"src\":41,\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}}},\"invokes\":[\"node-files.read\"],\"manifest\":{\"module\":\"xclip\",\"version\":\"1\"},\"module\":\"xclip\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"v0.0.0-20261101000000-f469eb387a30\"},\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"module\":\"mesh-build-go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1,\"verdict\":\"accepted\"}",
{name: "a catalog graph entry (xclip)", subject: "", given: "{\"bases\":[\"arch\"],\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"inputs\":{\"fp-src\":41,\"from\":{\"bases\":[\"arch\"],\"locks\":{\"go.sum\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"},\"packages\":[\"git.novox.be/novox/mesh-sdk/go@v0.0.0-20261101000000-f469eb387a30\"],\"tree\":\"4b825dc642cb6eb9a060e54bf8d69288fbee4904\"},\"src\":41,\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}}},\"invokes\":[\"node-files.read\"],\"manifest\":{\"module\":\"xclip\",\"version\":\"1\"},\"manifest-digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"module\":\"xclip\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"v0.0.0-20261101000000-f469eb387a30\"},\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"module\":\"mesh-build-go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1,\"verdict\":\"accepted\"}", wire: "{\"bases\":[\"arch\"],\"commit\":\"f469eb387a3015569d9b16c66ed418e9dd098f64\",\"fp\":\"src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56\",\"inputs\":{\"fp-src\":41,\"from\":{\"bases\":[\"arch\"],\"locks\":{\"go.sum\":\"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"},\"packages\":[\"git.novox.be/novox/mesh-sdk/go@v0.0.0-20261101000000-f469eb387a30\"],\"tree\":\"4b825dc642cb6eb9a060e54bf8d69288fbee4904\"},\"src\":41,\"toolchains\":{\"go\":{\"language\":\"go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}}},\"invokes\":[\"node-files.read\"],\"manifest\":{\"module\":\"xclip\",\"version\":\"1\"},\"manifest-digest\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\"module\":\"xclip\",\"packages\":{\"git.novox.be/novox/mesh-sdk/go\":\"v0.0.0-20261101000000-f469eb387a30\"},\"path\":\"modules/xclip\",\"repository\":\"novox/mesh-catalog\",\"toolchains\":{\"go\":{\"module\":\"mesh-build-go\",\"recipe\":\"1\",\"version\":\"1.26.0\"}},\"v\":1,\"verdict\":\"accepted\"}",
decode: func(b []byte) (any, error) {
var v CatalogRevisionsByEntryDigestValue
err := json.Unmarshal(b, &v)
@@ -675,6 +675,8 @@ type Wait struct {
Artifact string `json:"artifact,omitempty"`
// the watched branch, or the branch of a run on request
Branch string `json:"branch"`
// the time the bound counts from (design 47 G section 266, section 269, section 498)
ClockFrom *time.Time `json:"clock-from,omitempty"`
// the commit the module's entry was last changed at
Commit string `json:"commit,omitempty"`
// the module's build-input fingerprint at the commit
+56 -5
View File
@@ -39,6 +39,7 @@ const (
EventRoom = "room"
EventStepEnded = "step-ended"
EventVersionDelivered = "version-delivered"
EventStepProgress = "step-progress"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
@@ -48,7 +49,7 @@ func Verbs() []string {
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventPolicyChanged, EventAssignmentRevision, EventDifference, EventWanted, EventWaitHeld, EventWaitEnded, EventFilling, EventFull, EventRoom, EventStepEnded, EventVersionDelivered}
return []string{EventPolicyChanged, EventAssignmentRevision, EventDifference, EventWanted, EventWaitHeld, EventWaitEnded, EventFilling, EventFull, EventRoom, EventStepEnded, EventVersionDelivered, EventStepProgress}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
@@ -99,6 +100,17 @@ const StreamVersionDelivered = "SEAT_mesh-wants_version-delivered"
// SubjectVersionDelivered is where version-delivered is published.
const SubjectVersionDelivered = "mesh.seat.mesh-wants.event.version-delivered"
// StreamStepProgress is the stream step-progress is kept in.
const StreamStepProgress = "SEAT_mesh-wants_step-progress"
// SubjectStepProgress is where step-progress is published, on mesh.seat.mesh-wants.event.step-progress.<node>.
func SubjectStepProgress(node string) string {
return "mesh.seat.mesh-wants.event.step-progress." + node
}
// SubjectStepProgressAll hears every step-progress.
const SubjectStepProgressAll = "mesh.seat.mesh-wants.event.step-progress.*"
// AcceptSubjectStep is where step is sent.
const AcceptSubjectStep = "mesh.seat.mesh-wants.accept.step"
@@ -647,20 +659,43 @@ type Wait struct {
Bound string `json:"bound,omitempty"`
// on a release, who released it and why
By string `json:"by,omitempty"`
Fp string `json:"fp,omitempty"`
// the time the bound counts from (design 47 G section 266, section 269, section 498)
ClockFrom *time.Time `json:"clock-from,omitempty"`
// a put's wait: the fingerprint of the build put
Fingerprint string `json:"fingerprint,omitempty"`
Fp string `json:"fp,omitempty"`
// on wait-ended: ran or released
How string `json:"how,omitempty"`
// a dependent's delivery wait: the machines the dependency's version has yet to reach
Machines []string `json:"machines,omitempty"`
// the entry that waits
Module string `json:"module"`
Node string `json:"node,omitempty"`
// the acting part waited for
Part string `json:"part"`
Part string `json:"part,omitempty"`
Since time.Time `json:"since"`
Step string `json:"step"`
// which wait: an acting step of section 203 or put, delivery, kept-build, backup (design 47 G section 498)
Step string `json:"step"`
// its new version's fingerprint
Version string `json:"version"`
Version string `json:"version,omitempty"`
}
// The values of Wait's step.
const (
WaitStepTakeIn = "take-in"
WaitStepValidate = "validate"
WaitStepCheck = "check"
WaitStepBuild = "build"
WaitStepCompose = "compose"
WaitStepGrant = "grant"
WaitStepPlace = "place"
WaitStepPipeline = "pipeline"
WaitStepPut = "put"
WaitStepDelivery = "delivery"
WaitStepKeptBuild = "kept-build"
WaitStepBackup = "backup"
)
// UpgradeInput is what upgrade takes.
type UpgradeInput struct {
// "true": each node's own composition, by its digest, and the assigner's findings
@@ -823,6 +858,22 @@ type VersionDeliveredEvent struct {
Version string `json:"version"`
}
// StepProgressEvent is the body of the event step-progress.
type StepProgressEvent struct {
Ask string `json:"ask"`
Asker string `json:"asker"`
At time.Time `json:"at"`
Phase string `json:"phase"`
}
// The values of StepProgressEvent's phase.
const (
StepProgressEventPhaseFetch = "fetch"
StepProgressEventPhaseBuild = "build"
StepProgressEventPhaseRun = "run"
StepProgressEventPhaseReport = "report"
)
// WantsByWantNodeModuleValue is one value of the state wants under the key want.<node>.<module>.
type WantsByWantNodeModuleValue struct {
// again or skip
@@ -43,6 +43,12 @@ func TestFixtures(t *testing.T) {
decode: func(b []byte) (any, error) { var v StepEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-wants\\.event\\.step-ended\\.[^.*> ]+$")},
{name: "a version delivered with the machines away", subject: "mesh.seat.mesh-wants.event.version-delivered", given: "{\"away\":[\"laptop\"],\"module\":\"mesh-sdk\",\"parts\":{\"mesh-sdk:go\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"},\"v\":1,\"version\":\"0.1.15\"}", wire: "{\"away\":[\"laptop\"],\"module\":\"mesh-sdk\",\"parts\":{\"mesh-sdk:go\":\"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"},\"v\":1,\"version\":\"0.1.15\"}",
decode: func(b []byte) (any, error) { var v VersionDeliveredEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-wants\\.event\\.version-delivered$")},
{name: "a composition step's progress", subject: "mesh.seat.mesh-wants.event.step-progress.ace", given: "{\"ask\":\"a1b2\",\"asker\":\"assigner\",\"at\":\"2026-11-11T10:01:00Z\",\"phase\":\"build\"}", wire: "{\"ask\":\"a1b2\",\"asker\":\"assigner\",\"at\":\"2026-11-11T10:01:00Z\",\"phase\":\"build\"}",
decode: func(b []byte) (any, error) { var v StepProgressEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-wants\\.event\\.step-progress\\.[^.*> ]+$")},
{name: "a put without hold waits, counted from its start", subject: "mesh.seat.mesh-wants.event.wait-held", given: "{\"bound\":\"30m\",\"clock-from\":\"2026-11-11T10:00:00Z\",\"fingerprint\":\"fp-0123\",\"module\":\"nats\",\"node\":\"ace\",\"since\":\"2026-11-11T10:00:00Z\",\"step\":\"put\"}", wire: "{\"bound\":\"30m\",\"clock-from\":\"2026-11-11T10:00:00Z\",\"fingerprint\":\"fp-0123\",\"module\":\"nats\",\"node\":\"ace\",\"since\":\"2026-11-11T10:00:00Z\",\"step\":\"put\"}",
decode: func(b []byte) (any, error) { var v WaitHeldEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-wants\\.event\\.wait-held$")},
{name: "a dependent's delivery waits for a dependency's version", subject: "mesh.seat.mesh-wants.event.wait-held", given: "{\"bound\":\"60m\",\"clock-from\":\"2026-11-11T09:58:00Z\",\"machines\":[\"ace\",\"g14\"],\"module\":\"nats\",\"part\":\"mesh-sdk\",\"since\":\"2026-11-11T10:00:00Z\",\"step\":\"delivery\",\"version\":\"0.1.4\"}", wire: "{\"bound\":\"60m\",\"clock-from\":\"2026-11-11T09:58:00Z\",\"machines\":[\"ace\",\"g14\"],\"module\":\"nats\",\"part\":\"mesh-sdk\",\"since\":\"2026-11-11T10:00:00Z\",\"step\":\"delivery\",\"version\":\"0.1.4\"}",
decode: func(b []byte) (any, error) { var v WaitHeldEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.mesh-wants\\.event\\.wait-held$")},
{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
+139 -7
View File
@@ -33,7 +33,9 @@ const (
)
const (
EventStepEnded = "step-ended"
EventStepEnded = "step-ended"
EventToolchainReported = "toolchain-reported"
EventStepProgress = "step-progress"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
@@ -43,7 +45,7 @@ func Verbs() []string {
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventStepEnded}
return []string{EventStepEnded, EventToolchainReported, EventStepProgress}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
@@ -61,6 +63,28 @@ func SubjectStepEnded(node string) string { return "mesh.seat.node-build-go.even
// SubjectStepEndedAll hears every step-ended.
const SubjectStepEndedAll = "mesh.seat.node-build-go.event.step-ended.*"
// StreamToolchainReported is the stream toolchain-reported is kept in.
const StreamToolchainReported = "SEAT_node-build-go_toolchain-reported"
// SubjectToolchainReported is where toolchain-reported is published, on mesh.seat.node-build-go.event.toolchain-reported.<node>.
func SubjectToolchainReported(node string) string {
return "mesh.seat.node-build-go.event.toolchain-reported." + node
}
// SubjectToolchainReportedAll hears every toolchain-reported.
const SubjectToolchainReportedAll = "mesh.seat.node-build-go.event.toolchain-reported.*"
// StreamStepProgress is the stream step-progress is kept in.
const StreamStepProgress = "SEAT_node-build-go_step-progress"
// SubjectStepProgress is where step-progress is published, on mesh.seat.node-build-go.event.step-progress.<node>.
func SubjectStepProgress(node string) string {
return "mesh.seat.node-build-go.event.step-progress." + node
}
// SubjectStepProgressAll hears every step-progress.
const SubjectStepProgressAll = "mesh.seat.node-build-go.event.step-progress.*"
// AcceptSubjectDelivery is where delivery is sent.
const AcceptSubjectDelivery = "mesh.seat.node-build-go.accept.delivery"
@@ -86,6 +110,47 @@ const (
StateDetectedSteps = "detected-steps"
)
// StepsByAskerNodeIDKey is the key of one value of the state steps under the form "<asker>.<node>.<id>".
func StepsByAskerNodeIDKey(asker, node, id string) string { return asker + "." + node + "." + id }
// ParseStepsByAskerNodeIDKey reads the parts of a key of the form "<asker>.<node>.<id>", in the order of the form; the last result is false for a key of another form.
func ParseStepsByAskerNodeIDKey(key 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
if rest == "" {
return "", "", "", false
}
v4 := rest
rest = ""
if rest != "" {
return "", "", "", false
}
return v0, v2, v4, true
}
// DecodeStepsByAskerNodeIDValue reads one value of the state steps under the form "<asker>.<node>.<id>", refusing a field StepsByAskerNodeIDValue does not know and any text after the value.
func DecodeStepsByAskerNodeIDValue(raw []byte) (StepsByAskerNodeIDValue, error) {
var v StepsByAskerNodeIDValue
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
}
// StepsByNodeIDKey is the key of one value of the state steps under the form "<node>.<id>".
func StepsByNodeIDKey(node, id string) string { return node + "." + id }
@@ -317,11 +382,21 @@ type BuildAnswer struct {
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
}
// BuiltArtifactResolvedItem is a type of the contract.
type BuiltArtifactResolvedItem struct {
// sha256: of the package's bytes as the build fetched them
Digest string `json:"digest"`
Package string `json:"package"`
Version string `json:"version"`
}
// BuiltArtifact is one artifact a build made: its digest and the reference it was published under.
type BuiltArtifact struct {
Artifact string `json:"artifact"`
Digest string `json:"digest"`
Reference string `json:"reference,omitempty"`
// one per mesh package the build resolved (the catalog graph entry's packages, the edges of kind 1)
Resolved []BuiltArtifactResolvedItem `json:"resolved,omitempty"`
}
// Capacity is how many steps the node runs at once.
@@ -396,6 +471,16 @@ type FetchBesideItem struct {
Repository string `json:"repository"`
}
// FetchBuildsItem is a type of the contract.
type FetchBuildsItem struct {
Artifact string `json:"artifact"`
// the member's build digest, sha256:
Digest string `json:"digest"`
Module string `json:"module"`
// the name in its ecosystem (the Go module path, the npm name)
Package string `json:"package"`
}
// FetchPackage is the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's.
type FetchPackage struct {
Commit string `json:"commit,omitempty"`
@@ -411,6 +496,8 @@ type Fetch struct {
Base string `json:"base,omitempty"`
// the sibling repositories placed beside the fetched tree
Beside []FetchBesideItem `json:"beside,omitempty"`
// a group run's package members, each by its build's digest: the build module reads each from the artifact store at artifact-store://<module>/<artifact>/blobs/<digest>, checks the bytes against the digest and lays them as the group package; a package named here is named in neither packages nor package
Builds []FetchBuildsItem `json:"builds,omitempty"`
// the full commit
Commit string `json:"commit"`
// the module's directory in the repository ("." for its root), with paths
@@ -735,6 +822,22 @@ type StepNames struct {
Repositories []string `json:"repositories"`
}
// StepProgress is a running ask's progress, said once a minute while the holder runs it (design 47 G section 496).
type StepProgress struct {
Ask string `json:"ask"`
Asker string `json:"asker"`
At time.Time `json:"at"`
Phase string `json:"phase"`
}
// The values of StepProgress's phase.
const (
StepProgressPhaseFetch = "fetch"
StepProgressPhaseBuild = "build"
StepProgressPhaseRun = "run"
StepProgressPhaseReport = "report"
)
// StepResult is what a step answered, by the kind of ask: a build's artifacts, toolchain, imports, out-of-step modules and the artifact it kept; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five.
type StepResult struct {
Artifacts []BuiltArtifact `json:"artifacts,omitempty"`
@@ -828,8 +931,20 @@ type Toolchain struct {
Version string `json:"version"`
}
// ToolchainReported is a node's toolchain of a language, said at the build module's start and each time its toolchains key of the language changes (design 47 G section 492).
type ToolchainReported struct {
// the digest of the program of the provision the build module requires for that language's toolchain, never the build module's own
Digest string `json:"digest"`
Language string `json:"language"`
Node string `json:"node"`
Recipe string `json:"recipe"`
Version string `json:"version"`
}
// ToolchainUsed is the toolchain a build was made with: the language, the installed version and the build module's recipe.
type ToolchainUsed struct {
// the building node's digest of its toolchain program (kind 3's entry), never the build module's own program
Digest string `json:"digest,omitempty"`
Language string `json:"language"`
Recipe string `json:"recipe"`
Version string `json:"version"`
@@ -912,20 +1027,28 @@ const (
type StepsInput struct {
// find them anew, not from the kept answer
Again bool `json:"again,omitempty"`
// the artifact reference, <module>:<artifact>
Artifact string `json:"artifact"`
Fetch Fetch `json:"fetch"`
// the key of detected-steps, <module>.<artifact>
Artifact string `json:"artifact"`
// delete the artifact's kept key instead of detecting; needs why
Drop bool `json:"drop,omitempty"`
Fetch *Fetch `json:"fetch,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Module string `json:"module"`
// with drop, why: a non-empty text, written in the hand-act log with the caller the bus proved
Why string `json:"why,omitempty"`
}
// StepsAnswer is what steps answers.
type StepsAnswer struct {
Artifact string `json:"artifact"`
Artifact string `json:"artifact,omitempty"`
// the artifact whose key a drop deleted
Dropped string `json:"dropped,omitempty"`
// why a drop deleted nothing, naming the hand-act log when it refused
Error string `json:"error,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
// answered from the kept answer
Kept bool `json:"kept,omitempty"`
Steps []DetectedStep `json:"steps"`
Steps []DetectedStep `json:"steps,omitempty"`
}
// DeliveryAccept is the body of delivery.
@@ -949,6 +1072,15 @@ type CheckRunAccept = StepAsk
// StepEndedEvent is the body of the event step-ended.
type StepEndedEvent = StepEnded
// ToolchainReportedEvent is the body of the event toolchain-reported.
type ToolchainReportedEvent = ToolchainReported
// StepProgressEvent is the body of the event step-progress.
type StepProgressEvent = StepProgress
// StepsByAskerNodeIDValue is one value of the state steps under the key <asker>.<node>.<id>.
type StepsByAskerNodeIDValue = Step
// StepsByNodeIDValue is one value of the state steps under the key <node>.<id>.
type StepsByNodeIDValue = Step
@@ -21,6 +21,10 @@ func TestFixtures(t *testing.T) {
decode: func(b []byte) (any, error) { var v StepEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.event\\.step-ended\\.[^.*> ]+$")},
{name: "an integration step that could not run, naming the part not heard", subject: "mesh.seat.node-build-go.event.step-ended.g14", given: "{\"artifact\":\"nats:program\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fingerprint\":\"fp-0123\",\"id\":\"m2kx9a1b3c4d5f\",\"module\":\"nats\",\"node\":\"g14\",\"outcome\":\"could not run\",\"output-object\":\"mesh-build-go_sent-objects/m2kx9a1b3c4d5f\",\"pipeline\":\"main.12.nats\",\"said\":\"the sandbox could not fetch novox/mesh-sdk\",\"step\":\"integration\",\"v\":1}", wire: "{\"artifact\":\"nats:program\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fingerprint\":\"fp-0123\",\"id\":\"m2kx9a1b3c4d5f\",\"module\":\"nats\",\"node\":\"g14\",\"outcome\":\"could not run\",\"output-object\":\"mesh-build-go_sent-objects/m2kx9a1b3c4d5f\",\"pipeline\":\"main.12.nats\",\"said\":\"the sandbox could not fetch novox/mesh-sdk\",\"step\":\"integration\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.event\\.step-ended\\.[^.*> ]+$")},
{name: "a node's toolchain reported", subject: "mesh.seat.node-build-go.event.toolchain-reported.ace", given: "{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"language\":\"go\",\"node\":\"ace\",\"recipe\":\"1\",\"version\":\"1.26.0\"}", wire: "{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"language\":\"go\",\"node\":\"ace\",\"recipe\":\"1\",\"version\":\"1.26.0\"}",
decode: func(b []byte) (any, error) { var v ToolchainReportedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.event\\.toolchain-reported\\.[^.*> ]+$")},
{name: "a running ask says its progress", subject: "mesh.seat.node-build-go.event.step-progress.ace", given: "{\"ask\":\"a1b2\",\"asker\":\"runner\",\"at\":\"2026-11-11T10:01:00Z\",\"phase\":\"fetch\"}", wire: "{\"ask\":\"a1b2\",\"asker\":\"runner\",\"at\":\"2026-11-11T10:01:00Z\",\"phase\":\"fetch\"}",
decode: func(b []byte) (any, error) { var v StepProgressEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-go\\.event\\.step-progress\\.[^.*> ]+$")},
{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}",
@@ -75,6 +79,12 @@ func TestFixtures(t *testing.T) {
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a step keyed under its asker", 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 StepsByAskerNodeIDValue
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},
{name: "kept-artifact asks", subject: "", given: "{\"digest\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"fp\":\"src2:1480985e\",\"module\":\"artifact-store\"}", wire: "{\"digest\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"fp\":\"src2:1480985e\",\"module\":\"artifact-store\"}",
@@ -93,6 +103,12 @@ func TestFixtures(t *testing.T) {
decode: func(b []byte) (any, error) { var v StepsInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps answers lint and unit tests", subject: "", given: "{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"steps\":[{\"step\":\"lint\"},{\"packages\":[\"./modules/nats\"],\"step\":\"unit\"}]}", wire: "{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"steps\":[{\"step\":\"lint\"},{\"packages\":[\"./modules/nats\"],\"step\":\"unit\"}]}",
decode: func(b []byte) (any, error) { var v StepsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps drops an artifact's kept steps with a reason", subject: "", given: "{\"artifact\":\"nats.program\",\"drop\":true,\"module\":\"nats\",\"why\":\"the detection was wrong\"}", wire: "{\"artifact\":\"nats.program\",\"drop\":true,\"module\":\"nats\",\"why\":\"the detection was wrong\"}",
decode: func(b []byte) (any, error) { var v StepsInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps answers a drop with the artifact", subject: "", given: "{\"dropped\":\"nats.program\"}", wire: "{\"dropped\":\"nats.program\"}",
decode: func(b []byte) (any, error) { var v StepsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps answers a drop that the hand-act log refused", subject: "", given: "{\"error\":\"the hand-act log refused: nothing was deleted\"}", wire: "{\"error\":\"the hand-act log refused: nothing was deleted\"}",
decode: func(b []byte) (any, error) { var v StepsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
+139 -7
View File
@@ -32,7 +32,9 @@ const (
)
const (
EventStepEnded = "step-ended"
EventStepEnded = "step-ended"
EventToolchainReported = "toolchain-reported"
EventStepProgress = "step-progress"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
@@ -42,7 +44,7 @@ func Verbs() []string {
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventStepEnded}
return []string{EventStepEnded, EventToolchainReported, EventStepProgress}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
@@ -62,6 +64,28 @@ func SubjectStepEnded(node string) string {
// SubjectStepEndedAll hears every step-ended.
const SubjectStepEndedAll = "mesh.seat.node-build-image.event.step-ended.*"
// StreamToolchainReported is the stream toolchain-reported is kept in.
const StreamToolchainReported = "SEAT_node-build-image_toolchain-reported"
// SubjectToolchainReported is where toolchain-reported is published, on mesh.seat.node-build-image.event.toolchain-reported.<node>.
func SubjectToolchainReported(node string) string {
return "mesh.seat.node-build-image.event.toolchain-reported." + node
}
// SubjectToolchainReportedAll hears every toolchain-reported.
const SubjectToolchainReportedAll = "mesh.seat.node-build-image.event.toolchain-reported.*"
// StreamStepProgress is the stream step-progress is kept in.
const StreamStepProgress = "SEAT_node-build-image_step-progress"
// SubjectStepProgress is where step-progress is published, on mesh.seat.node-build-image.event.step-progress.<node>.
func SubjectStepProgress(node string) string {
return "mesh.seat.node-build-image.event.step-progress." + node
}
// SubjectStepProgressAll hears every step-progress.
const SubjectStepProgressAll = "mesh.seat.node-build-image.event.step-progress.*"
// AcceptSubjectDelivery is where delivery is sent.
const AcceptSubjectDelivery = "mesh.seat.node-build-image.accept.delivery"
@@ -84,6 +108,47 @@ const (
StateDetectedSteps = "detected-steps"
)
// StepsByAskerNodeIDKey is the key of one value of the state steps under the form "<asker>.<node>.<id>".
func StepsByAskerNodeIDKey(asker, node, id string) string { return asker + "." + node + "." + id }
// ParseStepsByAskerNodeIDKey reads the parts of a key of the form "<asker>.<node>.<id>", in the order of the form; the last result is false for a key of another form.
func ParseStepsByAskerNodeIDKey(key 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
if rest == "" {
return "", "", "", false
}
v4 := rest
rest = ""
if rest != "" {
return "", "", "", false
}
return v0, v2, v4, true
}
// DecodeStepsByAskerNodeIDValue reads one value of the state steps under the form "<asker>.<node>.<id>", refusing a field StepsByAskerNodeIDValue does not know and any text after the value.
func DecodeStepsByAskerNodeIDValue(raw []byte) (StepsByAskerNodeIDValue, error) {
var v StepsByAskerNodeIDValue
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
}
// StepsByNodeIDKey is the key of one value of the state steps under the form "<node>.<id>".
func StepsByNodeIDKey(node, id string) string { return node + "." + id }
@@ -315,11 +380,21 @@ type BuildAnswer struct {
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
}
// BuiltArtifactResolvedItem is a type of the contract.
type BuiltArtifactResolvedItem struct {
// sha256: of the package's bytes as the build fetched them
Digest string `json:"digest"`
Package string `json:"package"`
Version string `json:"version"`
}
// BuiltArtifact is one artifact a build made: its digest and the reference it was published under.
type BuiltArtifact struct {
Artifact string `json:"artifact"`
Digest string `json:"digest"`
Reference string `json:"reference,omitempty"`
// one per mesh package the build resolved (the catalog graph entry's packages, the edges of kind 1)
Resolved []BuiltArtifactResolvedItem `json:"resolved,omitempty"`
}
// Capacity is how many steps the node runs at once.
@@ -394,6 +469,16 @@ type FetchBesideItem struct {
Repository string `json:"repository"`
}
// FetchBuildsItem is a type of the contract.
type FetchBuildsItem struct {
Artifact string `json:"artifact"`
// the member's build digest, sha256:
Digest string `json:"digest"`
Module string `json:"module"`
// the name in its ecosystem (the Go module path, the npm name)
Package string `json:"package"`
}
// FetchPackage is the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's.
type FetchPackage struct {
Commit string `json:"commit,omitempty"`
@@ -409,6 +494,8 @@ type Fetch struct {
Base string `json:"base,omitempty"`
// the sibling repositories placed beside the fetched tree
Beside []FetchBesideItem `json:"beside,omitempty"`
// a group run's package members, each by its build's digest: the build module reads each from the artifact store at artifact-store://<module>/<artifact>/blobs/<digest>, checks the bytes against the digest and lays them as the group package; a package named here is named in neither packages nor package
Builds []FetchBuildsItem `json:"builds,omitempty"`
// the full commit
Commit string `json:"commit"`
// the module's directory in the repository ("." for its root), with paths
@@ -733,6 +820,22 @@ type StepNames struct {
Repositories []string `json:"repositories"`
}
// StepProgress is a running ask's progress, said once a minute while the holder runs it (design 47 G section 496).
type StepProgress struct {
Ask string `json:"ask"`
Asker string `json:"asker"`
At time.Time `json:"at"`
Phase string `json:"phase"`
}
// The values of StepProgress's phase.
const (
StepProgressPhaseFetch = "fetch"
StepProgressPhaseBuild = "build"
StepProgressPhaseRun = "run"
StepProgressPhaseReport = "report"
)
// StepResult is what a step answered, by the kind of ask: a build's artifacts, toolchain, imports, out-of-step modules and the artifact it kept; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five.
type StepResult struct {
Artifacts []BuiltArtifact `json:"artifacts,omitempty"`
@@ -826,8 +929,20 @@ type Toolchain struct {
Version string `json:"version"`
}
// ToolchainReported is a node's toolchain of a language, said at the build module's start and each time its toolchains key of the language changes (design 47 G section 492).
type ToolchainReported struct {
// the digest of the program of the provision the build module requires for that language's toolchain, never the build module's own
Digest string `json:"digest"`
Language string `json:"language"`
Node string `json:"node"`
Recipe string `json:"recipe"`
Version string `json:"version"`
}
// ToolchainUsed is the toolchain a build was made with: the language, the installed version and the build module's recipe.
type ToolchainUsed struct {
// the building node's digest of its toolchain program (kind 3's entry), never the build module's own program
Digest string `json:"digest,omitempty"`
Language string `json:"language"`
Recipe string `json:"recipe"`
Version string `json:"version"`
@@ -910,20 +1025,28 @@ const (
type StepsInput struct {
// find them anew, not from the kept answer
Again bool `json:"again,omitempty"`
// the artifact reference, <module>:<artifact>
Artifact string `json:"artifact"`
Fetch Fetch `json:"fetch"`
// the key of detected-steps, <module>.<artifact>
Artifact string `json:"artifact"`
// delete the artifact's kept key instead of detecting; needs why
Drop bool `json:"drop,omitempty"`
Fetch *Fetch `json:"fetch,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Module string `json:"module"`
// with drop, why: a non-empty text, written in the hand-act log with the caller the bus proved
Why string `json:"why,omitempty"`
}
// StepsAnswer is what steps answers.
type StepsAnswer struct {
Artifact string `json:"artifact"`
Artifact string `json:"artifact,omitempty"`
// the artifact whose key a drop deleted
Dropped string `json:"dropped,omitempty"`
// why a drop deleted nothing, naming the hand-act log when it refused
Error string `json:"error,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
// answered from the kept answer
Kept bool `json:"kept,omitempty"`
Steps []DetectedStep `json:"steps"`
Steps []DetectedStep `json:"steps,omitempty"`
}
// DeliveryAccept is the body of delivery.
@@ -944,6 +1067,15 @@ type RecheckAccept = StepAsk
// StepEndedEvent is the body of the event step-ended.
type StepEndedEvent = StepEnded
// ToolchainReportedEvent is the body of the event toolchain-reported.
type ToolchainReportedEvent = ToolchainReported
// StepProgressEvent is the body of the event step-progress.
type StepProgressEvent = StepProgress
// StepsByAskerNodeIDValue is one value of the state steps under the key <asker>.<node>.<id>.
type StepsByAskerNodeIDValue = Step
// StepsByNodeIDValue is one value of the state steps under the key <node>.<id>.
type StepsByNodeIDValue = Step
@@ -21,6 +21,10 @@ func TestFixtures(t *testing.T) {
decode: func(b []byte) (any, error) { var v StepEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-image\\.event\\.step-ended\\.[^.*> ]+$")},
{name: "a lint step that could not run, naming the part not heard", subject: "mesh.seat.node-build-image.event.step-ended.g14", given: "{\"artifact\":\"nats:program\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fingerprint\":\"fp-0123\",\"id\":\"m2kx9a1b3c4d5f\",\"module\":\"nats\",\"node\":\"g14\",\"outcome\":\"could not run\",\"output-object\":\"mesh-build-go_sent-objects/m2kx9a1b3c4d5f\",\"pipeline\":\"main.12.nats\",\"said\":\"the sandbox could not fetch novox/mesh-sdk\",\"step\":\"lint\",\"v\":1}", wire: "{\"artifact\":\"nats:program\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fingerprint\":\"fp-0123\",\"id\":\"m2kx9a1b3c4d5f\",\"module\":\"nats\",\"node\":\"g14\",\"outcome\":\"could not run\",\"output-object\":\"mesh-build-go_sent-objects/m2kx9a1b3c4d5f\",\"pipeline\":\"main.12.nats\",\"said\":\"the sandbox could not fetch novox/mesh-sdk\",\"step\":\"lint\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-image\\.event\\.step-ended\\.[^.*> ]+$")},
{name: "a node's toolchain reported", subject: "mesh.seat.node-build-image.event.toolchain-reported.ace", given: "{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"language\":\"go\",\"node\":\"ace\",\"recipe\":\"1\",\"version\":\"1.26.0\"}", wire: "{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"language\":\"go\",\"node\":\"ace\",\"recipe\":\"1\",\"version\":\"1.26.0\"}",
decode: func(b []byte) (any, error) { var v ToolchainReportedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-image\\.event\\.toolchain-reported\\.[^.*> ]+$")},
{name: "a running ask says its progress", subject: "mesh.seat.node-build-image.event.step-progress.ace", given: "{\"ask\":\"a1b2\",\"asker\":\"runner\",\"at\":\"2026-11-11T10:01:00Z\",\"phase\":\"fetch\"}", wire: "{\"ask\":\"a1b2\",\"asker\":\"runner\",\"at\":\"2026-11-11T10:01:00Z\",\"phase\":\"fetch\"}",
decode: func(b []byte) (any, error) { var v StepProgressEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-image\\.event\\.step-progress\\.[^.*> ]+$")},
{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}",
@@ -57,6 +61,12 @@ func TestFixtures(t *testing.T) {
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a step keyed under its asker", 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 StepsByAskerNodeIDValue
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},
{name: "kept-artifact asks", subject: "", given: "{\"digest\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"fp\":\"src2:1480985e\",\"module\":\"artifact-store\"}", wire: "{\"digest\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"fp\":\"src2:1480985e\",\"module\":\"artifact-store\"}",
@@ -75,6 +85,12 @@ func TestFixtures(t *testing.T) {
decode: func(b []byte) (any, error) { var v StepsInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps answers lint and unit tests", subject: "", given: "{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"steps\":[{\"step\":\"lint\"},{\"packages\":[\"./modules/nats\"],\"step\":\"unit\"}]}", wire: "{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"steps\":[{\"step\":\"lint\"},{\"packages\":[\"./modules/nats\"],\"step\":\"unit\"}]}",
decode: func(b []byte) (any, error) { var v StepsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps drops an artifact's kept steps with a reason", subject: "", given: "{\"artifact\":\"nats.program\",\"drop\":true,\"module\":\"nats\",\"why\":\"the detection was wrong\"}", wire: "{\"artifact\":\"nats.program\",\"drop\":true,\"module\":\"nats\",\"why\":\"the detection was wrong\"}",
decode: func(b []byte) (any, error) { var v StepsInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps answers a drop with the artifact", subject: "", given: "{\"dropped\":\"nats.program\"}", wire: "{\"dropped\":\"nats.program\"}",
decode: func(b []byte) (any, error) { var v StepsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps answers a drop that the hand-act log refused", subject: "", given: "{\"error\":\"the hand-act log refused: nothing was deleted\"}", wire: "{\"error\":\"the hand-act log refused: nothing was deleted\"}",
decode: func(b []byte) (any, error) { var v StepsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
+139 -7
View File
@@ -32,7 +32,9 @@ const (
)
const (
EventStepEnded = "step-ended"
EventStepEnded = "step-ended"
EventToolchainReported = "toolchain-reported"
EventStepProgress = "step-progress"
)
// Verbs is every verb the seat serves, in its definition's order: a new copy each call.
@@ -42,7 +44,7 @@ func Verbs() []string {
// Events is every event published on the seat's subject: a new copy each call.
func Events() []string {
return []string{EventStepEnded}
return []string{EventStepEnded, EventToolchainReported, EventStepProgress}
}
// Family is where the seat's events are kept: the shared events stream, filtered to the seat's subjects.
@@ -62,6 +64,28 @@ func SubjectStepEnded(node string) string {
// SubjectStepEndedAll hears every step-ended.
const SubjectStepEndedAll = "mesh.seat.node-build-node.event.step-ended.*"
// StreamToolchainReported is the stream toolchain-reported is kept in.
const StreamToolchainReported = "SEAT_node-build-node_toolchain-reported"
// SubjectToolchainReported is where toolchain-reported is published, on mesh.seat.node-build-node.event.toolchain-reported.<node>.
func SubjectToolchainReported(node string) string {
return "mesh.seat.node-build-node.event.toolchain-reported." + node
}
// SubjectToolchainReportedAll hears every toolchain-reported.
const SubjectToolchainReportedAll = "mesh.seat.node-build-node.event.toolchain-reported.*"
// StreamStepProgress is the stream step-progress is kept in.
const StreamStepProgress = "SEAT_node-build-node_step-progress"
// SubjectStepProgress is where step-progress is published, on mesh.seat.node-build-node.event.step-progress.<node>.
func SubjectStepProgress(node string) string {
return "mesh.seat.node-build-node.event.step-progress." + node
}
// SubjectStepProgressAll hears every step-progress.
const SubjectStepProgressAll = "mesh.seat.node-build-node.event.step-progress.*"
// AcceptSubjectDelivery is where delivery is sent.
const AcceptSubjectDelivery = "mesh.seat.node-build-node.accept.delivery"
@@ -84,6 +108,47 @@ const (
StateDetectedSteps = "detected-steps"
)
// StepsByAskerNodeIDKey is the key of one value of the state steps under the form "<asker>.<node>.<id>".
func StepsByAskerNodeIDKey(asker, node, id string) string { return asker + "." + node + "." + id }
// ParseStepsByAskerNodeIDKey reads the parts of a key of the form "<asker>.<node>.<id>", in the order of the form; the last result is false for a key of another form.
func ParseStepsByAskerNodeIDKey(key 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
if rest == "" {
return "", "", "", false
}
v4 := rest
rest = ""
if rest != "" {
return "", "", "", false
}
return v0, v2, v4, true
}
// DecodeStepsByAskerNodeIDValue reads one value of the state steps under the form "<asker>.<node>.<id>", refusing a field StepsByAskerNodeIDValue does not know and any text after the value.
func DecodeStepsByAskerNodeIDValue(raw []byte) (StepsByAskerNodeIDValue, error) {
var v StepsByAskerNodeIDValue
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
}
// StepsByNodeIDKey is the key of one value of the state steps under the form "<node>.<id>".
func StepsByNodeIDKey(node, id string) string { return node + "." + id }
@@ -315,11 +380,21 @@ type BuildAnswer struct {
Toolchain *ToolchainUsed `json:"toolchain,omitempty"`
}
// BuiltArtifactResolvedItem is a type of the contract.
type BuiltArtifactResolvedItem struct {
// sha256: of the package's bytes as the build fetched them
Digest string `json:"digest"`
Package string `json:"package"`
Version string `json:"version"`
}
// BuiltArtifact is one artifact a build made: its digest and the reference it was published under.
type BuiltArtifact struct {
Artifact string `json:"artifact"`
Digest string `json:"digest"`
Reference string `json:"reference,omitempty"`
// one per mesh package the build resolved (the catalog graph entry's packages, the edges of kind 1)
Resolved []BuiltArtifactResolvedItem `json:"resolved,omitempty"`
}
// Capacity is how many steps the node runs at once.
@@ -394,6 +469,16 @@ type FetchBesideItem struct {
Repository string `json:"repository"`
}
// FetchBuildsItem is a type of the contract.
type FetchBuildsItem struct {
Artifact string `json:"artifact"`
// the member's build digest, sha256:
Digest string `json:"digest"`
Module string `json:"module"`
// the name in its ecosystem (the Go module path, the npm name)
Package string `json:"package"`
}
// FetchPackage is the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's.
type FetchPackage struct {
Commit string `json:"commit,omitempty"`
@@ -409,6 +494,8 @@ type Fetch struct {
Base string `json:"base,omitempty"`
// the sibling repositories placed beside the fetched tree
Beside []FetchBesideItem `json:"beside,omitempty"`
// a group run's package members, each by its build's digest: the build module reads each from the artifact store at artifact-store://<module>/<artifact>/blobs/<digest>, checks the bytes against the digest and lays them as the group package; a package named here is named in neither packages nor package
Builds []FetchBuildsItem `json:"builds,omitempty"`
// the full commit
Commit string `json:"commit"`
// the module's directory in the repository ("." for its root), with paths
@@ -733,6 +820,22 @@ type StepNames struct {
Repositories []string `json:"repositories"`
}
// StepProgress is a running ask's progress, said once a minute while the holder runs it (design 47 G section 496).
type StepProgress struct {
Ask string `json:"ask"`
Asker string `json:"asker"`
At time.Time `json:"at"`
Phase string `json:"phase"`
}
// The values of StepProgress's phase.
const (
StepProgressPhaseFetch = "fetch"
StepProgressPhaseBuild = "build"
StepProgressPhaseRun = "run"
StepProgressPhaseReport = "report"
)
// StepResult is what a step answered, by the kind of ask: a build's artifacts, toolchain, imports, out-of-step modules and the artifact it kept; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five.
type StepResult struct {
Artifacts []BuiltArtifact `json:"artifacts,omitempty"`
@@ -826,8 +929,20 @@ type Toolchain struct {
Version string `json:"version"`
}
// ToolchainReported is a node's toolchain of a language, said at the build module's start and each time its toolchains key of the language changes (design 47 G section 492).
type ToolchainReported struct {
// the digest of the program of the provision the build module requires for that language's toolchain, never the build module's own
Digest string `json:"digest"`
Language string `json:"language"`
Node string `json:"node"`
Recipe string `json:"recipe"`
Version string `json:"version"`
}
// ToolchainUsed is the toolchain a build was made with: the language, the installed version and the build module's recipe.
type ToolchainUsed struct {
// the building node's digest of its toolchain program (kind 3's entry), never the build module's own program
Digest string `json:"digest,omitempty"`
Language string `json:"language"`
Recipe string `json:"recipe"`
Version string `json:"version"`
@@ -910,20 +1025,28 @@ const (
type StepsInput struct {
// find them anew, not from the kept answer
Again bool `json:"again,omitempty"`
// the artifact reference, <module>:<artifact>
Artifact string `json:"artifact"`
Fetch Fetch `json:"fetch"`
// the key of detected-steps, <module>.<artifact>
Artifact string `json:"artifact"`
// delete the artifact's kept key instead of detecting; needs why
Drop bool `json:"drop,omitempty"`
Fetch *Fetch `json:"fetch,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Module string `json:"module"`
// with drop, why: a non-empty text, written in the hand-act log with the caller the bus proved
Why string `json:"why,omitempty"`
}
// StepsAnswer is what steps answers.
type StepsAnswer struct {
Artifact string `json:"artifact"`
Artifact string `json:"artifact,omitempty"`
// the artifact whose key a drop deleted
Dropped string `json:"dropped,omitempty"`
// why a drop deleted nothing, naming the hand-act log when it refused
Error string `json:"error,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
// answered from the kept answer
Kept bool `json:"kept,omitempty"`
Steps []DetectedStep `json:"steps"`
Steps []DetectedStep `json:"steps,omitempty"`
}
// DeliveryAccept is the body of delivery.
@@ -944,6 +1067,15 @@ type RecheckAccept = StepAsk
// StepEndedEvent is the body of the event step-ended.
type StepEndedEvent = StepEnded
// ToolchainReportedEvent is the body of the event toolchain-reported.
type ToolchainReportedEvent = ToolchainReported
// StepProgressEvent is the body of the event step-progress.
type StepProgressEvent = StepProgress
// StepsByAskerNodeIDValue is one value of the state steps under the key <asker>.<node>.<id>.
type StepsByAskerNodeIDValue = Step
// StepsByNodeIDValue is one value of the state steps under the key <node>.<id>.
type StepsByNodeIDValue = Step
@@ -21,6 +21,10 @@ func TestFixtures(t *testing.T) {
decode: func(b []byte) (any, error) { var v StepEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-node\\.event\\.step-ended\\.[^.*> ]+$")},
{name: "an integration step that could not run, naming the part not heard", subject: "mesh.seat.node-build-node.event.step-ended.g14", given: "{\"artifact\":\"nats:program\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fingerprint\":\"fp-0123\",\"id\":\"m2kx9a1b3c4d5f\",\"module\":\"nats\",\"node\":\"g14\",\"outcome\":\"could not run\",\"output-object\":\"mesh-build-go_sent-objects/m2kx9a1b3c4d5f\",\"pipeline\":\"main.12.nats\",\"said\":\"the sandbox could not fetch novox/mesh-sdk\",\"step\":\"integration\",\"v\":1}", wire: "{\"artifact\":\"nats:program\",\"commit\":\"0123456789abcdef0123456789abcdef01234567\",\"fingerprint\":\"fp-0123\",\"id\":\"m2kx9a1b3c4d5f\",\"module\":\"nats\",\"node\":\"g14\",\"outcome\":\"could not run\",\"output-object\":\"mesh-build-go_sent-objects/m2kx9a1b3c4d5f\",\"pipeline\":\"main.12.nats\",\"said\":\"the sandbox could not fetch novox/mesh-sdk\",\"step\":\"integration\",\"v\":1}",
decode: func(b []byte) (any, error) { var v StepEndedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-node\\.event\\.step-ended\\.[^.*> ]+$")},
{name: "a node's toolchain reported", subject: "mesh.seat.node-build-node.event.toolchain-reported.ace", given: "{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"language\":\"go\",\"node\":\"ace\",\"recipe\":\"1\",\"version\":\"1.26.0\"}", wire: "{\"digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"language\":\"go\",\"node\":\"ace\",\"recipe\":\"1\",\"version\":\"1.26.0\"}",
decode: func(b []byte) (any, error) { var v ToolchainReportedEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-node\\.event\\.toolchain-reported\\.[^.*> ]+$")},
{name: "a running ask says its progress", subject: "mesh.seat.node-build-node.event.step-progress.ace", given: "{\"ask\":\"a1b2\",\"asker\":\"runner\",\"at\":\"2026-11-11T10:01:00Z\",\"phase\":\"fetch\"}", wire: "{\"ask\":\"a1b2\",\"asker\":\"runner\",\"at\":\"2026-11-11T10:01:00Z\",\"phase\":\"fetch\"}",
decode: func(b []byte) (any, error) { var v StepProgressEvent; err := json.Unmarshal(b, &v); return v, err }, subjectIs: regexp.MustCompile("^mesh\\.seat\\.node-build-node\\.event\\.step-progress\\.[^.*> ]+$")},
{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}",
@@ -61,6 +65,12 @@ func TestFixtures(t *testing.T) {
err := json.Unmarshal(b, &v)
return v, err
}, subjectIs: nil},
{name: "a step keyed under its asker", 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 StepsByAskerNodeIDValue
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},
{name: "kept-artifact asks", subject: "", given: "{\"digest\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"fp\":\"src2:1480985e\",\"module\":\"artifact-store\"}", wire: "{\"digest\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\",\"fp\":\"src2:1480985e\",\"module\":\"artifact-store\"}",
@@ -79,6 +89,12 @@ func TestFixtures(t *testing.T) {
decode: func(b []byte) (any, error) { var v StepsInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps answers lint and unit tests", subject: "", given: "{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"steps\":[{\"step\":\"lint\"},{\"packages\":[\"./modules/nats\"],\"step\":\"unit\"}]}", wire: "{\"artifact\":\"nats:program\",\"fingerprint\":\"fp-0123\",\"steps\":[{\"step\":\"lint\"},{\"packages\":[\"./modules/nats\"],\"step\":\"unit\"}]}",
decode: func(b []byte) (any, error) { var v StepsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps drops an artifact's kept steps with a reason", subject: "", given: "{\"artifact\":\"nats.program\",\"drop\":true,\"module\":\"nats\",\"why\":\"the detection was wrong\"}", wire: "{\"artifact\":\"nats.program\",\"drop\":true,\"module\":\"nats\",\"why\":\"the detection was wrong\"}",
decode: func(b []byte) (any, error) { var v StepsInput; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps answers a drop with the artifact", subject: "", given: "{\"dropped\":\"nats.program\"}", wire: "{\"dropped\":\"nats.program\"}",
decode: func(b []byte) (any, error) { var v StepsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
{name: "steps answers a drop that the hand-act log refused", subject: "", given: "{\"error\":\"the hand-act log refused: nothing was deleted\"}", wire: "{\"error\":\"the hand-act log refused: nothing was deleted\"}",
decode: func(b []byte) (any, error) { var v StepsAnswer; err := json.Unmarshal(b, &v); return v, err }, subjectIs: nil},
}
for _, c := range cases {
v, err := c.decode([]byte(c.given))
+164 -10
View File
@@ -159,10 +159,12 @@
},
{
"name": "steps",
"description": "Which pipeline steps an artifact can run, found by its language's convention (design 47 *Pipeline steps and the policy*): lint always, unit and integration tests where its packages hold them. Kept by the artifact's fingerprint; again finds them anew.",
"description": "Which pipeline steps an artifact can run, found by its language's convention (design 47 *Pipeline steps and the policy*): lint always, unit and integration tests where its packages hold them. Kept by the artifact's fingerprint; again finds them anew. With drop and why it deletes the artifact's kept key instead, writes the act in the hand-act log of the seat mesh-hand-acts first (a log that refuses deletes nothing and answers error), and answers dropped (design 47 G section 463, section 480).",
"callers": [
"seat:mesh-pipeline-runner",
"seat:mesh-catalog"
"seat:mesh-catalog",
"operator",
"sessions"
],
"input": {
"type": "object",
@@ -172,7 +174,7 @@
},
"artifact": {
"type": "string",
"description": "the artifact reference, <module>:<artifact>"
"description": "the key of detected-steps, <module>.<artifact>"
},
"fingerprint": {
"type": "string"
@@ -183,13 +185,21 @@
"again": {
"type": "boolean",
"description": "find them anew, not from the kept answer"
},
"drop": {
"type": "boolean",
"description": "delete the artifact's kept key instead of detecting; needs why"
},
"why": {
"type": "string",
"description": "with drop, why: a non-empty text, written in the hand-act log with the caller the bus proved"
}
},
"required": [
"module",
"artifact",
"fetch"
]
"artifact"
],
"description": "fetch is required unless drop is given, and why with drop"
},
"answer": {
"type": "object",
@@ -209,12 +219,17 @@
"kept": {
"type": "boolean",
"description": "answered from the kept answer"
},
"dropped": {
"type": "string",
"description": "the artifact whose key a drop deleted"
},
"error": {
"type": "string",
"description": "why a drop deleted nothing, naming the hand-act log when it refused"
}
},
"required": [
"artifact",
"steps"
]
"required": []
},
"class": "act"
}
@@ -857,6 +872,35 @@
]
},
"description": "the sibling repositories placed beside the fetched tree"
},
"builds": {
"type": "array",
"description": "a group run's package members, each by its build's digest: the build module reads each from the artifact store at artifact-store://<module>/<artifact>/blobs/<digest>, checks the bytes against the digest and lays them as the group package; a package named here is named in neither packages nor package",
"items": {
"type": "object",
"properties": {
"package": {
"type": "string",
"description": "the name in its ecosystem (the Go module path, the npm name)"
},
"module": {
"type": "string"
},
"artifact": {
"type": "string"
},
"digest": {
"type": "string",
"description": "the member's build digest, sha256:"
}
},
"required": [
"package",
"module",
"artifact",
"digest"
]
}
}
},
"required": [
@@ -875,6 +919,10 @@
},
"recipe": {
"type": "string"
},
"digest": {
"type": "string",
"description": "the building node's digest of its toolchain program (kind 3's entry), never the build module's own program"
}
},
"required": [
@@ -916,6 +964,30 @@
},
"reference": {
"type": "string"
},
"resolved": {
"type": "array",
"description": "one per mesh package the build resolved (the catalog graph entry's packages, the edges of kind 1)",
"items": {
"type": "object",
"properties": {
"package": {
"type": "string"
},
"version": {
"type": "string"
},
"digest": {
"type": "string",
"description": "sha256: of the package's bytes as the build fetched them"
}
},
"required": [
"package",
"version",
"digest"
]
}
}
},
"required": [
@@ -1628,6 +1700,66 @@
"fingerprint",
"steps"
]
},
"ToolchainReported": {
"type": "object",
"description": "a node's toolchain of a language, said at the build module's start and each time its toolchains key of the language changes (design 47 G section 492)",
"properties": {
"node": {
"type": "string"
},
"language": {
"type": "string"
},
"version": {
"type": "string"
},
"recipe": {
"type": "string"
},
"digest": {
"type": "string",
"description": "the digest of the program of the provision the build module requires for that language's toolchain, never the build module's own"
}
},
"required": [
"node",
"language",
"version",
"recipe",
"digest"
]
},
"StepProgress": {
"type": "object",
"description": "a running ask's progress, said once a minute while the holder runs it (design 47 G section 496)",
"properties": {
"asker": {
"type": "string"
},
"ask": {
"type": "string"
},
"phase": {
"type": "string",
"enum": [
"fetch",
"build",
"run",
"report"
]
},
"at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"asker",
"ask",
"phase",
"at"
]
}
},
"events": [
@@ -1641,6 +1773,28 @@
"max-age-days": 90,
"max-mib": 128
}
},
{
"name": "toolchain-reported",
"subject": "toolchain-reported.<node>",
"body": {
"$ref": "#/defs/ToolchainReported"
},
"keep": {
"max-age-days": 90,
"max-mib": 16
}
},
{
"name": "step-progress",
"subject": "step-progress.<node>",
"body": {
"$ref": "#/defs/StepProgress"
},
"keep": {
"max-age-days": 1,
"max-mib": 16
}
}
]
}
@@ -36,7 +36,21 @@
"v": 1,
"language": "go",
"version": "1.26.0",
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"digests": [
{
"digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"nodes": [
"ace",
"g14"
]
},
{
"digest": "sha256:fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210",
"nodes": [
"novox"
]
}
],
"at": "2026-11-10T10:00:00Z"
}
}
@@ -6,6 +6,14 @@
"module": "mesh-sdk"
}
},
{
"name": "reserved asks a package by its name",
"of": "verb:reserved.input",
"given": {
"name": "git.novox.be/novox/mesh-sdk/go",
"version": "0.1.15"
}
},
{
"name": "reserved answers the version and each artifact's digest",
"of": "verb:reserved.answer",
+31 -9
View File
@@ -16,22 +16,24 @@
"seat:container-image-registry",
"seat:oci-artifact-registry"
],
"description": "A module's reserved version with each artifact's digest, for a registry holder deciding whether to publish (design 47 G section 369; design 51, Publishing).",
"description": "A module's reserved version with each artifact's digest, for a registry holder deciding whether to publish; asked by the module or by a package's name in its ecosystem (design 47 G section 369, section 478; design 51, Publishing).",
"input": {
"type": "object",
"properties": {
"module": {
"type": "string",
"description": "the module"
"description": "the module; give this or name"
},
"name": {
"type": "string",
"description": "a package's or an image's name in its ecosystem (the Go module path, the npm name, the image name), in place of module; a name no entry carries, or more than one entry carries, is answered error naming it (design 47 G section 478, section 491)"
},
"version": {
"type": "string",
"description": "the version, when asked for a particular one"
}
},
"required": [
"module"
]
"required": []
},
"answer": {
"type": "object",
@@ -198,9 +200,29 @@
"type": "string",
"description": "the agreed toolchain's version"
},
"digest": {
"type": "string",
"description": "the toolchain part's digest"
"digests": {
"type": "array",
"description": "each digest the nodes reporting the agreed version report, as it stood when the version was agreed, with those nodes (design 47 G section 472, section 495)",
"items": {
"type": "object",
"properties": {
"digest": {
"type": "string",
"description": "the toolchain part's digest"
},
"nodes": {
"type": "array",
"items": {
"type": "string"
},
"description": "the nodes reporting that digest"
}
},
"required": [
"digest",
"nodes"
]
}
},
"at": {
"type": "string",
@@ -211,7 +233,7 @@
"v",
"language",
"version",
"digest"
"digests"
],
"description": "the agreed toolchain of a language changed (design 47 G section 376, ADR 0584 section 1)"
}
@@ -302,6 +302,7 @@
"module": "xclip",
"version": "1"
},
"manifest-digest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"verdict": "accepted",
"fp": "src2:b427bdde1f100e2f52490cfa9df0c5112dd3700226b4892349f0c14a15a4af56",
"inputs": {
+4
View File
@@ -1872,6 +1872,10 @@
"type": "object",
"description": "the validated manifest whole, kept as an object by the entry's digest, so no size limit applies; read by anyone but the indexer through the entry, never as a manifest (design 47 G section 443, section 442)"
},
"manifest-digest": {
"type": "string",
"description": "the sha256: digest of the manifest's bytes as the forge answered them, set by the indexer at take-in and never computed again from the manifest whole; the module's value and the manifests key are this digest (design 47 G section 506)"
},
"verdict": {
"type": "string",
"description": "its manifest's verdict: accepted, refused or held"
@@ -1483,6 +1483,11 @@
"how": {
"type": "string",
"description": "for its end, how it ended: answered, ran, released, superseded"
},
"clock-from": {
"type": "string",
"format": "date-time",
"description": "the time the bound counts from (design 47 G section 266, section 269, section 498)"
}
},
"description": "a step's wait, held and ended; a wait on a person is said as waiting on that person, never a stall (ADR 0283)",
@@ -166,5 +166,48 @@
"laptop"
]
}
},
{
"name": "a composition step's progress",
"of": "event:step-progress",
"subject": "mesh.seat.mesh-wants.event.step-progress.ace",
"given": {
"asker": "assigner",
"ask": "a1b2",
"phase": "build",
"at": "2026-11-11T10:01:00Z"
}
},
{
"name": "a put without hold waits, counted from its start",
"of": "event:wait-held",
"subject": "mesh.seat.mesh-wants.event.wait-held",
"given": {
"step": "put",
"node": "ace",
"module": "nats",
"fingerprint": "fp-0123",
"since": "2026-11-11T10:00:00Z",
"clock-from": "2026-11-11T10:00:00Z",
"bound": "30m"
}
},
{
"name": "a dependent's delivery waits for a dependency's version",
"of": "event:wait-held",
"subject": "mesh.seat.mesh-wants.event.wait-held",
"given": {
"step": "delivery",
"part": "mesh-sdk",
"version": "0.1.4",
"machines": [
"ace",
"g14"
],
"module": "nats",
"since": "2026-11-11T10:00:00Z",
"clock-from": "2026-11-11T09:58:00Z",
"bound": "60m"
}
}
]
+71 -3
View File
@@ -427,6 +427,45 @@
"parts"
]
}
},
{
"name": "step-progress",
"subject": "step-progress.<node>",
"keep": {
"max-age-days": 1,
"max-mib": 16
},
"body": {
"type": "object",
"description": "a composition step's progress, said once a minute while the assigner runs it (design 47 G section 496)",
"properties": {
"asker": {
"type": "string"
},
"ask": {
"type": "string"
},
"phase": {
"type": "string",
"enum": [
"fetch",
"build",
"run",
"report"
]
},
"at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"asker",
"ask",
"phase",
"at"
]
}
}
],
"family": {
@@ -986,7 +1025,22 @@
"description": "a step's wait the assigner holds: placing, wanting or composing until an acting part's new version runs",
"properties": {
"step": {
"type": "string"
"type": "string",
"enum": [
"take-in",
"validate",
"check",
"build",
"compose",
"grant",
"place",
"pipeline",
"put",
"delivery",
"kept-build",
"backup"
],
"description": "which wait: an acting step of section 203 or put, delivery, kept-build, backup (design 47 G section 498)"
},
"part": {
"type": "string",
@@ -1021,12 +1075,26 @@
"by": {
"type": "string",
"description": "on a release, who released it and why"
},
"clock-from": {
"type": "string",
"format": "date-time",
"description": "the time the bound counts from (design 47 G section 266, section 269, section 498)"
},
"fingerprint": {
"type": "string",
"description": "a put's wait: the fingerprint of the build put"
},
"machines": {
"type": "array",
"items": {
"type": "string"
},
"description": "a dependent's delivery wait: the machines the dependency's version has yet to reach"
}
},
"required": [
"step",
"part",
"version",
"module",
"since"
]
@@ -53,5 +53,28 @@
"said": "the sandbox could not fetch novox/mesh-sdk",
"output-object": "mesh-build-go_sent-objects/m2kx9a1b3c4d5f"
}
},
{
"name": "a node's toolchain reported",
"of": "event:toolchain-reported",
"subject": "mesh.seat.node-build-go.event.toolchain-reported.ace",
"given": {
"node": "ace",
"language": "go",
"version": "1.26.0",
"recipe": "1",
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"name": "a running ask says its progress",
"of": "event:step-progress",
"subject": "mesh.seat.node-build-go.event.step-progress.ace",
"given": {
"asker": "runner",
"ask": "a1b2",
"phase": "fetch",
"at": "2026-11-11T10:01:00Z"
}
}
]
@@ -526,5 +526,17 @@
],
"at": "2026-11-10T10:00:00Z"
}
},
{
"name": "a step keyed under its asker",
"of": "state:steps/<asker>.<node>.<id>",
"given": {
"v": 1,
"id": "a1b2",
"tool": "build",
"node": "ace",
"state": "running",
"started": "2026-11-05T10:00:00Z"
}
}
]
@@ -100,5 +100,29 @@
}
]
}
},
{
"name": "steps drops an artifact's kept steps with a reason",
"of": "verb:steps.input",
"given": {
"module": "nats",
"artifact": "nats.program",
"drop": true,
"why": "the detection was wrong"
}
},
{
"name": "steps answers a drop with the artifact",
"of": "verb:steps.answer",
"given": {
"dropped": "nats.program"
}
},
{
"name": "steps answers a drop that the hand-act log refused",
"of": "verb:steps.answer",
"given": {
"error": "the hand-act log refused: nothing was deleted"
}
}
]
+8 -1
View File
@@ -87,12 +87,19 @@
"name": "steps",
"template": "state",
"key-forms": [
{
"key": "<asker>.<node>.<id>",
"value": {
"$ref": "#/defs/Step"
},
"description": "a step on a node, keyed by the asker the bus proves (design 47 G section 467, section 481)"
},
{
"key": "<node>.<id>",
"value": {
"$ref": "#/defs/Step"
},
"description": "a step on a node"
"description": "a step whose ask came on main's subject, with no asker's name (design 47 G section 483)"
}
],
"description": "the steps this module runs",
@@ -36,5 +36,28 @@
"said": "the sandbox could not fetch novox/mesh-sdk",
"output-object": "mesh-build-go_sent-objects/m2kx9a1b3c4d5f"
}
},
{
"name": "a node's toolchain reported",
"of": "event:toolchain-reported",
"subject": "mesh.seat.node-build-image.event.toolchain-reported.ace",
"given": {
"node": "ace",
"language": "go",
"version": "1.26.0",
"recipe": "1",
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"name": "a running ask says its progress",
"of": "event:step-progress",
"subject": "mesh.seat.node-build-image.event.step-progress.ace",
"given": {
"asker": "runner",
"ask": "a1b2",
"phase": "fetch",
"at": "2026-11-11T10:01:00Z"
}
}
]
@@ -186,5 +186,17 @@
],
"at": "2026-11-10T10:00:00Z"
}
},
{
"name": "a step keyed under its asker",
"of": "state:steps/<asker>.<node>.<id>",
"given": {
"v": 1,
"id": "a1b2",
"tool": "build",
"node": "ace",
"state": "running",
"started": "2026-11-05T10:00:00Z"
}
}
]
@@ -100,5 +100,29 @@
}
]
}
},
{
"name": "steps drops an artifact's kept steps with a reason",
"of": "verb:steps.input",
"given": {
"module": "nats",
"artifact": "nats.program",
"drop": true,
"why": "the detection was wrong"
}
},
{
"name": "steps answers a drop with the artifact",
"of": "verb:steps.answer",
"given": {
"dropped": "nats.program"
}
},
{
"name": "steps answers a drop that the hand-act log refused",
"of": "verb:steps.answer",
"given": {
"error": "the hand-act log refused: nothing was deleted"
}
}
]
+8 -1
View File
@@ -65,12 +65,19 @@
"name": "steps",
"template": "state",
"key-forms": [
{
"key": "<asker>.<node>.<id>",
"value": {
"$ref": "#/defs/Step"
},
"description": "a step on a node, keyed by the asker the bus proves (design 47 G section 467, section 481)"
},
{
"key": "<node>.<id>",
"value": {
"$ref": "#/defs/Step"
},
"description": "a step on a node"
"description": "a step whose ask came on main's subject, with no asker's name (design 47 G section 483)"
}
],
"description": "the steps this module runs",
@@ -53,5 +53,28 @@
"said": "the sandbox could not fetch novox/mesh-sdk",
"output-object": "mesh-build-go_sent-objects/m2kx9a1b3c4d5f"
}
},
{
"name": "a node's toolchain reported",
"of": "event:toolchain-reported",
"subject": "mesh.seat.node-build-node.event.toolchain-reported.ace",
"given": {
"node": "ace",
"language": "go",
"version": "1.26.0",
"recipe": "1",
"digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
},
{
"name": "a running ask says its progress",
"of": "event:step-progress",
"subject": "mesh.seat.node-build-node.event.step-progress.ace",
"given": {
"asker": "runner",
"ask": "a1b2",
"phase": "fetch",
"at": "2026-11-11T10:01:00Z"
}
}
]
@@ -274,5 +274,17 @@
],
"at": "2026-11-10T10:00:00Z"
}
},
{
"name": "a step keyed under its asker",
"of": "state:steps/<asker>.<node>.<id>",
"given": {
"v": 1,
"id": "a1b2",
"tool": "build",
"node": "ace",
"state": "running",
"started": "2026-11-05T10:00:00Z"
}
}
]
@@ -100,5 +100,29 @@
}
]
}
},
{
"name": "steps drops an artifact's kept steps with a reason",
"of": "verb:steps.input",
"given": {
"module": "nats",
"artifact": "nats.program",
"drop": true,
"why": "the detection was wrong"
}
},
{
"name": "steps answers a drop with the artifact",
"of": "verb:steps.answer",
"given": {
"dropped": "nats.program"
}
},
{
"name": "steps answers a drop that the hand-act log refused",
"of": "verb:steps.answer",
"given": {
"error": "the hand-act log refused: nothing was deleted"
}
}
]
+8 -1
View File
@@ -72,12 +72,19 @@
"name": "steps",
"template": "state",
"key-forms": [
{
"key": "<asker>.<node>.<id>",
"value": {
"$ref": "#/defs/Step"
},
"description": "a step on a node, keyed by the asker the bus proves (design 47 G section 467, section 481)"
},
{
"key": "<node>.<id>",
"value": {
"$ref": "#/defs/Step"
},
"description": "a step on a node"
"description": "a step whose ask came on main's subject, with no asker's name (design 47 G section 483)"
}
],
"description": "the steps this module runs",
+13 -4
View File
@@ -55,11 +55,18 @@ export interface PackagePublished {
"version": string;
}
export interface ToolchainAgreedDigestsItem {
/** the toolchain part's digest */
"digest": string;
/** the nodes reporting that digest */
"nodes": string[];
}
/** the agreed toolchain of a language changed (design 47 G section 376, ADR 0584 section 1) */
export interface ToolchainAgreed {
"at"?: string;
/** the toolchain part's digest */
"digest": string;
/** each digest the nodes reporting the agreed version report, as it stood when the version was agreed, with those nodes (design 47 G section 472, section 495) */
"digests": ToolchainAgreedDigestsItem[];
/** the language whose agreed toolchain changed */
"language": string;
"v": number;
@@ -69,8 +76,10 @@ export interface ToolchainAgreed {
/** is what reserved takes. */
export interface ReservedInput {
/** the module */
"module": string;
/** the module; give this or name */
"module"?: string;
/** a package's or an image's name in its ecosystem (the Go module path, the npm name, the image name), in place of module; a name no entry carries, or more than one entry carries, is answered error naming it (design 47 G section 478, section 491) */
"name"?: string;
/** the version, when asked for a particular one */
"version"?: string;
}
+2
View File
@@ -247,6 +247,8 @@ export interface CatalogEntry {
"main"?: string;
/** the validated manifest whole, kept as an object by the entry's digest, so no size limit applies; read by anyone but the indexer through the entry, never as a manifest (design 47 G section 443, section 442) */
"manifest": Record<string, unknown>;
/** the sha256: digest of the manifest's bytes as the forge answered them, set by the indexer at take-in and never computed again from the manifest whole; the module's value and the manifests key are this digest (design 47 G section 506) */
"manifest-digest"?: string;
"module": string;
/** the manifest reads a node-engine must state to compose it, its templates' forms included, as the forge bridge read them at the merge (ADR 0614 §4, ADR 0632 §2) */
"needs"?: string[];
+2
View File
@@ -364,6 +364,8 @@ export interface Wait {
"artifact"?: string;
/** the watched branch, or the branch of a run on request */
"branch": string;
/** the time the bound counts from (design 47 G section 266, section 269, section 498) */
"clock-from"?: string;
/** the commit the module's entry was last changed at */
"commit"?: string;
/** the module's build-input fingerprint at the commit */
+25 -3
View File
@@ -29,6 +29,7 @@ export declare const events: {
readonly room: "room";
readonly stepEnded: "step-ended";
readonly versionDelivered: "version-delivered";
readonly stepProgress: "step-progress";
};
export declare const family: { readonly name: "mesh-wants.events"; readonly maxAgeDays: 7 };
@@ -61,6 +62,12 @@ export declare const versionDeliveredStream: "SEAT_mesh-wants_version-delivered"
export declare const versionDeliveredSubject: "mesh.seat.mesh-wants.event.version-delivered";
export declare const stepProgressStream: "SEAT_mesh-wants_step-progress";
export declare function stepProgressSubject(node: string): string;
export declare const stepProgressSubjectAll: "mesh.seat.mesh-wants.event.step-progress.*";
export declare const stepSubject: "mesh.seat.mesh-wants.accept.step";
export declare const states: {
@@ -304,18 +311,25 @@ export interface Wait {
"bound"?: string;
/** on a release, who released it and why */
"by"?: string;
/** the time the bound counts from (design 47 G section 266, section 269, section 498) */
"clock-from"?: string;
/** a put's wait: the fingerprint of the build put */
"fingerprint"?: string;
"fp"?: string;
/** on wait-ended: ran or released */
"how"?: string;
/** a dependent's delivery wait: the machines the dependency's version has yet to reach */
"machines"?: string[];
/** the entry that waits */
"module": string;
"node"?: string;
/** the acting part waited for */
"part": string;
"part"?: string;
"since": string;
"step": string;
/** which wait: an acting step of section 203 or put, delivery, kept-build, backup (design 47 G section 498) */
"step": "take-in" | "validate" | "check" | "build" | "compose" | "grant" | "place" | "pipeline" | "put" | "delivery" | "kept-build" | "backup";
/** its new version's fingerprint */
"version": string;
"version"?: string;
}
/** is what upgrade takes. */
@@ -474,6 +488,14 @@ export interface VersionDeliveredEvent {
"version": string;
}
/** is the body of the event step-progress. */
export interface StepProgressEvent {
"ask": string;
"asker": string;
"at": string;
"phase": "fetch" | "build" | "run" | "report";
}
/** is one value of the state wants under the key want.<node>.<module>. */
export interface WantsByWantNodeModuleValue {
/** again or skip */
+9
View File
@@ -29,6 +29,7 @@ export const events = {
room: "room",
stepEnded: "step-ended",
versionDelivered: "version-delivered",
stepProgress: "step-progress",
};
export const family = { name: "mesh-wants.events", maxAgeDays: 7 };
@@ -63,6 +64,14 @@ export const versionDeliveredStream = "SEAT_mesh-wants_version-delivered";
export const versionDeliveredSubject = "mesh.seat.mesh-wants.event.version-delivered";
export const stepProgressStream = "SEAT_mesh-wants_step-progress";
export function stepProgressSubject(node) {
return "mesh.seat.mesh-wants.event.step-progress." + node;
}
export const stepProgressSubjectAll = "mesh.seat.mesh-wants.event.step-progress.*";
export const stepSubject = "mesh.seat.mesh-wants.accept.step";
export const states = {
+29
View File
@@ -26,6 +26,8 @@ export declare const accepts: {
export declare const events: {
readonly stepEnded: "step-ended";
readonly toolchainReported: "toolchain-reported";
readonly stepProgress: "step-progress";
};
export declare const family: { readonly name: "node-build-go.events"; readonly maxAgeDays: 7 };
@@ -36,6 +38,18 @@ export declare function stepEndedSubject(node: string): string;
export declare const stepEndedSubjectAll: "mesh.seat.node-build-go.event.step-ended.*";
export declare const toolchainReportedStream: "SEAT_node-build-go_toolchain-reported";
export declare function toolchainReportedSubject(node: string): string;
export declare const toolchainReportedSubjectAll: "mesh.seat.node-build-go.event.toolchain-reported.*";
export declare const stepProgressStream: "SEAT_node-build-go_step-progress";
export declare function stepProgressSubject(node: string): string;
export declare const stepProgressSubjectAll: "mesh.seat.node-build-go.event.step-progress.*";
export declare const deliverySubject: "mesh.seat.node-build-go.accept.delivery";
export declare const checkSubject: "mesh.seat.node-build-go.accept.check";
@@ -55,6 +69,12 @@ export declare const states: {
readonly detectedSteps: "detected-steps";
};
/** The key of one value of the state steps under the form "<asker>.<node>.<id>". */
export declare function stepsByAskerNodeIDKey(asker: string, node: string, id: string): string;
/** The parts of a key of the form "<asker>.<node>.<id>", or undefined for a key of another form. */
export declare function parseStepsByAskerNodeIDKey(_key: string): { asker: string; node: string; id: string } | undefined;
/** The key of one value of the state steps under the form "<node>.<id>". */
export declare function stepsByNodeIDKey(node: string, id: string): string;
@@ -94,6 +114,7 @@ export declare function parseDetectedStepsByModuleArtifactKey(_key: string): { m
export type AskArtifact = buildStep.AskArtifact;
export type BuildAnswer = buildStep.BuildAnswer;
export type BuiltArtifact = buildStep.BuiltArtifact;
export type BuiltArtifactResolvedItem = buildStep.BuiltArtifactResolvedItem;
export type Capacity = buildStep.Capacity;
export type CheckAnswer = buildStep.CheckAnswer;
export type ComposeAnswer = buildStep.ComposeAnswer;
@@ -107,6 +128,7 @@ export type DropInput = buildStep.DropInput;
export type Fetch = buildStep.Fetch;
export type FetchAnswer = buildStep.FetchAnswer;
export type FetchBesideItem = buildStep.FetchBesideItem;
export type FetchBuildsItem = buildStep.FetchBuildsItem;
export type FetchPackage = buildStep.FetchPackage;
export type FetchRefusal = buildStep.FetchRefusal;
export type KeptArtifactAnswer = buildStep.KeptArtifactAnswer;
@@ -139,6 +161,8 @@ export type StepAskCompose = buildStep.StepAskCompose;
export type StepEnded = buildStep.StepEnded;
export type StepEndedEvent = buildStep.StepEndedEvent;
export type StepNames = buildStep.StepNames;
export type StepProgress = buildStep.StepProgress;
export type StepProgressEvent = buildStep.StepProgressEvent;
export type StepResult = buildStep.StepResult;
export type StepRun = buildStep.StepRun;
export type StepsAnswer = buildStep.StepsAnswer;
@@ -146,6 +170,8 @@ export type StepsInput = buildStep.StepsInput;
export type TakenVersion = buildStep.TakenVersion;
export type TestCounts = buildStep.TestCounts;
export type Toolchain = buildStep.Toolchain;
export type ToolchainReported = buildStep.ToolchainReported;
export type ToolchainReportedEvent = buildStep.ToolchainReportedEvent;
export type ToolchainUsed = buildStep.ToolchainUsed;
/** is the body of delivery. */
@@ -166,6 +192,9 @@ export type RecheckAccept = StepAsk;
/** is the body of check.run. */
export type CheckRunAccept = StepAsk;
/** is one value of the state steps under the key <asker>.<node>.<id>. */
export type StepsByAskerNodeIDValue = Step;
/** is one value of the state steps under the key <node>.<id>. */
export type StepsByNodeIDValue = Step;
+41
View File
@@ -24,6 +24,8 @@ export const accepts = {
export const events = {
stepEnded: "step-ended",
toolchainReported: "toolchain-reported",
stepProgress: "step-progress",
};
export const family = { name: "node-build-go.events", maxAgeDays: 7 };
@@ -36,6 +38,22 @@ export function stepEndedSubject(node) {
export const stepEndedSubjectAll = "mesh.seat.node-build-go.event.step-ended.*";
export const toolchainReportedStream = "SEAT_node-build-go_toolchain-reported";
export function toolchainReportedSubject(node) {
return "mesh.seat.node-build-go.event.toolchain-reported." + node;
}
export const toolchainReportedSubjectAll = "mesh.seat.node-build-go.event.toolchain-reported.*";
export const stepProgressStream = "SEAT_node-build-go_step-progress";
export function stepProgressSubject(node) {
return "mesh.seat.node-build-go.event.step-progress." + node;
}
export const stepProgressSubjectAll = "mesh.seat.node-build-go.event.step-progress.*";
export const deliverySubject = "mesh.seat.node-build-go.accept.delivery";
export const checkSubject = "mesh.seat.node-build-go.accept.check";
@@ -55,6 +73,29 @@ export const states = {
detectedSteps: "detected-steps",
};
/** The key of one value of the state steps under the form "<asker>.<node>.<id>". */
export function stepsByAskerNodeIDKey(asker, node, id) {
return asker + "." + node + "." + id;
}
/** The parts of a key of the form "<asker>.<node>.<id>", or undefined for a key of another form. */
export function parseStepsByAskerNodeIDKey(_key) {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const asker = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
const _at2 = _rest.indexOf(".");
if (_at2 <= 0) return undefined;
const node = _rest.slice(0, _at2);
_rest = _rest.slice(_at2 + 1);
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { asker, node, id };
}
/** The key of one value of the state steps under the form "<node>.<id>". */
export function stepsByNodeIDKey(node, id) {
return node + "." + id;
+60 -4
View File
@@ -13,6 +13,8 @@ export declare const verbs: {
export declare const events: {
readonly stepEnded: "step-ended";
readonly toolchainReported: "toolchain-reported";
readonly stepProgress: "step-progress";
};
/** one artifact of a build ask: built from the fetched tree, or copied from an upstream pin */
@@ -32,11 +34,20 @@ export interface BuildAnswer {
"toolchain"?: ToolchainUsed;
}
export interface BuiltArtifactResolvedItem {
/** sha256: of the package's bytes as the build fetched them */
"digest": string;
"package": string;
"version": string;
}
/** one artifact a build made: its digest and the reference it was published under */
export interface BuiltArtifact {
"artifact": string;
"digest": string;
"reference"?: string;
/** one per mesh package the build resolved (the catalog graph entry's packages, the edges of kind 1) */
"resolved"?: BuiltArtifactResolvedItem[];
}
/** how many steps the node runs at once */
@@ -100,6 +111,15 @@ export interface FetchBesideItem {
"repository": string;
}
export interface FetchBuildsItem {
"artifact": string;
/** the member's build digest, sha256: */
"digest": string;
"module": string;
/** the name in its ecosystem (the Go module path, the npm name) */
"package": string;
}
/** the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's */
export interface FetchPackage {
"commit"?: string;
@@ -115,6 +135,8 @@ export interface Fetch {
"base"?: string;
/** the sibling repositories placed beside the fetched tree */
"beside"?: FetchBesideItem[];
/** a group run's package members, each by its build's digest: the build module reads each from the artifact store at artifact-store://<module>/<artifact>/blobs/<digest>, checks the bytes against the digest and lays them as the group package; a package named here is named in neither packages nor package */
"builds"?: FetchBuildsItem[];
/** the full commit */
"commit": string;
/** the module's directory in the repository ("." for its root), with paths */
@@ -416,6 +438,14 @@ export interface StepNames {
"repositories": string[];
}
/** a running ask's progress, said once a minute while the holder runs it (design 47 G section 496) */
export interface StepProgress {
"ask": string;
"asker": string;
"at": string;
"phase": "fetch" | "build" | "run" | "report";
}
/** what a step answered, by the kind of ask: a build's artifacts, toolchain, imports, out-of-step modules and the artifact it kept; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five */
export interface StepResult {
"artifacts"?: BuiltArtifact[];
@@ -502,8 +532,20 @@ export interface Toolchain {
"version": string;
}
/** a node's toolchain of a language, said at the build module's start and each time its toolchains key of the language changes (design 47 G section 492) */
export interface ToolchainReported {
/** the digest of the program of the provision the build module requires for that language's toolchain, never the build module's own */
"digest": string;
"language": string;
"node": string;
"recipe": string;
"version": string;
}
/** the toolchain a build was made with: the language, the installed version and the build module's recipe */
export interface ToolchainUsed {
/** the building node's digest of its toolchain program (kind 3's entry), never the build module's own program */
"digest"?: string;
"language": string;
"recipe": string;
"version": string;
@@ -579,22 +621,36 @@ export interface KeptArtifactAnswer {
export interface StepsInput {
/** find them anew, not from the kept answer */
"again"?: boolean;
/** the artifact reference, <module>:<artifact> */
/** the key of detected-steps, <module>.<artifact> */
"artifact": string;
"fetch": Fetch;
/** delete the artifact's kept key instead of detecting; needs why */
"drop"?: boolean;
"fetch"?: Fetch;
"fingerprint"?: string;
"module": string;
/** with drop, why: a non-empty text, written in the hand-act log with the caller the bus proved */
"why"?: string;
}
/** is what steps answers. */
export interface StepsAnswer {
"artifact": string;
"artifact"?: string;
/** the artifact whose key a drop deleted */
"dropped"?: string;
/** why a drop deleted nothing, naming the hand-act log when it refused */
"error"?: string;
"fingerprint"?: string;
/** answered from the kept answer */
"kept"?: boolean;
"steps": DetectedStep[];
"steps"?: DetectedStep[];
}
/** is the body of the event step-ended. */
export type StepEndedEvent = StepEnded;
/** is the body of the event toolchain-reported. */
export type ToolchainReportedEvent = ToolchainReported;
/** is the body of the event step-progress. */
export type StepProgressEvent = StepProgress;
@@ -13,6 +13,8 @@ export const verbs = {
export const events = {
stepEnded: "step-ended",
toolchainReported: "toolchain-reported",
stepProgress: "step-progress",
};
export {};
+29
View File
@@ -25,6 +25,8 @@ export declare const accepts: {
export declare const events: {
readonly stepEnded: "step-ended";
readonly toolchainReported: "toolchain-reported";
readonly stepProgress: "step-progress";
};
export declare const family: { readonly name: "node-build-image.events"; readonly maxAgeDays: 7 };
@@ -35,6 +37,18 @@ export declare function stepEndedSubject(node: string): string;
export declare const stepEndedSubjectAll: "mesh.seat.node-build-image.event.step-ended.*";
export declare const toolchainReportedStream: "SEAT_node-build-image_toolchain-reported";
export declare function toolchainReportedSubject(node: string): string;
export declare const toolchainReportedSubjectAll: "mesh.seat.node-build-image.event.toolchain-reported.*";
export declare const stepProgressStream: "SEAT_node-build-image_step-progress";
export declare function stepProgressSubject(node: string): string;
export declare const stepProgressSubjectAll: "mesh.seat.node-build-image.event.step-progress.*";
export declare const deliverySubject: "mesh.seat.node-build-image.accept.delivery";
export declare const checkSubject: "mesh.seat.node-build-image.accept.check";
@@ -52,6 +66,12 @@ export declare const states: {
readonly detectedSteps: "detected-steps";
};
/** The key of one value of the state steps under the form "<asker>.<node>.<id>". */
export declare function stepsByAskerNodeIDKey(asker: string, node: string, id: string): string;
/** The parts of a key of the form "<asker>.<node>.<id>", or undefined for a key of another form. */
export declare function parseStepsByAskerNodeIDKey(_key: string): { asker: string; node: string; id: string } | undefined;
/** The key of one value of the state steps under the form "<node>.<id>". */
export declare function stepsByNodeIDKey(node: string, id: string): string;
@@ -91,6 +111,7 @@ export declare function parseDetectedStepsByModuleArtifactKey(_key: string): { m
export type AskArtifact = buildStep.AskArtifact;
export type BuildAnswer = buildStep.BuildAnswer;
export type BuiltArtifact = buildStep.BuiltArtifact;
export type BuiltArtifactResolvedItem = buildStep.BuiltArtifactResolvedItem;
export type Capacity = buildStep.Capacity;
export type CheckAnswer = buildStep.CheckAnswer;
export type ComposeAnswer = buildStep.ComposeAnswer;
@@ -104,6 +125,7 @@ export type DropInput = buildStep.DropInput;
export type Fetch = buildStep.Fetch;
export type FetchAnswer = buildStep.FetchAnswer;
export type FetchBesideItem = buildStep.FetchBesideItem;
export type FetchBuildsItem = buildStep.FetchBuildsItem;
export type FetchPackage = buildStep.FetchPackage;
export type FetchRefusal = buildStep.FetchRefusal;
export type KeptArtifactAnswer = buildStep.KeptArtifactAnswer;
@@ -136,6 +158,8 @@ export type StepAskCompose = buildStep.StepAskCompose;
export type StepEnded = buildStep.StepEnded;
export type StepEndedEvent = buildStep.StepEndedEvent;
export type StepNames = buildStep.StepNames;
export type StepProgress = buildStep.StepProgress;
export type StepProgressEvent = buildStep.StepProgressEvent;
export type StepResult = buildStep.StepResult;
export type StepRun = buildStep.StepRun;
export type StepsAnswer = buildStep.StepsAnswer;
@@ -143,6 +167,8 @@ export type StepsInput = buildStep.StepsInput;
export type TakenVersion = buildStep.TakenVersion;
export type TestCounts = buildStep.TestCounts;
export type Toolchain = buildStep.Toolchain;
export type ToolchainReported = buildStep.ToolchainReported;
export type ToolchainReportedEvent = buildStep.ToolchainReportedEvent;
export type ToolchainUsed = buildStep.ToolchainUsed;
/** is the body of delivery. */
@@ -160,6 +186,9 @@ export type DeliveryStepAccept = StepAsk;
/** is the body of recheck. */
export type RecheckAccept = StepAsk;
/** is one value of the state steps under the key <asker>.<node>.<id>. */
export type StepsByAskerNodeIDValue = Step;
/** is one value of the state steps under the key <node>.<id>. */
export type StepsByNodeIDValue = Step;
@@ -23,6 +23,8 @@ export const accepts = {
export const events = {
stepEnded: "step-ended",
toolchainReported: "toolchain-reported",
stepProgress: "step-progress",
};
export const family = { name: "node-build-image.events", maxAgeDays: 7 };
@@ -35,6 +37,22 @@ export function stepEndedSubject(node) {
export const stepEndedSubjectAll = "mesh.seat.node-build-image.event.step-ended.*";
export const toolchainReportedStream = "SEAT_node-build-image_toolchain-reported";
export function toolchainReportedSubject(node) {
return "mesh.seat.node-build-image.event.toolchain-reported." + node;
}
export const toolchainReportedSubjectAll = "mesh.seat.node-build-image.event.toolchain-reported.*";
export const stepProgressStream = "SEAT_node-build-image_step-progress";
export function stepProgressSubject(node) {
return "mesh.seat.node-build-image.event.step-progress." + node;
}
export const stepProgressSubjectAll = "mesh.seat.node-build-image.event.step-progress.*";
export const deliverySubject = "mesh.seat.node-build-image.accept.delivery";
export const checkSubject = "mesh.seat.node-build-image.accept.check";
@@ -52,6 +70,29 @@ export const states = {
detectedSteps: "detected-steps",
};
/** The key of one value of the state steps under the form "<asker>.<node>.<id>". */
export function stepsByAskerNodeIDKey(asker, node, id) {
return asker + "." + node + "." + id;
}
/** The parts of a key of the form "<asker>.<node>.<id>", or undefined for a key of another form. */
export function parseStepsByAskerNodeIDKey(_key) {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const asker = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
const _at2 = _rest.indexOf(".");
if (_at2 <= 0) return undefined;
const node = _rest.slice(0, _at2);
_rest = _rest.slice(_at2 + 1);
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { asker, node, id };
}
/** The key of one value of the state steps under the form "<node>.<id>". */
export function stepsByNodeIDKey(node, id) {
return node + "." + id;
+60 -4
View File
@@ -13,6 +13,8 @@ export declare const verbs: {
export declare const events: {
readonly stepEnded: "step-ended";
readonly toolchainReported: "toolchain-reported";
readonly stepProgress: "step-progress";
};
/** one artifact of a build ask: built from the fetched tree, or copied from an upstream pin */
@@ -32,11 +34,20 @@ export interface BuildAnswer {
"toolchain"?: ToolchainUsed;
}
export interface BuiltArtifactResolvedItem {
/** sha256: of the package's bytes as the build fetched them */
"digest": string;
"package": string;
"version": string;
}
/** one artifact a build made: its digest and the reference it was published under */
export interface BuiltArtifact {
"artifact": string;
"digest": string;
"reference"?: string;
/** one per mesh package the build resolved (the catalog graph entry's packages, the edges of kind 1) */
"resolved"?: BuiltArtifactResolvedItem[];
}
/** how many steps the node runs at once */
@@ -100,6 +111,15 @@ export interface FetchBesideItem {
"repository": string;
}
export interface FetchBuildsItem {
"artifact": string;
/** the member's build digest, sha256: */
"digest": string;
"module": string;
/** the name in its ecosystem (the Go module path, the npm name) */
"package": string;
}
/** the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's */
export interface FetchPackage {
"commit"?: string;
@@ -115,6 +135,8 @@ export interface Fetch {
"base"?: string;
/** the sibling repositories placed beside the fetched tree */
"beside"?: FetchBesideItem[];
/** a group run's package members, each by its build's digest: the build module reads each from the artifact store at artifact-store://<module>/<artifact>/blobs/<digest>, checks the bytes against the digest and lays them as the group package; a package named here is named in neither packages nor package */
"builds"?: FetchBuildsItem[];
/** the full commit */
"commit": string;
/** the module's directory in the repository ("." for its root), with paths */
@@ -416,6 +438,14 @@ export interface StepNames {
"repositories": string[];
}
/** a running ask's progress, said once a minute while the holder runs it (design 47 G section 496) */
export interface StepProgress {
"ask": string;
"asker": string;
"at": string;
"phase": "fetch" | "build" | "run" | "report";
}
/** what a step answered, by the kind of ask: a build's artifacts, toolchain, imports, out-of-step modules and the artifact it kept; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five */
export interface StepResult {
"artifacts"?: BuiltArtifact[];
@@ -502,8 +532,20 @@ export interface Toolchain {
"version": string;
}
/** a node's toolchain of a language, said at the build module's start and each time its toolchains key of the language changes (design 47 G section 492) */
export interface ToolchainReported {
/** the digest of the program of the provision the build module requires for that language's toolchain, never the build module's own */
"digest": string;
"language": string;
"node": string;
"recipe": string;
"version": string;
}
/** the toolchain a build was made with: the language, the installed version and the build module's recipe */
export interface ToolchainUsed {
/** the building node's digest of its toolchain program (kind 3's entry), never the build module's own program */
"digest"?: string;
"language": string;
"recipe": string;
"version": string;
@@ -579,22 +621,36 @@ export interface KeptArtifactAnswer {
export interface StepsInput {
/** find them anew, not from the kept answer */
"again"?: boolean;
/** the artifact reference, <module>:<artifact> */
/** the key of detected-steps, <module>.<artifact> */
"artifact": string;
"fetch": Fetch;
/** delete the artifact's kept key instead of detecting; needs why */
"drop"?: boolean;
"fetch"?: Fetch;
"fingerprint"?: string;
"module": string;
/** with drop, why: a non-empty text, written in the hand-act log with the caller the bus proved */
"why"?: string;
}
/** is what steps answers. */
export interface StepsAnswer {
"artifact": string;
"artifact"?: string;
/** the artifact whose key a drop deleted */
"dropped"?: string;
/** why a drop deleted nothing, naming the hand-act log when it refused */
"error"?: string;
"fingerprint"?: string;
/** answered from the kept answer */
"kept"?: boolean;
"steps": DetectedStep[];
"steps"?: DetectedStep[];
}
/** is the body of the event step-ended. */
export type StepEndedEvent = StepEnded;
/** is the body of the event toolchain-reported. */
export type ToolchainReportedEvent = ToolchainReported;
/** is the body of the event step-progress. */
export type StepProgressEvent = StepProgress;
@@ -13,6 +13,8 @@ export const verbs = {
export const events = {
stepEnded: "step-ended",
toolchainReported: "toolchain-reported",
stepProgress: "step-progress",
};
export {};
+29
View File
@@ -25,6 +25,8 @@ export declare const accepts: {
export declare const events: {
readonly stepEnded: "step-ended";
readonly toolchainReported: "toolchain-reported";
readonly stepProgress: "step-progress";
};
export declare const family: { readonly name: "node-build-node.events"; readonly maxAgeDays: 7 };
@@ -35,6 +37,18 @@ export declare function stepEndedSubject(node: string): string;
export declare const stepEndedSubjectAll: "mesh.seat.node-build-node.event.step-ended.*";
export declare const toolchainReportedStream: "SEAT_node-build-node_toolchain-reported";
export declare function toolchainReportedSubject(node: string): string;
export declare const toolchainReportedSubjectAll: "mesh.seat.node-build-node.event.toolchain-reported.*";
export declare const stepProgressStream: "SEAT_node-build-node_step-progress";
export declare function stepProgressSubject(node: string): string;
export declare const stepProgressSubjectAll: "mesh.seat.node-build-node.event.step-progress.*";
export declare const deliverySubject: "mesh.seat.node-build-node.accept.delivery";
export declare const checkSubject: "mesh.seat.node-build-node.accept.check";
@@ -52,6 +66,12 @@ export declare const states: {
readonly detectedSteps: "detected-steps";
};
/** The key of one value of the state steps under the form "<asker>.<node>.<id>". */
export declare function stepsByAskerNodeIDKey(asker: string, node: string, id: string): string;
/** The parts of a key of the form "<asker>.<node>.<id>", or undefined for a key of another form. */
export declare function parseStepsByAskerNodeIDKey(_key: string): { asker: string; node: string; id: string } | undefined;
/** The key of one value of the state steps under the form "<node>.<id>". */
export declare function stepsByNodeIDKey(node: string, id: string): string;
@@ -91,6 +111,7 @@ export declare function parseDetectedStepsByModuleArtifactKey(_key: string): { m
export type AskArtifact = buildStep.AskArtifact;
export type BuildAnswer = buildStep.BuildAnswer;
export type BuiltArtifact = buildStep.BuiltArtifact;
export type BuiltArtifactResolvedItem = buildStep.BuiltArtifactResolvedItem;
export type Capacity = buildStep.Capacity;
export type CheckAnswer = buildStep.CheckAnswer;
export type ComposeAnswer = buildStep.ComposeAnswer;
@@ -104,6 +125,7 @@ export type DropInput = buildStep.DropInput;
export type Fetch = buildStep.Fetch;
export type FetchAnswer = buildStep.FetchAnswer;
export type FetchBesideItem = buildStep.FetchBesideItem;
export type FetchBuildsItem = buildStep.FetchBuildsItem;
export type FetchPackage = buildStep.FetchPackage;
export type FetchRefusal = buildStep.FetchRefusal;
export type KeptArtifactAnswer = buildStep.KeptArtifactAnswer;
@@ -136,6 +158,8 @@ export type StepAskCompose = buildStep.StepAskCompose;
export type StepEnded = buildStep.StepEnded;
export type StepEndedEvent = buildStep.StepEndedEvent;
export type StepNames = buildStep.StepNames;
export type StepProgress = buildStep.StepProgress;
export type StepProgressEvent = buildStep.StepProgressEvent;
export type StepResult = buildStep.StepResult;
export type StepRun = buildStep.StepRun;
export type StepsAnswer = buildStep.StepsAnswer;
@@ -143,6 +167,8 @@ export type StepsInput = buildStep.StepsInput;
export type TakenVersion = buildStep.TakenVersion;
export type TestCounts = buildStep.TestCounts;
export type Toolchain = buildStep.Toolchain;
export type ToolchainReported = buildStep.ToolchainReported;
export type ToolchainReportedEvent = buildStep.ToolchainReportedEvent;
export type ToolchainUsed = buildStep.ToolchainUsed;
/** is the body of delivery. */
@@ -160,6 +186,9 @@ export type DeliveryStepAccept = StepAsk;
/** is the body of recheck. */
export type RecheckAccept = StepAsk;
/** is one value of the state steps under the key <asker>.<node>.<id>. */
export type StepsByAskerNodeIDValue = Step;
/** is one value of the state steps under the key <node>.<id>. */
export type StepsByNodeIDValue = Step;
+41
View File
@@ -23,6 +23,8 @@ export const accepts = {
export const events = {
stepEnded: "step-ended",
toolchainReported: "toolchain-reported",
stepProgress: "step-progress",
};
export const family = { name: "node-build-node.events", maxAgeDays: 7 };
@@ -35,6 +37,22 @@ export function stepEndedSubject(node) {
export const stepEndedSubjectAll = "mesh.seat.node-build-node.event.step-ended.*";
export const toolchainReportedStream = "SEAT_node-build-node_toolchain-reported";
export function toolchainReportedSubject(node) {
return "mesh.seat.node-build-node.event.toolchain-reported." + node;
}
export const toolchainReportedSubjectAll = "mesh.seat.node-build-node.event.toolchain-reported.*";
export const stepProgressStream = "SEAT_node-build-node_step-progress";
export function stepProgressSubject(node) {
return "mesh.seat.node-build-node.event.step-progress." + node;
}
export const stepProgressSubjectAll = "mesh.seat.node-build-node.event.step-progress.*";
export const deliverySubject = "mesh.seat.node-build-node.accept.delivery";
export const checkSubject = "mesh.seat.node-build-node.accept.check";
@@ -52,6 +70,29 @@ export const states = {
detectedSteps: "detected-steps",
};
/** The key of one value of the state steps under the form "<asker>.<node>.<id>". */
export function stepsByAskerNodeIDKey(asker, node, id) {
return asker + "." + node + "." + id;
}
/** The parts of a key of the form "<asker>.<node>.<id>", or undefined for a key of another form. */
export function parseStepsByAskerNodeIDKey(_key) {
let _rest = _key;
const _at0 = _rest.indexOf(".");
if (_at0 <= 0) return undefined;
const asker = _rest.slice(0, _at0);
_rest = _rest.slice(_at0 + 1);
const _at2 = _rest.indexOf(".");
if (_at2 <= 0) return undefined;
const node = _rest.slice(0, _at2);
_rest = _rest.slice(_at2 + 1);
if (_rest === "") return undefined;
const id = _rest;
_rest = "";
if (_rest !== "") return undefined;
return { asker, node, id };
}
/** The key of one value of the state steps under the form "<node>.<id>". */
export function stepsByNodeIDKey(node, id) {
return node + "." + id;
+60 -4
View File
@@ -13,6 +13,8 @@ export declare const verbs: {
export declare const events: {
readonly stepEnded: "step-ended";
readonly toolchainReported: "toolchain-reported";
readonly stepProgress: "step-progress";
};
/** one artifact of a build ask: built from the fetched tree, or copied from an upstream pin */
@@ -32,11 +34,20 @@ export interface BuildAnswer {
"toolchain"?: ToolchainUsed;
}
export interface BuiltArtifactResolvedItem {
/** sha256: of the package's bytes as the build fetched them */
"digest": string;
"package": string;
"version": string;
}
/** one artifact a build made: its digest and the reference it was published under */
export interface BuiltArtifact {
"artifact": string;
"digest": string;
"reference"?: string;
/** one per mesh package the build resolved (the catalog graph entry's packages, the edges of kind 1) */
"resolved"?: BuiltArtifactResolvedItem[];
}
/** how many steps the node runs at once */
@@ -100,6 +111,15 @@ export interface FetchBesideItem {
"repository": string;
}
export interface FetchBuildsItem {
"artifact": string;
/** the member's build digest, sha256: */
"digest": string;
"module": string;
/** the name in its ecosystem (the Go module path, the npm name) */
"package": string;
}
/** the TypeScript module's group package: the group's tagged version of the SDK ({name, version}), placed from the registry in each member whatever its lock names, or the group's package member ({repository, commit, ref}), packed by the fetch and served to the members in place of the registry's */
export interface FetchPackage {
"commit"?: string;
@@ -115,6 +135,8 @@ export interface Fetch {
"base"?: string;
/** the sibling repositories placed beside the fetched tree */
"beside"?: FetchBesideItem[];
/** a group run's package members, each by its build's digest: the build module reads each from the artifact store at artifact-store://<module>/<artifact>/blobs/<digest>, checks the bytes against the digest and lays them as the group package; a package named here is named in neither packages nor package */
"builds"?: FetchBuildsItem[];
/** the full commit */
"commit": string;
/** the module's directory in the repository ("." for its root), with paths */
@@ -416,6 +438,14 @@ export interface StepNames {
"repositories": string[];
}
/** a running ask's progress, said once a minute while the holder runs it (design 47 G section 496) */
export interface StepProgress {
"ask": string;
"asker": string;
"at": string;
"phase": "fetch" | "build" | "run" | "report";
}
/** what a step answered, by the kind of ask: a build's artifacts, toolchain, imports, out-of-step modules and the artifact it kept; a check's fetch and levels; a compose's built, key and verdict; a run's built, key, exit, output and said; a declined ask's declined, needs and lacks. The fields of the kinds do not meet, so one layout holds the five */
export interface StepResult {
"artifacts"?: BuiltArtifact[];
@@ -502,8 +532,20 @@ export interface Toolchain {
"version": string;
}
/** a node's toolchain of a language, said at the build module's start and each time its toolchains key of the language changes (design 47 G section 492) */
export interface ToolchainReported {
/** the digest of the program of the provision the build module requires for that language's toolchain, never the build module's own */
"digest": string;
"language": string;
"node": string;
"recipe": string;
"version": string;
}
/** the toolchain a build was made with: the language, the installed version and the build module's recipe */
export interface ToolchainUsed {
/** the building node's digest of its toolchain program (kind 3's entry), never the build module's own program */
"digest"?: string;
"language": string;
"recipe": string;
"version": string;
@@ -579,22 +621,36 @@ export interface KeptArtifactAnswer {
export interface StepsInput {
/** find them anew, not from the kept answer */
"again"?: boolean;
/** the artifact reference, <module>:<artifact> */
/** the key of detected-steps, <module>.<artifact> */
"artifact": string;
"fetch": Fetch;
/** delete the artifact's kept key instead of detecting; needs why */
"drop"?: boolean;
"fetch"?: Fetch;
"fingerprint"?: string;
"module": string;
/** with drop, why: a non-empty text, written in the hand-act log with the caller the bus proved */
"why"?: string;
}
/** is what steps answers. */
export interface StepsAnswer {
"artifact": string;
"artifact"?: string;
/** the artifact whose key a drop deleted */
"dropped"?: string;
/** why a drop deleted nothing, naming the hand-act log when it refused */
"error"?: string;
"fingerprint"?: string;
/** answered from the kept answer */
"kept"?: boolean;
"steps": DetectedStep[];
"steps"?: DetectedStep[];
}
/** is the body of the event step-ended. */
export type StepEndedEvent = StepEnded;
/** is the body of the event toolchain-reported. */
export type ToolchainReportedEvent = ToolchainReported;
/** is the body of the event step-progress. */
export type StepProgressEvent = StepProgress;
@@ -13,6 +13,8 @@ export const verbs = {
export const events = {
stepEnded: "step-ended",
toolchainReported: "toolchain-reported",
stepProgress: "step-progress",
};
export {};
@@ -0,0 +1,147 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"slices"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
mcc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// halves follows the catalog half of each watched branch through the seat mesh-catalog (design 47 G §14, §184, §195): one
// meshcatalogclient.Catalog per branch, a first view of the verb `revision` and then each `catalog-revision` of that branch,
// each entry 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
wake func()
logf func(string, ...any)
ctx context.Context
mu sync.Mutex
cats map[string]*mcc.Catalog
// started is called with each catalog made, for the part that reads their status.
started func(*mcc.Catalog)
// settled is told once when a branch's first view is answered, for the first pass to wait on (the trunk's alone).
settled chan<- struct{}
}
// follow makes the catalog of branch and asks its first view, once; true when it was made now.
func (h *halves) follow(branch string) bool {
h.mu.Lock()
if h.cats[branch] != nil {
h.mu.Unlock()
return false
}
c, err := mcc.NewCatalog(h.call, branch)
if err != nil {
h.mu.Unlock()
h.logf("the catalog half of branch %q is not followed: %v", branch, err)
return false
}
c.Follower.Logf = h.logf
c.Follower.Changed = func(module string, entry json.RawMessage, deleted bool, _ time.Time) {
h.changed(branch, module, entry, deleted)
}
if h.cats == nil {
h.cats = map[string]*mcc.Catalog{}
}
h.cats[branch] = c
h.mu.Unlock()
if h.started != nil {
h.started(c)
}
go func() {
if err := c.Start(h.ctx); err != nil {
if h.ctx.Err() == nil {
h.logf("the first view of the catalog half of branch %q is not answered: %v", branch, err)
}
return
}
if branch == trunkBranch && h.settled != nil {
h.settled <- struct{}{}
}
}()
return true
}
// hear gives a catalog-revision to every catalog: each takes its own branch's and passes over another's.
func (h *halves) hear(m jetstream.Msg) {
h.mu.Lock()
cats := make([]*mcc.Catalog, 0, len(h.cats))
for _, c := range h.cats {
cats = append(cats, c)
}
h.mu.Unlock()
for _, c := range cats {
if err := c.Hear(h.ctx, m.Data()); err != nil {
h.logf("%s on branch %q: %v", m.Subject(), c.Branch, err)
}
}
_ = m.Ack()
}
// followers are the first views the catalogs hold.
func (h *halves) followers() []*mcc.Catalog {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]*mcc.Catalog, 0, len(h.cats))
for _, c := range h.cats {
out = append(out, c)
}
return out
}
// 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)
}
// 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)
return v, n, &parsed{module: e.Module, manifest: man}
}
@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/keys"
"io"
"maps"
"mesh-delivery-assigner/internal/assignment"
@@ -52,7 +53,6 @@ import (
"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/clients/meshconditionsclient"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
@@ -71,6 +71,17 @@ type bed struct {
placed map[string][]string
// journal is what the programs the bed starts write in their journal.
journal *journal
// cat is the catalog half the seat mesh-catalog serves the assigner (serveCatalog).
cat *catalogBed
}
// catalogBed is the catalog half of each watched branch as the seat mesh-catalog keeps it: entries by branch and module, a
// revision per branch, and the manifest bodies by digest.
type catalogBed struct {
mu sync.Mutex
entries map[string]map[string]meshcatalog.CatalogEntry
rev map[string]int64
bodies map[string]string
}
// journal is a program's journal as the bed hears it: a test awaits a line in it (await) where the line is the only sign
@@ -144,7 +155,8 @@ func startBed(t *testing.T, leaveOut string) *bed {
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
t.Cleanup(cancel)
b := &bed{srv: srv, js: js, ctx: ctx, dir: dir, buckets: map[string]jetstream.KeyValue{}, journal: &journal{grew: make(chan struct{})}}
b := &bed{srv: srv, js: js, ctx: ctx, dir: dir, buckets: map[string]jetstream.KeyValue{}, journal: &journal{grew: make(chan struct{})},
cat: &catalogBed{entries: map[string]map[string]meshcatalog.CatalogEntry{}, rev: map[string]int64{}, bodies: map[string]string{}}}
for _, name := range append(append([]string{}, readBuckets...), ownBuckets...) {
if name == leaveOut {
continue
@@ -171,7 +183,7 @@ func startBed(t *testing.T, leaveOut string) *bed {
// event names the engine's machine last, so the filter is the event with every machine (mesh-grants buscompose
// machineEvent; the grants part's own test holds the derivation).
if _, err := js.CreateOrUpdateConsumer(ctx, "EVENTS", jetstream.ConsumerConfig{Durable: "n1_mesh-delivery-assigner",
FilterSubjects: []string{enginereads.SubjectPrefix + "*", "mesh.mod." + buckets.IndexerModule + ".event.views.>"}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
FilterSubjects: []string{enginereads.SubjectPrefix + "*", "mesh.mod." + buckets.IndexerModule + ".event.views.>", meshcatalog.SubjectCatalogRevisionAll}, AckPolicy: jetstream.AckExplicitPolicy}); err != nil {
t.Fatal(err)
}
// The operator-channel seat's stream of conditions, which the assigner says its conditions on.
@@ -180,6 +192,8 @@ func startBed(t *testing.T, leaveOut string) *bed {
}
// The indexer as the owner of its manifests and modules (DLV-F2r), with the real owner's side.
serveIndexer(t, b, nc)
// The seat mesh-catalog's catalog half (design 47 G §194), which the assigner follows per branch.
serveCatalog(t, b, nc)
// The assigner's views family is its ordinary events on EVENTS (ADR 0635 §5); its marks are its state.
if kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: marksBucket, History: 1}); err != nil {
t.Fatal(err)
@@ -422,6 +436,7 @@ func (b *bed) manifest(t *testing.T, module, body string) string {
t.Fatal(err)
}
b.putJSON(t, buckets.Manifests, key, value)
b.cat.bodies[digest] = body
return digest
}
@@ -447,11 +462,11 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
// DLV-F5r (ADR 0621 §3): the indexer's runtime starts together with the assigner and answers its first views only
// after the assigner asked; within the bound the assigner writes the wants from what they answer.
up := make(chan struct{})
indexerLate = up
catalogLate = up
// ADR 0631 §2: while the indexer does not answer, past the first-view bound the assigner raises the condition naming
// the indexer and graph, and reads nothing of the indexer's state; the answer clears it.
firstViewBound, firstViewWait = 300*time.Millisecond, 50*time.Millisecond
t.Cleanup(func() { indexerLate, firstViewBound, firstViewWait = nil, 0, 0 })
t.Cleanup(func() { catalogLate, firstViewBound, firstViewWait = nil, 0, 0 })
for _, name := range append(append([]string{}, readBuckets...), watched...) {
if strings.HasPrefix(name, buckets.IndexerModule+"_") {
t.Errorf("the assigner reads the indexer's state %s itself", name)
@@ -461,7 +476,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
fNew, fOld := meshcatalogclient.SourcePrefix2+strings.Repeat("b", 64), meshcatalogclient.SourcePrefix2+strings.Repeat("a", 64)
digest := b.manifest(t, "web", `{"module":"web","version":"1"}`)
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
b.putJSON(t, buckets.Modules, "web", mod)
b.putModule(t, "web", mod)
built, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest})
if err != nil {
t.Fatal(err)
@@ -493,20 +508,20 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
"seat-holders": {"n1.node-tools"},
"wants": {"n1.mesh-grants"},
})
graphKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, assignment.Module+"."+buckets.IndexerModule+"."+graph.State, views.UnansweredKind)
graphKey := meshconditionsclient.Key(meshconditionsclient.ScopeModule, assignment.Module+"."+meshcatalog.Seat+"."+views.BranchState(meshcatalog.StateCatalogRevisions, trunkBranch), views.UnansweredKind)
graphRaised := func() bool {
e, err := b.buckets[assign.FindingsBucket].Get(b.ctx, graphKey)
return err == nil && strings.Contains(string(e.Value()), buckets.IndexerModule+".graph")
return err == nil && strings.Contains(string(e.Value()), "mesh-catalog.revision")
}
for deadline := time.Now().Add(10 * time.Second); !graphRaised(); time.Sleep(50 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatalf("with the indexer down, no condition names its graph (finding %s)", graphKey)
t.Fatalf("with the seat mesh-catalog down, no condition names its revision verb (finding %s)", graphKey)
}
}
close(up)
for deadline := time.Now().Add(10 * time.Second); graphRaised(); time.Sleep(50 * time.Millisecond) {
if time.Now().After(deadline) {
t.Fatalf("the indexer answered, and the condition naming its graph stands (finding %s)", graphKey)
t.Fatalf("the seat answered, and the condition naming its revision verb stands (finding %s)", graphKey)
}
}
@@ -608,7 +623,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
fp, digest string
}{"db": {fpOf('2'), dbDigest}, "app": {fpOf('3'), appDigest}, "tool": {fpOf('4'), toolDigest}} {
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: row.fp, Manifest: row.digest, Repository: "novox/" + module, Commit: strings.Repeat("2", 40), Path: meshcatalogclient.RootPath})
b.putJSON(t, buckets.Modules, module, mod)
b.putModule(t, module, mod)
fps := []string{row.fp}
if module == "tool" {
fps = append(fps, toolOld)
@@ -680,7 +695,7 @@ func TestWantsAreWrittenAndNoDifferenceIs(t *testing.T) {
formsDigest := b.manifest(t, "forms", `{"module":"forms","version":"1"}`)
formsAt := func(fp string) {
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: formsDigest, Repository: "novox/forms", Commit: strings.Repeat("3", 40), Path: meshcatalogclient.RootPath})
b.putJSON(t, buckets.Modules, "forms", mod)
b.putModule(t, "forms", mod)
}
for _, fp := range []string{formsOld, formsNew} {
build := builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: formsDigest}
@@ -802,7 +817,7 @@ func TestAHeldFingerprintIsAskedAbout(t *testing.T) {
fNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("c", 64)
digest := b.manifest(t, "web", `{"module":"web","version":"1"}`)
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
b.putJSON(t, buckets.Modules, "web", mod)
b.putModule(t, "web", mod)
on, _ := presenceValue(true)
b.putJSON(t, buckets.Presence, "ace", on)
failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"})
@@ -860,7 +875,7 @@ func TestAFailedDataModuleIsAskedToRestore(t *testing.T) {
fNew := meshcatalogclient.SourcePrefix2 + strings.Repeat("d", 64)
digest := b.manifest(t, "mail", `{"module":"mail","version":"1","resources":[{"id":"data","type":"directory","path":"/var/lib/mail"}],"data":{"own":[{"id":"mail","path":"${dir:data}","class":"irreplaceable","backup":"copy"}]}}`)
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fNew, Manifest: digest, Repository: "novox/mail", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
b.putJSON(t, buckets.Modules, "mail", mod)
b.putModule(t, "mail", mod)
on, _ := presenceValue(true)
b.putJSON(t, buckets.Presence, "ace", on)
failed, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fNew, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthFailed, Detail: "connection refused"})
@@ -910,7 +925,7 @@ func TestTheAssignerReplacedResumesWithNothingLostOrRepeated(t *testing.T) {
fp := meshcatalogclient.SourcePrefix2 + strings.Repeat(string(rune('a'+i%6)), 63) + fmt.Sprintf("%x", i%16)
digest := b.manifest(t, name, fmt.Sprintf(`{"module":%q,"version":"1"}`, name))
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/" + name, Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
b.putJSON(t, buckets.Modules, name, mod)
b.putModule(t, name, mod)
built, err := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest})
if err != nil {
t.Fatal(err)
@@ -956,7 +971,7 @@ func (b *bed) fixture(t *testing.T, name, fp, manifest string) {
t.Helper()
digest := b.manifest(t, name, manifest)
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: digest, Repository: "novox/" + name, Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
b.putJSON(t, buckets.Modules, name, mod)
b.putModule(t, name, mod)
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, By: "builder.novox.1", Step: "pushed", Manifest: digest})
bkey, _ := builds.BuilderBuildKey(name, fp)
b.putJSON(t, buckets.Builds, bkey, built)
@@ -1305,9 +1320,9 @@ func TestAProposalIsAnsweredApprovedAndWritten(t *testing.T) {
rec, _ := nodeengineclient.EncodeHaveNode(nodeengine.HaveByNodeNodeValue{Record: &nodeengine.NodeRecord{Node: node}})
b.putJSON(t, buckets.Have, node+".node", rec)
}
b.putJSON(t, buckets.Modules, "web", []byte(`{"v":1}`))
b.putJSON(t, buckets.Modules, "shell", []byte(`{"v":1}`))
b.putJSON(t, buckets.Modules, "mesh-enrolment", []byte(`{"v":1}`))
b.putModule(t, "web", []byte(`{"v":1}`))
b.putModule(t, "shell", []byte(`{"v":1}`))
b.putModule(t, "mesh-enrolment", []byte(`{"v":1}`))
// place writes a node's assignments as another writer would, in its node and module keys (research 072 row 37), and
// answers its node key's revision.
place := func(node string, modules ...string) uint64 {
@@ -1611,9 +1626,9 @@ func assignmentsSubjects() []string {
return out
}
// indexerLate, when set before the bed is made, fails every ask of the indexer's first views until it is closed: the
// catalogLate, when set before the bed is made, fails every ask of the indexer's first views until it is closed: the
// indexer down, its runtime coming up after the assigner asked (DLV-F5r, ADR 0631 §2).
var indexerLate chan struct{}
var catalogLate chan struct{}
// serveIndexer is the indexer as the owner of its manifests, modules and module graph (ADR 0615, ADR 0621, ADR 0631): its
// buckets, which a test fills as the indexer writes them; its views family said by the views emitter as ordinary events on EVENTS,
@@ -1635,7 +1650,7 @@ func serveIndexer(t *testing.T, b *bed, nc *nats.Conn) {
if err := em.Start(b.ctx, state, kv, nil); err != nil {
t.Fatal(err)
}
late := indexerLate
late := catalogLate
sub, err := nc.Subscribe("mesh.mod.mesh-delivery-indexer.call."+state+".>", func(m *nats.Msg) {
if late != nil { // its runtime comes up after the assigner started (DLV-F5r): until then every ask fails, as on a down indexer
select {
@@ -1797,3 +1812,82 @@ func changeIn(body map[string]any) plc.Change {
_ = json.Unmarshal(raw, &c)
return c
}
// indexerFirstViews are the indexer's states the bed still serves (the verbs' check of a proposal asks the indexer's modules and
// manifests): the bucket each is kept in.
var indexerFirstViews = map[string]string{"manifests": buckets.Manifests, "modules": buckets.Modules}
// putModule keeps a module's value as the indexer does for the verbs, and as an entry of the trunk's catalog half; a value that
// names no manifest the bed holds is kept for the verbs alone.
func (b *bed) putModule(t *testing.T, module string, value []byte) {
t.Helper()
b.putJSON(t, buckets.Modules, module, value)
v, err := meshcatalogclient.DecodeModule(value)
if err != nil { // a value the bed writes only for the module to exist
v = meshcatalog.ModulesByModuleValue{Repository: "novox/" + module}
}
b.publish(t, trunkBranch, module, v)
}
// publish makes the trunk's catalog half of branch name module at v, a new revision of it, and says catalog-revision.
func (b *bed) publish(t *testing.T, branch, module string, v meshcatalog.ModulesByModuleValue) {
t.Helper()
e := meshcatalog.CatalogEntry{Module: module, Fp: v.Fp2, Commit: v.Commit, Repository: v.Repository, Path: v.Path, Needs: v.Needs, Verdict: "accepted"}
if body, ok := b.cat.bodies[v.Manifest]; ok {
_ = json.Unmarshal([]byte(body), &e.Manifest)
}
b.cat.mu.Lock()
if b.cat.entries[branch] == nil {
b.cat.entries[branch] = map[string]meshcatalog.CatalogEntry{}
}
b.cat.entries[branch][module] = e
b.cat.rev[branch]++
rev, n := b.cat.rev[branch], int64(len(b.cat.entries[branch]))
b.cat.mu.Unlock()
raw, _ := json.Marshal(meshcatalog.CatalogRevisionEvent{Kind: "trunk", Branch: keys.EscapeName(branch), Revision: rev, Entries: n, Changed: []string{module}})
if _, err := b.js.Publish(b.ctx, meshcatalog.SubjectCatalogRevision(branch), raw); err != nil {
t.Fatal(err)
}
}
// serveCatalog answers the seat mesh-catalog's verb revision from the bed's catalog half, all in one page; until catalogLate is
// closed every ask fails, as on a seat whose holder is down.
func serveCatalog(t *testing.T, b *bed, nc *nats.Conn) {
t.Helper()
late := catalogLate
answer := func(m *nats.Msg) {
if late != nil {
select {
case <-late:
default:
_ = m.Respond([]byte(`{"error":"the seat mesh-catalog's holder is not up"}`))
return
}
}
var in meshcatalog.RevisionInput
_ = json.Unmarshal(m.Data, &in)
branch, _ := keys.UnescapeName(in.Branch)
b.cat.mu.Lock()
defer b.cat.mu.Unlock()
if b.cat.rev[branch] == 0 {
_ = m.Respond([]byte(`{"error":"` + views.NoneYet + `"}`))
return
}
rev := meshcatalog.CatalogRevision{Kind: "trunk", Branch: in.Branch, Revision: b.cat.rev[branch]}
var entries []meshcatalog.CatalogEntry
for _, name := range slices.Sorted(maps.Keys(b.cat.entries[branch])) {
entries = append(entries, b.cat.entries[branch][name])
rev.Entries = append(rev.Entries, meshcatalog.EntryRef{Module: name, Digest: "sha256:" + strings.Repeat("0", 64)})
}
raw, _ := json.Marshal(map[string]any{"result": meshcatalog.RevisionAnswer{Revision: rev, Entries: entries}, "node": "n0"})
_ = m.Respond(raw)
}
for _, subject := range []string{"mesh.seat.mesh-catalog.tool.revision", "mesh.seat.mesh-catalog.call.revision.*", "mesh.seat.mesh-catalog.tool.revision.*"} {
sub, err := nc.Subscribe(subject, answer)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
subscribed(t, nc)
}
@@ -248,7 +248,7 @@ 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,
iv := &catalogViews{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)
}
@@ -306,7 +306,7 @@ var watched = []string{
// 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, logger *log.Logger) error {
w := newWorld()
dirty := make(chan struct{}, 1)
wake := func() {
@@ -425,9 +425,9 @@ 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{}
gate := seatlist.NewPart("assigner", seatListAgainCallers)
if bus != nil {
gate.follow(seatlist.ProcessBus(bus.Call, hub), wake, logger.Printf)
gate.Follow(seatlist.ProcessBus(bus.Call, hub), wake, logger.Printf)
}
// 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.
@@ -493,12 +493,24 @@ func follow(ctx context.Context, js jetstream.JetStream, nc process.Subscriber,
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.heard = newPipelineEnds(kvEnds{ctx, kv}, wake, logger.Printf)
if err := hearEnds(hub, p.heard); err != nil {
logger.Printf("a pipeline's end is not heard, so no want follows a pipeline until the assigner starts again: %v", err)
p.ends = newPipelineEnds(kvEnds{ctx, kv}, wake, logger.Printf)
if iv != nil && iv.h != nil {
p.ends.onBranches = func(branches []string) {
for _, b := range branches {
iv.h.follow(b)
}
}
}
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)
}
}
hearReadsChanged(ctx, js, hub, reads, findings, gate.eventsLive, logger.Printf)
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 {
p.acts = newPutActs(bus, wake, logger.Printf)
@@ -598,7 +610,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()
@@ -8,6 +8,7 @@ import (
"maps"
"mesh-delivery-assigner/internal/buckets"
"slices"
"sort"
"strings"
"time"
@@ -18,9 +19,12 @@ import (
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
"git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go/overlay"
"git.novox.be/novox/mesh-sdk/go/motion"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"sync"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-contracts/provisioning/go/clients/meshprovisionerclient"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient/enginereads"
@@ -58,10 +62,8 @@ type passer struct {
// neither is, by node and module, each module on neither list whose condition this process raised (issue 1945). Nil until the
// first pass after a start, which clears what a previous process may have raised (startClears).
neither map[string]neitherOne
// ends are the pipeline ends the wants follow (pipelines.go); nil is the trunk-only want, kept until the first registrations.
// ends are the pipeline ends the wants follow (pipelines.go), once the first registrations are heard; until then, and nil, the trunk-only want.
ends *pipelineEnds
// heard is the same follower while the wants do not follow it yet (ends is nil).
heard *pipelineEnds
// acts are the puts' acts (ADR 0546); nil: the puts are not taken further (no bus to call, or no puts bucket).
acts *putActs
// noWant is, by node and module, the reason last said why the node gets no want for the module (issue 1945): a reason is
@@ -81,7 +83,8 @@ type passer struct {
seat *seatActs
// seats is the seat list the process reads (seatlist.go): until it is read, seat-holders is not written and a proposal
// laid again is not ended on a seat it does not name (ADR 0636 §1). Nil reads nothing.
seats *seatGate
seats *seatlist.Part
seatWaitSaid sync.Once
publish publisher
saidProposed map[string]bool
// copied says the retired state was copied into `assignments` by this process, or found empty (copyRetired).
@@ -218,19 +221,21 @@ func (p *passer) nodeWants(ctx context.Context) {
// the hold of a fingerprint whose health check failed. The assigner compares its wants with nothing the controller
// sends: the differences bucket is kept read-only with its history (ADR 0539 §8).
func (p *passer) wants(ctx context.Context) {
get := p.w.getter(buckets.Manifests) // the indexer's manifests, as its first view and views family say them
assigned := p.w.assigned()
assignedBranches := p.w.placedBranches()
// Every module of every branch's catalog half, for the parts that ask which exist; a node's entries come from the half of the
// branch its placement names (design 47 G §14, §184).
trunk := p.w.trunk()
manifests := map[string]catalogue.Manifest{}
for name, mod := range trunk {
if mod.Manifest == "" {
continue
}
if got := p.w.manifest(ctx, get, mod.Manifest); got.err == "" {
manifests[name] = got.manifest
views := map[string]*branchView{}
viewOf := func(branch string) *branchView {
if v := views[branch]; v != nil {
return v
}
v := p.branchView(branch, assigned)
views[branch] = v
return v
}
nodes := p.w.nodes(manifests, assigned)
nodes := viewOf(trunkBranch).nodes
p.engineReads(nodes)
nodesOf := map[string][]string{}
for node, modules := range assigned {
@@ -267,111 +272,118 @@ func (p *passer) wants(ctx context.Context) {
defer func() { p.sayNeither(neither, stale, left) }()
// Where grants are read from, once for the pass: the grants part's (issue 774, ADR 0571 clause 9).
grants := p.w.grantsBucket()
// The verb edges, once for the pass (ADR 0614 §2, §3).
moduleGraph := p.moduleGraph()
served := p.served(ctx, get, moduleGraph, nodes)
offering := p.offering(ctx, get, manifests, nodes) // bus state (ADR 0614 §5)
assignedBranches := p.w.placedBranches()
for name := range names {
fp := trunk[name].Fp2
name := name
// A want follows a passed pipeline: a module whose fingerprint no pipeline passed at on every branch its placements
// chose, and whose wants do not name it already, gets none (design 47 G §29, §35).
branches := p.ends.branchesOf(name, assignedBranches)
if fp != "" && !p.ends.mayWant(name, fp, branches, wanted[name]) {
continue
}
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], FormNeeds: p.w.buildNeeds(name, fp),
Self: func(node string) bool { return placed[node] },
Composed: func(node, fp string) (assign.ComposedDigest, bool) {
return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6)
},
GrantedAt: func(fp string) bool {
key, err := grantsstate.PartGrantKey(name, fp)
return err == nil && p.w.has(grants, key)
},
Restored: func(node string) bool {
key, err := asks.RestoreAskKey(node, name, fp)
return err == nil && p.answeredWith(key, assign.OptionRestore)
},
Allowed: func(node, digest string) bool {
key, err := asks.RecordAskKey(node, name, digest)
return err == nil && p.answeredWith(key, assign.OptionYes)
},
Release: func(fp string) (string, bool) { return p.w.release(name, fp) }}
if fp != "" {
held, _ := mwc.HeldKey(name, fp)
grant, _ := grantsstate.PartGrantKey(name, fp)
hold, _ := asks.HoldAskKey(name, fp)
m.Released = p.answeredWith(hold, "release")
m.Kept = p.answeredWith(hold, "keep")
m.Built, m.Held, m.Granted = p.w.built(name, fp), p.w.has(buckets.Wants, held) && !m.Released, p.w.has(grants, grant)
if raw := p.w.value(grants, grant); raw != nil {
if g, err := grantsstate.DecodeGrant(raw); err == nil {
m.GrantedOn = func(node string) bool { return assign.GrantNamesNode(g, node, name) }
groups := p.groups(name, nodesOf[name], assignedBranches[name])
for gi, g := range groups {
func() {
bv := viewOf(g.branch)
trunk, manifests, nodes, moduleGraph, served, offering := bv.trunk, bv.manifests, bv.nodes, bv.graph, bv.served, bv.offering
fp := trunk[name].Fp2
name := name
nodesOf := map[string][]string{name: g.nodes}
wantedOf := map[string]map[string]mwc.Want{name: wantedHere(wanted[name], g.nodes, groups, gi == 0)}
// A want follows a passed pipeline: a module whose fingerprint no pipeline passed at on the branch its placements
// chose, and whose wants do not name it already, gets none (design 47 G §29, §35).
var branches []string
if p.ends.following() {
branches = []string{g.branch}
}
}
if m.Released {
p.clearHeld(name, fp)
}
}
if served != nil {
m.Invokes, m.Served = keptInvokes(moduleGraph, name), served
}
m.Offering = offering
last := map[string]string{}
for _, node := range m.Nodes {
last[node] = p.w.lastHealthy(node, name, fp)
}
out := assign.Wants(m, nodes, wanted[name], last)
for _, node := range m.Nodes {
for _, prov := range m.MissingProvisions(node) {
missing[node+"."+name+"."+prov] = true
}
}
p.yeses(ctx, name, m, wanted[name], &out)
gatherNeither(neither, name, out)
leftNeither(left, m, wanted[name], out)
p.sayNoWant(name, m.Nodes, wanted[name], out)
p.sayUnreached(name, m.Nodes, out)
p.sayNodeFailed(name, fp, m.Nodes, nodes, out, first)
if first {
startClears(stale, m, out)
if fp != "" && !m.Held && out.Held == nil && !out.Lifted {
p.clearHeld(name, fp)
}
}
if out.Held != nil {
key, err := mwc.HeldKey(name, fp)
if err == nil {
held := *out.Held
p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeHeld(held) })
}
}
if out.Lifted {
p.lift(ctx, name, fp)
}
if out.Held != nil || (m.Held && !out.Lifted) {
p.holdAsk(ctx, name, fp, out.Held)
p.heldCondition(ctx, name, fp, out.Held, len(out.Restore) > 0)
}
for _, node := range out.Restore {
p.restoreAsk(ctx, name, node, fp)
}
for _, need := range out.Record {
p.recordAsk(ctx, name, fp, need)
}
for node, want := range out.Wants {
key, err := mwc.WantKey(node, name)
if err != nil {
p.logf("no want of %s on %s is written: %v", name, node, err)
continue
}
want := want
p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeWant(want) })
}
if len(out.Wants) > 0 {
p.ends.wrote(name, fp, branches)
if fp != "" && !p.ends.mayWant(name, fp, branches, wantedOf[name]) {
return
}
m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Needs: trunk[name].Needs, Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Answering: answering(manifests, nodes), Offered: offered(manifests), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], FormNeeds: p.w.buildNeeds(name, fp),
Self: func(node string) bool { return placed[node] },
Composed: func(node, fp string) (assign.ComposedDigest, bool) {
return p.w.composed(node, name, fp) // a placed node's own: the digest its own have names (ADR 0571 §6)
},
GrantedAt: func(fp string) bool {
key, err := grantsstate.PartGrantKey(name, fp)
return err == nil && p.w.has(grants, key)
},
Restored: func(node string) bool {
key, err := asks.RestoreAskKey(node, name, fp)
return err == nil && p.answeredWith(key, assign.OptionRestore)
},
Allowed: func(node, digest string) bool {
key, err := asks.RecordAskKey(node, name, digest)
return err == nil && p.answeredWith(key, assign.OptionYes)
},
Release: func(fp string) (string, bool) { return p.w.release(name, fp) }}
if fp != "" {
held, _ := mwc.HeldKey(name, fp)
grant, _ := grantsstate.PartGrantKey(name, fp)
hold, _ := asks.HoldAskKey(name, fp)
m.Released = p.answeredWith(hold, "release")
m.Kept = p.answeredWith(hold, "keep")
m.Built, m.Held, m.Granted = p.w.built(name, fp), p.w.has(buckets.Wants, held) && !m.Released, p.w.has(grants, grant)
if raw := p.w.value(grants, grant); raw != nil {
if g, err := grantsstate.DecodeGrant(raw); err == nil {
m.GrantedOn = func(node string) bool { return assign.GrantNamesNode(g, node, name) }
}
}
if m.Released {
p.clearHeld(name, fp)
}
}
if served != nil {
m.Invokes, m.Served = keptInvokes(moduleGraph, name), served
}
m.Offering = offering
last := map[string]string{}
for _, node := range m.Nodes {
last[node] = p.w.lastHealthy(node, name, fp)
}
out := assign.Wants(m, nodes, wantedOf[name], last)
for _, node := range m.Nodes {
for _, prov := range m.MissingProvisions(node) {
missing[node+"."+name+"."+prov] = true
}
}
p.yeses(ctx, name, m, wantedOf[name], &out)
gatherNeither(neither, name, out)
leftNeither(left, m, wantedOf[name], out)
p.sayNoWant(name, m.Nodes, wantedOf[name], out)
p.sayUnreached(name, m.Nodes, out)
p.sayNodeFailed(name, fp, m.Nodes, nodes, out, first)
if first {
startClears(stale, m, out)
if fp != "" && !m.Held && out.Held == nil && !out.Lifted {
p.clearHeld(name, fp)
}
}
if out.Held != nil {
key, err := mwc.HeldKey(name, fp)
if err == nil {
held := *out.Held
p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeHeld(held) })
}
}
if out.Lifted {
p.lift(ctx, name, fp)
}
if out.Held != nil || (m.Held && !out.Lifted) {
p.holdAsk(ctx, name, fp, out.Held)
p.heldCondition(ctx, name, fp, out.Held, len(out.Restore) > 0)
}
for _, node := range out.Restore {
p.restoreAsk(ctx, name, node, fp)
}
for _, need := range out.Record {
p.recordAsk(ctx, name, fp, need)
}
for node, want := range out.Wants {
key, err := mwc.WantKey(node, name)
if err != nil {
p.logf("no want of %s on %s is written: %v", name, node, err)
continue
}
want := want
p.put(ctx, buckets.Wants, key, func() ([]byte, error) { return mwc.EncodeWant(want) })
}
if len(out.Wants) > 0 {
p.ends.wrote(name, fp, branches)
}
}()
}
}
}
@@ -1250,11 +1262,11 @@ func (p *passer) seatHolders(ctx context.Context) {
if len(assignments) == 0 {
return // no assignments read yet: nothing is known of who holds what, and nothing is deleted
}
if !p.seats.read() {
if !p.seats.Heard() {
// ADR 0636 §1: which claim names which seat is the list's, so nothing is written from the bootstrap alone; the
// reader's change makes a pass.
if p.seats != nil {
p.seats.waitSaid.Do(func() { p.logf("seat-holders is not written until the seat list is read: %s", p.seats.health()) })
p.seatWaitSaid.Do(func() { p.logf("seat-holders is not written until the seat list is read: %s", p.seats.Health()) })
}
return
}
@@ -1312,3 +1324,82 @@ func answering(manifests map[string]catalogue.Manifest, n assign.Nodes) func(str
return out
}
}
// branchView is what one pass reads of the catalog half of one watched branch (design 47 G §184, §195): its modules, their
// manifests, the nodes the rules read through them, the module graph and the two readings of what runs.
type branchView struct {
trunk map[string]meshcatalog.ModulesByModuleValue
manifests map[string]catalogue.Manifest
nodes assign.Nodes
graph graph.Graph
served func(string) bool
offering func(string) bool
}
// branchView reads the catalog half of branch for this pass.
func (p *passer) branchView(branch string, assigned map[string][]string) *branchView {
v := &branchView{trunk: p.w.trunkOf(branch), manifests: map[string]catalogue.Manifest{}}
for name, mod := range v.trunk {
if mod.Manifest == "" {
continue
}
if got := p.w.manifest(mod.Manifest); got.err == "" {
v.manifests[name] = got.manifest
}
}
v.nodes = p.w.nodes(v.manifests, assigned)
v.graph = p.moduleGraph(branch)
v.served = p.served(v.graph, v.nodes)
v.offering = p.offering(v.manifests, v.nodes) // bus state (ADR 0614 §5)
return v
}
// group is the nodes of one module whose placements name one watched branch.
type group struct {
branch string
nodes []string
}
// groups splits the nodes of module by the branch their placements name, the branches in name order (the trunk's first); a
// module placed nowhere is one group on its default branch.
func (p *passer) groups(module string, nodes []string, placed map[string]string) []group {
by := map[string][]string{}
for _, node := range nodes {
b := trunkBranch
if p.ends.following() {
b = p.ends.branchOf(module, placed[node])
}
by[b] = append(by[b], node)
}
if len(by) == 0 {
b := trunkBranch
if p.ends.following() {
b = p.ends.branchOf(module, "")
}
by[b] = nil
}
var out []group
for b, ns := range by {
out = append(out, group{b, ns})
}
sort.Slice(out, func(i, j int) bool { return out[i].branch < out[j].branch })
return out
}
// wantedHere is the wants of a module a group answers for: those of its own nodes, and (the first group only) those of nodes no
// group holds, which the rules take away.
func wantedHere(wanted map[string]mwc.Want, own []string, groups []group, first bool) map[string]mwc.Want {
held := map[string]bool{}
for _, g := range groups {
for _, n := range g.nodes {
held[n] = true
}
}
out := map[string]mwc.Want{}
for node, w := range wanted {
if slices.Contains(own, node) || (first && !held[node]) {
out[node] = w
}
}
return out
}
@@ -272,6 +272,7 @@ func TestAnAnswerCountsOnlyForTheAskingItAnswered(t *testing.T) {
t.Error("an answer to an ask taken away was taken")
}
t.Run("a put of the bus acts on the operator's warrant alone", putOfTheBus)
t.Run("a placement's build is put held, and released when the placement names it no more", aPlacementsBuildIsPut)
// ADR 0614 §4: the rules read what each node's engine states it reads from its record in `<node>.node`; a node with no
// record, or one that states nothing, is read as stating nothing (the rules then read the baseline).
t.Run("the rules read what each node's engine reads", func(t *testing.T) {
@@ -467,6 +468,63 @@ func (b *fakeBus) Emit(eventType string, body any) error {
return nil
}
// Design 47 line 912 and line 914 ("a placement's `build` delivers the earlier build"; ADR 0666 §24, B-K1): a placed
// module's build becomes a held put of that build, which then goes a put's steps; a placement naming no build any more
// releases the put it made, never a person's.
func aPlacementsBuildIsPut(t *testing.T) {
ctx := context.Background()
putsKV := &memKV{values: map[string][]byte{}}
p := newPasser(kvSet{buckets.Puts: putsKV}, time.Date(2026, 11, 10, 18, 0, 0, 0, time.UTC))
earlier := meshcatalogclient.SourcePrefix2 + strings.Repeat("e", 64)
place := func(build string) {
values, err := mmac.AssignmentsValues(mma.Assignments{Node: "g14", Modules: []mma.PlacedModule{{Module: "web", Build: build}, {Module: "db"}}})
if err != nil {
t.Fatal(err)
}
for key, raw := range values {
p.w.putEntry(plc.AssignmentsBucket, key, raw, uint64(len(raw)), time.Now())
}
}
read := func() (puts.Put, bool) {
raw, ok := putsKV.values["g14.web"]
if !ok {
return puts.Put{}, false
}
got, err := puts.Decode(raw)
if err != nil {
t.Fatal(err)
}
return got, true
}
place(earlier)
p.placementPuts(ctx)
got, ok := read()
if !ok || got.FP != earlier || !got.Hold || got.By != placementBy || got.Step != puts.Asked {
t.Fatalf("the placement's build made the put %+v (there %v)", got, ok)
}
if _, there := putsKV.values["g14.db"]; there {
t.Error("a placed module naming no build was put")
}
p.placementPuts(ctx) // again: the same build makes nothing new
if again, _ := read(); !again.At.Equal(got.At) || again.Step != puts.Asked {
t.Errorf("a second pass made the put again: %+v", again)
}
place("")
p.placementPuts(ctx)
if got, _ := read(); got.Step != puts.Released || !strings.Contains(got.Words, placementBy) {
t.Errorf("the placement naming no build left the put %+v", got)
}
// A person's put stands while the placement names no build.
person := puts.Put{FP: earlier, Hold: true, Why: "a try", By: "lead@shanks", At: time.Now()}.Said(time.Now(), puts.Holding, "held")
raw, _ := puts.Encode(person)
putsKV.values["g14.web"] = raw
p.w.put(buckets.Puts, "g14.web", raw)
p.placementPuts(ctx)
if got, _ := read(); got.Step != puts.Holding || got.By != "lead@shanks" {
t.Errorf("a person's put was released by the placement: %+v", got)
}
}
// ADR 0546, the first put (2026-10-25, want.novox.nats): the tool writes the put; a pass waits for the composition and grant
// the controller's take-in writes; once they are there it asks the operator with a yes that approves; only on that warrant does it have
// the streams snapshotted, then writes the want held and records the warrant; the have running it healthy holds it; a release
@@ -500,7 +558,7 @@ func putOfTheBus(t *testing.T) {
placeOn(t, p.w, "novox", "nats")
mdigest := strings.Repeat("e", 64)
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: mdigest, Repository: "novox/mesh-catalog", Commit: strings.Repeat("c", 40)})
world(buckets.Modules, "nats", mod)
putModuleRaw(p.w, trunkBranch, "nats", mod)
p.w.manifests[mdigest] = parsed{module: "nats", manifest: catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus"}}}}
bkey, _ := builds.BuilderBuildKey("nats", fp)
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest})
@@ -593,8 +651,21 @@ func putOfTheBus(t *testing.T) {
t.Errorf("the warrant was recorded as %q", call)
}
world(buckets.Wants, "want.novox.nats", wantsKV.values["want.novox.nats"])
running, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
world(buckets.Have, hkey, running)
// Design 47 G §309: the put ends on the node-engine seat's running naming its fingerprint, never on the have.
haveRunning, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: fp, Composed: digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
world(buckets.Have, hkey, haveRunning)
if got := step(); got.Step != puts.Wanted {
t.Fatalf("the have runs it and no running says so, the put stands at %s: %s", got.Step, got.Words)
}
if err := p.w.hearRunning("mesh.seat.node-engine.event.running.novox.nats", []byte(`{"v":1,"at":"2026-11-10T20:00:00Z","node":"novox","module":"nats"}`)); err != nil {
t.Fatal(err)
}
if got := step(); got.Step != puts.Wanted {
t.Fatalf("a running without a fingerprint ends none, the put stands at %s: %s", got.Step, got.Words)
}
if err := p.w.hearRunning("mesh.seat.node-engine.event.running.novox.nats", []byte(`{"v":1,"at":"2026-11-10T20:01:00Z","node":"novox","module":"nats","fingerprint":"`+fp+`"}`)); err != nil {
t.Fatal(err)
}
if got := step(); got.Step != puts.Holding {
t.Fatalf("running healthy, the put stands at %s: %s", got.Step, got.Words)
}
@@ -830,7 +901,7 @@ func TestTheFirstPassAfterAStartClearsWhatAPreviousProcessRaised(t *testing.T) {
var said []oc.Condition
p.say = func(c oc.Condition) error { said = append(said, c); return nil }
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
p.w.put(buckets.Modules, "web", mod)
putModuleRaw(p.w, trunkBranch, "web", mod)
placeOn(t, p.w, "g14", "web")
on, _ := presenceValue(true)
p.w.put(buckets.Presence, "g14", on)
@@ -868,7 +939,7 @@ func testAHeldModuleRaisesNoNeitherAndClearsAStandingOne(t *testing.T) {
said := &[]oc.Condition{}
p.say = func(c oc.Condition) error { *said = append(*said, c); return nil }
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/resolved", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
p.w.put(buckets.Modules, "systemd-resolved", mod)
putModuleRaw(p.w, trunkBranch, "systemd-resolved", mod)
placeOn(t, p.w, "shanks", "systemd-resolved")
on, _ := presenceValue(true)
p.w.put(buckets.Presence, "shanks", on)
@@ -1127,7 +1198,7 @@ func aReleasesApproveAnswersTheMoveOfItsBuildAlone(t *testing.T) {
p := newPasser(kvSet{buckets.Asks: asksKV}, time.Now())
p.logf = t.Logf
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: commit, Src: 7, FpSrc: c.fpSrc, Path: meshcatalogclient.RootPath})
p.w.put(buckets.Modules, "web", mod)
putModuleRaw(p.w, trunkBranch, "web", mod)
src, _ := mdc.EncodeSource(mdseat.SourceByBranchValue{Group: "feat/web", Heads: map[string]string{"novox/web": commit}})
p.w.putEntry(buckets.Source, mdc.SourceKey(trunkBranch), src, 7, time.Now())
later, _ := mdc.EncodeSource(mdseat.SourceByBranchValue{Group: "feat/other", Heads: map[string]string{"novox/web": commit, "novox/x": commit}})
@@ -1190,7 +1261,7 @@ func aPlacedNodesWantsNameTheBuildAloneAndItsYesComesFromItsHave(t *testing.T) {
p.logf = t.Logf
put := func(bucket, key string, v []byte) { p.w.put(bucket, key, v) }
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fpTest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
put(buckets.Modules, "web", mod) // the trunk's build is not done: rule 1 holds g14 at fpOld
putModuleRaw(p.w, trunkBranch, "web", mod) // the trunk's build is not done: rule 1 holds g14 at fpOld
placeOn(t, p.w, "g14", "web")
pl, _ := mmac.EncodeAssignments(mma.Assignments{Node: "g14", Modules: []mma.PlacedModule{{Module: "web"}}})
put(plc.AssignmentsBucket, "assignments.g14", pl)
@@ -1309,7 +1380,7 @@ func TestAnApprovedAssignmentsCallsNoControllerVerb(t *testing.T) {
func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
p := newPasser(kvSet{}, time.Now())
fp := func(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
if p.served(context.Background(), nil, graph.Graph{}, assign.Nodes{}) != nil {
if p.served(graph.Graph{}, assign.Nodes{}) != nil {
t.Error("with no graph read, a want waits on a verb")
}
for module, n := range map[string]graph.Node{
@@ -1323,26 +1394,25 @@ func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
if err != nil {
t.Fatal(err)
}
p.w.put(buckets.Graph, module, raw)
_ = raw
p.w.putModule(trunkBranch, module, meshcatalog.ModulesByModuleValue{}, n, nil)
}
g := p.moduleGraph()
g := p.moduleGraph(trunkBranch)
if got := keptInvokes(g, "caller"); !slices.Equal(got, []string{"seat:x.v"}) || keptInvokes(g, "unknown") != nil {
t.Errorf("the kept verb edges of caller = %v", got)
}
digest := strings.Repeat("d", 64)
man, _ := meshcatalogclient.EncodeManifest(meshcatalog.ManifestsByDigestValue{Module: "server", Manifest: `{"module":"server","claims":[{"name":"x","scope":"mesh","serves":["w"]}]}`})
get := func(_ context.Context, key string) ([]byte, error) {
if key == digest {
return man, nil
}
return nil, errNotThere
parsedServer, err := catalogue.Decode([]byte(`{"module":"server","claims":[{"name":"x","scope":"mesh","serves":["w"]}]}`))
if err != nil {
t.Fatal(err)
}
p.w.manifests[digest] = parsed{module: "server", manifest: parsedServer}
build, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: digest})
key, _ := builds.BuilderBuildKey("server", fp("f"))
p.w.put(buckets.Builds, key, build)
running := nodeengine.HaveByNodeModuleValue{Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
at := func(f string) nodeengine.HaveByNodeModuleValue { h := running; h.Fp = f; return h }
served := p.served(context.Background(), get, g, assign.Nodes{Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{
served := p.served(g, assign.Nodes{Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{
"g14": {"server": at(fp("f")), "late": at(fp("2"))}}})
for v, want := range map[string]bool{"seat:x.w": true, "seat:x.v": false, "late.t": true} {
if served(v) != want {
@@ -1353,7 +1423,7 @@ func aWantWaitsOnTheVerbEdgesTheGraphKeeps(t *testing.T) {
// offline node offers nothing, and one whose manifest is not read counts its trunk manifest's offer.
bus := map[string]catalogue.Manifest{"bus": {Module: "bus", Provides: []catalogue.Offer{{Name: graph.BusState, Scope: catalogue.ScopeMesh}}}}
for _, online := range []bool{false, true} {
offering := p.offering(context.Background(), get, bus, assign.Nodes{Online: map[string]bool{"n2": online},
offering := p.offering(bus, assign.Nodes{Online: map[string]bool{"n2": online},
Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{"n2": {"bus": at(fp("2"))}}})
if offering(graph.BusState) != online {
t.Errorf("with the bus module's node online %v, bus-state offered %v", online, offering(graph.BusState))
@@ -5,13 +5,19 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/nats-io/nats.go/jetstream"
mcc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
mprc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshpipelinerunnerclient"
mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient"
mpcat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
mpr "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/keys"
@@ -81,6 +87,9 @@ type endStore interface {
get(key string) (value []byte, revision uint64, err error)
// swap writes value at key if the key stands at revision (0: if it does not stand); errSwapped when it does not.
swap(key string, value []byte, revision uint64) error
// keys are the keys that stand, and drop deletes one.
keys() ([]string, error)
drop(key string) error
}
var errSwapped = errors.New("the key changed")
@@ -115,6 +124,16 @@ func (k kvEnds) swap(key string, value []byte, revision uint64) error {
return err
}
func (k kvEnds) keys() ([]string, error) {
ks, err := k.kv.Keys(k.ctx)
if errors.Is(err, jetstream.ErrNoKeysFound) {
return nil, nil
}
return ks, err
}
func (k kvEnds) drop(key string) error { return k.kv.Delete(k.ctx, key) }
func isWrongSequence(err error) bool {
var ae *jetstream.APIError
return errors.As(err, &ae) && ae.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence
@@ -126,10 +145,14 @@ type pipelineEnds struct {
st endStore
wake func()
logf func(string, ...any)
mu sync.Mutex
// on is whether the wants follow the ends: set by the first registration heard, never before (design 47 G §95, §353).
on atomic.Bool
mu sync.Mutex
// firsts is, by module, the first watched branch of its registration, the branch of a placement that names none (design
// 47 G §14).
firsts map[string]string
// onBranches is told the branches a registration names, for the catalog half of each to be followed (design 47 G §14, §195).
onBranches func([]string)
}
func newPipelineEnds(st endStore, wake func(), logf func(string, ...any)) *pipelineEnds {
@@ -278,7 +301,7 @@ func writtenTools(st store) []stdio.Tool {
// branchesOf is the watched branches the placements of module chose, each once; the default branch when none names one.
func (e *pipelineEnds) branchesOf(module string, placed map[string]map[string]string) []string {
if e == nil {
if !e.following() {
return nil
}
seen := map[string]bool{}
@@ -300,7 +323,7 @@ func (e *pipelineEnds) branchesOf(module string, placed map[string]map[string]st
// mayWant says whether module's wants may name fp: a pipeline passed at fp on every branch, or the wants name fp already
// (a node placed later, a module taken away).
func (e *pipelineEnds) mayWant(module, fp string, branches []string, wanted map[string]mwc.Want) bool {
if e == nil {
if !e.following() {
return true
}
for _, w := range wanted {
@@ -318,10 +341,80 @@ func (e *pipelineEnds) mayWant(module, fp string, branches []string, wanted map[
// wrote marks the end the wants of module were written from.
func (e *pipelineEnds) wrote(module, fp string, branches []string) {
if e == nil {
if !e.following() {
return
}
for _, b := range branches {
e.markWritten(module, b, fp)
}
}
// following says whether the wants follow the pipeline ends yet; a nil follower never does.
func (e *pipelineEnds) following() bool { return e != nil && e.on.Load() }
// registered takes a module's registration (design 47 G §353): the first one heard switches the wants to the ends, its first
// watched branch is the default a placement runs, and the ends kept for a branch it no longer watches are deleted (§352). gone
// is a registration removed: all its ends go.
func (e *pipelineEnds) registered(reg mpcat.Registration, gone bool) {
e.mu.Lock()
if gone || len(reg.Branches) == 0 {
delete(e.firsts, reg.Module)
} else {
e.firsts[reg.Module] = reg.Branches[0]
}
e.mu.Unlock()
kept, err := e.st.keys()
if err != nil {
e.logf("the ends kept cannot be listed: %v", err)
}
prefix := keys.EscapeName(reg.Module) + "."
for _, k := range kept {
rest, ok := strings.CutPrefix(k, prefix)
if !ok {
continue
}
branch, err := keys.UnescapeName(rest)
if err == nil && !gone && slices.Contains(reg.Branches, branch) {
continue
}
if err := e.st.drop(k); err != nil {
e.logf("the end %s is not deleted: %v", k, err)
}
}
if e.onBranches != nil && !gone {
e.onBranches(reg.Branches)
}
if !e.on.Swap(true) {
e.logf("the first registration is heard (%s): the wants follow the pipelines' ends from now", reg.Module)
}
e.wake()
}
// followRegistrations follows the registrations through the seat mesh-catalog (design 47 G §353): a first view of its verb
// and each registration-changed on the hub. It returns when the hub's handler is registered; the first view is asked on its own
// goroutine, the wants keep the trunk-only form until a registration is heard.
func followRegistrations(ctx context.Context, hub *process.EventHub, call mcc.Call, e *pipelineEnds) error {
regs := mcc.NewRegistrations(call)
regs.Follower.Logf = e.logf
regs.Follower.Changed = func(module string, value json.RawMessage, gone bool, _ time.Time) {
reg := mpcat.Registration{Module: module}
if !gone {
_ = json.Unmarshal(value, &reg)
}
e.registered(reg, gone)
}
if err := hub.Handle(mpcat.SubjectRegistrationChanged, func(m jetstream.Msg) {
if err := regs.Hear(ctx, m.Data()); err != nil {
e.logf("%s: %v", m.Subject(), err)
}
_ = m.Ack()
}); err != nil {
return err
}
go func() {
if err := regs.Start(ctx); err != nil && ctx.Err() == nil {
e.logf("the first view of the registrations is not answered: %v", err)
}
}()
return nil
}
@@ -3,6 +3,11 @@ package main
import (
"context"
"encoding/json"
"fmt"
mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
plc "mesh-delivery-assigner/internal/assignment"
"slices"
"strings"
"testing"
"time"
@@ -10,6 +15,7 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
mpcat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
mpr "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshpipelinerunner"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
@@ -34,6 +40,13 @@ type memEnds struct {
func newMemEnds() *memEnds { return &memEnds{vals: map[string][]byte{}, revs: map[string]uint64{}} }
func (m *memEnds) get(key string) ([]byte, uint64, error) { return m.vals[key], m.revs[key], nil }
func (m *memEnds) keys() (out []string, _ error) {
for k := range m.vals {
out = append(out, k)
}
return out, nil
}
func (m *memEnds) drop(key string) error { delete(m.vals, key); delete(m.revs, key); return nil }
func (m *memEnds) swap(key string, v []byte, rev uint64) error {
if m.revs[key] != rev {
return errSwapped
@@ -79,6 +92,7 @@ func TestAWantFollowsAPassedPipeline(t *testing.T) {
} {
woken := 0
e := newPipelineEnds(newMemEnds(), func() { woken++ }, t.Logf)
e.on.Store(true)
for _, p := range c.ends {
if p.Group != "" {
continue // heard and ignored, as hearPipeline does
@@ -100,12 +114,14 @@ func TestAWantFollowsAPassedPipeline(t *testing.T) {
}
// A want that names the fingerprint already is not held by a later failure (a node placed afterwards gets it).
e := newPipelineEnds(newMemEnds(), func() {}, t.Logf)
e.on.Store(true)
if !e.mayWant("web", fp1, []string{"main"}, map[string]mwc.Want{"g14": {Fp: fp1}}) {
t.Error("a want naming the fingerprint was held")
}
// markWritten keeps the end and says it was written from; a newer end replaces it unwritten.
st := newMemEnds()
e = newPipelineEnds(st, func() {}, t.Logf)
e.on.Store(true)
_ = e.take(pipe("web", "main", 5, 1, fp1, pass), true)
e.wrote("web", fp1, []string{"main"})
var end End
@@ -136,7 +152,7 @@ func TestAPassWritesNoWantWithoutAPassedPipeline(t *testing.T) {
p.say = func(oc.Condition) error { return nil }
mdigest := strings.Repeat("e", 64)
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: fp, Manifest: mdigest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
p.w.put(buckets.Modules, "web", mod)
putModuleRaw(p.w, trunkBranch, "web", mod)
p.w.manifests[mdigest] = parsed{module: "web", manifest: catalogue.Manifest{Module: "web"}}
bkey, _ := builds.BuilderBuildKey("web", fp)
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest})
@@ -153,6 +169,7 @@ func TestAPassWritesNoWantWithoutAPassedPipeline(t *testing.T) {
p.w.put(buckets.Presence, "g14", on)
if c.follow {
p.ends = newPipelineEnds(newMemEnds(), func() {}, t.Logf)
p.ends.on.Store(true)
if c.passed {
_ = p.ends.take(pipe("web", trunkBranch, 5, 1, fp, mpr.PipelineEndedOutcomePassed), true)
}
@@ -172,3 +189,128 @@ func TestAPassWritesNoWantWithoutAPassedPipeline(t *testing.T) {
}
}
}
// Design 47 G §95, §130, §353, §352 (test 100): the wants keep the trunk-only form until the first registration is heard and
// switch on it; a branch a registration no longer watches loses its end, and its first branch is a placement's default.
func TestTheWantsSwitchOnTheFirstRegistration(t *testing.T) {
st := newMemEnds()
woken := 0
e := newPipelineEnds(st, func() { woken++ }, t.Logf)
_ = e.take(pipe("web", "main", 5, 1, "fp1", mpr.PipelineEndedOutcomeFailed), false)
_ = e.take(pipe("web", "beta", 5, 1, "fp1", mpr.PipelineEndedOutcomePassed), true)
if e.following() || !e.mayWant("web", "fp1", []string{"main"}, nil) {
t.Fatal("the follower held a want before any registration")
}
e.registered(mpcat.Registration{Module: "web", Branches: []string{"beta"}}, false)
if !e.following() || woken == 0 {
t.Error("the first registration did not switch the wants on")
}
if e.mayWant("web", "fp1", []string{"main"}, nil) || !e.mayWant("web", "fp1", []string{"beta"}, nil) {
t.Error("after the switch the wants did not follow the ends")
}
if len(st.vals) != 1 || e.branchOf("web", "") != "beta" || e.branchOf("other", "") != trunkBranch {
t.Errorf("ends kept %d, default branch %q: an unwatched branch's end stayed, or the default is wrong", len(st.vals), e.branchOf("web", ""))
}
e.registered(mpcat.Registration{Module: "web"}, true)
if len(st.vals) != 0 {
t.Errorf("a registration gone left %d ends", len(st.vals))
}
}
// Design 47 G §14, §184 (test 68): a node's entry is computed from the catalog half of the branch its placement names, so a
// change on `beta` moves only the want of the node whose placement chose `beta`.
func TestACommitOnBetaMovesOnlyTheNodeThatChoseBeta(t *testing.T) {
fp := func(c string) string { return meshcatalogclient.SourcePrefix2 + strings.Repeat(c, 64) }
wantsKV := &memKV{values: map[string][]byte{}}
p := newPasser(kvSet{buckets.Wants: wantsKV, buckets.Asks: &memKV{values: map[string][]byte{}}}, time.Now())
p.say = func(oc.Condition) error { return nil }
p.ends = newPipelineEnds(newMemEnds(), func() {}, t.Logf)
p.ends.registered(mpcat.Registration{Module: "web", Branches: []string{"main", "beta"}}, false)
digest := "sha256:" + strings.Repeat("c", 64)
on, _ := presenceValue(true)
for _, node := range []string{"g14", "ace"} {
composes(t, p.w, node, digest)
p.w.put(buckets.Presence, node, on)
}
for node, branch := range map[string]string{"g14": "", "ace": "beta"} {
pl := mma.Assignments{Node: node, Modules: []mma.PlacedModule{{Module: "web", Branch: branch}}}
values, err := mmac.AssignmentsValues(pl)
if err != nil {
t.Fatal(err)
}
for key, raw := range values {
p.w.put(plc.AssignmentsBucket, key, raw)
}
}
// publish makes the half of branch name web at fingerprint f: built, granted and passed by its pipeline.
publish := func(branch, f string, source int64) {
mdigest := strings.Repeat("e", 62) + fmt.Sprintf("%02d", source)
if branch == "beta" {
mdigest = strings.Repeat("f", 62) + fmt.Sprintf("%02d", source)
}
mod, _ := meshcatalogclient.EncodeModule(meshcatalog.ModulesByModuleValue{Fp2: f, Manifest: mdigest, Repository: "novox/web", Commit: strings.Repeat("1", 40), Path: meshcatalogclient.RootPath})
putModuleRaw(p.w, branch, "web", mod)
p.w.manifests[mdigest] = parsed{module: "web", manifest: catalogue.Manifest{Module: "web"}}
bkey, _ := builds.BuilderBuildKey("web", f)
built, _ := builds.EncodeBuilderBuild(builds.BuilderBuild{State: builds.BuildDone, Manifest: mdigest})
p.w.put(buckets.Builds, bkey, built)
gkey, _ := grantsstate.PartGrantKey("web", f)
grant, _ := grantsstate.EncodeGrant(grantsstate.Grant{})
p.w.put(buckets.Grants, gkey, grant)
_ = p.ends.take(pipe("web", branch, source, 1, f, mpr.PipelineEndedOutcomePassed), true)
}
run := func(node, f string) {
have, _ := nodeengineclient.EncodeHave(nodeengine.HaveByNodeModuleValue{Fp: f, Composed: digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK})
p.w.put(buckets.Have, node+".web", have)
}
fpWant := func(node string) string {
key, _ := mwc.WantKey(node, "web")
w, err := mwc.DecodeWant(wantsKV.values[key])
if err != nil {
t.Fatalf("the want of web on %s: %v", node, err)
}
return w.Fp
}
publish("main", fp("a"), 5)
publish("beta", fp("b"), 5)
run("g14", fp("a"))
run("ace", fp("b"))
p.wants(context.Background())
if fpWant("g14") != fp("a") || fpWant("ace") != fp("b") {
t.Fatalf("each node is wanted at its branch's build: g14 %s, ace %s", fpWant("g14"), fpWant("ace"))
}
publish("beta", fp("d"), 6)
p.wants(context.Background())
if fpWant("g14") != fp("a") || fpWant("ace") != fp("d") {
t.Errorf("a commit on beta moves only ace: g14 %s (want main's), ace %s (want beta's new)", fpWant("g14"), fpWant("ace"))
}
}
// Design 47 G §14, §195: the catalog half of each branch is taken into the world per branch, an entry as the value and graph node
// the rules read, and a registration's branches are each followed.
func TestTheCatalogHalfIsTakenInPerBranch(t *testing.T) {
w := newWorld()
woken := 0
h := &halves{w: w, wake: func() { woken++ }, logf: t.Logf}
entry := func(fp string) json.RawMessage {
raw, _ := json.Marshal(meshcatalog.CatalogEntry{Module: "web", Fp: fp, Repository: "novox/web", Commit: "c1", Manifest: map[string]any{"module": "web", "version": "1"},
Invokes: []string{"seat:x.v"}})
return raw
}
h.changed("main", "web", entry("src2:1"), false)
h.changed("beta", "web", entry("src2:2"), false)
if w.trunkOf("main")["web"].Fp2 != "src2:1" || w.trunkOf("beta")["web"].Fp2 != "src2:2" || woken != 2 {
t.Errorf("main %+v, beta %+v, woken %d: each branch keeps its own entry", w.trunkOf("main")["web"], w.trunkOf("beta")["web"], woken)
}
v := w.trunkOf("beta")["web"]
if got := w.manifest(v.Manifest); got.err != "" || got.manifest.Module != "web" {
t.Errorf("the entry's manifest is not read by its digest: %+v", got)
}
if n := w.graphOf("main")["web"]; n.FP != "src2:1" || !slices.Equal(n.Invokes, []string{"seat:x.v"}) {
t.Errorf("graph node %+v", n)
}
h.changed("beta", "web", nil, true)
if _, ok := w.trunkOf("beta")["web"]; ok || w.trunkOf("main")["web"].Fp2 == "" {
t.Error("a module taken out of beta's half leaves main's")
}
}
@@ -0,0 +1,95 @@
package main
import (
"context"
"fmt"
"sort"
"mesh-delivery-assigner/internal/buckets"
"mesh-delivery-assigner/internal/puts"
)
// placementBy is who a put the placement's build makes names as its asker: the placement, whose proposal the operator
// approved, is the warrant (design 47 *Going back is one act*; ADR 0666 §24).
const placementBy = "the placement"
// placementPuts makes each placed module's `build` a held put of that build, and releases a put the placement made once its
// placement names that build no more (design 47 line 912: "a return to an earlier build is one value in the placement, the
// module's `build` in its placement key, set by a placement proposal"; ADR 0666 §24; B-K1). The put then goes its own steps
// (the builder's build, the composition, an ask where the module's policy needs one, the want, holding) and is read with
// `assigner_puts` and released with `assigner_release` like any other; a placement's build the builder never built is refused
// by its build step, said in the put's words. A put asked by a person stands until the placement names another build.
func (p *passer) placementPuts(ctx context.Context) {
kv := p.kvs[buckets.Puts]
if kv == nil {
return
}
now := p.clock().UTC()
named := map[string]string{} // put key -> the build its placement names
for node, a := range p.w.assignments() {
for _, m := range a.Modules {
if m.Build == "" {
continue
}
if key, err := puts.Key(node, m.Module); err == nil {
named[key] = m.Build
}
}
}
held := p.w.read(buckets.Puts)
keys := make([]string, 0, len(named)+len(held))
for k := range named {
keys = append(keys, k)
}
for k := range held {
if _, there := named[k]; !there {
keys = append(keys, k)
}
}
sort.Strings(keys)
for _, key := range keys {
build, names := named[key]
var before *puts.Put
if raw, ok := held[key]; ok {
if got, err := puts.Decode(raw); err == nil {
before = &got
}
}
var next puts.Put
switch {
case names && before != nil && before.FP == build:
continue // made already, or a person's put of the same build: it goes its own steps, ended or not
case names:
words := fmt.Sprintf("asked by %s: it names build %s of %s", placementBy, shortFP(build), key)
if before != nil && !puts.Ended(before.Step) {
words += fmt.Sprintf("; it replaces the put of %s, which stood at %s", shortFP(before.FP), before.Step)
}
next = puts.Put{FP: build, Hold: true, Why: "the placement names this build (ADR 0666 §24)", By: placementBy, At: now}.Said(now, puts.Asked, words)
case before != nil && before.By == placementBy && !puts.Ended(before.Step):
next = before.Said(now, puts.Released, fmt.Sprintf("released by %s: it names no build of its own any more; the assigner's rules write the want again", placementBy))
default:
continue
}
data, err := puts.Encode(next)
if err != nil {
p.logf("the placement's put %s is not written: %v", key, err)
continue
}
if before == nil {
_, err = kv.Create(ctx, key, data)
} else if e, gerr := kv.Get(ctx, key); gerr != nil {
err = gerr
} else {
_, err = kv.Update(ctx, key, data, e.Revision())
}
if err != nil {
p.logf("the placement's put %s could not be written, and is written at the next pass: %v", key, err)
continue
}
p.w.put(buckets.Puts, key, data)
p.logf("put %s: %s: %s", key, next.Step, next.Words)
if p.acts != nil {
p.acts.say(key, next)
}
}
}
@@ -105,13 +105,13 @@ func (p *passer) puts(ctx context.Context) {
if p.acts == nil {
return
}
p.placementPuts(ctx)
all := p.putsByModule()
if len(all) == 0 {
return
}
assigned := p.w.assigned()
trunk := p.w.trunk()
get := p.w.getter(buckets.Manifests) // the indexer's manifests, as its first view and views family say them
wanted := p.w.wantsOf()
modules := make([]string, 0, len(all))
for m := range all {
@@ -121,7 +121,7 @@ func (p *passer) puts(ctx context.Context) {
for _, module := range modules {
var manifest catalogue.Manifest
if mod, ok := trunk[module]; ok && mod.Manifest != "" {
if got := p.w.manifest(ctx, get, mod.Manifest); got.err == "" {
if got := p.w.manifest(mod.Manifest); got.err == "" {
manifest = got.manifest
}
}
@@ -220,6 +220,9 @@ func (p *passer) putWorld(node, module string, put puts.Put, manifest catalogue.
}
}
}
if r, ok := p.w.runningOf(node, module); ok {
w.Running = &r
}
if hkey, err := nodeengineclient.HaveKey(node, module); err == nil {
if raw := p.w.value(buckets.Have, hkey); raw != nil {
if h, err := nodeengineclient.DecodeHave(raw); err == nil {
@@ -0,0 +1,88 @@
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/process"
"mesh-delivery-assigner/internal/assign"
"mesh-delivery-assigner/internal/assignment"
)
// What each node runs is heard, never asked (design 47 G §199, §309): the node-engine seat's event `running.<node>.<module>` is
// kept last per subject on its own stream, and the assigner's durable consumer on it starts from the last message of every
// subject, so a node away still has its last word here. The assigner counts a provider healthy by it (rule 5) and ends a put on it.
// hearRunning keeps each `running` the stream holds and each it says from now, and wakes the pass on every one.
func hearRunning(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)
cc, err := acct.HearSeatEvents(ctx, js, nodeengine.StreamRunning, func(m jetstream.Msg) {
if err := m.Ack(); err != nil {
logf("acknowledging %s: %v", m.Subject(), err)
}
if err := w.hearRunning(m.Subject(), m.Data()); err != nil {
logf("%v", err)
return
}
wake()
}, logf)
if err != nil {
nh.NotHeard(nodeengine.StreamRunning, "what each node runs is not heard until the assigner starts again, so a provider is counted healthy by its have and a put does not end", err)
return
}
w.mu.Lock()
w.runningHeard = true
w.mu.Unlock()
nh.Heard(nodeengine.StreamRunning)
go func() {
<-ctx.Done()
cc.Stop()
}()
}
// hearRunning keeps one `running` event said on subject.
func (w *world) hearRunning(subject string, data []byte) error {
var r nodeengine.Running
if err := json.Unmarshal(data, &r); err != nil {
return fmt.Errorf("%s holds what is not a running: %w", subject, err)
}
if r.Node == "" || r.Module == "" {
return fmt.Errorf("%s names no node or no module, so it is kept for none", subject)
}
w.mu.Lock()
w.running[r.Node+"."+r.Module] = r
w.mu.Unlock()
return nil
}
// runningOf is the last `running` heard of module on node.
func (w *world) runningOf(node, module string) (nodeengine.Running, bool) {
w.mu.Lock()
defer w.mu.Unlock()
r, ok := w.running[node+"."+module]
return r, ok
}
// runningNow is every `running` heard, by node and module; nil while the seat's events are not heard (the rules then read the
// have).
func (w *world) runningNow() map[string]map[string]nodeengine.Running {
w.mu.Lock()
defer w.mu.Unlock()
if !w.runningHeard {
return nil
}
out := map[string]map[string]nodeengine.Running{}
for _, r := range w.running {
if out[r.Node] == nil {
out[r.Node] = map[string]nodeengine.Running{}
}
out[r.Node][r.Module] = r
}
return out
}
@@ -16,7 +16,6 @@ import (
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
mdcc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-assigner/asks"
@@ -440,10 +439,10 @@ func (p *passer) mustAnswer(ctx context.Context, prop mma.Proposal, rev uint64,
func (p *passer) layAgain(ctx context.Context, prop mma.Proposal, rev uint64, current map[string]mma.Assignments, revs map[string]uint64, why string) {
held, recorded := p.lays(ctx)
again, laid, findings := assign.Propose(assign.ProposeIn{ID: prop.ID, By: prop.By, Why: prop.Why, At: prop.At, Changes: prop.Changes,
Current: current, Revisions: revs, Held: held, Recorded: recorded, SeatListRead: p.seats.read()})
Current: current, Revisions: revs, Held: held, Recorded: recorded, SeatListRead: p.seats.Heard()})
if slices.ContainsFunc(findings, plc.WaitsForSeatList) {
// ADR 0636 §1: nothing is ended on a list not read; the reader's change makes a pass, which lays it again.
p.logf("the proposal %s is not laid again yet: %s", prop.ID, p.seats.health())
p.logf("the proposal %s is not laid again yet: %s", prop.ID, p.seats.Health())
return
}
if len(findings) > 0 {
@@ -465,10 +464,8 @@ func (p *passer) layAgain(ctx context.Context, prop mma.Proposal, rev uint64, cu
// nodes with a record.
func (p *passer) lays(ctx context.Context) (func(string) bool, func(string) bool) {
held := map[string]bool{}
for key := range p.w.read(buckets.Modules) {
if name, err := meshcatalogclient.ParseModulesKey(key); err == nil {
held[name] = true
}
for name := range p.w.trunk() {
held[name] = true
}
records := p.w.records()
return func(m string) bool { return held[m] }, func(n string) bool { _, ok := records[n]; return ok }
@@ -19,6 +19,7 @@ import (
mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/seatlist"
"git.novox.be/novox/mesh-sdk/go/views"
"mesh-delivery-assigner/internal/assign"
@@ -86,7 +87,7 @@ type seatTools struct {
now func() time.Time
// seats is the seat list the bundle reads (seatlist.go): until it is read, propose judges no mesh seat the bootstrapped
// definitions do not name as held once (ADR 0636 §1). Nil reads nothing.
seatList *seatGate
seatList *seatlist.Part
}
func newSeatTools(st seatStore, ask func(string, any) (json.RawMessage, error)) *seatTools {
@@ -265,7 +266,7 @@ func (s *seatTools) doPropose(args map[string]any, by string) (any, error) {
enrollers := plc.EnrollersOf(current)
now := s.now()
p, laid, findings := assign.Propose(assign.ProposeIn{ID: plc.NewProposalID(now), By: by, Why: arg(args, "why"), At: now,
Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, Warrant: warrant, SeatListRead: s.seatList.read()})
Changes: changes, Current: current, Revisions: revs, Held: held, Recorded: recorded, Warrant: warrant, SeatListRead: s.seatList.Heard()})
if refusal := plc.AddNodeRefusal(p, moduleOfCaller(by, enrollers), enrollers); refusal != "" {
findings = append(findings, refusal)
}
@@ -257,7 +257,7 @@ func TestTheSeatsVerbs(t *testing.T) {
// Such a session is proved as its machine's tool runner (issue 627); the healers too, no other module.
for caller, refused := range map[string]bool{"person.jochen": false, "shanks.node-tools": false, "ace.mesh-healers": false,
"ace.messenger": true} {
if err := seatlist.Refusal(seatListAgainTool, seatListAgainCallers, caller); (err != nil) != refused {
if err := seatlist.Refusal("assigner_seat_list_again", seatListAgainCallers, caller); (err != nil) != refused {
t.Errorf("%s: refused=%v, err %v", caller, refused, err)
}
}
@@ -1,103 +1,13 @@
package main
import (
"errors"
"sync"
"sync/atomic"
import stdio "git.novox.be/novox/mesh-sdk/go"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatlist"
)
// The assigner reads the seat list as every part does (novox/hq ADR 0635 §1, ADR 0636 §1, §7; issue 1966): through the seat
// mesh-catalog's verb seat-definitions and its two events, from the bootstrapped definitions until the seat has answered. What
// the reader holds is given to catalogue at every change, which the check of a proposal and the seat-holders state read: which
// mesh seat is replicated, and which name is a seat's. Both of its runtimes hold one: the process (the proposals it lays again,
// the state seat-holders) over its connection and the event hub, and the tools' bundle (the seat's verb propose) over the
// runtime. Until the list is read, a mesh seat the bootstrapped definitions do not name is never judged held once
// The assigner reads the seat list as every part does, through mesh-sdk's one wiring (go/seatlist.Part; design 47 G §204): its
// reader over the seat mesh-catalog's verb seat-definitions and its two events, its tools `assigner_seat_list` and
// `assigner_seat_list_again`. What the reader holds is given to catalogue at every change, which the check of a proposal and the
// seat-holders state read; until the list is read, a mesh seat the bootstrapped definitions do not name is never judged held once
// (plc.CheckAssignments), and seat-holders is not written.
// Seat list tools (ADR 0636 §7): what the reader holds, and reading it again now, which every operator's session and the
// healers may ask.
const (
seatListTool = "assigner_seat_list"
seatListAgainTool = "assigner_seat_list_again"
)
// A call through the mesh MCP server is proved only as its machine's tool runner (<node>.node-tools, issue 627), so
// node-tools stands for an operator's session until a session is proved (issue 1966).
var seatListAgainCallers = []string{stdio.AnyPerson, "node-tools", "mesh-healers"}
// seatGate is the reader a runtime of the assigner judges by, set once it is started; before, the list counts as unread.
type seatGate struct {
reader atomic.Pointer[seatlist.Reader]
// waitSaid says the journal was told that seat-holders waits for the list, so it is told once per wait.
waitSaid sync.Once
}
// read says the seat mesh-catalog has answered the reader: the list is read (seatlist.Status's Heard). A nil gate, or one
// whose reader has not started, has read nothing.
func (g *seatGate) read() bool {
if g == nil {
return false
}
r := g.reader.Load()
return r != nil && r.Status().Heard
}
// health is why the list is not read, or empty.
func (g *seatGate) health() string {
if g == nil {
return "the assigner follows no seat list"
}
r := g.reader.Load()
if r == nil {
return "the seat list reader has not started yet"
}
return r.Status().Health()
}
// follow starts the reader over bus: what it holds is given to catalogue at once and at every change, and changed is called
// then (the process makes a pass, which lays again what waited).
func (g *seatGate) follow(bus seatlist.Bus, changed func(), logf func(string, ...any)) *seatlist.Reader {
reader, err := seatlist.Start(seatlist.Config{Bus: bus, Bootstrap: seatlist.Bootstrapped(),
OnChange: func(seats []catalogue.Seat) {
catalogue.UseSeats(seats)
if changed != nil {
changed()
}
}})
if err != nil {
logf("the assigner cannot follow its seat list, so a mesh seat the bootstrapped definitions do not name waits for it: %v", err)
}
if reader != nil {
catalogue.UseSeats(reader.Seats())
g.reader.Store(reader)
}
return reader
}
// eventsLive is called once the process's consumer on EVENTS is heard: the seat may have said it speaks before that consumer
// existed, whose events start at the stream's end, so while the list is unread it is read now (ADR 0636 §1).
func (g *seatGate) eventsLive() {
if r := g.reader.Load(); r != nil {
r.ReloadIfUnread()
}
}
// tools are the reader's two tools over whatever reader the gate holds at the call.
func (g *seatGate) tools() []stdio.Tool {
shape := seatlist.Tools(&seatlist.Reader{}, seatListTool, seatListAgainTool, seatListAgainCallers)
for i := range shape {
run := i
shape[i].Run = func(args map[string]any) (any, error) {
r := g.reader.Load()
if r == nil {
return nil, errors.New("the seat list reader has not started yet: the runtime has not served this module")
}
return seatlist.Tools(r, seatListTool, seatListAgainTool, seatListAgainCallers)[run].Run(args)
}
}
return shape
}
@@ -3,6 +3,9 @@ package main
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -50,21 +53,44 @@ func (standInCatalog) Subscribe(string, func(string, json.RawMessage) error) err
// gateOver is a seat gate following the stand-in, once its first read has ended. A read one gives catalogue the test list
// (catalogue.UseSeats), as a host's does, for the tests after it too: the list is the same everywhere.
func gateOver(t *testing.T, silent bool) *seatGate {
func gateOver(t *testing.T, silent bool) *seatlist.Part {
t.Helper()
if silent {
// A silent seat reached no one: its reader asks again within the bus's retry bound (issue 1966), which a test
// that waits for it to stay unread keeps short.
t.Cleanup(busretry.Waits(20*time.Millisecond, time.Millisecond, 5*time.Millisecond))
}
g := &seatGate{}
r := g.follow(standInCatalog{silent: silent}, nil, t.Logf)
g := seatlist.NewPart("assigner", seatListAgainCallers)
r := g.Follow(standInCatalog{silent: silent}, nil, t.Logf)
if r == nil {
t.Fatal("the reader did not start")
}
<-r.Ready()
if g.read() == silent {
t.Fatalf("the gate read %v over a stand-in silent %v: %s", g.read(), silent, g.health())
if g.Heard() == silent {
t.Fatalf("the gate read %v over a stand-in silent %v: %s", g.Heard(), silent, g.Health())
}
return g
}
// Design 47 G §204: the seat list's reader is wired once, by mesh-sdk's go/seatlist.Part; no part wires its own. A search of the
// assigner's code finds no start of a reader and no tools of its own.
func TestTheSeatListIsWiredByTheSDKAlone(t *testing.T) {
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, own := range []string{"seatlist.Start(", "seatlist.Tools(", "OnChange:"} {
if strings.Contains(string(raw), own) {
t.Errorf("%s wires the seat list itself (%s), where mesh-sdk's seatlist.Part does it", f, own)
}
}
}
}
@@ -386,15 +386,15 @@ func serveTools() error {
}
}()
// The seat list (seatlist.go), read through the runtime once Serve has its channel: the seat's verbs judge by it.
gate := &seatGate{}
gate := seatlist.NewPart("assigner", seatListAgainCallers)
go func() {
<-stdio.Served()
gate.follow(seatlist.StdioBus(), nil, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "mesh-delivery-assigner: "+f+"\n", a...) })
gate.Follow(seatlist.StdioBus(), nil, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "mesh-delivery-assigner: "+f+"\n", a...) })
}()
seatVerbs := newSeatTools(runtimeStore{}, stdio.Ask)
seatVerbs.seatList = gate
tools := append(s.tools(), seatVerbs.tools()...)
tools = append(tools, gate.tools()...)
tools = append(tools, gate.Tools()...)
wants := newWantsTools(runtimeStore{}, stdio.Ask, func(e, id string, b any) error {
// The seat's event, said on the seat: a claimed seat's event is never the module's own.
return stdio.SeatPublish(wantsEventSubject(e), b, id)
@@ -1,7 +1,6 @@
package main
import (
"context"
"mesh-delivery-assigner/internal/buckets"
"slices"
@@ -20,21 +19,8 @@ import (
// each kept edge until a build serving its verb runs healthy, read from the haves, the builder's builds and the indexer's
// manifests, as a provider is counted (ADR 0603 §5).
// moduleGraph is the module graph as the indexer's first view and views family last said it (ADR 0631); a key that
// does not decode is left out, so its module waits on no verb.
func (p *passer) moduleGraph() graph.Graph {
g := graph.Graph{}
for key, raw := range p.w.read(buckets.Graph) {
module, err := graph.ParseKey(key)
if err != nil {
continue
}
if n, err := graph.Decode(raw); err == nil {
g[module] = n
}
}
return g
}
// moduleGraph is the module graph of the catalog half of branch, from the entries' invokes and serves (design 47 G §184, §195).
func (p *passer) moduleGraph(branch string) graph.Graph { return p.w.graphOf(branch) }
// keptInvokes are the verbs of module the graph keeps as edges: each it invokes and does not leave out, that a module
// other than itself serves. A verb no module of the graph serves is no edge, so it holds nothing; nil when the graph holds
@@ -61,11 +47,11 @@ func keptInvokes(g graph.Graph, module string) []string {
// serves are served. A healthy build whose manifest cannot be read counts what the graph says its module serves, said in
// the log, so a build read late never holds its callers for good; nil when the graph holds nothing, and no want waits on
// a verb.
func (p *passer) served(ctx context.Context, get getter, g graph.Graph, n assign.Nodes) func(string) bool {
func (p *passer) served(g graph.Graph, n assign.Nodes) func(string) bool {
if len(g) == 0 {
return nil
}
served, unread := graph.Served(n.Haves, func(module, fp string) ([]string, bool) { return p.servesOf(ctx, get, module, fp) },
served, unread := graph.Served(n.Haves, func(module, fp string) ([]string, bool) { return p.servesOf(module, fp) },
func(module string) []string { return g[module].Serves })
if len(unread) > 0 {
p.logf("the manifest of the running build of %v cannot be read, so what the module graph says they serve is counted as served", unread)
@@ -77,7 +63,7 @@ func (p *passer) served(ctx context.Context, get getter, g graph.Graph, n assign
// §5, ADR 0614 §5): every running healthy have on an online node, its build's manifest read as servesOf reads it. A
// healthy build whose manifest cannot be read counts what its module's trunk manifest offers, so a build read late never
// holds a consumer for good.
func (p *passer) offering(ctx context.Context, get getter, trunk map[string]catalogue.Manifest, n assign.Nodes) func(string) bool {
func (p *passer) offering(trunk map[string]catalogue.Manifest, n assign.Nodes) func(string) bool {
offered := map[string]bool{}
for node, haves := range n.Haves {
if !n.Online[node] {
@@ -87,7 +73,7 @@ func (p *passer) offering(ctx context.Context, get getter, trunk map[string]cata
if h.Phase != nodeengineclient.PhaseRunning || h.Health != nodeengineclient.HealthOK {
continue
}
m, ok := p.manifestOf(ctx, get, module, h.Fp)
m, ok := p.manifestOf(module, h.Fp)
if !ok {
m = trunk[module]
}
@@ -100,8 +86,8 @@ func (p *passer) offering(ctx context.Context, get getter, trunk map[string]cata
}
// servesOf are the verbs the manifest of module's build at fp serves (graph.ServesOf), and whether it could be read.
func (p *passer) servesOf(ctx context.Context, get getter, module, fp string) ([]string, bool) {
m, ok := p.manifestOf(ctx, get, module, fp)
func (p *passer) servesOf(module, fp string) ([]string, bool) {
m, ok := p.manifestOf(module, fp)
if !ok {
return nil, false
}
@@ -110,7 +96,7 @@ func (p *passer) servesOf(ctx context.Context, get getter, module, fp string) ([
// manifestOf is the manifest of module's build at fp (the builder's build names its digest in the indexer's manifests),
// and whether it could be read.
func (p *passer) manifestOf(ctx context.Context, get getter, module, fp string) (catalogue.Manifest, bool) {
func (p *passer) manifestOf(module, fp string) (catalogue.Manifest, bool) {
key, err := builds.BuilderBuildKey(module, fp)
if err != nil {
return catalogue.Manifest{}, false
@@ -123,7 +109,7 @@ func (p *passer) manifestOf(ctx context.Context, get getter, module, fp string)
if err != nil || b.Manifest == "" {
return catalogue.Manifest{}, false
}
got := p.w.manifest(ctx, get, b.Manifest)
got := p.w.manifest(b.Manifest)
if got.err != "" {
return catalogue.Manifest{}, false
}
@@ -6,6 +6,7 @@ import (
"mesh-delivery-assigner/internal/assignment"
"mesh-delivery-assigner/internal/buckets"
"slices"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
@@ -20,7 +21,8 @@ import (
sdkfindings "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/findings"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient/heard"
"git.novox.be/novox/mesh-delivery/sdk/graph"
mcat "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"mesh-delivery-assigner/internal/assign"
plc "mesh-delivery-assigner/internal/assignment"
@@ -126,10 +128,11 @@ func viewFindings(f assign.Findings) views.FindingWriter {
// firstViewsBucket is the assigner's own state of its first views (views.StatusState).
var firstViewsBucket = assignment.Module + "_" + views.StatusState
// indexerViews are the assigner's first views of the indexer's manifests, modules and module graph (ADR 0615 §1,
// ADR 0621 §2, ADR 0631; DLV-F2r): asked at the start through the indexer's tools, then followed on indexer-views, asking once more on a gap;
// past the bound its finding names the indexer and the tool, cleared by the answer.
type indexerViews struct {
// catalogViews is the assigner's first view of the catalog half of each watched branch (design 47 G §14, §184, §195): asked
// at the start through the seat mesh-catalog's verb `revision` (the trunk's, then each branch a registration names), then
// followed on its event `catalog-revision`, asking once more on a gap; past the bound its finding names the seat and the verb,
// cleared by the answer. Its status is read by assigner_first_views and asked again by assigner_first_view_again.
type catalogViews struct {
js jetstream.JetStream
account process.Account
call func(string, any) (json.RawMessage, error)
@@ -137,21 +140,17 @@ type indexerViews struct {
status *process.AwaitedBucket
logf func(string, ...any)
hub *process.EventHub
}
// indexerFirstViews are the indexer's states the assigner takes by first view and the views family, each by the name of
// the indexer's bucket the world keeps it under: manifests and modules (ADR 0621 §2), and the module graph, whose verb
// edges a want waits on (ADR 0614 §2, §3; ADR 0631 §1).
var indexerFirstViews = map[string]string{"manifests": buckets.Manifests, "modules": buckets.Modules, graph.State: buckets.Graph}
h *halves
}
// firstViewBound and firstViewWait are each first view's bound before its condition is raised, and its first wait
// between two failed asks; zero is the views package's (views.Bound, two minutes, ADR 0615 §6; and a first wait of five
// seconds). A test shortens them.
var firstViewBound, firstViewWait time.Duration
// start asks the three first views, each answer taken into w under the indexer's bucket names and settled once (a pass waits
// for it), and hears the views family; it answers how many settles to wait for.
func (iv *indexerViews) start(ctx context.Context, w *world, wake func(), settled chan<- struct{}) int {
// start follows the trunk's catalog half and hears the seat's catalog-revision; it answers how many settles to wait for.
func (iv *catalogViews) start(ctx context.Context, w *world, wake func(), settled chan<- struct{}) int {
finding := func(s views.Status, raised bool) {
if iv.findings.Put == nil {
return
@@ -167,33 +166,28 @@ func (iv *indexerViews) start(ctx context.Context, w *world, wake func(), settle
iv.logf("views: the finding of the first view %s is not written: %v", s.Tool, err)
}
}
followers := map[string]*views.Follower{}
var all []*views.Follower
for state, bucket := range indexerFirstViews {
bucket := bucket
tool := buckets.IndexerModule + "." + state
f := &views.Follower{Owner: buckets.IndexerModule, Tool: tool, State: state, Ask: views.AskVia(iv.call, tool), Logf: iv.logf,
Bound: firstViewBound, Wait: firstViewWait,
Raise: func(s views.Status) { finding(s, true) }, Clear: func(s views.Status) { finding(s, false) },
Changed: func(key string, value json.RawMessage, deleted bool, at time.Time) {
if deleted {
value = nil
}
w.putEntry(bucket, key, value, 0, at)
wake()
}}
followers[state] = f
all = append(all, f)
go func() {
if f.Start(ctx) == nil {
settled <- struct{}{}
}
}()
var statusKV jetstream.KeyValue
var mu sync.Mutex
iv.h = &halves{w: w, call: iv.call, wake: wake, logf: iv.logf, ctx: ctx, settled: settled}
iv.h.started = func(c *mcat.Catalog) {
f := c.Follower
f.Bound, f.Wait = firstViewBound, firstViewWait
f.Raise, f.Clear = func(s views.Status) { finding(s, true) }, func(s views.Status) { finding(s, false) }
mu.Lock()
defer mu.Unlock()
if statusKV != nil {
f.Said = views.KeepStatus(ctx, statusKV, iv.logf)
}
}
if iv.status != nil {
iv.status.Ready(func(kv jetstream.KeyValue) {
for _, f := range all {
f.Said = views.KeepStatus(ctx, kv, iv.logf)
mu.Lock()
statusKV = kv
mu.Unlock()
var all []*views.Follower
for _, c := range iv.h.followers() {
c.Follower.Said = views.KeepStatus(ctx, kv, iv.logf)
all = append(all, c.Follower)
}
if err := views.HearAgain(ctx, kv, all, iv.logf); err != nil {
iv.logf("views: assigner_first_view_again is not heard (%v) until the assigner starts again", err)
@@ -202,16 +196,17 @@ func (iv *indexerViews) start(ctx context.Context, w *world, wake func(), settle
}
// The hub says when the bus made its consumer: a picture asked before then is asked again (views.Follower.ConsumerMade).
iv.hub.OnBound(func(created time.Time) {
for _, f := range all {
f.ConsumerMade(ctx, created)
for _, c := range iv.h.followers() {
c.Follower.ConsumerMade(ctx, created)
}
})
// Not heard, the indexer's views family is the finding family-not-heard until it binds (ADR 0615 §5): the handler is
// registered on the module's hub, which the assigner starts once every handler is.
_ = notHeardWriter(iv.findings, iv.logf).Bind(process.EventsStream, "the indexer's manifests, modules and graph are the first views' alone until the assigner starts again", func() error {
return iv.hub.Handle("mesh.mod."+buckets.IndexerModule+".event."+views.EventPrefix+"*", views.Hear(ctx, followers, iv.logf))
// Not heard, the catalog-revision is the finding family-not-heard until it binds (ADR 0615 §5): the handler is registered
// on the module's hub, which the assigner starts once every handler is.
_ = notHeardWriter(iv.findings, iv.logf).Bind(process.EventsStream, "the catalog half is the first views' alone until the assigner starts again", func() error {
return iv.hub.Handle(meshcatalog.SubjectCatalogRevisionAll, iv.h.hear)
})
return len(all)
iv.h.follow(trunkBranch)
return 1
}
// notHeardWriter writes the assigner's finding of a family it does not hear into its findings (heard.Writer); with no
@@ -1,7 +1,6 @@
package main
import (
"context"
"encoding/json"
"errors"
"maps"
@@ -14,16 +13,14 @@ import (
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
wdc "git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshwatchdogsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
mdc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/sdk/graph"
"git.novox.be/novox/mesh-delivery/sdk/trunk"
"git.novox.be/novox/mesh-sdk/go/keys"
"github.com/nats-io/nats.go/jetstream"
mmac "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-sdk/go/catalogue"
@@ -52,6 +49,14 @@ type world struct {
// sourceGroups is, by revision of the gate's source.main, the release (group) that moved it, from every revision its
// history keeps (watched with history): a build's source revision names the release that carries it (ADR 0601 §1).
sourceGroups map[uint64]string
// running is, by `<node>.<module>`, the last `running` the node-engine seat said of it, kept from its stream by the assigner's
// durable consumer (design 47 G §199, §309); runningHeard says that consumer is bound.
running map[string]nodeengine.Running
runningHeard bool
// modules and graphs are, by watched branch and module, the catalog half the assigner follows per branch (design 47 G §14,
// §184, §195), as the value and the graph node the rules read: written by putModule from the entries of meshcatalogclient.Catalog.
modules map[string]map[string]meshcatalog.ModulesByModuleValue
graphs map[string]map[string]graph.Node
}
// entryMeta is a value's revision in its bucket and when it was written.
@@ -70,7 +75,8 @@ type parsed struct {
func newWorld() *world {
return &world{values: map[string]map[string][]byte{}, manifests: map[string]parsed{}, healthy: map[string][]string{}, changes: map[string]uint64{},
meta: map[string]entryMeta{}, trunkBuilt: map[string]builds.BuildLogEntry{}, assignmentKeys: mmac.NewKeys(),
sourceGroups: map[uint64]string{}}
sourceGroups: map[uint64]string{}, running: map[string]nodeengine.Running{},
modules: map[string]map[string]meshcatalog.ModulesByModuleValue{}, graphs: map[string]map[string]graph.Node{}}
}
// putEntry is put, keeping the value's revision and the time it was written.
@@ -190,71 +196,16 @@ func (w *world) read(bucket string) map[string][]byte {
return out
}
// getter reads one key's value; errNotThere when the key does not exist.
type getter func(ctx context.Context, key string) ([]byte, error)
var errNotThere = errors.New("no such key")
// kvGetter reads keys of a bucket.
func kvGetter(kv jetstream.KeyValue) getter {
return func(ctx context.Context, key string) ([]byte, error) {
e, err := kv.Get(ctx, key)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil, errNotThere
}
if err != nil {
return nil, err
}
return e.Value(), nil
}
}
// getter reads a key of a bucket the world holds: errNotThere where it holds none.
func (w *world) getter(bucket string) getter {
return func(_ context.Context, key string) ([]byte, error) {
w.mu.Lock()
defer w.mu.Unlock()
v, ok := w.values[bucket][key]
if !ok {
return nil, errNotThere
}
return v, nil
}
}
// manifest reads the manifest of a digest from the indexer's bucket once and keeps it: a digest's manifest never changes.
func (w *world) manifest(ctx context.Context, get getter, digest string) parsed {
// manifest is the manifest of a digest the catalog half said (putModule keeps each once); one not said yet is not readable.
func (w *world) manifest(digest string) parsed {
w.mu.Lock()
p, ok := w.manifests[digest]
w.mu.Unlock()
if ok {
defer w.mu.Unlock()
if p, ok := w.manifests[digest]; ok {
return p
}
key, err := meshcatalogclient.ManifestsKey(digest)
if err != nil {
return parsed{err: err.Error()}
}
value, err := get(ctx, key)
if errors.Is(err, errNotThere) {
return parsed{err: "the indexer holds no manifest of digest " + digest + " yet"}
}
if err != nil {
return parsed{err: err.Error()}
}
m, err := meshcatalogclient.DecodeManifest(value)
if err != nil {
p = parsed{err: err.Error()}
} else if man, err := catalogue.Decode([]byte(m.Manifest)); err != nil {
// Decoded, never judged (novox/hq ADR 0578 §3): the indexer's judge judged it, and its verdict is taken from the
// module's value where it is read (world.mesh).
p = parsed{module: m.Module, err: "it does not decode: " + err.Error()}
} else {
p = parsed{module: m.Module, manifest: man}
}
w.mu.Lock()
w.manifests[digest] = p
w.mu.Unlock()
return p
return parsed{err: "the catalog half holds no manifest of digest " + digest + " yet"}
}
// assigned is each node's modules the seat mesh-module-assigner's assignments names, in name order (ADR 0549 §5, ADR 0567 §8): the
@@ -295,21 +246,74 @@ func (w *world) placedNodes() map[string]bool {
return out
}
// trunk is every module the indexer holds at the trunk, with its value.
// trunkOf is every module the catalog half of branch holds, with its value (design 47 G §184).
func (w *world) trunkOf(branch string) map[string]meshcatalog.ModulesByModuleValue {
w.mu.Lock()
defer w.mu.Unlock()
return maps.Clone(w.modules[branch])
}
// graphOf is the module graph of the catalog half of branch.
func (w *world) graphOf(branch string) graph.Graph {
w.mu.Lock()
defer w.mu.Unlock()
return graph.Graph(maps.Clone(w.graphs[branch]))
}
// branches are the watched branches the catalog half is held of, the trunk's first.
func (w *world) branches() []string {
w.mu.Lock()
defer w.mu.Unlock()
out := slices.Sorted(maps.Keys(w.modules))
if i := slices.Index(out, trunkBranch); i > 0 {
out = slices.Insert(slices.Delete(out, i, i+1), 0, trunkBranch)
}
return out
}
// trunk is every module any branch's catalog half holds, each with its value on the first branch that holds it (the trunk's
// first): the modules that exist, for the parts that ask which, not at which fingerprint a node runs it.
func (w *world) trunk() map[string]meshcatalog.ModulesByModuleValue {
out := map[string]meshcatalog.ModulesByModuleValue{}
for key, value := range w.read(buckets.Modules) {
name, err := meshcatalogclient.ParseModulesKey(key)
if err != nil {
continue
}
if m, err := meshcatalogclient.DecodeModule(value); err == nil {
out[name] = m
for _, b := range w.branches() {
for name, v := range w.trunkOf(b) {
if _, ok := out[name]; !ok {
out[name] = v
}
}
}
return out
}
// putModule takes one module of branch's catalog half in: its value, its graph node and its parsed manifest by the digest the
// value names.
func (w *world) putModule(branch, module string, v meshcatalog.ModulesByModuleValue, n graph.Node, m *parsed) {
w.mu.Lock()
defer w.mu.Unlock()
if w.modules[branch] == nil {
w.modules[branch], w.graphs[branch] = map[string]meshcatalog.ModulesByModuleValue{}, map[string]graph.Node{}
}
w.modules[branch][module], w.graphs[branch][module] = v, n
if m != nil && v.Manifest != "" {
w.manifests[v.Manifest] = *m
}
}
// dropModule takes a module out of branch's catalog half.
func (w *world) dropModule(branch, module string) {
w.mu.Lock()
defer w.mu.Unlock()
delete(w.modules[branch], module)
delete(w.graphs[branch], module)
}
// holdsBranch says whether the catalog half of branch has been taken in (even empty).
func (w *world) holdsBranch(branch string) bool {
w.mu.Lock()
defer w.mu.Unlock()
return w.modules[branch] != nil
}
// records is each node's record in its own have key (`<node>.node`): the nodes an assignments proposal is answered by,
// with those assignments names (ADR 0593 §8).
func (w *world) records() map[string]nodeengine.NodeRecord {
@@ -391,7 +395,7 @@ func (w *world) wantsOf() map[string]map[string]mwc.Want {
// nodes is what the six rules read of the nodes: presence, haves, and every provision's providers with their nodes.
func (w *world) nodes(manifests map[string]catalogue.Manifest, assigned map[string][]string) assign.Nodes {
n := assign.Nodes{Online: map[string]bool{}, Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{}, Providers: map[string]map[string][]string{},
n := assign.Nodes{Online: map[string]bool{}, Running: w.runningNow(), Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{}, Providers: map[string]map[string][]string{},
Reads: map[string][]string{}}
for key, value := range w.read(buckets.Presence) {
if node, online, ok := presenceOf(key, value); ok {
@@ -10,6 +10,7 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-grants/grantsstate"
"git.novox.be/novox/mesh-delivery/sdk/graph"
)
// fakeManifests is the indexer's manifests bucket in memory.
@@ -71,3 +72,12 @@ func theWorldCountsTheValuesAKeyTakes(t *testing.T) {
}
}
}
// putModuleRaw takes a module value encoded as the catalog's modules say it into the world as branch's catalog half holds it.
func putModuleRaw(w *world, branch, module string, raw []byte) {
v, err := meshcatalogclient.DecodeModule(raw)
if err != nil {
panic(err)
}
w.putModule(branch, module, v, graph.Node{FP: v.Fp2, Repository: v.Repository}, nil)
}
@@ -2,7 +2,6 @@ package assign
import (
"fmt"
"git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
"git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
mwc "git.novox.be/novox/mesh-delivery/contracts/go/clients/meshwantsclient"
@@ -50,7 +49,10 @@ type PutWorld struct {
SnapshotFailed string
// Have is what the node says it runs of the module; nil when it says nothing.
Have *nodeengine.HaveByNodeModuleValue
Now time.Time
// Running is the node-engine seat's last `running` of the module on the node; nil when it said none (design 47 G §309). A put
// ends on it, never on the have.
Running *nodeengine.Running
Now time.Time
}
// PutNext is what a step decides: the put as it stands after it, and the acts the pass does for it.
@@ -154,26 +156,18 @@ func StepPut(w PutWorld) PutNext {
return next
}
// applied is a wanted put once the node's have says how it went: holding or done when it runs the composition healthy.
// applied is a wanted put once the node says how it went: holding or done when the node-engine seat's `running` names the put's
// fingerprint (design 47, *Compositions, wants and the apply*: from G a put ends on `running.<node>.<module>` naming its
// fingerprint, never on the have). A `running` without a fingerprint, or one that says the module stopped, ends none. The have
// still says a failed health check in the put's words.
func applied(w PutWorld, p puts.Put) puts.Put {
h := w.Have
if h == nil || h.Fp != p.FP {
return p
}
// A put without a hold ends on the fingerprint alone: a node that composes its own digests no composition of the put's, and
// a put that waits for that one pins the module's want for good (issue 1966). A held put keeps its composition.
if !p.Hold && h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK {
if r := w.Running; r != nil && !r.Stopped && r.Fingerprint != "" && r.Fingerprint == p.FP {
if p.Hold {
return p.Said(w.Now, puts.Holding, fmt.Sprintf("%s runs %s at %s healthy; held there until released", w.Node, w.Module, short(p.FP)))
}
return p.Said(w.Now, puts.Done, fmt.Sprintf("%s runs %s at %s healthy; the rules write its want again", w.Node, w.Module, short(p.FP)))
}
if h.Composed != p.Composed {
return p
}
switch {
case h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK && p.Hold:
return p.Said(w.Now, puts.Holding, fmt.Sprintf("%s runs %s at %s healthy; held there until released", w.Node, w.Module, short(p.FP)))
case h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK:
return p.Said(w.Now, puts.Done, fmt.Sprintf("%s runs %s at %s healthy; the rules write its want again", w.Node, w.Module, short(p.FP)))
case failed(*h):
if h := w.Have; h != nil && h.Fp == p.FP && h.Composed == p.Composed && failed(*h) {
return p.Said(w.Now, p.Step, fmt.Sprintf("%s runs %s at %s and its health check failed: %s", w.Node, w.Module, short(p.FP), h.Detail))
}
return p
@@ -109,6 +109,10 @@ func (m Module) composition(node, fp string) (ComposedDigest, bool) {
type Nodes struct {
Online map[string]bool
Haves map[string]map[string]nodeengine.HaveByNodeModuleValue
// Running is, by node and module, the last `running` the node-engine seat said (design 47 G §309): a provider runs healthy
// while the build it runs does, as this says it, whatever a newer want of it waits for (rule 5, main file *A provider
// before its consumers*). Nil: the seat's events are not heard, and the have says it.
Running map[string]map[string]nodeengine.Running
// Providers is, by provision, the modules that provide it, with the nodes each is assigned to.
Providers map[string]map[string][]string
// Reads is, by node, the reader capabilities its node-engine states in its node record (nodeengine.NodeRecord.ManifestReads,
@@ -276,6 +280,17 @@ func (m Module) MissingProvisions(node string) []string {
return out
}
// runsHealthy says whether module runs healthy on node: from G the node-engine seat's last `running` of it names a build and
// does not say it stopped; with the seat's events not heard, the have's phase and health.
func (n Nodes) runsHealthy(node, module string) bool {
if n.Running != nil {
r, ok := n.Running[node][module]
return ok && !r.Stopped && r.Fingerprint != ""
}
h, ok := n.Haves[node][module]
return ok && h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK
}
// providedHealthy is rule 5 for one provision: some module providing it runs healthy on the node, or, assigned
// elsewhere only, at its canary. A provision no module provides is not a module's to judge (the mesh or the node
// offers it) and holds.
@@ -295,7 +310,7 @@ func providedHealthy(provision, node string, n Nodes) (bool, string) {
if !slices.Contains(nodes, node) {
at = Canary(nodes, n.Online)
}
if h, ok := n.Haves[at][p]; ok && at != "" && h.Phase == nodeengineclient.PhaseRunning && h.Health == nodeengineclient.HealthOK {
if at != "" && n.runsHealthy(at, p) {
return true, ""
}
}
@@ -923,7 +923,7 @@ func stepsOfAPut(t *testing.T) {
w := bus()
w.Put = w.Put.Said(now, puts.Wanted, "wanted")
w.Put.Composed = d.Digest
w.Have = &nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: d.Digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
w.Running = &nodeengine.Running{Node: "novox", Module: "nats", Fingerprint: fNew}
return w
}, puts.Holding, "held there until released", false, false, false},
{"wanted without hold: done", func() PutWorld {
@@ -931,7 +931,7 @@ func stepsOfAPut(t *testing.T) {
w.Put.Hold = false
w.Put = w.Put.Said(now, puts.Wanted, "wanted")
w.Put.Composed = d.Digest
w.Have = &nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: d.Digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
w.Running = &nodeengine.Running{Node: "novox", Module: "nats", Fingerprint: fNew}
return w
}, puts.Done, "the rules write its want again", false, false, false},
{"wanted without hold, the node composes its own: done at the fingerprint", func() PutWorld {
@@ -939,14 +939,27 @@ func stepsOfAPut(t *testing.T) {
w.Put.Hold = false
w.Put = w.Put.Said(now, puts.Wanted, "wanted")
w.Put.Composed = d.Digest
w.Have = &nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: dig(fOld), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
w.Running = &nodeengine.Running{Node: "novox", Module: "nats", Fingerprint: fNew}
return w
}, puts.Done, "the rules write its want again", false, false, false},
{"wanted with hold, the node composes its own: stays wanted", func() PutWorld {
{"wanted, the node's running names no fingerprint: stays wanted (the have runs it, which ends nothing)", func() PutWorld {
w := bus()
w.Put = w.Put.Said(now, puts.Wanted, "wanted")
w.Put.Composed = d.Digest
w.Have = &nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: dig(fOld), Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
w.Running = &nodeengine.Running{Node: "novox", Module: "nats"}
w.Have = &nodeengine.HaveByNodeModuleValue{Fp: fNew, Composed: d.Digest, Phase: nodeengineclient.PhaseRunning, Health: nodeengineclient.HealthOK}
return w
}, puts.Wanted, "wanted", false, false, false},
{"wanted, the node's running says it stopped: stays wanted", func() PutWorld {
w := bus()
w.Put = w.Put.Said(now, puts.Wanted, "wanted")
w.Running = &nodeengine.Running{Node: "novox", Module: "nats", Fingerprint: fNew, Stopped: true}
return w
}, puts.Wanted, "wanted", false, false, false},
{"wanted, the node's running names another build: stays wanted", func() PutWorld {
w := bus()
w.Put = w.Put.Said(now, puts.Wanted, "wanted")
w.Running = &nodeengine.Running{Node: "novox", Module: "nats", Fingerprint: fOld}
return w
}, puts.Wanted, "wanted", false, false, false},
{"released: nothing more", func() PutWorld { w := bus(); w.Put = w.Put.Said(now, puts.Released, "released"); return w }, puts.Released, "released", false, false, false},
@@ -1546,3 +1559,27 @@ func TestAHeldModuleWithNoWantIsNotOnNeitherList(t *testing.T) {
}
}
}
// Main file *A provider before its consumers* (design 47 G §309): from G a provider runs healthy while the build it runs is
// healthy, as the node-engine seat's `running` says it, whatever a newer want of it waits for (its have's phase waiting-yes).
func TestAProviderRunsHealthyByItsRunning(t *testing.T) {
waiting := nodeengine.HaveByNodeModuleValue{Fp: fOld, Phase: nodeengineclient.PhaseWaitingYes, Health: nodeengineclient.HealthOK}
for _, c := range []struct {
name string
running map[string]map[string]nodeengine.Running
ok bool
}{
{"its new want waits for a yes, its old build runs", map[string]map[string]nodeengine.Running{"novox": {"nats": {Node: "novox", Module: "nats", Fingerprint: fOld}}}, true},
{"its running build is stopped", map[string]map[string]nodeengine.Running{"novox": {"nats": {Node: "novox", Module: "nats", Stopped: true}}}, false},
{"it said no running", map[string]map[string]nodeengine.Running{"novox": {}}, false},
{"its running names no build", map[string]map[string]nodeengine.Running{"novox": {"nats": {Node: "novox", Module: "nats"}}}, false},
{"the seat's events are not heard: the have says it, and waiting-yes is not running", nil, false},
} {
n := Nodes{Online: map[string]bool{"novox": true}, Running: c.running,
Haves: map[string]map[string]nodeengine.HaveByNodeModuleValue{"novox": {"nats": waiting}},
Providers: map[string]map[string][]string{"bus-state": {"nats": {"novox"}}}}
if ok, words := providedHealthy("bus-state", "novox", n); ok != c.ok {
t.Errorf("%s: healthy = %v (%s), want %v", c.name, ok, words, c.ok)
}
}
}
@@ -129,6 +129,24 @@ func TestAssignments(t *testing.T) {
if !reflect.DeepEqual(client.Assigned(current["g14"]), []string{"zsh", "app"}) {
t.Error("laying changed the current assignments")
}
// Design 47 line 1051 (the Choices table: a module's watched branch and its build in the placement key, set by a
// proposal): a place change sets them, and a re-place naming none keeps the module's.
onBeta, f := LayChanges(current, []seat.ProposalChange{{Op: client.OpPlace, Node: "g14", Module: "zsh", Branch: "beta", Build: "b1"}})
again, g := LayChanges(onBeta, []seat.ProposalChange{{Op: client.OpPlace, Node: "g14", Module: "zsh", Ports: map[string]int64{"80": 8081}}})
if m := onBeta["g14"].Modules[0]; len(f) != 0 || m.Branch != "beta" || m.Build != "b1" {
t.Errorf("a place change's branch and build: %+v, %v", m, f)
}
if m := again["g14"].Modules[0]; len(g) != 0 || m.Branch != "beta" || m.Build != "b1" || m.Ports["80"] != 8081 {
t.Errorf("a re-place dropped the branch and build: %+v, %v", m, g)
}
if ChangesDigest(seat.Proposal{Changes: []seat.ProposalChange{{Op: client.OpPlace, Node: "g14", Module: "zsh", Branch: "beta"}}}) ==
ChangesDigest(seat.Proposal{Changes: []seat.ProposalChange{{Op: client.OpPlace, Node: "g14", Module: "zsh"}}}) {
t.Error("an ask bound to a place change's digest does not bind its branch")
}
if ChangesDigest(seat.Proposal{Changes: []seat.ProposalChange{{Op: client.OpPlace, Node: "g14", Module: "zsh", Build: "b1"}}}) ==
ChangesDigest(seat.Proposal{Changes: []seat.ProposalChange{{Op: client.OpPlace, Node: "g14", Module: "zsh"}}}) {
t.Error("an ask bound to a place change's digest does not bind its build (design 47 G 360)")
}
if fresh, f := LayChanges(current, []seat.ProposalChange{{Op: client.OpPlace, Node: "pi", Module: "zsh"}}); len(f) != 0 || fresh["pi"].Node != "pi" {
t.Errorf("a node with no key: %+v, %v", fresh, f)
}
@@ -29,6 +29,8 @@ type placedForm struct {
HasLayer bool `json:"has-layer,omitempty"`
MeshValues map[string]any `json:"mesh-wide-values,omitempty"`
Values map[string]any `json:"values,omitempty"`
Branch string `json:"branch,omitempty"`
Build string `json:"build,omitempty"`
}
type holdingForm struct {
@@ -110,6 +112,8 @@ type changeForm struct {
Whole *assignmentsForm `json:"whole,omitempty"`
Decided *decidedForm `json:"decided,omitempty"`
Keys *joiningForm `json:"keys,omitempty"`
Branch string `json:"branch,omitempty"`
Build string `json:"build,omitempty"`
}
func providerOf(p seat.Provider) providerForm { return providerForm{Node: p.Node, Module: p.Module} }
@@ -150,7 +154,7 @@ func assignmentsOf(p seat.Assignments) assignmentsForm {
}
for _, m := range p.Modules {
out.Modules = append(out.Modules, placedForm{Module: m.Module, Ports: m.Ports, Bindings: providersOf(m.Bindings),
MeshWide: m.MeshWide, HasMesh: m.HasMeshWide, Layer: m.Layer, HasLayer: m.HasLayer, MeshValues: m.MeshWideValues, Values: m.Values})
MeshWide: m.MeshWide, HasMesh: m.HasMeshWide, Layer: m.Layer, HasLayer: m.HasLayer, MeshValues: m.MeshWideValues, Values: m.Values, Branch: m.Branch, Build: m.Build})
}
for _, h := range p.Holds {
out.Holds = append(out.Holds, holdingForm{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
@@ -161,7 +165,7 @@ func assignmentsOf(p seat.Assignments) assignmentsForm {
func changeOf(c seat.ProposalChange) changeForm {
out := changeForm{Op: c.Op, Node: c.Node, Module: c.Module, Ports: c.Ports, Bindings: providersOf(c.Bindings), Provision: c.Provision,
Claim: c.Claim, Scope: c.Scope, Kind: c.Kind, Site: c.Site, Field: c.Field, MeshWide: c.MeshWide, Layer: c.Layer,
MeshValues: c.MeshWideValues, Values: c.Values}
MeshValues: c.MeshWideValues, Values: c.Values, Branch: c.Branch, Build: c.Build}
if c.Provider != nil {
p := providerOf(*c.Provider)
out.Provider = &p
@@ -148,11 +148,19 @@ func LayChanges(current map[string]seat.Assignments, changes []seat.ProposalChan
}
continue
case client.OpPlace:
m := seat.PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings}
// A module's watched branch and its build are in the placement key, set by a proposal (design 47 line 1051,
// the Choices table's row): the change's when it names them, else kept from the module placed there.
m := seat.PlacedModule{Module: c.Module, Ports: c.Ports, Bindings: c.Bindings, Branch: c.Branch, Build: c.Build}
if at >= 0 {
o := p.Modules[at]
m.MeshWide, m.HasMeshWide, m.Layer, m.HasLayer = o.MeshWide, o.HasMeshWide, o.Layer, o.HasLayer
m.MeshWideValues, m.Values = o.MeshWideValues, o.Values
if m.Branch == "" {
m.Branch = o.Branch
}
if m.Build == "" {
m.Build = o.Build
}
p.Modules[at] = m
} else {
p.Modules = append(p.Modules, m)
@@ -123,12 +123,11 @@ func TestManifest(t *testing.T) {
}
// ADR 0618 §4: and it hears each node's engine say its reads changed; ADR 0636 §4 (issue 1966): and the seat list's two events;
// issue 1966 note 151: and the teller's ask-ended, its subject the one the assigner hears.
if !slices.Equal(m.Consumes, []string{"mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", "mesh-delivery-indexer.views.graph", "mesh-delivery-indexer.views.manifests", "mesh-delivery-indexer.views.modules", buckets.TellerModule + "." + tellerevents.AskEnded, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed"}) {
t.Errorf("consumes = %v, want the indexer's views of graph, manifests and modules, the teller's ask-ended and the engines' reads changed", m.Consumes)
if !slices.Equal(m.Consumes, []string{"mesh-catalog.catalog-revision", "mesh-catalog.registration-changed", "mesh-catalog.seat-definition-changed", "mesh-catalog.seat-definition-removed", buckets.TellerModule + "." + tellerevents.AskEnded, "mesh-pipeline-runner.pipeline-ended", "mesh-pipeline-runner.group-ended", "node-engine.manifest-reads-changed", "node-engine.running"}) {
t.Errorf("consumes = %v, want the catalog half's revisions (design 47 G §195), the teller's ask-ended, the engines' reads changed and what they run", m.Consumes)
}
if want := []string{"mesh-delivery-indexer.graph", "mesh-delivery-indexer.manifests",
"seat:mesh-catalog.revision", // ADR 0663 §2: which modules exist
"seat:mesh-catalog.seat-definitions", // ADR 0636 §4: the seat list
if want := []string{"mesh-delivery-indexer.manifests", "mesh-delivery-indexer.modules",
"seat:mesh-catalog.registrations", "seat:mesh-catalog.revision", "seat:mesh-catalog.seat-definitions", // ADR 0636 §4: the seat list
"seat:mesh-hand-acts.hand-act", "seat:mesh-hand-acts.hand-acts", "seat:mesh-hand-acts.warranted", // ADR 0565 §9, §11: the seat alone
"seat:node-backup.backed-up", "seat:node-backup.now",
"seat:node-engine.manifest-reads", // ADR 0618 §3
+6 -5
View File
@@ -39,19 +39,20 @@
"operator-channel"
],
"consumes": [
"mesh-catalog.catalog-revision",
"mesh-catalog.registration-changed",
"mesh-catalog.seat-definition-changed",
"mesh-catalog.seat-definition-removed",
"mesh-delivery-indexer.views.graph",
"mesh-delivery-indexer.views.manifests",
"mesh-delivery-indexer.views.modules",
"mesh-delivery-teller.ask-ended",
"mesh-pipeline-runner.pipeline-ended",
"mesh-pipeline-runner.group-ended",
"node-engine.manifest-reads-changed"
"node-engine.manifest-reads-changed",
"node-engine.running"
],
"invokes": [
"mesh-delivery-indexer.graph",
"mesh-delivery-indexer.manifests",
"mesh-delivery-indexer.modules",
"seat:mesh-catalog.registrations",
"seat:mesh-catalog.revision",
"seat:mesh-catalog.seat-definitions",
"seat:mesh-hand-acts.hand-act",
@@ -12,6 +12,7 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"mesh-delivery-builder/internal/build"
@@ -45,12 +46,14 @@ type reservations struct {
bound time.Duration
wake func(context.Context)
logf func(string, ...any)
// call asks a seat's verb: the git seat's tag (design 47 G §370).
call toolCaller
}
// newReservations makes the reserving over store; the released versions of a module are asked of the seat mesh-catalog's
// verb versions through call (design 47 G §348), the only source numbering reads them from.
func newReservations(store versions.ReserveStore, call toolCaller, bounds func() jetstream.KeyValue, found *findings, wake func(context.Context), logf func(string, ...any)) *reservations {
rv := &reservations{bounds: bounds, found: found, bound: versions.UnreleasedBound, wake: wake, logf: logf}
rv := &reservations{call: call, bounds: bounds, found: found, bound: versions.UnreleasedBound, wake: wake, logf: logf}
rv.r = &versions.Reserver{Store: store, Filled: rv.filled.Load, ReleasedOf: func(_ context.Context, module string) ([]string, error) {
raw, err := call("seat:"+meshcatalog.Seat+".versions", meshcatalog.VersionsInput{Module: module})
if err != nil {
@@ -73,8 +76,8 @@ var _ build.Reservations = (*reservations)(nil)
func (rv *reservations) Filled() bool { return rv.filled.Load() }
func (rv *reservations) Reserve(ctx context.Context, module, series string, artifacts []string) (versions.ReservedVersion, error) {
return rv.r.Reserve(ctx, module, series, artifacts)
func (rv *reservations) Reserve(ctx context.Context, module, series string, artifacts []string, src versions.Source) (versions.ReservedVersion, error) {
return rv.r.Reserve(ctx, module, series, artifacts, src)
}
// Built records the digests; once every artifact has one, the publish's bound starts (a key of the state bounds with its
@@ -165,6 +168,13 @@ func (rv *reservations) heard(ctx context.Context, m jetstream.Msg) bool {
rv.logf("%s holds what is no version-released, left: %v", m.Subject(), err)
break
}
if err := rv.tagged(ctx, ev); err != nil {
rv.logf("%v; heard again", err)
if nakErr := m.Nak(); nakErr != nil {
rv.logf("leaving %s for the bus to give again: %v", m.Subject(), nakErr)
}
return true
}
gone, err := rv.r.Released(ctx, ev.Module, ev.Version)
if err != nil {
rv.logf("%v; heard again", err)
@@ -206,3 +216,30 @@ func (rv *reservations) dropped(ctx context.Context, module, series, by, why str
rv.found.clear(ctx, versions.VersionUnreleased, meshconditionsclient.ScopeModule, module)
return fmt.Sprintf("the reservation of %s %s is dropped (%s, %s)", module, series, by, why), nil
}
// tagged writes the git tag of a released version (design 47 G §370): on the catalog's version-released, through the seat
// git's verb tag, at the commit the reservation of that version was made from. A version with no reservation (a superseded
// build's version is never released, and the key of a version released by another builder is gone) or one that names no
// tag (a module with no Go package) is tagged nowhere. A tag the forge could not write is an error, the message heard again.
func (rv *reservations) tagged(ctx context.Context, ev meshcatalog.VersionReleasedEvent) error {
res, found, err := rv.r.Of(ctx, ev.Module, ev.Version)
if err != nil {
return fmt.Errorf("the reservation of %s %s cannot be read to tag it: %w", ev.Module, ev.Version, err)
}
if !found || res.Tag == "" || res.Commit == "" {
return nil
}
raw, err := rv.call("seat:"+git.Seat+".tag", git.TagInput{Repository: res.Repository, Commit: res.Commit, Tag: res.Tag})
if err != nil {
return fmt.Errorf("the tag %s of %s is not written: %w", res.Tag, ev.Module, err)
}
var a git.TagAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return fmt.Errorf("the git seat answered what is no tag answer for %s: %w", res.Tag, err)
}
if a.Error != "" || !a.Performed {
return fmt.Errorf("the tag %s of %s is not written: %s (%s)", res.Tag, ev.Module, a.Error, a.ErrorKind)
}
rv.logf("%s is tagged %s at %s", ev.Module, res.Tag, res.Commit)
return nil
}
@@ -10,6 +10,7 @@ import (
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshconditionsclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"mesh-delivery-builder/internal/versions"
@@ -85,14 +86,14 @@ func TestTheCatalogsWordsMoveTheReservations(t *testing.T) {
}
rv := newReservations(&memReserve{vals: map[string][]byte{}, revs: map[string]uint64{}}, call, func() jetstream.KeyValue { return bounds }, found,
func(context.Context) { woke++ }, t.Logf)
if _, err := rv.Reserve(ctx, "m", "1.4", []string{"m:a"}); err == nil || rv.Filled() {
if _, err := rv.Reserve(ctx, "m", "1.4", []string{"m:a"}, versions.Source{}); err == nil || rv.Filled() {
t.Fatalf("a version was reserved before versions-filled (%v)", err)
}
filled := &wakeMsg{subject: meshcatalog.SubjectVersionsFilled, data: []byte(`{"v":1}`)}
if !rv.heard(ctx, filled) || !filled.acked || !rv.Filled() || woke != 1 {
t.Fatalf("versions-filled: acked %v, filled %v, woke %d", filled.acked, rv.Filled(), woke)
}
res, err := rv.Reserve(ctx, "m", "1.4", []string{"m:a", "m:b"})
res, err := rv.Reserve(ctx, "m", "1.4", []string{"m:a", "m:b"}, versions.Source{})
if err != nil || res.Version != "1.4.3" {
t.Fatalf("reserved %q (%v), want 1.4.3 after the catalog's 1.4.2", res.Version, err)
}
@@ -119,7 +120,7 @@ func TestTheCatalogsWordsMoveTheReservations(t *testing.T) {
if all, _ := rv.r.All(ctx); len(all) != 0 || bounds.keys[boundKey] || len(st.vals) != 0 {
t.Errorf("after version-released: reservations %v, bounds %v, findings %v; want none", all, bounds.keys, st.vals)
}
if _, err := rv.Reserve(ctx, "m", "1.4", []string{"m:a"}); err != nil {
if _, err := rv.Reserve(ctx, "m", "1.4", []string{"m:a"}, versions.Source{}); err != nil {
t.Fatal(err)
}
if answer, err := rv.dropped(ctx, "m", "1.4", "op", "never released", time.Unix(0, 0)); err != nil || !strings.Contains(answer, "dropped") {
@@ -129,3 +130,64 @@ func TestTheCatalogsWordsMoveTheReservations(t *testing.T) {
t.Error("a reservation that is gone was dropped again")
}
}
// TestAReleasedVersionIsTaggedAtTheCommitItWasBuiltFrom: design 47 G §370 — the git tag go/vMAJOR.MINOR.PATCH is written through
// the seat git's tag on the catalog's version-released, at the commit the version's reservation was made from; a module with no
// Go package, and a version never released (a superseded build's), are tagged nowhere; a tag the forge did not write leaves
// version-released to be heard again with the reservation standing.
func TestAReleasedVersionIsTaggedAtTheCommitItWasBuiltFrom(t *testing.T) {
ctx := context.Background()
var tags []git.TagInput
refuse := ""
call := func(addr string, body any) (json.RawMessage, error) {
if addr != "seat:git.tag" {
return json.Marshal(meshcatalog.VersionsAnswer{})
}
tags = append(tags, body.(git.TagInput))
return json.Marshal(git.TagAnswer{Performed: refuse == "", Error: refuse})
}
found := &findings{store: &memFindings{vals: map[string][]byte{}}, logf: t.Logf, now: func() time.Time { return time.Unix(0, 0) }}
rv := newReservations(&memReserve{vals: map[string][]byte{}, revs: map[string]uint64{}}, call, func() jetstream.KeyValue { return &boundsKV{keys: map[string]bool{}} }, found, nil, t.Logf)
rv.filled.Store(true)
goPkg := versions.Source{Repository: "o/r", Commit: "c0ffee", GoPackage: true}
if _, err := rv.Reserve(ctx, "pkg", "0.1", []string{"pkg:lib"}, goPkg); err != nil {
t.Fatal(err)
}
if _, err := rv.Reserve(ctx, "prog", "2.0", []string{"prog:bin"}, versions.Source{Repository: "o/p", Commit: "beef"}); err != nil {
t.Fatal(err)
}
release := func(module, version string) *wakeMsg {
m := &wakeMsg{subject: meshcatalog.SubjectVersionReleased, data: []byte(`{"v":1,"module":"` + module + `","version":"` + version + `","source":1,"artifacts":[]}`)}
rv.heard(ctx, m)
return m
}
if m := release("prog", "2.0.0"); !m.acked || len(tags) != 0 {
t.Fatalf("a module with no Go package: acked %v, tags %v; want none written", m.acked, tags)
}
refuse = "forge down"
if m := release("pkg", "0.1.0"); !m.naked || m.acked {
t.Fatalf("a tag the forge did not write: naked %v acked %v; want the release heard again", m.naked, m.acked)
}
if all, _ := rv.r.All(ctx); len(all) != 1 {
t.Fatalf("the reservation went with an unwritten tag: %v", all)
}
refuse = ""
tags = nil
if m := release("pkg", "0.1.0"); !m.acked || len(tags) != 1 || tags[0] != (git.TagInput{Repository: "o/r", Commit: "c0ffee", Tag: "go/v0.1.0"}) {
t.Fatalf("the release: acked %v, tags %+v; want go/v0.1.0 at c0ffee of o/r", m.acked, tags)
}
tags = nil
if _, err := rv.Reserve(ctx, "pkg", "0.1", []string{"pkg:lib"}, goPkg); err != nil { // build A: 0.1.0 (the stub catalog holds none released)
t.Fatal(err)
}
if v, err := rv.Reserve(ctx, "pkg", "0.1", []string{"pkg:lib"}, versions.Source{Repository: "o/r", Commit: "b0b", GoPackage: true}); err != nil || v.Version != "0.1.1" { // build B supersedes A
t.Fatalf("second build reserved %q (%v)", v.Version, err)
}
release("pkg", "0.1.0") // A's version is never released in fact; were it said, it is no reservation of the key
if len(tags) != 0 {
t.Fatalf("a superseded build's version was tagged: %+v", tags)
}
if release("pkg", "0.1.1"); len(tags) != 1 || tags[0].Commit != "b0b" {
t.Fatalf("the newer build's version: tags %+v; want go/v0.1.1 at b0b", tags)
}
}
+2
View File
@@ -11,6 +11,8 @@ require (
git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshwatchdogs 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/contracts/go/seats/git v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshbuilds v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/containerimageregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry v0.0.0-00010101000000-000000000000
git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog v0.0.0-00010101000000-000000000000
@@ -3,6 +3,7 @@ package build
import (
"context"
"fmt"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"strconv"
"strings"
"time"
@@ -148,7 +149,7 @@ type Reservations interface {
// Filled says the catalog said versions-filled: nothing is reserved before it.
Filled() bool
// Reserve reserves module's next version in series for the artifacts (their references, <module>:<artifact>).
Reserve(ctx context.Context, module, series string, artifacts []string) (versions.ReservedVersion, error)
Reserve(ctx context.Context, module, series string, artifacts []string, src versions.Source) (versions.ReservedVersion, error)
// Built records the digests of the artifacts built under the reserved version, by reference.
Built(ctx context.Context, module, version string, digests map[string]string) error
}
@@ -184,12 +185,16 @@ func (b *Builder) waitFill(module string, m meshcatalog.ModulesByModuleValue) bo
// reserve reserves the version of the build that starts; a reservation that cannot be made is said and the build goes on
// unreserved, as an unnumbered build does.
func (b *Builder) reserve(ctx context.Context, module string, plan Plan) (versions.ReservedVersion, bool) {
func (b *Builder) reserve(ctx context.Context, module string, m meshcatalog.ModulesByModuleValue, plan Plan) (versions.ReservedVersion, bool) {
refs := make([]string, 0, len(plan.Tasks))
for _, t := range plan.Tasks {
refs = append(refs, module+":"+t.Artifact)
}
res, err := b.Reserved.Reserve(ctx, module, plan.Series, refs)
src := versions.Source{Repository: m.Repository, Commit: m.Commit}
for _, t := range plan.Tasks {
src.GoPackage = src.GoPackage || (t.Kind == catalogue.ArtifactPackage && t.Language == "go")
}
res, err := b.Reserved.Reserve(ctx, module, plan.Series, refs, src)
if err != nil {
b.logf("%s: no version is reserved for this build: %v", module, err)
return versions.ReservedVersion{}, false
@@ -23,7 +23,7 @@ type fakeReservations struct {
func (f *fakeReservations) Filled() bool { return f.filled }
func (f *fakeReservations) Reserve(_ context.Context, module, series string, artifacts []string) (versions.ReservedVersion, error) {
func (f *fakeReservations) Reserve(_ context.Context, module, series string, artifacts []string, _ versions.Source) (versions.ReservedVersion, error) {
f.reserved, f.series = append(f.reserved, artifacts), append(f.series, series)
return versions.ReservedVersion{Module: module, Series: series, Version: series + ".5"}, nil
}
@@ -536,7 +536,7 @@ func (b *Builder) try(ctx context.Context, module string, m meshcatalog.ModulesB
var reserved versions.ReservedVersion
hasReserved := false
if reserving && cl.Build.State != builds.BuildDone {
reserved, hasReserved = b.reserve(ctx, module, plan)
reserved, hasReserved = b.reserve(ctx, module, m, plan)
}
var result builds.BuilderBuild
if nothing {
@@ -49,6 +49,18 @@ type ReservedVersion struct {
// Published are the artifacts a registry seat said published at this version with the built digest.
Published map[string]bool `json:"published,omitempty"`
At time.Time `json:"at"`
// Repository and Commit are what the build was made from, and Tag the git tag its release is written as (a Go
// package's, GoTagOf): empty for a module with no Go package (design 47 G §370).
Repository string `json:"repository,omitempty"`
Commit string `json:"commit,omitempty"`
Tag string `json:"tag,omitempty"`
}
// Source is what a build reserving a version is made from (design 47 G §370): the repository and commit the git tag is
// written at, and whether the module has a Go package, whose version is tagged.
type Source struct {
Repository, Commit string
GoPackage bool
}
// ReservedKey is the key of module's reservation of series (MAJOR.MINOR).
@@ -96,7 +108,7 @@ func SplitVersion(version string) (series string, patch int, err error) {
// Reserve reserves the next version of module's series for a build that starts, for artifacts (their names), and answers
// it. The next patch is the one after the higher of the highest released and the one reserved, 0 when there is neither. A
// conflicting write (another build reserved first) is read again and the next taken.
func (r *Reserver) Reserve(ctx context.Context, module, series string, artifacts []string) (ReservedVersion, error) {
func (r *Reserver) Reserve(ctx context.Context, module, series string, artifacts []string, src Source) (ReservedVersion, error) {
if r.Filled != nil && !r.Filled() {
return ReservedVersion{}, ErrNotFilled
}
@@ -122,7 +134,11 @@ func (r *Reserver) Reserve(ctx context.Context, module, series string, artifacts
next = p
}
}
out := ReservedVersion{V: 1, Module: module, Series: series, Version: fmt.Sprintf("%s.%d", series, next+1), Artifacts: map[string]string{}, At: r.now()}
out := ReservedVersion{V: 1, Module: module, Series: series, Version: fmt.Sprintf("%s.%d", series, next+1), Artifacts: map[string]string{}, At: r.now(),
Repository: src.Repository, Commit: src.Commit}
if src.GoPackage {
out.Tag = GoTagOf(out.Version)
}
for _, a := range artifacts {
out.Artifacts[a] = ""
}
@@ -46,7 +46,7 @@ func TestReservation(t *testing.T) {
arts := []string{"m:a", "m:b"}
filled = false
if _, err := r.Reserve(ctx, "m", "0.1", arts); !errors.Is(err, ErrNotFilled) {
if _, err := r.Reserve(ctx, "m", "0.1", arts, Source{}); !errors.Is(err, ErrNotFilled) {
t.Fatalf("before versions-filled: %v, want ErrNotFilled", err)
}
if got, _ := r.All(ctx); len(got) != 0 {
@@ -54,25 +54,25 @@ func TestReservation(t *testing.T) {
}
filled = true
first, err := r.Reserve(ctx, "m", "0.1", arts)
first, err := r.Reserve(ctx, "m", "0.1", arts, Source{})
if err != nil || first.Version != "0.1.20" {
t.Fatalf("first build reserves %q (%v), want 0.1.20 after the highest released 0.1.19", first.Version, err)
}
second, err := r.Reserve(ctx, "m", "0.1", arts)
second, err := r.Reserve(ctx, "m", "0.1", arts, Source{})
if err != nil || second.Version != "0.1.21" {
t.Fatalf("second build reserves %q (%v), want 0.1.21: never the first's", second.Version, err)
}
// Two builds at once: the rival reserves between this one's read and write, and this one takes the next.
store.onUpdate = func() {
if _, err := r.Reserve(ctx, "m", "0.1", arts); err != nil {
if _, err := r.Reserve(ctx, "m", "0.1", arts, Source{}); err != nil {
t.Error(err)
}
}
third, err := r.Reserve(ctx, "m", "0.1", arts)
third, err := r.Reserve(ctx, "m", "0.1", arts, Source{})
if err != nil || third.Version != "0.1.23" {
t.Fatalf("a build that lost the compare-and-set reserves %q (%v), want 0.1.23", third.Version, err)
}
if v, _ := r.Reserve(ctx, "m", "0.3", arts); v.Version != "0.3.0" {
if v, _ := r.Reserve(ctx, "m", "0.3", arts, Source{}); v.Version != "0.3.0" {
t.Errorf("a series with nothing released reserves %q, want 0.3.0", v.Version)
}
@@ -103,7 +103,7 @@ func TestReservation(t *testing.T) {
if all, _ := r.All(ctx); len(all) != 0 {
t.Errorf("reservations left: %v", all)
}
if again, _ := r.Reserve(ctx, "m", "0.1", arts); again.Version != "0.1.20" {
if again, _ := r.Reserve(ctx, "m", "0.1", arts, Source{}); again.Version != "0.1.20" {
t.Logf("with the catalog list unchanged the next is %s", again.Version)
}
if ok, _ := r.Drop(ctx, "m", "0.1"); !ok {
@@ -118,7 +118,7 @@ func TestReservationIsReadAndMarkedPublished(t *testing.T) {
ctx := context.Background()
store := &reserveMem{mem: newMem()}
r := &Reserver{Store: store, ReleasedOf: func(context.Context, string) ([]string, error) { return []string{"1.4.2"}, nil }}
v, err := r.Reserve(ctx, "m", "1.4", []string{"m:a", "m:b"})
v, err := r.Reserve(ctx, "m", "1.4", []string{"m:a", "m:b"}, Source{})
if err != nil || v.Version != "1.4.3" {
t.Fatalf("reserved %q (%v), want 1.4.3", v.Version, err)
}
@@ -0,0 +1,4 @@
package versions
// GoTagOf is the git tag a Go package's released version is written as (design 47 G §370, design 51 *The versions*).
func GoTagOf(version string) string { return "go/v" + version }
@@ -132,6 +132,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1
// The watchdogs seat's presence says a node's machine is heard (issue 1966, ADR 0596 §2, ADR 0668 §3).
if want := []string{"mesh-delivery-assigner.assigner_read_assignments", "mesh-delivery-forge-bridge.bridge_tags", "mesh-delivery-indexer.indexer_read_candidates", "mesh-delivery-indexer.indexer_read_manifests",
"mesh-delivery-indexer.indexer_read_modules", "mesh-delivery-indexer.indexer_read_packages", "mesh-delivery-indexer.indexer_requirements", "seat:container-image-registry.remove", "seat:container-image-registry.versions",
"seat:git.tag",
"seat:go-package-registry.remove", "seat:go-package-registry.versions", "seat:mesh-artifact-sweep.held", "seat:mesh-catalog.versions", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence", "seat:node-build-go.drop", "seat:node-build-image.drop", "seat:node-build-node.drop", "seat:node-engine.manifest-reads", "seat:npm-package-registry.remove",
"seat:npm-package-registry.versions", "seat:oci-artifact-registry.remove", "seat:oci-artifact-registry.versions"}; !reflect.DeepEqual(sorted(m.Invokes), want) || len(m.Config) != 0 {
t.Errorf("invokes = %v, config = %v, want %v and no config", m.Invokes, m.Config, want)
@@ -77,6 +77,7 @@
"mesh-delivery-indexer.indexer_requirements",
"seat:container-image-registry.remove",
"seat:container-image-registry.versions",
"seat:git.tag",
"seat:go-package-registry.remove",
"seat:go-package-registry.versions",
"seat:mesh-artifact-sweep.held",
@@ -14,7 +14,7 @@ import (
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
"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/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
stdio "git.novox.be/novox/mesh-sdk/go"
@@ -422,18 +422,16 @@ func main() {
PreviewState: read(stdio.State("previews")), InputState: read(stdio.State(check.InputsStateName)),
StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))},
HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(),
TurnState: read(stdio.State("mesh-delivery-gate.turns")), HearingState: read(stdio.State(buildrun.HearingState)),
AskState: read(stdio.State(check.AsksState)), BuildState: read(stdio.State("mesh-delivery-builder.builds"))}
HearingState: read(stdio.State(buildrun.HearingState)),
AskState: read(stdio.State(check.AsksState)), BuildState: read(stdio.State("mesh-delivery-builder.builds"))}
r.Runs = runsOf{ownerRead(stdio.Ask, indexerModulesTool)} // the node-engine at the commit the mesh runs, as the checker reads it
tries := stdio.State(check.TriesState)
source := stdio.State("mesh-delivery-gate.source")
trunks := func() (map[string]string, error) {
e, err := source.Get(meshdeliveryclient.SourceMainKey)
if err != nil || e == nil {
raw, err := stdio.Ask(check.TrunkVerb, meshcatalog.RevisionInput{})
if err != nil {
return nil, err
}
s, err := meshdeliveryclient.DecodeSource(e.Value)
return s.Heads, err
return check.TrunkHeads(raw)
}
r.Trunks = trunks
store := stateStore{StateRead: read(tries), create: func(key string, value []byte) error {
@@ -38,7 +38,6 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/gopackageregistry"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
@@ -417,9 +416,11 @@ type bed struct {
inputsAnswered atomic.Int32
fake *fakeBuildModules
srvURL string
announce []string
mu sync.Mutex
logs saidLog
// trunk is the catalog half's trunk revision the bed's seat answers `revision` with (design 47 G section 197).
trunkNow *atomic.Value
announce []string
mu sync.Mutex
logs saidLog
}
// saidLog is what the program said, safe for its goroutines and the test's.
@@ -536,9 +537,24 @@ func startBed(t *testing.T) *bed {
t.Fatal(err)
}
}
trunkView := &atomic.Value{}
trunkView.Store(map[string]string{})
for _, subject := range []string{"mesh.seat.mesh-catalog.call.revision", "mesh.seat.mesh-catalog.call.revision.>"} {
if _, err := ctl.Subscribe(subject, func(m *nats.Msg) {
var heads []meshcatalog.Head
for repo, commit := range trunkView.Load().(map[string]string) {
heads = append(heads, meshcatalog.Head{Repository: repo, Commit: commit})
}
body, _ := json.Marshal(meshcatalog.RevisionAnswer{Revision: meshcatalog.CatalogRevision{Kind: "trunk", Revision: 1, Trunk: heads}})
reply, _ := json.Marshal(map[string]any{"result": json.RawMessage(body), "node": "ctl"})
_ = m.Respond(reply)
}); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
t.Cleanup(cancel)
b := &bed{ctx: ctx, js: js, buckets: map[string]jetstream.KeyValue{}, tools: map[string]jetstream.KeyValue{}, srvURL: srv.ClientURL()}
b := &bed{trunkNow: trunkView, ctx: ctx, js: js, buckets: map[string]jetstream.KeyValue{}, tools: map[string]jetstream.KeyValue{}, srvURL: srv.ClientURL()}
for module, stream := range toolchainStreams {
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{"mesh.stream." + module + ".event.toolchain.reported"}}); err != nil {
t.Fatal(err)
@@ -589,9 +605,9 @@ func startBed(t *testing.T) *bed {
}
for name, history := range map[string]int{check.CheckerVerdictsBucket: 5, check.CheckerKeptBucket: 1,
check.CheckerOwedBucket: 5, check.PreviewsBucket: 5, check.CandidatesBucket: 10,
check.ModulesBucket: 20, check.ManifestsBucket: 1, check.SourceBucket: 64,
check.ModulesBucket: 20, check.ManifestsBucket: 1,
check.PresenceBucket: 20, check.HaveBucket: 20, check.TriesBucket: 1,
check.TurnsBucket: 5, builds.BuilderBuildsBucket: 1, check.InputsBucket: 1} {
builds.BuilderBuildsBucket: 1, check.InputsBucket: 1} {
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: uint8(history)})
if err != nil {
t.Fatal(err)
@@ -631,11 +647,9 @@ func startBed(t *testing.T) *bed {
func (b *bed) setTrunk(t *testing.T, heads map[string]string) {
t.Helper()
data, err := meshdeliveryclient.EncodeSource(meshdelivery.SourceByMainValue{Group: "g", Heads: heads})
if err != nil {
t.Fatal(err)
}
if _, err := b.buckets[check.SourceBucket].Put(b.ctx, meshdeliveryclient.SourceMainKey, data); err != nil {
b.trunkNow.Store(heads)
body, _ := json.Marshal(meshcatalog.CatalogRevisionEvent{Kind: "trunk", Branch: "main", Revision: 1, V: 1})
if _, err := b.js.Publish(b.ctx, meshcatalog.SubjectCatalogRevision("main"), body); err != nil {
t.Fatal(err)
}
}
@@ -921,26 +935,8 @@ func TestACheckOrACarryOfEqualInputsRunsNothing(t *testing.T) { // 1287/AC5, 128
t.Errorf("%d kept keys, was %d", got, kept)
}
// §3: the gate's turn on the repository holds a new check off; its delete claims it.
at := time.Now().UTC()
turn, err := meshdeliveryclient.EncodeTurn(meshdelivery.TurnsByRepositoryValue{Repository: repo, Waiting: []meshdelivery.TurnsByRepositoryValueWaitingItem{{Group: "fix/other", Heads: "0123456789ab", Approved: at, Since: at}}})
if err != nil {
t.Fatal(err)
}
tk, _ := meshdeliveryclient.TurnsKey(repo)
turnRev, err := b.buckets[check.TurnsBucket].Put(b.ctx, tk, turn)
if err != nil {
t.Fatal(err)
}
b.said(t, fmt.Sprintf("merge turn %s changed (revision %d)", tk, turnRev)) // the checker heard the turn
// Design 47 G §278 (test 52): a new check is claimed at once, with no state of the gate granted to the checker.
third := b.candidate(t, "feat/z", pull(repo, 9, "3333333333333333333333333333333333333333"))
b.said(t, third+": not claimed: the approved merge of fix/other") // it held the check off
if _, err := b.buckets[check.CheckerVerdictsBucket].Get(b.ctx, third); !errors.Is(err, jetstream.ErrKeyNotFound) {
t.Fatalf("verdicts.%s exists (%v) while a merge turn holds its repository", third, err)
}
if err := b.buckets[check.TurnsBucket].Delete(b.ctx, tk); err != nil {
t.Fatal(err)
}
b.verdict(t, third, ended)
// §5: the trunk moves; the first verdict is carried by its digests, nothing runs.
@@ -1,50 +0,0 @@
package main
import (
"sync"
"mesh-delivery-checker/internal/check"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
)
// lazyTurns is the gate's merge turns as the checker reads them (novox/hq ADR 0548 §3), opened when the gate's bucket is
// there: before each claim it is opened again until it is, so a checker that started before the gate made the bucket holds
// checks from the gate's first turn on, with no timer and no restart. Until then it holds nothing, and says so once, loudly,
// and once again when it opens.
type lazyTurns struct {
turns *check.Turns
open func() error
logf func(format string, args ...any)
mu sync.Mutex
opened bool
said bool
}
// ensure opens the bucket once it is there; a failure is said once.
func (l *lazyTurns) ensure() {
l.mu.Lock()
defer l.mu.Unlock()
if l.opened || l.open == nil {
return
}
if err := l.open(); err != nil {
if !l.said {
l.said = true
l.logf("NO MERGE TURN HOLDS A CHECK: the gate's bucket %s cannot be read (%v); every check is claimed as if no approved merge waited, "+
"and the bucket is opened again before each claim until it is there", check.TurnsBucket, err)
}
return
}
l.opened = true
if l.said {
l.logf("the gate's merge turns are read from %s from now on: an approved merge's turn holds checks off its repositories again", check.TurnsBucket)
}
}
// Holding opens the bucket when it is not yet, then answers what the turns hold.
func (l *lazyTurns) Holding(repositories []string, heads string) (string, meshdelivery.TurnsByRepositoryValueWaitingItem, bool) {
l.ensure()
return l.turns.Holding(repositories, heads)
}
@@ -26,7 +26,6 @@ import (
"mesh-delivery-checker/internal/check"
"git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
@@ -205,8 +204,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
tools := &buildrun.ToolchainSet{}
presence := &buildrun.PresenceSet{}
trunks := &check.Trunks{}
turns := &check.Turns{}
lazy := &lazyTurns{turns: turns, logf: logger.Printf}
// A check is one ask per pull request and build module on the build seats' check stream, pulled by a build node with a
// free slot (novox/hq ADR 0525 §1, §3): the checker chooses no node and starts no step.
asks := buildrun.JetStreamAsks{JS: js}
@@ -233,7 +230,6 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
Verdicts: kvStore{buckets[check.CheckerVerdictsBucket]},
Owed: kvStore{buckets[check.CheckerOwedBucket]},
Trunks: trunks,
Turns: lazy,
Bound: checkBound,
Link: link,
Logf: logger.Printf,
@@ -375,51 +371,13 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
}()
}
}
// The gate's source says the trunk head of each repository, which a pull request is merged onto. Where it is not there
// yet no trunk is known, so nothing is checked.
if kv := process.OptionalBucket(ctx, js, check.SourceBucket, "no trunk is known, so nothing is checked until the checker starts again", logger.Printf); kv != nil {
sw, err := settle(ctx, kv, func(e jetstream.KeyValueEntry) {
if e.Key() == meshdeliveryclient.SourceMainKey {
trunks.Put(e.Value(), !opOf(e))
}
})
if err != nil {
process.NotWatched(logger.Printf, check.SourceBucket, err, "no trunk is known, so nothing is checked until the checker starts again")
} else {
go func() {
errs <- follow(ctx, sw, func(e jetstream.KeyValueEntry) {
if e.Key() == meshdeliveryclient.SourceMainKey && trunks.Put(e.Value(), !opOf(e)) {
ch.TrunkMoved(ctx) // the trunk moved: its statuses go pending, and a verdict for the old trunk is judged again
}
})
}()
}
// The catalog half says the trunk head of each repository, which a pull request is merged onto (design 47 G §197). Where it
// does not answer yet no trunk is known, so nothing is checked.
firstTrunks, err := followTrunks(hub, caller.Call, trunks, ch.TrunkMoved, logger.Printf)
if err != nil {
return fmt.Errorf("the checker cannot hear the catalog half's revisions: %w", err)
}
// The gate's merge turns (novox/hq ADR 0548 §3): no check is claimed of a repository an approved merge waits on, and the
// delete of its turn claims it. Read before the candidates, so no check starts past a turn written before this start.
// Where the gate's bucket is not there yet it is read as empty, and opened again before each claim until it is there
// (lazyTurns), so a gate delivered after the checker started holds checks from its first turn on.
lazy.open = func() error {
kv, err := js.KeyValue(ctx, check.TurnsBucket)
if err != nil {
return err
}
tw, err := settle(ctx, kv, func(e jetstream.KeyValueEntry) { turns.Put(e.Key(), e.Value(), !opOf(e)) })
if err != nil {
return err
}
go func() {
errs <- follow(ctx, tw, func(e jetstream.KeyValueEntry) {
if turns.Put(e.Key(), e.Value(), !opOf(e)) {
// Said, so a wait on a held check can be read (and a test waits on it rather than on a pause).
logger.Printf("the gate's merge turn %s changed (revision %d): the checks it holds are held or claimed from now on", e.Key(), e.Revision())
ch.Reconsider(ctx) // a turn went or changed: a check it held is claimed now
}
})
}()
return nil
}
lazy.ensure()
firstTrunks(ctx)
afterFirstViews := func() error {
// The modules say which repository holds a TypeScript module, so the Node build module judges it too.
pw, err := settle(ctx, packages, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
@@ -0,0 +1,57 @@
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"mesh-delivery-checker/internal/check"
)
// The checker takes the trunk heads a check merges onto from the catalog half (design 47 G §197), never from the gate's state
// `source`: a first view by the seat mesh-catalog's verb `revision`, which carries the trunk head of every repository, then
// the seat's event `catalog-revision` of a trunk revision, each read again by the verb.
// readTrunks asks the catalog half's newest trunk revision and sets the heads it carries. It says whether they changed. A
// candidate's revision, or an answer that does not read, changes nothing.
func readTrunks(ask func(string, any) (json.RawMessage, error), trunks *check.Trunks) (bool, error) {
raw, err := ask(check.TrunkVerb, meshcatalog.RevisionInput{})
if err != nil {
return false, fmt.Errorf("the catalog half's revision cannot be read: %w", err)
}
heads, err := check.TrunkHeads(raw)
if err != nil {
return false, err
}
return trunks.Set(heads), nil
}
// followTrunks reads the first view and then follows `catalog-revision`: a trunk revision's event reads the heads again, and a
// change of them is the trunk moving (statuses go pending, a verdict for the old trunk is judged again). A read that fails is
// said, and made again at the next event.
func followTrunks(hub interface {
Handle(string, func(jetstream.Msg)) error
}, ask func(string, any) (json.RawMessage, error), trunks *check.Trunks, moved func(context.Context), logf func(string, ...any)) (first func(context.Context), err error) {
run := func(ctx context.Context) {
changed, err := readTrunks(ask, trunks)
if err != nil {
logf("no trunk is known until the catalog half answers, so nothing is checked: %v", err)
return
}
if changed {
moved(ctx)
}
}
err = hub.Handle(meshcatalog.SubjectCatalogRevisionAll, func(m jetstream.Msg) {
var e meshcatalog.CatalogRevisionEvent
if json.Unmarshal(m.Data(), &e) == nil && e.Kind == "trunk" {
run(context.Background())
}
_ = m.Ack()
})
return run, err
}
@@ -0,0 +1,38 @@
package main
import (
"encoding/json"
"errors"
"testing"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"mesh-delivery-checker/internal/check"
)
// design 47 G §197: the trunk heads come from the catalog half's trunk revision, by the seat verb revision, and never from the
// gate's source; a candidate's revision and an unanswered verb change nothing.
func TestTrunkHeadsComeFromTheCatalogHalf(t *testing.T) {
answer := func(kind string, heads ...meshcatalog.Head) func(string, any) (json.RawMessage, error) {
return func(key string, _ any) (json.RawMessage, error) {
if key != "seat:mesh-catalog.revision" {
t.Fatalf("asked %s", key)
}
return json.Marshal(meshcatalog.RevisionAnswer{Revision: meshcatalog.CatalogRevision{Kind: kind, Trunk: heads}})
}
}
trunks := &check.Trunks{}
h := meshcatalog.Head{Repository: "r", Commit: "c1"}
if changed, err := readTrunks(answer("trunk", h), trunks); err != nil || !changed || trunks.Heads()["r"] != "c1" {
t.Fatalf("first view: %v %v %v", changed, err, trunks.Heads())
}
if changed, _ := readTrunks(answer("trunk", h), trunks); changed {
t.Fatal("the same heads moved the trunk")
}
if _, err := readTrunks(answer("candidate", meshcatalog.Head{Repository: "r", Commit: "c2"}), trunks); err == nil || trunks.Heads()["r"] != "c1" {
t.Fatalf("a candidate's revision: %v %v", err, trunks.Heads())
}
if _, err := readTrunks(func(string, any) (json.RawMessage, error) { return nil, errors.New("down") }, trunks); err == nil || trunks.Heads()["r"] != "c1" {
t.Fatalf("an unanswered verb: %v %v", err, trunks.Heads())
}
}
@@ -7,9 +7,7 @@ import (
"time"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
)
@@ -17,26 +15,6 @@ import (
// a trunk or sibling move never ends a running check and carries its verdict once it ended (§1, §5, §6); only a head git
// refused gets a whole check after a move (§7); the line of a carried verdict says so (§8).
// turnOf is a turn's value on a repository for a delivery of the heads hash.
func turnOf(t testing.TB, repo, group, heads string) []byte {
t.Helper()
at := time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC)
data, err := meshdeliveryclient.EncodeTurn(meshdelivery.TurnsByRepositoryValue{Repository: repo, Waiting: []meshdelivery.TurnsByRepositoryValueWaitingItem{{Group: group, Heads: heads, Approved: at, Since: at}}})
if err != nil {
t.Fatal(err)
}
return data
}
func turnKey(t testing.TB, repo string) string {
t.Helper()
k, err := meshdeliveryclient.TurnsKey(repo)
if err != nil {
t.Fatal(err)
}
return k
}
// waitFor polls a condition the checker's goroutines make true, failing after ten seconds: a bound a loaded machine does
// not reach, waited out only by a failing test.
func waitFor(t testing.TB, what string, ok func() bool) {
@@ -59,66 +37,15 @@ func pullWithFP(repo string, number int, head string, d string) meshcatalog.Cand
func TestOneCheckPerDelivery(t *testing.T) {
ctx := context.Background()
for name, run := range map[string]func(t *testing.T){
// §3: no claim while a turn names one of its repositories; a claim on the turn's delete event.
"a turn on its repository holds a new check off until the turn goes": func(t *testing.T) {
// Design 47 G §278 (test 52): a new check is claimed at once; the checker reads no state of the gate, so no merge turn holds it.
"a new check is claimed at once": func(t *testing.T) {
j := passJudge()
r := newRig(t, j)
turns := &Turns{}
r.ch.Turns = turns
turns.Put(turnKey(t, repoA), turnOf(t, repoA, "fix/other", "0123456789ab"), false)
cand := candidateOf(t, "feat/x", pullOf(repoA, 7, head1))
r.ch.Candidate(ctx, "feat%2Fx", cand)
r.ch.Wait()
if j.count() != 0 || len(r.verdicts.vals) != 0 {
t.Fatalf("judged %d times with a turn on %s, verdicts %v", j.count(), repoA, r.verdicts.vals)
}
if held := r.ch.Held()[keyOf(t, "feat/x", cand)]; !strings.Contains(held, repoA) || !strings.Contains(held, "fix/other") {
t.Errorf("the hold is said as %q, want the turn's repository and group", held)
}
if !turns.Put(turnKey(t, repoA), nil, true) {
t.Fatal("the turn's delete changed nothing")
}
r.ch.Reconsider(ctx) // what the watch of turns does on the delete
r.ch.Wait()
if j.count() != 1 || r.verdict(t, keyOf(t, "feat/x", cand)).State != meshdeliverycheckerclient.VerdictPass {
t.Errorf("judged %d times after the turn went, want once", j.count())
}
},
"a turn on another repository, or of its own heads, holds nothing": func(t *testing.T) {
j := passJudge()
r := newRig(t, j)
turns := &Turns{}
r.ch.Turns = turns
cand := candidateOf(t, "feat/x", pullOf(repoA, 7, head1))
c, _ := meshcatalog.DecodeCandidatesByGroupValue(cand)
turns.Put(turnKey(t, repoSDK), turnOf(t, repoSDK, "fix/other", "0123456789ab"), false)
turns.Put(turnKey(t, repoA), turnOf(t, repoA, "feat/x", GateHeads(c)), false)
r.ch.Candidate(ctx, "feat%2Fx", cand)
r.ch.Wait()
if j.count() != 1 {
t.Errorf("judged %d times, want once: the turns hold another repository and its own merge", j.count())
}
},
// §3, the race: a check claimed before the gate's turn is judged on the commit it read, never restarted.
"a check claimed before a turn was written runs to its verdict": func(t *testing.T) {
release, started := make(chan struct{}), make(chan struct{})
j := &judgeFunc{f: func(context.Context, *Case, int) (Outcome, error) {
close(started)
<-release
return Outcome{Verdict: passing()}, nil
}}
r := newRig(t, j)
turns := &Turns{}
r.ch.Turns = turns
cand := candidateOf(t, "feat/x", pullOf(repoA, 7, head1))
r.ch.Candidate(ctx, "feat%2Fx", cand)
<-started
turns.Put(turnKey(t, repoA), turnOf(t, repoA, "fix/other", "0123456789ab"), false)
r.ch.Reconsider(ctx)
close(release)
r.ch.Wait()
if j.count() != 1 || r.verdict(t, keyOf(t, "feat/x", cand)).State != meshdeliverycheckerclient.VerdictPass {
t.Errorf("judged %d times; the running check is not ended by the turn", j.count())
t.Errorf("judged %d times, want once at once", j.count())
}
},
// §1, §5: a trunk move during a running check calls the judge once, writes its verdict, keeps the status pending (a pass
@@ -280,15 +207,7 @@ func TestOneCheckPerDelivery(t *testing.T) {
r.ch.Wait()
a, _ := meshcatalog.DecodeCandidatesByGroupValue(candA)
c, _ := meshcatalog.DecodeCandidatesByGroupValue(candC)
at := time.Date(2026, 10, 26, 9, 0, 0, 0, time.UTC)
turn, err := meshdeliveryclient.EncodeTurn(meshdelivery.TurnsByRepositoryValue{Repository: repoA, Waiting: []meshdelivery.TurnsByRepositoryValueWaitingItem{
{Group: "feat/a", Heads: GateHeads(a), Approved: at, Since: at}, {Group: "feat/c", Heads: GateHeads(c), Approved: at.Add(time.Minute), Since: at}}})
if err != nil {
t.Fatal(err)
}
turns := &Turns{}
turns.Put(turnKey(t, repoA), turn, false)
r.ch.Turns = turns
_, _ = a, c
r.setTrunks(t, map[string]string{repoA: trunkA2, repoSDK: trunkS}) // a third merge moves the trunk under both
r.ch.TrunkMoved(ctx)
r.ch.Wait()

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