From 9e9c2a7a577f7093ae83a7f45bcf27b193ca7a00 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 19 Oct 2026 15:10:16 +0200 Subject: [PATCH] Show the candidate groups and each module's fingerprint against its trunk build's in the delivery view (issue 1241) --- .../cmd/mesh-delivery-cli/main_test.go | 48 ++ .../mesh-delivery-view/integration_test.go | 91 +++ .../cmd/mesh-delivery-view/main.go | 2 +- modules/mesh-delivery-view/go.mod | 2 +- modules/mesh-delivery-view/go.sum | 4 +- .../internal/view/fingerprints_test.go | 190 ++++++ .../mesh-delivery-view/internal/view/serve.go | 52 +- .../mesh-delivery-view/internal/view/view.go | 159 ++++- modules/mesh-delivery-view/manifest_test.go | 2 +- modules/mesh-delivery-view/module.json | 2 + .../novox/mesh-sdk/go/asks/asks.go | 573 ++++++++++++++++++ .../novox/mesh-sdk/go/asks/condition.go | 149 +++++ .../novox/mesh-sdk/go/delivery/buildrecord.go | 41 ++ .../novox/mesh-sdk/go/delivery/delivery.go | 14 +- .../novox/mesh-sdk/go/delivery/fingerprint.go | 63 ++ .../novox/mesh-sdk/go/delivery/gate.go | 404 ++++++++++++ .../novox/mesh-sdk/go/delivery/source.go | 420 +++++++++++++ modules/mesh-delivery-view/vendor/modules.txt | 5 +- 18 files changed, 2207 insertions(+), 14 deletions(-) create mode 100644 modules/mesh-delivery-view/internal/view/fingerprints_test.go create mode 100644 modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go create mode 100644 modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/asks/condition.go create mode 100644 modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/buildrecord.go create mode 100644 modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/fingerprint.go create mode 100644 modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/gate.go create mode 100644 modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/source.go 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 bb3ba04..7105cad 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 @@ -9,6 +9,8 @@ import ( "strings" "testing" + "git.novox.be/novox/mesh-sdk/go/delivery" + "mesh-delivery-view/internal/view" ) @@ -61,3 +63,49 @@ func TestFailsLoudWhenTheViewIsNotThere(t *testing.T) { // 1134/AC6 t.Fatalf("an unknown flag must exit 2, got %d", code) } } + +func TestPrintsTheCandidateGroupsAndTheFingerprintComparison(t *testing.T) { // 1241/AC6 + m := view.New() + const head = "0123456789abcdef0123456789abcdef01234567" + cand, err := delivery.EncodeCandidate(delivery.Candidate{Group: "feat/x", Pulls: []delivery.CandidatePull{{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: head}, Modules: []string{"a"}}}, + HeadsHash: mustHash(t, delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: head})}) + if err != nil { + t.Fatal(err) + } + mod, err := delivery.EncodeModule(delivery.Module{None: "no tree", Repository: "novox/mesh-delivery", Commit: head}) + if err != nil { + t.Fatal(err) + } + m.Apply(view.Entry{Bucket: delivery.CandidatesBucket, Key: "feat/x", Value: cand}) + m.Apply(view.Entry{Bucket: delivery.ModulesBucket, Key: "a", Value: mod}) + 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) + var out, errOut bytes.Buffer + if code := run(nil, filepath.Join(dir, "cli.sock"), &out, &errOut); code != 0 { + t.Fatalf("exit %d: %s", code, errOut.String()) + } + for _, want := range []string{"CANDIDATES", "feat/x", "novox/mesh-delivery#12", head[:12], "none", "no tree"} { + if !strings.Contains(out.String(), want) { + t.Errorf("the command's table lacks %q:\n%s", want, out.String()) + } + } +} + +func mustHash(t *testing.T, h delivery.Head) string { + t.Helper() + s, err := delivery.HeadsHash([]delivery.Head{h}) + if err != nil { + t.Fatal(err) + } + return s +} 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 c7ac65a..2b98ddd 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 @@ -208,3 +208,94 @@ func TestProcessWholeShowsWhatTheWatchmanSays(t *testing.T) { // 1168/AC5 stuck(true) wait(func(s view.Snapshot) bool { return len(s.Offline) == 0 && len(s.Stuck) == 0 }) } + +// TestProcessWholeShowsTheIndexersBucketsOnceTheyExist starts the program whole before the indexer's buckets exist, then +// creates them and puts keys: the view's absent-bucket retry (follow, unchanged) finds them and the snapshot holds them. +// The retry is the view's own, every 5 s, so the wait here is 15 s, not 5. +func TestProcessWholeShowsTheIndexersBucketsOnceTheyExist(t *testing.T) { // 1241/AC5 + 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() + for _, b := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket} { + if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: b, History: 5}); err != nil { + t.Fatal(err) + } + } + 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 }) + + // The view is up and the indexer's buckets are not there. + for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + if _, err := view.Ask(socket); err == nil { + break + } + } + cands, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.CandidatesBucket, History: delivery.CandidatesHistory}) + if err != nil { + t.Fatal(err) + } + mods, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.ModulesBucket, History: delivery.ModulesHistory}) + if err != nil { + t.Fatal(err) + } + const head = "0123456789abcdef0123456789abcdef01234567" + hash, _ := delivery.HeadsHash([]delivery.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: head}}) + cand, err := delivery.EncodeCandidate(delivery.Candidate{Group: "feat/x", HeadsHash: hash, Pulls: []delivery.CandidatePull{{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: head}, Modules: []string{"a"}}}}) + if err != nil { + t.Fatal(err) + } + mod, err := delivery.EncodeModule(delivery.Module{None: "no tree", Repository: "novox/mesh-delivery", Commit: head}) + if err != nil { + t.Fatal(err) + } + if _, err := cands.Put(ctx, "feat/x", cand); err != nil { + t.Fatal(err) + } + if _, err := mods.Put(ctx, "a", mod); err != nil { + t.Fatal(err) + } + var last view.Snapshot + for deadline := time.Now().Add(15 * time.Second); time.Now().Before(deadline); time.Sleep(100 * time.Millisecond) { + if s, err := view.Ask(socket); err == nil { + last = s + if len(s.Groups) == 1 && len(s.Fingerprints) == 1 { + if s.Groups[0].Group != "feat/x" || s.Fingerprints[0].Module != "a" || s.Fingerprints[0].State != view.FPNone { + t.Fatalf("snapshot = %+v", s) + } + return + } + } + } + t.Fatalf("the snapshot never held the indexer's keys; last: %+v", last) +} 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 82757a8..9a307f8 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} { + for _, bucket := range []string{delivery.HaveBucket, view.AssignmentsBucket, view.BuildsBucket, delivery.PresenceBucket, delivery.StuckBucket, delivery.CandidatesBucket, delivery.ModulesBucket} { 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 6f830d0..12c8880 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.20261018184557-238440d6f8c4 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019124204-9ade199740e3 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 cf28001..cb9936c 100644 --- a/modules/mesh-delivery-view/go.sum +++ b/modules/mesh-delivery-view/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4 h1:yYDxFRJoH59kiCoEulD+FBwctyf9CrtKIxlEAsVL39Q= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019124204-9ade199740e3 h1:Mu2QiGbL/sZFU5ya4lGUyQOB5OKXPy/KB40XfXTf2EU= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019124204-9ade199740e3/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/fingerprints_test.go b/modules/mesh-delivery-view/internal/view/fingerprints_test.go new file mode 100644 index 0000000..24189a9 --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/fingerprints_test.go @@ -0,0 +1,190 @@ +package view + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +const ( + hA = "0123456789abcdef0123456789abcdef01234567" + hB = "89abcdef0123456789abcdef0123456789abcdef" + fF = "src1:1111111111111111111111111111111111111111111111111111111111111111" + fG = "src1:2222222222222222222222222222222222222222222222222222222222222222" +) + +func candidate(t *testing.T, group string, pulls ...delivery.CandidatePull) Entry { + t.Helper() + heads := make([]delivery.Head, len(pulls)) + for i, p := range pulls { + heads[i] = p.Head + } + hash, err := delivery.HeadsHash(heads) + if err != nil { + t.Fatal(err) + } + b, err := delivery.EncodeCandidate(delivery.Candidate{Group: group, HeadsHash: hash, Pulls: pulls}) + if err != nil { + t.Fatal(err) + } + return Entry{Bucket: delivery.CandidatesBucket, Key: group, Value: b, At: t0} +} + +func pullOf(repo string, n int, head string, modules ...string) delivery.CandidatePull { + if modules == nil { + modules = []string{} + } + return delivery.CandidatePull{Head: delivery.Head{Repository: repo, Number: n, Head: head}, Modules: modules} +} + +func modEntry(t *testing.T, name string, m delivery.Module) Entry { + t.Helper() + b, err := delivery.EncodeModule(m) + if err != nil { + t.Fatal(err) + } + return Entry{Bucket: delivery.ModulesBucket, Key: name, Value: b, At: t0} +} + +// trunkBuild is a build record as the controller writes it (mesh-sdk go/delivery/testdata/build-record-build-agent.json is +// the captured shape: id, state, repository, ref, module, commit, path, source-fingerprint, source-inputs, at). +func trunkBuild(module, id, ref, state, fp, at string) []Entry { + rec := `{"id":"` + id + `","state":"` + state + `","repository":"novox/mesh-delivery","ref":"` + ref + `","module":"` + module + `","commit":"7a74cee83f88e6585a5511c3cc53bdf0f5303d20","path":"modules/` + module + `","at":"` + at + `"` + if fp != "" { + rec += `,"source-fingerprint":"` + fp + `"` + } + rec += `}` + return []Entry{ + {Bucket: BuildsBucket, Key: module + ".build." + id, Value: []byte(rec), At: t0}, + {Bucket: BuildsBucket, Key: module + ".newest", Value: []byte(id), At: t0}, + } +} + +func fpRow(t *testing.T, s Snapshot, module string) FingerprintRow { + t.Helper() + for _, f := range s.Fingerprints { + if f.Module == module { + return f + } + } + t.Fatalf("no fingerprint row for %s in %+v", module, s.Fingerprints) + return FingerprintRow{} +} + +func TestSnapshotListsTheCandidateGroups(t *testing.T) { // 1241/AC1 + m := New() + fill(m, + candidate(t, "feat/x", pullOf("novox/mesh-delivery", 12, hA, "a", "b"), pullOf("novox/mesh-catalog", 300, hB, "c")), + candidate(t, "feat/y", pullOf("novox/mesh-delivery", 13, hB))) + g := m.Snapshot().Groups + if len(g) != 2 || g[0].Group != "feat/x" || g[1].Group != "feat/y" { + t.Fatalf("groups = %+v", g) + } + // sorted by repository: mesh-catalog before mesh-delivery + if len(g[0].Pulls) != 2 || g[0].Pulls[0].Repository != "novox/mesh-catalog" || g[0].Pulls[0].Number != 300 { + t.Fatalf("pulls = %+v", g[0].Pulls) + } + p := g[0].Pulls[1] + if p.Repository != "novox/mesh-delivery" || p.Number != 12 || p.Head != hA[:12] || strings.Join(p.Modules, ",") != "a,b" { + t.Errorf("pull = %+v, want #12 with head %s and modules a,b", p, hA[:12]) + } + if len(g[1].Pulls) != 1 || len(g[1].Pulls[0].Modules) != 0 { + t.Errorf("feat/y = %+v", g[1]) + } + m.Apply(Entry{Bucket: delivery.CandidatesBucket, Key: "feat/x", Deleted: true}) + if g := m.Snapshot().Groups; len(g) != 1 || g[0].Group != "feat/y" { + t.Errorf("after the delete: %+v", g) + } +} + +func TestFingerprintAgainstTheNewestTrunkBuild(t *testing.T) { // 1241/AC2 + m := New() + fill(m, trunkBuild("a", "build-1", "main", "built", fF, "2026-10-19T10:00:00Z")...) + fill(m, trunkBuild("d", "build-2", "main", "built", fF, "2026-10-19T10:00:00Z")...) + fill(m, + modEntry(t, "a", delivery.Module{FP: fF, Repository: "novox/mesh-delivery", Commit: hA}), + modEntry(t, "d", delivery.Module{FP: fG, Repository: "novox/mesh-delivery", Commit: hA}), + modEntry(t, "b", delivery.Module{None: "an artifact compiles a Go program from a build source that git trees do not pin: no fingerprint before release 4", Repository: "novox/mesh-delivery", Commit: hA}), + modEntry(t, "e", delivery.Module{FP: fG, Repository: "novox/mesh-delivery", Commit: hA})) + s := m.Snapshot() + if r := fpRow(t, s, "a"); r.State != FPSame || r.Indexer != fF || r.Build != fF { + t.Errorf("a = %+v, want same", r) + } + if r := fpRow(t, s, "d"); r.State != FPDiffers || r.Indexer != fG || r.Build != fF { + t.Errorf("d = %+v, want differs with both", r) + } + if r := fpRow(t, s, "b"); r.State != FPNone || !strings.Contains(r.Reason, "release 4") || r.Indexer != "" { + t.Errorf("b = %+v, want none with the reason", r) + } + if r := fpRow(t, s, "e"); r.State != FPNoBuild || r.Indexer != fG || r.Build != "" { + t.Errorf("e = %+v, want no build with the indexer's alone", r) + } +} + +func TestOnlyTheNewestSuccessfulTrunkBuildCounts(t *testing.T) { // 1241/AC2 (the newest key is only a hint) + m := New() + fill(m, trunkBuild("a", "build-1", "main", "built", fG, "2026-10-19T09:00:00Z")...) + fill(m, trunkBuild("a", "build-2", "main", "built", fF, "2026-10-19T10:00:00Z")...) + fill(m, trunkBuild("a", "build-3", "main", "failed", fG, "2026-10-19T11:00:00Z")...) // newer, not built + fill(m, trunkBuild("a", "build-4", "feat/x", "built", fG, "2026-10-19T12:00:00Z")...) // newer, not trunk; also the newest key + fill(m, modEntry(t, "a", delivery.Module{FP: fF, Repository: "novox/mesh-delivery", Commit: hA})) + if r := fpRow(t, m.Snapshot(), "a"); r.State != FPSame || r.Build != fF { + t.Errorf("a = %+v, want same with the 10:00 trunk build's fingerprint", r) + } +} + +func TestBuildFingerprintIsReadWithTheSDKRecord(t *testing.T) { // 1241/AC3 + // The record as the controller's encoder wrote it (mesh-sdk go/delivery/testdata/build-record-build-agent.json), whole, + // with its source-inputs under their Go field names: the view reads source-fingerprint from it. + const rec = `{"id":"build-1792395447088936520","state":"built","repository":"novox/mesh-controller","ref":"main","module":"build-agent","commit":"7a74cee83f88e6585a5511c3cc53bdf0f5303d20","path":"modules/build-agent","manifest-digest":"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","source-fingerprint":"src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7","source-inputs":{"Module":"build-agent","Tree":"11c0b4f90cae0d6e4ef9cc48cbfdf9f6dbe52bca","Bases":["sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"],"Contexts":{"server":"narrow:d51ca744fd11af59d616c858f76c27b3e58e16e11fbdc0c720eb9d7d19d55fac"},"Toolchains":null,"Unpinned":""},"made":[{"name":"server","kind":"image","reference":"registry.example/build-agent@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc"}],"at":"2026-10-19T09:38:00Z"}` + const fp = "src1:e846efe73716b78e80860baaac96153de2cb76551325753838ca245d2160a3d7" + m := New() + fill(m, Entry{Bucket: BuildsBucket, Key: delivery.BuildKey("build-agent", "build-1792395447088936520"), Value: []byte(rec), At: t0}, + Entry{Bucket: BuildsBucket, Key: delivery.NewestKey("build-agent"), Value: []byte("build-1792395447088936520"), At: t0}, + modEntry(t, "build-agent", delivery.Module{FP: fp, Repository: "novox/mesh-controller", Commit: hA})) + if r := fpRow(t, m.Snapshot(), "build-agent"); r.State != FPSame || r.Build != fp { + t.Errorf("build-agent = %+v, want same with %s", r, fp) + } +} + +func shown(t *testing.T) *Model { + m := New() + fill(m, trunkBuild("a", "build-1", "main", "built", fF, "2026-10-19T10:00:00Z")...) + fill(m, + candidate(t, "feat/x", pullOf("novox/mesh-delivery", 12, hA, "a"), pullOf("novox/mesh-catalog", 300, hB, "c")), + modEntry(t, "a", delivery.Module{FP: fG, Repository: "novox/mesh-delivery", Commit: hA}), + modEntry(t, "b", delivery.Module{None: "no tree", Repository: "novox/mesh-delivery", Commit: hA})) + return m +} + +func TestTableAndPagePrintTheGroupsAndTheComparison(t *testing.T) { // 1241/AC6 + m := shown(t) + var out bytes.Buffer + Table(&out, m.Snapshot()) + for _, want := range []string{"CANDIDATES", "feat/x", "novox/mesh-delivery#12", hA[:12], "novox/mesh-catalog#300", "FINGERPRINT", "differs", "indexer " + fG, "build " + fF, "no tree"} { + 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{"CANDIDATES", "feat/x", "novox/mesh-delivery#12", hA[:12], "FINGERPRINTS", "differs", fG, fF, "no tree"} { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("the page lacks %q:\n%s", want, rec.Body.String()) + } + } +} + +func TestUnreadableIndexerValuesAreNamed(t *testing.T) { // never dropped silently + m := New() + fill(m, Entry{Bucket: delivery.CandidatesBucket, Key: "feat/x", Value: []byte("{"), At: t0}, + Entry{Bucket: delivery.ModulesBucket, Key: "a", Value: []byte("{"), At: t0}) + s := m.Snapshot() + if len(s.Unreadable) != 2 || len(s.Groups) != 0 || len(s.Fingerprints) != 0 { + t.Errorf("snapshot = %+v", s) + } +} diff --git a/modules/mesh-delivery-view/internal/view/serve.go b/modules/mesh-delivery-view/internal/view/serve.go index 5bf3247..593a36c 100644 --- a/modules/mesh-delivery-view/internal/view/serve.go +++ b/modules/mesh-delivery-view/internal/view/serve.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "text/tabwriter" "time" ) @@ -118,6 +119,39 @@ func rowText(r Row) []string { return []string{r.Node, r.Module, r.Assigned, r.Have, r.Phase, r.Health, since(r.Since), r.Detail, what} } +// groupText is one line per pull request of a group: the branch, `#`, its head, its modules. +func groupText(s Snapshot) [][]string { + var out [][]string + for _, g := range s.Groups { + for i, p := range g.Pulls { + group := "" + if i == 0 { + group = g.Group + } + out = append(out, []string{group, fmt.Sprintf("%s#%d", p.Repository, p.Number), p.Head, strings.Join(p.Modules, ", ")}) + } + } + return out +} + +// fingerprintText is one line per module: the indexer's fingerprint beside the build's, or the reason it has none. +func fingerprintText(s Snapshot) [][]string { + var out [][]string + for _, f := range s.Fingerprints { + what := f.Indexer + switch f.State { + case FPDiffers: + what = "indexer " + f.Indexer + "; build " + f.Build + case FPNone: + what = f.Reason + case FPNoBuild: + what = "indexer " + f.Indexer + "; no successful trunk build recorded a fingerprint" + } + out = append(out, []string{f.Module, f.State, what}) + } + return out +} + var head = []string{"NODE", "MODULE", "ASSIGNED", "HAVE", "PHASE", "HEALTH", "SINCE", "DETAIL", "WAITS"} // Table prints the snapshot as a table. @@ -143,6 +177,20 @@ func Table(w io.Writer, s Snapshot) { row([]string{n.Node, fmt.Sprint(n.Windows), fmt.Sprint(n.Busy), fmt.Sprint(n.Strays), since(n.Since)}) } } + if g := groupText(s); len(g) > 0 { + fmt.Fprintln(tw) + row([]string{"CANDIDATES", "PULL REQUEST", "HEAD", "MODULES"}) + for _, cells := range g { + row(cells) + } + } + if f := fingerprintText(s); len(f) > 0 { + fmt.Fprintln(tw) + row([]string{"MODULE", "FINGERPRINT", "INDEXER AGAINST BUILD"}) + for _, cells := range f { + row(cells) + } + } if len(s.Offline) > 0 { fmt.Fprintln(tw) row([]string{"OFFLINE", "NAME", "STATE", "SINCE"}) @@ -178,6 +226,8 @@ var page = template.Must(template.New("p").Parse(`Delivery

What each node runs

{{range .Head}}{{end}} {{range .Rows}}{{range .}}{{end}}{{end}}
{{.}}
{{.}}
+{{if .Groups}}

CANDIDATES

{{range .Groups}}{{range .}}{{end}}{{end}}
GROUPPULL REQUESTHEADMODULES
{{.}}
{{end}} +{{if .Fingerprints}}

FINGERPRINTS

{{range .Fingerprints}}{{range .}}{{end}}{{end}}
MODULESTATEINDEXER AGAINST BUILD
{{.}}
{{end}} {{if .Offline}}

OFFLINE

{{range .Offline}}{{range .}}{{end}}{{end}}
{{.}}
{{end}} {{if .Stuck}}

STUCK

{{range .Stuck}}{{range .}}{{end}}{{end}}
BUCKETKEYWAITED FORSINCE
{{.}}
{{end}} {{if .Unreadable}}

UNREADABLE

{{range .Unreadable}}{{range .}}{{end}}{{end}}
{{.}}
{{end}}`)) @@ -210,6 +260,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, "Offline": off, "Stuck": stuck, "Unreadable": bad}) + page.Execute(w, map[string]any{"Head": head, "Rows": rows, "Groups": groupText(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 b838e32..e3b8e08 100644 --- a/modules/mesh-delivery-view/internal/view/view.go +++ b/modules/mesh-delivery-view/internal/view/view.go @@ -57,6 +57,45 @@ type Snapshot struct { Stuck []StuckRow `json:"stuck"` // Unreadable is every watchman value the view could not read, named by its key: never dropped silently. Unreadable []UnreadableRow `json:"unreadable"` + // Groups is every candidate group the indexer holds: the pull requests that share a branch, with the modules each touches. + 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"` +} + +// 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"` + Pulls []GroupPull `json:"pulls"` +} + +// GroupPull is one pull request of a group, its head shortened to 12 digits, and the modules it touches. +type GroupPull struct { + Repository string `json:"repository"` + Number int `json:"number"` + Head string `json:"head"` + Modules []string `json:"modules"` +} + +// The states of a module's fingerprint against its newest successful trunk build's. +const ( + FPSame = "same" + FPDiffers = "differs" + FPNone = "none" + FPNoBuild = "no build" +) + +// FingerprintRow compares the indexer's fingerprint of a module at a trunk merge with the source fingerprint its newest +// successful trunk build recorded. State is same, differs (both are given), none (the indexer could not fingerprint it: +// Reason says why) or no build (the indexer's alone is given, no successful trunk build recorded one). +type FingerprintRow struct { + Module string `json:"module"` + Repository string `json:"repository,omitempty"` + Commit string `json:"commit,omitempty"` + State string `json:"state"` + Indexer string `json:"indexer,omitempty"` + Build string `json:"build,omitempty"` + Reason string `json:"reason,omitempty"` } // OfflineRow is a node ("node") or a part's instance ("part") that is offline, since the presence entry's time. @@ -87,8 +126,10 @@ type UnreadableRow struct { type Model struct { mu sync.Mutex assigns map[string]assignment - builds map[string]build // by .build. - newest map[string]string // module -> id + 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 + newest map[string]string // module -> id haves map[string]haveEntry nodes map[string]nodeEntry // the watchman's buckets, by key @@ -136,6 +177,23 @@ type build struct { func (b build) done() bool { return b.State == "" && b.Failed == "" } +// buildEntry is a build as the view reads it: its own fields (state, failure) and the SDK's record (the source +// fingerprint, the ref, the time), decoded as the controller writes them. +type buildEntry struct { + build + rec delivery.BuildRecord +} + +type groupEntry struct { + value delivery.Candidate + err string +} + +type moduleEntry struct { + value delivery.Module + err string +} + type haveEntry struct { have delivery.Have err string @@ -150,7 +208,7 @@ type nodeEntry struct { // New is an empty model. func New() *Model { - return &Model{assigns: map[string]assignment{}, builds: map[string]build{}, newest: map[string]string{}, + return &Model{assigns: map[string]assignment{}, builds: map[string]buildEntry{}, groups: map[string]groupEntry{}, modules: map[string]moduleEntry{}, newest: map[string]string{}, haves: map[string]haveEntry{}, nodes: map[string]nodeEntry{}, presence: map[string]presenceEntry{}, stuck: map[string]stuckEntry{}} } @@ -205,6 +263,20 @@ func (m *Model) Apply(e Entry) { return } m.assigns[a.Node+"."+a.Module] = a + case delivery.CandidatesBucket: + if e.Deleted { + delete(m.groups, e.Key) + return + } + c, err := delivery.DecodeCandidate(e.Value) + m.groups[e.Key] = groupEntry{value: c, err: errText(err)} + case delivery.ModulesBucket: + if e.Deleted { + delete(m.modules, e.Key) + return + } + mod, err := delivery.DecodeModule(e.Value) + m.modules[e.Key] = moduleEntry{value: mod, err: errText(err)} case BuildsBucket: module, rest, ok := strings.Cut(e.Key, ".") if !ok { @@ -226,7 +298,12 @@ func (m *Model) Apply(e Entry) { if json.Unmarshal(e.Value, &b) != nil { return } - m.builds[e.Key] = b + // The source fingerprint is read with the SDK's record, not with the view's own struct. + var rec delivery.BuildRecord + if json.Unmarshal(e.Value, &rec) != nil { + return + } + m.builds[e.Key] = buildEntry{build: b, rec: rec} } } } @@ -327,6 +404,16 @@ func (m *Model) Snapshot() Snapshot { } stuck = append(stuck, StuckRow{Bucket: st.value.Bucket, Key: st.value.Key, Waited: st.value.Waited, Since: st.value.Since}) } + for key, g := range m.groups { + if g.err != "" { + unreadable = append(unreadable, UnreadableRow{Bucket: delivery.CandidatesBucket, Key: key, Error: g.err}) + } + } + for key, mod := range m.modules { + if mod.err != "" { + unreadable = append(unreadable, UnreadableRow{Bucket: delivery.ModulesBucket, Key: key, Error: mod.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 @@ -337,7 +424,69 @@ func (m *Model) Snapshot() Snapshot { sort.Slice(unreadable, func(i, j int) bool { 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} + return Snapshot{Rows: rows, Nodes: nodes, Offline: offline, Stuck: stuck, Unreadable: unreadable, + Groups: m.groupRows(), Fingerprints: m.fingerprintRows()} +} + +// groupRows are the readable candidate groups, ordered by group, their pull requests by repository and number. +func (m *Model) groupRows() []GroupRow { + out := []GroupRow{} + for key, g := range m.groups { + if g.err != "" { + continue + } + row := GroupRow{Group: key, Pulls: []GroupPull{}} + for _, p := range g.value.Pulls { + head := p.Head.Head + if len(head) > 12 { + head = head[:12] + } + row.Pulls = append(row.Pulls, GroupPull{Repository: p.Repository, Number: p.Number, Head: head, Modules: append([]string{}, p.Modules...)}) + } + sort.Slice(row.Pulls, func(i, j int) bool { + a, b := row.Pulls[i], row.Pulls[j] + return a.Repository < b.Repository || (a.Repository == b.Repository && a.Number < b.Number) + }) + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { return out[i].Group < out[j].Group }) + return out +} + +// fingerprintRows compare each module the indexer fingerprinted with the source fingerprint of the module's newest +// successful trunk build (ref main, state built) that recorded one. The newest key is any state and ref, so it is not +// used: the records are. +func (m *Model) fingerprintRows() []FingerprintRow { + newest := map[string]buildEntry{} + for _, b := range m.builds { + if b.rec.Module == "" || b.rec.Ref != "main" || b.rec.State != "built" || b.rec.SourceFingerprint == "" { + continue + } + if old, ok := newest[b.rec.Module]; !ok || b.rec.At.After(old.rec.At) || (b.rec.At.Equal(old.rec.At) && b.rec.ID > old.rec.ID) { + newest[b.rec.Module] = b + } + } + out := []FingerprintRow{} + for name, mod := range m.modules { + if mod.err != "" { + continue + } + row := FingerprintRow{Module: name, Repository: mod.value.Repository, Commit: mod.value.Commit} + b, hasBuild := newest[name] + switch { + case mod.value.None != "": + row.State, row.Reason = FPNone, mod.value.None + case !hasBuild: + row.State, row.Indexer = FPNoBuild, mod.value.FP + case b.rec.SourceFingerprint == mod.value.FP: + row.State, row.Indexer, row.Build = FPSame, mod.value.FP, b.rec.SourceFingerprint + default: + row.State, row.Indexer, row.Build = FPDiffers, mod.value.FP, b.rec.SourceFingerprint + } + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { return out[i].Module < out[j].Module }) + return out } // count is the number of items of a carried JSON array or object, and 0 for anything else. diff --git a/modules/mesh-delivery-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index 1506452..1a751e3 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-watchman.presence", "mesh-delivery-watchman.stuck", "node-engine.have"} // 1168/AC1 + 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 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 c8661b9..f7a16ea 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -6,6 +6,8 @@ "reads": [ "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" diff --git a/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go new file mode 100644 index 0000000..596bb8f --- /dev/null +++ b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/asks/asks.go @@ -0,0 +1,573 @@ +// Package asks is the contract of asking a person and answering on a channel (novox/hq ADR 0259): the +// shapes an asker, the router and a channel exchange on the bus, and the subjects they travel on. No +// transport and no channel's service: an asker publishes an Ask under its own name and acts on the Warrant +// it hears; the router holds the ask, sends channels a Message, and judges the Choice a channel says; a +// channel shows a Message and says what was chosen and by whom, as its service authenticated it. +package asks + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// The seats (novox/hq ADR 0259 §3). +const ( + // Seat is held by the router: an ask and its cancel are its accepts, a warrant its event, each named by + // the asker. + Seat = "operator-channel" + // ChannelSeat is the kinded bench a channel holds to show and say: its accepts carry the kind. + ChannelSeat = "channel" + // IntakeSeat is the kinded bench a channel holds to say what was chosen: its events and proofs carry + // the kind. + IntakeSeat = "intake" +) + +// AskSubject is where an asker publishes an ask, CancelSubject its cancel, and DecidedSubject where it +// hears the warrant, or the ask's end without one. +func AskSubject(asker string) string { return "mesh.seat." + Seat + ".accept.ask." + asker } +func CancelSubject(asker string) string { return "mesh.seat." + Seat + ".accept.cancel." + asker } +func DecidedSubject(asker string) string { return "mesh.seat." + Seat + ".event.decided." + asker } + +// The work a channel takes, on ChannelSubject. +const ( + Show = "show" // a message offering answers + Edit = "edit" // a message shown before, replaced + Send = "send" // a message offering nothing +) + +// ChannelSubject is where the router sends a channel of a kind its work. +func ChannelSubject(verb, kind string) string { + return "mesh.seat." + ChannelSeat + ".accept." + verb + "." + kind +} + +// What a channel says, on IntakeSubject. +const ( + Chosen = "choice" // a button tapped + Link = "link" // somebody asked to be linked as the operator +) + +// IntakeSubject is where a channel of a kind says what arrived. +func IntakeSubject(what, kind string) string { + return "mesh.seat." + IntakeSeat + ".event." + what + "." + kind +} + +// CodeProof is the one proof verb: a code the operator typed, carried by request and reply, never kept. +const CodeProof = "code" + +// ProofSubject is where a channel of a kind asks a proof. +func ProofSubject(verb, kind string) string { + return "mesh.seat." + IntakeSeat + ".proof." + verb + "." + kind +} + +// A Level is how much proof an option's answer needs (novox/hq ADR 0234 §8, the glossary's assurance level). +type Level string + +const ( + // Acknowledge performs only what any granted principal may already do: silencing, details. No proof. + Acknowledge Level = "acknowledge" + // Approve needs one proof: a verified sender (a linked account, linked an hour or more) or a code. + Approve Level = "approve" + // Destroy needs two proofs, one of them a code. + Destroy Level = "destroy" +) + +// Rank orders the levels; an unknown level ranks above every known one, so it is never taken as less. +func (l Level) Rank() int { + switch l { + case Acknowledge: + return 0 + case Approve: + return 1 + case Destroy: + return 2 + } + return 3 +} + +// Operator is the one role an ask may be answered by today. +const Operator = "operator" + +// Option is one answer an ask offers: a label for the button, what it does in plain words, its level. +type Option struct { + ID string `json:"id"` + Label string `json:"label"` + Does string `json:"does"` + Level Level `json:"level"` + // Binds is the digest of exactly what the asker performs when this option is chosen — the verb, the + // machine and every argument — as ActDigest gives it. It travels in the ask, so the router's warrant, + // which names the ask's digest, names it too: a warrant then authorises that act and no other, and an + // asker whose record of the act changed after it asked finds the digests differ and does nothing. + // Required on an option above acknowledge. + Binds string `json:"binds,omitempty"` +} + +// An Act is what an option binds: named fields, each a string — the verb, the machine, the level, and each +// argument under a name of its own ("arg.delivery"). Flat on purpose: its digest is over these names and +// values alone, never over how a language happens to encode a struct. +type Act map[string]string + +// ActDigest is the digest an asker puts in Option.Binds: SHA-256 over the act's canonical encoding (canonical), +// written "sha256:". The same names and values give the same digest in any language, whatever order +// they were set in; a field renamed, added or emptied gives another. +func ActDigest(act Act) (string, error) { + if len(act) == 0 { + return "", fmt.Errorf("the act cannot be digested: it names nothing") + } + keys := make([]string, 0, len(act)) + for k := range act { + if k == "" { + return "", fmt.Errorf("the act cannot be digested: a field has no name") + } + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + b.WriteString("novox.act.v1\n") + for _, k := range keys { + canonical(&b, k) + canonical(&b, act[k]) + } + sum := sha256.Sum256([]byte(b.String())) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// canonical writes one value as its length in bytes, a colon, the bytes and a newline: no value can be read +// as another's end or start, so two different sequences of values never encode the same. +func canonical(b *strings.Builder, v string) { + b.WriteString(strconv.Itoa(len(v))) + b.WriteByte(':') + b.WriteString(v) + b.WriteByte('\n') +} + +// Digest is the digest of the ask exactly as its asker published it: its id, words, options with what each +// binds, who answers, and its expiry. The router puts it in the warrant (Warrant.AskDigest), and an asker +// acts only on a warrant whose digest is that of the ask it keeps — so a warrant answers one ask, as the +// person was shown it, and nothing published under the same id before or after. +// +// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC +// RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask +// later changes no digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow +// the options only when the ask gives either: an ask without them digests as it did before they existed, so +// a router and an asker of different builds still agree on every such ask. +func (a Ask) Digest() string { + var b strings.Builder + b.WriteString("novox.ask.v1\n") + for _, v := range []string{a.ID, a.Headline, a.Explanation, a.Who, + a.Expires.UTC().Format(time.RFC3339Nano), a.OnExpiry, a.About, strconv.FormatBool(a.Urgent), + strconv.Itoa(len(a.Options))} { + canonical(&b, v) + } + for _, o := range a.Options { + for _, v := range []string{o.ID, o.Label, o.Does, string(o.Level), o.Binds} { + canonical(&b, v) + } + } + // Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever + // it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes — + // none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks + // encode the same (the review of mesh-sdk #25). An ask with none of them digests as before they existed, and + // one without WholeDetails as before it existed. + if a.Whole != "" || a.Details != "" || a.WholeDetails != "" { + canonical(&b, a.Whole) + canonical(&b, a.Details) + } + if a.WholeDetails != "" { + canonical(&b, a.WholeDetails) + } + sum := sha256.Sum256([]byte(b.String())) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// Ask is a request for a person's word (novox/hq ADR 0259 §4). +type Ask struct { + // ID is the asker's own, unique to it. + ID string `json:"id"` + // Headline names the thing and what is wrong, in a few plain words; Explanation is what happened and + // what it means. Both are held to the plain rule and the content rule by the router. + Headline string `json:"headline"` + Explanation string `json:"explanation"` + Options []Option `json:"options"` + // Who may answer: Operator. + Who string `json:"who"` + // Expires is when the ask ends unanswered; OnExpiry is what the asker then does, in words the person + // is shown ("the delivery stays held"). An ask that authorises never defaults. + Expires time.Time `json:"expires"` + OnExpiry string `json:"on-expiry"` + // About is what the ask is about (a condition's key): a newer ask about it replaces the older. + About string `json:"about,omitempty"` + Urgent bool `json:"urgent,omitempty"` + // Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind + // that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the + // person approves — a mount point, a share, a private address — must be readable where they approve it. + // The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left; + // Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere. + Whole string `json:"whole,omitempty"` + // Details are the ask's secondary words, line by line under the content rule: who proposed it, a + // fingerprint, how to read it whole. The router shows them in the note of the ask's own message + // (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq + // issue 639). + Details string `json:"details,omitempty"` + // WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown + // in place of the explanation (a kind that proves who answers, given the ask's answers): every value an + // approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like + // Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty, + // Details are shown everywhere. + WholeDetails string `json:"whole-details,omitempty"` +} + +// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary +// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at +// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when, +// the fingerprint and how to read it whole are Details, never the explanation. +const ( + SummaryLength = 300 + MostPoints = 3 + PointLength = 160 +) + +// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than +// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not +// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that +// does not hold to it. +func Glance(explanation string) error { + var lines []string + for _, l := range strings.Split(explanation, "\n") { + if strings.TrimSpace(l) != "" { + lines = append(lines, strings.TrimSpace(l)) + } + } + var problems []string + switch { + case len(lines) == 0: + problems = append(problems, "it has no summary") + case len([]rune(lines[0])) > SummaryLength: + problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength)) + } + if len(lines) > 1+MostPoints { + problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints)) + } + for i, l := range lines { + if i > 0 && len([]rune(l)) > PointLength { + problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength)) + } + } + if len(problems) > 0 { + return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; ")) + } + return nil +} + +// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4). +const ( + MostOptions = 4 + MostOpen = 3 + ApproveLasts = 24 * time.Hour + DestroyLasts = 10 * time.Minute + HeadlineLength = 60 + LabelLength = 24 +) + +var usableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`) + +// UsableID says whether a name can be an ask's or an option's id: a key and a subject token both. +func UsableID(id string) bool { return usableID.MatchString(id) } + +// Highest is the highest level among the ask's options. +func (a Ask) Highest() Level { + high := Acknowledge + for _, o := range a.Options { + if o.Level.Rank() > high.Rank() { + high = o.Level + } + } + return high +} + +// Option is the option of this id, or false. +func (a Ask) Option(id string) (Option, bool) { + for _, o := range a.Options { + if o.ID == id { + return o, true + } + } + return Option{}, false +} + +// Check is what an ask is held to before anything is shown: every refusal, in words its asker can act on. +func (a Ask) Check(now time.Time) error { + var problems []string + say := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) } + if !UsableID(a.ID) { + say("its id %q is not letters, digits, - and _, at most 64", a.ID) + } + if strings.TrimSpace(a.Headline) == "" || len([]rune(a.Headline)) > HeadlineLength { + say("its headline is empty or longer than %d characters", HeadlineLength) + } + if strings.TrimSpace(a.Explanation) == "" { + say("it explains nothing") + } + if a.Who != Operator { + say("it is answered by %q, and only the operator answers today", a.Who) + } + if len(a.Options) == 0 || len(a.Options) > MostOptions { + say("it offers %d options, and an ask offers one to %d", len(a.Options), MostOptions) + } + seen := map[string]bool{} + for _, o := range a.Options { + switch { + case !UsableID(o.ID): + say("an option's id %q is not letters, digits, - and _", o.ID) + case seen[o.ID]: + say("the option %s is offered twice", o.ID) + } + seen[o.ID] = true + if strings.TrimSpace(o.Label) == "" || len([]rune(o.Label)) > LabelLength { + say("the option %s's label is empty or longer than %d characters", o.ID, LabelLength) + } + if strings.TrimSpace(o.Does) == "" { + say("the option %s does not say what it does", o.ID) + } + if o.Level.Rank() > Destroy.Rank() { + say("the option %s has the level %q, which is none of acknowledge, approve, destroy", o.ID, o.Level) + } + if o.Level != Acknowledge && !strings.HasPrefix(o.Binds, "sha256:") { + say("the option %s authorises and does not bind what it performs (its binds is not an ActDigest)", o.ID) + } + } + if !a.Expires.After(now) { + say("it expires before it is asked") + } + switch a.Highest() { + case Approve: + if a.Expires.After(now.Add(ApproveLasts)) { + say("an ask that approves lasts at most %s", ApproveLasts) + } + case Destroy: + if a.Expires.After(now.Add(DestroyLasts)) { + say("an ask that destroys lasts at most %s", DestroyLasts) + } + } + if a.Highest() != Acknowledge && strings.TrimSpace(a.OnExpiry) == "" { + say("it does not say what happens when nobody answers, and an ask that authorises never defaults") + } + if a.About != "" && strings.ContainsAny(a.About, " \n") { + say("what it is about is a key, without spaces") + } + if len(problems) > 0 { + return errors.New("the ask is refused: " + strings.Join(problems, "; ")) + } + return nil +} + +// Outcome is how an ask ended. +type Outcome string + +const ( + OutcomeChosen Outcome = "chosen" // a person chose an option: a warrant + OutcomeExpired Outcome = "expired" // nobody answered in time + OutcomeCancelled Outcome = "cancelled" // its asker took it back + OutcomeReplaced Outcome = "replaced" // a newer ask about the same thing replaced it + OutcomeRefused Outcome = "refused" // it was never shown: Words says why +) + +// Person is who chose, as the router verified them. +type Person struct { + // Who is the role: Operator. + Who string `json:"who"` + // Kind is the channel kind they answered on, Identity their account on that service, Display the name + // the service shows, and Verified how the router knew it was them. + Kind string `json:"kind"` + Identity string `json:"identity"` + Display string `json:"display,omitempty"` + Verified string `json:"verified"` +} + +// Warrant is the router's record that a person chose one option of one ask, or the ask's end without one +// (novox/hq ADR 0259 §6). It carries no secret. +type Warrant struct { + Ask string `json:"ask"` + Asker string `json:"asker"` + About string `json:"about,omitempty"` + Outcome Outcome `json:"outcome"` + // Option, Label and Level are the option chosen; By who chose it, Channel the module it came through, + // Proofs which proofs were present (P1, P2, P3). + Option string `json:"option,omitempty"` + Label string `json:"label,omitempty"` + Level Level `json:"level,omitempty"` + By *Person `json:"by,omitempty"` + Channel string `json:"channel,omitempty"` + Proofs []string `json:"proofs,omitempty"` + At time.Time `json:"at"` + // AskDigest is the digest of the ask as the router took it (Ask.Digest): the warrant answers that ask + // alone, with the options it bound. + AskDigest string `json:"ask-digest,omitempty"` + // Words are why an ask ended without a choice, or what refused it. + Words string `json:"words,omitempty"` +} + +// Says is the warrant in the words an asker records with its act: "the operator, via telegram (user id +// verified), chose Release". +func (w Warrant) Says() string { + if w.Outcome != OutcomeChosen || w.By == nil { + return fmt.Sprintf("no person chose: the ask %s %s", w.Ask, w.Outcome) + } + via := w.By.Kind + if w.By.Verified != "" { + via += " (" + w.By.Verified + ")" + } + return fmt.Sprintf("the %s, via %s, chose %s", w.By.Who, via, w.Label) +} + +// For checks a warrant against the ask its asker made: the same asker and ask, the same ask's digest (so the +// same words, options and binds), a choice, an option the ask offered, at that option's level, before the +// ask expired. An asker acts on nothing else, and then performs only what the option's Binds names. +func (w Warrant) For(asker string, a Ask) (Option, error) { + if w.Asker != asker || w.Ask != a.ID { + return Option{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's %s", w.Asker, w.Ask, asker, a.ID) + } + if w.Outcome != OutcomeChosen || w.By == nil || w.By.Who != a.Who { + return Option{}, fmt.Errorf("the ask %s ended %s; no person chose", a.ID, w.Outcome) + } + if d := a.Digest(); w.AskDigest != d { + return Option{}, fmt.Errorf("the warrant answers an ask whose digest is %q, and the ask %s kept here is %s: "+ + "it was not the ask the person was shown", w.AskDigest, a.ID, d) + } + o, offered := a.Option(w.Option) + if !offered { + return Option{}, fmt.Errorf("the ask %s offered no option %s", a.ID, w.Option) + } + if w.Level != o.Level { + return Option{}, fmt.Errorf("the option %s is %s, and the warrant says %s", o.ID, o.Level, w.Level) + } + // A choice made after the ask expired is no answer to it, whatever the router said. An ask that says no + // expiry, or a warrant that says no time, is no answer either: neither can be shown to be in time (the + // confirmation review of 2026-10-09). + if a.Expires.IsZero() { + return Option{}, fmt.Errorf("the ask %s says no expiry, so no answer to it can be in time", a.ID) + } + if w.At.IsZero() { + return Option{}, fmt.Errorf("the warrant for the ask %s says no time it was given, so it cannot be shown to be in time", a.ID) + } + if w.At.After(a.Expires) { + return Option{}, fmt.Errorf("the warrant was given at %s, after the ask %s expired at %s", + w.At.UTC().Format(time.RFC3339), a.ID, a.Expires.UTC().Format(time.RFC3339)) + } + return o, nil +} + +// Performs checks that the act an asker is about to perform is the one the chosen option bound when it +// asked: the act's digest equals the option's Binds. An acknowledge option that bound nothing passes. +func (o Option) Performs(act Act) error { + if o.Binds == "" && o.Level == Acknowledge { + return nil + } + d, err := ActDigest(act) + if err != nil { + return err + } + if d != o.Binds { + return fmt.Errorf("the option %s bound %s, and the act about to be performed is %s: nothing is done", o.ID, o.Binds, d) + } + return nil +} + +// Button is one answer a channel offers: its label and the router's one-time ticket for it. +type Button struct { + Label string `json:"label"` + Ticket string `json:"ticket"` +} + +// Message is the work a channel takes: shown with buttons (Show), shown again in place (Edit), or said +// (Send). Handle is the router's name for it, the same across a show and its edits; the channel keeps +// which of its own messages that is. The words are the router's, shown as given. +type Message struct { + Handle string `json:"handle"` + Title string `json:"title"` + Body string `json:"body"` + Buttons []Button `json:"buttons,omitempty"` + // Note is the message's secondary words, after its body and its buttons: who asked and when, what each + // answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart + // where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's + // length cuts the body first and the note only when the title and the note alone do not fit, saying each + // cut (novox/hq ADR 0347 §5). + Note string `json:"note,omitempty"` + Urgent bool `json:"urgent,omitempty"` + Silent bool `json:"silent,omitempty"` + // Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made. + Reply string `json:"reply,omitempty"` + // To is the account linked as the operator on this kind, for a channel that verifies its sender: where + // the channel sends what is not a reply. The router's word, from its list; empty when none is linked. + To string `json:"to,omitempty"` + // Secret says the words carry something shown once (a link's code): the channel shows it and keeps no + // copy of it — no state, no history of its own. + Secret bool `json:"secret,omitempty"` +} + +// Sender is who a channel's service says sent something: the account, the name it shows, and whether the +// service authenticated it. The router alone judges whether that is the operator. +type Sender struct { + Identity string `json:"identity"` + Display string `json:"display,omitempty"` + Authenticated bool `json:"authenticated"` +} + +// Failed is a message a channel could not deliver: its handle, why, and whether trying again could help. +type Failed struct { + Handle string `json:"handle"` + Why string `json:"why"` + Permanent bool `json:"permanent,omitempty"` + At time.Time `json:"at"` +} + +// Standing is what a channel says of itself, at least every five minutes and whenever it changes: +// whether it can send now, why not, and whether its edits notify nobody. +type Standing struct { + Ready bool `json:"ready"` + Why string `json:"why,omitempty"` + EditsSilently bool `json:"edits-silently,omitempty"` + At time.Time `json:"at"` +} + +// What a channel says of its own delivery, on IntakeSubject. +const ( + FailedWhat = "failed" + StandingWhat = "standing" +) + +// Choice is a button chosen on a channel. +type Choice struct { + // ID is the channel's own for this arrival, unique, so the router acts on it once. + ID string `json:"id"` + Ticket string `json:"ticket"` + Handle string `json:"handle,omitempty"` + Sender Sender `json:"sender"` + At time.Time `json:"at"` +} + +// LinkAsked is somebody on a channel asking to be linked as the operator. +type LinkAsked struct { + ID string `json:"id"` + Sender Sender `json:"sender"` + At time.Time `json:"at"` +} + +// Code is a code a person typed on a channel, asked as a proof: never in an event, never kept. +type Code struct { + Sender Sender `json:"sender"` + Code string `json:"code"` + Purpose string `json:"purpose"` +} + +// ProofAnswer is the router's answer to a proof: whether it was taken, and words to say to the person. +type ProofAnswer struct { + Accepted bool `json:"accepted"` + Words string `json:"words"` +} diff --git a/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/asks/condition.go b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/asks/condition.go new file mode 100644 index 0000000..43aae39 --- /dev/null +++ b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/asks/condition.go @@ -0,0 +1,149 @@ +package asks + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "strings" +) + +// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel +// seat, `mesh.seat.operator-channel.accept.condition.` (novox/hq ADR 0437 §9). The router reads the +// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is +// refused when empty or when it holds a dot, a wildcard or whitespace. +func ConditionSubject(publisher string) (string, error) { + if publisher == "" { + return "", errors.New("asks: the condition's publisher is empty") + } + if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) { + return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher) + } + return "mesh.seat." + Seat + ".accept.condition." + publisher, nil +} + +// ConditionVersion is the version of the condition value this package writes and the only one it reads. +const ConditionVersion = 1 + +// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not +// ConditionVersion. Test it with errors.Is. +var ErrUnknownConditionVersion = errors.New("asks: unknown condition version") + +// The words a condition's explanation opens with (novox/hq ADR 0253 §1). +const ( + NothingToDo = "Nothing for you to do." + NeedsYou = "Needs you: " +) + +// The severities and states of a condition. +const ( + SeverityUrgent = "urgent" + SeverityWarning = "warning" + StateRaised = "raised" + StateCleared = "cleared" +) + +// Scope is what a condition is about, under the controller's names. +type Scope struct { + Scope string `json:"scope"` + ID string `json:"id"` + Machine string `json:"machine,omitempty"` +} + +// Action is one answer a notification offers, under the controller's names. +type Action struct { + Label string `json:"label"` + Verb string `json:"verb"` + Machine string `json:"machine,omitempty"` + Arguments map[string]string `json:"arguments,omitempty"` + Level Level `json:"level,omitempty"` +} + +// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key, +// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs +// only its key, subject and state. The router alone chooses the channels. +type Condition struct { + V int `json:"v"` + Key string `json:"key"` + Severity string `json:"severity,omitempty"` + Subject Scope `json:"subject"` + State string `json:"state"` + Summary string `json:"summary,omitempty"` + Headline string `json:"headline,omitempty"` + Needs string `json:"needs,omitempty"` + Explanation string `json:"explanation,omitempty"` + Resolved string `json:"resolved,omitempty"` + Actions []Action `json:"actions,omitempty"` +} + +func (c Condition) check() error { + if c.Key == "" { + return errors.New("asks: a condition has no key") + } + if c.Subject.Scope == "" || c.Subject.ID == "" { + return errors.New("asks: a condition's subject needs its scope and id") + } + switch c.State { + case StateCleared: + return nil + case StateRaised: + default: + return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State) + } + switch c.Severity { + case SeverityUrgent, SeverityWarning: + default: + return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity) + } + if c.Headline == "" { + return errors.New("asks: a raised condition has no headline") + } + if n := len([]rune(c.Headline)); n > HeadlineLength { + return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength) + } + verdict := NothingToDo + if c.Needs != "" { + verdict = NeedsYou + c.Needs + } + if !strings.HasPrefix(c.Explanation, verdict) { + return fmt.Errorf("asks: the condition's explanation must open with %q", verdict) + } + return nil +} + +// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse. +func EncodeCondition(c Condition) ([]byte, error) { + if err := c.check(); err != nil { + return nil, err + } + c.V = ConditionVersion + return json.Marshal(c) +} + +// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what +// EncodeCondition refuses. +func DecodeCondition(data []byte) (Condition, error) { + var probe struct { + V *int `json:"v"` + } + if err := json.Unmarshal(data, &probe); err != nil { + return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err) + } + if probe.V == nil { + return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion) + } + if *probe.V != ConditionVersion { + return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion) + } + var c Condition + if err := json.Unmarshal(data, &c); err != nil { + return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err) + } + if err := c.check(); err != nil { + return Condition{}, err + } + return c, nil +} + +// SameCondition says whether two conditions are the same value. +func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) } diff --git a/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/buildrecord.go b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/buildrecord.go new file mode 100644 index 0000000..8802f6f --- /dev/null +++ b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/buildrecord.go @@ -0,0 +1,41 @@ +package delivery + +import "time" + +// BuildRecord is the layout mesh-controller internal/chain/records.go Build writes (line 91), the fields a reader outside +// the controller reads (novox/hq ADR 0437 §4 §5, issue 1223). The controller's record carries more; a decode ignores +// what is not here. Path is the module's directory in its repository, as the controller writes it (Build.Path, records.go:100, +// set from the build request trimmed of slashes at internal/inventory/chain_requests.go:19); empty for a module whose +// manifest is the repository's root. SourceInputs has no json keys of its own: it is written under its Go field names, as the controller +// writes it. +type BuildRecord struct { + ID string `json:"id"` + State string `json:"state,omitempty"` + Repository string `json:"repository"` + Ref string `json:"ref,omitempty"` + Module string `json:"module,omitempty"` + Path string `json:"path,omitempty"` + Commit string `json:"commit,omitempty"` + At time.Time `json:"at,omitzero"` + SourceFingerprint string `json:"source-fingerprint,omitempty"` + SourceInputs *SourceInputs `json:"source-inputs,omitempty"` +} + +// BuildsBucket is the bucket the builds are kept in: mesh-controller internal/broker/controller_buckets.go:101, +// BucketName(ControllerSeat, ChainBuilds), with ControllerSeat "mesh-controller" (internal/broker/streams.go:359) and +// ChainBuilds "builds" (controller_buckets.go:113). +const BuildsBucket = "mesh-controller_builds" + +// BuildKey is a build's record under its module, a build that knew none under the request's key: copied from +// mesh-controller internal/chain/keys.go:35-40 (BuildKey, with RequestKey at line 42). +func BuildKey(module, id string) string { + if module == "" { + return "req." + id + } + return module + ".build." + id +} + +// NewestKey is the key of a module's newest build, of any state: copied from mesh-controller internal/chain/keys.go:44. +// The key holds no record: its value is the newest build's id as raw bytes (internal/inventory/chain_builds.go:210 and +// :546), and the record lives at BuildKey(module, id). The pointer is a hint, checked against that record. +func NewestKey(module string) string { return module + ".newest" } diff --git a/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/delivery.go b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/delivery.go index fd109ec..fe99528 100644 --- a/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/delivery.go +++ b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/delivery.go @@ -18,7 +18,19 @@ // question 9 item 7): a writer writes a new version only once every reader's have shows a version that reads it. A // writer writes only when the new value differs from the one before (the Same functions). // -// Standard library only. +// **Source, candidates, modules and the forge** (issue 1200; ADR 0437 §5, release 3): the gate's bucket `source` with +// its one key `main` (every repository's merged head and the group that moved it), the indexer's `candidates.` +// and `modules.` (a module's source fingerprint, or none and why), and the forge bridge's messages named +// `forge...` (source.go). +// +// **Merges, asks, owed and answers** (issue 1201; ADR 0437 §6, §8): the gate's `merges.`, its asks +// `merge..` (the router's ask whole), its owed keys `...` that the +// forge bridge performs, and the teller's answer under the ask's own key (gate.go). +// +// **The source fingerprint** (issue 1203; ADR 0437 §5): one function of named inputs that the controller's builder and +// the indexer both call, so they agree byte for byte (fingerprint.go). +// +// Standard library only, and this module's own asks package. package delivery import ( diff --git a/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/fingerprint.go b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/fingerprint.go new file mode 100644 index 0000000..8966d67 --- /dev/null +++ b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/fingerprint.go @@ -0,0 +1,63 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" +) + +// SourceInputs are what a module's source fingerprint is of (novox/hq ADR 0437 §5): the module, the git tree of +// its directory, its bases (references, of which only the digest counts), the root tree of each repository its +// artifacts read (by artifact), each artifact's toolchain line, and the reason the source does not pin the build +// when it does not (a build that resolves packages when built). A context is the value the builder records for it: +// a repository's root tree, or for an image that compiles a Go program the `narrow:` hash of the build source it was +// handed (mesh-controller internal/builder/builder.go:1146-1156). +type SourceInputs struct { + Module string + Tree string + Bases []string + Contexts map[string]string + Toolchains map[string]string + Unpinned string +} + +// SourceFingerprint is the module's source fingerprint, `src1:` and the hex sha256 of its inputs' lines, or none +// and the reason when the source does not pin the build: Unpinned when set, "no tree" when Tree is empty. It is the +// one function the controller's builder and the indexer both call, so they agree byte for byte. The lines are +// "module " and "tree ", then "base " (once each), "context " and "toolchain +// " sorted, all joined by a newline. +func SourceFingerprint(in SourceInputs) (fp, none string) { + if in.Unpinned != "" { + return "", in.Unpinned + } + if in.Tree == "" { + return "", "no tree" + } + lines := []string{"module " + in.Module, "tree " + in.Tree} + bases := map[string]bool{} + for _, b := range in.Bases { + bases[ReferenceDigest(b)] = true + } + for b := range bases { + lines = append(lines, "base "+b) + } + for a, t := range in.Contexts { + lines = append(lines, "context "+a+" "+t) + } + for a, t := range in.Toolchains { + lines = append(lines, "toolchain "+a+" "+t) + } + sort.Strings(lines[2:]) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return SourcePrefix + hex.EncodeToString(sum[:]), "" +} + +// ReferenceDigest is a reference's digest, `sha256:...`, so the registry address a base was copied into does not +// enter the fingerprint; the reference itself when it carries none. +func ReferenceDigest(reference string) string { + if _, digest, pinned := strings.Cut(reference, "@"); pinned && digest != "" { + return digest + } + return reference +} diff --git a/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/gate.go b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/gate.go new file mode 100644 index 0000000..cd85b3f --- /dev/null +++ b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/gate.go @@ -0,0 +1,404 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// The gate's buckets and the teller's (ADR 0437 §6, §8). The gate writes merges (each pull request's expected +// head, its state and each merge commit), its asks (`merge..`, the router's ask whole) and owed +// (one key per act it owes the forge, which the forge bridge performs once per revision). The teller writes its +// answer under the ask's own key in its bucket answers. +const ( + MergesBucket = GateModule + "_merges" + AsksBucket = GateModule + "_asks" + OwedBucket = GateModule + "_owed" + AnswersBucket = TellerModule + "_answers" + MergesHistory = 10 + AsksHistory = 5 + OwedHistory = 5 + AnswersHistory = 5 +) + +// AskKindMerge is the kind of the gate's ask for a group's merge, the first part of its key. +const AskKindMerge = "merge" + +// The acts the gate owes the forge, the last part of an owed key. +const ( + OwedStatus = "status" + OwedView = "view" + OwedNote = "note" + OwedComment = "comment" +) + +// The states of a pull request in a merge. +type MergeState string + +// The states a merge's pull request is in. +const ( + MergeMerging MergeState = "merging" + MergeMerged MergeState = "merged" + MergeRefused MergeState = "refused" +) + +// The states a status the gate owes the forge may carry. +var statusStates = []string{"pending", "success", "failure", "error", "warning"} + +var hashOK = regexp.MustCompile(`^[0-9a-f]{12}$`) + +// MergesKey is the key, in MergesBucket, of a group: ``. +func MergesKey(group string) (string, error) { + if err := checkName("group", group); err != nil { + return "", err + } + return group, nil +} + +// MergeAskKey is the key, in AsksBucket, of the ask for merging a group at these heads: `merge..`. The teller's answer has the same key in AnswersBucket (AnswerKey). +func MergeAskKey(group string, heads []Head) (string, error) { + if err := checkName("group", group); err != nil { + return "", err + } + hash, err := HeadsHash(heads) + if err != nil { + return "", err + } + return AskKindMerge + "." + group + "." + hash, nil +} + +// ParseAskKey is the inverse of MergeAskKey: the kind, the group and the heads hash. +func ParseAskKey(key string) (kind, group, hash string, err error) { + parts := strings.Split(key, ".") + if len(parts) != 3 || parts[0] != AskKindMerge { + return "", "", "", fmt.Errorf("delivery: %q is not an ask key, merge..", key) + } + if err := checkName("group", parts[1]); err != nil { + return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %w", key, err) + } + if !hashOK.MatchString(parts[2]) { + return "", "", "", fmt.Errorf("delivery: %q is not an ask key: %q is not 12 hex digits", key, parts[2]) + } + return parts[0], parts[1], parts[2], nil +} + +// AnswerKey is the key, in AnswersBucket, of the teller's answer to the ask at askKey: the same string. +func AnswerKey(askKey string) string { return askKey } + +// AskID is the id the teller gives the router for the ask at askKey. The router's ids refuse dots (asks.UsableID), +// so in the readable part a dot becomes `-` and any other byte outside letters, digits, `_` and `-` becomes `_`; that part is cut to fit, and every id ends in `-` and +// the first 12 hex digits of the sha256 of the whole key, so two keys never give one id. It cannot be read back: +// the teller keeps the key beside it. +func AskID(askKey string) string { + const most = 64 // the router's bound on an id + const tail = 13 // "-" and 12 hex digits + readable := []byte(askKey) + for i, c := range readable { + switch { + case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9': + case c == '.': + readable[i] = '-' + default: // a slash, an equals sign or any other byte the router's id refuses + readable[i] = '_' + } + } + if len(readable) > 0 && (readable[0] == '-' || readable[0] == '_') { + readable[0] = 'k' // an id starts with a letter or digit + } + if len(readable) > most-tail { + readable = readable[:most-tail] + } + sum := sha256.Sum256([]byte(askKey)) + return string(readable) + "-" + hex.EncodeToString(sum[:])[:12] +} + +func checkOwedAct(act string) (kind, context string, err error) { + kind, context, hasContext := strings.Cut(act, "=") + switch kind { + case OwedStatus: + if !hasContext { + return "", "", fmt.Errorf("delivery: the owed act %q is a status without its context, status=", act) + } + if err := checkName("status context", context); err != nil { + return "", "", err + } + return kind, context, nil + case OwedView, OwedNote, OwedComment: + if hasContext { + return "", "", fmt.Errorf("delivery: the owed act %q takes no context", act) + } + return kind, "", nil + } + return "", "", fmt.Errorf("delivery: the owed act %q is not status=, view, note or comment", act) +} + +// ParseOwedAct splits an owed act into its kind and, for a status, its context. +func ParseOwedAct(act string) (kind, context string, err error) { return checkOwedAct(act) } + +// OwedKey is the key, in OwedBucket, of an act the gate owes the forge on a pull request at a head: +// `...`, the act being `status=` (mesh/merge-gate), `view`, `note` or +// `comment`. +func OwedKey(repository string, number int, head, act string) (string, error) { + if err := checkRepository(repository); err != nil { + return "", err + } + if number <= 0 { + return "", fmt.Errorf("delivery: an owed key needs the pull request number, not %d", number) + } + if err := checkName("head", head); err != nil { + return "", err + } + if _, _, err := checkOwedAct(act); err != nil { + return "", err + } + return repository + "." + strconv.Itoa(number) + "." + head + "." + act, nil +} + +// ParseOwedKey is the inverse of OwedKey; act is whole, as `status=` for a status. +func ParseOwedKey(key string) (repository string, number int, head, act string, err error) { + parts := strings.Split(key, ".") + if len(parts) != 4 { + return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key, ...", key) + } + number, convErr := strconv.Atoi(parts[1]) + if convErr != nil { + return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %q is not a pull request number", key, parts[1]) + } + if _, err := OwedKey(parts[0], number, parts[2], parts[3]); err != nil { + return "", 0, "", "", fmt.Errorf("delivery: %q is not an owed key: %w", key, err) + } + return parts[0], number, parts[2], parts[3], nil +} + +// Merge is the value of a merges key: the ask approved and, for each pull request, the head the approval expects +// and how far its merge has come. +type Merge struct { + V int `json:"v"` + Group string `json:"group"` + Ask string `json:"ask"` + Pulls []MergePull `json:"pulls"` +} + +// MergePull is one pull request of a merge: its expected head, its state, the merge commit once merged, the +// forge's words when refused. +type MergePull struct { + Head + State MergeState `json:"state"` + Commit string `json:"commit,omitempty"` + Words string `json:"words,omitempty"` +} + +func (m Merge) check() error { + if err := checkName("group", m.Group); err != nil { + return err + } + if m.Ask == "" { + return fmt.Errorf("delivery: a merge names no ask") + } + if len(m.Pulls) == 0 { + return fmt.Errorf("delivery: a merge names no pull request") + } + for _, p := range m.Pulls { + who := fmt.Sprintf("%s#%d", p.Repository, p.Number) + switch p.State { + case MergeMerging: + case MergeMerged: + if p.Commit == "" { + return fmt.Errorf("delivery: the merged pull request %s has no merge commit", who) + } + case MergeRefused: + if p.Words == "" { + return fmt.Errorf("delivery: the refused pull request %s has no words of the forge", who) + } + default: + return fmt.Errorf("delivery: the state %q of %s is not merging, merged or refused", p.State, who) + } + } + return nil +} + +// Ask is the value of an asks key: the router's ask whole as the gate composes it, so the teller publishes it +// unchanged, and the group and heads it was composed for. +type Ask struct { + V int `json:"v"` + Group string `json:"group"` + Heads []Head `json:"heads"` + Ask asks.Ask `json:"ask"` +} + +func (a Ask) check() error { + key, err := MergeAskKey(a.Group, a.Heads) + if err != nil { + return fmt.Errorf("delivery: an ask needs its group and at least one pull request's head: %w", err) + } + if want := AskID(key); a.Ask.ID != want { + return fmt.Errorf("delivery: the ask's id %q is not %q, the id its group and heads give", a.Ask.ID, want) + } + if len(a.Ask.Options) == 0 { + return fmt.Errorf("delivery: the ask has no option") + } + return nil +} + +// Answer is the teller's answer to an ask: the option chosen, the warrant's id and its words. +type Answer struct { + V int `json:"v"` + Option string `json:"option"` + Warrant string `json:"warrant"` + Words string `json:"words"` +} + +func (a Answer) check() error { + for _, f := range []struct{ name, value string }{{"option", a.Option}, {"warrant", a.Warrant}, {"words", a.Words}} { + if f.value == "" { + return fmt.Errorf("delivery: an answer has no %s", f.name) + } + } + return nil +} + +// Owed is the value of an owed key: what the forge bridge performs. A status needs Context and State, a view and a +// comment need Text, a note needs Text and Ref (the notes ref, mesh-plan). +type Owed struct { + V int `json:"v"` + Act string `json:"act"` + Context string `json:"context,omitempty"` + State string `json:"state,omitempty"` + Text string `json:"text,omitempty"` + Ref string `json:"ref,omitempty"` +} + +func (o Owed) check() error { + switch o.Act { + case OwedStatus: + if o.Context == "" { + return fmt.Errorf("delivery: an owed status has no context") + } + for _, s := range statusStates { + if o.State == s { + return nil + } + } + return fmt.Errorf("delivery: the owed status state %q is not %s", o.State, strings.Join(statusStates, ", ")) + case OwedView, OwedComment: + if o.Text == "" { + return fmt.Errorf("delivery: an owed %s has no text", o.Act) + } + case OwedNote: + if o.Text == "" { + return fmt.Errorf("delivery: an owed note has no text") + } + if o.Ref == "" { + return fmt.Errorf("delivery: an owed note has no ref") + } + default: + return fmt.Errorf("delivery: the owed act %q is not status, view, note or comment", o.Act) + } + return nil +} + +// EncodeMerge is m as JSON at Version; it refuses a merged pull request without its merge commit, a refused one +// without the forge's words and a state outside the list. +func EncodeMerge(m Merge) ([]byte, error) { + if err := m.check(); err != nil { + return nil, err + } + m.V = Version + return json.Marshal(m) +} + +// DecodeMerge reads a merge; it refuses an unknown version (ErrUnknownVersion) and what EncodeMerge refuses. +func DecodeMerge(data []byte) (Merge, error) { + var m Merge + if err := decode(data, &m); err != nil { + return Merge{}, err + } + if err := m.check(); err != nil { + return Merge{}, err + } + return m, nil +} + +// EncodeAsk is a as JSON at Version; it refuses an ask whose id is not the one its group and heads give, or with +// no option. +func EncodeAsk(a Ask) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAsk reads an ask; it refuses an unknown version (ErrUnknownVersion) and what EncodeAsk refuses. +func DecodeAsk(data []byte) (Ask, error) { + var a Ask + if err := decode(data, &a); err != nil { + return Ask{}, err + } + if err := a.check(); err != nil { + return Ask{}, err + } + return a, nil +} + +// EncodeAnswer is a as JSON at Version; it refuses an answer missing its option, warrant or words. +func EncodeAnswer(a Answer) ([]byte, error) { + if err := a.check(); err != nil { + return nil, err + } + a.V = Version + return json.Marshal(a) +} + +// DecodeAnswer reads an answer; it refuses an unknown version (ErrUnknownVersion) and what EncodeAnswer refuses. +func DecodeAnswer(data []byte) (Answer, error) { + var a Answer + if err := decode(data, &a); err != nil { + return Answer{}, err + } + if err := a.check(); err != nil { + return Answer{}, err + } + return a, nil +} + +// EncodeOwed is o as JSON at Version; it refuses an act missing what it needs. +func EncodeOwed(o Owed) ([]byte, error) { + if err := o.check(); err != nil { + return nil, err + } + o.V = Version + return json.Marshal(o) +} + +// DecodeOwed reads an owed act; it refuses an unknown version (ErrUnknownVersion) and what EncodeOwed refuses. +func DecodeOwed(data []byte) (Owed, error) { + var o Owed + if err := decode(data, &o); err != nil { + return Owed{}, err + } + if err := o.check(); err != nil { + return Owed{}, err + } + return o, nil +} + +// SameMerge says whether two merges are the same value, so a writer writes only when they differ. +func SameMerge(a, b Merge) bool { return reflect.DeepEqual(a, b) } + +// SameAsk says whether two asks are the same value. +func SameAsk(a, b Ask) bool { return reflect.DeepEqual(a, b) } + +// SameAnswer says whether two answers are the same value. +func SameAnswer(a, b Answer) bool { return a == b } + +// SameOwed says whether two owed acts are the same value. +func SameOwed(a, b Owed) bool { return a == b } diff --git a/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/source.go b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/source.go new file mode 100644 index 0000000..f4328ff --- /dev/null +++ b/modules/mesh-delivery-view/vendor/git.novox.be/novox/mesh-sdk/go/delivery/source.go @@ -0,0 +1,420 @@ +package delivery + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" +) + +// The modules of the parts release 3 adds (novox/hq ADR 0437): their buckets are their own state, `_`. +const ( + GateModule = "mesh-delivery-gate" + IndexerModule = "mesh-delivery-indexer" + ForgeBridgeModule = "mesh-delivery-forge-bridge" + TellerModule = "mesh-delivery-teller" +) + +// The gate's source bucket and the indexer's candidates and modules buckets (ADR 0437 §5). The source bucket has +// one key, SourceMainKey: every repository's merged head and the group that moved it, written by the gate alone by +// compare-and-set; its revision is the mesh's source revision. The indexer writes the other two. +const ( + SourceBucket = GateModule + "_source" + SourceMainKey = "main" + SourceHistory = 64 + CandidatesBucket = IndexerModule + "_candidates" + // CandidatesHistory is the revisions the candidates bucket keeps per key. + CandidatesHistory = 10 + ModulesBucket = IndexerModule + "_modules" + // ModulesHistory is the revisions the modules bucket keeps per key. + ModulesHistory = 20 +) + +// ForgeStream is the forge bridge's stream as its manifest declares it; the broker derives the real names. +const ForgeStream = "forge" + +// The kinds of forge message, the last part of a forge event's name. +const ( + ForgePull = "pull" + ForgeMerged = "merged" + ForgeRefused = "refused" +) + +// The actions of a pull message. +const ( + PullOpened = "opened" + PullUpdated = "updated" + PullClosed = "closed" +) + +// SourcePrefix opens every source fingerprint. +const SourcePrefix = "src1:" + +// CandidatesKey is the key, in CandidatesBucket, of a group: ``, the branch name the group's pull requests +// share (a slash is allowed); a lone pull request is a group of one under its branch name. +func CandidatesKey(group string) (string, error) { + if err := checkName("group", group); err != nil { + return "", err + } + return group, nil +} + +// ParseCandidatesKey is the inverse of CandidatesKey. +func ParseCandidatesKey(key string) (group string, err error) { + if group, err = CandidatesKey(key); err != nil { + return "", fmt.Errorf("delivery: %q is not a candidates key: %w", key, err) + } + return group, nil +} + +// ModulesKey is the key, in ModulesBucket, of a module: ``. +func ModulesKey(module string) (string, error) { + if err := checkName("module", module); err != nil { + return "", err + } + return module, nil +} + +// ParseModulesKey is the inverse of ModulesKey. +func ParseModulesKey(key string) (module string, err error) { + if module, err = ModulesKey(key); err != nil { + return "", fmt.Errorf("delivery: %q is not a modules key: %w", key, err) + } + return module, nil +} + +// checkRepository refuses a repository that is not `/`: exactly one slash, each part a valid name. +func checkRepository(repository string) error { + owner, name, ok := strings.Cut(repository, "/") + if !ok || strings.Contains(name, "/") { + return fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", repository) + } + if err := checkName("repository owner", owner); err != nil { + return fmt.Errorf("delivery: the repository %q: %w", repository, err) + } + if err := checkName("repository name", name); err != nil { + return fmt.Errorf("delivery: the repository %q: %w", repository, err) + } + if strings.Contains(owner, "/") { + return fmt.Errorf("delivery: the repository %q is not /, with exactly one slash", repository) + } + return nil +} + +func checkForgeKind(kind string) error { + switch kind { + case ForgePull, ForgeMerged, ForgeRefused: + return nil + } + return fmt.Errorf("delivery: the forge kind %q is not pull, merged or refused", kind) +} + +// ForgeEvent is the name of a forge message of the given kind: `forge...`. +func ForgeEvent(repository, kind string) (string, error) { + if err := checkRepository(repository); err != nil { + return "", err + } + if err := checkForgeKind(kind); err != nil { + return "", err + } + owner, name, _ := strings.Cut(repository, "/") + return "forge." + owner + "." + name + "." + kind, nil +} + +// ParseForgeEvent is the inverse of ForgeEvent. +func ParseForgeEvent(event string) (repository, kind string, err error) { + parts := strings.Split(event, ".") + if len(parts) != 4 || parts[0] != "forge" { + return "", "", fmt.Errorf("delivery: %q is not a forge event, forge...", event) + } + repository = parts[1] + "/" + parts[2] + if _, err := ForgeEvent(repository, parts[3]); err != nil { + return "", "", fmt.Errorf("delivery: %q is not a forge event: %w", event, err) + } + return repository, parts[3], nil +} + +// Head is one pull request at one commit: the repository as `/` and the full sha. +type Head struct { + Repository string `json:"repository"` + Number int `json:"number"` + Head string `json:"head"` +} + +// HeadsHash is the first 12 hex digits of the sha256 over the lines `#=`, sorted and +// joined by a newline: the same for every order of heads, different when any head changes. An empty list is refused. +func HeadsHash(heads []Head) (string, error) { + if len(heads) == 0 { + return "", fmt.Errorf("delivery: a heads hash needs at least one pull request") + } + lines := make([]string, len(heads)) + for i, h := range heads { + lines[i] = fmt.Sprintf("%s#%d=%s", h.Repository, h.Number, h.Head) + } + sort.Strings(lines) + sum := sha256.Sum256([]byte(strings.Join(lines, "\n"))) + return hex.EncodeToString(sum[:])[:12], nil +} + +// Source is the value of SourceMainKey: every repository's merged head (repository to full sha, not only the +// group's) and the group that moved it. +type Source struct { + V int `json:"v"` + Group string `json:"group"` + Heads map[string]string `json:"heads"` +} + +func (s Source) check() error { + if len(s.Heads) == 0 { + return fmt.Errorf("delivery: a source names no repository") + } + for repository, head := range s.Heads { + if repository == "" { + return fmt.Errorf("delivery: a source names a repository with no name") + } + if head == "" { + return fmt.Errorf("delivery: the source's head of %s is empty", repository) + } + } + return nil +} + +// Candidate is the value of a candidates key: the group's pull requests and heads, the heads hash, the base (the +// source revision it was previewed onto) and the modules each pull request touches. It carries no fingerprint +// until release 4 (ADR 0437 §5). +type Candidate struct { + V int `json:"v"` + Group string `json:"group"` + Base uint64 `json:"base"` + HeadsHash string `json:"heads"` + Pulls []CandidatePull `json:"pulls"` +} + +// CandidatePull is one pull request of a candidate and the modules it touches. +type CandidatePull struct { + Head + Modules []string `json:"modules"` +} + +func (c Candidate) check() error { + if len(c.Pulls) == 0 { + return fmt.Errorf("delivery: a candidate names no pull request") + } + heads := make([]Head, len(c.Pulls)) + for i, p := range c.Pulls { + heads[i] = p.Head + } + want, _ := HeadsHash(heads) + if c.HeadsHash != want { + return fmt.Errorf("delivery: the candidate's heads hash %q is not the hash %q of its pull requests' heads", c.HeadsHash, want) + } + return nil +} + +// Module is the value of a modules key: the module's source fingerprint, or none and why (a build that resolves +// packages when built gets none, said here), and where it was computed: Repository and Commit are the trunk merge, +// Src the source revision then (0 before the gate writes source.main). Exactly one of FP and None is set. +type Module struct { + V int `json:"v"` + FP string `json:"fp,omitempty"` + None string `json:"none,omitempty"` + Src uint64 `json:"src,omitempty"` + Repository string `json:"repository"` + Commit string `json:"commit"` +} + +func (m Module) check() error { + switch { + case m.FP != "" && m.None != "": + return fmt.Errorf("delivery: a module has both a fingerprint and a reason for none") + case m.FP == "" && m.None == "": + return fmt.Errorf("delivery: a module has neither a fingerprint nor a reason for none") + case m.FP != "" && !strings.HasPrefix(m.FP, SourcePrefix): + return fmt.Errorf("delivery: the module's fingerprint %q does not start with %s", m.FP, SourcePrefix) + } + return nil +} + +// Forge is a message of the forge bridge's stream, trimmed to repository, branch, pull request and head. A pull +// message says Action; a merged message carries the merge commit, the tree of every directory holding a +// module.json and the repository's root tree; a refused message names the owed act it answers and the forge's own +// words. A pull message may also carry the module directories its head changes (Dirs, in the order the forge said) and +// DirsKnown, true when the forge could say them: known and empty is DirsKnown with no Dirs, not known is neither +// (the forge's module_dirs and module_dirs_said, mesh-catalog modules/gitea/trunk.ts). Other messages carry neither. +type Forge struct { + V int `json:"v"` + Kind string `json:"kind"` + Repository string `json:"repository"` + Branch string `json:"branch,omitempty"` + Number int `json:"number,omitempty"` + Head string `json:"head,omitempty"` + Action string `json:"action,omitempty"` + Dirs []string `json:"dirs,omitempty"` + DirsKnown bool `json:"dirs-known,omitempty"` + Merge string `json:"merge,omitempty"` + Trees map[string]string `json:"trees,omitempty"` + Root string `json:"root,omitempty"` + Act string `json:"act,omitempty"` + Words string `json:"words,omitempty"` +} + +func (f Forge) check() error { + if err := checkRepository(f.Repository); err != nil { + return err + } + if err := checkForgeKind(f.Kind); err != nil { + return err + } + switch f.Kind { + case ForgePull: + if f.Number <= 0 { + return fmt.Errorf("delivery: a pull message needs its pull request number") + } + if f.Head == "" { + return fmt.Errorf("delivery: a pull message needs its head") + } + switch f.Action { + case PullOpened, PullUpdated, PullClosed: + default: + return fmt.Errorf("delivery: the pull action %q is not opened, updated or closed", f.Action) + } + if len(f.Dirs) > 0 && !f.DirsKnown { + return fmt.Errorf("delivery: a pull message with module directories must say it knows them (dirs-known)") + } + case ForgeMerged: + if len(f.Dirs) > 0 || f.DirsKnown { + return fmt.Errorf("delivery: a merged message carries no module directories") + } + if f.Number <= 0 { + return fmt.Errorf("delivery: a merged message needs its pull request number") + } + if f.Head == "" { + return fmt.Errorf("delivery: a merged message needs its head") + } + if f.Merge == "" { + return fmt.Errorf("delivery: a merged message needs its merge commit") + } + if f.Root == "" { + return fmt.Errorf("delivery: a merged message needs the repository's root tree") + } + if len(f.Trees) == 0 { + return fmt.Errorf("delivery: a merged message needs the tree of at least one directory holding a module.json") + } + for dir, tree := range f.Trees { + if tree == "" { + return fmt.Errorf("delivery: a merged message's tree of the directory %q is empty", dir) + } + } + case ForgeRefused: + if len(f.Dirs) > 0 || f.DirsKnown { + return fmt.Errorf("delivery: a refused message carries no module directories") + } + if f.Act == "" { + return fmt.Errorf("delivery: a refused message needs the act it answers") + } + if f.Words == "" { + return fmt.Errorf("delivery: a refused message needs the forge's words") + } + } + return nil +} + +// EncodeSource is s as JSON at Version; it refuses a source with no repository or an empty head. +func EncodeSource(s Source) ([]byte, error) { + if err := s.check(); err != nil { + return nil, err + } + s.V = Version + return json.Marshal(s) +} + +// DecodeSource reads a source; it refuses an unknown version (ErrUnknownVersion) and what EncodeSource refuses. +func DecodeSource(data []byte) (Source, error) { + var s Source + if err := decode(data, &s); err != nil { + return Source{}, err + } + if err := s.check(); err != nil { + return Source{}, err + } + return s, nil +} + +// EncodeCandidate is c as JSON at Version; it refuses a heads hash that is not the hash of its pull requests' heads. +func EncodeCandidate(c Candidate) ([]byte, error) { + if err := c.check(); err != nil { + return nil, err + } + c.V = Version + return json.Marshal(c) +} + +// DecodeCandidate reads a candidate; it refuses an unknown version (ErrUnknownVersion) and what EncodeCandidate refuses. +func DecodeCandidate(data []byte) (Candidate, error) { + var c Candidate + if err := decode(data, &c); err != nil { + return Candidate{}, err + } + if err := c.check(); err != nil { + return Candidate{}, err + } + return c, nil +} + +// EncodeModule is m as JSON at Version; it refuses both or neither of a fingerprint and a reason, and a +// fingerprint not of the form src1:. +func EncodeModule(m Module) ([]byte, error) { + if err := m.check(); err != nil { + return nil, err + } + m.V = Version + return json.Marshal(m) +} + +// DecodeModule reads a module; it refuses an unknown version (ErrUnknownVersion) and what EncodeModule refuses. +func DecodeModule(data []byte) (Module, error) { + var m Module + if err := decode(data, &m); err != nil { + return Module{}, err + } + if err := m.check(); err != nil { + return Module{}, err + } + return m, nil +} + +// EncodeForge is f as JSON at Version; it refuses a message missing what its kind needs. +func EncodeForge(f Forge) ([]byte, error) { + if err := f.check(); err != nil { + return nil, err + } + f.V = Version + return json.Marshal(f) +} + +// DecodeForge reads a forge message; it refuses an unknown version (ErrUnknownVersion) and what EncodeForge refuses. +func DecodeForge(data []byte) (Forge, error) { + var f Forge + if err := decode(data, &f); err != nil { + return Forge{}, err + } + if err := f.check(); err != nil { + return Forge{}, err + } + return f, nil +} + +// SameSource says whether two sources are the same value, so a writer writes only when they differ. +func SameSource(a, b Source) bool { return reflect.DeepEqual(a, b) } + +// SameCandidate says whether two candidates are the same value. +func SameCandidate(a, b Candidate) bool { return reflect.DeepEqual(a, b) } + +// SameModule says whether two modules are the same value. +func SameModule(a, b Module) bool { return a == b } + +// SameForge says whether two forge messages are the same value. +func SameForge(a, b Forge) bool { return reflect.DeepEqual(a, b) } diff --git a/modules/mesh-delivery-view/vendor/modules.txt b/modules/mesh-delivery-view/vendor/modules.txt index e5acdca..ac7734d 100644 --- a/modules/mesh-delivery-view/vendor/modules.txt +++ b/modules/mesh-delivery-view/vendor/modules.txt @@ -1,5 +1,6 @@ -# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261018184557-238440d6f8c4 -## explicit; go 1.22 +# git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261019124204-9ade199740e3 +## explicit; go 1.26.0 +git.novox.be/novox/mesh-sdk/go/asks git.novox.be/novox/mesh-sdk/go/delivery # github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op ## explicit; go 1.24.0