mesh/repo-check head d6db334840a5 merged into trunk 98155e327b6d beside mesh-host#129@09c77d57 — pass: its merge-check.sh passed
mesh/merge-gate head d6db334840a5 merged into trunk 98155e327b6d — pass: group fix/1732-held-moves-asked composed: every machine composes with the change a…
mesh/delivery delivered
mesh/delivery-group group fix/1732-held-moves-asked delivered: every member is delivered
199 lines
8.4 KiB
Go
199 lines
8.4 KiB
Go
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.<node>.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
|
|
}
|