v1.46.5: audit of every automatic deletion

Owner's decision after the incident: a detached plan is never deleted, at any
age. v1.46.4 gave it a month; this makes it permanent and, more importantly,
writes the reasoning where the next change will trip over it — docs/SCOPE.md now
carries the standing rule. The component may delete a file only when a user
action says so. 'Nothing points at this any more' is not such an action, because
the two errors are not symmetrical: wasted disk is visible, cheap and
reversible; a deleted file is none of those.

Went through every other automatic deletion with the same question. One more
was wrong: houseplan/files/cleanup rmtree'd whatever folder the card named. A
partial migration leaves urls pointing into it — files/migrate deliberately does
not rewrite the ones it could not confirm — so those were live links to files
being deleted; and a wrong or stale id from any client destroyed a live device's
manuals. The server now reads the stored config under its lock and removes only
what nothing references, keeping the rest and saying so.

Also: a plan of a DELETED space now waits thirty days rather than an hour.
Deleting a space is deliberate; an hour is a short window to notice a misclick.

The rest came out clean: layout/delete and marker/room/space removal are all
confirm-guarded user actions, upload temporaries are never user-visible, and
dropping legacy 'segments' is a documented migration.
This commit is contained in:
Matysh
2026-07-28 20:36:17 +03:00
parent f953a3c286
commit 33e71ca96c
17 changed files with 213 additions and 50 deletions
+18 -8
View File
@@ -240,11 +240,12 @@ def collect_plans(
# other file of its own is a superseded or rejected upload, so the short
# rule is right for those. Getting this distinction wrong (protecting
# nothing) destroyed two detached plans on 2026-07-28.
detached = {
str(sp.get("id")) for sp in (new_cfg or {}).get("spaces") or []
if not sp.get("plan_url")
}
cutoff = (time.time() if now is None else now) - PLAN_ORPHAN_TTL_S
# The short rule fits exactly one case: a space that HAS a plan, where any
# other file of its own can only be a superseded or rejected upload.
spaces = {str(sp.get("id")): sp.get("plan_url") for sp in (new_cfg or {}).get("spaces") or []}
now_s = time.time() if now is None else now
reject_cutoff = now_s - PLAN_ORPHAN_TTL_S
cutoff = now_s - SCHEDULED_GRACE_S
removed = 0
try:
items = sorted(plans_dir.iterdir()) if plans_dir.is_dir() else []
@@ -259,10 +260,19 @@ def collect_plans(
continue
superseded = item.name in old_refs
if not superseded:
if item.name.split(".")[0] in detached:
continue # detached, not abandoned — the space is waiting for it
space = item.name.split(".")[0]
if space in spaces and not spaces[space]:
# PRODUCT RULE (owner's decision, 2026-07-28): a detached plan
# is never deleted, at any age. The space is there and currently
# has no plan — the image was detached, one click undoes that,
# and the editor says the file stays. The two errors are not
# symmetrical: a few megabytes we did not need can always be
# removed by hand, a file we should not have removed cannot be
# brought back. When in doubt, keep it.
continue
limit = reject_cutoff if space in spaces else cutoff
try:
if item.stat().st_mtime >= cutoff:
if item.stat().st_mtime >= limit:
continue
except OSError:
continue