Files
mesh-delivery/modules/mesh-delivery-view/internal/view/holds.go
T
jschoubben 2fe8d01e86 Join item 1: the rewrite map, asks/asker, the vault's types and compose-candidate to mesh-contracts
mesh-sdk holds no go/asks, go/asker, vault answers or compose-candidate types (A1d, J1): each module now names the
operator-channel contract and client, the channel benches (mesh-sdk/go/channel), mesh-vault's contract, and node-engine's
contract and client (a1-progress.md, the A1d and J1 rewrite maps).
- assigner, teller, view: delivery.Ask.Ask and AssignerAsk.Ask are the router's ask as JSON; the refusals mesh-sdk
  dropped (an id other than AskingID(key, asking), no option) are each reader's and writer's: assign.EncodeAsk/DecodeAsk
  (askjson.go), the teller's routerAskOf (teller.go) and the view's routerAsk (view.go); the teller refuses an option
  the asking does not offer and one whose level is not acknowledge (viewanswers.go), the two refusals ViewAnswerFits
  gave. Row: subtest of TestTheHoldAskIsValid (another id, no option, no ask; each written before).
- enrolment, grants: the vault's verbs and answers from meshvault; asks' conditions from the contract and client.
- checker, assigner's judge: compose-candidate from nodeengine/nodeengineclient (nec.Own for "node").
- mesh-grants' user-lists golden: the node-engine's users now subscribe to mesh-delivery-view's settled (mesh-sdk's
  NodeAgentHears, A7's ENG-8 retry).
go.mod: each module requires the mesh-contracts modules it imports (and nodeengineclient, which mesh-sdk imports) at the
zero version, for the group pin.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 15:05:12 +01:00

189 lines
6.8 KiB
Go

