screen-locker/screen_locker/_workout_sync.py
Krzysztof kuhy Rudnicki 233536d275 Manual-workout phone sync P3: PC pushes its manual entries to pc/log.json
Two-way sync: the PC now publishes its own manual workouts so the phone can see
them for the shared budget. Reverses the old "the PC never pushes" invariant.

- _manual_push.py (new): a small crdt-sync store of PC-originated manuals
  (manual_sync_log.json, next to workout_log.json). record_pc_manual() appends
  the form's entry (wrapped as the shared kind/date payload under a stable
  manual: id, monotonic HLC); push_pc_manuals() runs one sync_log tick to
  devices/pc/log.json. Best-effort: no token, empty store, or a 403 from a
  read-only token is swallowed so the local form still works.
- _manual_workout_dialog.py: record the manual locally on form save (fast, both
  hosts) — published on the next lock startup.
- screen_lock.py: push_pc_manuals() runs in the startup sync step (also retries
  any manual a previous push failed to publish).
- Updated the now-false "PC only ever reads, never pushes" docstrings in
  _workout_sync.py and _constants.py; noted the contents:write PAT requirement.

A PC-pushed record round-trips through the same ingestion decoder, so it is
ingestable by any device. Full suite green at 100% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM3auNTifaVgcXWDizZrjH
2026-07-13 11:29:41 +02:00

192 lines
6.6 KiB
Python

"""Pulls phone/other-device workouts via crdt-sync's GitHub transport.
GitHub is used purely as dumb file storage (see ``crdt_sync``'s own docs) --
the phone app pushes its completed-workout log to a private repo; this module
reads the session log and every device's manual-workout records. (The PC does
push its OWN manual workouts, but via :mod:`screen_locker._manual_push`, not
here.) Sync is optional: an unconfigured token is a normal, expected state,
not an error -- unlike diet_guard, where sync is core to the app.
"""
from __future__ import annotations
import json
import logging
from crdt_sync import GitHubSyncClient, GitHubSyncError, Hlc, Record
from screen_locker._constants import (
SYNC_PHONE_DEVICE_ID,
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from screen_locker._manual_workout import MANUAL_WORKOUT_SYNC_KIND
_logger = logging.getLogger(__name__)
_DEVICES_PREFIX = "screen-locker-sync/devices"
_LOG_PATH = f"{_DEVICES_PREFIX}/{SYNC_PHONE_DEVICE_ID}/log.json"
_PAYLOAD_FIELD = "payload"
def _is_manual_payload(payload: object) -> bool:
"""True if a decoded sync payload is a manual-workout record."""
return isinstance(payload, dict) and payload.get("kind") == MANUAL_WORKOUT_SYNC_KIND
def read_sync_token() -> str | None:
"""Return the saved sync PAT, or None if sync isn't configured.
Unlike diet_guard's equivalent, an absent or empty token file is a
normal state here -- sync is an optional primary channel, not something
the app requires to function.
"""
if not SYNC_TOKEN_FILE.exists():
return None
token = SYNC_TOKEN_FILE.read_text().strip()
return token or None
def _latest_payload(log_json: str) -> dict | None:
"""Return the most recently pushed workout payload from a log.json blob.
Raises:
TypeError: If the top-level JSON isn't an object, a record's shape
doesn't match what :meth:`Record.from_dict` expects, or the
payload field itself isn't a JSON object.
KeyError: If a record is missing an expected key.
ValueError: Via ``json.loads``, if the text isn't valid JSON, or via
:meth:`crdt_sync.Hlc.from_str` on a malformed clock string.
"""
raw = json.loads(log_json)
if not isinstance(raw, dict):
msg = f"top-level sync payload is not a JSON object: {raw!r}"
raise TypeError(msg)
records = [Record.from_dict(data) for data in raw.values()]
candidates = [
record
for record in records
if _PAYLOAD_FIELD in record.fields
and not _is_manual_payload(record.fields[_PAYLOAD_FIELD][0])
]
if not candidates:
return None
latest = max(candidates, key=lambda record: record.fields[_PAYLOAD_FIELD][1])
payload = latest.fields[_PAYLOAD_FIELD][0]
if not isinstance(payload, dict):
msg = f"payload field is not a JSON object: {payload!r}"
raise TypeError(msg)
return payload
def pull_synced_workout() -> tuple[dict | None, str | None]:
"""Return ``(data, error)``: the phone's last-synced workout, if any.
- No token configured: ``(None, None)`` -- benign, sync is optional.
- Nothing pushed yet (repo reachable, path unused): ``(None, None)``.
- A real sync error (network, bad token, repo unreachable):
``(None, <message>)``.
- Corrupt/malformed sync data: ``(None, "corrupt sync data: ...")``.
- Success: ``(payload_dict, None)``.
"""
token = read_sync_token()
if token is None:
return None, None
client = GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
try:
text = client.get_file_text(_LOG_PATH)
except GitHubSyncError as exc:
return None, str(exc)
if text is None:
return None, None
try:
payload = _latest_payload(text)
except (ValueError, KeyError, TypeError) as exc:
_logger.warning("Corrupt sync data at %s: %s", _LOG_PATH, exc)
return None, f"corrupt sync data: {exc}"
return payload, None
def _manual_records(log_json: str) -> dict[str, tuple[dict, Hlc]]:
"""Return ``{record_id: (payload, hlc)}`` for manual records in a log blob.
Raises the same decode errors as :func:`_latest_payload`; callers treat a
corrupt device log as "no manual records from that device".
"""
raw = json.loads(log_json)
if not isinstance(raw, dict):
msg = f"top-level sync payload is not a JSON object: {raw!r}"
raise TypeError(msg)
result: dict[str, tuple[dict, Hlc]] = {}
for data in raw.values():
record = Record.from_dict(data)
field = record.fields.get(_PAYLOAD_FIELD)
if field is None:
continue
payload, hlc = field
if _is_manual_payload(payload):
result[record.id] = (payload, hlc)
return result
def pull_all_manual_records() -> list[tuple[str, dict]]:
"""Return all synced manual-workout records across every device log.
Merges every ``devices/<device>/log.json`` under the sync prefix (phone,
pc, …), keeping the highest-HLC copy of each record id — records are
id-stable, so the same workout mirrored into two device logs dedups to one.
Best-effort: an unconfigured token, an unreachable repo, or a corrupt device
log yields fewer/no records rather than raising — manual sync, like session
sync, is optional.
"""
token = read_sync_token()
if token is None:
return []
client = GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
try:
devices = client.list_directory(_DEVICES_PREFIX)
except GitHubSyncError as exc:
_logger.info("Manual sync unavailable (%s)", exc)
return []
merged: dict[str, tuple[dict, Hlc]] = {}
for device in devices:
path = f"{_DEVICES_PREFIX}/{device}/log.json"
try:
text = client.get_file_text(path)
except GitHubSyncError as exc:
_logger.info("Skipping device log %s (%s)", path, exc)
continue
if text is None:
continue
try:
records = _manual_records(text)
except (ValueError, KeyError, TypeError) as exc:
_logger.warning("Corrupt sync data at %s: %s", path, exc)
continue
for rid, (payload, hlc) in records.items():
existing = merged.get(rid)
if existing is None or existing[1] < hlc:
merged[rid] = (payload, hlc)
return [(rid, payload) for rid, (payload, _hlc) in merged.items()]