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

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:
2026-10-14 16:47:13 +02:00
parent acbbf4b305
commit 8d862e4e69
24 changed files with 1865 additions and 3 deletions
+33
View File
@@ -386,6 +386,39 @@ func (s *Server) ServeSignupClosed(w http.ResponseWriter, r *http.Request) {
}
}
// lostFilesNote is the app page's lost-files note (R-1007): the files the app's own check reported lost after an
// unclean boot, until the household dismisses it, and the boot time in the box's zone. Nothing for an app with no
// lost file (repaired previews need no note). Pinned by TestLostFilesNote_*.
func lostFilesNote(deployed bool, cfg *stacks.AppConfig) ([]stacks.LostFile, string) {
if !deployed || cfg == nil || cfg.AfterUncleanBoot == nil {
return nil, ""
}
rec := cfg.AfterUncleanBoot
if len(rec.Lost) == 0 || rec.Dismissed {
return nil, ""
}
at := ""
if t, err := time.Parse(time.RFC3339, rec.BootAt); err == nil {
at = t.In(getTimezone()).Format("2006-01-02 15:04")
}
return rec.Lost, at
}
// appLostFilesDismissHandler hides the app page's lost-files note (R-1007). The record (and the operator's counts)
// stays; only the household's note goes.
func (s *Server) appLostFilesDismissHandler(w http.ResponseWriter, r *http.Request, slug string) {
found := s.stackBySlug(slug)
if found == nil {
escrowJSON(w, http.StatusNotFound, nil, s.msg(r, "escrow.unknown_app"))
return
}
if err := s.stackMgr.DismissLostFiles(found.Name); err != nil {
escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.lost_files.nothing_to_dismiss"))
return
}
escrowJSON(w, http.StatusOK, map[string]any{"dismissed": true}, "")
}
// appCloseSignupHandler is decision 49's "close sign-up now" (POST /apps/<slug>/close-signup) for an app installed
// before decision 47. It applies exactly what a fresh install gets after its setup; it never gates the app.
func (s *Server) appCloseSignupHandler(w http.ResponseWriter, r *http.Request, slug string) {