v0.256.0: the box sends its own sentence in the household's language (R-558 Part B)
gates / gates (push) Successful in 24s

MinAgent: 0.131.0 (unchanged). Needs hub v0.118.0+, which shipped first and
tolerates a box that sends none of this - every box in the fleet is that box
until this release reaches it.

The hub writes a household's e-mails in their language now, but about a third
of those mails carry a sentence the BOX composed, naming a drive, an app or a
number. The hub cannot translate one. So the box sends it twice.

- message_customer on POST /api/v1/event, omitempty. A HUNGARIAN household
  sends nothing extra at all, so its payload stays byte-for-byte what every box
  sends today and the hub's fallback path keeps being the one production
  exercises rather than a branch nobody takes.
- 19 producers render both sentences from ONE bundle key. `message` stays
  Hungarian always: it is what the operator is mailed and what the hub logs.
- customer.language bootstraps a new box - stored choice, then config, then
  Hungarian. The config value is NEVER written into settings.json: that would
  record a choice the household never made.

The Hungarian did not move, measured twice: the wire golden from the slice-2
base commit, and the Go parity gate over all 19 new keys.

Three guards had to learn the change and one caught me: the test seam now
carries the new field; the R-329 severity register reported two dynamic sites
as no longer existing the moment they moved off PushEvent (the walk now checks
36 severity literals, up from 20); and TestConfigLanguageIsWiredInMain reads
main.go, because cmd/ is gitignored and ripgrep does not.

