screen-locker/screen_locker/_source_health.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

120 lines
4.4 KiB
Python

"""Why a workout source is untrustworthy, in words a human can act on.
Two different failures produced the same blank screen on 2026-08-24:
* Firebase raised -- the credential no longer authenticated. Loud, but only
in the journal.
* The GitHub mirror did *not* raise. It answered promptly and completely with
data that had stopped changing on 2026-08-15, because the phone had quietly
stopped writing to it. Nothing in the code compared a record's age to now,
so a backend that had been dead for nine days looked identical to a backend
reporting "no workouts yet today".
The second is the dangerous one: an exception at least leaves a traceback. A
silent staleness leaves a confident, wrong answer. Both are surfaced here as
the same kind of finding, so the lock screen can explain itself instead of
accusing the user of skipping a workout they actually did.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from screen_locker._degraded_sources import degraded_sources
# A phone that has synced nothing for this long is not "quiet", it is broken.
# Two days spans a rest day plus a travel day without crying wolf, while still
# catching the nine-day silence that made 2026-08-24 unexplainable.
STALE_AFTER = timedelta(days=2)
@dataclass(frozen=True)
class SourceFinding:
"""One reason a workout source cannot be trusted right now."""
name: str
headline: str
detail: str
def describe_staleness(
name: str, newest: datetime | None, *, now: datetime | None = None
) -> SourceFinding | None:
"""Return a finding when *name*'s newest record is too old, else None.
Args:
name: The backend, as the user would name it ("Firebase").
newest: Timestamp of the most recent record it returned, or None when
it returned nothing at all.
now: Injected for tests.
"""
instant = now if now is not None else datetime.now(tz=UTC)
if newest is None:
return SourceFinding(
name=name,
headline=f"{name} returned no workouts at all",
detail=(
f"{name} answered, but with an empty log. Either nothing has "
"ever synced from your phone, or this backend is not the one "
"your phone writes to."
),
)
age = instant - newest.astimezone(UTC)
if age < STALE_AFTER:
return None
return SourceFinding(
name=name,
headline=f"{name} has not changed in {age.days} days",
detail=(
f"The newest workout {name} knows about is from "
f"{newest.date().isoformat()} — {age.days} days ago. It is "
"answering, so nothing looks broken, but your phone has stopped "
"writing to it. A workout logged today would not appear here."
),
)
def collect_source_findings(
*, newest_synced: datetime | None = None, now: datetime | None = None
) -> list[SourceFinding]:
"""Gather every reason the workout sources may be lying right now.
Combines the two failure shapes: backends that raised (recorded by
``_sync_client``) and backends that answered with stale data.
"""
findings = [
SourceFinding(
name=source.name,
headline=f"{source.name} could not be read at all",
detail=(
f"{source.name} refused the connection: {source.reason}. Your "
"phone syncs workouts there, so anything logged today is "
"invisible to this machine until that is fixed."
),
)
for source in degraded_sources()
]
stale = describe_staleness("the workout sync", newest_synced, now=now)
if stale is not None:
findings.append(stale)
return findings
def explain_findings(findings: list[SourceFinding]) -> str:
"""Render findings as the block of text shown on the lock screen.
Deliberately plain prose: this is read by someone who just finished a
two-hour workout and is being told it does not count. "Sync error" is
not an explanation; naming the backend, the age and the consequence is.
"""
if not findings:
return ""
lines = ["Why this may be wrong:"]
lines.extend(f"• {finding.headline}\n {finding.detail}" for finding in findings)
lines.append(
"If you did work out, this is a sync fault, not a missed workout — "
"use TRY AGAIN, or log it once sync is restored."
)
return "\n".join(lines)