diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go
index 7105cad..19ff059 100644
--- a/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go
+++ b/modules/mesh-delivery-view/cmd/mesh-delivery-cli/main_test.go
@@ -8,7 +8,9 @@ import (
"path/filepath"
"strings"
"testing"
+ "time"
+ "git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-view/internal/view"
@@ -109,3 +111,59 @@ func mustHash(t *testing.T, h delivery.Head) string {
}
return s
}
+
+// TestPrintsTheOpenAsksInTheTableAndTheJSON: the command shows what waits for the operator's Approve (1345/AC2). The ask is
+// the gate's, as mesh-sdk go/delivery EncodeAsk writes it under the key MergeAskKey gives (as internal/view asks_test.go).
+func TestPrintsTheOpenAsksInTheTableAndTheJSON(t *testing.T) { // 1345/AC2
+ m := view.New()
+ heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
+ key, err := delivery.MergeAskKey("feat/x", heads)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ask := asks.Ask{ID: delivery.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: asks.Operator,
+ Expires: time.Now().Add(time.Hour), OnExpiry: "the merge stays held",
+ Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}}
+ value, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: ask})
+ if err != nil {
+ t.Fatal(err)
+ }
+ m.Apply(view.Entry{Bucket: delivery.AsksBucket, Key: key, Value: value})
+ dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "c")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ l, err := view.ListenSocket(filepath.Join(dir, "cli.sock"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ go view.ServeSocket(ctx, l, m)
+ t.Cleanup(cancel)
+ socket := filepath.Join(dir, "cli.sock")
+ var out, errOut bytes.Buffer
+ if code := run(nil, socket, &out, &errOut); code != 0 {
+ t.Fatalf("exit %d: %s", code, errOut.String())
+ }
+ for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} {
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("the table lacks %q:\n%s", want, out.String())
+ }
+ }
+ out.Reset()
+ if code := run([]string{"--json"}, socket, &out, &errOut); code != 0 {
+ t.Fatalf("exit %d: %s", code, errOut.String())
+ }
+ var got struct {
+ Asks []struct {
+ Key string `json:"key"`
+ Headline string `json:"headline"`
+ How string `json:"how"`
+ } `json:"asks"`
+ }
+ if err := json.Unmarshal(out.Bytes(), &got); err != nil || len(got.Asks) != 1 || got.Asks[0].Key != key ||
+ got.Asks[0].Headline != "Merge into main: mesh-delivery#12" || got.Asks[0].How != "answer on the phone" {
+ t.Errorf("json = %s (%v)", out.String(), 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 2b98ddd..09b17f5 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
@@ -15,6 +15,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
+ "git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-view/internal/view"
@@ -299,3 +300,93 @@ func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 124
}
t.Fatalf("the snapshot never held the indexer's keys; last: %+v", last)
}
+
+// TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes starts the program whole before the gate's asks bucket and the
+// teller's answers bucket exist, then creates them: an ask appears in the snapshot, and leaves it once the teller has an
+// answer under its key (1345/AC1 against a real bus; the retry is the view's own, every 5 s).
+func TestProcessWholeShowsTheOpenAsksAndDropsTheAnsweredOnes(t *testing.T) { // 1345/AC1
+ srv, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: -1, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ go srv.Start()
+ if !srv.ReadyForConnections(10 * time.Second) {
+ t.Fatal("the bus did not come up")
+ }
+ t.Cleanup(srv.Shutdown)
+ nc, err := nats.Connect(srv.ClientURL())
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(nc.Close)
+ js, err := jetstream.New(nc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i")
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ broker := filepath.Join(dir, "broker")
+ cred, _ := json.Marshal(credential{URL: srv.ClientURL(), Node: "n", Module: "mesh-delivery-view", User: "mesh-delivery-view", Password: "x"})
+ if err := os.WriteFile(broker, cred, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ socket := filepath.Join(dir, "run", "cli.sock")
+ env := map[string]string{"MESH_BROKER_FILE": broker, "MESH_DELIVERY_SOCKET": socket, "MESH_DELIVERY_WEB": "127.0.0.1:0"}
+ runCtx, stop := context.WithCancel(ctx)
+ done := make(chan error, 1)
+ go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
+ t.Cleanup(func() { stop(); <-done })
+
+ gate, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AsksBucket, History: delivery.AsksHistory})
+ if err != nil {
+ t.Fatal(err)
+ }
+ teller, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.AnswersBucket, History: delivery.AnswersHistory})
+ if err != nil {
+ t.Fatal(err)
+ }
+ heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: "0123456789abcdef0123456789abcdef01234567"}}
+ key, err := delivery.MergeAskKey("feat/x", heads)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ask := asks.Ask{ID: delivery.AskID(key), Headline: "Merge into main: mesh-delivery#12", Explanation: "Merges one pull request.", Who: asks.Operator,
+ Expires: time.Now().Add(time.Hour), OnExpiry: "the merge stays held",
+ Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}}
+ value, err := delivery.EncodeAsk(delivery.Ask{Group: "feat/x", Heads: heads, Ask: ask})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := gate.Put(ctx, key, value); err != nil {
+ t.Fatal(err)
+ }
+ wait := func(check func(view.Snapshot) bool, what string) {
+ t.Helper()
+ var last view.Snapshot
+ for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) {
+ if s, err := view.Ask(socket); err == nil {
+ last = s
+ if check(s) {
+ return
+ }
+ }
+ }
+ t.Fatalf("%s; last: %+v", what, last)
+ }
+ wait(func(s view.Snapshot) bool {
+ return len(s.Asks) == 1 && s.Asks[0].Key == key && s.Asks[0].How == "answer on the phone"
+ }, "the open ask never showed")
+ answer, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: "w1", Words: "the operator, via telegram, chose Approve"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := teller.Put(ctx, key, answer); err != nil {
+ t.Fatal(err)
+ }
+ wait(func(s view.Snapshot) bool { return len(s.Asks) == 0 }, "the answered ask never left the list")
+}
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 9a307f8..4f3cf6c 100644
--- a/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go
+++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view/main.go
@@ -136,7 +136,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
if err != nil {
return err
}
- for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket} {
+ for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket, delivery.AsksBucket, delivery.AnswersBucket} {
go follow(ctx, js, bucket, model, logger)
}
select {
diff --git a/modules/mesh-delivery-view/go.mod b/modules/mesh-delivery-view/go.mod
index 1570291..37a8ead 100644
--- a/modules/mesh-delivery-view/go.mod
+++ b/modules/mesh-delivery-view/go.mod
@@ -3,7 +3,7 @@ module mesh-delivery-view
go 1.26.0
require (
- git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a
+ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
diff --git a/modules/mesh-delivery-view/go.sum b/modules/mesh-delivery-view/go.sum
index 9000f11..e7d24d6 100644
--- a/modules/mesh-delivery-view/go.sum
+++ b/modules/mesh-delivery-view/go.sum
@@ -2,6 +2,8 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337 h1:cZyFnvDi
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019203925-84a764372337/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a h1:Vjk2Kq+g/Q86N8CCHq3OatJIJ0JSy6Kh980ekGxNLmE=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019223929-58373d65b47a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69 h1:kvtV/SPsdRVUL/BZ3xxGS6U8YrXy/t7TRBjVrD6VLw8=
+git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019230201-5786ba190c69/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
diff --git a/modules/mesh-delivery-view/internal/view/asks_test.go b/modules/mesh-delivery-view/internal/view/asks_test.go
new file mode 100644
index 0000000..b155085
--- /dev/null
+++ b/modules/mesh-delivery-view/internal/view/asks_test.go
@@ -0,0 +1,132 @@
+package view
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "git.novox.be/novox/mesh-sdk/go/asks"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+)
+
+const askHeadA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+
+// askEntry is a key of the gate's asks as the gate writes it (mesh-sdk go/delivery EncodeAsk, MergeAskKey): the router's ask
+// whole, under the id its key gives.
+func askEntry(t *testing.T, group, headline, words string) Entry {
+ t.Helper()
+ heads := []delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: askHeadA}}
+ key, err := delivery.MergeAskKey(group, heads)
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := asks.Ask{ID: delivery.AskID(key), Headline: headline, Explanation: words, Who: asks.Operator, Expires: t0.Add(23 * time.Hour), OnExpiry: "the merge stays held",
+ Options: []asks.Option{{ID: "approve", Label: "Approve", Does: "merges", Level: asks.Approve, Binds: "sha256:00"}}}
+ v, err := delivery.EncodeAsk(delivery.Ask{Group: group, Heads: heads, Ask: a})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return Entry{Bucket: delivery.AsksBucket, Key: key, Value: v, At: t0}
+}
+
+func answerEntry(t *testing.T, key string) Entry {
+ t.Helper()
+ v, err := delivery.EncodeAnswer(delivery.Answer{Option: "approve", Warrant: "w1", Words: "the operator, via telegram, chose Approve"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return Entry{Bucket: delivery.AnswersBucket, Key: key, Value: v, At: t0}
+}
+
+func TestAnAskWithNoAnswerIsListedAndAnAnsweredOneIsNot(t *testing.T) { // 1345/AC1
+ m := New()
+ e := askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request.\nReview: approved.")
+ fill(m, e)
+ s := m.Snapshot()
+ if len(s.Asks) != 1 {
+ t.Fatalf("asks = %+v, want the one open ask", s.Asks)
+ }
+ a := s.Asks[0]
+ if a.Key != e.Key || a.Group != "feat/x" || a.Headline != "Merge into main: mesh-delivery#12" || !strings.Contains(a.Words, "Merges one pull request.") ||
+ a.How != "answer on the phone" || len(a.Pulls) != 1 || !strings.Contains(a.Pulls[0], "novox/mesh-delivery#12") || !strings.Contains(a.Pulls[0], askHeadA[:12]) {
+ t.Errorf("ask = %+v", a)
+ }
+ fill(m, answerEntry(t, e.Key))
+ if got := m.Snapshot().Asks; len(got) != 0 {
+ t.Errorf("an answered ask is listed: %+v", got)
+ }
+ // An answer taken away leaves the ask open again; an ask taken away leaves the list.
+ fill(m, Entry{Bucket: delivery.AnswersBucket, Key: e.Key, Deleted: true})
+ if got := m.Snapshot().Asks; len(got) != 1 {
+ t.Errorf("asks after the answer was taken away = %+v", got)
+ }
+ fill(m, Entry{Bucket: delivery.AsksBucket, Key: e.Key, Deleted: true})
+ if got := m.Snapshot().Asks; len(got) != 0 {
+ t.Errorf("a withdrawn ask is listed: %+v", got)
+ }
+}
+
+func TestAnAnswerHeardBeforeItsAskStillHidesTheAsk(t *testing.T) {
+ m := New()
+ e := askEntry(t, "feat/x", "Merge", "Merges.")
+ fill(m, answerEntry(t, e.Key), e)
+ if got := m.Snapshot().Asks; len(got) != 0 {
+ t.Errorf("asks = %+v", got)
+ }
+}
+
+func TestAnUnreadableAskIsNamedAndNotListed(t *testing.T) { // never dropped silently
+ m := New()
+ fill(m, Entry{Bucket: delivery.AsksBucket, Key: "merge.g.0123456789ab", Value: []byte("{"), At: t0})
+ s := m.Snapshot()
+ if len(s.Asks) != 0 || len(s.Unreadable) != 1 || s.Unreadable[0].Bucket != delivery.AsksBucket {
+ t.Errorf("snapshot = %+v", s)
+ }
+}
+
+func TestTableAndPageShowTheAsks(t *testing.T) { // 1345/AC2
+ m := New()
+ fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request."))
+ var out bytes.Buffer
+ Table(&out, m.Snapshot())
+ for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone", "novox/mesh-delivery#12"} {
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("the table lacks %q:\n%s", want, out.String())
+ }
+ }
+ rec := httptest.NewRecorder()
+ Web(m).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
+ for _, want := range []string{"ASKS", "Merge into main: mesh-delivery#12", "Merges one pull request.", "answer on the phone"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("the page lacks %q:\n%s", want, rec.Body.String())
+ }
+ }
+ // No ask, no section.
+ out.Reset()
+ Table(&out, New().Snapshot())
+ if strings.Contains(out.String(), "ASKS") {
+ t.Errorf("the table shows an ASKS section with no ask:\n%s", out.String())
+ }
+}
+
+func TestTheSnapshotsJSONCarriesTheAsks(t *testing.T) { // 1345/AC2
+ m := New()
+ fill(m, askEntry(t, "feat/x", "Merge into main: mesh-delivery#12", "Merges one pull request."))
+ raw, err := json.Marshal(m.Snapshot())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var back struct {
+ Asks []struct {
+ Headline string `json:"headline"`
+ How string `json:"how"`
+ } `json:"asks"`
+ }
+ if err := json.Unmarshal(raw, &back); err != nil || len(back.Asks) != 1 || back.Asks[0].How != "answer on the phone" {
+ t.Errorf("json %s: %+v %v", raw, back, err)
+ }
+}
diff --git a/modules/mesh-delivery-view/internal/view/serve.go b/modules/mesh-delivery-view/internal/view/serve.go
index 593a36c..73fa01e 100644
--- a/modules/mesh-delivery-view/internal/view/serve.go
+++ b/modules/mesh-delivery-view/internal/view/serve.go
@@ -152,6 +152,15 @@ func fingerprintText(s Snapshot) [][]string {
return out
}
+// askText is one line per open ask: its key, headline, words on one line, the pull requests it covers and how to answer.
+func askText(s Snapshot) [][]string {
+ var out [][]string
+ for _, a := range s.Asks {
+ out = append(out, []string{a.Key, a.Headline, strings.Join(strings.Fields(strings.ReplaceAll(a.Words, "\n", " / ")), " "), strings.Join(a.Pulls, ", "), a.How})
+ }
+ return out
+}
+
var head = []string{"NODE", "MODULE", "ASSIGNED", "HAVE", "PHASE", "HEALTH", "SINCE", "DETAIL", "WAITS"}
// Table prints the snapshot as a table.
@@ -184,6 +193,13 @@ func Table(w io.Writer, s Snapshot) {
row(cells)
}
}
+ if a := askText(s); len(a) > 0 {
+ fmt.Fprintln(tw)
+ row([]string{"ASKS", "HEADLINE", "WORDS", "PULL REQUESTS", "ANSWER"})
+ for _, cells := range a {
+ row(cells)
+ }
+ }
if f := fingerprintText(s); len(f) > 0 {
fmt.Fprintln(tw)
row([]string{"MODULE", "FINGERPRINT", "INDEXER AGAINST BUILD"})
@@ -227,6 +243,7 @@ var page = template.Must(template.New("p").Parse(`
What each node runs
{{range .Head}}| {{.}} | {{end}}
{{range .Rows}}{{range .}}| {{.}} | {{end}}
{{end}}
{{if .Groups}}CANDIDATES
| GROUP | PULL REQUEST | HEAD | MODULES |
{{range .Groups}}{{range .}}| {{.}} | {{end}}
{{end}}
{{end}}
+{{if .Asks}}ASKS
| ASK | HEADLINE | WORDS | PULL REQUESTS | ANSWER |
{{range .Asks}}{{range .}}| {{.}} | {{end}}
{{end}}
{{end}}
{{if .Fingerprints}}FINGERPRINTS
| MODULE | STATE | INDEXER AGAINST BUILD |
{{range .Fingerprints}}{{range .}}| {{.}} | {{end}}
{{end}}
{{end}}
{{if .Offline}}OFFLINE
{{range .Offline}}{{range .}}| {{.}} | {{end}}
{{end}}
{{end}}
{{if .Stuck}}STUCK
| BUCKET | KEY | WAITED FOR | SINCE |
{{range .Stuck}}{{range .}}| {{.}} | {{end}}
{{end}}
{{end}}
@@ -260,6 +277,6 @@ func Web(m *Model) http.Handler {
for _, u := range snap.Unreadable {
bad = append(bad, []string{u.Bucket, u.Key, u.Error})
}
- page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Fingerprints": fingerprintText(snap), "Offline": off, "Stuck": stuck, "Unreadable": bad})
+ page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(snap), "Asks": askText(snap), "Fingerprints": fingerprintText(snap), "Offline": off, "Stuck": stuck, "Unreadable": bad})
})
}
diff --git a/modules/mesh-delivery-view/internal/view/view.go b/modules/mesh-delivery-view/internal/view/view.go
index 7ab0ec4..6caaf3c 100644
--- a/modules/mesh-delivery-view/internal/view/view.go
+++ b/modules/mesh-delivery-view/internal/view/view.go
@@ -3,6 +3,7 @@ package view
import (
"encoding/json"
+ "fmt"
"sort"
"strings"
"sync"
@@ -61,8 +62,25 @@ type Snapshot struct {
Groups []GroupRow `json:"groups"`
// Fingerprints is every module the indexer fingerprinted at a trunk merge, beside its newest successful trunk build's.
Fingerprints []FingerprintRow `json:"fingerprints"`
+ // Asks is every ask the gate holds that has no answer yet: what waits for the operator's Approve.
+ Asks []AskRow `json:"asks"`
}
+// AskRow is an open ask (novox/hq ADR 0437 §10): the gate's ask with no answer under its key in the teller's answers.
+type AskRow struct {
+ Key string `json:"key"`
+ Group string `json:"group"`
+ Headline string `json:"headline"`
+ Words string `json:"words"`
+ Pulls []string `json:"pulls"`
+ Expires time.Time `json:"expires,omitzero"`
+ How string `json:"how"`
+}
+
+// AnswerOnThePhone is what an open ask says about answering it: the view only shows, and the Approve is answered on the
+// operator's phone (ADR 0437 §10: answering in the view is release 5's).
+const AnswerOnThePhone = "answer on the phone"
+
// GroupRow is a candidate group (novox/hq ADR 0437 §5): the branch its pull requests share and the pull requests.
type GroupRow struct {
Group string `json:"group"`
@@ -129,6 +147,8 @@ type Model struct {
builds map[string]buildEntry // by .build.
groups map[string]groupEntry // the indexer's candidates, by group
modules map[string]moduleEntry // the indexer's modules, by module
+ asks map[string]askRecord // the gate's asks, by key
+ answers map[string]bool // the keys the teller has an answer under
newest map[string]string // module -> id
haves map[string]haveEntry
nodes map[string]nodeEntry
@@ -194,6 +214,11 @@ type moduleEntry struct {
err string
}
+type askRecord struct {
+ value delivery.Ask
+ err string
+}
+
type haveEntry struct {
have delivery.Have
err string
@@ -208,7 +233,7 @@ type nodeEntry struct {
// New is an empty model.
func New() *Model {
- return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, newest: map[string]string{},
+ return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, asks: map[string]askRecord{}, answers: map[string]bool{}, newest: map[string]string{},
haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{},
presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}}
}
@@ -277,6 +302,20 @@ func (m *Model) Apply(e Entry) {
}
mod, err := delivery.DecodeModule(e.Value)
m.modules[e.Key] = moduleEntry{value: mod, err: errText(err)}
+ case delivery.AsksBucket:
+ if e.Deleted {
+ delete(m.asks, e.Key)
+ return
+ }
+ a, err := delivery.DecodeAsk(e.Value)
+ m.asks[e.Key] = askRecord{value: a, err: errText(err)}
+ case delivery.AnswersBucket:
+ // An answer under the ask's key, whatever it says, ends the ask here: the gate acts on it, and the view only shows.
+ if e.Deleted {
+ delete(m.answers, e.Key)
+ return
+ }
+ m.answers[e.Key] = true
case BuildsBucket:
module, rest, ok := strings.Cut(e.Key, ".")
if !ok {
@@ -414,6 +453,11 @@ func (m *Model) Snapshot() Snapshot {
unreadable = append(unreadable, UnreadableRow{Bucket: delivery.ModulesBucket, Key: key, Error: mod.err})
}
}
+ for key, a := range m.asks {
+ if a.err != "" {
+ unreadable = append(unreadable, UnreadableRow{Bucket: delivery.AsksBucket, Key: key, Error: a.err})
+ }
+ }
sort.Slice(offline, func(i, j int) bool {
a, b := offline[i], offline[j]
return a.Kind+a.Node+a.Part+a.Instance < b.Kind+b.Node+b.Part+b.Instance
@@ -425,7 +469,7 @@ func (m *Model) Snapshot() Snapshot {
return unreadable[i].Bucket+"."+unreadable[i].Key < unreadable[j].Bucket+"."+unreadable[j].Key
})
return Snapshot{Rows: rows, Nodes: nodes, Offline: offline, Stuck: stuck, Unreadable: unreadable,
- Groups: m.groupRows(), Fingerprints: m.fingerprintRows()}
+ Groups: m.groupRows(), Fingerprints: m.fingerprintRows(), Asks: m.askRows()}
}
// groupRows are the readable candidate groups, ordered by group, their pull requests by repository and number.
@@ -524,3 +568,26 @@ const (
StatusAssigned = "assigned"
StatusLeftBehind = "left behind"
)
+
+// askRows are the readable asks with no answer under their key, ordered by key: each with its headline, its words, the
+// pull requests it covers (repository, number, head shortened to 12 digits) and how to answer.
+func (m *Model) askRows() []AskRow {
+ out := []AskRow{}
+ for key, a := range m.asks {
+ if a.err != "" || m.answers[key] {
+ continue
+ }
+ row := AskRow{Key: key, Group: a.value.Group, Headline: a.value.Ask.Headline, Words: a.value.Ask.Explanation,
+ Expires: a.value.Ask.Expires, How: AnswerOnThePhone, Pulls: []string{}}
+ for _, h := range a.value.Heads {
+ head := h.Head
+ if len(head) > 12 {
+ head = head[:12]
+ }
+ row.Pulls = append(row.Pulls, fmt.Sprintf("%s#%d @%s", h.Repository, h.Number, head))
+ }
+ out = append(out, row)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
+ return out
+}
diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go
index 1a751e3..f3bc40e 100644
--- a/modules/mesh-delivery-view/manifest_test.go
+++ b/modules/mesh-delivery-view/manifest_test.go
@@ -74,7 +74,7 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
if m.Module != "mesh-delivery-view" {
t.Errorf("module = %q", m.Module)
}
- want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4
+ want := []string{"mesh-controller.assignments", "mesh-controller.builds", "mesh-delivery-gate.asks", "mesh-delivery-indexer.candidates", "mesh-delivery-indexer.modules", "mesh-delivery-teller.answers", "mesh-delivery-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1, 1241/AC4, 1345/AC3
got := slices.Clone(m.Reads)
slices.Sort(got)
if !reflect.DeepEqual(got, want) {
diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json
index f7a16ea..eb33058 100644
--- a/modules/mesh-delivery-view/module.json
+++ b/modules/mesh-delivery-view/module.json
@@ -6,8 +6,10 @@
"reads": [
"mesh-controller.assignments",
"mesh-controller.builds",
+ "mesh-delivery-gate.asks",
"mesh-delivery-indexer.candidates",
"mesh-delivery-indexer.modules",
+ "mesh-delivery-teller.answers",
"mesh-delivery-watchman.presence",
"mesh-delivery-watchman.stuck",
"node-engine.have"