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
@@ -330,6 +330,13 @@ func (m *Manager) pruneUndoCopies(dumpDir, stack string) {
// on a misconfigured box. The write side logs loudly when it cannot persist (see
// holdAppAfterFailedRollback), which is where that case is caught.
func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
return m.RestoreHoldForLang(stack, m.boxLang())
}
// RestoreHoldForLang is RestoreHoldFor with the sentence in lang (v0.264.0, R-606). The page and the
// household mail ask for the READER's language; every other caller takes the box's. The Hungarian is
// byte-identical to the literals it replaced (UpdateHoldFmt & co. — TestR606_HoldSentenceHungarianUnchanged).
func (m *Manager) RestoreHoldForLang(stack, lang string) (bool, string) {
if m == nil || m.settings == nil {
return false, ""
}
@@ -340,43 +347,44 @@ func (m *Manager) RestoreHoldFor(stack string) (bool, string) {
// Slice 4: one storage, two reasons. An update hold names the copy it can be restored from; a
// restore hold names nothing, because the restore it refers to already consumed the copy.
if h.Reason == settings.HoldReasonUpdateFailed {
return true, m.undoHoldPrefix(h.UndoState) + m.updateHoldSentence(stack, h)
return true, m.undoHoldPrefix(lang, h.UndoState) + updateHoldSentence(lang, stack, h)
}
when := h.At
if t, err := time.Parse(time.RFC3339, h.At); err == nil {
when = t.Format("2006-01-02 15:04")
}
return true, m.note("note.reconstitute.held", stack, when)
return true, util.Text(lang, "note.reconstitute.held", stack, when)
}
// undoHoldPrefix (v0.263.0) opens the hold sentence when the box already TRIED to undo the update and
// that failed too: what was tried, then what state the data is in. "" when no undo was attempted, so
// every hold written before v0.263.0 reads exactly as it did.
func (m *Manager) undoHoldPrefix(state string) string {
func (m *Manager) undoHoldPrefix(lang, state string) string {
switch state {
case "untouched", "half", "not_started":
return m.note("hold.update.undo_failed") + " " + m.note("hold.update.undo_state."+state) + " "
return util.Text(lang, "hold.update.undo_failed") + " " + util.Text(lang, "hold.update.undo_state."+state) + " "
case "":
return ""
}
m.logger.Printf("[WARN] [backup] unknown undo state %q on a hold — rendering the plain prefix", state)
return m.note("hold.update.undo_failed") + " "
return util.Text(lang, "hold.update.undo_failed") + " "
}
// updateHoldSentence is the update hold's own sentence (slice 4, R-475, R-479), unchanged.
func (m *Manager) updateHoldSentence(stack string, h settings.RestoreHold) string {
copyDate := m.note("note.reconstitute.copy_latest")
// updateHoldSentence is the update hold's own sentence (slice 4, R-475, R-479) in lang. Its Hungarian
// is UpdateHoldFmt / UpdateHoldTierFmt / UpdateHoldLegacyFmt byte for byte (pinned by a test).
func updateHoldSentence(lang, stack string, h settings.RestoreHold) string {
copyDate := util.Text(lang, "note.reconstitute.copy_latest")
if h.CopyDate != "" {
copyDate = fmtHoldTime(h.CopyDate)
}
// R-475: name the tier when the hold recorded one; an older hold keeps its own sentence.
if label := UpdateTierLabel(h.CopyTier); label != "" && h.CopyDate != "" {
if label := UpdateTierLabelIn(lang, h.CopyTier); label != "" && h.CopyDate != "" {
if h.CopyHolds != "" { // R-479: name what the copy holds
return fmt.Sprintf(UpdateHoldFmt, stack, fmtHoldTime(h.At), label, copyDate, h.CopyHolds)
return util.Text(lang, "hold.update.sentence", stack, fmtHoldTime(h.At), label, copyDate, copyHoldsIn(lang, h.CopyHolds))
}
return fmt.Sprintf(UpdateHoldTierFmt, stack, fmtHoldTime(h.At), label, copyDate)
return util.Text(lang, "hold.update.sentence_tier", stack, fmtHoldTime(h.At), label, copyDate)
}
return fmt.Sprintf(UpdateHoldLegacyFmt, stack, fmtHoldTime(h.At), copyDate)
return util.Text(lang, "hold.update.sentence_legacy", stack, fmtHoldTime(h.At), copyDate)
}
// holdAppAfterFailedRollback records the R-379/R-380 hold and makes sure nothing restarts the app