RN9: pipeline-ended carries test counts and over-budget; group-ended says a group's end

pipeline-ended carries the module's test counts (the sum of its steps') and over-budget
past 500 unit or 50 integration tests; once every member of a group on a watched branch's
source revision ended, group-ended is said under the group.

Builds: design 47 G §24, §73, §332, §419

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-12 05:51:33 +01:00
parent 8953adad88
commit e5c864c246
2 changed files with 128 additions and 1 deletions
@@ -0,0 +1,60 @@
package pipeline
import (
"strings"
"testing"
seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshpipelinerunner"
nodebuildgo "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/nodebuildgo"
)
// pipeline-ended carries the module's test counts and says it over budget past 500 unit or 50 integration tests (design 47 G
// §24, §419), and a group's one pipeline ends with group-ended under the group once every member ended (§73, §332).
func TestPipelineEndedCarriesTestCountsAndAGroupEndsOnItsMembers(t *testing.T) {
h, store, _, _, _, _ := fixture()
bodies := map[string]any{}
var order []string
h.Say = func(subject string, body any) error {
ev := subject[strings.Index(subject, ".event.")+7:]
bodies[ev] = body
order = append(order, ev)
return nil
}
e := store[pkey]
e.p.Steps = e.p.Steps[:2]
e.p.Steps[1].Status = seat.StepResultStatusRequired
e.p.Group = "g1"
store[pkey] = e
// A second member of the same group, on the same source revision, still running.
store["main.5.lib"] = struct {
p seat.Pipeline
rev uint64
}{seat.Pipeline{V: 1, Pipeline: "main.5.lib", Branch: "main", Source: 5, Module: "lib", Commit: "c2", Group: "g1", Outcome: seat.PipelineOutcomeRunning,
Steps: []seat.StepResult{{Step: seat.StepResultStepLint, Status: seat.StepResultStatusRequired, Outcome: seat.StepResultOutcomeRunning, Ask: "b1", Seat: nodebuildgo.Seat}}}, 1}
lint := ended("a1", "lint", "passed")
unit := ended("a2", "unit", "passed")
unit.Tests = &nodebuildgo.TestCounts{Unit: 501, Integration: 0}
for _, ev := range []StepEnded{lint, unit} {
if _, err := h.OnStepEnded(goSeat, goSeat.Prefix+"n1", ev); err != nil {
t.Fatal(err)
}
}
pe, ok := bodies["pipeline-ended.app.main.g1"].(seat.PipelineEnded)
if !ok || pe.Tests == nil || pe.Tests.Unit != 501 || !pe.OverBudget {
t.Fatalf("pipeline-ended = %+v (said %v)", bodies["pipeline-ended.app.main.g1"], order)
}
if _, said := bodies["group-ended.main.g1"]; said {
t.Fatalf("group-ended said while a member runs: %v", order)
}
lib := ended("b1", "lint", "passed")
lib.Pipeline, lib.Module, lib.Commit, lib.Fingerprint = "main.5.lib", "lib", "c2", ""
if _, err := h.OnStepEnded(goSeat, goSeat.Prefix+"n1", lib); err != nil {
t.Fatal(err)
}
ge, ok := bodies["group-ended.main.g1"].(seat.GroupEnded)
if !ok || ge.Outcome != seat.GroupEndedOutcomePassed || ge.Group != "g1" || ge.Branch != "main" || ge.Source != 5 || len(ge.Pipelines) != 2 {
t.Fatalf("group-ended = %+v (said %v)", bodies["group-ended.main.g1"], order)
}
// A pipeline of no group says no group-ended.
}
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
@@ -207,6 +208,49 @@ func (h *Hearer) said(p seat.Pipeline, asked, ended bool) error {
if err := h.say(seat.EventPipelineEnded, p, nil); err != nil {
return fmt.Errorf("pipeline %s ended but %s is not said: %w", p.Pipeline, seat.EventPipelineEnded, err)
}
return h.groupEnded(p)
}
return nil
}
// groupEnded says a group's one pipeline's end once every member's pipeline of the same watched branch and source revision
// ended (design 47 G §73, §332): on the subject under the group, no module part, with every member's end. A pipeline of no
// group says none; a member still running says none yet.
func (h *Hearer) groupEnded(p seat.Pipeline) error {
if p.Group == "" {
return nil
}
pkeys, err := h.Store.Keys("*.*.*")
if err != nil {
return fmt.Errorf("the members of group %s are not listed: %w", p.Group, err)
}
sort.Strings(pkeys)
var members []seat.PipelineEnded
outcome, why := seat.GroupEndedOutcomePassed, ""
var last *time.Time
for _, key := range pkeys {
m, _, err := h.Store.Get(key)
if err != nil || m.Branch != p.Branch || m.Source != p.Source || m.Group != p.Group {
continue
}
if m.Outcome == seat.PipelineOutcomeRunning {
return nil
}
members = append(members, pipelineEnded(m))
if m.Ended != nil && (last == nil || m.Ended.After(*last)) {
last = m.Ended
}
if m.Outcome != seat.PipelineOutcomePassed && outcome != seat.GroupEndedOutcomeFailed {
outcome, why = m.Outcome, m.Module+": "+m.Why
if m.Outcome == seat.PipelineOutcomeFailed {
outcome = seat.GroupEndedOutcomeFailed
}
}
}
subject := "mesh.seat." + seat.Seat + ".event." + seat.EventGroupEnded + "." + keys.EscapeName(p.Branch) + "." + keys.EscapeName(p.Group)
if err := h.Say(subject, seat.GroupEnded{V: 1, Branch: p.Branch, Group: p.Group, Source: p.Source, Revision: p.Revision, Ended: last,
Outcome: outcome, Why: why, Pipelines: members}); err != nil {
return fmt.Errorf("group %s ended but %s is not said: %w", p.Group, seat.EventGroupEnded, err)
}
return nil
}
@@ -247,8 +291,31 @@ func (h *Hearer) say(event string, p seat.Pipeline, step *seat.StepResult) error
}
}
// The test budget per catalog module (design 47 G §24, §419; the test budget): at most 500 unit and 50 integration tests.
const (
budgetUnit = 500
budgetIntegration = 50
)
// testCounts is the module's test counts, the sum of its test steps' own counts; nil when no step counted any.
func testCounts(steps []seat.StepResult) *seat.TestCounts {
var t seat.TestCounts
counted := false
for _, s := range steps {
if s.Tests != nil {
t.Unit, t.Integration, counted = t.Unit+s.Tests.Unit, t.Integration+s.Tests.Integration, true
}
}
if !counted {
return nil
}
return &t
}
func pipelineEnded(p seat.Pipeline) seat.PipelineEnded {
return seat.PipelineEnded{V: 1, Pipeline: p.Pipeline, Branch: p.Branch, Module: p.Module, Commit: p.Commit, Fingerprint: p.Fingerprint,
tests := testCounts(p.Steps)
return seat.PipelineEnded{Tests: tests, OverBudget: tests != nil && (tests.Unit > budgetUnit || tests.Integration > budgetIntegration),
V: 1, Pipeline: p.Pipeline, Branch: p.Branch, Module: p.Module, Commit: p.Commit, Fingerprint: p.Fingerprint,
Group: p.Group, Revision: p.Revision, Source: p.Source, Run: p.Run, Outcome: p.Outcome, Why: p.Why, Started: p.Started,
Ended: p.Ended, EndCount: p.EndCount, Steps: p.Steps}
}