Draw a plan's order per module from the graph and the manifests' change, and read no after: line (issue 977, part B, ADR 0419)

This commit is contained in:
2026-10-17 20:57:30 +02:00
parent 0d83c6ffc9
commit e43d52c1ea
15 changed files with 710 additions and 363 deletions
+53 -69
View File
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"regexp"
"slices" "slices"
"sort" "sort"
"strconv" "strconv"
@@ -71,9 +70,9 @@ const batchLateAfter = time.Minute
// made before the controller began keeping merges (owedLate). // made before the controller began keeping merges (owedLate).
const mergeGrace = 10 * time.Minute const mergeGrace = 10 * time.Minute
// edgeGroupOrder is a delivery group's order made an edge of a walk's tiers (ADR 0276 decision 5): the // edgeChangeOrder is the order the rules of a plan draw among its modules (novox/hq ADR 0419 clauses 3 and 4, delivery_edges.go)
// modules of the member after depend on the modules of the member before, and are gated on them. // made an edge of its tiers: the module after depends on the module before, and is gated on it.
const edgeGroupOrder = "group-order" const edgeChangeOrder = "change-order"
// mergeWindowOf is the window and its maximum, from the controller's settings; a value unreadable is its // mergeWindowOf is the window and its maximum, from the controller's settings; a value unreadable is its
// default, and said. // default, and said.
@@ -1039,9 +1038,13 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
} }
} }
sort.Strings(also) sort.Strings(also)
ordered := groupOrderEdges(members, entries, read, edges) ordered, err := changeOrderEdges(ctx, members, moved, edges)
if err != nil {
return err
}
var withheld []string var withheld []string
plan := planOfMoves(moved, append(append([]inventory.Edge{}, edges...), ordered...)) plan := planOfMoves(moved, append(append([]inventory.Edge{}, edges...), ordered...))
placedBy(&plan, ordered)
// **Withheld, a stopped merge's modules** (novox/hq ADR 0299): a module is built at its branch, which holds the // **Withheld, a stopped merge's modules** (novox/hq ADR 0299): a module is built at its branch, which holds the
// merge a stop left out of the walk these merges were walked again from — built here only as a dependent, it // merge a stop left out of the walk these merges were walked again from — built here only as a dependent, it
// would deliver that merge. One a merge of this batch moves is built: that merge carries the stopped change, // would deliver that merge. One a merge of this batch moves is built: that merge carries the stopped change,
@@ -1094,7 +1097,14 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
} }
var tiers []string var tiers []string
for i, t := range plan.Tiers { for i, t := range plan.Tiers {
tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(t, ", "))) var names []string
for _, n := range t {
if m := plan.Modules[n]; m != nil && m.Placed != "" {
n += " " + m.Placed
}
names = append(names, n)
}
tiers = append(tiers, fmt.Sprintf("%d: %s", i, strings.Join(names, ", ")))
} }
var answers []string var answers []string
for _, n := range named { for _, n := range named {
@@ -1122,6 +1132,23 @@ func planBatch(ctx context.Context, open *stores, batch *inventory.Plan, carry [
return nil return nil
} }
// placedBy writes on each module of a plan the edges of its order that put it after another module of the plan, with the
// rule that drew each (novox/hq ADR 0419 clause 5).
func placedBy(plan *inventory.Plan, ordered []inventory.Edge) {
for _, e := range ordered {
m, in := plan.Modules[e.From]
if _, before := plan.Modules[e.To]; !in || !before {
continue
}
said := "after " + e.To + " (" + e.Why + ")"
if m.Placed == "" {
m.Placed = said
} else if !strings.Contains(m.Placed, said) {
m.Placed += "; " + said
}
}
}
// unchangedSaid names the modules a walk says matched what runs, for its note. // unchangedSaid names the modules a walk says matched what runs, for its note.
func unchangedSaid(unchanged map[string]string) string { func unchangedSaid(unchanged map[string]string) string {
names := make([]string, 0, len(unchanged)) names := make([]string, 0, len(unchanged))
@@ -1310,73 +1337,30 @@ type batchMember struct {
moved []string moved []string
} }
// afterLines are the `after: <repository>` lines of a pull request's description, as mesh-delivery reads them. // changeOrderEdges is the order of a batch's moved modules as edges of its plan's tiers (novox/hq ADR 0419): drawn
var afterLines = regexp.MustCompile(`(?im)^\s*after:\s*([A-Za-z0-9_.\-/]+)\s*$`) // per module from the module graph and from what the merges say of the manifests they touch (delivery_edges.go),
// whatever repository the merges are in and whether or not they form a delivery group. No `after:` line is read.
// groupOrderEdges is the order of every delivery group among a batch's merges as edges of its walk's tiers // A cycle is said, never lost: the plan puts the modules left in its last tier, and the composed check fails the
// (ADR 0276 decision 5, ADR 0249 unchanged): merges of two repositories or more from one head branch name are // group before it merges.
// a group; each pair its order rules give puts the modules of the member after in a later tier than the func changeOrderEdges(ctx context.Context, members []batchMember, moved []string, edges []inventory.Edge) ([]inventory.Edge, error) {
// member before's. A pair in a contradiction orders nothing: its group was refused before it merged. var sources []changeSource
func groupOrderEdges(members []batchMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
edges []inventory.Edge) []inventory.Edge {
byHead := map[string][]batchMember{}
for _, m := range members { for _, m := range members {
if m.merge.Head != "" { sources = append(sources, changeSource{Owner: m.merge.Owner, Repo: m.merge.Repo, New: m.merge.Commit,
byHead[m.merge.Head] = append(byHead[m.merge.Head], m) Paths: m.merge.Paths, Truncated: m.merge.PathsTruncated, Removed: m.merge.Removed})
}
} }
var out []inventory.Edge changes, err := changesOf(ctx, sources)
heads := make([]string, 0, len(byHead)) if err != nil {
for h := range byHead { return nil, fmt.Errorf("what the merges say of their manifests cannot be read, so the order of their modules "+
heads = append(heads, h) "cannot be drawn: %w", err)
} }
sort.Strings(heads) order := orderEdges(moved, changes, edges)
for _, h := range heads { for _, e := range order.Edges {
group := byHead[h] fmt.Printf(" order: %s\n", edgeSaid(e))
repos := map[string]bool{}
for _, m := range group {
repos[strings.ToLower(m.merge.Owner+"/"+m.merge.Repo)] = true
}
if len(repos) < 2 {
continue
}
var om []orderMember
moved := map[string][]string{}
for _, m := range group {
repository := m.merge.Owner + "/" + m.merge.Repo
id := repository + "@" + m.merge.Commit
var after []string
for _, l := range afterLines.FindAllStringSubmatch(m.merge.Body, -1) {
after = append(after, strings.TrimSuffix(l[1], ".git"))
}
om = append(om, orderMember{ID: id, Repository: repository, Base: m.merge.Base, Head: m.merge.Commit,
Number: m.merge.Number, Paths: m.merge.Paths, PathsTruncated: m.merge.PathsTruncated,
Removed: m.merge.Removed, ModuleDirs: m.merge.ModuleDirs, ModuleDirsSaid: m.merge.ModuleDirsSaid,
CloneURL: m.merge.CloneURL, After: after})
moved[id] = m.moved
}
order := orderOf(om, reachOfMembers(om, entries, read, edges), edges)
contradicted := map[string]bool{}
for _, c := range order.Contradictions {
for _, id := range c.Members {
contradicted[id] = true
}
}
for _, p := range order.Pairs {
if contradicted[p.Before] && contradicted[p.After] {
continue
}
for _, before := range moved[p.Before] {
for _, after := range moved[p.After] {
if before != after {
out = append(out, inventory.Edge{From: after, To: before, Kind: edgeGroupOrder})
}
}
}
fmt.Printf(" group %s: %s before %s (%s) inside the walk\n", h, p.Before, p.After, p.Why)
}
} }
return out if said := order.CycleSaid(); said != "" {
fmt.Printf(" %s\n", said)
}
return order.Edges, nil
} }
// searchAfterFailures starts the search a failed walk leaves (ADR 0276 decision 3): every merge it answered // searchAfterFailures starts the search a failed walk leaves (ADR 0276 decision 3): every merge it answered
+9 -1
View File
@@ -19,7 +19,15 @@ import (
// The suite's tests of a merge, but for the merge window's own (this file's), plan the merge as it is heard: a // The suite's tests of a merge, but for the merge window's own (this file's), plan the merge as it is heard: a
// window of nothing closes at once, so a merge with no walk open is cut into its walk at once, as every merge // window of nothing closes at once, so a merge with no walk open is cut into its walk at once, as every merge
// was planned before novox/hq ADR 0276. The window's tests set it through the controller's settings. // was planned before novox/hq ADR 0276. The window's tests set it through the controller's settings.
func init() { mergeWindowDefault = 0 } func init() {
mergeWindowDefault = 0
// No forge in a test: no manifest is found at any commit, so a merge draws no edge of the manifests' change
// (delivery_edges.go). A test that wants some replaces these (forgeHolding).
manifestAt = func(context.Context, string, string, string, string) (catalogue.Manifest, bool, error) {
return catalogue.Manifest{}, false, nil
}
parentOf = func(context.Context, string, string, string) (string, error) { return "", nil }
}
// windowed is a mesh whose controller's settings give a merge window of 90 seconds and at most 10 minutes, as its // windowed is a mesh whose controller's settings give a merge window of 90 seconds and at most 10 minutes, as its
// settings file says them, and // settings file says them, and
+50 -177
View File
@@ -579,8 +579,6 @@ type orderMember struct {
ModuleDirs []string `json:"module_dirs,omitempty"` ModuleDirs []string `json:"module_dirs,omitempty"`
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"` ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
CloneURL string `json:"clone_url,omitempty"` CloneURL string `json:"clone_url,omitempty"`
// After are the repositories its pull request says it goes after (`after: <repository>`).
After []string `json:"after,omitempty"`
// BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit // BaseSHA is the base branch's head the forge last announced the head with (novox/hq ADR 0323): the trunk commit
// a verdict no build judges names when the head is checked again through mesh-delivery. // a verdict no build judges names when the head is checked again through mesh-delivery.
BaseSHA string `json:"base_sha,omitempty"` BaseSHA string `json:"base_sha,omitempty"`
@@ -611,14 +609,6 @@ type orderPair struct {
Over []string `json:"over,omitempty"` Over []string `json:"over,omitempty"`
} }
// orderContradiction is two members that rules of one rank order both ways: no precedence resolves it, so
// the group is refused, the rules named, and Said says how a person declares the order.
type orderContradiction struct {
Members []string `json:"members"`
Rules []orderPair `json:"rules"`
Said string `json:"said"`
}
// orderReach is what a member moves, as the planner says. // orderReach is what a member moves, as the planner says.
type orderReach struct { type orderReach struct {
Moved []string `json:"moved,omitempty"` Moved []string `json:"moved,omitempty"`
@@ -627,114 +617,76 @@ type orderReach struct {
Manifests []string `json:"manifests,omitempty"` Manifests []string `json:"manifests,omitempty"`
} }
// groupOrder is a group's order: the members in it, every pair and why, and the cycle when there is one — // groupOrder is a group's order: the members in it, every pair and why, and the cycle when there is one. The
// with each contradiction no precedence resolves. // order is read from the module graph and the manifests' change alone (novox/hq ADR 0419): a cycle is the only
// refusal, and CycleSaid names every module and edge of it. Edges are the module edges the pairs come from.
type groupOrder struct { type groupOrder struct {
Order []string `json:"order"` Order []string `json:"order"`
Pairs []orderPair `json:"pairs,omitempty"` Pairs []orderPair `json:"pairs,omitempty"`
Cycle []string `json:"cycle,omitempty"` Cycle []string `json:"cycle,omitempty"`
Contradictions []orderContradiction `json:"contradictions,omitempty"` CycleSaid string `json:"cycle_said,omitempty"`
Reach map[string]orderReach `json:"reach,omitempty"` Edges []inventory.Edge `json:"edges,omitempty"`
Reach map[string]orderReach `json:"reach,omitempty"`
} }
// The reasons a pair is ordered, in the words the delivery plan shows. // orderBuiltBy is the reason a member goes before another that moves a module built by, standing on, packaging or
const ( // declaring one of its modules: the module graph's own edge.
orderDeclared = "declared" const orderBuiltBy = "built by"
orderBuiltBy = "built by"
orderVersionSkew = "version skew"
orderEngineFirst = "engine before controller"
)
// orderRank is a rule's precedence when two rules order one pair both ways (novox/hq ADR 0249), the lower // orderOf is a group's order (novox/hq ADR 0419), pure: a member goes before another when a module it moves goes
// winning: what a person declared; then what the graph and the change say (built by, version skew); then // before a module the other moves by an edge of the module graph (built by), or by a rule of delivery_edges.go
// the rollout default, which infers a direction from the modules' names alone. // (verb before caller, version skew, engine before controller), which changes names the manifests' added verbs.
func orderRank(why string) int { // No `after:` line is read. Ties break by repository then id, so every reading gives one order. A cycle through
switch why { // two members or more is named by its members, and none of them is ordered.
case orderDeclared: func orderOf(members []orderMember, reach map[string]orderReach, edges []inventory.Edge, changes map[string]moduleChange) groupOrder {
return 0
case orderBuiltBy, orderVersionSkew:
return 1
case orderEngineFirst:
return 2
}
return 3
}
// orderOf is a group's order (novox/hq ADR 0239 decision 4, ADR 0249), pure. A member goes before another
// when:
//
// - its pull request is named in the other's `after:` lines (declared);
// - it moves a module the other's moved modules are built by, stand on, package or declare (built by);
// - it moves the controller and the other changes any module's manifest (version skew: the newer
// controller parses what the newer manifest says) — the node-engine's excepted, which goes first;
// - it moves the node-engine and the other moves the controller (the witness reads nothing the controller
// does not yet grant, and a controller sends nothing an older engine would refuse — ADR 0236's rollout
// order).
//
// When rules order one pair both ways, the one of higher precedence wins (orderRank) and the pair says
// what it won over. Rules of one rank both ways are a contradiction: both are kept as pairs, the members
// are the cycle, and the contradiction says how to declare the order. Otherwise, by repository then id, so
// every reading gives one order. A cycle through three members or more is named by its members, and none
// of them is ordered.
func orderOf(members []orderMember, reach map[string]orderReach, edges []inventory.Edge) groupOrder {
out := groupOrder{Reach: reach} out := groupOrder{Reach: reach}
byID := map[string]orderMember{} movedBy := map[string][]string{}
var all []string
for _, m := range members { for _, m := range members {
byID[m.ID] = m for _, n := range reach[m.ID].Moved {
movedBy[n] = append(movedBy[n], m.ID)
all = appendNew(all, n)
}
} }
var claims []orderPair
add := func(before, after, why string) { add := func(before, after, why string) {
if before == after { if before == after {
return return
} }
for _, p := range claims { for _, p := range out.Pairs {
if p.Before == before && p.After == after && p.Why == why { if p.Before == before && p.After == after && p.Why == why {
return return
} }
} }
claims = append(claims, orderPair{Before: before, After: after, Why: why}) out.Pairs = append(out.Pairs, orderPair{Before: before, After: after, Why: why})
} }
named := func(said, repository string) bool { for _, e := range edges {
said = strings.TrimSuffix(strings.TrimSpace(said), ".git") if !orders(e) {
if strings.EqualFold(said, repository) { continue
return true
} }
_, repo, _ := strings.Cut(repository, "/") for _, before := range movedBy[e.To] {
return strings.EqualFold(said, repo) for _, after := range movedBy[e.From] {
add(before, after, orderBuiltBy)
}
}
} }
for _, a := range members { order := orderEdges(all, changes, edges)
for _, b := range members { out.Edges = order.Edges
if a.ID == b.ID { out.CycleSaid = order.CycleSaid()
continue for _, e := range order.Edges {
} for _, before := range movedBy[e.To] {
ra, rb := reach[a.ID], reach[b.ID] for _, after := range movedBy[e.From] {
for _, said := range b.After { add(before, after, e.Why)
if named(said, a.Repository) {
add(a.ID, b.ID, orderDeclared)
}
}
for _, e := range edges {
if slices.Contains(ra.Moved, e.To) && slices.Contains(rb.Moved, e.From) && e.From != e.To {
add(a.ID, b.ID, orderBuiltBy)
break
}
}
if slices.Contains(ra.Moved, "mesh-controller") && len(rb.Manifests) > 0 &&
!slices.Contains(rb.Moved, "mesh-controller") && !slices.Contains(rb.Moved, "mesh-host") {
add(a.ID, b.ID, orderVersionSkew)
}
if slices.Contains(ra.Moved, "mesh-host") && slices.Contains(rb.Moved, "mesh-controller") &&
!slices.Contains(ra.Moved, "mesh-controller") {
add(a.ID, b.ID, orderEngineFirst)
} }
} }
} }
out.Pairs, out.Contradictions = resolveClaims(claims, byID)
sort.Slice(out.Pairs, func(i, j int) bool { sort.Slice(out.Pairs, func(i, j int) bool {
if out.Pairs[i].Before != out.Pairs[j].Before { if out.Pairs[i].Before != out.Pairs[j].Before {
return out.Pairs[i].Before < out.Pairs[j].Before return out.Pairs[i].Before < out.Pairs[j].Before
} }
return out.Pairs[i].After < out.Pairs[j].After if out.Pairs[i].After != out.Pairs[j].After {
return out.Pairs[i].After < out.Pairs[j].After
}
return out.Pairs[i].Why < out.Pairs[j].Why
}) })
// Kahn's walk, the next always the first by repository and id among those with nothing before them. // Kahn's walk, the next always the first by repository and id among those with nothing before them.
waiting := map[string]int{} waiting := map[string]int{}
@@ -779,89 +731,6 @@ func orderOf(members []orderMember, reach map[string]orderReach, edges []invento
return out return out
} }
// orderRules is the rules in the order a pair names them when more than one says the same.
var orderRules = []string{orderDeclared, orderBuiltBy, orderVersionSkew, orderEngineFirst}
// resolveClaims is the pairs the rules' claims come to (novox/hq ADR 0249): for each two members, the claims
// of the highest precedence decide, and the claims they overrule are said on the pair. Claims of one rank
// both ways are a contradiction: both pairs are kept, so the walk finds the cycle, and the contradiction
// says how a person declares the order.
func resolveClaims(claims []orderPair, byID map[string]orderMember) ([]orderPair, []orderContradiction) {
byTwo := map[[2]string][]orderPair{}
var keys [][2]string
for _, c := range claims {
k := [2]string{min(c.Before, c.After), max(c.Before, c.After)}
if _, ok := byTwo[k]; !ok {
keys = append(keys, k)
}
byTwo[k] = append(byTwo[k], c)
}
slices.SortFunc(keys, func(a, b [2]string) int {
if c := strings.Compare(a[0], b[0]); c != 0 {
return c
}
return strings.Compare(a[1], b[1])
})
ruleIndex := func(why string) int { return slices.Index(orderRules, why) }
var pairs []orderPair
var contradictions []orderContradiction
for _, k := range keys {
cs := byTwo[k]
slices.SortStableFunc(cs, func(a, b orderPair) int { return ruleIndex(a.Why) - ruleIndex(b.Why) })
top := orderRank(cs[0].Why)
var won []orderPair
ways := map[string]bool{}
for _, c := range cs {
if orderRank(c.Why) == top {
won = append(won, c)
ways[c.Before] = true
}
}
if len(ways) == 1 {
p := orderPair{Before: won[0].Before, After: won[0].After, Why: won[0].Why}
for _, c := range cs {
if c.Before != p.Before && !slices.Contains(p.Over, c.Why) {
p.Over = append(p.Over, c.Why)
}
}
pairs = append(pairs, p)
continue
}
// One rank both ways: no precedence resolves it.
var first, second *orderPair
var said []string
for i := range won {
c := won[i]
if c.Before == k[0] && first == nil {
first = &won[i]
}
if c.Before == k[1] && second == nil {
second = &won[i]
}
said = append(said, fmt.Sprintf("%s puts %s first", c.Why, c.Before))
}
pairs = append(pairs, orderPair{Before: first.Before, After: first.After, Why: first.Why},
orderPair{Before: second.Before, After: second.After, Why: second.Why})
a, b := byID[k[0]], byID[k[1]]
how := fmt.Sprintf("neither outranks the other: declare the order with a line `after: %s` in %s's "+
"description, or `after: %s` in %s's", a.Repository, pullName(b), b.Repository, pullName(a))
if top == orderRank(orderDeclared) {
how = "each pull request declares it goes after the other: remove one of the `after:` lines"
}
contradictions = append(contradictions, orderContradiction{Members: []string{k[0], k[1]}, Rules: won,
Said: fmt.Sprintf("%s and %s: %s; %s", k[0], k[1], strings.Join(said, ", "), how)})
}
return pairs, contradictions
}
// pullName is a member as a person finds it: its pull request, or its id when no number was said.
func pullName(m orderMember) string {
if m.Number > 0 {
return fmt.Sprintf("%s#%d", m.Repository, m.Number)
}
return m.ID
}
// reachOfMembers is each member's reach, as the planner says it. // reachOfMembers is each member's reach, as the planner says it.
func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository, func reachOfMembers(members []orderMember, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
edges []inventory.Edge) map[string]orderReach { edges []inventory.Edge) map[string]orderReach {
@@ -959,7 +828,11 @@ func deliveryCommand(ctx context.Context, args []string) error {
if err != nil { if err != nil {
return err return err
} }
return answer(orderOf(members, reachOfMembers(members, entries, read, edges), edges)) changes, err := changesOf(ctx, changeSourcesOf(members))
if err != nil {
return fmt.Errorf("what the members say of their manifests cannot be read: %w", err)
}
return answer(orderOf(members, reachOfMembers(members, entries, read, edges), edges, changes))
case "check": case "check":
if *group == "" && len(members) == 1 { if *group == "" && len(members) == 1 {
// One head, checked again as the forge's announcement would have it (a recheck): the controller's // One head, checked again as the forge's announcement would have it (a recheck): the controller's
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"path"
"slices"
"strings"
"sync"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/link"
)
// What a change says of the manifests it touches, read from the forge (novox/hq ADR 0419 clause 3, the lead's settlement
// of issue 977 question 1): the controller asks the forge for each changed module.json at the commit and at what it
// replaced, once per commit. The composed check's result is not used: the plan is cut after the merge.
// changeSource is one change to a repository whose manifests are read: the commit after it and, when known, what it
// replaced (a pull request's base commit or branch). An empty Old is the first parent of New: the merge's base.
type changeSource struct {
Owner, Repo string
New, Old string
Paths []string
Truncated bool
Removed []string
}
// manifestAt reads a repository's module.json at a ref from the forge; the bool says it exists there. A variable so a
// test needs no forge.
var manifestAt = func(ctx context.Context, owner, repo, file, ref string) (catalogue.Manifest, bool, error) {
var found struct {
Result struct {
Content string `json:"content"`
Encoding string `json:"encoding"`
} `json:"result"`
}
missing, err := askForgeApi(ctx, "/repos/"+owner+"/"+repo+"/contents/"+escapedPath(file)+"?ref="+url.QueryEscape(ref), &found)
if err != nil {
return catalogue.Manifest{}, false, err
}
if missing {
return catalogue.Manifest{}, false, nil
}
body := []byte(found.Result.Content)
if found.Result.Encoding == "base64" {
if body, err = base64.StdEncoding.DecodeString(strings.ReplaceAll(found.Result.Content, "\n", "")); err != nil {
return catalogue.Manifest{}, false, fmt.Errorf("%s/%s %s at %s cannot be read: %w", owner, repo, file, ref, err)
}
}
var m catalogue.Manifest
if err := json.Unmarshal(body, &m); err != nil {
return catalogue.Manifest{}, false, fmt.Errorf("%s/%s %s at %s is not a manifest: %w", owner, repo, file, ref, err)
}
return m, true, nil
}
// parentOf is the first parent of a commit, from the forge. A variable so a test needs no forge.
var parentOf = func(ctx context.Context, owner, repo, commit string) (string, error) {
var found struct {
Result struct {
Parents []struct {
SHA string `json:"sha"`
} `json:"parents"`
} `json:"result"`
}
if _, err := askForgeApi(ctx, "/repos/"+owner+"/"+repo+"/git/commits/"+url.PathEscape(commit), &found); err != nil {
return "", err
}
if len(found.Result.Parents) == 0 {
return "", nil
}
return found.Result.Parents[0].SHA, nil
}
func escapedPath(p string) string {
parts := strings.Split(p, "/")
for i := range parts {
parts[i] = url.PathEscape(parts[i])
}
return strings.Join(parts, "/")
}
// askForgeApi makes one read of the forge's API through its module's tool on the bus. It answers true when the forge
// says there is no such thing (404), and an error for anything else it cannot answer.
func askForgeApi(ctx context.Context, apiPath string, into any) (bool, error) {
js, err := aBus()
if err != nil {
return false, err
}
defer js.Close()
raw, _ := json.Marshal(map[string]any{"path": apiPath})
answer, err := link.Ask(ctx, link.OverNATS{Conn: js.Conn()}, "gitea", "gitea_api", raw, 30*time.Second)
if err != nil {
return false, err
}
if answer.Error != "" {
if strings.Contains(answer.Error, " 404 ") {
return true, nil
}
return false, fmt.Errorf("the forge did not answer %s: %s", apiPath, answer.Error)
}
if err := json.Unmarshal(answer.Result, into); err != nil {
return false, fmt.Errorf("the forge's answer to %s cannot be read: %w", apiPath, err)
}
return false, nil
}
// manifestsRead keeps what the forge said of a manifest at a commit, which never changes: one ask per commit.
var (
manifestsRead = map[string]manifestRead{}
manifestsReadMu sync.Mutex
)
type manifestRead struct {
manifest catalogue.Manifest
exists bool
}
func manifestCached(ctx context.Context, owner, repo, file, ref string) (catalogue.Manifest, bool, error) {
key := strings.Join([]string{owner, repo, file, ref}, "\x00")
if len(ref) == 40 {
manifestsReadMu.Lock()
got, ok := manifestsRead[key]
manifestsReadMu.Unlock()
if ok {
return got.manifest, got.exists, nil
}
}
m, exists, err := manifestAt(ctx, owner, repo, file, ref)
if err == nil && len(ref) == 40 {
manifestsReadMu.Lock()
if len(manifestsRead) > 512 {
clear(manifestsRead)
}
manifestsRead[key] = manifestRead{m, exists}
manifestsReadMu.Unlock()
}
return m, exists, err
}
// verbsServed are the seat verbs a manifest serves, as "<seat>.<verb>": those of the seats it defines, those of the
// seats it claims, and its own tools as "<module>.<tool>".
func verbsServed(m catalogue.Manifest) []string {
var out []string
for _, s := range m.DefinesSeats {
for _, v := range s.Serves {
out = appendNew(out, s.Name+"."+v.Name)
}
}
for _, c := range m.Claims {
for _, v := range c.ServesFor(m) {
out = appendNew(out, c.Name+"."+v)
}
}
for _, t := range m.Tools {
out = appendNew(out, m.Module+"."+t)
}
return out
}
// verbsInvoked are the verbs a manifest calls, as "<seat>.<verb>" or "<module>.<tool>".
func verbsInvoked(m catalogue.Manifest) []string {
var out []string
for _, i := range m.Invokes {
if i = strings.TrimPrefix(i, "seat:"); i != "*" && i != "" {
out = appendNew(out, i)
}
}
return out
}
func added(now, was []string) []string {
var out []string
for _, v := range now {
if !slices.Contains(was, v) {
out = append(out, v)
}
}
return out
}
// changesOf reads, for each manifest the changes touch, what they add to the verbs its module serves and invokes, by
// module. A change whose paths are unsaid or truncated names no manifest: nothing is read for it. An error is the
// forge's, never read as "no change": the plan is not cut on a guess.
func changesOf(ctx context.Context, sources []changeSource) (map[string]moduleChange, error) {
out := map[string]moduleChange{}
for _, s := range sources {
if s.Truncated {
continue
}
for _, p := range s.Paths {
if path.Base(p) != "module.json" || slices.Contains(s.Removed, p) {
continue
}
now, exists, err := manifestCached(ctx, s.Owner, s.Repo, p, s.New)
if err != nil {
return nil, err
}
if !exists || now.Module == "" {
continue
}
old := s.Old
if old == "" {
if old, err = parentOf(ctx, s.Owner, s.Repo, s.New); err != nil {
return nil, err
}
}
var was catalogue.Manifest
if old != "" {
if was, _, err = manifestCached(ctx, s.Owner, s.Repo, p, old); err != nil {
return nil, err
}
}
c := out[now.Module]
c.Manifest = true
for _, v := range added(verbsServed(now), verbsServed(was)) {
c.Serves = appendNew(c.Serves, v)
}
for _, v := range added(verbsInvoked(now), verbsInvoked(was)) {
c.Invokes = appendNew(c.Invokes, v)
}
out[now.Module] = c
}
}
return out, nil
}
// changeSourcesOf are a group's members as changes to read: each head over what its pull request merges into.
func changeSourcesOf(members []orderMember) []changeSource {
var out []changeSource
for _, m := range members {
p := m.pull()
old := p.BaseSHA
if old == "" {
old = p.Base
}
out = append(out, changeSource{Owner: p.Owner, Repo: p.Repo, New: p.Commit, Old: old, Paths: p.Paths,
Truncated: p.PathsTruncated, Removed: p.Removed})
}
return out
}
+170
View File
@@ -0,0 +1,170 @@
package main
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/inventory"
)
// The order of a plan's modules is read from the module graph and from what the change says of the manifests
// (novox/hq ADR 0419), per module and never per repository: no `after:` line is read, and no member of a group
// is ordered against another. Pure, so a test needs no store.
// The rules that draw an edge of a plan's order, in the words the plan shows.
const (
ruleVerbBeforeCaller = "verb before caller"
ruleVersionSkew = "version skew"
ruleEngineFirst = "engine before controller"
)
// The two modules the rollout rules name.
const (
theController = "mesh-controller"
theEngine = "mesh-host"
)
// moduleChange is what a change says of one module's manifest: the seat verbs ("<seat>.<verb>") it adds to what the
// module serves, the seat verbs it adds to what the module invokes, and whether the manifest changes at all.
type moduleChange struct {
Serves []string
Invokes []string
Manifest bool
}
// planOrder is the edges the rules draw among a plan's modules, and the cycle they make when they make one.
type planOrder struct {
Edges []inventory.Edge
// Cycle is every edge of a cycle among the order's edges and the graph's, the module graph's included.
Cycle []inventory.Edge
}
// CycleSaid names every module and edge of the cycle, for a check to fail with (ADR 0419 clause 6).
func (o planOrder) CycleSaid() string {
if len(o.Cycle) == 0 {
return ""
}
var parts []string
for _, e := range o.Cycle {
parts = append(parts, edgeSaid(e))
}
return "the modules of the plan are ordered in a cycle: " + strings.Join(parts, "; ")
}
// edgeSaid is an edge as a person reads it: the module after, the module before, and why.
func edgeSaid(e inventory.Edge) string {
why := e.Why
if why == "" {
why = e.Kind
}
return fmt.Sprintf("%s after %s (%s)", e.From, e.To, why)
}
// orders is whether an edge of the module graph orders a plan's tiers (tiersOf).
func orders(e inventory.Edge) bool {
return e.Kind != inventory.EdgePackages && e.Kind != inventory.EdgeBuiltBy && e.From != e.To
}
// orderEdges draws the edges of a plan's order among its moved modules (ADR 0419 clause 3, 4): a module whose change
// adds a verb to a seat goes before one whose change adds a call of that verb; the controller, when the plan moves
// it, goes before every module whose manifest the plan changes, the node-engine excepted; and the node-engine goes
// before the controller by default, when the plan moves both and no edge orders them either way, transitively. An
// edge is "From after To". graph is the module graph, whose edges order too and are never drawn again.
func orderEdges(moved []string, changes map[string]moduleChange, graph []inventory.Edge) planOrder {
var out planOrder
sorted := append([]string(nil), moved...)
sort.Strings(sorted)
for _, server := range sorted {
for _, caller := range sorted {
if server == caller {
continue
}
for _, verb := range changes[server].Serves {
if slices.Contains(changes[caller].Invokes, verb) {
out.Edges = append(out.Edges, inventory.Edge{From: caller, To: server, Kind: edgeChangeOrder,
Why: ruleVerbBeforeCaller + ": " + verb})
}
}
}
}
if slices.Contains(sorted, theController) {
for _, m := range sorted {
if m != theController && m != theEngine && changes[m].Manifest {
out.Edges = append(out.Edges, inventory.Edge{From: m, To: theController, Kind: edgeChangeOrder,
Why: ruleVersionSkew})
}
}
}
if slices.Contains(sorted, theController) && slices.Contains(sorted, theEngine) {
all := orderingEdges(graph, out.Edges)
switch {
case pathBetween(all, theController, theEngine) != nil:
// An edge already puts the controller after the engine: the default says the same.
case pathBetween(all, theEngine, theController) != nil:
// An edge puts the engine after the controller: it overrules the default, and says so.
path := pathBetween(all, theEngine, theController)
for i := range out.Edges {
if out.Edges[i] == path[0] {
out.Edges[i].Why += " (overrules " + ruleEngineFirst + ")"
break
}
}
default:
out.Edges = append(out.Edges, inventory.Edge{From: theController, To: theEngine, Kind: edgeChangeOrder,
Why: ruleEngineFirst})
}
}
all := orderingEdges(graph, out.Edges)
for _, e := range out.Edges {
if back := pathBetween(all, e.To, e.From); back != nil {
out.Cycle = append(out.Cycle, e)
out.Cycle = append(out.Cycle, back...)
break
}
}
return out
}
// orderingEdges is the graph's edges that order and the edges drawn, together.
func orderingEdges(graph, drawn []inventory.Edge) []inventory.Edge {
var out []inventory.Edge
for _, e := range graph {
if orders(e) {
out = append(out, e)
}
}
return append(out, drawn...)
}
// pathBetween is the edges that lead from a module to another along "depends on" (From to To), the shortest, or nil
// when the first does not depend on the second.
func pathBetween(edges []inventory.Edge, from, to string) []inventory.Edge {
if from == to {
return nil
}
via := map[string]inventory.Edge{}
seen := map[string]bool{from: true}
queue := []string{from}
for len(queue) > 0 {
at := queue[0]
queue = queue[1:]
for _, e := range edges {
if e.From != at || seen[e.To] {
continue
}
seen[e.To] = true
via[e.To] = e
if e.To == to {
var path []inventory.Edge
for m := to; m != from; m = via[m].From {
path = append([]inventory.Edge{via[m]}, path...)
}
return path
}
queue = append(queue, e.To)
}
}
return nil
}
+115
View File
@@ -0,0 +1,115 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/inventory"
)
// The order of a plan's modules is read from the module graph and the manifests' change alone (novox/hq ADR 0419):
// no `after:` line, no member of a group. Pure, like orderOf was.
func hasEdge(edges []inventory.Edge, from, to string) (inventory.Edge, bool) {
for _, e := range edges {
if e.From == from && e.To == to {
return e, true
}
}
return inventory.Edge{}, false
}
// A module whose change adds a verb to a seat goes before the module whose change adds a call of it: the caller
// depends on the server, the plan puts them in two tiers, and the edge says why.
func TestAVerbIsServedBeforeItsCallerIsSent(t *testing.T) {
changes := map[string]moduleChange{
"server": {Serves: []string{"mesh-delivery.groups"}, Manifest: true},
"caller": {Invokes: []string{"mesh-delivery.groups"}, Manifest: true},
}
got := orderEdges([]string{"caller", "server"}, changes, nil)
e, ok := hasEdge(got.Edges, "caller", "server")
if !ok || !strings.Contains(e.Why, "verb before caller") || !strings.Contains(e.Why, "mesh-delivery.groups") {
t.Fatalf("the caller does not follow the server by the verb: %+v", got.Edges)
}
if len(got.Cycle) != 0 {
t.Fatalf("a cycle: %+v", got.Cycle)
}
if tiers := tiersOf([]string{"caller", "server"}, got.Edges); len(tiers) != 2 || tiers[0][0] != "server" {
t.Fatalf("tiers %v, want the server first", tiers)
}
}
// Edges of one rule both ways are a cycle: every module and edge is named.
func TestEdgesBothWaysAreACycleNamingEveryModuleAndEdge(t *testing.T) {
changes := map[string]moduleChange{
"x": {Serves: []string{"s.a"}, Invokes: []string{"s.b"}},
"y": {Serves: []string{"s.b"}, Invokes: []string{"s.a"}},
}
got := orderEdges([]string{"x", "y"}, changes, nil)
if len(got.Cycle) < 2 {
t.Fatalf("no cycle found: %+v", got)
}
said := got.CycleSaid()
for _, want := range []string{"x", "y", "s.a", "s.b"} {
if !strings.Contains(said, want) {
t.Fatalf("the cycle is said as %q, without %s", said, want)
}
}
}
// The node-engine goes before the controller by default, and says so; an edge of the manifests the other way
// overrules it, and the edge that did is said.
func TestTheEngineGoesBeforeTheControllerUnlessAnEdgeSaysOtherwise(t *testing.T) {
got := orderEdges([]string{"mesh-controller", "mesh-host"}, nil, nil)
e, ok := hasEdge(got.Edges, "mesh-controller", "mesh-host")
if !ok || e.Why != "engine before controller" {
t.Fatalf("the engine does not go first: %+v", got.Edges)
}
changes := map[string]moduleChange{
"mesh-host": {Serves: []string{"s.v"}},
"mesh-controller": {Invokes: []string{"s.v"}},
}
got = orderEdges([]string{"mesh-controller", "mesh-host"}, changes, nil)
if _, ok := hasEdge(got.Edges, "mesh-controller", "mesh-host"); !ok || len(got.Edges) != 1 ||
!strings.Contains(got.Edges[0].Why, "verb before caller") {
t.Fatalf("the verb edge does not place the controller after the engine: %+v", got.Edges)
}
// The other way: the engine calls what the controller brings.
changes = map[string]moduleChange{
"mesh-controller": {Serves: []string{"s.v"}},
"mesh-host": {Invokes: []string{"s.v"}},
}
got = orderEdges([]string{"mesh-controller", "mesh-host"}, changes, nil)
e, ok = hasEdge(got.Edges, "mesh-host", "mesh-controller")
if !ok || len(got.Edges) != 1 || !strings.Contains(e.Why, "overrules engine before controller") {
t.Fatalf("the verb edge does not say it overruled the default: %+v", got.Edges)
}
}
// The controller goes before every module whose manifest the plan changes (version skew), the node-engine excepted.
func TestTheControllerGoesBeforeEveryChangedManifest(t *testing.T) {
changes := map[string]moduleChange{"app": {Manifest: true}, "other": {}}
got := orderEdges([]string{"mesh-controller", "app", "other"}, changes, nil)
if e, ok := hasEdge(got.Edges, "app", "mesh-controller"); !ok || e.Why != "version skew" {
t.Fatalf("the changed manifest does not follow the controller: %+v", got.Edges)
}
if _, ok := hasEdge(got.Edges, "other", "mesh-controller"); ok {
t.Fatalf("a module with an unchanged manifest was ordered: %+v", got.Edges)
}
if got := orderEdges([]string{"app", "other"}, changes, nil); len(got.Edges) != 0 {
t.Fatalf("version skew ordered a plan that does not move the controller: %+v", got.Edges)
}
}
// The order is per module, not per repository: of two modules of one repository only the one with the edge moves.
func TestTheOrderIsPerModuleNotPerRepository(t *testing.T) {
changes := map[string]moduleChange{
"a": {Serves: []string{"s.v"}},
"c": {Invokes: []string{"s.v"}},
}
got := orderEdges([]string{"a", "b", "c"}, changes, nil)
tiers := tiersOf([]string{"a", "b", "c"}, got.Edges)
if len(tiers) != 2 || strings.Join(tiers[0], ",") != "a,b" || strings.Join(tiers[1], ",") != "c" {
t.Fatalf("tiers %v, want [a b] then [c]", tiers)
}
}
+40 -90
View File
@@ -3,7 +3,6 @@ package main
import ( import (
"encoding/json" "encoding/json"
"reflect" "reflect"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -17,34 +16,36 @@ import (
"github.com/novox/mesh-controller/internal/testbus" "github.com/novox/mesh-controller/internal/testbus"
) )
// novox/hq ADR 0239 decision 4: a group's order, from the graph and the pull requests, the same on every // novox/hq ADR 0419: a group's order, from the module graph and the manifests' change alone, the same on every
// reading — the build agent before what it builds, the controller before a manifest that needs it, the // reading — what a module stands on before it, the controller before a manifest that needs it, the node-engine
// node-engine before the controller, a declared `after:` — and a contradiction named, never ordered. // before the controller, a verb's server before its caller — and no `after:` line read.
func TestAGroupIsOrderedByTheGraphAndWhatItsPullRequestsSay(t *testing.T) { func TestAGroupIsOrderedByTheGraphAndTheManifestsChange(t *testing.T) {
members := []orderMember{ members := []orderMember{
{ID: "cat", Repository: "novox/mesh-catalog"}, {ID: "cat", Repository: "novox/mesh-catalog"},
{ID: "ctl", Repository: "novox/mesh-controller"}, {ID: "ctl", Repository: "novox/mesh-controller"},
{ID: "host", Repository: "novox/mesh-host"}, {ID: "host", Repository: "novox/mesh-host"},
{ID: "agent", Repository: "novox/build-agent"}, {ID: "base", Repository: "novox/base"},
{ID: "app", Repository: "novox/app", After: []string{"lab"}}, {ID: "app", Repository: "novox/app"},
{ID: "lab", Repository: "novox/lab"}, {ID: "lab", Repository: "novox/lab"},
} }
reach := map[string]orderReach{ reach := map[string]orderReach{
"cat": {Moved: []string{"gitea"}, Manifests: []string{"modules/gitea/module.json"}}, "cat": {Moved: []string{"gitea"}, Manifests: []string{"modules/gitea/module.json"}},
"ctl": {Moved: []string{"mesh-controller"}, Manifests: []string{"module.json"}}, "ctl": {Moved: []string{"mesh-controller"}, Manifests: []string{"module.json"}},
"host": {Moved: []string{"mesh-host"}, Manifests: []string{"module.json"}}, "host": {Moved: []string{"mesh-host"}, Manifests: []string{"module.json"}},
"agent": {Moved: []string{"build-agent"}}, "base": {Moved: []string{"base"}},
"app": {Moved: []string{"app"}}, "app": {Moved: []string{"app"}},
"lab": {Moved: []string{"lab"}}, "lab": {Moved: []string{"lab"}},
} }
edges := []inventory.Edge{{From: "app", To: "build-agent", Kind: inventory.EdgeBuiltBy}, edges := []inventory.Edge{{From: "app", To: "base", Kind: inventory.EdgeStandsOn},
{From: "gitea", To: "postgres", Kind: inventory.EdgeDeclared}} {From: "gitea", To: "postgres", Kind: inventory.EdgeDeclared}}
got := orderOf(members, reach, edges) changes := map[string]moduleChange{"gitea": {Manifest: true}, "mesh-controller": {Manifest: true},
"mesh-host": {Manifest: true}, "lab": {Serves: []string{"seat.v"}}, "app": {Invokes: []string{"seat.v"}}}
got := orderOf(members, reach, edges, changes)
if len(got.Cycle) > 0 { if len(got.Cycle) > 0 {
t.Fatalf("a cycle where there is none: %v", got.Cycle) t.Fatalf("a cycle where there is none: %v", got.Cycle)
} }
// By repository among those with nothing before them: the build agent, the lab, then what waited on both. // By repository among those with nothing before them: app waits for base and lab; gitea's catalogue for the controller.
want := []string{"agent", "lab", "app", "host", "ctl", "cat"} want := []string{"base", "lab", "app", "host", "ctl", "cat"}
if !reflect.DeepEqual(got.Order, want) { if !reflect.DeepEqual(got.Order, want) {
t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs) t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs)
} }
@@ -52,95 +53,44 @@ func TestAGroupIsOrderedByTheGraphAndWhatItsPullRequestsSay(t *testing.T) {
for _, p := range got.Pairs { for _, p := range got.Pairs {
why[p.Before+">"+p.After] = p.Why why[p.Before+">"+p.After] = p.Why
} }
for pair, reason := range map[string]string{"host>ctl": orderEngineFirst, "ctl>cat": orderVersionSkew, for pair, reason := range map[string]string{"host>ctl": ruleEngineFirst, "ctl>cat": ruleVersionSkew,
"ctl>host": "", "agent>app": orderBuiltBy, "lab>app": orderDeclared} { "ctl>host": "", "base>app": orderBuiltBy, "lab>app": ruleVerbBeforeCaller + ": seat.v"} {
if why[pair] != reason { if why[pair] != reason {
t.Errorf("%s is ordered %q, wanted %q", pair, why[pair], reason) t.Errorf("%s is ordered %q, wanted %q", pair, why[pair], reason)
} }
} }
// The same members in another order read the same. // The same members in another order read the same.
shuffled := []orderMember{members[5], members[3], members[0], members[4], members[2], members[1]} shuffled := []orderMember{members[5], members[3], members[0], members[4], members[2], members[1]}
if again := orderOf(shuffled, reach, edges); !reflect.DeepEqual(again.Order, want) { if again := orderOf(shuffled, reach, edges, changes); !reflect.DeepEqual(again.Order, want) {
t.Fatalf("another reading ordered %v", again.Order) t.Fatalf("another reading ordered %v", again.Order)
} }
// A declared order against an inferred one wins (novox/hq ADR 0249), and the pair says what it won over.
members[1].After = []string{"mesh-catalog"}
got = orderOf(members, reach, edges)
if len(got.Cycle) > 0 || len(got.Contradictions) > 0 {
t.Fatalf("a declared order did not win: cycle %v, %+v", got.Cycle, got.Contradictions)
}
if want := []string{"agent", "lab", "app", "cat", "host", "ctl"}; !reflect.DeepEqual(got.Order, want) {
t.Fatalf("ordered %v, wanted %v", got.Order, want)
}
if !slices.ContainsFunc(got.Pairs, func(p orderPair) bool {
return p.Before == "cat" && p.After == "ctl" && p.Why == orderDeclared && reflect.DeepEqual(p.Over, []string{orderVersionSkew})
}) || slices.ContainsFunc(got.Pairs, func(p orderPair) bool { return p.Before == "ctl" && p.After == "cat" }) {
t.Fatalf("the declared pair does not say it won over version skew: %+v", got.Pairs)
}
// Declared both ways is a contradiction no precedence resolves: a cycle, and how to end it.
members[0].After = []string{"novox/mesh-controller"}
got = orderOf(members, reach, edges)
if !reflect.DeepEqual(got.Cycle, []string{"cat", "ctl"}) || len(got.Contradictions) != 1 {
t.Fatalf("the cycle is %v, ordered %v, contradictions %+v", got.Cycle, got.Order, got.Contradictions)
}
if c := got.Contradictions[0]; !reflect.DeepEqual(c.Members, []string{"cat", "ctl"}) ||
!strings.Contains(c.Said, "remove one of the `after:` lines") {
t.Fatalf("the contradiction is said %+v", c)
}
} }
// novox/hq ADR 0249: when two rules order one pair both ways, the rule of higher precedence wins and the // A cycle is the one refusal, and it names every module and edge. A description's `after:` line changes nothing.
// pair says what it overruled; rules of one rank both ways are refused, naming both and how to declare. func TestAGroupInACycleIsNamedAndNothingElseIsRefused(t *testing.T) {
func TestAnOrderRuleYieldsToAStrongerOne(t *testing.T) {
members := []orderMember{ members := []orderMember{
{ID: "ctl", Repository: "novox/mesh-controller", Number: 7}, {ID: "x", Repository: "novox/x", Number: 7},
{ID: "host", Repository: "novox/mesh-host", Number: 9}, {ID: "y", Repository: "novox/y", Number: 9},
} }
reach := map[string]orderReach{ reach := map[string]orderReach{"x": {Moved: []string{"mx"}}, "y": {Moved: []string{"my"}}}
"ctl": {Moved: []string{"mesh-controller", "build-agent"}}, changes := map[string]moduleChange{
"host": {Moved: []string{"mesh-host"}}, "mx": {Serves: []string{"s.a"}, Invokes: []string{"s.b"}},
"my": {Serves: []string{"s.b"}, Invokes: []string{"s.a"}},
} }
builtBy := []inventory.Edge{{From: "mesh-host", To: "build-agent", Kind: inventory.EdgeBuiltBy}} got := orderOf(members, reach, nil, changes)
if !reflect.DeepEqual(got.Cycle, []string{"x", "y"}) || len(got.Order) > 0 {
// Built by outranks engine before controller. t.Fatalf("a cycle was ordered: %v, cycle %v", got.Order, got.Cycle)
got := orderOf(members, reach, builtBy)
if !reflect.DeepEqual(got.Order, []string{"ctl", "host"}) || len(got.Cycle) > 0 {
t.Fatalf("ordered %v, cycle %v", got.Order, got.Cycle)
} }
if want := []orderPair{{Before: "ctl", After: "host", Why: orderBuiltBy, Over: []string{orderEngineFirst}}}; !reflect.DeepEqual(got.Pairs, want) { for _, w := range []string{"mx", "my", "s.a", "s.b"} {
t.Fatalf("pairs %+v, wanted %+v", got.Pairs, want) if !strings.Contains(got.CycleSaid, w) {
} t.Errorf("the cycle does not say %q: %s", w, got.CycleSaid)
// A declared line outranks both.
members[0].After = []string{"mesh-host"}
got = orderOf(members, reach, builtBy)
if !reflect.DeepEqual(got.Order, []string{"host", "ctl"}) || len(got.Cycle) > 0 ||
!reflect.DeepEqual(got.Pairs[0].Over, []string{orderBuiltBy}) || got.Pairs[0].Why != orderDeclared {
t.Fatalf("a declared order did not win: %v %+v", got.Order, got.Pairs)
}
// Without the build agent, engine before controller stands alone.
members[0].After = nil
got = orderOf(members, map[string]orderReach{"ctl": {Moved: []string{"mesh-controller"}}, "host": reach["host"]}, builtBy)
if !reflect.DeepEqual(got.Order, []string{"host", "ctl"}) || got.Pairs[0].Why != orderEngineFirst || len(got.Pairs[0].Over) > 0 {
t.Fatalf("engine before controller alone: %v %+v", got.Order, got.Pairs)
}
// Built by both ways is one rank against itself: refused, both rules named, and how to declare.
both := append(builtBy, inventory.Edge{From: "build-agent", To: "mesh-host", Kind: inventory.EdgeStandsOn})
got = orderOf(members, reach, both)
if !reflect.DeepEqual(got.Cycle, []string{"ctl", "host"}) || len(got.Order) > 0 || len(got.Contradictions) != 1 {
t.Fatalf("a contradiction was ordered: %v, cycle %v, %+v", got.Order, got.Cycle, got.Contradictions)
}
said := got.Contradictions[0].Said
for _, w := range []string{"built by puts ctl first", "built by puts host first",
"`after: novox/mesh-controller` in novox/mesh-host#9's description", "`after: novox/mesh-host` in novox/mesh-controller#7's"} {
if !strings.Contains(said, w) {
t.Errorf("the contradiction does not say %q: %s", w, said)
} }
} }
// Without the edges the same members are ordered by repository.
got = orderOf(members, reach, nil, nil)
if !reflect.DeepEqual(got.Order, []string{"x", "y"}) || len(got.Cycle) > 0 || len(got.Pairs) > 0 {
t.Fatalf("members with no edge: %v %v %+v", got.Order, got.Cycle, got.Pairs)
}
} }
// novox/hq ADR 0239 decision 8: a walk waits for the delivery's owner only while the seat has a holder on // novox/hq ADR 0239 decision 8: a walk waits for the delivery's owner only while the seat has a holder on
+2 -2
View File
@@ -114,7 +114,7 @@ func reachableFrom(moved []string, edges []inventory.Edge) []string {
// packages edge, read from a record made before novox/hq ADR 0267, widens nothing either: // packages edge, read from a record made before novox/hq ADR 0267, widens nothing either:
// a shared file moves each module whose build source holds it, directly. // a shared file moves each module whose build source holds it, directly.
if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || e.Kind == inventory.EdgePackages || if e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || e.Kind == inventory.EdgePackages ||
e.Kind == edgeGroupOrder { e.Kind == edgeChangeOrder {
continue continue
} }
if in[e.To] && !in[e.From] { if in[e.To] && !in[e.From] {
@@ -204,7 +204,7 @@ func gates(p inventory.Plan, edges []inventory.Edge, rollsOut func(string) bool)
// A delivery group's order inside a walk (novox/hq ADR 0276 decision 5) gates as built-by does: the // A delivery group's order inside a walk (novox/hq ADR 0276 decision 5) gates as built-by does: the
// member before is running on its machines before the member after is asked. // member before is running on its machines before the member after is asked.
// A built-by edge waits for nothing since novox/hq ADR 0360 §3. // A built-by edge waits for nothing since novox/hq ADR 0360 §3.
ordered := e.Kind == edgeGroupOrder ordered := e.Kind == edgeChangeOrder
if later[e.From] && inTier[e.To] && ordered && !seen[e.To] && rollsOut(e.To) { if later[e.From] && inTier[e.To] && ordered && !seen[e.To] && rollsOut(e.To) {
seen[e.To] = true seen[e.To] = true
out = append(out, e.To) out = append(out, e.To)
+5 -9
View File
@@ -7,8 +7,7 @@ import (
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
) )
// novox/hq issue 309, replayed with only what orderOf had before its fix, so it can be laid over the older // novox/hq issue 309, replayed. The group `feat/a-machine-joins-through-the-tunnel` (2026-10-08): the controller's pull request
// commit. The group `feat/a-machine-joins-through-the-tunnel` (2026-10-08): the controller's pull request
// moved the controller and the build agent, the node-engine's moved the node-engine, the lab's moved // moved the controller and the build agent, the node-engine's moved the node-engine, the lab's moved
// nothing. The build agent builds the node-engine (built by: the controller's member first), and the // nothing. The build agent builds the node-engine (built by: the controller's member first), and the
// node-engine goes before the controller (engine before controller: the node-engine's member first). Both // node-engine goes before the controller (engine before controller: the node-engine's member first). Both
@@ -34,17 +33,14 @@ func TestReplay309(t *testing.T) {
{From: "mesh-host", To: "build-agent", Kind: inventory.EdgeBuiltBy}, {From: "mesh-host", To: "build-agent", Kind: inventory.EdgeBuiltBy},
{From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy}, {From: "route-proxy", To: "build-agent", Kind: inventory.EdgeBuiltBy},
} }
got := orderOf(members, reach, edges) got := orderOf(members, reach, edges, map[string]moduleChange{"mesh-controller": {Manifest: true}})
if len(got.Cycle) > 0 { if len(got.Cycle) > 0 {
t.Fatalf("the group is refused as a cycle %v; pairs %+v", got.Cycle, got.Pairs) t.Fatalf("the group is refused as a cycle %v; pairs %+v", got.Cycle, got.Pairs)
} }
want := []string{"novox/mesh-controller@8bbfdb53db2b", "novox/mesh-host@86cbebd10f8a", "novox/mesh-lab@e80a4b1642bf"} // Being built by the build seat's holder orders nothing (ADR 0360 section 3, ADR 0419): the node-engine goes
// first by the rollout default, and no pair puts the controller's member before it.
want := []string{"novox/mesh-host@86cbebd10f8a", "novox/mesh-controller@8bbfdb53db2b", "novox/mesh-lab@e80a4b1642bf"}
if !reflect.DeepEqual(got.Order, want) { if !reflect.DeepEqual(got.Order, want) {
t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs) t.Fatalf("ordered %v, wanted %v; pairs %+v", got.Order, want, got.Pairs)
} }
for _, p := range got.Pairs {
if p.Before == want[1] && p.After == want[0] {
t.Fatalf("a pair that lost still stands: %+v", p)
}
}
} }
+1 -1
View File
@@ -157,7 +157,7 @@ func withDependentsOf(same map[string]string, plan *inventory.Plan, moved []stri
var bases []string var bases []string
for _, e := range edges { for _, e := range edges {
ordering := e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf || ordering := e.Kind == inventory.EdgeBuiltBy || e.Kind == inventory.EdgeWorkerOf ||
e.Kind == inventory.EdgePackages || e.Kind == edgeGroupOrder e.Kind == inventory.EdgePackages || e.Kind == edgeChangeOrder
if !ordering && e.From == n && same[e.To] != "" && !slices.Contains(bases, e.To) { if !ordering && e.From == n && same[e.To] != "" && !slices.Contains(bases, e.To) {
bases = append(bases, e.To) bases = append(bases, e.To)
} }
+5 -5
View File
@@ -149,12 +149,12 @@ var ControllerVerbs = []Verb{
"paths": "the files it changes, comma-separated, from the repository's root", "paths": "the files it changes, comma-separated, from the repository's root",
"module-dirs": "the directories holding a module.json at the head, comma-separated, as the forge says them", "module-dirs": "the directories holding a module.json at the head, comma-separated, as the forge says them",
"removed": "the files among paths it deletes, comma-separated"}, []string{"repository", "paths"})}, "removed": "the files among paths it deletes, comma-separated"}, []string{"repository", "paths"})},
{Name: "delivery-order", Description: "A delivery group's order (novox/hq ADR 0239): its members in the order " + {Name: "delivery-order", Description: "A delivery group's order (novox/hq ADR 0419): its members in the order " +
"they are delivered, every pair and why — declared, built by, version skew, engine before controller — and " + "they are delivered, every pair and why — built by, verb before caller, version skew, engine before " +
"the rules each pair won over by precedence (ADR 0249); the cycle, and each contradiction no precedence " + "controller — read from the module graph and the manifests' change alone; no `after:` line is read. The " +
"resolves with how to declare the order, when the pairs contradict each other.", "cycle, with every module and edge of it, is the one refusal.",
Input: schema(map[string]string{"members": "the members, as JSON: [{id, repository, base, head, number, paths, " + Input: schema(map[string]string{"members": "the members, as JSON: [{id, repository, base, head, number, paths, " +
"module_dirs, module_dirs_said, removed, after}]"}, []string{"members"})}, "module_dirs, module_dirs_said, removed}]"}, []string{"members"})},
{Name: "delivery-check", Description: "Ask the build seat for a delivery group's composed check (novox/hq ADR " + {Name: "delivery-check", Description: "Ask the build seat for a delivery group's composed check (novox/hq ADR " +
"0239): every member's head laid over the mesh in turn and judged as one future state. Answers the ask's id; " + "0239): every member's head laid over the mesh in turn and judged as one future state. Answers the ask's id; " +
"the verdict is said as `checked` with the group's id. With one member and no group, that head is checked " + "the verdict is said as `checked` with the group's id. With one member and no group, that head is checked " +
+3
View File
@@ -38,6 +38,9 @@ type Edge struct {
From string `json:"from"` From string `json:"from"`
To string `json:"to"` To string `json:"to"`
Kind string `json:"kind"` Kind string `json:"kind"`
// Why is the rule and the fact that drew an edge of a plan's order, in the words the plan shows (novox/hq ADR
// 0419 clause 5): "verb before caller: <seat>.<verb>". Empty on the catalogue's own edges, which Kind names.
Why string `json:"why,omitempty"`
} }
// Dependencies is the catalogue's dependency relation, whole: every module the mesh holds, with // Dependencies is the catalogue's dependency relation, whole: every module the mesh holds, with
+6 -6
View File
@@ -42,12 +42,12 @@ func TestDependenciesAreOneRelationWithTheirKinds(t *testing.T) {
return false return false
} }
for _, want := range []Edge{ for _, want := range []Edge{
{"shop", "mesh-tools", EdgeStandsOn}, {From: "shop", To: "mesh-tools", Kind: EdgeStandsOn},
{"builder", "mesh-tools", EdgeStandsOn}, {From: "builder", To: "mesh-tools", Kind: EdgeStandsOn},
{"shop-plugin", "shop", EdgeDeclared}, {From: "shop-plugin", To: "shop", Kind: EdgeDeclared},
{"shop", "builder", EdgeBuiltBy}, {From: "shop", To: "builder", Kind: EdgeBuiltBy},
{"mesh-controller", "builder", EdgeBuiltBy}, {From: "mesh-controller", To: "builder", Kind: EdgeBuiltBy},
{"mesh-tools", "builder", EdgeBuiltBy}, {From: "mesh-tools", To: "builder", Kind: EdgeBuiltBy},
} { } {
if !has(want.From, want.To, want.Kind) { if !has(want.From, want.To, want.Kind) {
t.Errorf("missing %+v in %+v", want, got) t.Errorf("missing %+v in %+v", want, got)
+3
View File
@@ -292,6 +292,9 @@ type PlanModule struct {
FirstAt *time.Time `json:"first_at,omitempty"` FirstAt *time.Time `json:"first_at,omitempty"`
Commit string `json:"commit,omitempty"` Commit string `json:"commit,omitempty"`
Why string `json:"why,omitempty"` Why string `json:"why,omitempty"`
// Placed is the edge that put the module after another, and the rule that drew it (novox/hq ADR 0419 clause 5):
// "after <module> (verb before caller: <seat>.<verb>)". Empty for a module placed first by nothing.
Placed string `json:"placed,omitempty"`
// Made is what the walk's build of it did (novox/hq ADR 0350 §4): "reused from <check build>", naming the pull // Made is what the walk's build of it did (novox/hq ADR 0350 §4): "reused from <check build>", naming the pull
// request's head, the trunk commit and the digests, or "built: <why>". Said in `plan-moved` and kept here. // request's head, the trunk commit and the digests, or "built: <why>". Said in `plan-moved` and kept here.
Made string `json:"made,omitempty"` Made string `json:"made,omitempty"`
+2 -3
View File
@@ -224,9 +224,8 @@ type SourceMoved struct {
// stands: a directory is a module only when the merge changed its manifest. // stands: a directory is a module only when the merge changed its manifest.
ModuleDirsSaid bool `json:"module_dirs_said,omitempty"` ModuleDirsSaid bool `json:"module_dirs_said,omitempty"`
// Number is the pull request's, Title its title and Body its description (novox/hq ADR 0276): a delivery // Number is the pull request's, Title its title and Body its description (novox/hq ADR 0276): the
// group's `after:` lines, read when its members are merged into one batch and their order becomes the // pull request's number and words, which `plans` names a walk by (no `after:` line is read, ADR 0419).
// walk's, and the words `plans` names a walk by.
Number int `json:"number,omitempty"` Number int `json:"number,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"` Body string `json:"body,omitempty"`