screen-locker/screen_locker/_manual_sync.py
Krzysztof kuhy Rudnicki 9b84af7537 Count the manual budget per workout, keep a synced twin's id, and let a logged workout outrank an expired early bird
Three faults that together locked kuhy out on 2026-09-18 with a workout
on disk and "Manual-workout budget exhausted" on the screen.

1. The football logged at 00:02 on 2026-09-13 was rekeyed to its local
   day (keeping workout_id manual:2026-09-12T14:30), published, and pulled
   straight back by the 09:40 tick as a new record: _already_ingested only
   matched sync_record_id (which a locally logged entry never carries) and
   the chokepoint re-derived the id from the corrected date, so the same
   workout landed twice. Ingest now files a synced manual under the wire
   id it arrived with and recognises an entry whose own workout_id is that
   record.

2. count_in_window counted raw manual_workout entries, so the twin took a
   second budget slot for a week (2/2) while the weekly count, which goes
   through credit_key, saw one. The budget now shares credit_key -- the
   rule the docstring says every counter must share -- and distinct
   workouts on one day still cost a slot each.

3. _check_today_state_exits returned False (full lock) from the expired
   early-bird branch before ever consulting has_logged_today, so a manual
   walk entered on the lock screen at 09:32 held for exactly one tick: the
   09:35 run locked again ("also=workout_logged_today"), and kept doing so
   every 5 minutes while the day's marker lasted, which is how a sick day
   got spent over a logged workout. The explain chain already treated the
   expired marker as non-terminal; the live ladder now falls through the
   same way.

_manual_sync.py went over the 250-line cap and is split: draft
reconstruction lives in _manual_sync_draft.py, re-exported from the
original path so patch targets are unchanged.

Replayed on the real log: count_day_credits("2026-09-13") == 1 while the
old count_in_window said 2; after the fix the status API reports
used_7d=1, exhausted=False. test_manual_rekey_dedup.py fails 3/4 on HEAD.
1334 tests, 100% coverage.

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

149 lines
6.4 KiB
Python

"""Ingest phone-/PC-synced manual workouts into ``log.json``.
A manual workout logged on another device (phone form, or the PC form pushed
from elsewhere) arrives via crdt-sync as a ``kind="manual_workout"`` payload
(see :func:`screen_locker._manual_workout.build_sync_payload`). This module
turns those synced payloads into ordinary signed ``manual_workout`` entries in
``log.json`` so they count toward the weekly minimum and — via the
optional ``on_ingested`` callback — earn the same shutdown/debt reward a
live-logged workout would.
Trust model: the PC never trusts the phone's derived fields. Every synced
manual is re-validated with the same :func:`validate_manual_workout` the local
form uses, re-built with :func:`build_entry`, and only then HMAC-signed by the
PC. A phone can therefore at most add a budget-limited, honour-system
``manual_workout`` — never a ``phone_verified`` one (the session path skips
manual records, see :mod:`screen_locker._workout_sync`).
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from screen_locker._log_io import load_workout_log
from screen_locker._log_mixin import _entry_workout_id, write_signed_entry
from screen_locker._manual_sync_draft import (
_draft_or_report,
is_empty_stub,
reconstruct_draft,
)
from screen_locker._manual_workout import (
MANUAL_WORKOUT_SYNC_KIND,
build_entry,
is_budget_exhausted,
validate_manual_workout,
)
if TYPE_CHECKING:
from collections.abc import Callable, Iterable, Mapping
from pathlib import Path
OnIngestedCallback = Callable[[dict, "list[dict]"], None]
__all__ = ["ingest_manual_records", "is_empty_stub", "reconstruct_draft"]
_logger = logging.getLogger(__name__)
# Key under which the source sync record id is stored in the ingested
# ``workout_data`` — makes re-ingestion of the same record idempotent.
_SYNC_ID_FIELD = "sync_record_id"
def _already_ingested(logs: dict[str, list[dict]], record_id: str) -> bool:
"""True if any logged entry already IS this sync record.
Matches either way a record can already be here: an entry ingested from
sync carries the id in ``sync_record_id``; an entry logged on this PC
(form or CLI) and then published carries it as its own ``workout_id``,
since the publisher keys records on exactly that. Checking only the first
is how the 2026-09-13 football was re-ingested: it had been logged locally
(no ``sync_record_id``), pushed as ``manual:2026-09-12T14:30``, and pulled
back as a "new" record. A cheap early-out before reconstructing the draft;
the write chokepoint dedups by ``workout_id`` as a second guard.
"""
for day, entries in logs.items():
for entry in entries:
if _entry_workout_id(day, entry) == record_id:
return True
workout_data = entry.get("workout_data", {})
if (
isinstance(workout_data, dict)
and workout_data.get(_SYNC_ID_FIELD) == record_id
):
return True
return False
def ingest_manual_records(
log_file: Path,
records: Iterable[tuple[str, Mapping[str, object]]],
*,
today: str | None = None,
on_ingested: OnIngestedCallback | None = None,
) -> list[str]:
"""Ingest synced manual workouts into ``log.json``.
For each ``(record_id, payload)`` tagged ``kind="manual_workout"``:
reconstruct + re-validate the draft on the PC, enforce the rate budget, then
HMAC-sign and APPEND it under its own ``date`` (a day may hold several
workouts). Idempotent: dedup by ``record_id`` here and by ``workout_id`` in
the write chokepoint, so re-syncing the same record is a no-op.
When ``on_ingested`` is given, it's called ``on_ingested(entry, prior_entries)``
right after each newly-appended record — ``prior_entries`` being that
entry's own date's entries written before it — so the caller (see
``screen_lock.ScreenLocker._ingest_synced_manual_workouts``) can apply the
identical live-workout shutdown/debt reward. This applies regardless of
whether ``date`` is today or back-dated: there's only one current shutdown
config, so a back-dated sync still pushes it exactly as a live workout
would have that day.
Returns the record ids actually ingested.
"""
ingested: list[str] = []
for record_id, payload in records:
if payload.get("kind") != MANUAL_WORKOUT_SYNC_KIND:
continue
date = payload.get("date")
if not isinstance(date, str):
_logger.warning("Manual record %s has no date — skipped", record_id)
continue
if _already_ingested(load_workout_log(log_file), record_id):
continue
draft = _draft_or_report(record_id, payload)
if draft is None:
continue
error = validate_manual_workout(draft)
if error is not None:
_logger.warning("Manual record %s invalid: %s", record_id, error)
continue
if is_budget_exhausted(log_file, today=today):
_logger.info("Manual-workout budget exhausted — %s not ingested", record_id)
continue
entry = build_entry(draft)
entry[_SYNC_ID_FIELD] = record_id
# The wire id IS the workout's id: the device that logged it minted
# ``manual:<date>T<start>`` once, and the publisher keys on the same
# id. Without this the chokepoint re-derived one from ``date`` +
# start, and when the two dates disagreed (a record whose day key was
# later corrected to the local day) the same workout landed twice
# under two ids -- and then consumed two manual-budget slots.
entry["workout_id"] = record_id
result = write_signed_entry(log_file, date, entry)
if not result.appended:
# This is dedup working, not credit being lost: the workout is
# already in the log under this same workout_id. It was logged at
# warning and re-reported on every 15-minute cycle, which read like
# a recurring fault and buried the lines that did need attention.
_logger.info(
"Manual record %s is already logged under the same workout_id "
"— skipped as a duplicate (its credit is already counted)",
record_id,
)
continue
ingested.append(record_id)
if on_ingested is not None:
on_ingested(entry, result.prior_entries)
return ingested