package main import ( "context" "fmt" "sort" "strings" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" ) // A move held for a person is asked of the person (novox/hq issue 1732, issue 1320; ADR 0377, ADR 0259). // // **A wait only a person can end is said to that person at once.** ADR 0377 holds a recorded module's move that // recreates a container or touches its data until a person pushes the machine. The walk said so in its note and // ended "done", `status` named the machine behind, and nobody was asked: on 2026-10-21 mailu's new build waited on // novox for 40 minutes behind a plan that read "done, 3 tier(s)", and on 2026-10-19 mssql waited on ace the same way. // Row S24 raises one condition per machine that has such a move, for the operator, with the answer **Release**: the // push of that machine, which the controller's own `push` verb performs on the operator's warrant (ADR 0259). It ends // when the machine no longer waits, whoever pushed it. // // A hold a person chose is not asked again — a module they set to `record` with a why, and the bus, whose step has // its own row (S17). // kindMovesHeld is S24's kind: machine..moves-held. const kindMovesHeld = "moves-held" // heldFacts is one machine and the moves of recorded modules that wait there for a person. type heldFacts struct { node string moves []heldMove } // gatherHeldMoves is, per machine that holds at least one move no person chose to hold, all its held moves, by machine. func gatherHeldMoves(ctx context.Context, inv *inventory.Inventory) ([]heldFacts, error) { nodes, err := inv.Nodes(ctx) if err != nil { return nil, fmt.Errorf("the machines cannot be read: %w", err) } f, err := readMoveFacts(ctx, inv) if err != nil { return nil, err } var out []heldFacts for _, n := range nodes { moves, err := heldMovesOn(ctx, inv, f, n.Name) if err != nil { return nil, err } h := heldFacts{node: n.Name, moves: moves} if len(h.asked()) > 0 { out = append(out, h) } } return out, nil } // asked are the moves no person chose to hold, and chosen the ones a person did (a module set to record with a why, the bus). func (h heldFacts) asked() []heldMove { return h.byChoice(false) } func (h heldFacts) chosen() []heldMove { return h.byChoice(true) } func (h heldFacts) byChoice(chosen bool) []heldMove { var out []heldMove for _, m := range h.moves { if m.Chosen == chosen { out = append(out, m) } } return out } // modules are the held moves' modules, in order, of all of them. func (h heldFacts) modules() []string { return moduleNames(h.moves) } func moduleNames(moves []heldMove) []string { out := make([]string, 0, len(moves)) for _, m := range moves { out = append(out, m.Module) } return out } // releaseAction is the one answer a held move offers: the push of the machine, approved by the operator. The push // names every module whose move waits there as one that may move its data: the operator's Approve is the word // ADR 0217 asks for, and the ask says so. Offered only where no person's own hold is on the machine too: a push // composes every recorded module at its current build, so it would send that move as well, which the ask never named // (ADR 0259 §6), and hold the whole machine if that module's data would move. func (h heldFacts) releaseAction() conditions.Action { return conditions.Action{Label: "Release", Verb: "mesh-controller.push", Level: conditions.LevelApprove, Arguments: map[string]string{"node": h.node, "move": strings.Join(moduleNames(h.asked()), ","), "why": "", "cause": conditions.CauseOperatorAnswer}} } // heldWords are the plain words of a machine's held moves: which modules wait, why in general, and what an Approve does. func heldWords(h heldFacts) (headline, explanation, needs string, resolved string) { names := namesWords(moduleNames(h.asked()), 3) headline = clipWords(conditions.Capital(names)+" waits for you on "+h.node, conditions.HeadlineMax) recreates, data := false, false for _, m := range h.asked() { switch { case strings.Contains(m.Why, "recreates"): recreates = true default: data = true } } why := "would change what it runs" switch { case recreates && data: why = "would restart a container or touch its data" case recreates: why = "would restart one of its containers" case data: why = "would touch its files or data" } explanation = clipWords(fmt.Sprintf("The new version of %s %s on %s, so it does not go out with its delivery; "+ "it waits for you. Release sends %s everything it is owed, these among it, and lets their data move where it "+ "has to. Nothing is sent until you answer.", names, why, h.node, h.node), conditions.ExplanationMax) needs = "release them " + FromMeshMCPServer if chosen := h.chosen(); len(chosen) > 0 { // A push of the machine would send the move a person chose to hold as well: no answer is offered here. explanation = clipWords(fmt.Sprintf("The new version of %s %s on %s, so it does not go out with its delivery; "+ "it waits for you. %s is also held there by your own choice, and a push of %s would send that too, so "+ "nothing is offered here: the push is yours at the controller's terminal.", names, why, h.node, conditions.Capital(namesWords(moduleNames(chosen), 3)), h.node), conditions.ExplanationMax) needs = fmt.Sprintf("push %s at the controller's terminal, once you have decided about %s.", h.node, namesWords(moduleNames(chosen), 3)) } resolved = "Resolved: " + names + " is no longer waiting on " + h.node return } // watchMovesHeld is S24: a machine that has moves of recorded modules waiting for a person, said to the operator with the // answer that sends them. The condition ends when the machine no longer waits: it is no longer among the facts. func watchMovesHeld(f *signalFacts) []conditions.Observation { var out []conditions.Observation for _, h := range f.held { var said []string for _, m := range h.moves { said = append(said, fmt.Sprintf("%s %s → %s %s", m.Module, short(m.From), short(m.To), m.Why)) } headline, explanation, needs, resolved := heldWords(h) if c := h.chosen(); len(c) > 0 { said = append(said, "held by a person's own choice: "+strings.Join(moduleNames(c), ", ")) } var actions []conditions.Action if len(h.chosen()) == 0 { actions = []conditions.Action{h.releaseAction()} } out = append(out, conditions.Observation{Scope: conditions.ScopeMachine, ID: h.node, Kind: kindMovesHeld, Machine: h.node, Severity: conditions.Warning, Resolver: conditions.ResolverOperator, Summary: fmt.Sprintf("%s has %d move(s) of recorded modules waiting for a person's push: %s — `push %s` sends them", h.node, len(h.moves), strings.Join(h.modules(), ", "), h.node), Said: strings.Join(said, "; "), Headline: headline, Explanation: explanation, Needs: needs, Resolved: resolved, Actions: actions}) } return out } // planHeld is, for a plan that ended or is running, the moves its modules carried that a machine still holds for a // person: "module on machine", sorted. The plan's own record says the move waited when it was judged // (PlanModule.Recorded); the machines say whether it still does, and for the build this plan carried, so a plan older // than the build now held is not named. func planHeld(p inventory.Plan, held []heldFacts) []string { var out []string for _, h := range held { for _, m := range h.moves { s := p.Modules[m.Module] if s == nil || !strings.HasPrefix(s.Recorded[h.node], "waits:") || !sameCommit(s.Commit, m.To) { continue } out = append(out, m.Module+" on "+h.node) } } sort.Strings(out) return out } // heldSaid is what a plan's line adds when it carries a move that a machine holds for a person (novox/hq issue 1732): a // plan whose module waits for a push is not plainly "done". func heldSaid(p inventory.Plan, held []heldFacts) string { waiting := planHeld(p, held) if len(waiting) == 0 { return "" } return fmt.Sprintf(" — %d held for a person: %s (the operator is asked)", len(waiting), strings.Join(waiting, ", ")) } // heldNow reads the machines' held moves for a plan's line, nil when they cannot be read: the line then reads as before. func heldNow(ctx context.Context, inv *inventory.Inventory) []heldFacts { held, err := gatherHeldMoves(ctx, inv) if err != nil { return nil // empty-on-error: a plan's line then reads as it did before; the watchdog (S24) says it cannot read } return held }