screen-locker/screen_locker/_log_mixin.py
Krzysztof kuhy Rudnicki 373a075606 Shutdown: walks as workout, rest days, grace floor, workout ledger (earned-time 0.4.0)
- A day's RunnerUp walks (_Walking.tcx) count once their moving time sums to 40 min
- Rest days: declared ahead, 2 per ISO week, HMAC-signed; pay the first workout unit
- Grace floor: shutdown >= first task + 60 min (capped); skipped on a sick day
- Workout ledger: one signed credit row per log credit slot and rest day
- Day-aware ladder amounts from earned_time 0.4.0; preview_shutdown dry-run script

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QrjUTo1yhgAsUUasdw4bpG

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-09 10:43:45 +02:00

203 lines
8.0 KiB
Python

"""Mixin: workout log persistence (read/write log.json)."""
from __future__ import annotations
from contextlib import ExitStack
from dataclasses import dataclass, field
from datetime import UTC, datetime
import json
import logging
from typing import TYPE_CHECKING
from gatelock.log_integrity import compute_entry_hmac
from screen_locker import _compliance_state
from screen_locker._bonus_lock import bonus_lock
from screen_locker._day import today_str
from screen_locker._log_io import load_workout_log
from screen_locker._manual_workout import MANUAL_WORKOUT_TYPE, manual_sync_record_id
from screen_locker._workout_ledger import record_log_entry
if TYPE_CHECKING:
from collections.abc import Mapping
from pathlib import Path
_logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class RecordResult:
"""Outcome of appending a workout entry to the day-keyed log.
``appended`` is ``False`` when an entry with the same ``workout_id`` was
already present for that day — the append was a no-op, so the caller must
apply NO new credit (re-verifying the same workout is idempotent).
``prior_entries`` is the day's entries *before* this append, which the
credit path uses to tell a first-of-day workout from an additional one.
"""
appended: bool
prior_entries: list[dict] = field(default_factory=list)
def _derive_workout_id(date: str, workout_data: Mapping[str, object]) -> str | None:
"""Return a stable dedup id for ``workout_data`` on ``date``.
An explicit ``workout_id`` in ``workout_data`` wins (used by manual-sync
ingestion, which carries the wire record id). Otherwise a manual workout
reuses :func:`manual_sync_record_id` so a locally-logged manual and the
same one synced back share an id; every other type keys on
``"{type}:{date}"`` — idempotent per day, which matches the "one verified
check per day" reality of the live phone/RunnerUp verifiers.
"""
explicit = workout_data.get("workout_id")
if explicit:
return str(explicit)
wtype = workout_data.get("type")
if not wtype:
return None
if wtype == MANUAL_WORKOUT_TYPE:
start = str(workout_data.get("start_time", "")).strip()
if start:
return manual_sync_record_id(date, start)
return f"{wtype}:{date}"
def _entry_workout_id(date: str, entry: Mapping[str, object]) -> str | None:
"""Return an already-logged entry's dedup id, deriving it if it has none.
Entries written before ``workout_id`` existed carry no id. Comparing only
the stored field would treat them as un-matchable, so the SAME workout
syncing back from another device would append a duplicate — which is
exactly what happened to a pre-migration manual workout once the PC began
publishing its history. Deriving the id the same way the writer would have
makes a legacy entry match its own synced copy.
"""
stored = entry.get("workout_id")
if stored:
return str(stored)
workout_data = entry.get("workout_data", {})
if not isinstance(workout_data, dict):
return None
return _derive_workout_id(date, workout_data)
def _is_logged_anywhere(logs: dict[str, list[dict]], workout_id: str) -> bool:
"""True if any day already holds an entry with this ``workout_id``.
Searched across ALL days, not just the one being written: a workout id is
globally unique (it embeds its own date), and a copy of the same workout
can arrive keyed on a different day — e.g. an entry moved to the local day
it was really logged on, then its synced twin coming back stamped with
the old UTC day. Per-day dedup would append that twin as a duplicate.
"""
return any(
_entry_workout_id(day, e) == workout_id
for day, entries in logs.items()
for e in entries
)
def write_signed_entry(
log_file: Path, date: str, workout_data: Mapping[str, object]
) -> RecordResult:
"""Append one HMAC-signed workout entry for ``date``, deduped by workout_id.
The single write chokepoint. Shared by the live save path (today's
workout) and manual-sync ingestion, which files a synced workout under its
OWN date so the weekly count places it in the right ISO week. The log is
day-keyed but now holds MULTIPLE entries per day: this APPENDS rather than
overwrites. If an entry with the same ``workout_id`` already exists on ANY
day, it is a no-op — the log itself is the idempotency store, so
re-verifying the same workout never double-records. Every write normalizes
the whole file to the list shape, progressively migrating legacy entries.
Returns a :class:`RecordResult` so the credit path can tell an appended
workout from a duplicate and a first-of-day workout from an additional one.
The read-dedup-write runs under an exclusive lock beside the log: the
5-minute locker pass, the 15-minute sync and the RunnerUp upload watcher
all backfill the same run, and two of them reading before either wrote
would both see it missing -- two entries and, since only an appended
result is credited, two shutdown rewards.
"""
with ExitStack() as stack:
try:
stack.enter_context(bonus_lock(log_file))
except OSError as e:
_logger.warning(
"Could not lock workout log %s (%s) — writing without the lock; "
"a pass running at the same moment could record this twice",
log_file,
e,
)
return _write_signed_entry_locked(log_file, date, workout_data)
def _write_signed_entry_locked(
log_file: Path, date: str, workout_data: Mapping[str, object]
) -> RecordResult:
"""Body of :func:`write_signed_entry`; the caller holds the log lock."""
logs = load_workout_log(log_file)
entries = logs.setdefault(date, [])
prior = list(entries)
workout_id = _derive_workout_id(date, workout_data)
if workout_id is not None and _is_logged_anywhere(logs, workout_id):
return RecordResult(appended=False, prior_entries=prior)
entry: dict[str, object] = {
"timestamp": datetime.now(tz=UTC).isoformat(),
"workout_data": workout_data,
}
if workout_id is not None:
entry["workout_id"] = workout_id
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
else:
_logger.warning("HMAC key unavailable — saving unsigned entry")
entries.append(entry)
try:
with log_file.open("w") as f:
json.dump(logs, f, indent=2)
except OSError as e:
_logger.warning("Could not save workout log: %s", e)
else:
# The one chokepoint every credit passes, so the signed workout
# ledger (earned_time's view of the workout) can never miss one.
record_log_entry(date, entries)
return RecordResult(appended=True, prior_entries=prior)
class LogMixin:
"""Handles reading and writing log.json for the ScreenLocker.
``log_file``/``workout_data`` are declared here (not assigned) so mypy
knows their types on any composing class without needing
``type: ignore[attr-defined]`` on every access — the real values are set
by ``ScreenLocker.__init__``.
"""
log_file: Path
workout_data: Mapping[str, object]
def has_logged_today(self) -> bool:
"""Check if workout has been logged today with valid HMAC."""
return _compliance_state.has_logged_today(self.log_file)
def _is_scheduled_skip_today(self) -> bool:
"""Return True if today's date is listed in the scheduled skips file."""
return _compliance_state.is_scheduled_skip_today()
def save_workout_log(self) -> RecordResult:
"""Append today's workout to the log (HMAC-signed) and report the result.
Returns the :class:`RecordResult` from the write chokepoint so callers
(notably :meth:`WorkoutCreditMixin._apply_workout_credit`) can apply
credit only for a genuinely new workout and scale it by whether today
already had one.
"""
today = today_str()
return write_signed_entry(self.log_file, today, self.workout_data)