VW5: the view says its answers on its views family

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
This commit is contained in:
2026-11-12 05:49:52 +01:00
parent 5788fbb5d6
commit 0fba15e32c
7 changed files with 87 additions and 9 deletions
@@ -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 <ask 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{})
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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.
+4 -4
View File
@@ -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
+8 -2
View File
@@ -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": [
@@ -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