mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
manual:2026-09-13T14:30 -- the synced copy of the football that this
morning's day-key rekey let back in under a second id -- made every
workout-sync tick log "Manual-workout budget exhausted -- not ingested".
Tombstoning it once did not stick: sync_log pulls only the OTHER devices'
logs and _manual_push re-derives the PC's log from log.json each tick, so
the next push overwrote the tombstone ("tombstoned", then "LIVE" five
seconds later). Deletion is monotonic only for records still in the merge.
- New _sync_tombstones: {id: {deleted_hlc, reason, tombstoned_at}} in
screen_locker/sync_tombstones.json (gitignored with its siblings). The
HLC is minted once and stored so re-pushing does not churn the store.
The ledger path is resolved at call time, not as a default argument, so
the test suite's redirect actually applies -- verified by hashing the
real ledger before and after the run.
- _manual_push.records_from_workout_log merges the ledger over the live
derivation, so every push carries the tombstones.
- scripts/tombstone_sync_record.py <id> [--apply --reason ...]: dry run
reports live/tombstoned; --apply writes the ledger, runs one tick and
reports what the store holds afterwards. Rerunnable.
- tests/_isolated_state.py redirects SYNC_TOMBSTONES_FILE (both bindings).
Verified: --apply -> "tombstoned"; a second ordinary tick -> "already
tombstoned" with the same HLC; workout-sync.service now logs "Sync record
manual:2026-09-13T14:30 is tombstoned -- skipping it" and the budget line
for that id is gone. log.json untouched (the local entry stays; it shares
the original's credit per duplicate_credits.json).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vgacLhb4WJjxuVj2QLEP
112 lines
3.9 KiB
Python
112 lines
3.9 KiB
Python
"""Durable tombstones for workout records the PC must keep deleted in sync.
|
|
|
|
``_manual_push`` re-derives this device's sync log from ``log.json`` on every
|
|
tick and pushes it whole, and ``crdt_sync.sync_log`` pulls only the *other*
|
|
devices' logs — so a tombstone written once to the PC's device log is
|
|
overwritten by the very next push (observed 2026-09-13: ``--apply`` reported
|
|
"tombstoned", the dry run five seconds later reported "LIVE"). Deletion is
|
|
monotonic only for records that are still *in* the merge.
|
|
|
|
The fix is to make the tombstone part of what the PC pushes every time. This
|
|
module owns that ledger: ``{record_id: {deleted_hlc, reason, tombstoned_at}}``
|
|
in ``SYNC_TOMBSTONES_FILE``. The HLC is minted once and stored, so re-pushing
|
|
the same tombstone does not churn the store.
|
|
|
|
The signed ``log.json`` is never touched — a tombstone only stops the record
|
|
from syncing; the local entry stays (see ``scripts/annotate_duplicate_credits.py``
|
|
for why this repo annotates rather than deletes).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from crdt_sync import Hlc, Record
|
|
|
|
from screen_locker._constants import SYNC_TOMBSTONES_FILE
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _load(path: Path) -> dict[str, dict[str, Any]]:
|
|
"""Read the ledger; a missing file is an empty ledger, a corrupt one is loud."""
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
raw = json.loads(path.read_text())
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
_logger.warning(
|
|
"Sync tombstone ledger %s is unreadable (%s) — pushing NO tombstones "
|
|
"this tick, so a record deleted here may resurface on other devices",
|
|
path,
|
|
exc,
|
|
)
|
|
return {}
|
|
if not isinstance(raw, dict):
|
|
_logger.warning(
|
|
"Sync tombstone ledger %s is not a JSON object — ignoring it", path
|
|
)
|
|
return {}
|
|
return {str(rid): entry for rid, entry in raw.items() if isinstance(entry, dict)}
|
|
|
|
|
|
def _ledger_path(path: Path | None) -> Path:
|
|
"""Resolve the ledger path at call time, so a patched module global applies.
|
|
|
|
A default argument would bind the real file at import time and the test
|
|
suite's redirect (``tests/_isolated_state.py``) would silently miss it.
|
|
"""
|
|
return SYNC_TOMBSTONES_FILE if path is None else path
|
|
|
|
|
|
def add_tombstone(
|
|
record_id: str,
|
|
device_id: str,
|
|
reason: str,
|
|
path: Path | None = None,
|
|
) -> bool:
|
|
"""Record *record_id* as deleted. Returns False if it already was."""
|
|
path = _ledger_path(path)
|
|
ledger = _load(path)
|
|
if record_id in ledger:
|
|
return False
|
|
ledger[record_id] = {
|
|
"deleted_hlc": Hlc.new_tick(device_id).to_str(),
|
|
"reason": reason,
|
|
"tombstoned_at": datetime.now(tz=UTC).isoformat(),
|
|
}
|
|
path.write_text(json.dumps(ledger, indent=2, sort_keys=True) + "\n")
|
|
return True
|
|
|
|
|
|
def tombstone_records(path: Path | None = None) -> dict[str, Record]:
|
|
"""Return every ledgered tombstone as a crdt_sync :class:`Record`.
|
|
|
|
Entries without a parsable ``deleted_hlc`` are skipped with a warning
|
|
rather than pushed clockless: a tombstone with no HLC still deletes, but
|
|
it cannot be ordered against a later un-delete on another device.
|
|
"""
|
|
records: dict[str, Record] = {}
|
|
for record_id, entry in _load(_ledger_path(path)).items():
|
|
hlc_str = entry.get("deleted_hlc")
|
|
try:
|
|
hlc = Hlc.from_str(str(hlc_str))
|
|
except (ValueError, TypeError) as exc:
|
|
_logger.warning(
|
|
"Sync tombstone %s has no usable deleted_hlc (%r: %s) — not pushed",
|
|
record_id,
|
|
hlc_str,
|
|
exc,
|
|
)
|
|
continue
|
|
records[record_id] = Record(
|
|
id=record_id, fields={}, deleted=True, deleted_hlc=hlc
|
|
)
|
|
return records
|