controller v0.264.0: the household is told when an update is undone or held, in its language
gates / gates (push) Successful in 25s

app_update_undone / app_update_held events (09 decision 15), on by
default and seeded once on existing boxes; R-606 update sentences as
key+args rendered per reader; R-646 startup applied-meta backfill for
apps current with the catalog; R-620 a disabled notifier WARNs once per
event type. Needs hub v0.120.0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-23 13:51:21 +02:00
parent c3a2aba0d2
commit bc278944a3
27 changed files with 1086 additions and 86 deletions
+74 -1
View File
@@ -41,7 +41,8 @@ type Notifier struct {
settings *settings.Settings
mu sync.Mutex
prevHealthStatus string // tracks previous health check status for change detection
prevHealthStatus string // tracks previous health check status for change detection
droppedSeen map[string]bool // R-620: event types a DISABLED notifier has already said it dropped
// oomSeen (R-514) remembers container runs already reported as OOM-killed.
oomSeen map[string]bool
@@ -267,6 +268,7 @@ func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer s
return
}
if !n.enabled {
n.dropped(eventType, severity)
return
}
@@ -357,6 +359,7 @@ func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer s
// Detects both degradation (ok→warn, ok→fail, warn→fail) and recovery (fail→ok, warn→ok, fail→warn).
func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string) {
if !n.enabled {
n.dropped("health_change", status)
return
}
@@ -1056,6 +1059,7 @@ type notifyRequest struct {
// No local cooldown — Hub handles cooldowns.
func (n *Notifier) Notify(eventType, severity, message, details string) {
if !n.enabled {
n.dropped(eventType, severity)
return
}
@@ -1138,3 +1142,72 @@ func (n *Notifier) NotifyWholeGuestBackupRecovered(tier, message string) {
n.PushEvent("whole_guest_backup_recovered", "info", message,
WholeGuestBackupDetails{Tier: tier})
}
// dropped is R-620: a DISABLED notifier (no hub configured) says what it drops. Once per event type per
// process at WARN — naming the type and the severity — and DEBUG for every repeat, so a box whose hub
// configuration is absent or broken leaves a greppable trace where the alarm vanished, instead of one
// INFO line at its last start. Measured 2026-09-21 on guest 9202: an update night's whole event half
// was unmeasurable because every event vanished without a word.
func (n *Notifier) dropped(eventType, severity string) {
n.mu.Lock()
if n.droppedSeen == nil {
n.droppedSeen = map[string]bool{}
}
first := !n.droppedSeen[eventType]
n.droppedSeen[eventType] = true
n.mu.Unlock()
if n.logger == nil {
return
}
if first {
n.logger.Printf("[WARN] notifier disabled (no hub configured): DROPPED event %s (severity %s) — further %s events are logged at DEBUG only", eventType, severity, eventType)
return
}
if n.debug {
n.logger.Printf("[DEBUG] notifier disabled: dropped event %s (severity %s)", eventType, severity)
}
}
// AppUpdateDetails is the payload of app_update_undone / app_update_held (v0.264.0). `stack_name` is
// what the hub's per-app cooldown keys on (R-389's register), so two apps on one night are two mails.
type AppUpdateDetails struct {
App string `json:"app"`
StackName string `json:"stack_name"`
From map[string]string `json:"from,omitempty"`
To map[string]string `json:"to,omitempty"`
At string `json:"at"`
CopyTier int `json:"copy_tier,omitempty"`
CopyDate string `json:"copy_date,omitempty"`
CopyHolds string `json:"copy_holds,omitempty"`
}
// budapestMinute renders a time as the household reads it everywhere else (the hold sentence, the
// page): Europe/Budapest, minute precision.
func budapestMinute(t time.Time) string {
if loc, err := time.LoadLocation("Europe/Budapest"); err == nil {
t = t.In(loc)
}
return t.Format("2006-01-02 15:04")
}
// NotifyAppUpdateUndone (v0.264.0, `09` §3 decision 15): the box put a failed update back by itself.
// Severity warning: nothing was lost and nothing needs doing, but the household asked for a new version
// and did not get it. The sentence is ONE bundle key rendered twice (R-558).
func (n *Notifier) NotifyAppUpdateUndone(app string, from, to map[string]string, at time.Time) {
when := budapestMinute(at)
n.pushEventMsg("app_update_undone", "warning", "event.app_update_undone",
AppUpdateDetails{App: app, StackName: app, From: from, To: to, At: at.UTC().Format(time.RFC3339)}, app, when)
}
// NotifyAppUpdateHeld (v0.264.0): the update — or its undo — failed and the app is HELD STOPPED.
// `sentence` renders the hold's OWN sentence in a language (backup.RestoreHoldForLang, wired in
// main.go), so the mail says exactly what the page says: what happened, which copy brings it back,
// and what that copy holds. Severity error: the household must act (a restore).
func (n *Notifier) NotifyAppUpdateHeld(d AppUpdateDetails, sentence func(lang string) string) {
hu := sentence(i18n.Default)
household := ""
if lang := n.boxLang(); lang != i18n.Default {
household = sentence(lang)
}
n.pushEventBoth("app_update_held", "error", hu, household, d)
}