screen-locker/screen_locker/_manual_push.py
Krzysztof kuhy Rudnicki 79879263ba Support multiple workouts per day; fix the dead sync; ban silent failures
The workout log stored ONE workout per calendar day, so a second same-day
workout was silently dropped and could never earn credit.

Multiple workouts per day:
- workout_log.json: {date: entry} -> {date: [entry, ...]}. Legacy entries are
  wrapped on read (_log_io.load_workout_log) so their HMACs stay valid — no
  re-signing; every write normalizes the file, migrating it progressively.
- New write chokepoint (_log_mixin.write_signed_entry) APPENDS and dedups by a
  stable workout_id, returning RecordResult so callers can tell a new workout
  from a duplicate. Re-verifying the same workout is a no-op.
- count_weekly_workouts is anti-gaming: each VERIFIED workout counts
  individually; self-reported manual entries count at most once per day. Four
  real workouts in one day satisfy the week; four manual entries count as one.
- Reward: the day's first counted workout gets the base +2h; an additional
  same-day VERIFIED workout gets +1h; an additional manual gets nothing. The
  old "+1h when weekly_count > 4" gate is replaced — weekly volume is the
  banked bonus's job and shouldn't be paid twice.
- Fixed: the unlock screen labelled the +1h "#{weekly_count - 4}", which can
  render negative under the new rule. Removed the dead runnerup_manual type.
- Flutter: the manual budget counts distinct DAYS, matching the PC.