A mistake, named: the first pass dropped displayName from three producers,
which would have mailed customers "Alkalmazás telepítve: %!s(MISSING)". Caught
reading the diff; now pinned by a test that refuses %!/MISSING/%s/%d in either
language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-18 16:54:20 +02:00
parent 8fb2f9ef9d
commit bef39598d0
16 changed files with 1601 additions and 1068 deletions
+131 -57
View File
@@ -13,6 +13,8 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// Notifier sends structured events to the hub via /api/v1/event.
@@ -51,7 +53,11 @@ type Notifier struct {
// pushFn is a test seam for the transition-emitting notifiers (fix-3). nil → the real async
// PushEvent; tests inject a synchronous recorder.
pushFn func(eventType, severity, message string, details interface{})
// v0.256.0 (R-558): messageCustomer is IN the seam, not beside it. A seam that cannot see a new
// field is a seam that cannot test it — the same lesson this file already records one comment
// down, where PushEvent had to become the seam because emit alone made a whole class of
// producer invisible.
pushFn func(eventType, severity, message, messageCustomer string, details interface{})
// Event history ring buffer (debug page)
historyMu sync.RWMutex
@@ -164,17 +170,92 @@ type CrossDriveDetails struct {
// eventRequest is the JSON payload sent to /api/v1/event.
type eventRequest struct {
CustomerID string `json:"customer_id"`
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
Details json.RawMessage `json:"details,omitempty"`
CustomerID string `json:"customer_id"`
EventType string `json:"event_type"`
Severity string `json:"severity"`
Message string `json:"message"`
// MessageCustomer (v0.256.0, R-558) is the SAME sentence in the household's language.
//
// `omitempty` is load-bearing: a Hungarian household sends no second copy at all, so its event
// payload is byte-for-byte what it has always been, and the hub's fallback path — the one every
// box in the fleet uses today — is the one that keeps being exercised in production rather than
// becoming a branch nobody takes.
//
// Message stays Hungarian ALWAYS. It is what the operator is mailed, what the hub logs and what
// notification_log records; nothing an operator reads may move because a household switched.
MessageCustomer string `json:"message_customer,omitempty"`
Details json.RawMessage `json:"details,omitempty"`
}
// PushEvent sends a structured event to the hub's /api/v1/event endpoint.
// Non-blocking (goroutine). Retries twice with 3s backoff.
// details may be nil (omitted from JSON) or a struct that marshals to JSON.
// pushEventMsg renders ONE bundle key twice — Hungarian for the wire, the household's language
// beside it — and pushes both (R-558).
//
// Producers call this instead of composing a Hungarian sentence, because the hub CANNOT translate a
// sentence the box composed: it arrives as finished text naming a drive, an app or a number. The
// only place both languages can be produced is here, where the key and its arguments still exist.
//
// The Hungarian is rendered from the SAME key, so it cannot drift from the English: there is one
// sentence with two spellings, not two sentences. That the Hungarian is byte-identical to the
// literal it replaced is pinned by TestEventMessageWireTextIsFrozen and by the Go parity gate.
func (n *Notifier) pushEventMsg(eventType, severity, key string, details interface{}, args ...interface{}) {
b, err := i18n.Shared()
if err != nil {
// The bundle is embedded, so this is a broken build rather than a runtime condition. Push
// the key itself rather than an empty sentence: an event with a visible key reaches the
// operator and gets fixed; an event with an empty message is a silent hole.
n.logger.Printf("[ERROR] pushEventMsg: bundle unavailable for %s: %v", key, err)
n.pushEventBoth(eventType, severity, key, "", details)
return
}
hungarian := b.Msgf(i18n.Default, key, args...)
// A Hungarian household sends nothing extra — see MessageCustomer's comment.
household := ""
if lang := n.boxLang(); lang != i18n.Default {
household = b.Msgf(lang, key, args...)
}
n.pushEventBoth(eventType, severity, hungarian, household, details)
}
// pushEventMsgSuffix is pushEventMsg with a tail that CANNOT be translated — a docker error, a
// validator's sentence, something that arrived as finished text. It is appended to both renderings,
// so an English household gets an English sentence with a foreign tail rather than no sentence.
func (n *Notifier) pushEventMsgSuffix(eventType, severity, key, suffix string, details interface{}, args ...interface{}) {
b, err := i18n.Shared()
if err != nil {
n.logger.Printf("[ERROR] pushEventMsgSuffix: bundle unavailable for %s: %v", key, err)
n.pushEventBoth(eventType, severity, key+suffix, "", details)
return
}
household := ""
if lang := n.boxLang(); lang != i18n.Default {
household = b.Msgf(lang, key, args...) + suffix
}
n.pushEventBoth(eventType, severity, b.Msgf(i18n.Default, key, args...)+suffix, household, details)
}
// boxLang is the household's language, nil-safe. A notifier built without settings (several tests,
// and the setup-mode path) is Hungarian rather than a panic.
func (n *Notifier) boxLang() string {
if n.settings == nil {
return i18n.Default
}
return n.settings.GetLanguage()
}
// PushEvent sends a structured event to the hub's /api/v1/event endpoint, Hungarian only.
//
// Kept for producers whose sentence is composed somewhere else and reaches them already finished —
// and for the OPERATOR-tier types, which are Hungarian (or English) by design and have no household
// half. A customer-facing producer should use pushEventMsg.
func (n *Notifier) PushEvent(eventType, severity, message string, details interface{}) {
n.pushEventBoth(eventType, severity, message, "", details)
}
func (n *Notifier) pushEventBoth(eventType, severity, message, messageCustomer string, details interface{}) {
// The test seam sits HERE, not only in emit (v0.252.0, R-557). Most producers call PushEvent
// directly, so a test that set pushFn saw nothing from them and the difference was invisible: a
// producer that pushes nothing and a producer whose message is empty both leave a recorder empty.
@@ -182,7 +263,7 @@ func (n *Notifier) PushEvent(eventType, severity, message string, details interf
// only do that if every producer is reachable through one seam. In production pushFn is nil and
// this line does nothing.
if n.pushFn != nil {
n.pushFn(eventType, severity, message, details)
n.pushFn(eventType, severity, message, messageCustomer, details)
return
}
if !n.enabled {
@@ -200,11 +281,12 @@ func (n *Notifier) PushEvent(eventType, severity, message string, details interf
}
payload := eventRequest{
CustomerID: n.customerID,
EventType: eventType,
Severity: severity,
Message: message,
Details: detailsJSON,
CustomerID: n.customerID,
EventType: eventType,
Severity: severity,
Message: message,
MessageCustomer: messageCustomer,
Details: detailsJSON,
}
jsonData, err := json.Marshal(payload)
@@ -293,16 +375,13 @@ func (n *Notifier) NotifyHealthChange(status string, issues, warnings []string)
if newRank > prevRank {
// Degradation
if status == "fail" {
n.PushEvent("health_critical", "error",
fmt.Sprintf("Rendszer állapot kritikus (volt: %s)", prev), details)
n.pushEventMsg("health_critical", "error", "event.health_critical", details, prev)
} else if status == "warn" {
n.PushEvent("health_degraded", "warning",
fmt.Sprintf("Rendszer állapot romlott (volt: %s)", prev), details)
n.pushEventMsg("health_degraded", "warning", "event.health_degraded", details, prev)
}
} else {
// Recovery
n.PushEvent("health_recovered", "info",
fmt.Sprintf("Rendszer állapot helyreállt: %s (volt: %s)", status, prev), details)
n.pushEventMsg("health_recovered", "info", "event.health_recovered", details, status, prev)
}
}
@@ -402,7 +481,7 @@ func (n *Notifier) NotifyDBDumpFailed(message, errMsg string) {
// NotifyDBDumpCompleted sends a DB dump success event.
func (n *Notifier) NotifyDBDumpCompleted(details DBDumpDetails) {
n.PushEvent("db_dump_completed", "info", "Adatbázis mentés elkészült", details)
n.pushEventMsg("db_dump_completed", "info", "event.db_dump_completed", details)
}
// NotifyIntegrityFailed sends a backup integrity check failure event.
@@ -435,29 +514,27 @@ func (n *Notifier) NotifyIntegrityOK(message string) {
// NotifyControllerUpdated sends a controller update event.
func (n *Notifier) NotifyControllerUpdated(fromVer, toVer string, success bool) {
severity := "info"
msg := fmt.Sprintf("Controller frissítve: %s → %s", fromVer, toVer)
key := "event.controller_updated"
details := UpdateDetails{FromVersion: fromVer, ToVersion: toVer}
if !success {
severity = "error"
msg = fmt.Sprintf("Controller frissítés sikertelen: %s → %s", fromVer, toVer)
key = "event.controller_update_failed"
}
n.PushEvent("controller_updated", severity, msg, details)
n.pushEventMsg("controller_updated", severity, key, details, fromVer, toVer)
}
// NotifyControllerStarted sends a controller startup event.
// details may include self-test summary (e.g., {"selftest_pass": 8, "selftest_warn": 1, "selftest_fail": 0}).
func (n *Notifier) NotifyControllerStarted(version string, details map[string]interface{}) {
n.PushEvent("controller_started", "info",
fmt.Sprintf("Controller elindult (%s)", version), details)
n.pushEventMsg("controller_started", "info", "event.controller_started", details, version)
}
// NotifyStorageDisconnected sends a drive disconnection event.
func (n *Notifier) NotifyStorageDisconnected(label string, stoppedApps []string) {
msg := fmt.Sprintf("Meghajtó váratlanul leválasztva: %s", label)
n.PushEvent("storage_disconnected", "error", msg, StorageDetails{
n.pushEventMsg("storage_disconnected", "error", "event.storage_disconnected", StorageDetails{
Label: label,
StoppedApps: stoppedApps,
})
}, label)
}
// NotifyBackupTargetAbsent (E-2) reports that the drive holding the WHOLE-GUEST backup is gone.
@@ -468,23 +545,21 @@ func (n *Notifier) NotifyStorageDisconnected(label string, stoppedApps []string)
// at all: the tier stays DUE (targetStoragePresent checks name presence, never reachability), so the
// only evidence was its own failure at the next due cycle, up to ~24 h away on the daily local tier.
func (n *Notifier) NotifyBackupTargetAbsent(label, target string) {
n.PushEvent("backup_target_absent", "error",
fmt.Sprintf("A rendszermentés meghajtója nem érhető el: %s (%s)", label, target),
StorageDetails{Label: label})
n.pushEventMsg("backup_target_absent", "error", "event.backup_target_absent",
StorageDetails{Label: label}, label, target)
}
// NotifyBackupTargetRestored is the paired recovery. info severity — the existing recovery pattern;
// severityNotifies is deliberately NOT widened.
func (n *Notifier) NotifyBackupTargetRestored(label, target string) {
n.PushEvent("backup_target_restored", "info",
fmt.Sprintf("A rendszermentés meghajtója újra elérhető: %s (%s)", label, target),
StorageDetails{Label: label})
n.pushEventMsg("backup_target_restored", "info", "event.backup_target_restored",
StorageDetails{Label: label}, label, target)
}
// NotifyStorageReconnected sends a drive reconnection event.
func (n *Notifier) NotifyStorageReconnected(label string) {
n.PushEvent("storage_reconnected", "info",
fmt.Sprintf("Meghajtó újra csatlakoztatva: %s", label), StorageDetails{Label: label})
n.pushEventMsg("storage_reconnected", "info", "event.storage_reconnected",
StorageDetails{Label: label}, label)
}
// AgentChannelDetails carries the classified reason for a controller→agent channel-down event.
@@ -532,9 +607,8 @@ type EndpointDriftDetails struct {
// so an install that was interrupted five seconds later still stood on the hub's timeline as
// „Alkalmazás telepítve" forever — measured 2026-09-16 with mealie, which ended `not_deployed`.
func (n *Notifier) NotifyAppDeployed(stackName, displayName string) {
n.PushEvent("app_deployed", "info",
fmt.Sprintf("Alkalmazás telepítve: %s", displayName),
AppDetails{StackName: stackName, DisplayName: displayName})
n.pushEventMsg("app_deployed", "info", "event.app_deployed",
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
}
// NotifyAppDeployStarted records the ACCEPTANCE — the fact `app_deployed` used to assert. It keeps
@@ -543,21 +617,22 @@ func (n *Notifier) NotifyAppDeployed(stackName, displayName string) {
// NOTE: the hub validates event_type against allowedEventTypes and 400s an unknown one, so this type
// MUST exist there too (hub handler.go) or the event is silently inert.
func (n *Notifier) NotifyAppDeployStarted(stackName, displayName string) {
n.PushEvent("app_deploy_started", "info",
fmt.Sprintf("Alkalmazás telepítése elindult: %s", displayName),
AppDetails{StackName: stackName, DisplayName: displayName})
n.pushEventMsg("app_deploy_started", "info", "event.app_deploy_started",
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
}
// NotifyAppDeployFailed closes the pair. severity=warning, not info: an install the customer started
// and that did not finish is a thing someone should see, and the silent version of this is exactly
// what left a completed-install record for an app that was never installed.
func (n *Notifier) NotifyAppDeployFailed(stackName, displayName, reason string) {
msg := fmt.Sprintf("Alkalmazás telepítése nem sikerült: %s", displayName)
// The reason is appended to BOTH renderings rather than folded into the key: it arrives as
// finished text (a docker error, a validator's sentence) that this function cannot translate.
suffix := ""
if reason != "" {
msg += " — " + reason
suffix = " — " + reason
}
n.PushEvent("app_deploy_failed", "warning", msg,
AppDetails{StackName: stackName, DisplayName: displayName})
n.pushEventMsgSuffix("app_deploy_failed", "warning", "event.app_deploy_failed", suffix,
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
}
// AppRunState is one deployed app's running state for the fix-3 start-failure notifier: Down=true
@@ -725,9 +800,12 @@ func (n *Notifier) NotifyDiskHealthDegraded(a DiskAlert) {
}
// emit sends an event through the test seam if set, else the real async PushEvent.
//
// Hungarian only: its one producer (the disk-health alerts) still composes a finished sentence.
// It passes no household copy, which is what an un-converted producer looks like.
func (n *Notifier) emit(eventType, severity, message string, details interface{}) {
if n.pushFn != nil {
n.pushFn(eventType, severity, message, details)
n.pushFn(eventType, severity, message, "", details)
return
}
n.PushEvent(eventType, severity, message, details)
@@ -735,27 +813,23 @@ func (n *Notifier) emit(eventType, severity, message string, details interface{}
// NotifyAppRemoved sends an app removal event.
func (n *Notifier) NotifyAppRemoved(stackName, displayName string) {
n.PushEvent("app_removed", "info",
fmt.Sprintf("Alkalmazás eltávolítva: %s", displayName),
AppDetails{StackName: stackName, DisplayName: displayName})
n.pushEventMsg("app_removed", "info", "event.app_removed",
AppDetails{StackName: stackName, DisplayName: displayName}, displayName)
}
// NotifyCrossDriveCompleted sends a cross-drive backup success event.
func (n *Notifier) NotifyCrossDriveCompleted(details CrossDriveDetails) {
n.PushEvent("crossdrive_completed", "info",
fmt.Sprintf("Másodlagos mentés elkészült: %s", details.StackName), details)
n.pushEventMsg("crossdrive_completed", "info", "event.crossdrive_completed", details, details.StackName)
}
// NotifyCrossDriveFailed sends a cross-drive backup failure event.
func (n *Notifier) NotifyCrossDriveFailed(details CrossDriveDetails) {
n.PushEvent("crossdrive_failed", "error",
fmt.Sprintf("Másodlagos mentés sikertelen: %s", details.StackName), details)
n.pushEventMsg("crossdrive_failed", "error", "event.crossdrive_failed", details, details.StackName)
}
// NotifyDRStarted sends a disaster recovery start event.
func (n *Notifier) NotifyDRStarted(appCount int) {
n.PushEvent("disaster_recovery_started", "warning",
fmt.Sprintf("Katasztrófa helyreállítás elindítva (%d alkalmazás)", appCount), nil)
n.pushEventMsg("disaster_recovery_started", "warning", "event.disaster_recovery_started", nil, appCount)
}
// NotifyDRCompleted sends a disaster recovery completion event.
@@ -764,8 +838,8 @@ func (n *Notifier) NotifyDRCompleted(successCount, failCount int) {
if failCount > 0 {
severity = "warning"
}
n.PushEvent("disaster_recovery_completed", severity,
fmt.Sprintf("Katasztrófa helyreállítás befejezve (%d sikeres, %d sikertelen)", successCount, failCount), nil)
n.pushEventMsg("disaster_recovery_completed", severity, "event.disaster_recovery_completed", nil,
successCount, failCount)
}
// ── Preferences sync ─────────────────────────────────────────────────