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
+84
View File
@@ -401,6 +401,7 @@ func (m *Manager) tryUndo(ctx context.Context, name, dir, why string, entry *upd
_ = m.RefreshStatus()
m.clearJournal(name)
m.finishUpdate(name, UpdatePhaseUndone, "")
m.emitUpdateEvent(UpdateEventUndone, name, entry, UpdateRestorePoint{}, true)
m.logger.Printf("[INFO] [stacks] update %s: UNDONE in %s — the previous version is running on the data from before the update (%s)", name, m.now().Sub(start).Round(time.Second), detail)
return ""
}
@@ -424,3 +425,86 @@ func (m *Manager) recordUpdateUndone(name, dir string, u *UpdateUndone) {
}
m.mu.Unlock()
}
// ── The household and the operator are TOLD (v0.264.0, `09` §3 decision 15, §6.4 part 2) ─────────
// Update event kinds — the hub event types, one register for both repos (hub allowedEventTypes).
const (
UpdateEventUndone = "app_update_undone" // the box put the previous version back — warning
UpdateEventHeld = "app_update_held" // the update (or its undo) failed and the app is HELD — error
)
// UpdateEvent is what the update job hands to the notifier. The stacks package composes no sentence
// for it: the notifier renders the undone line from a bundle key, and the held line is the hold's own
// sentence, rendered by the backup side in each language (main.go wires both).
type UpdateEvent struct {
Kind string
App string
From, To map[string]string
At time.Time
CopyTier int
CopyDate time.Time
// HoldRecorded is false when the hold could not be persisted — the event still goes (the operator
// must hear about exactly that), and the household sentence is then update.error.hold_unsaved.
HoldRecorded bool
}
// SetUpdateEventSink wires the notifier (main.go). INIT-ONLY. Nil = no events (tests, setup mode).
// Pinned by TestUpdateEventSinkIsWiredAtStartup (an AST walk of main.go).
func (m *Manager) SetUpdateEventSink(fn func(UpdateEvent)) {
m.mu.Lock()
m.updateEventSink = fn
m.mu.Unlock()
}
// emitUpdateEvent sends ONE event for an undone or held update. Called exactly once per outcome by
// tryUndo (undone) and failAndHold (held) — the job never retries (decision 15), so one outcome is one
// event; the hub's per-app cooldown is the second belt against a storm (R-629).
func (m *Manager) emitUpdateEvent(kind, name string, entry *updateJournalEntry, rp UpdateRestorePoint, holdRecorded bool) {
m.mu.RLock()
sink := m.updateEventSink
m.mu.RUnlock()
if sink == nil {
return
}
ev := UpdateEvent{Kind: kind, App: name, At: m.now(), CopyTier: rp.Tier, CopyDate: rp.ProvenAt, HoldRecorded: holdRecorded}
if entry != nil {
ev.From, ev.To = entry.PrevPin, entry.NewPin
}
m.logger.Printf("[INFO] [stacks] update %s: event %s (hold recorded: %v)", name, kind, holdRecorded)
sink(ev)
}
// BackfillAppliedMeta is R-646's startup pass (v0.264.0), beside AdoptPins. An app pinned before
// v0.263.2 has no applied-meta record, so its FIRST undo would judge the old version with whatever
// .felhom.yml the catalog sync last put in place. For an app that is CURRENT with the catalog, that file
// IS the pinned version's own — so it is recorded now. An app that is Behind or Unknown is skipped and
// NAMED: its pinned version's file is already gone, and guessing it would be worse than saying so.
// Idempotent; never overwrites an existing record. Returns (recorded, skipped) app names.
func (m *Manager) BackfillAppliedMeta() (recorded, skipped []string) {
for _, st := range m.GetStacks() {
if !st.Deployed || st.AppConfig == nil || len(st.AppConfig.PinnedImages) == 0 {
continue
}
dir := filepath.Dir(st.ComposePath)
if _, err := os.Stat(filepath.Join(dir, appliedMetaDir, ".felhom.yml")); err == nil {
continue // already recorded — never overwritten
}
if CatalogOrder(st) != UpdateOrderCurrent {
skipped = append(skipped, st.Name)
continue
}
b, err := os.ReadFile(filepath.Join(dir, ".felhom.yml"))
if err == nil {
err = storeAppliedMeta(dir, b)
}
if err != nil {
m.logger.Printf("[WARN] [stacks] applied-meta backfill: %s: %v", st.Name, err)
skipped = append(skipped, st.Name)
continue
}
recorded = append(recorded, st.Name)
}
m.logger.Printf("[INFO] [stacks] applied-meta backfill (R-646): recorded %d %v; skipped %d %v — not current with the catalog, their pinned version's .felhom.yml is no longer on the box", len(recorded), recorded, len(skipped), skipped)
return recorded, skipped
}