Files
mesh-controller/internal/link/handacts.go
T
jochen e92a3fe237
mesh/merge-gate pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus step; every machine composes with the change as it…
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery delivered
Record a push that only moves recorded builds as the person's word, not a repair (hq issue 301)
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.
2026-10-08 00:12:23 +02:00

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
}