Finish the wonderland: System told truthfully, in eight tabs instead of ten
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -39,6 +39,13 @@ KEEP = 15
|
||||
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
|
||||
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
|
||||
|
||||
# A label somebody types, kept inside the envelope rather than in the filename.
|
||||
# The name on disk stays the timestamp SNAPSHOT_RE describes: it is what
|
||||
# orders the list, what prune and restore match against, and what confines a
|
||||
# restore to this directory. A user-supplied filename would put all three of
|
||||
# those in the caller's hands.
|
||||
LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,79}$")
|
||||
|
||||
|
||||
class BackupError(RuntimeError):
|
||||
pass
|
||||
@@ -210,12 +217,27 @@ def clean_transaction_artifacts() -> None:
|
||||
fsync_directory(TRANSACTION_PARENT)
|
||||
|
||||
|
||||
# Quickshell's FileView writes atomically through QSaveFile, which stages at
|
||||
# "<name>.XXXXXX" -- no leading dot, six random characters -- and renames. A
|
||||
# shell killed mid-write leaves that file behind forever: nothing ever looks at
|
||||
# it again, and ~/.config/panama slowly fills with half-remembered copies of
|
||||
# the settings store. Panama's own writer uses the dotted prefixes above, so
|
||||
# this pattern is only ever somebody else's leftover.
|
||||
QSAVEFILE_RE = re.compile(r"^(settings\.json|panama-home\.json)\.[A-Za-z0-9]{6}$")
|
||||
|
||||
# A write in flight looks exactly like a leaked one. Only files older than this
|
||||
# are swept, which is several orders of magnitude longer than a settings write
|
||||
# takes and short enough that nobody accumulates them.
|
||||
STALE_AFTER_SECONDS = 3600
|
||||
|
||||
|
||||
def clean_stale_atomic_files() -> None:
|
||||
locations = (
|
||||
(SETTINGS.parent, (".settings.json.",)),
|
||||
(HOME_STATE.parent, (".panama-home.json.",)),
|
||||
(BACKUP_DIR, (".settings-",)),
|
||||
)
|
||||
now = time.time()
|
||||
for directory, prefixes in locations:
|
||||
if not directory.exists():
|
||||
continue
|
||||
@@ -223,8 +245,15 @@ def clean_stale_atomic_files() -> None:
|
||||
fail(f"{directory} is not a safe directory.")
|
||||
changed = False
|
||||
for child in directory.iterdir():
|
||||
if not any(child.name.startswith(prefix) for prefix in prefixes):
|
||||
continue
|
||||
owned = any(child.name.startswith(prefix) for prefix in prefixes)
|
||||
if not owned:
|
||||
if QSAVEFILE_RE.fullmatch(child.name) is None:
|
||||
continue
|
||||
try:
|
||||
if now - child.stat().st_mtime < STALE_AFTER_SECONDS:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Only Panama's hidden atomic-write names are eligible. A matching
|
||||
# directory is unexpected and is never recursively removed.
|
||||
if child.is_dir() and not child.is_symlink():
|
||||
@@ -359,7 +388,23 @@ def prune_snapshots() -> None:
|
||||
durable_remove(old)
|
||||
|
||||
|
||||
def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
|
||||
def sanitize_label(raw: str) -> str:
|
||||
"""A typed name, reduced to what can safely sit in a JSON envelope.
|
||||
|
||||
Collapsed rather than refused: somebody who typed two spaces or a trailing
|
||||
one meant the obvious thing, and losing their snapshot over it would be
|
||||
absurd. Anything still outside the charset after that is refused, because
|
||||
at that point they typed something this does not mean to carry.
|
||||
"""
|
||||
collapsed = " ".join(raw.split())
|
||||
if not collapsed:
|
||||
return ""
|
||||
if LABEL_RE.fullmatch(collapsed) is None:
|
||||
fail("That name cannot be used.")
|
||||
return collapsed
|
||||
|
||||
|
||||
def save_snapshot(*, require_any: bool, validate: bool, label: str = "") -> Path | None:
|
||||
try:
|
||||
desktop_present, desktop = current_store(
|
||||
SETTINGS, "The current settings file"
|
||||
@@ -386,6 +431,11 @@ def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
|
||||
envelope["desktop"]["data"] = desktop
|
||||
if home_present:
|
||||
envelope["home"]["data"] = home
|
||||
# Absent rather than empty when there is no label, so a snapshot taken
|
||||
# automatically before a risky action is distinguishable from one somebody
|
||||
# deliberately named "".
|
||||
if label:
|
||||
envelope["label"] = label
|
||||
|
||||
destination = next_snapshot_path()
|
||||
atomic_write_json(destination, envelope)
|
||||
@@ -515,6 +565,37 @@ def command_save(arguments: list[str]) -> None:
|
||||
print(json.dumps({"saved": destination.name}, separators=(",", ":")))
|
||||
|
||||
|
||||
def command_create(arguments: list[str]) -> None:
|
||||
"""save, with a name on it.
|
||||
|
||||
Kept as its own verb rather than an optional argument to `save`: `save`
|
||||
already takes live Home state as its first argument, and overloading that
|
||||
position by type is how a Home payload eventually gets read as a label.
|
||||
"""
|
||||
label = sanitize_label(arguments[0]) if arguments else ""
|
||||
if len(arguments) > 1:
|
||||
write_live_home(arguments[1])
|
||||
destination = save_snapshot(require_any=True, validate=True, label=label)
|
||||
assert destination is not None
|
||||
print(json.dumps({"saved": destination.name, "label": label},
|
||||
separators=(",", ":")))
|
||||
|
||||
|
||||
def command_delete(arguments: list[str]) -> None:
|
||||
"""Remove one snapshot, named the way restore names one.
|
||||
|
||||
Goes through snapshot_source, which is what confines the name to this
|
||||
directory -- the same gate a restore passes. A delete that resolved paths
|
||||
its own way would be a second boundary to keep correct, and the weaker of
|
||||
the two is the one that gets used.
|
||||
"""
|
||||
if not arguments:
|
||||
fail("Which snapshot?")
|
||||
source = snapshot_source(arguments[0])
|
||||
durable_remove(source)
|
||||
print(json.dumps({"deleted": source.name}, separators=(",", ":")))
|
||||
|
||||
|
||||
def snapshot_files() -> list[Path]:
|
||||
ensure_directory(BACKUP_DIR)
|
||||
return sorted(
|
||||
@@ -533,6 +614,7 @@ def snapshot_files() -> list[Path]:
|
||||
def command_list() -> None:
|
||||
output: list[dict[str, Any]] = []
|
||||
for path in snapshot_files():
|
||||
label = ""
|
||||
try:
|
||||
value = read_json(path, "A snapshot")
|
||||
if is_v2_envelope(value):
|
||||
@@ -540,6 +622,9 @@ def command_list() -> None:
|
||||
keys = len(desktop["data"]) if desktop["present"] else 0
|
||||
else:
|
||||
keys = len(value)
|
||||
raw_label = value.get("label")
|
||||
if isinstance(raw_label, str) and LABEL_RE.fullmatch(raw_label):
|
||||
label = raw_label
|
||||
except BackupError:
|
||||
keys = 0
|
||||
raw = path.name.removeprefix("settings-").removesuffix(".json")
|
||||
@@ -547,7 +632,14 @@ def command_list() -> None:
|
||||
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} "
|
||||
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}"
|
||||
)
|
||||
output.append({"name": path.name, "when": pretty, "keys": keys})
|
||||
# Size on disk, so the page can say what fifteen snapshots actually
|
||||
# cost rather than leaving it as an unbounded mystery.
|
||||
try:
|
||||
size = path.stat().st_size
|
||||
except OSError:
|
||||
size = 0
|
||||
output.append({"name": path.name, "when": pretty, "keys": keys,
|
||||
"bytes": size, "label": label})
|
||||
print(json.dumps(output, separators=(",", ":")))
|
||||
|
||||
|
||||
@@ -628,12 +720,17 @@ def main() -> None:
|
||||
arguments = sys.argv[2:]
|
||||
if command == "save":
|
||||
command_save(arguments)
|
||||
elif command == "create":
|
||||
command_create(arguments)
|
||||
elif command == "list":
|
||||
command_list()
|
||||
elif command == "restore":
|
||||
command_restore(arguments)
|
||||
elif command == "delete":
|
||||
command_delete(arguments)
|
||||
else:
|
||||
fail("usage: panama-settings-backup [save|list|restore <name>]")
|
||||
fail("usage: panama-settings-backup "
|
||||
"[save|create [name]|list|restore <name>|delete <name>]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user