mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:07:16 +01:00
The morning routine is started by systemd right after boot and right after
resume, so its sync ran seconds before DHCP/DNS were up. Every such run failed
outright — and said this:
Workout sync push FAILED for 6 workout(s): network error reading
screen-locker-sync/devices — a 403 here means the token lacks
contents:write on kuhyx/syncs
Both halves of that were wrong. It was not a 403, and the token was fine: the
same six workouts pushed successfully by hand minutes later on the same token,
which reports push=true on the repo. The message cost a real investigation
chasing a permissions bug that did not exist.
- _sync_retry.with_sync_retry wraps the three sync call sites in a bounded
retry (4 attempts, 2s/4s/8s backoff — ~14s, enough for the network to come
up, short enough not to drag the routine). Verified offline in a network
namespace: three retries, then an honest give-up.
- Any GitHubSyncError retries, deliberately including RepoNotFoundError.
Classifying transient vs permanent would couple us to which exception
crdt_sync chains where; a few wasted seconds on a genuinely bad token is the
cheaper mistake.
- The push failure log now reports what actually happened. The contents:write
hint survives only for RepoNotFoundError, which is the one error that
actually implicates the repo or the token.
Retry at the application layer rather than ordering the unit after
network-online.target: the same race hits on resume, where boot ordering does
not apply, and these are user units that cannot depend on a system target.
The tests' autouse no-sleep fixture patches the module-local `sleep` name
rather than `_sync_retry.time.sleep` — the latter mutates the shared time
module object and silently disabled sleep for the whole suite, breaking an
unrelated temperature timeout test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
237 lines
8.3 KiB
Python
237 lines
8.3 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
|
|
from screen_locker._sync_retry import with_sync_retry
|
|
|
|
_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:
|
|
_logger.warning(
|
|
"Cannot pull the synced StrongLifts session: no sync token at %s — "
|
|
"GitHub sync is OFF, so only ADB/HTTP can verify a phone workout. "
|
|
"Create a fine-grained PAT with contents:write on %s/%s and save it "
|
|
"there (chmod 600)",
|
|
SYNC_TOKEN_FILE,
|
|
SYNC_REPO_OWNER,
|
|
SYNC_REPO_NAME,
|
|
)
|
|
return None, None
|
|
|
|
client = GitHubSyncClient(
|
|
SYNC_REPO_OWNER,
|
|
SYNC_REPO_NAME,
|
|
token,
|
|
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
|
)
|
|
try:
|
|
text = with_sync_retry(
|
|
lambda: client.get_file_text(_LOG_PATH),
|
|
description="fetch the phone's workout log",
|
|
)
|
|
except GitHubSyncError as exc:
|
|
_logger.warning(
|
|
"Could not fetch the phone's workout log %s from %s/%s: %s — "
|
|
"falling back to ADB/HTTP verification",
|
|
_LOG_PATH,
|
|
SYNC_REPO_OWNER,
|
|
SYNC_REPO_NAME,
|
|
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:
|
|
_logger.warning(
|
|
"Pulling NO synced manual workouts: no sync token at %s — GitHub "
|
|
"sync is OFF, so workouts logged on the phone will NOT count here. "
|
|
"Create a fine-grained PAT with contents:write on %s/%s and save it "
|
|
"there (chmod 600)",
|
|
SYNC_TOKEN_FILE,
|
|
SYNC_REPO_OWNER,
|
|
SYNC_REPO_NAME,
|
|
)
|
|
return []
|
|
|
|
client = GitHubSyncClient(
|
|
SYNC_REPO_OWNER,
|
|
SYNC_REPO_NAME,
|
|
token,
|
|
timeout_seconds=SYNC_TIMEOUT_SECONDS,
|
|
)
|
|
try:
|
|
devices = with_sync_retry(
|
|
lambda: client.list_directory(_DEVICES_PREFIX),
|
|
description="list synced device logs",
|
|
)
|
|
except GitHubSyncError as exc:
|
|
_logger.warning(
|
|
"Could not list device logs under %s in %s/%s: %s — pulling NO "
|
|
"synced manual workouts, so phone-logged workouts will not count",
|
|
_DEVICES_PREFIX,
|
|
SYNC_REPO_OWNER,
|
|
SYNC_REPO_NAME,
|
|
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.warning(
|
|
"Could not fetch device log %s: %s — SKIPPING that device, so "
|
|
"any manual workouts it holds will not count",
|
|
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()]
|