v0.256.0: the box sends its own sentence in the household's language (R-558 Part B)
gates / gates (push) Successful in 24s
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:
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user