From 30788c46a1a16c606054880aeaf0e5b78c1f4f74 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 11 Oct 2026 03:10:22 +0200 Subject: [PATCH] mesh-delivery: give an unanswered line's checks as data, so the controller says their times in the operator's zone (novox/hq issue 443) --- .../cmd/mesh-delivery/issue443_test.go | 66 ++++++++++++++++++ .../mesh-delivery/cmd/mesh-delivery/verbs.go | 14 ++-- .../cmd/mesh-delivery/waiting_checks.go | 67 +++++++++++++++++++ 3 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 modules/mesh-delivery/cmd/mesh-delivery/issue443_test.go create mode 100644 modules/mesh-delivery/cmd/mesh-delivery/waiting_checks.go diff --git a/modules/mesh-delivery/cmd/mesh-delivery/issue443_test.go b/modules/mesh-delivery/cmd/mesh-delivery/issue443_test.go new file mode 100644 index 00000000..19b6dfa8 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/issue443_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/json" + "slices" + "strings" + "testing" + "time" +) + +// **An unanswered line gives the checks it waits on as data** (novox/hq issue 443): each check's context, its state +// and, for a pending one, since when in RFC 3339 — so the controller says the time in the operator's own zone. A time +// inside a finished sentence cannot be said again in another zone, and the operator read "since +// 2026-10-11T00:11:39Z". The finished words stay beside the data, for a controller that does not read it yet. +func TestAnUnansweredLineGivesItsChecksAsData(t *testing.T) { + w := newWorldOver(t, newMemStore(), newFakeController(), newFakeForge(), time.Date(2026, 10, 11, 0, 12, 5, 0, time.UTC)) + w.forge.open = openPullsOf(t, strings.Replace(startedNeverAnswered, "2026-10-11T00:11:39Z", "2026-10-11T02:11:39+02:00", 1)) + w.h.SeeOpenPulls() + w.later(Bounds[Proposed].For + time.Minute) + lines := w.h.Stalled() + if len(lines) != 1 { + t.Fatalf("the head is not said: %+v", lines) + } + want := []WaitingCheck{{Context: "mesh/merge-gate", State: CheckPending, Since: "2026-10-11T00:11:39Z"}, + {Context: "mesh/repo-check", State: CheckNeverSet}} + if !slices.Equal(lines[0].Checks, want) { + t.Fatalf("the line's checks are %+v, not %+v", lines[0].Checks, want) + } + if words := []string{"mesh/merge-gate pending since 2026-10-11T00:11:39Z", "mesh/repo-check never set"}; !slices.Equal(lines[0].Waiting, words) { + t.Fatalf("the finished words a controller of before reads are gone: %q", lines[0].Waiting) + } + raw, _ := json.Marshal(lines[0]) + t.Logf("the line as the controller reads it: %s", raw) + if !strings.Contains(string(raw), `"checks":[{"context":"mesh/merge-gate","state":"pending","since":"2026-10-11T00:11:39Z"},{"context":"mesh/repo-check","state":"never-set"}]`) { + t.Fatalf("the line's checks are not in the shape the controller reads: %s", raw) + } +} + +// The data and the words never disagree: for every head the words are made from, the checks give the same contexts +// in the same order, and a pending time the forge did not say, or said unreadably, is given as no time at all. +func TestAnUnansweredLinesChecksAgreeWithItsWords(t *testing.T) { + for _, tc := range []struct { + name, at string + since string + }{{"a time in UTC", "2026-10-11T00:11:39Z", "2026-10-11T00:11:39Z"}, + {"a time with an offset", "2026-10-11T02:11:39+02:00", "2026-10-11T00:11:39Z"}, + {"no time", "", ""}, + {"an unreadable time", "yesterday", ""}} { + t.Run(tc.name, func(t *testing.T) { + p := openPullsOf(t, strings.Replace(startedNeverAnswered, "2026-10-11T00:11:39Z", tc.at, 1))[0] + words, _, _ := p.unanswered() + checks := p.waitingChecks() + if len(words) != len(checks) { + t.Fatalf("words %q, checks %+v", words, checks) + } + for i, c := range checks { + if !strings.HasPrefix(words[i], c.Context+" ") { + t.Fatalf("word %q is not of check %+v", words[i], c) + } + } + if checks[0].State != CheckPending || checks[0].Since != tc.since { + t.Fatalf("the pending check is %+v, not since %q", checks[0], tc.since) + } + }) + } +} diff --git a/modules/mesh-delivery/cmd/mesh-delivery/verbs.go b/modules/mesh-delivery/cmd/mesh-delivery/verbs.go index a1800c04..e1045ac6 100644 --- a/modules/mesh-delivery/cmd/mesh-delivery/verbs.go +++ b/modules/mesh-delivery/cmd/mesh-delivery/verbs.go @@ -175,10 +175,14 @@ type StalledLine struct { // never set", "mesh/merge-gate pending since " — so the controller's words name them without reading // Says (novox/hq issue 438). Waiting []string `json:"waiting,omitempty"` - For string `json:"for"` - Bound string `json:"bound"` - H2 string `json:"h2"` - Says string `json:"says"` + // Checks are the same merge checks as data — context, state, since when — so the controller says their times in + // the operator's own zone, which it cannot do inside a finished sentence (novox/hq issue 443). Waiting stays + // beside them for a controller that does not read them yet. + Checks []WaitingCheck `json:"checks,omitempty"` + For string `json:"for"` + Bound string `json:"bound"` + H2 string `json:"h2"` + Says string `json:"says"` } // Stalled is the `stalled` verb: what the controller's self-check raises as `delivery..stalled`. @@ -210,7 +214,7 @@ func (h *Holder) Stalled() []StalledLine { out = append(out, StalledLine{ID: d.ID, State: d.State, For: now.Sub(boundSince(d)).Round(time.Second).String(), Bound: b.For.String(), H2: h2, Says: b.Says}) } - out = append(out, h.uncheckedLines(now)...) + out = append(out, h.withChecks(h.uncheckedLines(now))...) out = append(out, h.orphanLines(now)...) sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out diff --git a/modules/mesh-delivery/cmd/mesh-delivery/waiting_checks.go b/modules/mesh-delivery/cmd/mesh-delivery/waiting_checks.go new file mode 100644 index 00000000..1da0f356 --- /dev/null +++ b/modules/mesh-delivery/cmd/mesh-delivery/waiting_checks.go @@ -0,0 +1,67 @@ +package main + +import ( + "slices" + "time" +) + +// The merge checks an unanswered line waits on, as data (novox/hq issue 443). The line's Waiting says them as finished +// words, with a pending check's time in UTC; the operator reads the controller's words, which give every time in the +// operator's own zone, and the controller cannot say a time again inside a sentence it did not write. So each check +// is given too as its context, its state and since when, and the controller words it. + +// WaitingCheck is one merge check a stalled line of the state Unanswered waits on. +type WaitingCheck struct { + Context string `json:"context"` + // State is CheckPending or CheckNeverSet. + State string `json:"state"` + // Since is when a pending check was set so, in RFC 3339 and UTC; empty when the forge did not say, or said what + // cannot be read — never a time made up. + Since string `json:"since,omitempty"` +} + +const ( + CheckPending = "pending" + CheckNeverSet = "never-set" +) + +// waitingChecks are the checks unanswered names, in its order and by its rule, as data. +func (p OpenPull) waitingChecks() []WaitingCheck { + if p.HeadSHA == "" { + return nil + } + started := slices.ContainsFunc(mergeChecks, func(c string) bool { + return slices.Contains(p.Statuses, c) || p.Checks[c].State != "" + }) + var out []WaitingCheck + for _, c := range mergeChecks { + if !slices.Contains(p.Required, c) { + continue + } + got, ok := p.Checks[c] + switch { + case p.Checks == nil && started: + // Read as checked, as unanswered does: nothing says whether the check it has answered. + case !ok || got.State == "": + out = append(out, WaitingCheck{Context: c, State: CheckNeverSet}) + case got.State == "pending": + w := WaitingCheck{Context: c, State: CheckPending} + if t, err := time.Parse(time.RFC3339, got.UpdatedAt); err == nil { + w.Since = t.UTC().Format(time.RFC3339) + } + out = append(out, w) + } + } + return out +} + +// withChecks gives each unanswered line the checks it waits on as data, from the head it was said of; under the lock. +// It reads every line of the state, whichever rule said it, so a line said for another reason carries them too. +func (h *Holder) withChecks(lines []StalledLine) []StalledLine { + for i, l := range lines { + if u, ok := h.unchecked[l.ID]; ok && l.State == Unanswered && len(l.Waiting) > 0 { + lines[i].Checks = u.pull.waitingChecks() + } + } + return lines +}