Merge branch 'feat/47-G-fix9-catalog' into feat/47-G-join9

This commit is contained in:
2026-11-12 01:46:51 +01:00
5 changed files with 256 additions and 0 deletions
@@ -52,6 +52,9 @@ func (w processWiring) Drop(owner, state string) error { return w.r.Drop(owner,
// ReaderWiring wires Branches on a bundle's reader.
func ReaderWiring(r *views.Reader) Wiring { return readerWiring{r} }
// Rows adds the list to the reader's `<module>_first_views`.
func (w readerWiring) Rows(name string, rows func() any) { w.r.Rows(name, rows) }
// ProcessWiring wires Branches on a process's reader.
func ProcessWiring(r *views.ProcessReader) Wiring { return processWiring{r} }
@@ -185,6 +188,11 @@ func NewBranches(w Wiring, call Call) (*Branches, error) {
if err := w.Adopt(b.source.Follower, delivery.Seat, delivery.EventSourceMoved, b.source.Hear); err != nil {
return nil, err
}
if rw, ok := w.(interface {
Rows(name string, rows func() any)
}); ok { // the entries by fingerprint are said in the part's `<module>_first_views` (design 47 G A-103)
rw.Rows("entries", func() any { return b.EntryStatuses() })
}
return b, nil
}
@@ -386,6 +394,8 @@ type Entries struct {
// Changed, when not nil, is told of each entry that was answered or found missing, so the part starts a new pass at
// once, or writes the finding `entry-not-found`.
Changed func(row EntryRow)
// Released, when not nil, is told of each entry removed by Release.
Released func(fingerprint string)
// Now is the clock, time.Now when nil.
Now func() time.Time
@@ -442,6 +452,9 @@ func (e *Entries) Release(fingerprint string) {
e.mu.Lock()
delete(e.rows, fingerprint)
e.mu.Unlock()
if e.Released != nil {
e.Released(fingerprint)
}
}
// Rows lists the entries kept, by fingerprint (the part's tool `<module>_first_views` lists them with their state and
@@ -0,0 +1,116 @@
package meshcatalogclient
import (
"context"
"fmt"
"strings"
"time"
seat "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/views"
)
// Ready is nil once every watched branch has its first view (`none-yet` counting as one), else says what it waits for
// (design 47 G §614, A-103): a part acts on nothing until then.
func (b *Branches) Ready() error {
followed := b.Followed()
if len(followed) == 0 {
return fmt.Errorf("the catalog half names no watched branch yet (the registrations of the seat %s are asked until they answer)", seat.Seat)
}
var waiting []string
for _, branch := range followed {
if c, ok := b.Catalog(branch); ok {
select {
case <-c.Ready():
default:
waiting = append(waiting, branch)
}
}
}
if len(waiting) > 0 {
return fmt.Errorf("the catalog half has no first view of %s yet", strings.Join(waiting, ", "))
}
return nil
}
// Entry is a module's entry at the trunk: the module's own registration decides, so the branch followed first, which lists
// the module, is the one read (design 47 G A-103). An error says a watched branch has no first view yet, never "no module".
func (b *Branches) Entry(module string) (seat.CatalogEntry, bool, error) {
if err := b.Ready(); err != nil {
return seat.CatalogEntry{}, false, err
}
for _, branch := range b.Followed() {
if c, ok := b.Catalog(branch); ok {
if e, ok := c.Entry(module); ok {
return e, true, nil
}
}
}
return seat.CatalogEntry{}, false, nil
}
// EntryStatus is one entry kept by fingerprint as a part's `<module>_first_views` says it: its state and since when.
type EntryStatus struct {
Fingerprint string `json:"fingerprint"`
State EntryState `json:"state"`
Since time.Time `json:"since"`
Detail string `json:"detail,omitempty"`
}
// EntryStatuses are the entries kept by fingerprint, as `<module>_first_views` lists them (design 47 G §614, A-103).
func (b *Branches) EntryStatuses() []EntryStatus {
rows := b.Entries.Rows()
out := make([]EntryStatus, 0, len(rows))
for _, r := range rows {
out = append(out, EntryStatus{Fingerprint: r.Fingerprint, State: r.State, Since: r.Since, Detail: r.Detail})
}
return out
}
// WriteEntryFindings makes the part write the finding `entry-not-found` through w for each entry the holder answered it does not
// hold, and clear it when a later ask answers it or the part releases the entry (design 47 G §614, A-15, A-103). then, when not
// nil, is told of each change after, so the part starts a new pass. The kind `entry-not-found` is one of w's kinds.
func (b *Branches) WriteEntryFindings(w views.WireWriter, then func(EntryRow)) {
b.Entries.Changed = func(row EntryRow) {
switch row.State {
case EntryNotFound:
if err := views.WriteEntryNotFound(w, row.Fingerprint, row.Since, time.Now()); err != nil && b.Logf != nil {
b.Logf("the finding entry-not-found of %s is not kept: %v", row.Fingerprint, err)
}
case EntryAnswered:
_ = views.ClearEntryNotFound(w, row.Fingerprint)
}
if then != nil {
then(row)
}
}
b.Entries.Released = func(fingerprint string) { _ = views.ClearEntryNotFound(w, fingerprint) }
}
// EntryAgainTool is the part's tool `<module>_entry_again` (design 47 G A-103; class act): it asks one entry by fingerprint
// again now, as a hand act recorded first through handAct (what, why, cause), and answers where the entry stands.
func (b *Branches) EntryAgainTool(module string, handAct func(what, why, cause string) error) stdio.Tool {
name := module + "_entry_again"
return stdio.Tool{Name: name,
Description: "Ask the catalog's entry of one build, by its fingerprint, again now, whatever its state: a hand act, for an entry the part holds as asked or not found. Answers where it stands after. (a)",
Input: map[string]any{"type": "object", "required": []string{"fingerprint", "why"}, "properties": map[string]any{
"fingerprint": map[string]any{"type": "string"}, "why": map[string]any{"type": "string"}}},
Run: func(a map[string]any) (any, error) {
fingerprint, _ := a["fingerprint"].(string)
why, _ := a["why"].(string)
if fingerprint = strings.TrimSpace(fingerprint); fingerprint == "" {
return nil, fmt.Errorf("name the fingerprint of the entry")
}
if strings.TrimSpace(why) == "" {
return nil, fmt.Errorf("say why: a hand act keeps its why")
}
if err := handAct(name+" "+fingerprint, strings.TrimSpace(why), name); err != nil {
return nil, fmt.Errorf("the hand-act log does not hold it, so nothing is asked: %w", err)
}
if err := b.Entries.Again(context.Background(), fingerprint); err != nil {
return nil, err
}
return map[string]any{"entries": b.EntryStatuses()}, nil
}}
}
@@ -0,0 +1,96 @@
package meshcatalogclient
import (
"context"
"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"
)
// wireFindings is a part's finding writer in memory: the findings by `<kind>/<id>` it holds.
type wireFindings struct {
mu sync.Mutex
held map[string]bool
}
func (w *wireFindings) WriteWire(_ context.Context, f map[string]any) error {
w.mu.Lock()
defer w.mu.Unlock()
w.held[f["kind"].(string)+"/"+f["id"].(string)] = true
return nil
}
func (w *wireFindings) Clear(_ context.Context, _, id, kind, _ string) error {
w.mu.Lock()
defer w.mu.Unlock()
delete(w.held, kind+"/"+id)
return nil
}
func (w *wireFindings) ModuleNode() (string, string) { return "part", "n1" }
func (w *wireFindings) has(key string) bool {
w.mu.Lock()
defer w.mu.Unlock()
return w.held[key]
}
// A-103 (design 47 G §614): the helpers every part uses over the branch follower: Ready and Entry before a first view, the
// entries by fingerprint as `first_views` rows, the finding entry-not-found written and cleared, and `<module>_entry_again`.
func TestTheBranchFollowerGivesEveryPartItsEntryHelpers(t *testing.T) {
seats := &fakeSeats{entries: map[string]seat.CatalogEntry{"fp:a": {Module: "m", Fp: "fp:a"}}}
b, err := NewBranches(&fakeWiring{adopted: map[string]*views.Follower{}, hears: map[string]func(context.Context, []byte) error{}}, seats.call)
if err != nil {
t.Fatal(err)
}
if err := b.Ready(); err == nil {
t.Error("Ready said ready with no watched branch")
}
if _, _, err := b.Entry("m"); err == nil {
t.Error("Entry answered with no first view: that is an error, never no module")
}
told := make(chan EntryRow, 4)
w := &wireFindings{held: map[string]bool{}}
b.WriteEntryFindings(w, func(r EntryRow) { told <- r })
b.Entries.Need(context.Background(), "fp:none")
<-told
if !w.has(views.EntryNotFoundKind + "/" + keys.EscapeName("fp:none")) {
t.Errorf("entry-not-found not written: %v", w.held)
}
if st := b.EntryStatuses(); len(st) != 1 || st[0].State != EntryNotFound {
t.Errorf("entry statuses = %+v", st)
}
seats.mu.Lock()
seats.entries["fp:none"] = seat.CatalogEntry{Module: "late", Fp: "fp:none"}
seats.mu.Unlock()
var acts []string
tool := b.EntryAgainTool("part", func(what, why, cause string) error { acts = append(acts, what+"|"+why+"|"+cause); return nil })
if tool.Name != "part_entry_again" {
t.Errorf("tool name %s", tool.Name)
}
if _, err := tool.Run(map[string]any{"fingerprint": "fp:none"}); err == nil {
t.Error("entry_again ran without a why")
}
if _, err := tool.Run(map[string]any{"fingerprint": "fp:none", "why": "late"}); err != nil {
t.Fatal(err)
}
if len(acts) != 1 || w.has(views.EntryNotFoundKind+"/"+keys.EscapeName("fp:none")) {
t.Errorf("hand acts %v, findings %v", acts, w.held)
}
select {
case <-told:
case <-time.After(5 * time.Second):
t.Error("the part was not told of the answer")
}
b.Entries.Need(context.Background(), "fp:gone")
<-told
b.Entries.Release("fp:gone")
if w.has(views.EntryNotFoundKind + "/" + keys.EscapeName("fp:gone")) {
t.Error("a released entry kept its finding")
}
}
@@ -0,0 +1,24 @@
package nodebuildimage
import (
"encoding/json"
"os"
"testing"
)
// The seat's `build` acting rule matches an artifact by its kind, an image having no language (design 47 G §324, §397).
func TestTheSeatsBuildRuleActsOnTheKindImage(t *testing.T) {
raw, err := os.ReadFile("../../../seats/node-build-image/seat.json")
if err != nil {
t.Fatal(err)
}
var def struct {
Acts []struct{ Step, On, Bound string } `json:"acts"`
}
if err := json.Unmarshal(raw, &def); err != nil {
t.Fatal(err)
}
if len(def.Acts) != 1 || def.Acts[0].Step != "build" || def.Acts[0].On != "kind:image" {
t.Errorf("acts = %+v; wanted the one rule {build, kind:image}", def.Acts)
}
}
@@ -7,6 +7,13 @@
"decision": "novox/hq ADR 0525, ADR 0536, ADR 0553",
"pulled-by-kind": true,
"yields": true,
"acts": [
{
"step": "build",
"on": "kind:image",
"bound": "60m"
}
],
"accepts": [
{
"name": "delivery",