Files
mesh-catalog/modules/mesh-delivery/cmd/mesh-delivery/verbs.go
T

560 lines
18 KiB
Go

package main
import (
"errors"
"fmt"
"sort"
"strings"
"time"
)
// The seat's verbs (novox/hq ADR 0239, the mesh-delivery seat in the controller's set): six that read, and
// the acts — a person's recheck, release and stop, and healer H2's close, each only by a transition the table
// holds.
// Line is one delivery as `deliveries` lists it.
type Line struct {
ID string `json:"id"`
State State `json:"state"`
Since string `json:"since"`
For string `json:"for"`
Group string `json:"group,omitempty"`
Number int `json:"number,omitempty"`
Waits string `json:"waits,omitempty"`
Plan string `json:"plan,omitempty"`
Walk string `json:"walk,omitempty"`
Owed int `json:"owed,omitempty"`
Stalled bool `json:"stalled,omitempty"`
}
// waitsFor is what a delivery waits for, in words.
func (h *Holder) waitsFor(d *Delivery) string {
switch d.State {
case Proposed:
return "its check"
case Ready:
return "its merge"
case Rejected:
return "a new head, or a recheck"
case Published:
if d.Group != "" {
return "its turn in group " + d.Group
}
return "its walk's start"
case Held:
return "a person: " + d.HeldWhy
case Delivering:
if d.Walk != nil {
return "its walk: " + clip(d.Walk.Note, 120)
}
}
return ""
}
// Deliveries is the `deliveries` verb.
func (h *Holder) Deliveries(state, repository, group string, all bool) []Line {
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
var out []Line
for _, d := range h.deliveries {
if state != "" && string(d.State) != state || repository != "" && !strings.EqualFold(d.Repository, repository) ||
group != "" && d.Group != group {
continue
}
if d.State.Final() && !all && now.Sub(d.Since) > 24*time.Hour {
continue
}
l := Line{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), For: now.Sub(d.Since).Round(time.Second).String(),
Group: d.Group, Number: d.Number, Waits: h.waitsFor(d), Owed: len(d.Owed)}
if d.Plan != nil {
l.Plan = d.Plan.Summary
}
if d.Walk != nil {
l.Walk = d.Walk.ID
}
l.Stalled = pastBound(d, now)
out = append(out, l)
}
sort.Slice(out, func(i, j int) bool {
if out[i].State.Final() != out[j].State.Final() {
return !out[i].State.Final()
}
return out[i].Since > out[j].Since
})
return out
}
// Show is the `show` verb: a delivery whole, or a group with its members.
func (h *Holder) Show(id string) (any, error) {
h.mu.Lock()
defer h.mu.Unlock()
if d := h.deliveries[id]; d != nil {
out := map[string]any{"delivery": d, "waits": h.waitsFor(d)}
if g := h.groups[d.Group]; g != nil {
state, why := GroupState(g, h.membersOf(g))
out["group"] = map[string]any{"group": g, "state": state, "why": why}
}
return out, nil
}
if g := h.groups[id]; g != nil {
members := h.membersOf(g)
state, why := GroupState(g, members)
return map[string]any{"group": g, "state": state, "why": why, "members": members}, nil
}
return nil, fmt.Errorf("no delivery or group %q: `deliveries` lists them", id)
}
// GroupLine is one group as `groups` lists it.
type GroupLine struct {
ID string `json:"id"`
State string `json:"state"`
Why string `json:"why"`
Order []string `json:"order"`
Pairs []Pair `json:"pairs,omitempty"`
Cycle []string `json:"cycle,omitempty"`
// Contradictions is each contradiction no precedence resolves, said with how to declare the order.
Contradictions []Contradiction `json:"contradictions,omitempty"`
Check string `json:"composed,omitempty"`
Members []string `json:"members"`
}
// Groups is the `groups` verb.
func (h *Holder) Groups(all bool) []GroupLine {
h.mu.Lock()
defer h.mu.Unlock()
var out []GroupLine
for _, g := range h.groups {
members := h.membersOf(g)
if len(members) == 0 {
continue
}
state, why := GroupState(g, members)
if !all && (state == "delivered" || state == "failed" || state == "stopped") {
continue
}
l := GroupLine{ID: g.ID, State: state, Why: why, Order: g.Order, Pairs: g.Pairs, Cycle: g.Cycle,
Contradictions: g.Contradictions}
for _, d := range members {
l.Members = append(l.Members, fmt.Sprintf("%s (%s)", d.ID, d.State))
}
if g.Check != nil {
l.Check = g.Check.Verdict
if l.Check == "" {
l.Check = "asked as " + g.Check.Asked
}
}
out = append(out, l)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// StalledLine is one delivery past its state's bound.
type StalledLine struct {
ID string `json:"id"`
State State `json:"state"`
For string `json:"for"`
Bound string `json:"bound"`
H2 string `json:"h2"`
Says string `json:"says"`
}
// Stalled is the `stalled` verb: what the controller's self-check raises as `delivery.<id>.stalled`.
func (h *Holder) Stalled() []StalledLine {
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
var out []StalledLine
for _, d := range h.deliveries {
if !pastBound(d, now) {
continue
}
b := Bounds[d.State]
h2 := "none: the state is the operator's"
switch {
case d.State == Proposed && d.CheckAsk == nil:
h2 = "none: its check could not be asked yet — the controller does not answer delivery-check"
case d.State == Proposed && d.CheckAsk.Reasked > 0:
h2 = "none: its check was re-asked once already and still not answered — the operator's"
case d.State == Proposed:
h2 = "close, by re-ask: its check asked once more"
case len(b.H2) > 0:
var evs []string
for _, e := range b.H2 {
evs = append(evs, string(e))
}
h2 = "close, by " + strings.Join(evs, " or ") + ", when the walk's record says so"
}
out = append(out, StalledLine{ID: d.ID, State: d.State, For: now.Sub(boundSince(d)).Round(time.Second).String(),
Bound: b.For.String(), H2: h2, Says: b.Says})
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out
}
// Recheck is the `recheck` verb: a rejected or ready delivery proposed again, and its check asked again.
//
// A recheck asks of the head the forge already judged, and the forge keeps the newest status of each
// context on it: left alone, the old verdict stands, and a branch that requires it lets the pull request merge
// on it while the fresh check runs. So the merge check's statuses are put back to pending first — by the
// forge's holder, which keeps them — and only then is the check asked. A forge that cannot be told refuses the
// recheck whole: the delivery stays where it stood, and nothing is asked on a stale verdict.
func (h *Holder) Recheck(id, why, by string) (string, error) {
h.mu.Lock()
d := h.deliveries[id]
if d == nil {
h.mu.Unlock()
return "", fmt.Errorf("no delivery %q", id)
}
// Whether the table takes it, tried on a copy: nothing is said on the forge for a recheck it refuses.
probe := *d
probe.Transitions = append([]Transition(nil), d.Transitions...)
if _, err := Apply(&probe, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by}); err != nil {
h.mu.Unlock()
return "", err
}
owner, repo, commit, page := d.Owner(), d.Repo(), d.Commit, d.HTMLURL
h.mu.Unlock()
if err := h.checkingAgain(owner, repo, commit, page, why); err != nil {
return "", fmt.Errorf("%s is not rechecked: %v; its merge check must say pending before it is asked again, "+
"so nothing merges on the old verdict — ask again once the forge answers", id, err)
}
h.mu.Lock()
t, err := Apply(d, EvRecheck, true, Facts{Now: h.Now(), Why: why, By: by})
if err != nil {
h.mu.Unlock()
return "", fmt.Errorf("%v (its merge check says pending; the forge's next announcement asks it)", err)
}
d.Check = nil
h.moved(d, []Transition{t})
m := MemberOf(d)
h.mu.Unlock()
asked, err := h.Controller.Check("", []Member{m})
if err != nil {
return "", fmt.Errorf("%s is proposed again and its check could not be asked yet (%v); the forge's next "+
"announcement asks it", id, err)
}
return fmt.Sprintf("%s is proposed again, its merge check pending; its check was asked (%s)", id, asked), nil
}
// checkingAgain puts a head's merge check statuses — those a branch's protection may require — back to
// pending, so the forge refuses the merge until the fresh verdict sets them. The gate always, since every
// verdict sets it; the repository's own check only when the head holds one, since a verdict that says none
// would leave a pending one standing for ever. Asked of the forge's holder, which keeps them; it takes
// pending on them and nothing else, a verdict being the controller's alone (novox/hq issue 293).
func (h *Holder) checkingAgain(owner, repo, commit, page, why string) error {
if h.Forge == nil {
return errors.New("no forge")
}
contexts := []string{ctxGate, ctxRepo}
if held, err := h.Forge.Statuses(owner, repo, 0, commit, ""); err == nil {
contexts = []string{ctxGate}
for _, s := range held.Statuses {
if s.Context == ctxRepo {
contexts = append(contexts, ctxRepo)
}
}
}
line := clip(oneLine("checking again: "+why), 140)
for _, c := range contexts {
if err := h.Forge.Status(owner, repo, commit, c, "pending", line, page); err != nil {
return fmt.Errorf("the forge did not set %s pending on %s: %w", c, shortOf(commit, 8), err)
}
}
return nil
}
// Release is the `release` verb: a held delivery goes on, on a person's word.
func (h *Holder) Release(id, why, by string) (string, error) {
h.mu.Lock()
d := h.deliveries[id]
if d == nil {
h.mu.Unlock()
return "", fmt.Errorf("no delivery %q", id)
}
t, err := Apply(d, EvRelease, true, Facts{Now: h.Now(), Why: why, By: by})
if err != nil {
h.mu.Unlock()
return "", err
}
d.HeldWhy = ""
h.moved(d, []Transition{t})
var walk string
if d.Walk != nil && d.Walk.Waits {
walk = d.Walk.ID
}
h.mu.Unlock()
if walk == "" {
return fmt.Sprintf("%s is delivering, on %s's word", id, by), nil
}
if err := h.Controller.Deliver(walk, "released by "+by+": "+why); err != nil {
return "", fmt.Errorf("%s is delivering on %s's word, and its walk %s could not be let go yet (%v): the next "+
"tick asks again", id, by, walk, err)
}
h.mu.Lock()
if d := h.deliveries[id]; d != nil {
d.LetGo, d.LetGoWhy = h.Now(), "released by "+by
if d.Walk != nil && d.Walk.ID == walk {
d.Walk.Waits, d.Walk.GoBy = false, byOwner
}
h.keep(d)
}
h.mu.Unlock()
return fmt.Sprintf("%s is delivering on %s's word; its walk %s is let go", id, by, walk), nil
}
// Stop is the `stop` verb: a delivery stopped, its walk ended through the controller, and in a group every
// member after it stopped too.
func (h *Holder) Stop(id, why, by string) (string, error) {
h.mu.Lock()
d := h.deliveries[id]
if d == nil {
h.mu.Unlock()
return "", fmt.Errorf("no delivery %q", id)
}
t, err := Apply(d, EvStop, true, Facts{Now: h.Now(), Why: why, By: by})
if err != nil {
h.mu.Unlock()
return "", err
}
h.moved(d, []Transition{t})
var walk string
if d.Walk != nil && (d.Walk.State == walkBuilding || d.Walk.State == walkRolling) {
walk = d.Walk.ID
}
var asks []func()
if g := h.groups[d.Group]; g != nil && g.Closed {
asks = h.groupTurns(g, h.membersOf(g))
} else if g != nil {
h.leaveGroup(d)
}
h.mu.Unlock()
for _, a := range asks {
a()
}
if walk == "" {
return fmt.Sprintf("%s is stopped, on %s's word", id, by), nil
}
if err := h.Controller.Stop(walk, why, by); err != nil {
return "", fmt.Errorf("%s is stopped, and its walk %s could not be ended yet: %v — `plans stop %s --why …` "+
"ends it by hand", id, walk, err, walk)
}
return fmt.Sprintf("%s is stopped, on %s's word; its walk %s is ended", id, by, walk), nil
}
// Close is healer H2's verb: for a delivery past its bound, the walk's record is read again and the
// delivery settled; only a transition the table names for that state counts, any other answer is refused.
func (h *Holder) Close(id, why string) (string, error) {
h.mu.Lock()
d := h.deliveries[id]
if d == nil {
h.mu.Unlock()
return "", fmt.Errorf("no delivery %q", id)
}
b, bounded := Bounds[d.State]
if !bounded || len(b.H2) == 0 {
h.mu.Unlock()
return "", fmt.Errorf("%s is %s: H2 has no transition there — the state is the operator's", id, d.State)
}
if !pastBound(d, h.Now()) {
h.mu.Unlock()
return "", fmt.Errorf("%s has been %s for %s, within its bound of %s: nothing to close", id, d.State,
h.Now().Sub(boundSince(d)).Round(time.Second), b.For)
}
if d.State == Proposed {
return h.reask(d, why) // unlocks
}
if d.Walk == nil {
h.mu.Unlock()
return "", fmt.Errorf("%s has no walk on record: H2 has nothing to read", id)
}
walk, was := d.Walk.ID, d.State
h.mu.Unlock()
_, walks, err := h.Controller.Walks(walk)
if err != nil {
return "", fmt.Errorf("the walk %s cannot be read: %w", walk, err)
}
for _, w := range walks {
h.WalkMoved(w)
}
h.mu.Lock()
defer h.mu.Unlock()
d = h.deliveries[id]
if d.State == was {
return "", errors.New(id + " is still " + string(was) + ": its walk's record does not say it moved — the condition is the operator's")
}
return fmt.Sprintf("%s: %s → %s, as its walk's record says (%s)", id, was, d.State, why), nil
}
// reask is H2's close of a proposed delivery past its bound: its check, asked and not answered, asked once
// more — the table's re-ask, which holds only once. The row is tried first on a copy, so an ask the controller
// does not take spends nothing. Called under the lock; it unlocks.
func (h *Holder) reask(d *Delivery, why string) (string, error) {
id := d.ID
trial := *d
trial.Transitions = nil
if _, err := Apply(&trial, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why}); err != nil {
h.mu.Unlock()
return "", err
}
m := MemberOf(d)
h.mu.Unlock()
asked, err := h.Controller.Check("", []Member{m})
if err != nil {
return "", fmt.Errorf("%s's check could not be re-asked yet: %w", id, err)
}
h.mu.Lock()
defer h.mu.Unlock()
d = h.deliveries[id]
if d == nil {
return "", fmt.Errorf("no delivery %q", id)
}
t, err := Apply(d, EvReask, true, Facts{Now: h.Now(), By: byH2, Why: why + " — re-asked as " + asked})
if err != nil {
return "", err
}
d.CheckAsk.Reasked++
d.CheckAsk.ID, d.CheckAsk.At, d.CheckAsk.By, d.CheckAsk.Why = asked, h.Now(), byH2, why
h.moved(d, []Transition{t})
return fmt.Sprintf("%s: its check re-asked as %s, once (%s); if that is not answered either, the operator's", id,
asked, why), nil
}
// WhatIf is the `what-if` verb.
func (h *Holder) WhatIf(repository, base string, paths []string) (*DeliveryPlan, error) {
if base == "" {
base = "main"
}
return h.Controller.Plan(repository, base, "", paths, nil, nil)
}
// Status is the module's own `delivery_status`.
func (h *Holder) Status(listening string) map[string]any {
h.mu.Lock()
defer h.mu.Unlock()
count := map[State]int{}
owed := 0
for _, d := range h.deliveries {
count[d.State]++
owed += len(d.Owed)
}
for _, g := range h.groups {
owed += len(g.Owed)
}
byState := map[string]int{}
for s, n := range count {
byState[string(s)] = n
}
out := map[string]any{"deliveries": byState, "groups": len(h.groups), "owed": owed, "unkept": len(h.dirty),
"walks-unmatched": len(h.unmatched), "events": listening, "started": h.started.Format(time.RFC3339),
"seat-on-record": h.held}
if !h.reconciled.IsZero() {
out["walks-read"] = h.reconciled.Format(time.RFC3339)
}
if h.unreached != "" {
out["controller-unreached"] = h.unreached
}
if len(h.refusals) > 0 {
from := 0
if len(h.refusals) > 10 {
from = len(h.refusals) - 10
}
out["refused"] = h.refusals[from:]
}
if len(h.backlog) > 0 {
var ids []string
for _, w := range h.backlog {
ids = append(ids, w.ID+" "+w.State)
}
out["backlog-walks"] = ids
}
return out
}
// RetiredAnswer is what `retire-history` answers.
type RetiredAnswer struct {
Retired int `json:"retired"`
ByRepository map[string]int `json:"by-repository,omitempty"`
Dry bool `json:"dry,omitempty"`
// LeftHeld are the held deliveries the rule does not take, with why — a person's still.
LeftHeld int `json:"left-held"`
Left []string `json:"left,omitempty"`
Says string `json:"says"`
}
// RetireHistory is the `retire-history` verb (novox/hq issue 313): held deliveries that are history — made from
// the forge's word for a merge on the trunk heard long after it happened, with no walk of theirs open — retired
// on a person's word, never released or delivered. One by its id, refused when the rule does not take it; or
// every held one, of one repository when named, each the rule does not take left held and named. Dry answers
// what it would do and changes nothing.
func (h *Holder) RetireHistory(id, repository, why, by string, dry bool) (*RetiredAnswer, error) {
if why == "" {
return nil, errors.New("a retire says why")
}
h.mu.Lock()
defer h.mu.Unlock()
now := h.Now()
var held []*Delivery
if id != "" {
d := h.deliveries[id]
if d == nil {
return nil, fmt.Errorf("no delivery %q", id)
}
held = []*Delivery{d}
} else {
for _, d := range h.deliveries {
if d.State == Held && (repository == "" || strings.EqualFold(d.Repository, repository)) {
held = append(held, d)
}
}
sort.Slice(held, func(i, j int) bool { return held[i].ID < held[j].ID })
}
out := &RetiredAnswer{ByRepository: map[string]int{}, Dry: dry}
for _, d := range held {
// Tried on a copy first: a refused retire changes nothing, and a dry one nothing either.
probe := *d
probe.Transitions = append([]Transition(nil), d.Transitions...)
f := Facts{Now: now, By: by, Why: why}
if _, err := Apply(&probe, EvRetire, true, f); err != nil {
if id != "" {
return nil, err
}
out.LeftHeld++
if len(out.Left) < 25 {
out.Left = append(out.Left, err.Error())
}
continue
}
out.Retired++
out.ByRepository[d.Repository]++
if dry {
continue
}
t, _ := Apply(d, EvRetire, true, f)
d.History = "retired by " + by + ": " + why
d.HeldWhy = ""
// Its pull request is not told: the forge may no longer hold it, or holds another under its number.
d.Owed = withoutKinds(d.Owed, EffectView, EffectStatus)
h.moved(d, []Transition{t})
}
verb := "retired"
if dry {
verb = "would retire"
}
out.Says = fmt.Sprintf("%s %d held delivery(ies) as history; %d held left for a person", verb, out.Retired, out.LeftHeld)
h.Logf("[mesh-delivery] retire-history (%s): %s", why, out.Says)
return out, nil
}
// withoutKinds is an owed list without the effects of the kinds named.
func withoutKinds(owed []Effect, kinds ...string) []Effect {
var out []Effect
for _, e := range owed {
if !containsString(kinds, e.Kind) {
out = append(out, e)
}
}
return out
}