package main import ( "context" "errors" "flag" "fmt" "regexp" "slices" "sort" "strings" "sync" "time" "github.com/novox/mesh-controller/internal/builder" "github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // following acts on what the catalogue announces. // // **It holds the stores, not a copy of the decision.** What to do about an upgrade is read when // one arrives, so changing it takes effect on the next upgrade rather than on the next restart of // the control plane. type following struct{ open *stores } // Upgraded sends the machines running a module the version the catalogue now considers current — // or records that they are behind, which is the default and needs no record. // // **Recording is not a second code path.** A machine that is not running what the mesh would send // it is already something the mesh notices and reports; that is what `status` and `push --behind` // are built on. So "record it" is the absence of an action, and the only thing this has to decide // is whether to act. func (f following) Upgraded(ctx context.Context, u link.Upgraded) error { inv := f.open.inventory // The store read first, and an outage there said as one, so the announcement is held and asked // again (novox/hq issue 083). Only here: a push that fails further down is not asked again. decision, err := inv.UpgradeOf(ctx, u.Module) if err != nil { return notNow(err) } on, err := inv.Running(ctx, u.Module) if err != nil { return notNow(err) } if len(on) == 0 { fmt.Printf("%s moved to %s; no machine runs it\n", u.Module, shortCommit(u.Commit)) return nil } if !decision.RollOut { // Named rather than counted, and said even though nothing happens: an upgrade that was // deliberately not rolled out and an upgrade that was never noticed look identical in a // log that only speaks when it acts. fmt.Printf("%s moved to %s; %s %s behind it, and this mesh records upgrades rather than "+ "rolling them out — `push --behind` when you want them\n", u.Module, shortCommit(u.Commit), readableList(on), isAre(len(on))) return nil } // **A plan that holds the module rolls it out, and this does not** (novox/hq issue 249, ADR // 0218). A merge's plan builds the module and sends it one machine first, the rest once that one // has applied it; this announcement arrives as the build registers, and sending here too — one // machine after another without waiting for any to apply — put the new bundle on every machine in // the same minute, whatever the plan was waiting for. A move no plan answers (a build asked by // hand) is still this handler's. if plans, err := inv.OpenPlans(ctx); err != nil { return notNow(err) } else if id := rolledOutByAPlan(plans, u.Module); id != "" { // Said with its remedy: a plan that ends without sending it — failed, or closed by hand — leaves // these machines behind, which `status` lists and `push --behind` sends (novox/hq issue 249). fmt.Printf("%s moved to %s; %s rolls it out to %s — if that plan ends without sending it, "+ "`status` lists them as behind and `push --behind` sends it\n", u.Module, shortCommit(u.Commit), id, readableList(on)) return nil } // **No plan holds it: a release plan sends it** (novox/hq ADR 0236). A build asked outside a plan — a // `rebuild`, a `replay` registered — was sent here one machine after another without any judging; it // now waits for a gate like any other build, and the release plan walks it through the machines, one // at a time, each judged. fmt.Printf("%s moved to %s outside a plan; a release plan sends it to %s, one machine at a time, each judged "+ "(`upgrade backlog` lists what waits)\n", u.Module, shortCommit(u.Commit), readableList(on)) return nil } // askAgainOnGrants marks a send that stopped at its grants as one to ask again (novox/hq issue 249): // an announcement handled by a send whose memberships could not be issued is held and redelivered, // rather than taken as handled with the machines left on the old version. func askAgainOnGrants(err error) error { if err != nil && errors.Is(err, errGrants) && !errors.Is(err, link.ErrTryAgain) { return fmt.Errorf("%w: %w", link.ErrTryAgain, err) } return err } // rolledOutByAPlan is the open plan that will send a module's machines its new build — one holding // the module that has not finished sending it — or empty when none will (novox/hq issue 249). func rolledOutByAPlan(plans []inventory.Plan, module string) string { for _, p := range plans { if s, holds := p.Modules[module]; p.Open() && holds && (s == nil || (s.SentAt == nil && s.State != "failed")) { return p.ID } } return "" } // readableList names machines the way a sentence does, because this is read by a person deciding // whether an upgrade went where they expected. func readableList(names []string) string { switch len(names) { case 0: return "nothing" case 1: return names[0] case 2: return names[0] + " and " + names[1] } return strings.Join(names[:len(names)-1], ", ") + " and " + names[len(names)-1] } func shortCommit(commit string) string { if len(commit) > 8 { return commit[:8] } return commit } func isAre(n int) string { if n == 1 { return "is" } return "are" } // upgradeCommand says what should happen when a module's current version moves (ADR 0162 §3, ADR // 0235): every module's policy and where it comes from; one module's; or a person's choice for one. func upgradeCommand(ctx context.Context, args []string) error { // What waits for a gate, and releasing it by a person's word (ADR 0236). if len(args) > 0 && (args[0] == "backlog" || args[0] == "release-backlog") { return backlogCommand(ctx, args[0], args[1:]) } set := flag.NewFlagSet("upgrade", flag.ContinueOnError) together := set.Bool("together", false, "send every machine running it at once, instead of one machine first") why := set.String("why", "", "why this choice — required for record; kept and said with the policy") positionals, err := parseAround(set, args) if err != nil { return err } open, err := openStores(ctx) if err != nil { return err } defer open.Close() inv := open.inventory if len(positionals) == 0 { all, err := inv.Upgrades(ctx) if err != nil { return err } names := make([]string, 0, len(all)) for n := range all { names = append(names, n) } sort.Strings(names) for _, n := range names { u := all[n] line := fmt.Sprintf("%-28s %-9s %s", n, u.Policy(), u.From) if u.Why != "" { line += ": " + u.Why } fmt.Println(line) } return nil } module := positionals[0] if len(positionals) == 1 { decision, err := inv.UpgradeOf(ctx, module) if err != nil { return err } fmt.Println(sayUpgrade(module, decision)) return nil } decision := inventory.Upgrade{Why: strings.TrimSpace(*why), By: link.Caller()} switch positionals[1] { case "roll-out", "roll": decision.RollOut, decision.Together = true, *together case "record": if *together { // Refused rather than ignored: --together only means anything for a roll-out, and // accepting it here would store a preference that never applies and looks like it does. return errors.New("`--together` says how to roll out, so it cannot be given with " + "`record`, which is the choice not to") } if decision.Why == "" { return fmt.Errorf("holding %s back from every merge is a choice a person reads later: --why "+ "(novox/hq ADR 0236). Nothing was changed", module) } case "default": if err := inv.ClearUpgradeOf(ctx, module); err != nil { return err } decision, err := inv.UpgradeOf(ctx, module) if err != nil { return err } fmt.Println(sayUpgrade(module, decision)) return nil default: return fmt.Errorf("upgrade roll-out|record|default — not %q", positionals[1]) } if err := inv.SetUpgradeOf(ctx, module, decision); err != nil { return err } decision, err = inv.UpgradeOf(ctx, module) if err != nil { return err } fmt.Println(sayUpgrade(module, decision)) return nil } func sayUpgrade(module string, u inventory.Upgrade) string { from := " (" + u.From if u.By != "" { from += ", " + u.By } if u.Why != "" { from += ": " + u.Why } from += ")" if !u.RollOut { return fmt.Sprintf("when %s moves, the mesh records it and the machines running it are "+ "reported as behind until a person pushes them%s", module, from) } if u.Together { return fmt.Sprintf("when %s moves, every machine running it is sent the new version "+ "together%s", module, from) } return fmt.Sprintf("when %s moves, one machine running it is sent the new version first and judged at "+ "the gate; the rest follow once it passes, and a build that fails is put back there%s", module, from) } // Announceable is every build this mesh recorded, in the shape the builder announces one. // // **The catalogue asks for this when it starts, and the answer is the graph's foundation** // (novox/hq 04-ISSUES/050). A durable queue keeps what arrived after it existed, so a running // catalogue misses nothing — but the modules built before it first ran were announced to a queue // that did not exist, and on a fresh mesh those are always the same three: the shared base, the // store the catalogue runs on, and the catalogue itself. // // **Announced as fetchable, recorded as what it is** (novox/hq 04-ISSUES/102). A build is // recorded by digest and path; the catalogue hears the builder's own announcements, which name // the store's address, so a replay composes the address back in — the store's address as the // network reaches it NOW, which is the whole point of not having recorded the old one. With no // store on the network yet, the recorded form goes as it is. func (f following) Announceable(ctx context.Context) ([]link.Announcement, error) { builds, err := f.open.inventory.Announceable(ctx) if err != nil { return nil, err } address, err := whereTheStoreIs(ctx, f.open.inventory, "") if err != nil { return nil, err } out := make([]link.Announcement, 0, len(builds)) for _, b := range builds { a := link.Announcement{ Module: b.Module, Commit: b.Commit, Repository: b.Repository, Path: b.Path, Ref: b.Ref, Against: b.Against, } if len(b.Manifest) > 0 { a.Manifest = b.Manifest if address != "" { a.Manifest = routedManifest(b.Manifest, b.Made, address) } } for _, made := range routedArtifacts(b.Made, address) { a.Made = append(a.Made, link.MadeArtifact{ Name: made.Name, Kind: made.Kind, Reference: made.Reference, }) } out = append(out, a) } return out, nil } // notNow marks a store that could not be read right now, so the announcement is held rather than // lost; anything else is returned as it was. func notNow(err error) error { if inventory.Unreachable(err) { return fmt.Errorf("%w: %w", link.ErrTryAgain, err) } return err } // SourceMoved is the forge announcing a merge. It is put into the open batch (novox/hq ADR 0276), which // becomes one walk when its merge window closes: hearMerge, and cutBatchesHeld in batches.go. func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error { return f.hearMerge(ctx, m, time.Now().UTC()) } // movesOfMerge is what one merge moves, acted on: every module recorded as built from that repository and // branch is marked as moved to the merge commit; a module the merge deleted is forgotten or said; a new module // is asked for and sent nowhere. The names returned are what the walk builds, bases first along the graph // (novox/hq 04-ISSUES/131). Nothing is built or pushed here: the walk asks its tiers. Called when a batch is // cut, once per repository, with the latest merge of the repository's branch and every file the batch's // merges of it changed. func movesOfMerge(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository) ([]string, error) { from, packaging, already := mergeCandidates(m, entries, read) if len(from) == 0 && len(packaging) == 0 { // "Already built from it" and "nothing reads it" are different facts, and reading the first // as the second sends somebody looking for a broken trigger when the mesh is up to date. if already > 0 { fmt.Printf("%s/%s merged into %s (%.8s); %d module(s) the mesh holds are already built "+ "from it\n", m.Owner, m.Repo, m.Base, m.Commit, already) return nil, nil } fmt.Printf("%s/%s merged into %s (%.8s); nothing the mesh holds reads it\n", m.Owner, m.Repo, m.Base, m.Commit) return nil, nil } // Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows // another branch is not part of this merge, and whoever is waiting for its change should read why. for _, e := range entries { if sameRepository(e.Source.Repository, m) && !sourceIs(e.Source, m) { fmt.Printf(" %s is built from %s/%s and follows %s, not %s; this merge leaves it out\n", e.Manifest.Module, m.Owner, m.Repo, e.Source.Ref, m.Base) } } touched, added, _ := touchedBy(from, entries, m, read) // **A module the merge deleted is not built** (novox/hq ADR 0236): its manifest is gone, so the build // seat finds nothing saying what it is, and the plan failed on it (`has no module.json at …`) with // every other module of its tier left unsent. It is forgotten where nothing holds it, said otherwise. touched, deleted := splitDeleted(touched, m) for _, e := range deleted { retireDeleted(ctx, inv, e, m) } for _, e := range touched { if err := inv.SourceMoved(ctx, e.Manifest.Module, m.Commit); err != nil { return nil, notNow(err) } } // **A new module is built and registered, and sent nowhere** (novox/hq issue 300): the delivery plan // says so, and assigning it is a person's act, which needs it registered first. built := askNewModules(ctx, inv, m, from, added) moved := append(append([]inventory.Entry{}, touched...), packaging...) if len(moved) == 0 { if len(built) > 0 { fmt.Printf("%s/%s merged into %s (%.8s); it changed no module the mesh holds, and adds %s: "+ "built, registered when the build lands, and sent nowhere\n", m.Owner, m.Repo, m.Base, m.Commit, strings.Join(built, ", ")) return nil, nil } fmt.Printf("%s/%s merged into %s (%.8s); it changed nothing any module the mesh holds is "+ "built from\n", m.Owner, m.Repo, m.Base, m.Commit) return nil, nil } // Why each is in it (issue 363): the files of its build source the merge changed, or why it is read whole. for _, e := range moved { fmt.Printf(" %s: %s\n", e.Manifest.Module, whyMoved(e, read[e.Manifest.Module], m)) } for _, e := range packaging { fmt.Printf(" %s reads %s/%s through its build context: its own source record is left where it is\n", e.Manifest.Module, m.Owner, m.Repo) } var names []string for _, e := range moved { names = append(names, e.Manifest.Module) } return names, nil } // askNewModules asks the build seat for every module a merge adds to the repository — a directory holding a // module.json that no module of the mesh is known from (touchedBy's `added`) — at the branch merged into, // not waited for: the build's take-in registers it, as a hand `build` does, and sends it nowhere, since no // machine is assigned it (novox/hq issue 300). Before this a merge said "it changed nothing any module // the mesh holds is built from", the module was never registered, and `assign` refused it as unknown. // // The repository is spelled, and found on the seat, as the modules already built from it are; `from` is // those of them this merge moves, so a merge read as history, or of a branch nothing follows, builds no // new module either. Outside the plan: the plan walks modules the catalogue holds, and a new one has no // machine to send to and nothing standing on it. What could not be asked is said, and left to `build`. // Returns the directories asked for. func askNewModules(ctx context.Context, inv *inventory.Inventory, m link.SourceMoved, from []inventory.Entry, added []string) []string { if len(added) == 0 || len(from) == 0 { return nil } source := buildSource{Repository: from[0].Source.Repository, Seat: from[0].Source.Seat} var asked []string for _, dir := range added { path := dir if path == "." { path = "" } id, err := askABuild(ctx, source, path, m.Base) // Kept, asked or not, so `assign` can tell a build in flight, or a merge that could not ask for one, // from a module nobody ever heard of (novox/hq issue 325). request := inventory.BuildRequest{ID: id, Repository: source.Repository, Seat: source.Seat, Path: path, Ref: m.Base, Commit: m.Commit, For: "merge"} if err != nil { request.ID = fmt.Sprintf("not-asked-%s-%s", short(m.Commit), strings.ReplaceAll(dir, "/", "-")) request.NotAsked = err.Error() recordBuildRequest(ctx, inv, request) fmt.Printf(" %s is a new module in %s/%s and could not be built: %v — a hand `build` of it "+ "asks again\n", dir, m.Owner, m.Repo, err) continue } recordBuildRequest(ctx, inv, request) fmt.Printf(" %s is a new module in %s/%s: build %s asked at %s; registered when it lands, assigned "+ "nowhere\n", dir, m.Owner, m.Repo, id, m.Base) asked = append(asked, dir) } return asked } // actingOnMerges keeps one merge acted on at a time (novox/hq issue 266). var actingOnMerges sync.Mutex // mergeCandidates is what one merge could move, judged against what the catalogue holds. // // Two kinds of module are affected by one merge, and they are affected differently. // // A module **built from** this repository and branch has moved: the mesh records the new commit as // what its source now has, and only what the merge actually changed is rebuilt. A module that only // **packages source from** it has not moved — its own source is somewhere else, at the commit it // already records — so it is rebuilt and its record left alone. Writing this commit as its source // would make it permanently behind a repository its manifest does not come from. `already` counts // the modules built from this repository that are already built from this very commit. func mergeCandidates(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository) (from, packaging []inventory.Entry, already int) { for _, e := range entries { switch { case sourceIs(e.Source, m): if e.Source.BuiltFrom == m.Commit { already++ continue } // **A merge older than the last look at the source is history, not a move.** The forge // announces what it finds merged, and an old merge surfacing late would otherwise move // the recorded head backwards and rebuild everything built from that repository, once // per old merge (2026-09-28). if isHistory(m.MergedAt, e.Source.Seen) { continue } from = append(from, e) case readsFrom(read[e.Manifest.Module], m): // **A merge older than the module's last look is history for it** (novox/hq ADR 0267): a // build or plan of it after the merge already read the repository with the merge in it. Per // module, since a merge that moved only the module built from the repository says nothing // about the ones packaging it. // Judged with a margin for the forge's clock running behind the store's: too late a look // rebuilds once more, too early one would miss the merge. if lookedAtCommit(read[e.Manifest.Module], m.Commit) { continue } if looked := lookedOf(read[e.Manifest.Module]); !looked.IsZero() && isHistory(m.MergedAt, looked.Add(-historyMargin)) { continue } packaging = append(packaging, e) } } return from, packaging, already } // lookedAtCommit is whether a plan that built a module, or is building it, answered this merge commit. func lookedAtCommit(read []inventory.ReadRepository, commit string) bool { for _, r := range read { if slices.Contains(r.LookedAt, commit) { return true } } return false } // historyMargin is how far a packaging module's last look is taken back before a merge is history for it. const historyMargin = time.Minute // whyMoved is why a merge moves a module, as a plan says it (novox/hq ADR 0267, issue 363): the changed // files in its build source, or why it is read whole. For a module built from the merged repository or one // whose build context is that repository; a dependent is in a plan for what it stands on. func whyMoved(e inventory.Entry, read []inventory.ReadRepository, m link.SourceMoved) string { if len(m.Paths) == 0 || m.PathsTruncated { return "read whole: the merge's changed files were not all said" } whole := "no build source recorded" for _, r := range read { if r.Own && r.Whole != "" { whole = r.Whole } } held := func(paths []string) []string { var in []string for _, p := range m.Paths { if builder.SourceHolds(paths, p) { in = append(in, p) } } return in } changed := func(where string, in []string) string { return fmt.Sprintf("its build source%s changed: %d changed file(s) in it, e.g. %s", where, len(in), in[0]) } if sameRepository(e.Source.Repository, m) { if own := ownSource(read); own != nil { if in := held(own); len(in) > 0 { return changed("", in) } } dir := strings.Trim(e.Source.Path, "/") if dir == "" { return "read whole: " + whole + ", so every file of its repository is its build source" } for _, p := range m.Paths { if inside(p, dir) { return "read whole: " + whole + ", so its directory is its build source; e.g. " + p } } return "read whole: " + whole } for _, r := range read { if r.Own || !sameRepository(r.Repository, m) || (r.Ref != "" && r.Ref != m.Base) { continue } if len(r.Paths) > 0 { if in := held(r.Paths); len(in) > 0 { return changed(" in "+m.Owner+"/"+m.Repo, in) } continue } return "read whole: " + whole + ", so every file of " + m.Owner + "/" + m.Repo + ", which its build context is, is its build source" } return "read whole: " + whole } // lookedOf is when a module packaging another repository was last looked at, as readForPlanning says. func lookedOf(read []inventory.ReadRepository) time.Time { var at time.Time for _, r := range read { if r.Looked.After(at) { at = r.Looked } } return at } // wouldMove is the modules acting on this merge would move and rebuild — SourceMoved's judgement, made // without acting (novox/hq issue 266). Empty for a merge already acted on: acting marks each module built // from the repository as looked at, so the merge then reads as history for it. // // **The ones packaging source from it too** (novox/hq ADR 0267): with a module moved only by the files of // its build source, a merge can move a packaging module and nothing built from the repository, and a missed // one of those was never acted on. A packaging module's look is its newest build or plan (lookedAt), so a // merge acted on for it reads as history once its plan is made. func wouldMove(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository) []inventory.Entry { from, packaging, _ := mergeCandidates(m, entries, read) touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m, read), m) return append(touched, packaging...) } // splitDeleted parts the modules a merge touched into those it changed and those whose manifest it // removed — deleted at their source (ADR 0236). func splitDeleted(touched []inventory.Entry, m link.SourceMoved) (kept, deleted []inventory.Entry) { removed := map[string]bool{} for _, p := range m.Removed { removed[strings.Trim(p, "/")] = true } for _, e := range touched { dir := strings.Trim(e.Source.Path, "/") manifest := moduleManifestFile if dir != "" { manifest = dir + "/" + manifest } if len(removed) > 0 && removed[manifest] { deleted = append(deleted, e) continue } kept = append(kept, e) } return kept, deleted } // retireDeleted is what the mesh does with a module deleted at its source: its record says the merge // was looked at, so it is not acted on again; it is forgotten where nothing holds it; where a machine // runs it or the mesh holds something for it, that is said, and nothing is built. func retireDeleted(ctx context.Context, inv *inventory.Inventory, e inventory.Entry, m link.SourceMoved) { name := e.Manifest.Module if err := inv.SourceMoved(ctx, name, m.Commit); err != nil { fmt.Printf(" %s was deleted from %s/%s at %.8s, and that could not be recorded: %v\n", name, m.Owner, m.Repo, m.Commit, err) } err := inv.ForgetModule(ctx, name) switch { case err == nil: fmt.Printf(" %s was deleted from %s/%s at %.8s: forgotten, nothing built\n", name, m.Owner, m.Repo, m.Commit) case errors.Is(err, inventory.ErrStillAssigned) || errors.Is(err, inventory.ErrStillHolds): fmt.Printf(" %s was deleted from %s/%s at %.8s and is not built; the mesh still runs or holds it, so it is "+ "kept until a person unassigns it and `module forget %s`: %v\n", name, m.Owner, m.Repo, m.Commit, name, firstLine(err.Error())) default: fmt.Printf(" %s was deleted from %s/%s at %.8s and is not built; forgetting it failed: %v\n", name, m.Owner, m.Repo, m.Commit, err) } } // sourceIs is whether a recorded source is the repository and branch a merge announced. A source on // the git seat is recorded as its path on the forge; one elsewhere as the URL it was cloned from. // An empty recorded ref is the repository's default branch, which is what a merge into the base // branch of the forge's default means. func sourceIs(s inventory.Source, m link.SourceMoved) bool { if !sameRepository(s.Repository, m) { return false } ref := followedBranch(s.Ref) return ref == "" || ref == m.Base } // commitRef is a ref that names a commit rather than a branch: what `build --ref ` asks for. var commitRef = regexp.MustCompile(`^[0-9a-f]{7,40}$`) // followedBranch is the branch a recorded ref means a module follows (novox/hq 04-ISSUES/215). **A // commit is never a branch to follow.** A build asked at a commit — to try one, or to pin it during a // fix — recorded that commit as the module's ref; every merge after it then failed to match the // module, its plan left it out without saying so, and every plan that rebuilt it asked for that same // old commit again. A commit recorded so is read as the repository's default branch, which is what // the module followed before it; a branch is followed as named. func followedBranch(ref string) string { if commitRef.MatchString(strings.TrimSpace(ref)) { return "" } return ref } // sameRepository is whether a recorded repository is the one a merge names, in either spelling it // may have been recorded in: a path on the git seat, or the URL it was cloned from. func sameRepository(repository string, m link.SourceMoved) bool { want := strings.ToLower(m.Owner + "/" + m.Repo) repo := strings.ToLower(strings.TrimSuffix(repository, ".git")) return repo == want || strings.HasSuffix(repo, "/"+want) || (m.CloneURL != "" && repo == strings.ToLower(strings.TrimSuffix(m.CloneURL, ".git"))) } // readsFrom is whether a merge changed what a module's build read in another repository: the second // repository its recipe packages source from. Its ref must be the branch that moved, or unset — the same // rule a module's own source follows. // // **Only a changed file in what the build read there** (novox/hq ADR 0267 rule 2): where the module's // newest trunk build said its build source in that repository, a merge touching none of it is no change // to the module (issue 338: every merge to the controller's repository moved the route proxy and the // build seat's holder). Where it said none, or the merge's files are not all said, the whole repository is // read, as before. func readsFrom(read []inventory.ReadRepository, m link.SourceMoved) bool { for _, r := range read { if r.Own || !sameRepository(r.Repository, m) || (r.Ref != "" && r.Ref != m.Base) { continue } if len(r.Paths) == 0 || len(m.Paths) == 0 || m.PathsTruncated { return true } for _, p := range m.Paths { if builder.SourceHolds(r.Paths, p) { return true } } } return false } // ownSource is the build source a module's newest trunk build said it read in its own repository; nil // when it said none, and the module's own directory — or, built from the root, its whole repository — is // its build source, as before (novox/hq ADR 0267). func ownSource(read []inventory.ReadRepository) []string { for _, r := range read { if r.Own && len(r.Paths) > 0 { return r.Paths } } return nil } // readsFile is whether a module built from the merged repository reads one of its changed files: in its // build source where its newest trunk build said one, else anywhere in its directory, or anywhere at all // for a module built from the repository's root. func readsFile(e inventory.Entry, read []inventory.ReadRepository, p string) bool { if own := ownSource(read); own != nil { return builder.SourceHolds(own, p) } return readsDirectory(e, p) } // readsDirectory is whether a changed file is in a module's directory, or anywhere for a module built from its // repository's root: the reading of a module with no recorded build source. func readsDirectory(e inventory.Entry, p string) bool { return strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path) } // unchangedOfMerge are the modules a merge changed files for under a reading without recorded build sources, but // whose recorded build source holds none of them, each with why (novox/hq issue 611): not built, because the build // the mesh holds is theirs (ADR 0267 §5). That reading — a delivery plan made while another walk built the module // (issue 606) — would have moved them; the walk says they matched what runs rather than leaving them unnamed. // A module built from the repository whose directory, or whole repository when built from its root, holds a // changed file, and a module packaging the repository's branch, are each read so. A merge whose changed files // were not all said moves every one of them: none is said here. Without acting. func unchangedOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository) map[string]string { if len(m.Paths) == 0 || m.PathsTruncated { return nil } from, _, _ := mergeCandidates(m, entries, read) touched, _, _ := touchedBy(from, entries, m, read) out := map[string]string{} for _, e := range from { name := e.Manifest.Module if ownSource(read[name]) == nil || slices.ContainsFunc(touched, func(t inventory.Entry) bool { return t.Manifest.Module == name }) { continue } var in []string for _, p := range m.Paths { if readsDirectory(e, p) { in = append(in, p) } } if len(in) > 0 { out[name] = fmt.Sprintf("its build source holds none of the %d changed file(s) in its directory (e.g. %s): "+ "the build the mesh holds is its build", len(in), in[0]) } } for _, e := range entries { name := e.Manifest.Module if sourceIs(e.Source, m) || !packagesSaid(read[name], m) || readsFrom(read[name], m) { continue } out[name] = fmt.Sprintf("its build source in %s/%s holds none of the %d changed file(s) (e.g. %s): "+ "the build the mesh holds is its build", m.Owner, m.Repo, len(m.Paths), m.Paths[0]) } if len(out) == 0 { return nil } return out } // packagesSaid is whether a module's newest trunk build said the build source it read in a merge's repository and // branch, as a context beside its own. func packagesSaid(read []inventory.ReadRepository, m link.SourceMoved) bool { for _, r := range read { if !r.Own && sameRepository(r.Repository, m) && (r.Ref == "" || r.Ref == m.Base) && len(r.Paths) > 0 { return true } } return false } // staleIn is the modules whose recorded build source a plan has overtaken (novox/hq ADR 0267): a plan still // working that has yet to build one, or a plan made after that build which never built it — failed, stopped // or superseded. What such a module is built from is changing, or changed without a build to say so: a merge // that added an import to it, and a later one changing only what that import names, would otherwise move // nothing. Each is read whole, as before, until a build of it works again. // // Each is answered with the plan that overtook it, for saying why it is read whole. func staleIn(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string]string { stale := map[string]string{} for name, rs := range read { var since time.Time for _, r := range rs { if r.Built.After(since) { since = r.Built } } for _, p := range plans { s, in := p.Modules[name] if !in || (s != nil && (s.State == "built" || s.State == planDeleted)) { continue } if p.Open() || p.Created.After(since) { stale[name] = p.ID } } } return stale } // lookedAt is when a merge was last acted on for a module that packages another repository's source: its // newest build, or the newest plan that built it or is still building it, whichever is later. A build asked // after a merge clones that repository with the merge in it, so an older merge is history for it; a plan // that closed without building it looked at nothing. func lookedAt(name string, read []inventory.ReadRepository, plans []inventory.Plan) time.Time { var at time.Time for _, r := range read { if r.Looked.After(at) { at = r.Looked } } for _, p := range plans { s, in := p.Modules[name] if in && (p.Open() || (s != nil && s.State == "built")) && p.Created.After(at) { at = p.Created } } return at } // readForPlanning is what each module's build read, as the planner maps a change onto it — for a merge // acting now, the merge gate, a pull request's check, a delivery's order and the what-if alike, so planning // and gating cannot disagree (novox/hq ADR 0238): the build sources the newest trunk builds said, but for // the modules a plan has overtaken (staleIn), and with when each was last looked at (lookedAt). func readForPlanning(ctx context.Context, inv *inventory.Inventory) (map[string][]inventory.ReadRepository, error) { read, err := inv.ReadRepositories(ctx) if err != nil { return nil, err } // Every plan since the oldest build whose source is recorded: one made after a module's build can have // overtaken it, however long ago, so no window of recent plans would do. var oldest time.Time for _, rs := range read { for _, r := range rs { if !r.Built.IsZero() && (oldest.IsZero() || r.Built.Before(oldest)) { oldest = r.Built } } } plans, err := inv.PlansSince(ctx, oldest) if err != nil { return nil, err } return planningView(read, plans), nil } // planningView is readForPlanning over what was read, so a test can hand it records. func planningView(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string][]inventory.ReadRepository { stale := staleIn(read, plans) out := make(map[string][]inventory.ReadRepository, len(read)) for name, rs := range read { looked := lookedAt(name, rs, plans) var commits []string for _, p := range plans { if st, in := p.Modules[name]; in && p.Commit != "" && (p.Open() || (st != nil && st.State == "built")) { // Every commit the walk carries (novox/hq ADR 0276): a batch's walk answers one per repository. for _, c := range p.Carried() { commits = append(commits, c.Commit) } } } var kept []inventory.ReadRepository overtaken, isStale := stale[name] for _, r := range rs { if isStale { if r.Own { continue } r.Paths = nil } r.Looked, r.LookedAt = looked, commits kept = append(kept, r) } if isStale { kept = append(kept, inventory.ReadRepository{Own: true, Whole: "plan " + overtaken + " has not built it yet", Looked: looked, LookedAt: commits}) } out[name] = kept } return out } // whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed: **a // changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0238). It is // touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules. func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved, read map[string][]inventory.ReadRepository) []inventory.Entry { touched, _, _ := touchedBy(candidates, known, m, read) return touched } // touchedBy maps a merge's changed files onto the mesh's modules — **the one place it is done**, for the // merge handler, the release planner's what-if, the merge gate and a pull request's check alike (novox/hq // ADR 0238), so planning and gating cannot disagree about what a change touches. // // **A changed file touches exactly the modules whose build reads it.** What a build reads is its build // source, where the module's newest trunk build said one (novox/hq ADR 0267): a Go program's import closure, // an archive's directory, a recipe, its manifest — so a README at the root of a repository whose module is // built from its root, or another program's package beside it, touches nothing. Where none was said, it is // the module's own directory — the builder clones the repository and builds within that directory alone: the manifest, // the recipes, the bundles' sources, the Docker context — or the whole repository for a module built from // its root. A second repository a recipe packages (an artifact's `context`) is read too; that is the build // record's `read`, answered by readsFrom in mergeCandidates. So a changed file inside a module's directory // is that module's; a file in no module's directory — a script at the root, a README, CI configuration — // is read by no build and touches nothing: it is answered as `unread`. // // **It used to be read as shared code**, rebuilding everything built from the repository, on the theory // that a root file might be a build input (04-ISSUES/131). No build reads one: the theory cost a rebuild // of 103 modules for a merge-check.sh added at the catalogue's root (issue 280), as it had for a module // held by no machine (278) and a new module's directory (252), each patched as an exception to a rule that // was wrong. Rebuilding too much is not the safe direction when every rebuild is a rollout. // // `added` is the directories the change holds a module in that no module of this repository is known // from — said by the announcer at the head (issue 278), or a module.json among the changed files — `.` // for the root: a new module, which a check judges before it merges and the merge builds and registers, // sending it nowhere (askNewModules, novox/hq issue 300). // // Nothing said about the files, or not all of them said, is still everything: what is not known cannot // be narrowed. func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved, read map[string][]inventory.ReadRepository) (touched []inventory.Entry, added, unread []string) { knownDirs := map[string]bool{} for _, e := range known { if !e.Provided && sameRepository(e.Source.Repository, m) { knownDirs[strings.Trim(e.Source.Path, "/")] = true } } newDir := map[string]bool{} for _, d := range saidModuleDirs(m) { if !knownDirs[d] { newDir[d] = true } } for _, p := range m.Paths { p = strings.Trim(p, "/") if p != moduleManifestFile && !strings.HasSuffix(p, "/"+moduleManifestFile) { continue } d := strings.TrimSuffix(strings.TrimSuffix(p, moduleManifestFile), "/") if !knownDirs[d] { if d == "" { d = "." } newDir[d] = true } } for d := range newDir { added = append(added, d) } sort.Strings(added) if len(m.Paths) == 0 || m.PathsTruncated { return candidates, added, nil } for _, e := range candidates { for _, p := range m.Paths { if readsFile(e, read[e.Manifest.Module], p) { touched = append(touched, e) break } } } for _, p := range m.Paths { isRead := newDir["."] for _, e := range candidates { isRead = isRead || readsFile(e, read[e.Manifest.Module], p) } for d := range newDir { isRead = isRead || inside(p, d) } // A file a module packages from this repository is read too, by that module's build. for _, e := range known { isRead = isRead || readsFrom(read[e.Manifest.Module], link.SourceMoved{Owner: m.Owner, Repo: m.Repo, Base: m.Base, CloneURL: m.CloneURL, Paths: []string{p}}) } if !isRead { unread = append(unread, p) } } return touched, added, unread } // mergeReach is what a merge of a repository's branch reaches, as the planner reckons it: the planner's // one answer, given to the release planner's what-if, the merge gate and a pull request's check (novox/hq // ADR 0238). The merge handler acts on the same pieces — mergeCandidates, touchedBy, splitDeleted, // planOfMerge — as it goes. type mergeReach struct { // Touched are the modules built from the repository and branch whose build reads a changed file, and // Deleted those of them whose manifest the merge removes. Touched, Deleted []inventory.Entry // Packaging are the modules whose build packages source from the repository. Packaging []inventory.Entry // Already counts the modules built from the repository that are built from this very commit. Already int // Added are the directories the change holds a new module in; Unread the changed files no build reads. Added, Unread []string // Plan is what moves and everything that follows it along the catalogue's dependencies, tiered — // what a merge would build. Empty when nothing moves. Plan inventory.Plan } // Moved are the modules the merge moves itself, by name: touched, deleted and packaging. func (r mergeReach) Moved() []string { var out []string for _, group := range [][]inventory.Entry{r.Touched, r.Deleted, r.Packaging} { for _, e := range group { out = append(out, e.Manifest.Module) } } sort.Strings(out) return slices.Compact(out) } // Dependents are the modules the plan builds after the moved ones because they stand on them. func (r mergeReach) Dependents() []string { moved := map[string]bool{} for _, name := range r.Moved() { moved[name] = true } var out []string for name := range r.Plan.Modules { if !moved[name] { out = append(out, name) } } sort.Strings(out) return out } // reachOfMerge is what a merge of these changed files into this branch would move and build, without // acting: the merge handler's own judgement and the release plan's dependency walk. func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository, edges []inventory.Edge) mergeReach { from, packaging, already := mergeCandidates(m, entries, read) touched, added, unread := touchedBy(from, entries, m, read) kept, deleted := splitDeleted(touched, m) r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread} var building []string for _, e := range append(append([]inventory.Entry{}, kept...), packaging...) { building = append(building, e.Manifest.Module) } if len(building) > 0 { r.Plan = planOfMerge(m, building, edges) } return r } // saidModuleDirs is the directories the announcer says hold a module at the merge commit (novox/hq // issue 278): a changed file in one of them is that module's business and nobody else's — the // catalogue's reference module, held by no machine, whose code a merge touched beside its manifest, // read as shared and rebuilt 103 modules. Nothing when the announcer did not look, and never the // repository's root: a module built from the root is handled as one built from it, and a root that // counted would make every file a module's. func saidModuleDirs(m link.SourceMoved) []string { if !m.ModuleDirsSaid { return nil } var out []string for _, d := range m.ModuleDirs { if d = strings.Trim(strings.TrimSpace(d), "/"); d != "" && d != "." { out = append(out, d) } } return out } // inside is whether a changed file is in a directory: that directory itself, or under it. func inside(path, dir string) bool { dir = strings.Trim(dir, "/") path = strings.TrimPrefix(path, "/") return path == dir || strings.HasPrefix(path, dir+"/") } // anyInside is whether any of these changed files is in a directory. func anyInside(paths []string, dir string) bool { for _, p := range paths { if inside(p, dir) { return true } } return false } // orderByBases is the entries with every base before what stands on it: a module whose build stood // on another's artifact comes after that module. Entries outside the set are not waited for — they // are not being rebuilt. Stable for what has no order between it. // // `against` is what each module's newest build stood on (inventory.BuiltAgainst): the edges are // derived from builds, not declared, because a recorded manifest no longer carries `build.on`. func orderByBases(entries []inventory.Entry, against map[string][]string) []inventory.Entry { inSet := map[string]bool{} for _, e := range entries { inSet[e.Manifest.Module] = true } var out []inventory.Entry placed := map[string]bool{} var place func(e inventory.Entry, seen map[string]bool) place = func(e inventory.Entry, seen map[string]bool) { name := e.Manifest.Module if placed[name] || seen[name] { return } seen[name] = true for _, base := range entries { if base.Manifest.Module != name && inSet[base.Manifest.Module] && standsOnModule(e, base.Manifest.Module, against) { place(base, seen) } } placed[name] = true out = append(out, e) } for _, e := range entries { place(e, map[string]bool{}) } return out } // standsOn is whether anything in the set is built on the named module's artifacts. func standsOn(entries []inventory.Entry, module string, against map[string][]string) bool { for _, e := range entries { if standsOnModule(e, module, against) { return true } } return false } // standsOnModule is whether an entry's build stood on the named module: by what its newest build // recorded it was handed (`artifact-store:///@…`, the module's own artifact), or // — for a module registered from a manifest and not yet built — by the base its manifest names. func standsOnModule(e inventory.Entry, module string, against map[string][]string) bool { if e.Manifest.Module == module { return false } if e.Manifest.Build != nil { for _, on := range e.Manifest.Build.On { if on.Module == module { return true } } } prefix := catalogue.ArtifactStoreScheme + module + "/" for _, ref := range against[e.Manifest.Module] { if strings.HasPrefix(ref, prefix) { return true } } return false } // isHistory is whether a merge made at mergedAt predates the last time the source was seen. A merge // with no time on it is taken as news: refusing it would silence a forge that says less. func isHistory(mergedAt string, seen time.Time) bool { if mergedAt == "" || seen.IsZero() { return false } at, err := time.Parse(time.RFC3339, mergedAt) if err != nil { return false } return at.Before(seen) } // dependentsOf is every catalogued module that stands on one of the moved modules, directly or // through another dependent, and is not itself among them — in the catalogue's order, so the // answer is the same each time. A module standing on nothing that moved is left alone: a merge // rebuilds what it changed and what is built on top of that, not the catalogue. func dependentsOf(moved, entries []inventory.Entry, against map[string][]string) []inventory.Entry { bases := map[string]bool{} for _, e := range moved { bases[e.Manifest.Module] = true } var out []inventory.Entry taken := map[string]bool{} for grew := true; grew; { grew = false for _, e := range entries { name := e.Manifest.Module if bases[name] || taken[name] { continue } for base := range bases { if standsOnModule(e, base, against) { taken[name] = true out = append(out, e) grew = true break } } } for _, e := range out { bases[e.Manifest.Module] = true } } return out } // moduleManifestFile is the file that says what a module is, in its directory (the build seat's // ManifestName). const moduleManifestFile = "module.json" // deletedAtSource is whether a build failed because its module's manifest is not at its source any // more — the build seat's own words (internal/builder) — which a merge that deleted the module causes // when its announcer did not say which files went (ADR 0236). Such a module is not a failure of the plan. func deletedAtSource(failed string) bool { return strings.Contains(failed, "has no "+moduleManifestFile+" at ") && strings.Contains(failed, "so there is nothing saying what it is") }