Show the candidate groups and each module's fingerprint against its trunk build's in the delivery view (issue 1241)

This commit is contained in:
2026-10-19 15:10:16 +02:00
parent a9c3980a2b
commit 9e9c2a7a57
18 changed files with 2207 additions and 14 deletions
@@ -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
}
@@ -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)
}
@@ -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 {
+1 -1
View File
@@ -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
)
+2 -2
View File
@@ -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=
@@ -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)
}
}
@@ -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, `<repository>#<number>`, 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(`<!doctype html><html><head><me
<title>Delivery</title><style>body{font:14px sans-serif;margin:1em}table{border-collapse:collapse}td,th{padding:2px 10px;text-align:left}</style></head>
<body><h1>What each node runs</h1><table><tr>{{range .Head}}<th>{{.}}</th>{{end}}</tr>
{{range .Rows}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>
{{if .Groups}}<h2>CANDIDATES</h2><table><tr><th>GROUP</th><th>PULL REQUEST</th><th>HEAD</th><th>MODULES</th></tr>{{range .Groups}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
{{if .Fingerprints}}<h2>FINGERPRINTS</h2><table><tr><th>MODULE</th><th>STATE</th><th>INDEXER AGAINST BUILD</th></tr>{{range .Fingerprints}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
{{if .Offline}}<h2>OFFLINE</h2><table>{{range .Offline}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
{{if .Stuck}}<h2>STUCK</h2><table><tr><th>BUCKET</th><th>KEY</th><th>WAITED FOR</th><th>SINCE</th></tr>{{range .Stuck}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}
{{if .Unreadable}}<h2>UNREADABLE</h2><table>{{range .Unreadable}}<tr>{{range .}}<td>{{.}}</td>{{end}}</tr>{{end}}</table>{{end}}</body></html>`))
@@ -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})
})
}
@@ -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 <module>.build.<id>
newest map[string]string // module -> id
builds map[string]buildEntry // by <module>.build.<id>
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.
+1 -1
View File
@@ -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) {
+2
View File
@@ -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"
@@ -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:<hex>". 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"`
}
@@ -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.<publisher>` (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) }
@@ -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" }
@@ -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.<group>`
// and `modules.<module>` (a module's source fingerprint, or none and why), and the forge bridge's messages named
// `forge.<owner>.<name>.<kind>` (source.go).
//
// **Merges, asks, owed and answers** (issue 1201; ADR 0437 §6, §8): the gate's `merges.<group>`, its asks
// `merge.<group>.<heads hash>` (the router's ask whole), its owed keys `<repository>.<number>.<head>.<act>` 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 (
@@ -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 <m>" and "tree <t>", then "base <digest>" (once each), "context <artifact> <tree>" and "toolchain
// <artifact> <line>" 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
}
@@ -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.<group>.<heads hash>`, 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: `<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.<group>.<heads
// hash>`. 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.<group>.<heads hash>", 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=<context>", 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=<context>, 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:
// `<repository>.<number>.<head>.<act>`, the act being `status=<context>` (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=<context>` 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, <repository>.<number>.<head>.<act>", 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 }
@@ -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, `<module>_<name>`.
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: `<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: `<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 `<owner>/<name>`: 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 <owner>/<name>, 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 <owner>/<name>, 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.<owner>.<name>.<kind>`.
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.<owner>.<name>.<kind>", 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 `<owner>/<name>` 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 `<repository>#<number>=<head>`, 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) }
+3 -2
View File
@@ -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