From 9c70fa65996cc74bfc46700098be1dec0cf17bdf Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:02:09 +0100 Subject: [PATCH 1/2] Fix 9.5: the seat node-build-image's build acting rule acts on kind:image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G §324, §397 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../go/seats/nodebuildimage/acts_test.go | 24 +++++++++++++++++++ contracts/seats/node-build-image/seat.json | 7 ++++++ 2 files changed, 31 insertions(+) create mode 100644 contracts/go/seats/nodebuildimage/acts_test.go diff --git a/contracts/go/seats/nodebuildimage/acts_test.go b/contracts/go/seats/nodebuildimage/acts_test.go new file mode 100644 index 00000000..7fc457f0 --- /dev/null +++ b/contracts/go/seats/nodebuildimage/acts_test.go @@ -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) + } +} diff --git a/contracts/seats/node-build-image/seat.json b/contracts/seats/node-build-image/seat.json index 5528da1f..57b1f438 100644 --- a/contracts/seats/node-build-image/seat.json +++ b/contracts/seats/node-build-image/seat.json @@ -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", From 46b5d409bf0ba0764ee1d4c8a1bca214de62dce9 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 01:09:46 +0100 Subject: [PATCH 2/2] Fix 9.4b: meshcatalogclient: Ready and Entry over the branch follower, the entries rows, the entry-not-found finding and the entry_again tool, built once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds: design 47 G A-103, A-15, §614 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../go/clients/meshcatalogclient/branches.go | 13 ++ .../go/clients/meshcatalogclient/entry.go | 116 ++++++++++++++++++ .../clients/meshcatalogclient/entry_test.go | 96 +++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 contracts/go/clients/meshcatalogclient/entry.go create mode 100644 contracts/go/clients/meshcatalogclient/entry_test.go diff --git a/contracts/go/clients/meshcatalogclient/branches.go b/contracts/go/clients/meshcatalogclient/branches.go index aa7b79e8..dd606307 100644 --- a/contracts/go/clients/meshcatalogclient/branches.go +++ b/contracts/go/clients/meshcatalogclient/branches.go @@ -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 `_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 `_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 `_first_views` lists them with their state and diff --git a/contracts/go/clients/meshcatalogclient/entry.go b/contracts/go/clients/meshcatalogclient/entry.go new file mode 100644 index 00000000..efcc5bf8 --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/entry.go @@ -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 `_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 `_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 `_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 + }} +} diff --git a/contracts/go/clients/meshcatalogclient/entry_test.go b/contracts/go/clients/meshcatalogclient/entry_test.go new file mode 100644 index 00000000..0d282f3c --- /dev/null +++ b/contracts/go/clients/meshcatalogclient/entry_test.go @@ -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 `/` 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 `_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") + } +}