mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
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
210 lines
8.0 KiB
Python
210 lines
8.0 KiB
Python
"""Push the PC's workouts to the shared Firebase sync store.
|
|
|
|
``log.json`` is the single source of truth: this module derives the
|
|
crdt-sync log directly from it and pushes to this device's log, 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 store churn.
|
|
|
|
This reverses the old "the PC only ever reads, never pushes" invariant (it had
|
|
no data of its own to contribute). Firebase is the only transport since
|
|
2026-10-09; the old GitHub mirror is a frozen archive. 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 UTC, datetime
|
|
import json
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
from crdt_sync import (
|
|
FileSyncStateStore,
|
|
Hlc,
|
|
LogCodec,
|
|
Record,
|
|
RemoteSyncError,
|
|
RevisionTracking,
|
|
SyncTarget,
|
|
sync_log,
|
|
)
|
|
|
|
from screen_locker._constants import SYNC_STATE_FILE
|
|
from screen_locker._device import device_identity
|
|
from screen_locker._log_io import load_workout_log
|
|
from screen_locker._log_mixin import _derive_workout_id
|
|
from screen_locker._sync_retry import with_sync_retry
|
|
from screen_locker._sync_tombstones import tombstone_records
|
|
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
|
|
from screen_locker._workout_sync import _DEVICES_PREFIX, sync_client_or_reason
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
from crdt_sync import Log
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_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=UTC)
|
|
return int(midnight.timestamp() * 1000)
|
|
|
|
|
|
def records_from_workout_log(log_file: Path) -> dict[str, Record]:
|
|
"""Derive the crdt-sync log from ``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.
|
|
"""
|
|
identity = device_identity()
|
|
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(
|
|
identity.device_id, wall_time_ms=_entry_wall_ms(entry, date)
|
|
)
|
|
log[str(record_id)] = Record(
|
|
id=str(record_id), fields={_PAYLOAD_FIELD: (payload, hlc)}
|
|
)
|
|
# Re-pushed every tick: sync_log never reads this device's own remote
|
|
# log, so a tombstone written once is overwritten by the next push.
|
|
log.update(tombstone_records())
|
|
return log
|
|
|
|
|
|
def push_pc_workouts(log_file: Path) -> PushResult:
|
|
"""Publish every counted workout in ``log_file`` to this device's log.
|
|
|
|
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.
|
|
"""
|
|
identity = device_identity()
|
|
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, reason = sync_client_or_reason()
|
|
if client is None:
|
|
# sync_client_or_reason has already said why, in full; this line
|
|
# ties that cause to the records it just stranded.
|
|
_logger.warning(
|
|
"Workouts NOT synced: %d workout(s) stay local — %s", len(log), reason
|
|
)
|
|
return PushResult(pushed=False, record_count=len(log), reason=reason)
|
|
try:
|
|
with_sync_retry(
|
|
lambda: sync_log(
|
|
SyncTarget(
|
|
client=client,
|
|
device_id=identity.device_id,
|
|
legacy_device_id=identity.legacy_id,
|
|
path_prefix=_DEVICES_PREFIX,
|
|
),
|
|
log,
|
|
LogCodec(
|
|
decode=_decode_log,
|
|
encode=_encode_log,
|
|
),
|
|
RevisionTracking(
|
|
state_store=FileSyncStateStore(SYNC_STATE_FILE),
|
|
),
|
|
),
|
|
description="push PC workouts",
|
|
)
|
|
except RemoteSyncError as exc:
|
|
# Report what actually happened, nothing more: this used to assert
|
|
# "a 403 means the token lacks contents:write" for every failure,
|
|
# including a plain network error, which sent a 2026-07-20
|
|
# investigation chasing a permissions bug that did not exist.
|
|
reason = f"sync error: {exc}"
|
|
_logger.warning("Workout sync push FAILED for %d workout(s): %s", len(log), exc)
|
|
return PushResult(pushed=False, record_count=len(log), reason=reason)
|
|
|
|
_logger.info("Synced %d workout(s) to Firebase", len(log))
|
|
return PushResult(pushed=True, record_count=len(log), reason="pushed")
|