screen-locker/scripts/tombstone_sync_record.py
Krzysztof kuhy Rudnicki 965b9c4ebb Sync: Firebase only — drop the GitHub transport (kuhyx/syncs stays a frozen archive)
The PC push aborted before it ever tried Firebase whenever the GitHub PAT
was missing ("Workouts NOT synced: no sync token"), so once the PAT was lost
this machine published nothing although Firebase was configured and live.
The phone has not written the GitHub repo since 2026-08-21.

- sync_client() / sync_client_or_reason(): firebase_client_for("screen_locker")
  only, proven with can_access_remote(), healed once from a sibling app's
  session, else a warning naming what will not sync (push AND pull) plus a
  recorded degraded source. No config -> warning, no fallback.
- _manual_push and scripts/tombstone_sync_record.py take their client from
  sync_client_or_reason(); the push result carries the reason.
- Deleted: read_sync_token, remote_client, the mirror client, _push_outcome
  (the half-landed-push case no longer exists), SYNC_TOKEN_FILE and
  SYNC_REPO_OWNER/NAME, the sync_token test fixture. Status backend is
  "firebase" or "none".
- crdt-sync pinned to v0.9.2 in pyproject, requirements.txt and
  install_systemd.sh (the installer still said v0.9.0).

kuhyx/syncs/screen-locker-sync is left untouched as an archive; no code
reads it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
2026-10-09 19:18:56 +02:00

128 lines
4.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Tombstone one workout record in the shared sync store, durably.
Deletion in crdt_sync is monotonic: once a device log carries ``deleted=True``
for an id, every merge keeps it deleted. But ``sync_log`` pulls only the OTHER
devices' logs and ``_manual_push`` re-derives the PC's log from ``log.json``
every tick, so a tombstone written once to the PC's device log is gone by the
next push (seen 2026-09-13: "tombstoned", then "LIVE" five seconds later).
So this records the id in ``screen_locker/sync_tombstones.json``
(``_sync_tombstones``), which every push carries, then runs one sync tick and
reports what the store holds afterwards.
Written for ``manual:2026-09-13T14:30``: a synced copy of a football already
on disk, filed under a second id by that morning's day-key repair. It shares
the original's credit (``_weekly_check.credit_key``) and stays in the signed
log -- this repo annotates, never deletes -- but its sync record made every
15-minute tick log "budget exhausted -- not ingested".
Dry-run by default: pulls the store and reports whether the id is live.
Rerunnable: an id already in the ledger is left as it is.
Usage:
python3 scripts/tombstone_sync_record.py manual:2026-09-13T14:30
python3 scripts/tombstone_sync_record.py manual:2026-09-13T14:30 --apply \
--reason "synced duplicate of manual:2026-09-12T14:30"
"""
from __future__ import annotations
import argparse
import logging
from pathlib import Path
import sys
from crdt_sync import (
FileSyncStateStore,
LogCodec,
Record,
RevisionTracking,
SyncTarget,
sync_log,
)
from screen_locker._constants import SYNC_STATE_FILE
from screen_locker._device import device_identity
from screen_locker._manual_push import (
_decode_log,
_encode_log,
records_from_workout_log,
)
from screen_locker._sync_tombstones import add_tombstone
from screen_locker._workout_sync import _DEVICES_PREFIX, sync_client_or_reason
_logger = logging.getLogger(__name__)
# The same path ScreenLocker resolves at runtime (screen_lock.py), as the
# sibling scripts do; there is no package constant for it.
LOG_FILE = Path(__file__).resolve().parent.parent / "screen_locker" / "log.json"
def _report(message: str) -> None:
"""Write one line to stdout (matches the other scripts in this directory)."""
sys.stdout.write(f"{message}\n")
def _sync(local_log: dict[str, Record]) -> dict[str, Record]:
"""Run one sync tick with *local_log* as this device's view; return the merge."""
identity = device_identity()
client, reason = sync_client_or_reason()
if client is None:
msg = f"cannot reach the Firebase sync store: {reason}"
raise SystemExit(msg)
return sync_log(
SyncTarget(
client=client,
device_id=identity.device_id,
legacy_device_id=identity.legacy_id,
path_prefix=_DEVICES_PREFIX,
),
local_log,
LogCodec(decode=_decode_log, encode=_encode_log),
RevisionTracking(state_store=FileSyncStateStore(SYNC_STATE_FILE)),
)
def main() -> int:
"""Entry point."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"record_id", help="sync record id, e.g. manual:2026-09-13T14:30"
)
parser.add_argument("--apply", action="store_true", help="write the tombstone")
parser.add_argument("--reason", default="", help="why (kept in the ledger)")
args = parser.parse_args()
logging.basicConfig(
level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s"
)
if args.apply:
if not args.reason:
parser.error("--apply needs --reason so the ledger says why")
if add_tombstone(args.record_id, device_identity().device_id, args.reason):
_report(f"{args.record_id}: added to the tombstone ledger")
else:
_report(f"{args.record_id}: already in the tombstone ledger")
# Exactly what _manual_push pushes on a tick: log.json plus the ledger.
local_log = records_from_workout_log(LOG_FILE)
merged = _sync(local_log)
record = merged.get(args.record_id)
if record is None:
_report(
f"{args.record_id}: not in the store on any device — nothing to tombstone"
)
return 1
if record.deleted:
verb = "tombstoned" if args.apply else "already tombstoned"
_report(f"{args.record_id}: {verb} (deleted_hlc={record.deleted_hlc})")
return 0
_report(
f"{args.record_id}: LIVE in the store — re-run with --apply to tombstone it"
)
return 0
if __name__ == "__main__":
sys.exit(main())