screen-locker/screen_locker/_log_mixin.py
Krzysztof kuhy Rudnicki 967e3cd322 Key every workout day on the LOCAL calendar day, not UTC
A manual workout logged at 00:02 CEST on 2026-09-13 was filed under
2026-09-12: every day-key derivation used datetime.now(tz=UTC), and at
that instant UTC was still yesterday. The 09:18 lock check looked for
2026-09-13, found nothing, and locked the PC — for a workout that was on
disk. The budget counter (a trailing window) counted it fine, which is
why the status showed 1/2 used while the locker still locked. Any log
between local midnight and 02:00 CEST hit this; it had happened before.

- New screen_locker/_day.py: today_str() is the one definition of "what
  day is it" — the local calendar day the user sees on the clock, which
  is also what the phone app, RunnerUp verification, the status window
  and the freedays pool already used. All 14 UTC sites (log writer, lock
  predicates, explain chain, early-bird, wake state, shutdown/sick state,
  sick budget, manual CLI, phone verification) now go through it, writers
  and readers together so no new midnight window opens between them.
- write_signed_entry dedups by workout_id across ALL days: a workout id is
  globally unique, and an entry moved to its correct day must not let its
  synced twin (still stamped with the old day) re-ingest as a duplicate.
- scripts/rekey_log_entry.py: move a wrongly-filed entry to its real day
  (the HMAC never covered the day key, so it still verifies).
- test_local_day_key.py freezes the clock at the exact failing instant.

Replayed the real timeline (log at 00:02:35, evaluate at 09:18:53, weekly
3/5) against HEAD in a throwaway worktree vs this tree:
  HEAD  -> logged under 2026-09-12, LOCK
  fixed -> logged under 2026-09-13, no lock (already_logged)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FcU5dCcUNHMAMzUNChdrrJ
2026-09-13 09:50:06 +02:00

173 lines
6.7 KiB
Python

"""Mixin: workout log persistence (read/write log.json)."""
from __future__ import annotations
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._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
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.
"""
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)
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)