PC->phone sync (it had never worked once):
- push_pc_manuals pushed from a side-store written at log-time, so anything
  logged before that feature existed could never publish. _manual_push now
  derives the crdt log directly from workout_log.json — the single source of
  truth — covering manual AND verified runs. The record id is the workout's own
  workout_id (derived for legacy entries to match the phone's format) and the
  HLC comes from the entry's own timestamp, so a re-push is byte-identical and
  the repo never churns.
- The phone reads every kind (readMergedWorkoutPayloads) and lists the PC's
  workouts under "SYNCED FROM PC". The manual budget still sees manual-only, so
  a verified run can't consume a self-report slot.
- Settings claimed "Connected." whenever a token STRING existed. It now
  verifies against the API on open, so a revoked token reports 401 instead of
  showing a green lie.

Never fail silently:
- 30 swallowed exceptions across 19 files now log WARNING with the concrete
  cause AND consequence — plus the token guards in _workout_sync that returned
  [] in total silence, the exact bug that hid the dead sync for weeks.
- New pre-commit hook (scripts/check_silent_failures.py) fails any except that
  neither re-raises nor logs loudly. ruff's S110 only catches `except: pass`;
  the real pattern here was `except: log.info(); return []`. Documented in
  CLAUDE.md.

Verified on real data: the Jul 13 run (pulled from its actual TCX over ADB) now
counts alongside that day's manual squash, and all three workouts are published
to kuhyx/syncs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fv8ULVt3Q67GwCFbM7doHB
2026-07-17 09:22:31 +02:00

207 lines
7.3 KiB
Python

"""Push the PC's workouts to the shared sync repo.
``workout_log.json`` is the single source of truth: this module derives the
crdt-sync log directly from it and pushes to ``devices/pc/log.json``, so the
phone converges on the SAME history the PC has — manual workouts *and*
machine-verified ones (StrongLifts sessions, RunnerUp runs).
Deriving from the log (rather than a side-store written at log-time) means a
workout is published automatically no matter when or how it was recorded,
including entries logged before this module existed. Idempotence comes from two
properties: the record id is the workout's own stable ``workout_id``, and its
HLC is derived deterministically from the entry's own timestamp — so re-pushing
an unchanged log produces a byte-identical record set and no repo churn.
This reverses the old "the PC only ever reads, never pushes" invariant (it had
no data of its own to contribute). Pushing needs a sync token with
**contents:write** on the repo — a read-only token 403s. Nothing here fails
silently: every path returns a :class:`PushResult` whose ``reason`` says exactly
what happened, and logs it.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
import json
import logging
from typing import TYPE_CHECKING
from crdt_sync import (
GitHubSyncClient,
GitHubSyncError,
Hlc,
Record,
sync_log,
)
from screen_locker._constants import (
SYNC_REPO_NAME,
SYNC_REPO_OWNER,
SYNC_TIMEOUT_SECONDS,
SYNC_TOKEN_FILE,
)
from screen_locker._log_io import load_workout_log
from screen_locker._log_mixin import _derive_workout_id
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
from screen_locker._workout_sync import _DEVICES_PREFIX, read_sync_token
if TYPE_CHECKING:
from pathlib import Path
from crdt_sync import Log
_logger = logging.getLogger(__name__)
_PC_DEVICE_ID = "pc"
_PAYLOAD_FIELD = "payload"
@dataclass(frozen=True)
class PushResult:
"""Outcome of :func:`push_pc_workouts` — never silent.
``reason`` always states what happened in plain words, whether or not the
push succeeded, so a caller (and the log) can tell "nothing to do" apart
from "it broke".
"""
pushed: bool
record_count: int
reason: str
def _encode_log(log: Log) -> str:
"""Serialize a crdt-sync log to JSON (record id -> record dict)."""
return json.dumps({rid: record.to_dict() for rid, record in log.items()})
def _decode_log(text: str) -> Log:
"""Parse a crdt-sync log blob back into ``{id: Record}``."""
raw = json.loads(text)
return {rid: Record.from_dict(data) for rid, data in raw.items()}
def _entry_wall_ms(entry: dict, date: str) -> int:
"""Return the entry's timestamp in epoch ms, falling back to its date.
The HLC is derived from this, so it must be stable for a given entry — the
workout's own recorded time is exactly that. A missing/unparsable timestamp
falls back to that day's midnight UTC (still stable) and says so in the log,
rather than silently inventing "now", which would churn the repo on every
push.
"""
raw = entry.get("timestamp")
if isinstance(raw, str):
try:
return int(datetime.fromisoformat(raw).timestamp() * 1000)
except ValueError:
_logger.warning(
"Workout entry for %s has an unparsable timestamp (%r) — using "
"that date's midnight for its sync clock",
date,
raw,
)
else:
_logger.warning(
"Workout entry for %s has no timestamp — using that date's midnight "
"for its sync clock",
date,
)
midnight = datetime.fromisoformat(date).replace(tzinfo=timezone.utc)
return int(midnight.timestamp() * 1000)
def records_from_workout_log(log_file: Path) -> dict[str, Record]:
"""Derive the crdt-sync log from ``workout_log.json``.
Every counted workout becomes one record keyed by its ``workout_id`` — the
same id the phone mints for its own records, so the two sides dedup to one
rather than doubling. Entries written before ``workout_id`` existed get
theirs derived the same way it would have been, so history publishes too.
The payload is the workout's own data plus the ``kind``/``date`` the wire
contract requires.
"""
log: dict[str, Record] = {}
for date, entries in load_workout_log(log_file).items():
for entry in entries:
workout_data = entry.get("workout_data", {})
if not isinstance(workout_data, dict):
continue
if workout_data.get("type") not in COUNTED_WORKOUT_TYPES:
continue
record_id = entry.get("workout_id") or _derive_workout_id(
date, workout_data
)
if not record_id:
_logger.warning(
"Workout on %s (type=%r) has no derivable id — NOT synced",
date,
workout_data.get("type"),
)
continue
payload = {**workout_data, "kind": workout_data["type"], "date": date}
hlc = Hlc.new_tick(_PC_DEVICE_ID, wall_time_ms=_entry_wall_ms(entry, date))
log[str(record_id)] = Record(
id=str(record_id), fields={_PAYLOAD_FIELD: (payload, hlc)}
)
return log
def push_pc_workouts(log_file: Path) -> PushResult:
"""Publish every counted workout in ``log_file`` to ``devices/pc/log.json``.
Runs one full sync tick, so it also retries anything a previous push failed
to publish. It never raises into the caller — but it is never silent either:
the returned :class:`PushResult` and a WARNING say why a push did not happen.
"""
token = read_sync_token()
if token is None:
reason = f"no sync token at {SYNC_TOKEN_FILE}"
_logger.warning(
"Workouts NOT synced: %s — create a fine-grained GitHub PAT with "
"contents:write on %s/%s and save it there (chmod 600)",
reason,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
return PushResult(pushed=False, record_count=0, reason=reason)
log = records_from_workout_log(log_file)
if not log:
reason = f"no counted workouts in {log_file}"
_logger.warning("Workouts NOT synced: %s", reason)
return PushResult(pushed=False, record_count=0, reason=reason)
client = GitHubSyncClient(
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
token,
timeout_seconds=SYNC_TIMEOUT_SECONDS,
)
try:
sync_log(
client=client,
device_id=_PC_DEVICE_ID,
path_prefix=_DEVICES_PREFIX,
local_log=log,
encode=_encode_log,
decode=_decode_log,
)
except GitHubSyncError as exc:
reason = f"sync error: {exc}"
_logger.warning(
"Workout sync push FAILED for %d workout(s): %s — a 403 here means "
"the token lacks contents:write on %s/%s",
len(log),
exc,
SYNC_REPO_OWNER,
SYNC_REPO_NAME,
)
return PushResult(pushed=False, record_count=len(log), reason=reason)
_logger.info(
"Synced %d workout(s) to %s/%s", len(log), SYNC_REPO_OWNER, SYNC_REPO_NAME
)
return PushResult(pushed=True, record_count=len(log), reason="pushed")