Files
mesh-catalog/modules/messenger/cmd/messenger/condition.go
T
jochen a4b92c1306 messenger: never say history as news — read the open conditions first, coalesce bursts
A consumer made on 2026-10-06 was handed three hours of raised-and-cleared
conditions at once and the holder said each as new: 20 desktop notifications
in a second. What is said is now decided by the controller's open set and by
an event's own time, never by its arrival; bursts are one message, the
desktop gets warnings at most every 15 min, the cap is said once, and the
first minute after start says only the urgent conditions still open.
Replays of that morning's 96 events are tests. Also drops two committed
binaries. (novox/hq issue 271)
2026-10-06 12:03:19 +02:00

326 lines
10 KiB
Go

package main
// The controller's condition events, read in this one place (novox/hq to-be 45 §2).
//
// **The contract this reads, and every assumption it makes about it**, because the controller's
// side was built at the same time from the same design and the design names the events and the
// fields but not their spelling on the wire:
//
// - The events are the mesh-controller seat's own: `condition-raised`, `condition-changed` and
// `condition-cleared`, consumed as `mesh-controller.<event>` (subject
// `mesh.seat.mesh-controller.event.<event>`), exactly as `applied` and `built-before` are.
// - The body is the condition as the store holds it, one JSON object, with the field names of the
// design's table in kebab-case: key, kind, subject, severity, summary, evidence, source, raised,
// last-observed, observations, tried, resolver, silenced, epoch. snake_case and camelCase
// spellings of the two-word names are read too.
// - `subject` is an object {scope, id, machine}; a plain string is read as well.
// - `severity` is `urgent` or `warning`. Anything else is not guessed: the event is unreadable.
// - `silenced` is absent, null, or an object {until, by, why}; `until` in the past is not silenced.
// - Times are RFC 3339.
// - `key` is `<scope>.<id>.<kind>`. When it is absent it is made from subject and kind; when
// neither gives one the event is unreadable.
// - A cleared event carries the condition as last held, and may add `cleared` (its time).
// - Every event carries `at`, when the transition happened (the controller's conditions.Event).
// It is how this holder tells news from history: a stream replays what it holds to a consumer
// that is new or was away, and an event's delivery time says nothing about when it happened.
// - The `conditions` verb of the controller's seat answers `{conditions: [...], open, note}`, each
// entry the condition as the store holds it — the same shape as an event's body.
//
// An event this cannot read is refused by name — which event, which field, why — counted, said in
// the status and in the log, and told to the operator; never read as an empty condition.
import (
"encoding/json"
"fmt"
"strings"
"time"
)
// The events, by their local names under the controller's seat.
const (
EventRaised = "condition-raised"
EventChanged = "condition-changed"
EventCleared = "condition-cleared"
// ControllerSeat is the role the events are stated under.
ControllerSeat = "mesh-controller"
)
// Severities: two levels, no more (to-be 45 §2).
const (
Urgent = "urgent"
Warning = "warning"
)
// Condition is what this holder needs of one.
type Condition struct {
Key string
Kind string
Scope string
ID string
Machine string
Severity string
Summary string
Source string
Resolver string
Raised time.Time
LastObserved time.Time
Observations int
SilencedTill time.Time
SilencedWhy string
Cleared time.Time
// At is when the transition the event says happened; zero for a condition read from the open set.
At time.Time
}
// When is the event's own time: `at`; without it, the clearing's or the raising's time; without
// those, zero — and a zero time is read as now by the holder, never as old.
func (c Condition) When(event string) time.Time {
switch {
case !c.At.IsZero():
return c.At
case event == EventCleared:
return c.Cleared
case event == EventRaised:
return c.Raised
}
return time.Time{}
}
// SubjectWords is the subject as a person reads it: "machine ace", "plan 41", "provider keycloak".
func (c Condition) SubjectWords() string {
parts := []string{}
if c.Scope != "" {
parts = append(parts, c.Scope)
}
if c.ID != "" {
parts = append(parts, c.ID)
}
if c.Machine != "" && c.Machine != c.ID {
parts = append(parts, "on "+c.Machine)
}
return strings.Join(parts, " ")
}
// SilencedAt says whether the condition's messages are stopped at that moment.
func (c Condition) SilencedAt(now time.Time) bool {
return !c.SilencedTill.IsZero() && now.Before(c.SilencedTill)
}
// eventOf is the local event name of an envelope's key: `mesh-controller.condition-raised` is
// `condition-raised`. Anything not of the controller's seat is not a condition event.
func eventOf(key string) (string, bool) {
emitter, event, ok := strings.Cut(key, ".")
if !ok || emitter != ControllerSeat {
return "", false
}
switch event {
case EventRaised, EventChanged, EventCleared:
return event, true
}
return "", false
}
// DecodeCondition reads one condition event's body.
func DecodeCondition(event string, body []byte) (Condition, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return Condition{}, fmt.Errorf("%s: the body is not a JSON object: %v", event, err)
}
if raw == nil {
return Condition{}, fmt.Errorf("%s: the body is null", event)
}
var c Condition
var err error
str := func(names ...string) string {
if err != nil {
return ""
}
for _, n := range names {
v, ok := raw[n]
if !ok || string(v) == "null" {
continue
}
var s string
if e := json.Unmarshal(v, &s); e != nil {
err = fmt.Errorf("%s: %s is not a string", event, n)
return ""
}
return strings.TrimSpace(s)
}
return ""
}
when := func(names ...string) time.Time {
s := str(names...)
if s == "" || err != nil {
return time.Time{}
}
t, e := time.Parse(time.RFC3339Nano, s)
if e != nil {
err = fmt.Errorf("%s: %s is not an RFC 3339 time: %q", event, names[0], s)
}
return t
}
c.Key = str("key")
c.Kind = str("kind")
c.Severity = str("severity")
c.Summary = str("summary")
c.Resolver = str("resolver")
c.Raised = when("raised")
c.LastObserved = when("last-observed", "last_observed", "lastObserved")
c.Cleared = when("cleared")
c.At = when("at")
if err != nil {
return Condition{}, err
}
// source: a string, or an object naming the row, probe or event.
if v, ok := raw["source"]; ok && string(v) != "null" {
var s string
if json.Unmarshal(v, &s) == nil {
c.Source = s
} else {
var o map[string]any
if json.Unmarshal(v, &o) == nil {
for _, k := range []string{"row", "probe", "event", "name", "id"} {
if s, ok := o[k].(string); ok && s != "" {
c.Source = s
break
}
}
}
}
}
for _, n := range []string{"observations", "count"} {
if v, ok := raw[n]; ok && string(v) != "null" {
var f float64
if json.Unmarshal(v, &f) != nil {
return Condition{}, fmt.Errorf("%s: %s is not a number", event, n)
}
c.Observations = int(f)
break
}
}
if v, ok := raw["subject"]; ok && string(v) != "null" {
var s string
if json.Unmarshal(v, &s) == nil {
c.ID = strings.TrimSpace(s)
} else {
var o struct {
Scope string `json:"scope"`
ID string `json:"id"`
Machine string `json:"machine"`
Node string `json:"node"`
}
if e := json.Unmarshal(v, &o); e != nil {
return Condition{}, fmt.Errorf("%s: subject is neither a string nor {scope, id, machine}", event)
}
c.Scope, c.ID, c.Machine = o.Scope, o.ID, o.Machine
if c.Machine == "" {
c.Machine = o.Node
}
}
}
if v, ok := raw["silenced"]; ok && string(v) != "null" && string(v) != "{}" && string(v) != `""` && string(v) != "false" {
var o struct {
Until string `json:"until"`
Why string `json:"why"`
}
if e := json.Unmarshal(v, &o); e != nil {
return Condition{}, fmt.Errorf("%s: silenced is not {until, by, why}", event)
}
if o.Until != "" {
t, e := time.Parse(time.RFC3339Nano, o.Until)
if e != nil {
return Condition{}, fmt.Errorf("%s: silenced.until is not an RFC 3339 time: %q", event, o.Until)
}
c.SilencedTill = t
}
c.SilencedWhy = o.Why
}
if c.Key == "" && c.Scope != "" && c.ID != "" && c.Kind != "" {
c.Key = c.Scope + "." + c.ID + "." + c.Kind
}
if c.Key == "" {
return Condition{}, fmt.Errorf("%s: no key, and no subject and kind to make one from", event)
}
if c.Scope == "" || c.ID == "" || c.Kind == "" {
// The key names them (`<scope>.<id>.<kind>`, the id itself possibly dotted).
parts := strings.Split(c.Key, ".")
if len(parts) >= 3 {
if c.Scope == "" {
c.Scope = parts[0]
}
if c.Kind == "" {
c.Kind = parts[len(parts)-1]
}
if c.ID == "" {
c.ID = strings.Join(parts[1:len(parts)-1], ".")
}
}
}
switch c.Severity {
case Urgent, Warning:
case "":
if event != EventCleared {
return Condition{}, fmt.Errorf("%s %s: no severity", event, c.Key)
}
default:
return Condition{}, fmt.Errorf("%s %s: severity %q is neither urgent nor warning", event, c.Key, c.Severity)
}
if c.Summary == "" && event != EventCleared {
return Condition{}, fmt.Errorf("%s %s: no summary", event, c.Key)
}
return c, nil
}
// DecodeOpen reads the controller's `conditions` answer — what is open now — bare, inside the
// runtime's `{answer}` or `{output}`, inside a tool reply's text content, or as a JSON string. An
// answer without a `conditions` list is refused: an empty open set is said, never assumed.
func DecodeOpen(raw json.RawMessage) ([]Condition, error) {
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
var s string
if json.Unmarshal(raw, &s) == nil {
return DecodeOpen(json.RawMessage(s))
}
return nil, fmt.Errorf("the conditions answer is not JSON")
}
if list, ok := m["conditions"]; ok {
var items []json.RawMessage
if string(list) != "null" {
if err := json.Unmarshal(list, &items); err != nil {
return nil, fmt.Errorf("the conditions answer's conditions is not a list")
}
}
out := make([]Condition, 0, len(items))
for _, it := range items {
c, err := DecodeCondition("conditions", it)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
for _, k := range []string{"answer", "output"} {
if v, ok := m[k]; ok && string(v) != "null" {
return DecodeOpen(v)
}
}
if v, ok := m["content"]; ok {
var content []struct {
Text string `json:"text"`
}
var e struct {
IsError bool `json:"isError"`
}
_ = json.Unmarshal(raw, &e)
if json.Unmarshal(v, &content) == nil && len(content) > 0 {
if e.IsError {
return nil, fmt.Errorf("the controller refused: %s", content[0].Text)
}
return DecodeOpen(json.RawMessage(content[0].Text))
}
}
return nil, fmt.Errorf("the conditions answer lists no conditions")
}