v0.283.0: apps go off-site by themselves (decision 50) with a size warning; a Stop holds during a backup (R-721); page slips (R-724/R-725)
gates / gates (push) Successful in 25s
gates / gates (push) Successful in 25s
Red-proofs RP31-RP38. MinAgent 0.131.0 (unchanged). 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:
@@ -0,0 +1,88 @@
|
||||
package settings
|
||||
|
||||
// ── Decision 50 (2026-09-30, R-720): a new app is in the off-site copy by default ──────────────────────
|
||||
//
|
||||
// The 2026-09-16 ruling turned the off-site tier ON for every new customer, but each app's own switch
|
||||
// (`app_backup[<app>].offbox`) started OFF and only the backups page's „Bekapcsolás" set it. Measured on a
|
||||
// fresh box 2026-09-29: three apps installed, every one „3. mentés Kikapcsolva", the night's off-site run
|
||||
// „nincs kijelölt alkalmazás". The household was never told.
|
||||
//
|
||||
// DefaultOffboxOnForNewApp is called by the deploy-done hook (the one place a fresh install ends). It switches
|
||||
// the app ON when, and only when:
|
||||
// - an off-site target exists and is enabled (the customer has off-site — hub-provisioned or the
|
||||
// household's own NAS), and
|
||||
// - the app has NO per-app backup record yet. A record means someone already chose: an app removed
|
||||
// WITHOUT deleting its backups keeps its record (R-474), so a reinstall keeps the household's earlier
|
||||
// OFF. An app removed WITH its backups had its record deleted, so it is a new app again.
|
||||
//
|
||||
// It never turns anything OFF and never touches an app that is already installed (a release switches
|
||||
// nothing by itself — the 2026-09-29 Part 0 rule).
|
||||
// Pinned by internal/settings/offbox_default_test.go.
|
||||
func (s *Settings) DefaultOffboxOnForNewApp(stackName string) (bool, string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.Offbox == nil || !s.Offbox.Enabled || s.Offbox.Host == "" {
|
||||
return false, "no off-site target"
|
||||
}
|
||||
if _, chosen := s.AppBackup[stackName]; chosen {
|
||||
return false, "the app already has a backup choice"
|
||||
}
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
s.AppBackup[stackName] = AppBackupPrefs{Offbox: true}
|
||||
if err := s.save(); err != nil {
|
||||
delete(s.AppBackup, stackName)
|
||||
return false, "save failed: " + err.Error()
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// AppsWithoutOffbox lists the given installed apps that are NOT in the off-site copy, when an off-site target
|
||||
// exists (decision 50: the one-press offer for apps installed before the release). Empty when there is no
|
||||
// target, or every app is on.
|
||||
func (s *Settings) AppsWithoutOffbox(installed []string) []string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.Offbox == nil || !s.Offbox.Enabled || s.Offbox.Host == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, n := range installed {
|
||||
if !s.AppBackup[n].Offbox {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EnableOffboxFor switches off-site ON for each named app in one save (the one-press offer).
|
||||
func (s *Settings) EnableOffboxFor(apps []string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.AppBackup == nil {
|
||||
s.AppBackup = make(map[string]AppBackupPrefs)
|
||||
}
|
||||
for _, a := range apps {
|
||||
p := s.AppBackup[a]
|
||||
p.Offbox = true
|
||||
s.AppBackup[a] = p
|
||||
}
|
||||
s.OffboxOfferDismissed = true // answered
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// DismissOffboxOffer records the household's „Nem most".
|
||||
func (s *Settings) DismissOffboxOffer() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.OffboxOfferDismissed = true
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// OffboxOfferDismissedValue reports whether the offer was answered.
|
||||
func (s *Settings) OffboxOfferDismissedValue() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.OffboxOfferDismissed
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadTmp(t *testing.T) (*Settings, string) {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "settings.json")
|
||||
s, err := Load(p, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, p
|
||||
}
|
||||
|
||||
// Decision 50 (R-720). The CONSEQUENCE is asserted: after a fresh install on a box with off-site, the app is in
|
||||
// the list the off-site run reads (GetOffboxApps), and it survives a reload from disk.
|
||||
// COMPANION RED-PROOF: make DefaultOffboxOnForNewApp return (false, "") at the top → this test fails with
|
||||
// „the new app is not in the off-site run".
|
||||
func TestDecision50_NewAppIsOffsiteByDefault(t *testing.T) {
|
||||
s, p := loadTmp(t)
|
||||
if err := s.SetOffboxTarget(&OffboxTarget{Enabled: true, Host: "u1.your-storagebox.de", Port: 23, User: "u1", RepoPath: "/home/r"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
on, why := s.DefaultOffboxOnForNewApp("bookstack")
|
||||
if !on {
|
||||
t.Fatalf("switched=false (%s)", why)
|
||||
}
|
||||
s2, err := Load(p, log.New(io.Discard, "", 0))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s2.GetOffboxApps(); !reflect.DeepEqual(got, []string{"bookstack"}) {
|
||||
t.Fatalf("the new app is not in the off-site run after a reload: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// No off-site target → nothing is switched (a box without off-site keeps no phantom choice).
|
||||
func TestDecision50_NoTargetSwitchesNothing(t *testing.T) {
|
||||
for name, tgt := range map[string]*OffboxTarget{
|
||||
"none": nil,
|
||||
"disabled": {Enabled: false, Host: "h"},
|
||||
"no host": {Enabled: true},
|
||||
} {
|
||||
s, _ := loadTmp(t)
|
||||
if tgt != nil {
|
||||
_ = s.SetOffboxTarget(tgt)
|
||||
}
|
||||
if on, _ := s.DefaultOffboxOnForNewApp("bookstack"); on {
|
||||
t.Fatalf("%s: switched an app on with no usable off-site target", name)
|
||||
}
|
||||
if len(s.GetOffboxApps()) != 0 || s.AppsWithoutOffbox([]string{"bookstack"}) != nil {
|
||||
t.Fatalf("%s: phantom off-site state", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A household's earlier OFF survives a reinstall: an app removed WITHOUT deleting its backups keeps its record
|
||||
// (R-474), and the default never overrides a record.
|
||||
// COMPANION RED-PROOF: drop the „already has a backup choice" check → this test fails.
|
||||
func TestDecision50_EarlierChoiceIsKept(t *testing.T) {
|
||||
s, _ := loadTmp(t)
|
||||
_ = s.SetOffboxTarget(&OffboxTarget{Enabled: true, Host: "h"})
|
||||
_ = s.SetAppOffbox("immich", false) // the household switched it off (e.g. too big)
|
||||
if on, _ := s.DefaultOffboxOnForNewApp("immich"); on {
|
||||
t.Fatal("the default overrode the household's own OFF")
|
||||
}
|
||||
if len(s.GetOffboxApps()) != 0 {
|
||||
t.Fatalf("immich is off-site against the household's choice: %v", s.GetOffboxApps())
|
||||
}
|
||||
}
|
||||
|
||||
// The one-press offer lists exactly the installed apps that are off.
|
||||
func TestDecision50_AppsWithoutOffbox(t *testing.T) {
|
||||
s, _ := loadTmp(t)
|
||||
_ = s.SetOffboxTarget(&OffboxTarget{Enabled: true, Host: "h"})
|
||||
_ = s.SetAppOffbox("bookstack", true)
|
||||
got := s.AppsWithoutOffbox([]string{"actualbudget", "bookstack", "vikunja"})
|
||||
if !reflect.DeepEqual(got, []string{"actualbudget", "vikunja"}) {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,9 @@ type Settings struct {
|
||||
|
||||
// Per-app backup preferences
|
||||
AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
|
||||
// OffboxOfferDismissed (decision 50): the household answered „Nem most" to the one-press offer that
|
||||
// switches off-site on for apps installed before v0.283.0. The offer is shown once, not forever.
|
||||
OffboxOfferDismissed bool `json:"offbox_offer_dismissed,omitempty"`
|
||||
|
||||
// OffsiteDataAt (v0.275.0, R-696) — per app, the DATA time of the recovery unit this box last pushed
|
||||
// off-site, and when. An off-site snapshot's own time is when it was TAKEN; a run whose dump leg failed
|
||||
|
||||
Reference in New Issue
Block a user