package buildrun import ( "fmt" "maps" "slices" "sort" "sync" "time" "git.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep" ) // A build module writes . of its toolchains state on every node it runs on, and nothing deletes the key // when the module leaves a node: no part writes another module's state, and the module is gone. A report of a node the // build module no longer runs on is therefore no builder's (issue 1940: mesh-build-node taken off ace on 2026-10-23 left // ace.typescript at v26.12.0, and every Node fingerprint said "builders disagree"). // // ToolchainGate stands between the toolchains buckets and what takes their entries in (the indexer's PutToolchain, a // ToolchainSet): an entry is passed on only while the assignments (the seat mesh-module-assigner's state `assignments`, held by // the assigner, its holder's state assignments, read by meshmoduleassignerclient.Placed; ADR 0567, ADR 0549 §3) places its build module on // its node, and only once it was written since that assignments; it is passed on as deleted otherwise, said once, and // passed on when its node writes it again. A report written before the assignments is one an earlier run of the module // left (issue 1940, 2026-10-25 20:56: mesh-build-node put on ace again brought ace.typescript v26.12.0 of 2026-10-23 // back into the agreement before the module ran there). Until the assignments is settled, and while no node's assignments // is held (before the assigner's adopt writes the first one, or where the state cannot be read), every entry is passed // on. Builders that run and report different versions still disagree: that refusal is the guard of novox/hq ADR 0465, // and it stays. // // This replaces the copies of the gate the indexer, the builder and the checker each kept over the controller's // assignments (issue 1940's subtask, issue 774: those go with the controller's lease, ADR 0549 §6). type ToolchainGate struct { mu sync.Mutex modules map[string]bool // the build modules whose toolchains pass the gate forward func(module, key string, value []byte, deleted bool) bool // answers whether it changed what it holds logf func(format string, a ...any) placed placementsSince known bool // the assignments is settled raw map[string]map[string][]byte // module -> toolchains key -> value, as the bucket holds it left map[string]string // / -> why it was said left out, so each reason is said once // OnPlaced is told, outside the gate's lock, once the assignments's history is read and after each revision of it // FollowAssignmentsIn hears from then on (the counted nodes, Nodes, may have changed). Nil tells nobody. OnPlaced func() } // placementsSince is where the gate reads which modules stand on which node, and since when: the assigner's state // (meshmoduleassignerclient.Placed, fed by Assignments) or the assignment half (meshwantsclient.Placed, fed by Revision). type placementsSince interface { Seeded() bool Since(node, module string) (time.Time, bool) ByNode() map[string][]string } // NewToolchainGate is a gate over the toolchains of modules, passing what stands on to forward and saying with logf what // it leaves out. func NewToolchainGate(modules []string, forward func(module, key string, value []byte, deleted bool) bool, logf func(string, ...any)) *ToolchainGate { g := &ToolchainGate{modules: map[string]bool{}, forward: forward, logf: logf, placed: meshmoduleassignerclient.NewPlaced(), raw: map[string]map[string][]byte{}, left: map[string]string{}} for _, m := range modules { g.modules[m] = true } return g } // UseModules replaces the build modules whose reports the gate passes on again when the assignments moves (a part that // finds them from the seat list and the manifests, as the indexer does, gives them at each change), and passes every // report it holds on again. It answers whether what it passed on changed. func (g *ToolchainGate) UseModules(modules []string) bool { g.mu.Lock() defer g.mu.Unlock() g.modules = map[string]bool{} for _, m := range modules { g.modules[m] = true } return g.all() } // Toolchain takes one change of a build module's toolchains bucket in and answers whether what it passed on changed. func (g *ToolchainGate) Toolchain(module, key string, value []byte, deleted bool) bool { g.mu.Lock() defer g.mu.Unlock() if g.raw[module] == nil { g.raw[module] = map[string][]byte{} } if deleted { delete(g.raw[module], key) } else { g.raw[module][key] = value } return g.pass(module, key) } // Assignments takes one revision of the seat mesh-module-assigner's assignments in (at is when the bus wrote it) and answers whether what it // passed on changed. A value that does not read is said, and places nothing on its node. func (g *ToolchainGate) Assignments(key string, value []byte, deleted bool, at time.Time) bool { g.mu.Lock() defer g.mu.Unlock() placed, ok := g.placed.(*meshmoduleassignerclient.Placed) if !ok { g.logf("the assignments: %s is not read, the gate takes the assignment half (design 47)", key) return false } was := placed.Seeded() node, changed, err := placed.Put(key, value, deleted, at) if err != nil { g.logf("the assignments: %v (issue 1940)", err) } if !g.known { return false } if was != g.placed.Seeded() { // The first assignments came, or the last went: every report's standing changed. return g.all() } return g.again(node, changed) } // Revision takes one assignment graph revision the part's view holds in (design 47 *Each part acts on its own entries*: // the placements come from the seat mesh-wants' assignment half, never the assigner's state; assignmentview's Held hands // each) and answers whether what it passed on changed. The first revision taken settles the gate, as the assignments' // history read does; a later one passes on again the reports of each node whose build modules' standing changed. A gate // fed by Revision reads no Assignments. func (g *ToolchainGate) Revision(r meshwants.AssignmentRevision) bool { g.mu.Lock() defer g.mu.Unlock() placed, ok := g.placed.(*meshwantsclient.Placed) if !ok { placed = meshwantsclient.NewPlaced() g.placed = placed } was := placed.Seeded() changed := placed.Take(r) if changed == nil { return false } if !g.known || was != placed.Seeded() { g.known = true return g.all() } moved := false for _, node := range slices.Sorted(maps.Keys(changed)) { moved = g.again(node, changed[node]) || moved } return moved } // Nodes are the nodes the assignments places any module on, sorted: the nodes the mesh counts (a node leaves the count // when the assignments no longer names it; ADR 0624 §1). None until the assignments is read. func (g *ToolchainGate) Nodes() []string { var out []string for node, modules := range g.placed.ByNode() { if len(modules) > 0 { out = append(out, node) } } sort.Strings(out) return out } // Settled says the assignments's history is read: from now on only the reports of placed nodes pass, once a node's // assignments is held. It answers whether what it passed on changed. func (g *ToolchainGate) Settled() bool { g.mu.Lock() defer g.mu.Unlock() g.known = true return g.all() } // all passes on every report again. The caller holds mu. func (g *ToolchainGate) all() bool { changed := false for _, module := range slices.Sorted(maps.Keys(g.raw)) { for _, k := range slices.Sorted(maps.Keys(g.raw[module])) { changed = g.pass(module, k) || changed } } return changed } // again passes on every report of node by a build module among modules. The caller holds mu. func (g *ToolchainGate) again(node string, modules []string) bool { changed := false for _, module := range modules { if !g.modules[module] { continue } for _, k := range slices.Sorted(maps.Keys(g.raw[module])) { if n, _, err := buildstep.ParseToolchainKey(k); err == nil && n == node { changed = g.pass(module, k) || changed } } } return changed } // pass hands one key on as it stands: its value when its node is placed and wrote it since (or the assignments is not // known), deleted otherwise. The caller holds mu. func (g *ToolchainGate) pass(module, key string) bool { value, held := g.raw[module][key] if !held { return g.forward(module, key, nil, true) } node, _, err := buildstep.ParseToolchainKey(key) why := "" if err == nil && g.known && g.placed.Seeded() { why = g.leftOut(module, node, value) } if why == "" { delete(g.left, module+"/"+key) return g.forward(module, key, value, false) } if g.left[module+"/"+key] != why { g.left[module+"/"+key] = why g.logf("%s.%s is left out of the agreed toolchain: %s (issue 1940)", ToolchainsBucket(module), key, why) } return g.forward(module, key, nil, true) } // leftOut is why a node's report is no builder's, or empty when it counts: the catalogue does not place the module on its // node, or the report was written before it did. A value that is no toolchain is passed on for its reader to refuse. // The caller holds mu. func (g *ToolchainGate) leftOut(module, node string, value []byte) string { since, placed := g.placed.Since(node, module) if !placed { return fmt.Sprintf("the catalogue does not place %s on %s, so it is no builder", module, node) } tc, err := buildstep.DecodeToolchain(value) if err == nil && tc.At.Before(since) { return fmt.Sprintf("it was written at %s, before %s was placed on %s at %s: it counts once %s writes it again", tc.At.UTC().Format(time.RFC3339), module, node, since.UTC().Format(time.RFC3339), node) } return "" }