From 0fba15e32ca788454bd77edec3ad09531d4c7148 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 05:49:52 +0100 Subject: [PATCH] VW5: the view says its answers on its views family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The teller follows the view's answers by view_read_answers and views.answers instead of reading the view's state; the view emits from its marks state view-marks, as the other owners do. Builds: design 47 G §431; lead answers A-175, A-178 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-delivery-view-tools/main.go | 9 ++++++++ .../mesh-delivery-view-tools/owners_test.go | 23 +++++++++++++++++++ .../mesh-delivery-view/integration_test.go | 18 ++++++++++++++- .../cmd/mesh-delivery-view/main.go | 22 +++++++++++++++++- modules/mesh-delivery-view/manifest_test.go | 8 +++---- modules/mesh-delivery-view/module.json | 10 ++++++-- modules/mesh-delivery-view/viewstate/reach.go | 6 ++++- 7 files changed, 87 insertions(+), 9 deletions(-) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go index 9be22969..5aaf5331 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -449,6 +449,7 @@ func main() { // view_first_views and view_first_view_again: where the view's process stands with its first views of the indexer's and // the assigner's states (ADR 0615 §6), and a re-read by hand. tools := append(Tools(r, u), gate.Tools()...) + tools = append(tools, AnswersReadTool(func(name string) views.Source { return views.KeptSource{State: stdio.State(name)} }, time.Now)) tools = append(tools, views.StatusTools(viewstate.ViewModule, "view_first_views", "view_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...) if err := stdio.Serve("", tools); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-view-tools] %v\n", err) @@ -456,6 +457,14 @@ func main() { } } +// AnswersReadTool is view_read_answers, the view's ordinary read tool of its own state answers (mesh-sdk views.ReadTool): the +// teller's first view of the answers the view says on views.answers (design 47 G A-175, A-178). +func AnswersReadTool(state func(string) views.Source, now func() time.Time) stdio.Tool { + return views.ReadTool(viewstate.ViewModule, viewstate.ViewAnswersReadTool, viewstate.ViewAnswersState, + "the operator's answers given in the view to the assigner's asks, one key .view each, which the teller fills the ask from.", + state(viewstate.ViewAnswersState), state(views.MarksState), now) +} + // pendingOf asks the indexer's tool indexer_pending (declared in invokes): what it holds back and why (ADR 0633 §5). func pendingOf() (takein.PendingAnswer, error) { raw, err := stdio.Ask(view.IndexerModule+".indexer_pending", map[string]any{}) diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go index a4883180..b5affab6 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/owners_test.go @@ -12,6 +12,7 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/graph" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -193,3 +194,25 @@ var catalogAnswer = meshcatalog.RevisionAnswer{ {Module: "b", Repository: "novox/r", Commit: "c2", Path: "modules/b", Manifest: map[string]any{"module": "b"}, None: "builder's fingerprint not yet known"}, }, } + +// view_read_answers answers the view's own answers as every owner's read tool answers its state (design 47 G A-178): one +// key's value, and every key when none is named. +func TestTheViewReadsItsAnswersAsAnOwner(t *testing.T) { + answers := source{"hold.plex.ab.view": `{"asking":"a1","option":"release"}`} + tool := AnswersReadTool(func(name string) views.Source { + if name == viewstate.ViewAnswersState { + return answers + } + return source{} + }, time.Now) + if tool.Name != "view_read_answers" { + t.Fatalf("the tool is %s", tool.Name) + } + for args, want := range map[string]map[string]any{"release": {"key": "hold.plex.ab.view"}, "hold.plex.ab.view": {}} { + out, err := tool.Run(want) + raw, _ := json.Marshal(out) + if err != nil || !strings.Contains(string(raw), args) { + t.Errorf("%v: %s, %v", args, raw, err) + } + } +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go index f0f5534f..5f2cad89 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/integration_test.go @@ -32,6 +32,7 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-view/viewstate" "git.novox.be/novox/mesh-sdk/go/process" + "git.novox.be/novox/mesh-sdk/go/views" "git.novox.be/novox/mesh-sdk/go/views/viewstest" "github.com/nats-io/nats-server/v2/server" "github.com/nats-io/nats.go" @@ -516,7 +517,15 @@ func TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof(t *testing.T) { // 129 } askBucket := bucket(view.AssignerAsksBucket, view.AssignerAsksHistory) viewBucket := bucket(viewstate.ViewAnswersBucket, viewstate.ViewAnswersHistory) + bucket(viewstate.ViewModule+"_"+views.MarksState, 1) teller := bucket(view.AnswersBucket, view.AnswersHistory) + // The view says its answers on its views family (design 47 G A-178), which the teller follows. + said := make(chan *nats.Msg, 8) + sub, err := nc.ChanSubscribe("mesh.mod."+viewstate.ViewModule+".event."+views.EventPrefix+viewstate.ViewAnswersState, said) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { sub.Unsubscribe() }) dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i") if err != nil { @@ -575,6 +584,14 @@ func TestProcessWholeWritesTheAnswerToAnAskNeedingNoProof(t *testing.T) { // 129 t.Fatalf("answer = %+v, %v", a, err) } wait(func(s view.Snapshot) bool { return len(s.Asks) == 1 && s.Asks[0].Chosen == "release" }, "the ask never showed the view's choice") + select { + case msg := <-said: + if !strings.Contains(string(msg.Data), key+".view") { + t.Errorf("the view said %s, not its answer under %s.view", msg.Data, key) + } + case <-time.After(20 * time.Second): + t.Error("the view never said its answer on views.answers") + } // The merge's Approve is no ask of the view's (design 47 G §431): refused by its key, never written. mkey, err := meshdeliveryclient.MergeAskKey("feat/x", []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}) @@ -935,4 +952,3 @@ func TestProcessWholeSaysModulesInMotion(t *testing.T) { t.Errorf("events said by type = %v, want moving 2, settled 1, stuck 1", count) } } - diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go index d670158b..3d02f714 100644 --- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go @@ -130,6 +130,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } defer writer.Close() reader.Findings = func() views.WireWriter { return writer } + sayErrs := make(chan error, 1) + sayAnswers(ctx, js, conn, cred, writer, sayErrs, logger.Printf) for _, o := range ownersFollowed { if _, err := reader.Follow(o.owner, o.state, o.tool); err != nil { return err @@ -151,8 +153,10 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro } reader.Start(ctx, js, cred, statusBucket) catalogHeld(ctx, catalog, model) + var said error select { case <-ctx.Done(): + case said = <-sayErrs: case err := <-webUp: if !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("the web view stopped: %w", err) @@ -161,7 +165,23 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro shutdown, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() srv.Shutdown(shutdown) - return nil + return said +} + +// sayAnswers says each change of the view's own state answers on its views family (`views.answers`), from the revision its +// marks state view-marks says it said up to, as every owner says its states (design 47 G A-175, A-178): the teller follows it +// by view_read_answers and that family, never by reading the state. A watch that ends is sent on errs, and the program ends +// with it, loudly. +func sayAnswers(ctx context.Context, js jetstream.JetStream, nc process.Subscriber, cred process.Account, writer views.WireWriter, errs chan<- error, logf func(string, ...any)) { + marks := viewstate.ViewModule + "_" + views.MarksState + process.AwaitBucket(ctx, js, nc, marks, "the view's answers are not said on its views family, so the teller waits on its first view", logf).Ready(func(mk jetstream.KeyValue) { + em := views.Emitter{Owner: viewstate.ViewModule, Subject: cred.EventSubject, Publish: views.JetStreamPublish(js), Marks: mk, Findings: writer, Logf: logf} + process.AwaitBucket(ctx, js, nc, viewstate.ViewAnswersBucket, "the view's answers are not said on its views family", logf).Ready(func(kv jetstream.KeyValue) { + if err := em.Start(ctx, viewstate.ViewAnswersState, kv, errs); err != nil { + logf("%v: the teller asks view_read_answers at each change until the view starts again", err) + } + }) + }) } // kvWriter is the view's one write: a key of its own bucket answers (manifest state entry answers), which the teller reads. diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 1080cd44..def20938 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -146,8 +146,8 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 t.Errorf("consumes = %v, want %v", m.Consumes, want) } // The one event the view says: a source revision is delivered (1906/D9, ADR 0539 §17), derived by it from the wants and the haves. - if !reflect.DeepEqual(m.Emits, []string{"delivered", "reached", "moving", "settled", "stuck"}) { - t.Errorf("emits = %v, want exactly delivered, reached (ADR 0596 §2), moving, settled and stuck (1906/D9, D10)", m.Emits) + if !reflect.DeepEqual(m.Emits, []string{"delivered", "reached", "moving", "settled", "stuck", "views.answers"}) { + t.Errorf("emits = %v, want exactly delivered, reached (ADR 0596 §2), moving, settled and stuck (1906/D9, D10), and views.answers (A-178)", m.Emits) } // The view uses no build seat: it reads their workers by the broker's named grant and publishes no ask (ADR 0525 §2). if len(m.Uses) != 0 { @@ -156,7 +156,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 // The states the view writes: the operator's answers to the asks that need no proof (ADR 0528 clause 6, 1297/B9), which the // teller reads, and how far each repository reached (ADR 0556 clause 2, issue 1280), which the gate reads. // Its first views of the owners' states and the finding of one unanswered past its bound (ADR 0615 §6). - if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "first-views", History: 1}}; !reflect.DeepEqual(m.State, want) { + if want := []state{{Name: "answers", History: 5}, {Name: "reach", History: 1}, {Name: "first-views", History: 1}, {Name: "view-marks", History: 1}}; !reflect.DeepEqual(m.State, want) { t.Errorf("state = %+v, want %+v", m.State, want) } if _, ok := m.OwnSecrets["broker"]; !ok || len(m.OwnSecrets) != 1 { @@ -167,7 +167,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 } // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. - if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again", "node_manifest_reads", "view_seat_list", "view_seat_list_again"}; !reflect.DeepEqual(m.Tools, want) { + if want := []string{"view_status", "indexer_module", "indexer_modules", "indexer_candidate", "indexer_candidates", "indexer_predefined", "indexer_config_candidates", "indexer_packages", "builder_build", "builder_builds", "builder_history", "assigner_wants", "assigner_want", "assigner_differences", "node_haves", "node_trusts", "build_usage", "view_reach", "indexer_module_history", "indexer_heard", "view_tiers", "view_first_views", "view_first_view_again", "node_manifest_reads", "view_seat_list", "view_seat_list_again", "view_read_answers"}; !reflect.DeepEqual(m.Tools, want) { t.Errorf("tools = %v, want %v", m.Tools, want) } // D-11: the holders and buckets the view names are its manifest's (no contract names a holder, novox/hq ADR 0634 §1): a diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index b25b0810..19914b85 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -29,14 +29,16 @@ "view_first_view_again", "node_manifest_reads", "view_seat_list", - "view_seat_list_again" + "view_seat_list_again", + "view_read_answers" ], "emits": [ "delivered", "reached", "moving", "settled", - "stuck" + "stuck", + "views.answers" ], "state": [ { @@ -50,6 +52,10 @@ { "name": "first-views", "history": 1 + }, + { + "name": "view-marks", + "history": 1 } ], "consumes": [ diff --git a/modules/mesh-delivery-view/viewstate/reach.go b/modules/mesh-delivery-view/viewstate/reach.go index 746e4281..1c6bb4ca 100644 --- a/modules/mesh-delivery-view/viewstate/reach.go +++ b/modules/mesh-delivery-view/viewstate/reach.go @@ -136,8 +136,12 @@ func SameReach(a, b Reach) bool { // ViewAnswersBucket is the delivery view's state `answers`: one key per ask it answered, and ViewAnswersHistory the revisions it // keeps per key. const ( - ViewAnswersBucket = ViewModule + "_answers" + ViewAnswersState = "answers" + ViewAnswersBucket = ViewModule + "_" + ViewAnswersState ViewAnswersHistory = 5 + // ViewAnswersReadTool is the view's read tool of its state answers (mesh-sdk views.ReadTool), whose changes it says on its + // views family as views.answers; its marks are its state view-marks (views.MarksState) (design 47 G A-178). + ViewAnswersReadTool = "view_read_answers" ) // ViewAnswer is the value of the view's answer to an ask of the assigner: the asking it answers, the option chosen and the words