Files
mesh-host/internal/seatwait/held.go
T
2026-11-11 12:12:55 +01:00

118 lines
3.9 KiB
Go

// HeldKeeper keeps the node-engine's finding `module-held`: a module the node leaves out of its pass because its
// claim cannot be held (its seat's definition missing, removed or refused, a claim another holder keeps, a credential the
// vault refuses) is no silent leave-out. The finding names the node, the module and why, and the condition keeper keeps the
// condition (novox/hq ADR 0564 §2) until the module is composed again or is no longer wanted on the node, when the finding
// is cleared (design 47, *A claim problem holds its module, never the node's pass*; test 55).
package seatwait
import (
"fmt"
"sort"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/views"
)
// HeldKind is the finding of a held module; its id is `<module>.<node>`.
const HeldKind = "module-held"
// HeldKeeper writes one finding for each module the node holds, and clears it when the module is no longer held.
type HeldKeeper struct {
Node string
Observer string
Writer views.FindingWriter
// Left lists the findings of this kind an earlier run of the engine left, once, at the first Reconcile: a module held
// then and composed while the engine was away must not keep its condition. Nil leaves none.
Left func() ([]views.Finding, error)
// Now is the clock; nil is time.Now.
Now func() time.Time
// Say tells the node's journal; nil says nothing.
Say func(string)
mu sync.Mutex
swept bool
written map[string]views.Finding // by module
}
// HeldID is the finding's id for a module on a node.
func HeldID(module, node string) string { return module + "." + node }
// Reconcile makes the findings those of held (each module held and why): one is written for a module not yet written or
// held for other words, and one is cleared for a module no longer held, composed or no longer wanted here. A write or a
// clear that fails is said and tried again at the next Reconcile.
func (k *HeldKeeper) Reconcile(held map[string]string) {
k.mu.Lock()
defer k.mu.Unlock()
if k.written == nil {
k.written = map[string]views.Finding{}
}
if !k.swept && k.Left != nil {
left, err := k.Left()
if err != nil {
k.say(fmt.Sprintf("the findings an earlier run left of held modules cannot be read: %v", err))
} else {
k.swept = true
for _, f := range left {
module := strings.TrimSuffix(f.ID, "."+k.Node)
if _, ok := k.written[module]; !ok {
k.written[module] = f
}
}
}
} else if k.Left == nil {
k.swept = true
}
names := make([]string, 0, len(k.written))
for m := range k.written {
names = append(names, m)
}
sort.Strings(names)
for _, m := range names {
if _, still := held[m]; still {
continue
}
if err := k.Writer.Delete(k.written[m]); err != nil {
k.say(fmt.Sprintf("the finding that %s is held on %s cannot be cleared: %v", m, k.Node, err))
continue
}
delete(k.written, m)
}
names = names[:0]
for m := range held {
names = append(names, m)
}
sort.Strings(names)
for _, m := range names {
why := held[m]
if was, ok := k.written[m]; ok && was.Summary != "" && strings.HasSuffix(was.Summary, ": "+why) {
continue
}
f := views.Finding{Observer: k.Observer, Kind: HeldKind, ID: HeldID(m, k.Node),
Summary: fmt.Sprintf("%s is held on %s: %s", m, k.Node, why),
Headline: fmt.Sprintf("%s is held on %s", m, k.Node),
Explanation: "The node leaves this module out of its pass, keeps what runs of it and composes and applies every other module. The finding clears when the module is composed again or is no longer wanted on the node.",
At: k.now()}
if err := k.Writer.Put(f); err != nil {
k.say(fmt.Sprintf("the finding that %s is held on %s cannot be written: %v", m, k.Node, err))
continue
}
k.written[m] = f
k.say(f.Summary)
}
}
func (k *HeldKeeper) now() time.Time {
if k.Now != nil {
return k.Now()
}
return time.Now()
}
func (k *HeldKeeper) say(s string) {
if k.Say != nil {
k.Say(s)
}
}