v0.311.0: after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007)
gates / gates (push) Successful in 52s
gates / gates (push) Successful in 52s
New template block after_unclean_boot (decision 236): once per controller start that followed an UNCLEAN host boot (crashboot's own verdict, after the 15-minute crash grace; a normal or unknown boot never runs it), each app with the block rebuilds ONLY its zero-byte files (scan: regular files, no symlinks, its own filesystem; ids appended to the app's own command in batches; one retry, then reported) and its own lost-file check is read. The household sees lost files (name, date taken) on the app page until dismissed; the operator gets app_repaired_after_crash with counts only. Skips, with the reason, an unproven image, an unknown key, an app not running. MinAgent 0.155.0 unchanged. Tests red-proofed (14 guards). One new parity fixture (the new card only). Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// R-1007 (decision 236) — app_repaired_after_crash carries COUNTS ONLY to the operator. A file name or photo data in
|
||||
// the payload would leave the household's box. Asserted on the marshalled payload, and on the struct's fields (a new
|
||||
// field must come through this test).
|
||||
// RED-PROOF: add `Names []string json:"names"` to AppRepairDetails → the field list test fails.
|
||||
func TestAppRepairedAfterCrash_PayloadIsCountsOnly(t *testing.T) {
|
||||
var fields []string
|
||||
rt := reflect.TypeOf(AppRepairDetails{})
|
||||
for i := 0; i < rt.NumField(); i++ {
|
||||
fields = append(fields, strings.Split(rt.Field(i).Tag.Get("json"), ",")[0])
|
||||
}
|
||||
sort.Strings(fields)
|
||||
want := "app,boot_at,duration_s,found,lost,lost_checked,problem,repaired,skipped,stack_name,still_broken"
|
||||
if strings.Join(fields, ",") != want {
|
||||
t.Fatalf("AppRepairDetails fields = %v, want exactly %s — counts only, never a file name", fields, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The severity: lost or still broken → warning; everything repaired → info; nothing found and nothing lost → no
|
||||
// event at all. RED-PROOF: send `info` when Lost > 0 → the lost case reads info → fails.
|
||||
func TestAppRepairedAfterCrash_SeverityByOutcome(t *testing.T) {
|
||||
n := &Notifier{}
|
||||
var got []string
|
||||
var payloads []string
|
||||
n.pushFn = func(eventType, severity, msg, _ string, d interface{}) {
|
||||
b, _ := json.Marshal(d)
|
||||
got = append(got, eventType+"/"+severity)
|
||||
payloads = append(payloads, string(b)+" "+msg)
|
||||
}
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Found: 81, Repaired: 81})
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Found: 81, Repaired: 81, Lost: 1, LostChecked: true})
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Found: 5, Repaired: 4, StillBroken: 1})
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", Skipped: "immich-server runs v3.3.0, which the template has not proven"})
|
||||
n.NotifyAppRepairedAfterCrash(AppRepairDetails{App: "Immich", LostChecked: true})
|
||||
want := []string{"app_repaired_after_crash/info", "app_repaired_after_crash/warning", "app_repaired_after_crash/warning", "app_repaired_after_crash/warning"}
|
||||
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Fatalf("events %v, want %v (and nothing for a run that found nothing)", got, want)
|
||||
}
|
||||
for _, p := range payloads {
|
||||
if strings.Contains(p, ".jpg") || strings.Contains(p, "IMG_") {
|
||||
t.Fatalf("a file name in the event: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The three keys R-1007 was born with, in BOTH languages, with the same placeholders (an English box gets English).
|
||||
func TestAppRepairedAfterCrash_KeysInBothLanguages(t *testing.T) {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"event.app_repaired_after_crash", "event.app_repair_after_crash_skipped", "err.lost_files.nothing_to_dismiss"} {
|
||||
hu, en := b.Msg("hu", k), b.Msg("en", k)
|
||||
if hu == "" || en == "" || hu == k || en == k || hu == en {
|
||||
t.Fatalf("%s: hu=%q en=%q", k, hu, en)
|
||||
}
|
||||
if strings.Count(hu, "%") != strings.Count(en, "%") {
|
||||
t.Fatalf("%s: the two languages take different arguments", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +851,36 @@ type oomRun struct {
|
||||
|
||||
// OOMStormDetails is the app_oom_storm payload. stack_name lets the hub's per-app cooldown tell two
|
||||
// storming apps apart.
|
||||
// AppRepairDetails is app_repaired_after_crash's payload (R-1007, decision 236): COUNTS ONLY — never a file name,
|
||||
// never photo data. The household's list of lost files stays on its box (app.yaml), shown on the app page.
|
||||
type AppRepairDetails struct {
|
||||
App string `json:"app"`
|
||||
StackName string `json:"stack_name"`
|
||||
BootAt string `json:"boot_at,omitempty"`
|
||||
Found int `json:"found"`
|
||||
Repaired int `json:"repaired"`
|
||||
StillBroken int `json:"still_broken"`
|
||||
Lost int `json:"lost"`
|
||||
LostChecked bool `json:"lost_checked"`
|
||||
DurationS int64 `json:"duration_s"`
|
||||
Skipped string `json:"skipped,omitempty"`
|
||||
Problem string `json:"problem,omitempty"`
|
||||
}
|
||||
|
||||
// NotifyAppRepairedAfterCrash tells the OPERATOR what an app's after_unclean_boot run did (operator-only at the hub).
|
||||
// warning: a file is lost or still broken, a step failed, or the run was skipped; info: everything found was
|
||||
// repaired (recorded at the hub, not mailed). Nothing found and nothing lost: no event.
|
||||
func (n *Notifier) NotifyAppRepairedAfterCrash(d AppRepairDetails) {
|
||||
switch {
|
||||
case d.Skipped != "":
|
||||
n.pushEventMsg("app_repaired_after_crash", "warning", "event.app_repair_after_crash_skipped", d, d.App, d.Skipped)
|
||||
case d.Lost > 0 || d.StillBroken > 0 || d.Problem != "":
|
||||
n.pushEventMsg("app_repaired_after_crash", "warning", "event.app_repaired_after_crash", d, d.App, d.Found, d.Repaired, d.StillBroken, d.Lost)
|
||||
case d.Found > 0:
|
||||
n.pushEventMsg("app_repaired_after_crash", "info", "event.app_repaired_after_crash", d, d.App, d.Found, d.Repaired, d.StillBroken, d.Lost)
|
||||
}
|
||||
}
|
||||
|
||||
type OOMStormDetails struct {
|
||||
App string `json:"app"`
|
||||
StackName string `json:"stack_name"`
|
||||
|
||||
Reference in New Issue
Block a user