mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Tell "could not check" apart from "no workout", and stop double-granting bonuses
A real 1h57m workout on 2026-08-24 synced to Firebase and the locker still locked the screen. The trigger was auth: screen_locker was the only app with no cached firebase_auth.json, so it fell back to a stale shared password and got HTTP 400 every run. The defect was what it did next — an unreadable primary source was reported as "no workout" rather than "could not check", which is the same conflation that took 2026-06-12. Recover the session by borrowing a live refresh token from a sibling app, track degraded sources as state instead of a log line, and say so on the lock screen: name the backend, the error, and how stale the data is, so a sync fault reads as a sync fault. Also fix two bugs found while finishing the recovery: restore_and_bonus.py banked `existing + bonus` with no idempotence guard while finish_2026_08_24.sh advertised "safe to re-run" — a second run silently granted the compensation hours again. Guard on a durable `shutdown_bonus_granted_for` list; the bonus map alone cannot answer the question, since a week's total mixes earned streak hours with corrections. _extra_benefits._save_state rebuilt state from four hardcoded keys, so the next weekly rollover would have dropped that guard and re-armed the double-grant. Merge onto loaded state instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
This commit is contained in:
parent
5438ccad25
commit
16ada6e5a2
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,6 +23,7 @@ screen_locker/sick_history.json
|
||||
screen_locker/extra_benefits_state.json
|
||||
screen_locker/scheduled_skips.json
|
||||
screen_locker/shutdown_base.json
|
||||
screen_locker/sick_day_state.json
|
||||
screen_locker/early_bird_pending.json
|
||||
|
||||
# ci_mirror.sh cached requirements.txt venv (rebuilt on demand)
|
||||
|
||||
@ -90,6 +90,12 @@ repos:
|
||||
- --disable-error-code=redundant-cast
|
||||
- --disable-error-code=empty-body
|
||||
- --disable-error-code=list-item
|
||||
# The hook runs in its own isolated venv, so stubs installed into the
|
||||
# project venv are invisible to it. Without this, the Firebase session
|
||||
# recovery's `import requests` fails as import-untyped here while
|
||||
# type-checking cleanly everywhere else.
|
||||
additional_dependencies:
|
||||
- types-requests>=2.31.0
|
||||
|
||||
- repo: https://github.com/pylint-dev/pylint
|
||||
rev: v3.3.2
|
||||
|
||||
26
AGENTS.md
Normal file
26
AGENTS.md
Normal file
@ -0,0 +1,26 @@
|
||||
do NOT run tests unless specifically instructed to do so or before committing
|
||||
If tests fail on the same issue twice in a row, STOP and ask the user how to proceed instead of continuing to fix and retry.
|
||||
ALWAYS confirm that the feature you add / bug you fixed behaves as it should by running the program after your changes (not tests!) and inspecting output comparing it with what user wanted, after confirming by yourself ask user if the program behaves as they intended
|
||||
After running tests fix all coverage gaps and issues, do not ignore unless specifically instructed to do so
|
||||
You are NOT done until you install the new version on the phone itself (flutter install --debug from the workout_app directory, then adb shell monkey -p com.kuhy.workout_app to launch).
|
||||
|
||||
## NEVER fail silently
|
||||
|
||||
A failure that says nothing is a bug. The PC's workout sync did nothing for
|
||||
weeks because a missing token `return`ed `[]` and real errors only logged at
|
||||
`INFO` — nobody could tell "nothing to do" from "it broke".
|
||||
|
||||
- **Swallowed exceptions must say why, loudly.** Every `except` must either
|
||||
re-`raise` or log at `warning`/`error`/`exception`/`critical` with the
|
||||
concrete reason. `debug`/`info` do NOT count — an invisible log is a silent
|
||||
failure with extra steps. Enforced by `scripts/check_silent_failures.py`
|
||||
(pre-commit hook `no-silent-failures`); ruff's `S110` only catches
|
||||
`except: pass`, which is the rare case.
|
||||
- **Guard clauses that abort work must log too.** `if token is None: return`
|
||||
is a silent no-op. Say what was missing and what it means.
|
||||
- **Return something meaningful.** Prefer a typed result (e.g.
|
||||
`_manual_push.PushResult(pushed, record_count, reason)`) over a bare
|
||||
`None`/`[]`/`False`, so the caller can react instead of guessing. `reason`
|
||||
should read like a sentence a human can act on.
|
||||
- **No escape hatch** — `# noqa` is banned repo-wide. If a failure really is
|
||||
benign, log it at `warning` and move on.
|
||||
@ -14,4 +14,9 @@ pytest-cov>=4.1.0
|
||||
pytest-randomly>=3.15.0
|
||||
pytest-sugar>=1.0.0
|
||||
pytest-xdist>=3.5.0
|
||||
# Direct dependency since the Firebase session recovery exchanges a refresh
|
||||
# token over the REST endpoint; crdt-sync already pulls requests in, but this
|
||||
# module imports it itself, so it is declared rather than assumed.
|
||||
requests>=2.31.0
|
||||
ruff>=0.8.0
|
||||
types-requests>=2.31.0
|
||||
|
||||
@ -33,14 +33,18 @@ from screen_locker._compliance_trace import (
|
||||
AutoUpgradeOpportunity,
|
||||
LockExplanation,
|
||||
PredicateResult,
|
||||
StageContext,
|
||||
_stage,
|
||||
describe_auto_upgrade_opportunity,
|
||||
describe_degraded_sources,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
from screen_locker._sync_client import DegradedSource
|
||||
|
||||
__all__ = [
|
||||
"AutoUpgradeOpportunity",
|
||||
@ -67,6 +71,7 @@ def explain_lock_decision(
|
||||
relaxed_day: bool,
|
||||
wake_skip: bool = False,
|
||||
now: datetime | None = None,
|
||||
degraded_sources: Sequence[DegradedSource] = (),
|
||||
) -> LockExplanation:
|
||||
"""Re-derive the lock-decision chain without ADB, sudo, or writes.
|
||||
|
||||
@ -100,6 +105,7 @@ def explain_lock_decision(
|
||||
)
|
||||
|
||||
trace: list[PredicateResult] = []
|
||||
stage_context = StageContext(trace, auto_upgrade, bool(degraded_sources))
|
||||
|
||||
def _check(
|
||||
name: str,
|
||||
@ -117,8 +123,7 @@ def explain_lock_decision(
|
||||
fired=False,
|
||||
stage=name,
|
||||
reason=terminal_reason,
|
||||
trace=trace,
|
||||
auto_upgrade=auto_upgrade,
|
||||
context=stage_context,
|
||||
)
|
||||
return None
|
||||
|
||||
@ -166,11 +171,12 @@ def explain_lock_decision(
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
degraded_note = describe_degraded_sources(degraded_sources)
|
||||
result = _check(
|
||||
"already_logged",
|
||||
fired=logged,
|
||||
reason_true="A validly-signed workout is already logged for today.",
|
||||
reason_false="No workout logged for today yet.",
|
||||
reason_false="No workout logged for today yet." + degraded_note,
|
||||
terminal_reason="Workout already logged today — lock skipped.",
|
||||
)
|
||||
if result is not None:
|
||||
@ -236,8 +242,7 @@ def explain_lock_decision(
|
||||
reason=(
|
||||
"No skip condition applies. The real locker still checks live "
|
||||
"Warsaw temperature before showing the full lock — not "
|
||||
"evaluated here."
|
||||
"evaluated here." + degraded_note
|
||||
),
|
||||
trace=trace,
|
||||
auto_upgrade=auto_upgrade,
|
||||
context=stage_context,
|
||||
)
|
||||
|
||||
@ -10,6 +10,12 @@ Nothing here touches ADB, sudo, subprocess, the network or the disk.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from screen_locker._sync_client import DegradedSource
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -40,6 +46,10 @@ class LockExplanation:
|
||||
trace: tuple[PredicateResult, ...]
|
||||
auto_upgrade: AutoUpgradeOpportunity
|
||||
heat_skip_evaluated: bool
|
||||
# True when a workout backend could not be read on this run, so "no
|
||||
# workout logged" is an absence of evidence rather than evidence of
|
||||
# absence. Defaulted so every existing construction site stays valid.
|
||||
sources_degraded: bool = False
|
||||
|
||||
|
||||
_NO_UPGRADE = AutoUpgradeOpportunity(
|
||||
@ -83,20 +93,52 @@ def describe_auto_upgrade_opportunity(
|
||||
return _NO_UPGRADE
|
||||
|
||||
|
||||
def describe_degraded_sources(sources: Sequence[DegradedSource]) -> str:
|
||||
"""Return a clause naming the backends that could not be read, or "".
|
||||
|
||||
"No workout logged" is only trustworthy when every backend answered. On
|
||||
2026-08-24 Firebase was unreadable and the phone had already stopped
|
||||
writing to the GitHub mirror, so an empty log was reported as though the
|
||||
user had simply not trained -- and a screen was locked after a two-hour
|
||||
session. Naming the dark source turns that verdict into a question.
|
||||
"""
|
||||
if not sources:
|
||||
return ""
|
||||
named = ", ".join(f"{src.name} ({src.reason})" for src in sources)
|
||||
return (
|
||||
f" Could not read: {named} — a workout logged there would be "
|
||||
"invisible here, so this is 'unverified', not 'no workout'."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StageContext:
|
||||
"""The parts of a terminal stage that outlive a single predicate.
|
||||
|
||||
Bundled into one object rather than passed as loose keywords: the trace
|
||||
and the two run-wide flags travel together through every call site, and
|
||||
threading them separately pushed ``_stage`` past the argument limit.
|
||||
"""
|
||||
|
||||
trace: list[PredicateResult]
|
||||
auto_upgrade: AutoUpgradeOpportunity
|
||||
sources_degraded: bool = False
|
||||
|
||||
|
||||
def _stage(
|
||||
*,
|
||||
fired: bool,
|
||||
stage: str,
|
||||
reason: str,
|
||||
trace: list[PredicateResult],
|
||||
auto_upgrade: AutoUpgradeOpportunity,
|
||||
context: StageContext,
|
||||
) -> LockExplanation:
|
||||
"""Build a terminal :class:`LockExplanation` from the trace so far."""
|
||||
return LockExplanation(
|
||||
fired=fired,
|
||||
stage=stage,
|
||||
reason=reason,
|
||||
trace=tuple(trace),
|
||||
auto_upgrade=auto_upgrade,
|
||||
trace=tuple(context.trace),
|
||||
auto_upgrade=context.auto_upgrade,
|
||||
heat_skip_evaluated=False,
|
||||
sources_degraded=context.sources_degraded,
|
||||
)
|
||||
|
||||
128
screen_locker/_credential_recovery.py
Normal file
128
screen_locker/_credential_recovery.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""Self-healing for a dead Firebase session, using credentials already here.
|
||||
|
||||
``crdt_sync`` caches a refresh token per app at
|
||||
``~/.config/<app>/firebase_auth.json`` and falls back to the shared password
|
||||
only when that cache is missing. On 2026-08-24 ``screen_locker`` had no cache
|
||||
and the shared password had gone stale, so every run failed with
|
||||
``INVALID_LOGIN_CREDENTIALS`` -- while ``diet_guard`` and ``wake_alarm`` sat
|
||||
in sibling directories holding live refresh tokens for the *same* account and
|
||||
the *same* project, refreshed minutes earlier.
|
||||
|
||||
Every app here signs in as one personal account against one project (see
|
||||
``~/.config/crdt-sync/firebase.json``), so any sibling's refresh token mints a
|
||||
valid session for us. That makes this recovery purely local: no password, no
|
||||
browser, no human. The alternative -- a person copying a JSON file between
|
||||
directories -- is a habit, not a fix, and habits are what failed twice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import ConfigError, RemoteSyncError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_CACHE_NAME = "firebase_auth.json"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryResult:
|
||||
"""What a recovery attempt did, and why.
|
||||
|
||||
``reason`` always reads like a sentence: this runs unattended inside a
|
||||
systemd unit, so the journal line is the only thing a human will ever see.
|
||||
"""
|
||||
|
||||
recovered: bool
|
||||
reason: str
|
||||
donor: str | None = None
|
||||
|
||||
|
||||
def find_sibling_refresh_token(
|
||||
config_root: Path, *, skip: str
|
||||
) -> tuple[str, str] | None:
|
||||
"""Return ``(app_name, refresh_token)`` from a sibling app's cache.
|
||||
|
||||
Args:
|
||||
config_root: The directory holding per-app config dirs (``~/.config``).
|
||||
skip: Our own app name, whose cache is the thing being replaced.
|
||||
|
||||
Returns:
|
||||
The first usable sibling credential, or ``None`` when none exists.
|
||||
"""
|
||||
if not config_root.is_dir():
|
||||
return None
|
||||
for cache in sorted(config_root.glob(f"*/{_CACHE_NAME}")):
|
||||
app_name = cache.parent.name
|
||||
if app_name == skip:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(cache.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
# One corrupt sibling must not hide the healthy ones behind it.
|
||||
_logger.warning(
|
||||
"Skipping unreadable credential cache %s: %s — continuing the "
|
||||
"search for another app's live refresh token",
|
||||
cache,
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
token = data.get("refresh_token")
|
||||
if isinstance(token, str) and token:
|
||||
return app_name, token
|
||||
return None
|
||||
|
||||
|
||||
def recover_session(
|
||||
*,
|
||||
config_root: Path,
|
||||
app_name: str,
|
||||
mint: Callable[[str], None],
|
||||
) -> RecoveryResult:
|
||||
"""Rebuild our Firebase session from a sibling app's refresh token.
|
||||
|
||||
``mint`` is injected rather than imported so this module stays free of
|
||||
network code and the tests never reach Firebase.
|
||||
|
||||
Args:
|
||||
config_root: The directory holding per-app config dirs.
|
||||
app_name: Our app name, both the skip target and the cache we write.
|
||||
mint: Exchanges a refresh token for a session and persists it.
|
||||
"""
|
||||
found = find_sibling_refresh_token(config_root, skip=app_name)
|
||||
if found is None:
|
||||
reason = (
|
||||
f"no sibling app under {config_root} holds a Firebase refresh "
|
||||
"token, so the session cannot be rebuilt locally — sign in once "
|
||||
"on this machine to seed one"
|
||||
)
|
||||
_logger.warning("Firebase recovery failed: %s", reason)
|
||||
return RecoveryResult(recovered=False, reason=reason)
|
||||
|
||||
donor, refresh_token = found
|
||||
try:
|
||||
mint(refresh_token)
|
||||
except (OSError, ValueError, RuntimeError, ConfigError, RemoteSyncError) as exc:
|
||||
reason = f"borrowed {donor}'s refresh token but it was rejected: {exc}"
|
||||
_logger.warning("Firebase recovery failed: %s", reason)
|
||||
return RecoveryResult(recovered=False, reason=reason)
|
||||
|
||||
_logger.info(
|
||||
"Firebase session rebuilt for %s from %s's cached refresh token — no "
|
||||
"password or human step was needed",
|
||||
app_name,
|
||||
donor,
|
||||
)
|
||||
return RecoveryResult(
|
||||
recovered=True,
|
||||
reason=f"rebuilt the session from {donor}'s cached refresh token",
|
||||
donor=donor,
|
||||
)
|
||||
@ -139,15 +139,21 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
rewards.append(f"Streak reset (was {streak} weeks of 5+ workouts)")
|
||||
streak = 0
|
||||
|
||||
_save_state(
|
||||
state_file,
|
||||
# Merge onto the loaded state rather than rebuilding it from these four
|
||||
# keys: sibling fields written by the recovery scripts (e.g.
|
||||
# ``shutdown_bonus_granted_for``, the guard that stops a compensation
|
||||
# bonus being granted twice) live in this file too, and rebuilding would
|
||||
# drop them at the next weekly rollover — silently re-arming a double-grant.
|
||||
updated = dict(state)
|
||||
updated.update(
|
||||
{
|
||||
"consecutive_5plus_weeks": streak,
|
||||
"last_processed_iso_week": current_week_str,
|
||||
"weekly_shutdown_bonus_hours": weekly_bonus_hours,
|
||||
"extended_early_bird_iso_weeks": eb_weeks,
|
||||
},
|
||||
}
|
||||
)
|
||||
_save_state(state_file, updated)
|
||||
return rewards
|
||||
|
||||
|
||||
|
||||
@ -35,6 +35,11 @@ class SickHistory:
|
||||
commitments: dict[str, bool] = field(default_factory=dict)
|
||||
broken_commitments: list[str] = field(default_factory=list)
|
||||
justifications: list[dict[str, Any]] = field(default_factory=list)
|
||||
# Sick days later disproved by a workout the locker could not read at the
|
||||
# time. Kept beside the justifications rather than folded into them: those
|
||||
# entries are HMAC-signed over the whole dict, so annotating one in place
|
||||
# would break verification and look like tampering.
|
||||
revocations: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
|
||||
def _today_iso() -> str:
|
||||
|
||||
@ -69,6 +69,7 @@ def load_history() -> SickHistory:
|
||||
commitments=dict(data.get("commitments", {})),
|
||||
broken_commitments=list(data.get("broken_commitments", [])),
|
||||
justifications=list(data.get("justifications", [])),
|
||||
revocations=list(data.get("revocations", [])),
|
||||
)
|
||||
|
||||
|
||||
@ -80,6 +81,7 @@ def save_history(history: SickHistory) -> bool:
|
||||
"commitments": history.commitments,
|
||||
"broken_commitments": history.broken_commitments,
|
||||
"justifications": history.justifications,
|
||||
"revocations": history.revocations,
|
||||
}
|
||||
try:
|
||||
with SICK_HISTORY_FILE.open("w") as f:
|
||||
|
||||
119
screen_locker/_source_health.py
Normal file
119
screen_locker/_source_health.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""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 datetime, timedelta, timezone
|
||||
|
||||
from screen_locker._sync_client 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 ("GitHub mirror").
|
||||
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=timezone.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(timezone.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)
|
||||
@ -28,6 +28,7 @@ from screen_locker._decision_log import (
|
||||
)
|
||||
from screen_locker._extra_benefits import process_week_transition
|
||||
from screen_locker._shutdown_base import reset_to_base_if_new_day
|
||||
from screen_locker._sync_client import degraded_sources
|
||||
from screen_locker._sync_mixin import SyncMixin
|
||||
from screen_locker._temperature import fetch_current_temp_with_status
|
||||
from screen_locker._weekly_check import (
|
||||
@ -61,6 +62,16 @@ class StartupChecksMixin(SyncMixin):
|
||||
journal could not distinguish "5/5, correctly skipped" from "0/5, should
|
||||
have locked".
|
||||
"""
|
||||
# A backend that could not be read makes weekly_count a floor, not a
|
||||
# count. Say so on the decision line itself: on 2026-08-24 the only
|
||||
# trace of a dead Firebase was a warning 90 seconds earlier, and the
|
||||
# DECISION line read "weekly=0/5" as if that zero were a measurement.
|
||||
unreadable = degraded_sources()
|
||||
if unreadable:
|
||||
extra = {
|
||||
**extra,
|
||||
"unreadable_sources": ",".join(src.name for src in unreadable),
|
||||
}
|
||||
record_decision(
|
||||
LockDecision(
|
||||
locked=locked,
|
||||
|
||||
@ -10,18 +10,25 @@ because a silent None here is exactly how the PC stopped syncing for weeks.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from crdt_sync import (
|
||||
CONFIG_FILE,
|
||||
ConfigError,
|
||||
FirebaseAuthError,
|
||||
FirebaseConfig,
|
||||
FirebaseCredentials,
|
||||
GitHubSyncClient,
|
||||
RemoteStore,
|
||||
RemoteSyncError,
|
||||
credential_store_for,
|
||||
firebase_client_for,
|
||||
mirror_client_for,
|
||||
)
|
||||
import requests
|
||||
|
||||
from screen_locker._constants import (
|
||||
SYNC_REPO_NAME,
|
||||
@ -29,10 +36,87 @@ from screen_locker._constants import (
|
||||
SYNC_TIMEOUT_SECONDS,
|
||||
SYNC_TOKEN_FILE,
|
||||
)
|
||||
from screen_locker._credential_recovery import RecoveryResult, recover_session
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DegradedSource:
|
||||
"""A workout backend that could not be read on this run.
|
||||
|
||||
``reason`` is the backend's own error text, kept verbatim so the status
|
||||
view and the lock decision can quote something actionable rather than a
|
||||
generic "sync problem".
|
||||
"""
|
||||
|
||||
name: str
|
||||
reason: str
|
||||
|
||||
|
||||
# Process-scoped rather than persisted: it describes THIS run's ability to
|
||||
# read, and a stale marker from an earlier run would be worse than none. The
|
||||
# lock chain and the pull both happen in one process, so this survives exactly
|
||||
# as long as it is true.
|
||||
_degraded: list[DegradedSource] = []
|
||||
|
||||
|
||||
def degraded_sources() -> list[DegradedSource]:
|
||||
"""Return the backends that failed to answer during this run."""
|
||||
return list(_degraded)
|
||||
|
||||
|
||||
def clear_degraded_sources() -> None:
|
||||
"""Forget recorded failures (called at the start of a fresh check)."""
|
||||
_degraded.clear()
|
||||
|
||||
|
||||
def _record_degraded(name: str, reason: str) -> None:
|
||||
"""Note that *name* could not be read, so callers can stop guessing.
|
||||
|
||||
Logging alone was not enough: on 2026-08-24 the warning was emitted, the
|
||||
Firebase read was skipped, and the lock decision still reported
|
||||
"0 workouts this week" as though the source had answered "none".
|
||||
"""
|
||||
_degraded.append(DegradedSource(name, reason))
|
||||
|
||||
|
||||
def try_recover_firebase_session() -> RecoveryResult:
|
||||
"""Rebuild our Firebase session from a sibling app's cached credential.
|
||||
|
||||
The exchange itself lives here rather than in ``_credential_recovery`` so
|
||||
that module stays network-free and unit-testable; this wrapper supplies
|
||||
the one impure step.
|
||||
"""
|
||||
|
||||
def _mint(refresh_token: str) -> None:
|
||||
config = FirebaseConfig.load()
|
||||
# Exchange the borrowed token for our own session. Done against the
|
||||
# documented REST endpoint rather than through FirebaseTokenProvider,
|
||||
# whose only refresh entry point is private -- and reaching into a
|
||||
# library's privates is how a dependency bump breaks enforcement.
|
||||
response = requests.post(
|
||||
f"https://securetoken.googleapis.com/v1/token?key={config.api_key}",
|
||||
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
||||
timeout=SYNC_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
store = credential_store_for("screen_locker")
|
||||
store.save(
|
||||
FirebaseCredentials(
|
||||
id_token=body["id_token"],
|
||||
refresh_token=body["refresh_token"],
|
||||
expires_at=datetime.now(tz=timezone.utc)
|
||||
+ timedelta(seconds=int(body["expires_in"])),
|
||||
)
|
||||
)
|
||||
|
||||
return recover_session(
|
||||
config_root=Path.home() / ".config", app_name="screen_locker", mint=_mint
|
||||
)
|
||||
|
||||
|
||||
def read_sync_token() -> str | None:
|
||||
"""Return the saved sync PAT, or None if sync isn't configured.
|
||||
|
||||
@ -66,9 +150,31 @@ def remote_client(github: RemoteStore) -> RemoteStore:
|
||||
try:
|
||||
return mirror_client_for("screen_locker", github)
|
||||
except (ConfigError, FirebaseAuthError, RemoteSyncError) as exc:
|
||||
# Before degrading, try to heal: a sibling app on this machine almost
|
||||
# certainly holds a live refresh token for the same account. Waiting
|
||||
# for a human to copy a JSON file is what cost 2026-06-12 and
|
||||
# 2026-08-24 -- two workouts done, two lockouts anyway.
|
||||
recovery = try_recover_firebase_session()
|
||||
if recovery.recovered:
|
||||
try:
|
||||
client = mirror_client_for("screen_locker", github)
|
||||
except (ConfigError, FirebaseAuthError, RemoteSyncError) as retry_exc:
|
||||
_logger.warning(
|
||||
"Firebase still unusable after %s: %s", recovery.reason, retry_exc
|
||||
)
|
||||
_record_degraded("firebase", str(retry_exc))
|
||||
return github
|
||||
_logger.info("Firebase recovered automatically: %s", recovery.reason)
|
||||
return client
|
||||
_logger.warning(
|
||||
"Firebase unavailable, reading workouts via GitHub only: %s", exc
|
||||
"Firebase unavailable, reading workouts via GitHub only: %s — the "
|
||||
"phone syncs to Firebase, so a workout logged there is INVISIBLE "
|
||||
"on this machine until this is fixed. Automatic recovery also "
|
||||
"failed: %s",
|
||||
exc,
|
||||
recovery.reason,
|
||||
)
|
||||
_record_degraded("firebase", f"{exc}; recovery: {recovery.reason}")
|
||||
return github
|
||||
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _manual_workout, _sick_tracker
|
||||
from screen_locker._source_health import collect_source_findings, explain_findings
|
||||
from screen_locker._ui_flows_lockout import LockoutFlowMixin
|
||||
from screen_locker._ui_flows_sick import SickDayFlowMixin
|
||||
|
||||
@ -56,6 +57,13 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin):
|
||||
"No Workout Found", role="display", color=self._colors.danger, pad="md"
|
||||
)
|
||||
self._text(message, color=self._colors.warning)
|
||||
# Never accuse the user of skipping a workout the machine simply could
|
||||
# not see. On 2026-08-24 this screen said "No Workout Found" after a
|
||||
# 1h57m session, because Firebase was unreadable and the GitHub mirror
|
||||
# had been stale for nine days -- neither of which was shown here.
|
||||
diagnosis = explain_findings(collect_source_findings())
|
||||
if diagnosis:
|
||||
self._text(diagnosis, color=self._colors.muted)
|
||||
history = _sick_tracker.load_history()
|
||||
self._text(_sick_tracker.budget_summary(history), color=self._colors.muted)
|
||||
self._text(
|
||||
|
||||
143
screen_locker/tests/test_credential_recovery.py
Normal file
143
screen_locker/tests/test_credential_recovery.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""A dead Firebase credential must heal itself, not wait for a human.
|
||||
|
||||
On 2026-08-24 the PC could not read Firebase because ``screen_locker`` had no
|
||||
cached session and the shared password no longer authenticated. Every sibling
|
||||
app on the same machine -- ``diet_guard``, ``wake_alarm``, ``todo`` -- held a
|
||||
live refresh token for the *same* account and project the whole time. The
|
||||
credential was on disk, three directories away, and nothing looked for it.
|
||||
|
||||
Recovery is therefore local and needs no human: borrow a sibling's refresh
|
||||
token, mint a session, and cache it under our own app name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._credential_recovery import (
|
||||
find_sibling_refresh_token,
|
||||
recover_session,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _write_cache(path: Path, refresh_token: str, *, expires_at: str) -> None:
|
||||
"""Write a credential cache shaped like ``FileCredentialStore``'s."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id_token": "stale-id-token",
|
||||
"refresh_token": refresh_token,
|
||||
"expires_at": expires_at,
|
||||
"local_id": "uid-1",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestFindSiblingRefreshToken:
|
||||
"""Locating a live credential already present on this machine."""
|
||||
|
||||
def test_finds_a_sibling_apps_token(self, tmp_path: Path) -> None:
|
||||
"""Any sibling app's cache is a valid source -- one account, one project."""
|
||||
_write_cache(
|
||||
tmp_path / "diet_guard" / "firebase_auth.json",
|
||||
"refresh-abc",
|
||||
expires_at="2026-08-24T12:00:00+00:00",
|
||||
)
|
||||
|
||||
found = find_sibling_refresh_token(tmp_path, skip="screen_locker")
|
||||
|
||||
assert found == ("diet_guard", "refresh-abc")
|
||||
|
||||
def test_ignores_our_own_stale_cache(self, tmp_path: Path) -> None:
|
||||
"""Our own dead cache is the thing being replaced, never the source."""
|
||||
_write_cache(
|
||||
tmp_path / "screen_locker" / "firebase_auth.json",
|
||||
"refresh-ours",
|
||||
expires_at="2026-08-24T12:00:00+00:00",
|
||||
)
|
||||
|
||||
assert find_sibling_refresh_token(tmp_path, skip="screen_locker") is None
|
||||
|
||||
def test_skips_unreadable_and_malformed_caches(self, tmp_path: Path) -> None:
|
||||
"""A corrupt sibling must not abort the search over the healthy ones."""
|
||||
bad = tmp_path / "broken_app" / "firebase_auth.json"
|
||||
bad.parent.mkdir(parents=True)
|
||||
bad.write_text("{not json")
|
||||
_write_cache(
|
||||
tmp_path / "wake_alarm" / "firebase_auth.json",
|
||||
"refresh-xyz",
|
||||
expires_at="2026-08-24T12:00:00+00:00",
|
||||
)
|
||||
|
||||
found = find_sibling_refresh_token(tmp_path, skip="screen_locker")
|
||||
|
||||
assert found == ("wake_alarm", "refresh-xyz")
|
||||
|
||||
def test_reports_nothing_when_no_sibling_has_one(self, tmp_path: Path) -> None:
|
||||
"""No source is a real outcome; the caller must hear it, not guess."""
|
||||
assert find_sibling_refresh_token(tmp_path, skip="screen_locker") is None
|
||||
|
||||
|
||||
class TestRecoverSession:
|
||||
"""Turning a borrowed refresh token into our own cached session."""
|
||||
|
||||
def test_recovers_and_reports_the_donor(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A successful recovery must say where the credential came from."""
|
||||
_write_cache(
|
||||
tmp_path / "diet_guard" / "firebase_auth.json",
|
||||
"refresh-abc",
|
||||
expires_at="2026-08-24T12:00:00+00:00",
|
||||
)
|
||||
minted: list[str] = []
|
||||
|
||||
def _mint(refresh_token: str) -> None:
|
||||
minted.append(refresh_token)
|
||||
|
||||
result = recover_session(
|
||||
config_root=tmp_path, app_name="screen_locker", mint=_mint
|
||||
)
|
||||
|
||||
assert result.recovered is True
|
||||
assert result.donor == "diet_guard"
|
||||
assert minted == ["refresh-abc"]
|
||||
|
||||
def test_failure_explains_itself(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A failed recovery must carry a reason a human can act on."""
|
||||
|
||||
def _mint(_refresh_token: str) -> None:
|
||||
message = "TOKEN_EXPIRED"
|
||||
raise RuntimeError(message)
|
||||
|
||||
_write_cache(
|
||||
tmp_path / "diet_guard" / "firebase_auth.json",
|
||||
"refresh-abc",
|
||||
expires_at="2026-08-24T12:00:00+00:00",
|
||||
)
|
||||
|
||||
result = recover_session(
|
||||
config_root=tmp_path, app_name="screen_locker", mint=_mint
|
||||
)
|
||||
|
||||
assert result.recovered is False
|
||||
assert "TOKEN_EXPIRED" in result.reason
|
||||
|
||||
def test_no_donor_is_reported_not_swallowed(self, tmp_path: Path) -> None:
|
||||
""" "Nothing to borrow" must be a stated reason, never a silent False."""
|
||||
result = recover_session(
|
||||
config_root=tmp_path, app_name="screen_locker", mint=lambda _t: None
|
||||
)
|
||||
|
||||
assert result.recovered is False
|
||||
assert "no sibling" in result.reason.lower()
|
||||
68
screen_locker/tests/test_source_health.py
Normal file
68
screen_locker/tests/test_source_health.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""A backend that answers with stale data must still be called broken.
|
||||
|
||||
The 2026-08-24 lockout had two causes and only one of them raised. These
|
||||
tests cover the silent one: the GitHub mirror answered every request
|
||||
correctly while the phone had stopped writing to it nine days earlier.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from screen_locker._source_health import (
|
||||
SourceFinding,
|
||||
describe_staleness,
|
||||
explain_findings,
|
||||
)
|
||||
|
||||
_NOW = datetime(2026, 8, 24, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class TestDescribeStaleness:
|
||||
"""Turning "answered fine, but the data is old" into a stated fault."""
|
||||
|
||||
def test_fresh_source_is_not_a_finding(self) -> None:
|
||||
"""Today's data must not raise an alarm."""
|
||||
newest = _NOW - timedelta(hours=3)
|
||||
|
||||
assert describe_staleness("GitHub mirror", newest, now=_NOW) is None
|
||||
|
||||
def test_nine_day_silence_is_reported_with_its_age(self) -> None:
|
||||
"""The real 2026-08-15 mirror silence, named in days."""
|
||||
newest = datetime(2026, 8, 15, 18, 0, tzinfo=timezone.utc)
|
||||
|
||||
finding = describe_staleness("GitHub mirror", newest, now=_NOW)
|
||||
|
||||
assert finding is not None
|
||||
assert "8 days" in finding.headline
|
||||
assert "2026-08-15" in finding.detail
|
||||
|
||||
def test_boundary_is_not_flagged(self) -> None:
|
||||
"""Just inside the window stays quiet, so rest days do not cry wolf."""
|
||||
newest = _NOW - timedelta(days=1, hours=23)
|
||||
|
||||
assert describe_staleness("GitHub mirror", newest, now=_NOW) is None
|
||||
|
||||
def test_empty_backend_is_its_own_finding(self) -> None:
|
||||
""" "Answered with nothing" is different from "answered with old"."""
|
||||
finding = describe_staleness("Firebase", None, now=_NOW)
|
||||
|
||||
assert finding is not None
|
||||
assert "no workouts at all" in finding.headline
|
||||
|
||||
|
||||
class TestExplainFindings:
|
||||
"""The text a locked-out user actually reads."""
|
||||
|
||||
def test_no_findings_render_nothing(self) -> None:
|
||||
"""A healthy machine must not show a diagnostics block."""
|
||||
assert explain_findings([]) == ""
|
||||
|
||||
def test_findings_name_the_backend_and_the_consequence(self) -> None:
|
||||
"""The panel must say it is a sync fault, not a missed workout."""
|
||||
text = explain_findings(
|
||||
[SourceFinding("Firebase", "Firebase is unreadable", "HTTP 400.")]
|
||||
)
|
||||
|
||||
assert "Firebase is unreadable" in text
|
||||
assert "sync fault, not a missed workout" in text
|
||||
130
screen_locker/tests/test_sync_degradation.py
Normal file
130
screen_locker/tests/test_sync_degradation.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""A degraded workout source must never be reported as "no workout".
|
||||
|
||||
Regression tests for 2026-06-12 and 2026-08-24, when a completed ~2h workout
|
||||
sat in Firebase while the PC locked the screen anyway. The PC's Firebase
|
||||
credential no longer authenticated, so ``remote_client`` degraded to the
|
||||
GitHub mirror -- which the phone had stopped writing to on 2026-08-15. The
|
||||
pull then returned zero records and the lock chain read that as "you did not
|
||||
train today" rather than "I could not read the place your workouts live".
|
||||
|
||||
The distinction is the whole point. "No workout" is a statement about the
|
||||
user; "could not check" is a statement about the machine, and only one of
|
||||
them justifies taking the screen away.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import FirebaseAuthError
|
||||
|
||||
from screen_locker import _sync_client, _workout_sync
|
||||
from screen_locker._compliance_state import explain_lock_decision
|
||||
from screen_locker._sick_tracker import SickHistory
|
||||
from screen_locker.tests._workout_sync_fixtures import _firebase_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
|
||||
class TestUnreadableSourceIsNotNoWorkout:
|
||||
"""The 2026-08-24 lockout, at the layer that actually took the screen.
|
||||
|
||||
With Firebase unreadable the PC had NO information about today. It
|
||||
reported "No workout logged for today yet" and locked. These assertions
|
||||
fail on the old code, which is the point: they encode the difference
|
||||
between an answer and a failure to ask.
|
||||
"""
|
||||
|
||||
def _files(self, tmp_path: Path) -> dict[str, Path]:
|
||||
return {
|
||||
"log_file": tmp_path / "log.json",
|
||||
"scheduled_skips_file": tmp_path / "scheduled_skips.json",
|
||||
"early_bird_pending_file": tmp_path / "early_bird_pending.json",
|
||||
}
|
||||
|
||||
def test_lock_reports_unreadable_source_not_absent_workout(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""An empty log plus a dead backend must not read as "you did nothing"."""
|
||||
result = explain_lock_decision(
|
||||
**self._files(tmp_path),
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
degraded_sources=(_sync_client.DegradedSource("firebase", "HTTP 400"),),
|
||||
)
|
||||
|
||||
assert result.sources_degraded is True
|
||||
assert "firebase" in result.reason.lower()
|
||||
|
||||
def test_healthy_sources_keep_the_plain_verdict(self, tmp_path: Path) -> None:
|
||||
"""With every backend answering, an empty log really does mean none."""
|
||||
result = explain_lock_decision(
|
||||
**self._files(tmp_path),
|
||||
sick_history=SickHistory(),
|
||||
extended_early_bird=False,
|
||||
weekly_minimum_met=False,
|
||||
relaxed_day=False,
|
||||
)
|
||||
|
||||
assert result.sources_degraded is False
|
||||
|
||||
|
||||
class TestFirebaseDegradationIsVisible:
|
||||
"""Losing the primary backend must leave a trace the caller can act on."""
|
||||
|
||||
def test_degradation_is_recorded_not_just_logged(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A dead Firebase must be queryable, not only a log line.
|
||||
|
||||
The 2026-08-24 run *did* warn. Nothing read the warning, so the lock
|
||||
decision was made as if the source were healthy. Recording the failure
|
||||
in state is what lets the decision layer tell the two cases apart.
|
||||
"""
|
||||
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
||||
|
||||
def _boom(*_args: object, **_kwargs: object) -> None:
|
||||
message = "failed to sign in: HTTP 400 (INVALID_LOGIN_CREDENTIALS)"
|
||||
raise FirebaseAuthError(message)
|
||||
|
||||
monkeypatch.setattr(_sync_client, "mirror_client_for", _boom)
|
||||
_sync_client.clear_degraded_sources()
|
||||
github = object()
|
||||
|
||||
assert _workout_sync.remote_client(github) is github
|
||||
degraded = _sync_client.degraded_sources()
|
||||
assert degraded, "a failed Firebase sign-in must be recorded"
|
||||
assert "INVALID_LOGIN_CREDENTIALS" in degraded[0].reason
|
||||
|
||||
def test_healthy_firebase_records_nothing(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The happy path must stay silent, or the signal means nothing."""
|
||||
_firebase_config(_sync_client, tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_sync_client,
|
||||
"mirror_client_for",
|
||||
lambda _app, client: ("mirror", client),
|
||||
)
|
||||
_sync_client.clear_degraded_sources()
|
||||
|
||||
_workout_sync.remote_client(object())
|
||||
|
||||
assert _sync_client.degraded_sources() == []
|
||||
|
||||
def test_unconfigured_machine_is_not_degraded(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""No Firebase config is a choice, not a failure -- do not cry wolf."""
|
||||
monkeypatch.setattr(
|
||||
_workout_sync, "CONFIG_FILE", Path("/nonexistent/firebase.json")
|
||||
)
|
||||
_sync_client.clear_degraded_sources()
|
||||
|
||||
_workout_sync.remote_client(object())
|
||||
|
||||
assert _sync_client.degraded_sources() == []
|
||||
103
scripts/finish_2026_08_24.sh
Executable file
103
scripts/finish_2026_08_24.sh
Executable file
@ -0,0 +1,103 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Finish the 2026-08-24 "workout not found" recovery, end to end.
|
||||
#
|
||||
# Everything Claude could not run itself, in the one order that works. The
|
||||
# ordering is not cosmetic: `sick_day` fires BEFORE `already_logged` in the
|
||||
# lock chain, so while the sick day stands it short-circuits the decision and
|
||||
# hides whether the workout credit actually landed. Credit first, revoke
|
||||
# second, re-arm last.
|
||||
#
|
||||
# Safe to re-run: every step is idempotent, and the script stops at the first
|
||||
# failure rather than pressing on to re-arm a locker that cannot see today's
|
||||
# workout.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/finish_2026_08_24.sh # do everything, then re-arm
|
||||
# ./scripts/finish_2026_08_24.sh --no-arm # ...but leave enforcement off
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
readonly PY="$SCRIPT_DIR/.venv/bin/python"
|
||||
readonly TARGET_DATE="2026-08-24"
|
||||
|
||||
ARM=1
|
||||
|
||||
usage() {
|
||||
echo "Usage: $(basename "$0") [--no-arm]"
|
||||
echo " --no-arm Run every step but leave enforcement disarmed"
|
||||
exit 0
|
||||
}
|
||||
|
||||
step() {
|
||||
echo ""
|
||||
echo "=============================================================="
|
||||
echo " $1"
|
||||
echo "=============================================================="
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
if [[ ! -x $PY ]]; then
|
||||
echo "Error: no venv interpreter at $PY — run ./run.sh once first" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
validate_requirements
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
step "1/7 Test suite (must be green before touching live state)"
|
||||
"$PY" -m pytest screen_locker/tests/ -q --no-cov
|
||||
|
||||
step "2/7 Lint + type gate"
|
||||
"$PY" -m pre_commit run --all-files
|
||||
|
||||
step "3/7 Sync: recover Firebase, then ingest today's workout"
|
||||
# --sync-only never locks, so this is safe to run while disarmed.
|
||||
./run.sh --sync-only
|
||||
|
||||
step "4/7 Did $TARGET_DATE actually land in the log?"
|
||||
# Gate, don't warn: if the credit is not on disk there is nothing to
|
||||
# revoke a sick day against, and re-arming here would lock you out again.
|
||||
"$PY" scripts/verify_workout_credited.py --date "$TARGET_DATE"
|
||||
|
||||
step "5/7 Restore shutdown hours + grant the bonus"
|
||||
"$PY" scripts/restore_and_bonus.py --date "$TARGET_DATE"
|
||||
|
||||
step "6/7 Revoke the sick days that this workout disproves"
|
||||
"$PY" scripts/revoke_sick_day.py --date "$TARGET_DATE" --date 2026-06-12
|
||||
|
||||
if ((ARM)); then
|
||||
step "7/7 Re-arm enforcement"
|
||||
./arm.sh --rearm
|
||||
else
|
||||
step "7/7 Skipped re-arm (--no-arm); enforcement stays OFF"
|
||||
echo "Re-arm later with: ./arm.sh --rearm"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✓ Done."
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--no-arm)
|
||||
ARM=0
|
||||
shift
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
main "$@"
|
||||
134
scripts/restore_and_bonus.py
Executable file
134
scripts/restore_and_bonus.py
Executable file
@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Undo a sick day's shutdown penalty and grant the agreed bonus hours.
|
||||
|
||||
Two separate corrections, in this order:
|
||||
|
||||
1. **Restore the baseline.** Taking a sick day pulled the shutdown hours down
|
||||
to 21/21 and stashed the real values in ``sick_day_state.json`` as
|
||||
``original_mon_wed_hour`` / ``original_thu_sun_hour`` (both 22). Restoring
|
||||
comes first, because otherwise a "bonus" merely returns you to baseline and
|
||||
the compensation is silently swallowed.
|
||||
|
||||
2. **Bank the bonus.** Written into ``extra_benefits_state.json`` under
|
||||
``weekly_shutdown_bonus_hours[<ISO week>]`` -- the same key
|
||||
``_extra_benefits.weekly_shutdown_bonus_hours()`` reads and
|
||||
``_apply_weekly_shutdown_bonus`` applies every day of the week. This is the
|
||||
mechanism the locker already uses for streak rewards; nothing bespoke.
|
||||
|
||||
Two bonus hours by default: one for each day a completed workout was not
|
||||
counted (2026-06-12 and 2026-08-24).
|
||||
|
||||
Usage:
|
||||
python3 scripts/restore_and_bonus.py --date 2026-08-24
|
||||
python3 scripts/restore_and_bonus.py --date 2026-08-24 --bonus-hours 3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SICK_DAY_STATE_FILE = REPO_ROOT / "screen_locker" / "sick_day_state.json"
|
||||
EXTRA_BENEFITS_FILE = REPO_ROOT / "screen_locker" / "extra_benefits_state.json"
|
||||
|
||||
DEFAULT_BONUS_HOURS = 2
|
||||
|
||||
|
||||
def _report(message: str) -> None:
|
||||
"""Write one line to stdout (matches the other scripts in this directory)."""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
|
||||
|
||||
def _iso_week(date_str: str) -> str:
|
||||
"""Return ``YYYY-Www`` for ``date_str``, matching ``_current_iso_week``."""
|
||||
parsed = datetime.strptime(date_str, "%Y-%m-%d").astimezone()
|
||||
year, week, _ = parsed.isocalendar()
|
||||
return f"{year}-W{week:02d}"
|
||||
|
||||
|
||||
def _load(path: Path) -> dict:
|
||||
"""Return ``path``'s JSON object, or an empty dict when absent."""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_report(f"WARN: could not read {path}: {exc} — treating as empty")
|
||||
return {}
|
||||
|
||||
|
||||
def restore_hours(state: dict) -> tuple[int, int] | None:
|
||||
"""Return the (mon_wed, thu_sun) hours the sick day displaced, if any."""
|
||||
mon_wed = state.get("original_mon_wed_hour")
|
||||
thu_sun = state.get("original_thu_sun_hour")
|
||||
if mon_wed is None or thu_sun is None:
|
||||
return None
|
||||
return int(mon_wed), int(thu_sun)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Restore the pre-sick-day shutdown hours and bank the bonus."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--date", required=True, help="the revoked sick day")
|
||||
parser.add_argument("--bonus-hours", type=int, default=DEFAULT_BONUS_HOURS)
|
||||
parser.add_argument("--sick-day-state", default=str(SICK_DAY_STATE_FILE))
|
||||
parser.add_argument("--benefits-file", default=str(EXTRA_BENEFITS_FILE))
|
||||
args = parser.parse_args()
|
||||
|
||||
sick_state = _load(Path(args.sick_day_state))
|
||||
restored = restore_hours(sick_state)
|
||||
if restored is None:
|
||||
_report(
|
||||
"No original shutdown hours recorded — the baseline is already "
|
||||
"whatever the daily reset produces, so nothing to restore."
|
||||
)
|
||||
else:
|
||||
mon_wed, thu_sun = restored
|
||||
_report(
|
||||
f"Baseline to restore: Mon-Wed={mon_wed}:00, Thu-Sun={thu_sun}:00 "
|
||||
"(the daily reset re-applies this on the next run)"
|
||||
)
|
||||
|
||||
benefits = _load(Path(args.benefits_file))
|
||||
week = _iso_week(args.date)
|
||||
bonus_map = dict(benefits.get("weekly_shutdown_bonus_hours", {}))
|
||||
existing = int(bonus_map.get(week, 0))
|
||||
|
||||
# Idempotence guard. Banking is `existing + bonus`, so without a durable
|
||||
# record of which dates were already compensated a re-run silently grants
|
||||
# the hours a second time. The bonus map alone cannot answer that question:
|
||||
# a week's total mixes earned streak hours with these corrections, so no
|
||||
# arithmetic on it can tell "already applied" from "earned that much".
|
||||
granted = list(benefits.get("shutdown_bonus_granted_for", []))
|
||||
if args.date in granted:
|
||||
_report(
|
||||
f"Bonus for {args.date} was already banked into {week} "
|
||||
f"(currently {existing}h) — not granting it twice."
|
||||
)
|
||||
return 0
|
||||
|
||||
granted.append(args.date)
|
||||
benefits["shutdown_bonus_granted_for"] = granted
|
||||
bonus_map[week] = existing + args.bonus_hours
|
||||
benefits["weekly_shutdown_bonus_hours"] = bonus_map
|
||||
|
||||
try:
|
||||
Path(args.benefits_file).write_text(json.dumps(benefits, indent=2) + "\n")
|
||||
except OSError as exc:
|
||||
_report(f"FAIL: could not write {args.benefits_file}: {exc}")
|
||||
return 1
|
||||
|
||||
_report(
|
||||
f"Banked +{args.bonus_hours}h shutdown bonus for {week} "
|
||||
f"(was {existing}h, now {bonus_map[week]}h) — applied every day of "
|
||||
"that week on top of the restored baseline."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
144
scripts/revoke_sick_day.py
Executable file
144
scripts/revoke_sick_day.py
Executable file
@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Revoke a sick day that a later-verified workout disproves.
|
||||
|
||||
On 2026-06-12 and 2026-08-24 a real workout was completed and the locker could
|
||||
not see it, so a sick day was spent to get the screen back. Once the workout is
|
||||
recovered, the penalty has to come off: the date leaves ``sick_days`` and the
|
||||
debt it incurred is returned.
|
||||
|
||||
What this deliberately does NOT do is edit the justification entry. Those are
|
||||
HMAC-signed over the whole entry (``gatelock.log_integrity.compute_entry_hmac``),
|
||||
so mutating one to add a ``revoked`` flag would make it fail verification and
|
||||
read as tampering -- the opposite of the intent. Instead the revocation is
|
||||
recorded in a sibling ``revocations`` list, keyed by date, naming the workout
|
||||
that disproved it. The signed history stays verifiable; the correction sits
|
||||
beside it, which is how an audit trail is supposed to work.
|
||||
|
||||
Usage:
|
||||
python3 scripts/revoke_sick_day.py --date 2026-08-24
|
||||
python3 scripts/revoke_sick_day.py --date 2026-08-24 --date 2026-06-12
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SICK_HISTORY_FILE = REPO_ROOT / "screen_locker" / "sick_history.json"
|
||||
LOG_FILE = REPO_ROOT / "screen_locker" / "log.json"
|
||||
|
||||
|
||||
def _report(message: str) -> None:
|
||||
"""Write one line to stdout (matches the other scripts in this directory)."""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
|
||||
|
||||
def _workout_id_for(log: dict, date: str) -> str | None:
|
||||
"""Return the id of a counted workout on ``date``, if one exists."""
|
||||
for entry in log.get(date) or []:
|
||||
workout_id = entry.get("workout_id")
|
||||
if workout_id:
|
||||
return str(workout_id)
|
||||
return None
|
||||
|
||||
|
||||
def revoke(history: dict, log: dict, date: str) -> str:
|
||||
"""Apply one revocation to ``history`` in place; return a status line.
|
||||
|
||||
Idempotent: re-running never double-refunds the debt, because the date is
|
||||
checked against the existing revocations first.
|
||||
"""
|
||||
revocations = history.setdefault("revocations", [])
|
||||
if any(entry.get("date") == date for entry in revocations):
|
||||
return f" {date}: already revoked — nothing to do"
|
||||
|
||||
was_sick_day = date in history.get("sick_days", [])
|
||||
workout_id = _workout_id_for(log, date)
|
||||
|
||||
if not was_sick_day and workout_id is None:
|
||||
# 2026-06-12 is exactly this case: a justification exists but the date
|
||||
# never made it into sick_days, and the log no longer reaches back that
|
||||
# far. Record the correction anyway -- the justification is the thing
|
||||
# being annotated, and silence would leave it looking uncontested.
|
||||
revocations.append(
|
||||
{
|
||||
"date": date,
|
||||
"revoked_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"reason": (
|
||||
"workout was completed but the locker could not read it; "
|
||||
"no local log entry survives for this date"
|
||||
),
|
||||
"workout_id": None,
|
||||
}
|
||||
)
|
||||
return f" {date}: annotated (no sick_days entry, no surviving log record)"
|
||||
|
||||
if was_sick_day:
|
||||
history["sick_days"] = [d for d in history["sick_days"] if d != date]
|
||||
# The debt was incurred by taking the sick day; taking it back returns
|
||||
# the debt with it. Floor at zero so a double-run cannot go negative.
|
||||
history["debt"] = max(0, int(history.get("debt", 0)) - 1)
|
||||
|
||||
revocations.append(
|
||||
{
|
||||
"date": date,
|
||||
"revoked_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"reason": (
|
||||
"workout verified after the fact; the sick day was spent only "
|
||||
"because the locker could not read the synced workout"
|
||||
),
|
||||
"workout_id": workout_id,
|
||||
}
|
||||
)
|
||||
detail = f"workout_id={workout_id}" if workout_id else "no log record found"
|
||||
return f" {date}: revoked ({detail}), debt now {history['debt']}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Revoke every ``--date`` given, preserving the signed justifications."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--date", action="append", required=True, help="YYYY-MM-DD (repeatable)"
|
||||
)
|
||||
parser.add_argument("--history-file", default=str(SICK_HISTORY_FILE))
|
||||
parser.add_argument("--log-file", default=str(LOG_FILE))
|
||||
args = parser.parse_args()
|
||||
|
||||
history_path = Path(args.history_file)
|
||||
try:
|
||||
history = json.loads(history_path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_report(f"FAIL: could not read {history_path}: {exc}")
|
||||
return 1
|
||||
|
||||
log_path = Path(args.log_file)
|
||||
try:
|
||||
log = json.loads(log_path.read_text()) if log_path.is_file() else {}
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_report(f"FAIL: could not read {log_path}: {exc}")
|
||||
return 1
|
||||
|
||||
before = int(history.get("debt", 0))
|
||||
_report(f"Sick days before: {history.get('sick_days')} (debt {before})")
|
||||
for date in args.date:
|
||||
_report(revoke(history, log, date))
|
||||
|
||||
# Written with json.dump rather than _sick_tracker.save_history because
|
||||
# that helper rebuilds the payload from a fixed field list and would drop
|
||||
# the revocations key on the next save.
|
||||
try:
|
||||
history_path.write_text(json.dumps(history, indent=2) + "\n")
|
||||
except OSError as exc:
|
||||
_report(f"FAIL: could not write {history_path}: {exc}")
|
||||
return 1
|
||||
|
||||
_report(f"Sick days after: {history.get('sick_days')} (debt {history['debt']})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
89
scripts/verify_workout_credited.py
Executable file
89
scripts/verify_workout_credited.py
Executable file
@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail unless a counted workout is on disk for a given date.
|
||||
|
||||
A gate, not a report. The 2026-08-24 recovery revokes a sick day and re-arms
|
||||
enforcement on the strength of "the workout was ingested" -- so that claim has
|
||||
to be checked by code before the irreversible steps run, not asserted by
|
||||
whoever is watching the output scroll past.
|
||||
|
||||
Exits non-zero when the date has no counted workout, which stops the calling
|
||||
script before it revokes anything.
|
||||
|
||||
Reads ``log.json`` directly rather than importing ``screen_locker``, matching
|
||||
``add_scheduled_skip.py``: these scripts must keep working even when the
|
||||
package itself is mid-edit.
|
||||
|
||||
Usage:
|
||||
python3 scripts/verify_workout_credited.py --date 2026-08-24
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
LOG_FILE = Path(__file__).resolve().parent.parent / "screen_locker" / "log.json"
|
||||
|
||||
# Mirrors ``_weekly_check.COUNTED_WORKOUT_TYPES``. Duplicated deliberately: a
|
||||
# gate that imports the code it is checking cannot fail independently of it.
|
||||
COUNTED_WORKOUT_TYPES = frozenset(
|
||||
{
|
||||
"phone_verified",
|
||||
"runnerup_verified",
|
||||
"pc_workout_verified",
|
||||
"manual_workout",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _report(message: str) -> None:
|
||||
"""Write one line to stdout (matches the other scripts in this directory)."""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Report whether ``--date`` holds a counted workout; non-zero if not."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--date", required=True, help="YYYY-MM-DD to check")
|
||||
parser.add_argument("--log-file", default=str(LOG_FILE))
|
||||
args = parser.parse_args()
|
||||
|
||||
log_path = Path(args.log_file)
|
||||
if not log_path.is_file():
|
||||
_report(f"FAIL: no workout log at {log_path}")
|
||||
return 1
|
||||
|
||||
try:
|
||||
log = json.loads(log_path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_report(f"FAIL: could not read {log_path}: {exc}")
|
||||
return 1
|
||||
|
||||
entries = log.get(args.date) or []
|
||||
counted = [
|
||||
entry
|
||||
for entry in entries
|
||||
if (entry.get("workout_data") or {}).get("type") in COUNTED_WORKOUT_TYPES
|
||||
]
|
||||
|
||||
if not counted:
|
||||
_report(f"FAIL: no counted workout logged for {args.date}.")
|
||||
_report(f" {len(entries)} entr(ies) exist for that date, none counted.")
|
||||
_report(" Nothing to revoke a sick day against — stopping here so")
|
||||
_report(" enforcement is not re-armed against a log that cannot see")
|
||||
_report(" the workout you actually did.")
|
||||
return 1
|
||||
|
||||
for entry in counted:
|
||||
data = entry.get("workout_data") or {}
|
||||
_report(
|
||||
f"OK: {args.date} has {data.get('type')} "
|
||||
f"(id={entry.get('workout_id')}, source={data.get('source', 'n/a')})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in New Issue
Block a user