v0.290.0: the clean-up guard skips same-day superseded young snapshots instead of refusing (R-824), refuses above the weekly cap; a due set-aside deletion is handed to the hub's 7-day wait (decision 74, R-823)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
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:
@@ -169,8 +169,8 @@ func TestOffsiteGuard_RecentRemovalRefused(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Honest retention inside a window: the oldest first, at most MaxRemove, and the forget names ids.
|
||||
func TestOffsiteGuard_HonestPlanPrunesOldestFirstCapped(t *testing.T) {
|
||||
// Honest retention inside a window: oldest first, the forget names ids.
|
||||
func TestOffsiteGuard_HonestPlanPrunesOldestFirst(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
pinTarget(t, sett)
|
||||
now := time.Now()
|
||||
@@ -178,21 +178,60 @@ func TestOffsiteGuard_HonestPlanPrunesOldestFirstCapped(t *testing.T) {
|
||||
all := append([]guardSnap{snap("keep", now.Add(-time.Hour))}, plan...)
|
||||
wr := &windowRunner{snaps: all, plan: plan}
|
||||
m.SetOffboxRunner(wr.run)
|
||||
fw := &fakeWindow{grant: OffsiteWindow{Granted: true, ID: 9, NewestAllowed: now, MaxRemove: 2}}
|
||||
fw := &fakeWindow{grant: OffsiteWindow{Granted: true, ID: 9, NewestAllowed: now, MaxRemove: 5}}
|
||||
m.SetOffsiteWindowClient(fw)
|
||||
m.offsiteWindowRetention(context.Background(), nil, nil, "after-run")
|
||||
if len(wr.forgets) != 1 {
|
||||
t.Fatalf("forgets = %v", wr.forgets)
|
||||
}
|
||||
got := strings.Join(wr.forgets[0], " ")
|
||||
if !strings.Contains(got, "forget a-full b-full --prune") || strings.Contains(got, "c-full") {
|
||||
t.Fatalf("forget = %q (want the two OLDEST, capped)", got)
|
||||
if len(wr.forgets) != 1 || !strings.Contains(strings.Join(wr.forgets[0], " "), "forget a-full b-full c-full --prune") {
|
||||
t.Fatalf("forget = %v", wr.forgets)
|
||||
}
|
||||
if len(fw.closed) != 1 || fw.closed[0].Outcome != "pruned" || fw.closed[0].ID != 9 {
|
||||
t.Fatalf("close = %+v", fw.closed)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan larger than one week's removal REFUSES (the 2026-10-04 brief), nothing removed.
|
||||
func TestOffsiteGuard_AboveWeeklyCapRefused(t *testing.T) {
|
||||
now := time.Now()
|
||||
var plan []guardSnap
|
||||
for i := 0; i < 6; i++ {
|
||||
plan = append(plan, snap(fmt.Sprintf("o%d", i), now.Add(-time.Duration(30+i)*24*time.Hour)))
|
||||
}
|
||||
ids, why := offsiteGuard(plan, plan, now, now, 5)
|
||||
if ids != nil || !strings.Contains(why, "more than one week") {
|
||||
t.Fatalf("ids=%v why=%q", ids, why)
|
||||
}
|
||||
}
|
||||
|
||||
// R-824, THE MEASURED SHAPE (demo-hp window 1, 2026-10-03): the night run's snapshots of each app were
|
||||
// superseded the SAME DAY by a manual run, so the honest policy removes them while they are young.
|
||||
// v0.289 refused the whole window; now they are EXCLUDED (kept for later) and the old removal goes ahead.
|
||||
func TestOffsiteGuard_SameDaySupersededYoungExcluded(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
pinTarget(t, sett)
|
||||
now := time.Now()
|
||||
day := now.Add(-26 * time.Hour).Truncate(24 * time.Hour)
|
||||
night := guardSnap{ID: "night-full", ShortID: "night", Time: day.Add(2 * time.Hour), Hostname: "demo-hp", Tags: []string{"opengist"}}
|
||||
manual := guardSnap{ID: "manual-full", ShortID: "manual", Time: day.Add(15 * time.Hour), Hostname: "demo-hp", Tags: []string{"opengist"}}
|
||||
old := guardSnap{ID: "old-full", ShortID: "old", Time: now.Add(-40 * 24 * time.Hour), Hostname: "demo-hp", Tags: []string{"opengist"}}
|
||||
wr := &windowRunner{snaps: []guardSnap{old, night, manual}, plan: []guardSnap{night, old}}
|
||||
m.SetOffboxRunner(wr.run)
|
||||
fw := &fakeWindow{grant: OffsiteWindow{Granted: true, ID: 2, NewestAllowed: now, MaxRemove: 5}}
|
||||
m.SetOffsiteWindowClient(fw)
|
||||
m.offsiteWindowRetention(context.Background(), nil, nil, "after-run")
|
||||
if len(fw.closed) != 1 || fw.closed[0].Outcome != "pruned" {
|
||||
t.Fatalf("the window must run, not refuse: %+v", fw.closed)
|
||||
}
|
||||
got := strings.Join(wr.forgets[0], " ")
|
||||
if !strings.Contains(got, "old-full") || strings.Contains(got, "night-full") {
|
||||
t.Fatalf("forget = %q (the young superseded copy must be KEPT for now)", got)
|
||||
}
|
||||
// A young removal with NO same-day successor in its group is still the poisoning signature.
|
||||
lone := guardSnap{ID: "lone-full", ShortID: "lone", Time: now.Add(-50 * time.Hour), Hostname: "demo-hp", Tags: []string{"bookstack"}}
|
||||
if _, why := offsiteGuard([]guardSnap{lone, manual}, []guardSnap{lone}, now, now, 5); !strings.Contains(why, "not superseded the same day") {
|
||||
t.Fatalf("why = %q", why)
|
||||
}
|
||||
}
|
||||
|
||||
// The orphan reset on the pinned tier asks the HUB; no ssh `mv` from the box.
|
||||
func TestResetOrphaned_PinnedAsksTheHub(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
@@ -212,30 +251,6 @@ func TestResetOrphaned_PinnedAsksTheHub(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Abandonment on the pinned tier: due → nothing deleted, the operator is told, the sweep goes quiet.
|
||||
func TestAbandon_PinnedDefersToOperator(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
pinTarget(t, sett)
|
||||
sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonRepoPath = "/home/felhom-repo.orphaned-20260901"
|
||||
o.AbandonStartedAt = time.Now().Add(-20 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
o.AbandonAt = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||
})
|
||||
m.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
|
||||
t.Fatal("the box tried to delete on the pinned tier")
|
||||
return nil, nil
|
||||
})
|
||||
var evs []string
|
||||
m.SetOffboxOrphanEvent(func(e, _ string) { evs = append(evs, e) })
|
||||
deleted, err := m.AbandonSweep(context.Background())
|
||||
if deleted || err != nil || len(evs) != 1 || evs[0] != "offbox_abandon_deferred" {
|
||||
t.Fatalf("deleted=%v err=%v events=%v", deleted, err, evs)
|
||||
}
|
||||
if again, _ := m.AbandonSweep(context.Background()); again {
|
||||
t.Fatal("second sweep deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// The provider's rclone notice must not reach a JSON parser — measured live on demo-felhom (v0.289.0).
|
||||
func TestStripRcloneNotice(t *testing.T) {
|
||||
in := "rclone: 2026/10/03 15:05:42 NOTICE: Config file \"/home/.config/rclone/rclone.conf\" not found - using defaults\n[{\"id\":\"s1\"}]\n"
|
||||
@@ -266,3 +281,65 @@ func TestRunOffbox_UnreadableCountIsNotZero(t *testing.T) {
|
||||
t.Fatalf("an unreadable count was recorded as a measured zero: %+v", got.SnapshotCount)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAbandon struct {
|
||||
requested []string
|
||||
state string
|
||||
cancels int
|
||||
due time.Time
|
||||
}
|
||||
|
||||
func (f *fakeAbandon) Request(_ context.Context, p string) (time.Time, error) {
|
||||
f.requested = append(f.requested, p)
|
||||
f.state = "pending"
|
||||
return f.due, nil
|
||||
}
|
||||
func (f *fakeAbandon) Status(context.Context) (string, error) { return f.state, nil }
|
||||
func (f *fakeAbandon) Cancel(context.Context) error { f.cancels++; f.state = "cancelled"; return nil }
|
||||
|
||||
// Decision 74 (R-823), the box side: a due abandonment on the pinned tier is HANDED to the hub (nothing
|
||||
// deleted by the box), the page keeps a dated, cancellable deletion, a recovery cancels it at the hub, and
|
||||
// a "deleted" from the hub completes the two-phase commit.
|
||||
func TestAbandon_PinnedHandsToHubAndFollows(t *testing.T) {
|
||||
m, sett := newOffboxManager(t)
|
||||
pinTarget(t, sett)
|
||||
setDue := func() {
|
||||
sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) {
|
||||
o.AbandonRepoPath = "/home/felhom-repo.orphaned-20260901"
|
||||
o.AbandonStartedAt = time.Now().Add(-20 * 24 * time.Hour).UTC().Format(time.RFC3339)
|
||||
o.AbandonAt = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
|
||||
})
|
||||
}
|
||||
setDue()
|
||||
m.SetOffboxSSH(func(context.Context, string, string, int, string, string, string) ([]byte, error) {
|
||||
t.Fatal("the box tried to delete on the pinned tier")
|
||||
return nil, nil
|
||||
})
|
||||
fa := &fakeAbandon{due: time.Now().Add(7 * 24 * time.Hour)}
|
||||
m.SetOffsiteAbandonClient(fa)
|
||||
if del, err := m.AbandonSweep(context.Background()); del || err != nil || len(fa.requested) != 1 {
|
||||
t.Fatalf("del=%v err=%v requested=%v", del, err, fa.requested)
|
||||
}
|
||||
st := m.AbandonStatus()
|
||||
if !st.Active || !st.HubPending || st.DaysLeft < 6 {
|
||||
t.Fatalf("the page would lose the dated deletion: %+v", st)
|
||||
}
|
||||
// Still pending: nothing happens, nothing re-requested.
|
||||
if del, _ := m.AbandonSweep(context.Background()); del || len(fa.requested) != 1 {
|
||||
t.Fatal("re-requested or deleted while pending")
|
||||
}
|
||||
// The household recovers → cancelled at the hub, countdown gone.
|
||||
m.CancelAbandon("recovery succeeded")
|
||||
if fa.cancels != 1 || m.AbandonStatus().Active {
|
||||
t.Fatalf("cancels=%d status=%+v", fa.cancels, m.AbandonStatus())
|
||||
}
|
||||
// Again, and this time the hub deletes.
|
||||
setDue()
|
||||
_, _ = m.AbandonSweep(context.Background())
|
||||
fa.state = "deleted"
|
||||
var evs []string
|
||||
m.SetOffboxOrphanEvent(func(e, _ string) { evs = append(evs, e) })
|
||||
if del, err := m.AbandonSweep(context.Background()); !del || err != nil || !m.AbandonStatus().PurgeRequested || len(evs) != 1 || evs[0] != "offbox_abandon_completed" {
|
||||
t.Fatalf("del=%v err=%v status=%+v evs=%v", del, err, m.AbandonStatus(), evs)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user