Join item 7: a key's history through the owners' read tools; the verdict and its event name each series change

History (A2j finding 1, J4): the indexer's and the assigner's read tools are views.ReadTool over views.KeptSource, which
answers `{key, history: true}` now; modules (20), heard (64) and wants (20) keep more than one revision. Rows in the
view's "each tool answers through the owners' read tools as before": indexer_module_history and indexer_heard answer
the owner's kept history, and a want's history (view_tiers' Since) is read through assigner_read_wants (the row it
replaces held that the owner answered no history).
Series (A2j finding 2, J4): the checker keeps each series change as a delivery.SeriesChange {module, artifact, from,
to} and renders the status line from it; delivery.Verdict.Series holds the case's changes (each module's artifact once),
and the verdict event's series the pull request's (meshdeliverychecker.VerdictHolderEventSeriesItem). An artifact new
on the head is no series change (the lead's ruling). Rows: TestTheIndexersJudgeJudgesTheChangesManifests (the verdict's
series; failed first, empty) and TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure (the event's; failed
with the fill cut).

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
This commit is contained in:
2026-11-04 15:17:50 +01:00
parent c8eccf299a
commit 12f5e24e7f
7 changed files with 112 additions and 19 deletions
@@ -47,7 +47,9 @@ type Case struct {
warnings map[string]string
// seriesOf are, by pull request, the changes of a package series it makes (series.go), named in its status line.
seriesOf map[string][]string
notes []string
// seriesChanges are, by pull request, the same changes as structures (series.go), named in the verdict and its event.
seriesChanges map[string][]delivery.SeriesChange
notes []string
// asks are the ids of the asks the check put on the build seats' streams, in order: a try names them (Try.Asks).
asks []string
// Watch, when set, hears each ask the check puts, as it is put (step nil), and each value its step key takes while
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-contracts/go/seats/meshdeliverychecker"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -430,6 +431,7 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T
raw, _, _, _ := r.owed.Get(ctx, key)
o, _ := delivery.DecodeCheckerOwed(raw)
seen = append(seen, o.State)
c.series(repoA+"#7", []string{"web/pkg series 0.1 → 0.2"}, []delivery.SeriesChange{{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}})
return Outcome{Verdict: delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.FailedFirst{Test: "m/a.TestX"}},
OutOfStep: map[string][]string{repoA + "#7": {"go/seats/x/contract.go"}}}, nil
}}
@@ -450,7 +452,9 @@ func TestTheStatusIsPendingWhileTheCheckRunsAndEndsSuccessOrFailure(t *testing.T
// DLV-C2 (design 26 *Out of step*): the check's end says one verdict event for the pull request, naming its out-of-step
// files; the running status says none.
if len(says.said) != 1 || says.said[0].Repository != repoA || says.said[0].Pull != 7 || says.said[0].Head != head1 ||
says.said[0].Verdict != delivery.VerdictFail || strings.Join(says.said[0].OutOfStep, ",") != "go/seats/x/contract.go" {
says.said[0].Verdict != delivery.VerdictFail || strings.Join(says.said[0].OutOfStep, ",") != "go/seats/x/contract.go" ||
// The join (A2j finding 2): and its series changes, as the seat's event names them.
len(says.said[0].Series) != 1 || says.said[0].Series[0] != (meshdeliverychecker.VerdictHolderEventSeriesItem{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}) {
t.Errorf("verdict events %+v", says.said)
}
}
@@ -1151,6 +1151,14 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
if line := Line(v, cs, cs.Pulls[0], nil, nil, nil); row.seriesWords != "" && !strings.Contains(line, row.seriesWords) || row.seriesWords == "" && strings.Contains(line, "series") {
t.Errorf("%s: the line %q, want it to name %q", row.name, line, row.seriesWords)
}
// The join (A2j finding 2): the verdict names each series change as {module, artifact, from, to}.
if want := []delivery.SeriesChange(nil); row.series == "0.2" {
if want = []delivery.SeriesChange{{Module: "web", Artifact: "pkg", From: "0.1", To: "0.2"}}; !reflect.DeepEqual(v.Series, want) {
t.Errorf("%s: the verdict's series %+v, want %+v", row.name, v.Series, want)
}
} else if len(v.Series) != 0 {
t.Errorf("%s: the verdict's series %+v, want none", row.name, v.Series)
}
if len(judged) != 1 || judged[0].Fetch["repository"] != DeliveryRepository || judged[0].Fetch["commit"] != trunk ||
judged[0].Package != JudgePackage || judged[0].Dir != JudgeDir {
t.Fatalf("%s: judged %+v (verdict %+v)", row.name, judged, v)
@@ -327,6 +327,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) {
// outcome fills the verdict's levels, times, machines and composition from what ran.
func (st *state) outcome(v delivery.Verdict) Outcome {
v.Series = st.c.allSeries() // the series changes, as structures (A2j finding 2)
if v.State == delivery.VerdictPass || len(v.Levels) == 0 {
v.Levels = map[string]delivery.LevelResult{}
for level, a := range st.levels {
@@ -7,6 +7,7 @@ import (
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// A change of a package series is named in the verdict, so the review of the change sees it (design 32 *A package, its
@@ -18,6 +19,7 @@ import (
func (st *state) seriesChanges(ctx context.Context) {
for _, p := range st.c.Pulls {
var words []string
var changes []delivery.SeriesChange
for _, module := range p.Modules {
was, now, ok, err := st.manifestPair(ctx, p, module)
if err != nil {
@@ -25,11 +27,15 @@ func (st *state) seriesChanges(ctx context.Context) {
continue
}
if ok {
words = append(words, seriesMoved(module, was, now)...)
moved := seriesMoved(module, was, now)
changes = append(changes, moved...)
for _, c := range moved {
words = append(words, seriesLine(c))
}
}
}
if len(words) > 0 {
st.c.series(pullKey(p), words)
st.c.series(pullKey(p), words, changes)
}
}
}
@@ -65,8 +71,14 @@ func (st *state) manifestPair(ctx context.Context, p Pull, module string) (was,
return was, now, true, nil
}
// seriesMoved are the artifacts of module whose series the head's manifest changes from the trunk's.
func seriesMoved(module string, was, now catalogue.Manifest) []string {
// seriesLine is a series change as the status line names it: `<module>/<artifact> series <trunk> → <head>`.
func seriesLine(c delivery.SeriesChange) string {
return fmt.Sprintf("%s/%s series %s → %s", c.Module, c.Artifact, c.From, c.To)
}
// seriesMoved are the artifacts of module whose series the head's manifest changes from the trunk's, by artifact. An
// artifact new on the head is no series change (the lead's ruling): it has no series on the trunk.
func seriesMoved(module string, was, now catalogue.Manifest) []delivery.SeriesChange {
if was.Build == nil || now.Build == nil {
return nil
}
@@ -74,24 +86,50 @@ func seriesMoved(module string, was, now catalogue.Manifest) []string {
for _, a := range was.Build.Artifacts {
before[a.Name] = a.SeriesOf()
}
var out []string
var out []delivery.SeriesChange
for _, a := range now.Build.Artifacts {
if old, ok := before[a.Name]; ok && old != a.SeriesOf() {
out = append(out, fmt.Sprintf("%s/%s series %s → %s", module, a.Name, old, a.SeriesOf()))
out = append(out, delivery.SeriesChange{Module: module, Artifact: a.Name, From: old, To: a.SeriesOf()})
}
}
slices.Sort(out)
slices.SortFunc(out, func(a, b delivery.SeriesChange) int { return strings.Compare(a.Artifact, b.Artifact) })
return out
}
// series keeps, of a pull request (pullKey), the words of the series changes it makes.
func (c *Case) series(key string, words []string) {
// series keeps, of a pull request (pullKey), the words of the series changes it makes and the changes themselves.
func (c *Case) series(key string, words []string, changes []delivery.SeriesChange) {
c.notesMu.Lock()
defer c.notesMu.Unlock()
if c.seriesOf == nil {
c.seriesOf = map[string][]string{}
c.seriesOf, c.seriesChanges = map[string][]string{}, map[string][]delivery.SeriesChange{}
}
c.seriesOf[key] = words
c.seriesOf[key], c.seriesChanges[key] = words, changes
}
// SeriesChanges are the series changes a pull request (pullKey) makes, as the verdict and its event name them.
func (c *Case) SeriesChanges(key string) []delivery.SeriesChange {
c.notesMu.Lock()
defer c.notesMu.Unlock()
return slices.Clone(c.seriesChanges[key])
}
// allSeries are the series changes of every pull request of the case, each module's artifact once, by module and
// artifact: what the verdict names (delivery.Verdict.Series).
func (c *Case) allSeries() []delivery.SeriesChange {
var out []delivery.SeriesChange
seen := map[string]bool{}
for _, p := range c.Pulls {
for _, s := range c.SeriesChanges(pullKey(p)) {
if k := s.Module + "/" + s.Artifact; !seen[k] {
seen[k] = true
out = append(out, s)
}
}
}
slices.SortFunc(out, func(a, b delivery.SeriesChange) int {
return strings.Compare(a.Module+"/"+a.Artifact, b.Module+"/"+b.Artifact)
})
return out
}
// Series are the series changes a pull request (pullKey) makes, as the check named them; none when it makes none.
@@ -28,8 +28,11 @@ func (ch *Checker) sayVerdict(ctx context.Context, cs *Case, v delivery.Verdict,
return
}
for _, p := range cs.Pulls {
e := VerdictEvent{Repository: p.Repository, Pull: int64(p.Number), Head: p.Head, Verdict: v.State,
OutOfStep: out.OutOfStep[pullKey(Pull{Repository: p.Repository, Number: p.Number})]}
key := pullKey(Pull{Repository: p.Repository, Number: p.Number})
e := VerdictEvent{Repository: p.Repository, Pull: int64(p.Number), Head: p.Head, Verdict: v.State, OutOfStep: out.OutOfStep[key]}
for _, s := range cs.SeriesChanges(key) {
e.Series = append(e.Series, meshdeliverychecker.VerdictHolderEventSeriesItem{Module: s.Module, Artifact: s.Artifact, From: s.From, To: s.To})
}
if err := ch.Says.Say(ctx, e); err != nil {
ch.logf("%s: the verdict of %s#%d was written, and its event could not be said: %v", cs.Key, p.Repository, p.Number, err)
}
@@ -36,6 +36,15 @@ func (s source) Get(key string) (*views.Entry, bool, error) {
return &views.Entry{Value: json.RawMessage(v), Revision: uint64(len(key))}, true, nil
}
// kept is a fake owner state that keeps its keys' history, as views.KeptSource does: what the owner's read tool answers a
// `history` from.
type kept struct {
source
history map[string][]views.Revision
}
func (k kept) History(key string) ([]views.Revision, error) { return k.history[key], nil }
// ownersAnswerTheToolsAsBefore is DLV-V1: every view tool over the indexer's and the assigner's states answers through
// the owners' read tools (the real owner side, views.Read, over a fake state) what it answered reading the states.
func ownersAnswerTheToolsAsBefore(t *testing.T) {
@@ -55,6 +64,17 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
delivery.IndexerModule + "." + delivery.PackagesState: {pkgKey: `{"v":1,"module":"git.novox.be/novox/mesh-sdk/go","commit":"c1"}`},
delivery.IndexerModule + "." + graph.State: {},
}
// The kept history of the indexer's modules and heard and the assigner's wants (each declared with more than one
// revision in its owner's manifest), which indexer_module_history, indexer_heard and view_tiers' times read.
at := time.Date(2026, 11, 4, 9, 0, 0, 0, time.UTC)
const heard1 = `{"v":1,"subject":"s","sequence":41,"repository":"novox/mesh-catalog","commit":"9cdac09a"}`
states[delivery.IndexerModule+".heard"] = source{"forge": heard1}
histories := map[string]map[string][]views.Revision{
delivery.IndexerModule + ".modules": {"a": {{Revision: 7, At: at, Op: "put", Value: json.RawMessage(withNone)},
{Revision: 9, At: at.Add(time.Minute), Op: "put", Value: json.RawMessage(withFP2)}}},
delivery.IndexerModule + ".heard": {"forge": {{Revision: 3, At: at, Op: "put", Value: json.RawMessage(heard1)}}},
delivery.AssignerModule + ".wants": {"want.ace.zsh": {{Revision: 12, At: at, Op: "put", Value: json.RawMessage(`{"fp":"` + fp2A + `"}`)}}},
}
// The direct reading, as the tools read the states before (the reads the manifest no longer has).
st := func(name string) view.StateRead { return fake(states[name]).reader().State }
direct := view.Reader{State: st(delivery.IndexerModule + ".modules"), CandidatesState: st(delivery.IndexerModule + ".candidates"),
@@ -82,6 +102,14 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
all, _ := args["values"].(bool)
after, _ := args["after"].(string)
owner, state, _ := strings.Cut(name, ".")
if h, _ := args["history"].(bool); h {
// The join (J4): the owner's read tool answers a key's kept history.
a, err := views.ReadHistory(owner, state, kept{states[name], histories[name]}, k)
if err != nil {
return nil, err
}
return json.Marshal(a)
}
a, err := views.Read(owner, state, states[name], nil, k, all, after, time.Unix(0, 0))
if err != nil {
return nil, err
@@ -118,14 +146,23 @@ func ownersAnswerTheToolsAsBefore(t *testing.T) {
t.Errorf("%s %v through the owner's read tool = %s, %v; reading the state it was %s, %v", row.tool, row.args, gj, gerr, wj, werr)
}
}
// A key's history is asked of the owner's read tool and answered from its kept values (A2j finding 1, J4).
out, err := run(t, owned, view.ModuleHistoryTool, map[string]any{"module": "a"})
if h, _ := out.(view.ModuleHistory); err != nil || len(h.Values) != 2 || h.Values[0].Revision != 9 || h.Values[1].FP2 != "" || !h.Values[1].At.Equal(at) {
t.Errorf("indexer_module_history a through the indexer's read tool = %+v, %v", out, err)
}
out, err = run(t, owned, view.HeardTool, map[string]any{})
if rows, _ := out.(view.HeardAnswer); err != nil || len(rows.Heard) != 1 || rows.Heard[0].Revision != 3 || string(rows.Heard[0].Heard) != heard1 {
t.Errorf("indexer_heard through the indexer's read tool = %+v, %v", out, err)
}
// view_tiers' Since of a want is its first kept revision's time, read through the assigner's read tool.
if revs, err := owned.WantsState.History("want.ace.zsh"); err != nil || len(revs) != 1 || !revs[0].At.Equal(at) || revs[0].Revision != 12 {
t.Errorf("the want's history through the assigner's read tool = %+v, %v", revs, err)
}
// Each owner state the tools read was asked of its owner's read tool.
for tool, name := range tools {
if name != delivery.IndexerModule+"."+graph.State && asked[tool] == 0 {
t.Errorf("%s was never asked", tool)
}
}
// A history the owner's read tool does not answer is said, never answered as none.
if _, err := run(t, owned, view.ModuleHistoryTool, map[string]any{"module": "a"}); err == nil || !strings.Contains(err.Error(), "no history argument") {
t.Errorf("indexer_module_history with an owner answering no history: %v", err)
}
}