v1.46.6: the detach promise, actually kept this time

v1.46.4 and v1.46.5 documented that detaching a plan leaves the image on disk,
added guards for it, and shipped tests. The guards were never reached: they sit
behind 'not superseded', and a file that left the configuration was called
superseded. From old_refs - new_refs alone, replacing a plan, detaching one and
deleting its space are indistinguishable — so all three deleted the file, at the
moment of the save, before any scheduled pass ever ran.

Every test I wrote for this called collect_plans(d, cfg, cfg): old config equal
to new, i.e. only the scheduled pass. The transition that mattered was never
exercised. Codex reproduced it in four lines.

Classification is by owner now:
  space in both, plan A -> plan B  : the user picked another image -> removed
  space in both, plan -> none      : detached -> kept
  space gone                       : kept (the image was imported; a thirty-day
                                     grace measured from file age is meaningless
                                     anyway, it was uploaded months ago)
  space has a plan, other file     : rejected upload -> 1 h
Attachments follow the same shape: dropped from a device that still exists ->
removed (a trash button promises nothing); device gone -> kept; staging folder
-> 1 h.

Tests: a matrix per rule in the pure module, and — the part that was missing —
test_detaching_a_plan_keeps_the_file, which goes through real config/set calls:
attach, detach, assert the file is there, restart, assert again, re-attach,
replace, assert the replaced one is gone, delete the space, assert the plan
survives. Also strengthened the sweep/save race test to assert the save actually
succeeded and the config points at the specific expected file, per the report.
This commit is contained in:
Matysh
2026-07-28 21:11:11 +03:00
parent 2c7a2f849d
commit 9868f1035f
15 changed files with 288 additions and 123 deletions
+58 -24
View File
@@ -121,14 +121,21 @@ def collect_attachments(
) -> int:
"""The same commit-scoped rule as `collect_plans`, for marker attachments.
A file the old revision referenced and the new one does not was superseded
by this commit and goes. Otherwise a staging folder (`up_*` — only ever a
dialog that was never saved) is collected after PLAN_ORPHAN_TTL_S, and
anything else waits out SCHEDULED_GRACE_S. Never raises: it runs behind a
durable write.
A file the old revision referenced and the new one does not, whose marker
still exists, was removed on purpose — the dialog has a trash button and
promises nothing. It goes. If the marker itself is gone, that is a different
transition and the files are kept, like a deleted space's plan. A staging
folder (`up_*` — only ever a dialog that was never saved) is collected after
PLAN_ORPHAN_TTL_S, anything else after SCHEDULED_GRACE_S. Never raises: it
runs behind a durable write.
"""
new_refs = attachment_refs(new_cfg)
old_refs = attachment_refs(old_cfg)
# Removing an attachment from a device that still exists is the user saying
# "drop this one" — a trash button, no promise that anything is kept. A
# device that is GONE is a different transition, and its files follow the
# same rule as a deleted space's plan: kept.
live_markers = {str(m.get("id")) for m in (new_cfg or {}).get("markers") or []}
# Same distinction as for plans. A staging folder (`up_*`) is different: it
# only ever holds an upload from a dialog that was never saved, so the short
# rule is exactly right there even on the timer.
@@ -144,10 +151,9 @@ def collect_attachments(
removed += sweep_upload_temps(files_dir, now)
for folder in folders:
# A staging folder only ever holds an upload from a dialog that was never
# saved — unambiguous, so an hour is right. A marker's own folder is not:
# removing an attachment is deliberate, but so is re-adding one, and the
# file may have been detached rather than abandoned. Give it a month.
limit = staging_cutoff if folder.name.startswith("up_") else cutoff
# saved — unambiguous, so an hour is right, and no device owns it.
staging = folder.name.startswith("up_")
limit = staging_cutoff if staging else cutoff
try:
items = sorted(p for p in folder.iterdir() if p.is_file())
except OSError:
@@ -156,7 +162,15 @@ def collect_attachments(
rel = f"{folder.name}/{item.name}"
if rel in new_refs:
continue
if rel not in old_refs: # not superseded: absence alone is weak evidence
dropped = rel in old_refs and folder.name in live_markers
if not dropped:
if not staging and folder.name not in live_markers:
# No device owns this folder any more. Whether the file was
# attached once or is a leftover upload we cannot tell, and
# by the standing rule that means we keep it. (A staging
# folder is exempt above: it has no device by construction.)
continue
# the device is there and never listed this file: a rejected upload
try:
if item.stat().st_mtime >= limit:
continue
@@ -196,6 +210,14 @@ def plan_refs(cfg: dict[str, Any] | None) -> set[str]:
return out
def plan_by_space(cfg: dict[str, Any] | None) -> dict[str, str]:
"""space id -> the plan file it references ('' when it has none)."""
return {
str(sp.get("id")): plan_basename(sp.get("plan_url"))
for sp in (cfg or {}).get("spaces") or []
}
def is_plan_file(name: str) -> bool:
"""Does this look like a plan we wrote: <space>.<ext> or <space>.<token>.<ext>?"""
parts = name.split(".")
@@ -242,10 +264,19 @@ def collect_plans(
# nothing) destroyed two detached plans on 2026-07-28.
# 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 []}
old_by_space = plan_by_space(old_cfg)
new_by_space = plan_by_space(new_cfg)
# A file that left the configuration tells us nothing on its own: replacing a
# plan, detaching one and deleting a space all look identical from
# `old_refs - new_refs`. Only the first is a deletion the user asked for
# (HP-1465-01 — the guards below were written and then never reached,
# because the code decided "superseded" before asking why).
replaced = {
name for space, name in old_by_space.items()
if new_by_space.get(space) and new_by_space[space] != name
}
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 []
@@ -258,21 +289,24 @@ def collect_plans(
for item in items:
if not item.is_file() or item.name in new_refs or not is_plan_file(item.name):
continue
superseded = item.name in old_refs
if not superseded:
if item.name not in replaced:
# Not a replacement. PRODUCT RULE (owner's decision, 2026-07-28):
# a file we were not told to delete is kept, however long it sits
# there. Detaching a plan is one click to undo and the editor says
# the image stays; deleting a space is deliberate but the image is
# usually something the user imported and may not have elsewhere.
# The two errors are not symmetrical — a few unnecessary megabytes
# can always be removed by hand, a file we should not have removed
# cannot be brought back.
#
# The single exception: a space that HAS a plan, and another file of
# its own that has never been the plan. That can only be an upload
# whose save was rejected, and an hour is plenty 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.
if not new_by_space.get(space) or item.name in old_by_space.values():
continue
limit = reject_cutoff if space in spaces else cutoff
try:
if item.stat().st_mtime >= limit:
if item.stat().st_mtime >= reject_cutoff:
continue
except OSError:
continue