A recorded build moves only by a person's push (ADR 0242), so that push is the word its upgrade policy asks for; S15 counted it as a repair and wanted a healer for split-dns, words and uplink-verbs. The push now reads what it carries before it is recorded and says so in its kind, and the ten pushes of 2026-10-07 are named so their three warnings clear on the next tick.
173 lines
5.5 KiB
Go
173 lines
5.5 KiB
Go
package link
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
|
|
"github.com/novox/mesh-controller/internal/broker"
|
|
)
|
|
|
|
// The hand-act log (novox/hq to-be 45 §7).
|
|
//
|
|
// **A repair a person makes by hand is the record of a healer the mesh does not have yet.** Tonight's
|
|
// pushes of one machine, plans closed because a report never came, a consumer re-made because it fell
|
|
// a week behind — each was done, and the only trace was a chat. So every verb that repairs by hand
|
|
// asks why, and writes one entry: who, which verb and arguments, why, when, the condition it
|
|
// addresses if one is named, and a cause — a word that, recorded twice in a fortnight, says a healer
|
|
// is wanted (S15, from Phase 3). `hand-act record` is the same entry for an act done outside the mesh.
|
|
// The controller is the bucket's only writer; its verbs are the way in.
|
|
|
|
// HandAct is one entry.
|
|
type HandAct struct {
|
|
ID string `json:"id"`
|
|
At time.Time `json:"at"`
|
|
// By is who: the bus principal a seat call came from, or the account and machine at a shell.
|
|
By string `json:"by"`
|
|
// Verb and Args are the act as given: `push`, `plans close`, `broker consumer-reset`, or
|
|
// `hand-act record` with what was done outside the mesh.
|
|
Verb string `json:"verb"`
|
|
Args []string `json:"arguments,omitempty"`
|
|
Why string `json:"why"`
|
|
// Cause is the condition kind, or a word the person gives; the verb's own name when neither.
|
|
Cause string `json:"cause"`
|
|
// Condition is the condition's key the act addresses, when it names one.
|
|
Condition string `json:"condition,omitempty"`
|
|
// Kind is what the controller read the act to be from what it did, never a word a person gives:
|
|
// KindRecordedBuilds for a push that carried only builds a `record` policy held back for a person's
|
|
// word (novox/hq ADR 0242), empty for everything else. Absent from entries written before it existed.
|
|
Kind string `json:"kind,omitempty"`
|
|
// Carried is what such a push moved, one "module from → to" per module, so the log says it.
|
|
Carried []string `json:"carried,omitempty"`
|
|
}
|
|
|
|
// KindRecordedBuilds is a push that only moved recorded builds: the person's word their `record` policy
|
|
// asks for, which is no repair (novox/hq ADR 0242, to-be 45 §7).
|
|
const KindRecordedBuilds = "recorded-builds"
|
|
|
|
// CallerVar carries a seat call's caller to the command the controller runs for it, so an act done
|
|
// through the console says who asked rather than "the controller".
|
|
const CallerVar = "MESH_CALLER"
|
|
|
|
// Caller is who is acting in this process: the seat call's caller when the controller ran it for
|
|
// one, otherwise the account and machine at the shell.
|
|
func Caller() string {
|
|
if c := strings.TrimSpace(os.Getenv(CallerVar)); c != "" {
|
|
return c
|
|
}
|
|
user := os.Getenv("USER")
|
|
if user == "" {
|
|
user = "an unnamed account"
|
|
}
|
|
host, _ := os.Hostname()
|
|
return fmt.Sprintf("%s at a shell on %s", user, host)
|
|
}
|
|
|
|
type callerKey struct{}
|
|
|
|
// CallerIn is the caller of the seat call ctx belongs to, empty outside one.
|
|
func CallerIn(ctx context.Context) string {
|
|
c, _ := ctx.Value(callerKey{}).(string)
|
|
return c
|
|
}
|
|
|
|
var handActSeq atomic.Uint64
|
|
|
|
// RecordHandAct writes one entry. Its key is its time and a sequence, so the bucket lists in order.
|
|
func RecordHandAct(ctx context.Context, conn *nats.Conn, act HandAct) (HandAct, error) {
|
|
if strings.TrimSpace(act.Why) == "" {
|
|
return act, errors.New("an act by hand says why: --why <text>")
|
|
}
|
|
if act.At.IsZero() {
|
|
act.At = time.Now().UTC()
|
|
}
|
|
if act.ID == "" {
|
|
act.ID = "act-" + strconv.FormatInt(act.At.UnixNano(), 10) + "-" + strconv.FormatUint(handActSeq.Add(1), 10)
|
|
}
|
|
if act.By == "" {
|
|
act.By = Caller()
|
|
}
|
|
if act.Cause == "" {
|
|
act.Cause = act.Verb
|
|
}
|
|
kv, err := handActs(ctx, conn)
|
|
if err != nil {
|
|
return act, err
|
|
}
|
|
body, err := json.Marshal(act)
|
|
if err != nil {
|
|
return act, err
|
|
}
|
|
_, err = kv.Put(ctx, act.ID, body)
|
|
return act, err
|
|
}
|
|
|
|
// HandActs is every entry since a moment, oldest first.
|
|
func HandActs(ctx context.Context, conn *nats.Conn, since time.Time) ([]HandAct, error) {
|
|
kv, err := handActs(ctx, conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
w, err := kv.WatchAll(ctx, jetstream.IgnoreDeletes())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = w.Stop() }()
|
|
var out []HandAct
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fmt.Errorf("reading the hand-act log: %w", ctx.Err())
|
|
case entry := <-w.Updates():
|
|
if entry == nil {
|
|
sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) })
|
|
return out, nil
|
|
}
|
|
var a HandAct
|
|
if json.Unmarshal(entry.Value(), &a) == nil && !a.At.Before(since) {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// RepeatedCauses are the causes recorded more than once within the fortnight before now, with how
|
|
// often: each is a repair done by hand again, which is what S15 will raise as a healer wanted.
|
|
func RepeatedCauses(acts []HandAct, now time.Time) map[string]int {
|
|
counts := map[string]int{}
|
|
for _, a := range acts {
|
|
if now.Sub(a.At) <= 14*24*time.Hour {
|
|
counts[a.Cause]++
|
|
}
|
|
}
|
|
for c, n := range counts {
|
|
if n < 2 {
|
|
delete(counts, c)
|
|
}
|
|
}
|
|
return counts
|
|
}
|
|
|
|
func handActs(ctx context.Context, conn *nats.Conn) (jetstream.KeyValue, error) {
|
|
api, err := jetstream.New(conn)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
kv, err := api.KeyValue(ctx, broker.HandActsBucket)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the hand-act log %s is not on the bus — the controller asserts it at its "+
|
|
"start, so one older than this has not: %w", broker.HandActsBucket, err)
|
|
}
|
|
return kv, nil
|
|
}
|