package view
import (
"encoding/json"
"errors"
"fmt"
"net"
"slices"
"sort"
"strings"
"time"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The assigner's holds, listed and answered from the operator's command (issue 1906 note 23). A hold ask answered once (on the
// phone or in the view) ended with that answer, so nothing listed it any more and an answer "keep" could not be changed: the
// operator kept systemd-resolved held and then could not release it, and a kept hold is never lifted by itself. Now the view
// lists every hold with its node, its cause and its answer, and answers the hold ask that stands, again if the operator changes
// their mind, through the view's one write (`<ask key>.view`), which the teller takes into the ask's answer. Nothing else writes:
// the answer still reaches the assigner only through the teller (mesh-delivery-teller viewanswers.go).
// The options of the hold ask (mesh-delivery-assigner internal/assign asks.go HoldAsk).
const (
HoldRelease = "release"
HoldKeep = "keep"
)
type heldEntry struct {
module, fp string
value delivery.Held
err string
at time.Time
}
// applyHeld takes a change of a held key of the assigner's wants, and says whether the key was one.
func (m *Model) applyHeld(e Entry) bool {
module, fp, err := delivery.ParseHeldKey(e.Key)
if err != nil {
return false
}
if e.Deleted {
delete(m.holds, e.Key)
return true
}
h, err := delivery.DecodeHeld(e.Value)
m.holds[e.Key] = heldEntry{module: module, fp: fp, value: h, err: errText(err), at: e.At}
return true
}
// HoldRow is one fingerprint the assigner holds: the module, the fingerprint, the node whose health check failed, the hold's
// words (its cause) and since when; the ask about it (its key and the asking's id, empty when the assigner asks none), and the
// answer that stands for that asking (the option and its words, empty when none), with the view's own answer when it waits for
// the teller.
type HoldRow struct {
Module string `json:"module"`
FP string `json:"fp"`
Node string `json:"node,omitempty"`
Words string `json:"words,omitempty"`
Since time.Time `json:"since,omitempty"`
AskKey string `json:"ask-key,omitempty"`
Asking string `json:"asking,omitempty"`
Answer string `json:"answer,omitempty"`
Answered string `json:"answered,omitempty"`
Pending string `json:"pending,omitempty"`
Error string `json:"error,omitempty"`
}
// Holds is every hold, ordered by module and fingerprint.
func (m *Model) Holds() []HoldRow {
m.mu.Lock()
defer m.mu.Unlock()
out := []HoldRow{}
for _, h := range m.holds {
row := HoldRow{Module: h.module, FP: h.fp, Node: h.value.Node, Words: h.value.Words, Since: h.at, Error: h.err}
if key, err := delivery.HoldAskKey(h.module, h.fp); err == nil {
if a, ok := m.aasks[key]; ok && a.err == "" {
row.AskKey, row.Asking = key, a.ask.ID
if ans, ok := m.answerOf[key]; ok && m.answers[key] == a.ask.ID {
row.Answer, row.Answered = ans.Option, ans.Words
}
if c := m.chosenOf(key, a.ask); c != "" && c != row.Answer {
row.Pending = c
}
}
}
out = append(out, row)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Module != out[j].Module {
return out[i].Module < out[j].Module
}
return out[i].FP < out[j].FP
})
return out
}
// AnswerHold writes the view's answer to the hold ask of module at the fingerprint fp: release or keep, with the operator's
// words. fp is the whole fingerprint, or the start of its hex (as the rows print it), naming one hold of the module. Unlike
// Answer it answers an ask answered already, so the operator can change their mind; an answer equal to the one that stands is
// refused, as is a hold the assigner asks nothing about.
func (m *Model) AnswerHold(module, fp, option, words string) (HoldRow, error) {
option = strings.TrimSpace(option)
if option != HoldRelease && option != HoldKeep {
return HoldRow{}, fmt.Errorf("a hold is answered %s or %s, not %q", HoldRelease, HoldKeep, option)
}
var match []HoldRow
want := strings.TrimPrefix(strings.TrimSpace(fp), delivery.SourcePrefix2)
for _, r := range m.Holds() {
if r.Module == module && want != "" && strings.HasPrefix(strings.TrimPrefix(r.FP, delivery.SourcePrefix2), want) {
match = append(match, r)
}
}
switch {
case len(match) == 0:
return HoldRow{}, fmt.Errorf("the assigner holds no fingerprint %q of %s (mesh-delivery-cli holds lists them)", fp, module)
case len(match) > 1:
return HoldRow{}, fmt.Errorf("%q names %d holds of %s: give more of the fingerprint", fp, len(match), module)
}
r := match[0]
if r.AskKey == "" {
return r, fmt.Errorf("the assigner asks nothing about the hold of %s at %s, so there is nothing to answer", module, r.FP)
}
if r.Answer == option && r.Pending == "" {
return r, fmt.Errorf("the hold of %s at %s is answered %s already", module, r.FP, option)
}
m.mu.Lock()
a, w := m.aasks[r.AskKey], m.writer
m.mu.Unlock()
if !slices.ContainsFunc(a.ask.Options, func(o oc.Option) bool { return o.ID == option }) {
return r, fmt.Errorf("the hold ask %s offers no option %q", r.AskKey, option)
}
if w == nil {
return r, fmt.Errorf("the view cannot write its answers here")
}
key, err := delivery.ViewAnswerKey(r.AskKey)
if err != nil {
return r, err
}
body, err := delivery.EncodeViewAnswer(delivery.ViewAnswer{Asking: a.ask.ID, Option: option, Words: strings.TrimSpace(words)})
if err != nil {
return r, err
}
if err := w.Put(key, body); err != nil {
return r, err
}
return r, nil
}
// askOn sends one ask to the socket at path and decodes the reply into out; the view's refusal is the error.
func askOn(path string, req ask, out any) error {
c, err := net.DialTimeout("unix", path, 5*time.Second)
if err != nil {
return fmt.Errorf("cannot reach the view at %s: %w", path, err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(10 * time.Second))
raw, _ := json.Marshal(req)
if _, err := c.Write(append(raw, '\n')); err != nil {
return err
}
var reply json.RawMessage
if err := json.NewDecoder(c).Decode(&reply); err != nil {
return fmt.Errorf("the view's reply cannot be read: %w", err)
}
var refusal struct {
Error string `json:"error"`
}
if json.Unmarshal(reply, &refusal) == nil && refusal.Error != "" {
return errors.New(refusal.Error)
}
return json.Unmarshal(reply, out)
}
// AskHolds asks the socket at path for every hold.
func AskHolds(path string) ([]HoldRow, error) {
var out []HoldRow
err := askOn(path, ask{Ask: "holds"}, &out)
return out, err
}
// AnswerHoldOn asks the socket at path to answer the hold ask of module at fp with option.
func AnswerHoldOn(path, module, fp, option, words string) (HoldRow, error) {
var out HoldRow
err := askOn(path, ask{Ask: "hold", Module: module, FP: fp, Option: option, Words: words}, &out)
return out, err
}