diff --git a/DOCS-morning-session-pc.md b/DOCS-morning-session-pc.md index 68bac57..a670ae7 100644 --- a/DOCS-morning-session-pc.md +++ b/DOCS-morning-session-pc.md @@ -82,8 +82,18 @@ cutoffs, neither imports `crdt_sync`, neither talks to Firebase: **A PC booted mid-morning** has a missing or yesterday's file until the timer's `Persistent=true` catch-up lands, and that needs the network. Only the enforce path, only inside 05:00–11:00, and only when the file is not -today's, both consumers retry for ≤ 30 s (5 s steps) and then proceed as +today's, both consumers retry for ≤ 180 s (5 s steps) and then proceed as normal with a WARNING. Status paths never wait; tests pin the retry to 0. +The refresher itself retries an unreadable backend every 5 s for 150 s +(`REFRESH_RETRY_SECONDS`) before exiting 1 with a WARNING: a PC booted at +07:48 had no DNS for its first seconds, and the old single try left the next +chance at the 07:50 tick (2026-11-07). + +**`next_step`** (signed with the rest): while the session is `in_progress`, +the label of the first leg not in the phone's `passed` map, else `null`. +Names and labels live in `_session_legs.py` and are pinned to the phone's +`SessionLeg` by `wake_alarm/tests/fixtures/session_legs.json`, asserted by a +Python and a Dart test. ## Known limits (v1, deliberate) diff --git a/phone_app/test/services/session_legs_fixture_test.dart b/phone_app/test/services/session_legs_fixture_test.dart new file mode 100644 index 0000000..3187990 --- /dev/null +++ b/phone_app/test/services/session_legs_fixture_test.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:wake_alarm_sync/services/morning_session.dart'; + +/// The PC reads leg names (the keys of the published `passed` map) and labels +/// (its `next_step`) from the same literal: the Python half asserts it in +/// `wake_alarm/tests/test_session_legs.py`. Neither side's own round-trip can +/// catch a rename on the other. +void main() { + test('SessionLeg names and labels equal the shared fixture', () { + final raw = File( + '../wake_alarm/tests/fixtures/session_legs.json', + ).readAsStringSync(); + final legs = (jsonDecode(raw) as Map)['legs']! as List; + expect( + <(String, String)>[for (final l in SessionLeg.values) (l.name, l.label)], + <(String, String)>[ + for (final l in legs) (l['name'] as String, l['label'] as String), + ], + ); + }); +} diff --git a/wake-alarm-session.timer b/wake-alarm-session.timer index f8bc171..7dee402 100644 --- a/wake-alarm-session.timer +++ b/wake-alarm-session.timer @@ -11,8 +11,9 @@ OnCalendar=*-*-* 05..06:00/10:00 OnCalendar=*-*-* 07..10:00/2:00 OnCalendar=*-*-* 11:00:00 AccuracySec=1s -# A PC booted mid-morning refreshes at once; the consumers wait up to 30 s -# for exactly this catch-up before treating the file as stale. +# A PC booted mid-morning refreshes at once, retrying in-process every 5 s +# for 150 s (no DNS yet at boot); the consumers wait up to 180 s for exactly +# this catch-up before treating the file as stale. Persistent=true [Install] diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py index 9d75d61..484878b 100644 --- a/wake_alarm/_constants.py +++ b/wake_alarm/_constants.py @@ -156,6 +156,13 @@ SESSION_COMPLETED_UNTIL: tuple[int, int] = (11, 0) # PC). A phone switched off at the alarm locks this long after it. SESSION_NONE_GRACE: timedelta = timedelta(minutes=15) +# A PC booted mid-morning often has no DNS for its first seconds (2026-11-07: +# one try at 07:48:43, exit 1, next timer tick at 07:50). ``--refresh`` keeps +# trying in-process this long, every REFRESH_POLL_SECONDS, before giving up. +# Consumers (gatelock.morning_session) wait up to 180 s, so this stays under. +REFRESH_RETRY_SECONDS: int = 150 +REFRESH_POLL_SECONDS: int = 5 + # Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days # themselves are freedays.WORKDAYS, the one set every gate app reads. diff --git a/wake_alarm/_session.py b/wake_alarm/_session.py index 6120cc8..8d7941c 100644 --- a/wake_alarm/_session.py +++ b/wake_alarm/_session.py @@ -31,10 +31,17 @@ from datetime import datetime import json import logging import sys +import time +from typing import TYPE_CHECKING from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac -from wake_alarm._constants import FALLBACK_ALARM_HOUR, MORNING_SESSION_FILE +from wake_alarm._constants import ( + FALLBACK_ALARM_HOUR, + MORNING_SESSION_FILE, + REFRESH_POLL_SECONDS, + REFRESH_RETRY_SECONDS, +) from wake_alarm._enforcement_payload import enforcement_view from wake_alarm._escapes import note_escapes from wake_alarm._logging_setup import configure_logging @@ -45,6 +52,9 @@ from wake_alarm._sync_transport import fetch_firebase_logs from wake_alarm._workday_penalty import cancel_for_sick_day as _cancel_for_sick_day from wake_alarm._workday_penalty import maybe_apply as _maybe_apply_workday_penalty +if TYPE_CHECKING: + from collections.abc import Callable + _logger = logging.getLogger(__name__) @@ -81,18 +91,49 @@ def read_entry() -> dict[str, object] | None: return entry -def refresh(now: datetime) -> dict[str, object] | None: +def _fetch_with_retry( + sleep: Callable[[float], None], clock: Callable[[], float] +) -> list[tuple[str, str]] | None: + """``fetch_firebase_logs`` retried every poll interval within the budget. + + A PC booted mid-morning usually has no DNS for its first seconds, and the + next timer tick is minutes away while consumers are already waiting on the + file. None only once ``REFRESH_RETRY_SECONDS`` have passed. + """ + started = clock() + while True: + published = fetch_firebase_logs() + if published is not None: + return published + waited = clock() - started + if waited + REFRESH_POLL_SECONDS > REFRESH_RETRY_SECONDS: + _logger.warning( + "Morning session not refreshed: backend unreadable for %d s " + "(retried every %d s)", + REFRESH_RETRY_SECONDS, + REFRESH_POLL_SECONDS, + ) + return None + sleep(REFRESH_POLL_SECONDS) + + +def refresh( + now: datetime, + *, + sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.monotonic, +) -> dict[str, object] | None: """Fetch, select, and rewrite the file; None when the backend was unreadable. - An unreadable backend leaves the file alone: whatever it says is still - the newest thing known, and a consumer judges freshness by its date. A - penalty issued on a day since marked sick is retracted first, from the - local pool alone, so it does not wait on Firebase. + An unreadable backend is retried for ``REFRESH_RETRY_SECONDS``, then + leaves the file alone: whatever it says is still the newest thing known, + and a consumer judges freshness by its date. A penalty issued on a day + since marked sick is retracted first, once, from the local pool alone, so + it does not wait on Firebase. ``sleep`` and ``clock`` are seams for tests. """ _cancel_for_sick_day() - published = fetch_firebase_logs() + published = _fetch_with_retry(sleep, clock) if published is None: - _logger.info("Morning session not refreshed: backend unreadable") return None today = now.date().isoformat() record = newest_session(published) diff --git a/wake_alarm/_session_legs.py b/wake_alarm/_session_legs.py new file mode 100644 index 0000000..a3b2669 --- /dev/null +++ b/wake_alarm/_session_legs.py @@ -0,0 +1,30 @@ +"""The morning session's legs, in order, as the phone names and labels them. + +Mirrors ``SessionLeg`` and ``SessionLegSlot.label`` in +``phone_app/lib/services/morning_session.dart``. A leg's *name* is the key of +the phone's ``passed`` map; its *label* is what the PC's ``next_step`` shows. +``tests/fixtures/session_legs.json`` is asserted by a Python and a Dart test, +so a rename on one side fails the other's build. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Collection + +LEGS: tuple[tuple[str, str], ...] = ( + ("bathroomIn", "Tap the bathroom tag"), + ("showerDone", "Shower, then tap the bathroom tag again"), + ("dressed", "Get dressed — tap the wardrobe tag"), + ("atDesk", "Sit down — tap the desk tag"), +) + + +def next_step(passed: Collection[str]) -> str | None: + """Label of the first leg (in order) not in ``passed``; None when all are.""" + for name, label in LEGS: + if name not in passed: + return label + return None diff --git a/wake_alarm/_session_payload.py b/wake_alarm/_session_payload.py index 0d0d8a9..9460048 100644 --- a/wake_alarm/_session_payload.py +++ b/wake_alarm/_session_payload.py @@ -52,6 +52,8 @@ class SessionRecord: re_rings: int device_id: str published_at: float | None + # Names (``_session_legs.LEGS``) of the legs the phone has passed. + passed_legs: tuple[str, ...] = () @property def finished(self) -> bool: @@ -73,6 +75,19 @@ def _float_or_none(value: object) -> float | None: return float(value) +def _passed_legs(value: object) -> tuple[str, ...]: + """Leg names in the phone's ``passed`` map that carry a pass timestamp.""" + if not isinstance(value, dict): + return () + return tuple( + name + for name, entry in value.items() + if isinstance(name, str) + and isinstance(entry, dict) + and isinstance(entry.get("at"), str) + ) + + def record_from_json( value: object, *, @@ -106,6 +121,7 @@ def record_from_json( re_rings=_int_or_none(value.get("re_rings")) or 0, device_id=device_id, published_at=published_at, + passed_legs=_passed_legs(value.get("passed")), ) diff --git a/wake_alarm/_session_policy.py b/wake_alarm/_session_policy.py index e9e6899..d5edd0a 100644 --- a/wake_alarm/_session_policy.py +++ b/wake_alarm/_session_policy.py @@ -25,6 +25,7 @@ from wake_alarm._constants import ( SESSION_PROVISIONAL_CAP, SESSION_PROVISIONAL_GRACE, ) +from wake_alarm._session_legs import next_step if TYPE_CHECKING: from wake_alarm._session_payload import SessionRecord @@ -113,6 +114,7 @@ def to_entry( "device_id": "", "published_at": None, "refreshed_at": refreshed_at, + "next_step": None, } if alarm is not None: entry["exempt_until"] = (_at(today, alarm) + SESSION_NONE_GRACE).isoformat() @@ -128,6 +130,10 @@ def to_entry( "device_id": record.device_id, "published_at": record.published_at, "refreshed_at": refreshed_at, + # Signed with the rest: the HMAC covers every key of the entry. + "next_step": ( + next_step(record.passed_legs) if record.outcome == "in_progress" else None + ), } until = exempt_until(record) if until is not None: diff --git a/wake_alarm/tests/conftest.py b/wake_alarm/tests/conftest.py index 30ea4cb..e6aa964 100644 --- a/wake_alarm/tests/conftest.py +++ b/wake_alarm/tests/conftest.py @@ -49,6 +49,33 @@ def _no_process(cmd: list[str]) -> object: raise AssertionError(msg) +class FakeClock: + """A monotonic clock that only moves when ``sleep`` is called. + + Pass ``clock.sleep`` and ``clock.now`` to ``_session.refresh`` so a dead + backend burns its 150 s retry budget instantly instead of for real. + """ + + def __init__(self) -> None: + self.elapsed = 0.0 + self.sleeps: list[float] = [] + + def now(self) -> float: + """Seconds elapsed on the fake clock.""" + return self.elapsed + + def sleep(self, seconds: float) -> None: + """Advance the clock without waiting.""" + self.sleeps.append(seconds) + self.elapsed += seconds + + +@pytest.fixture +def fake_clock() -> FakeClock: + """A fresh :class:`FakeClock`.""" + return FakeClock() + + @pytest.fixture(autouse=True) def _hermetic(tmp_path: Path) -> Generator[None]: """Keep signing keys, state writes and processes inside ``tmp_path``.""" diff --git a/wake_alarm/tests/fixtures/session_legs.json b/wake_alarm/tests/fixtures/session_legs.json new file mode 100644 index 0000000..0365178 --- /dev/null +++ b/wake_alarm/tests/fixtures/session_legs.json @@ -0,0 +1,8 @@ +{ + "legs": [ + {"name": "bathroomIn", "label": "Tap the bathroom tag"}, + {"name": "showerDone", "label": "Shower, then tap the bathroom tag again"}, + {"name": "dressed", "label": "Get dressed — tap the wardrobe tag"}, + {"name": "atDesk", "label": "Sit down — tap the desk tag"} + ] +} diff --git a/wake_alarm/tests/test_session.py b/wake_alarm/tests/test_session.py index 2a630d2..f66b2c9 100644 --- a/wake_alarm/tests/test_session.py +++ b/wake_alarm/tests/test_session.py @@ -172,12 +172,6 @@ class TestRefresh: assert entry is not None assert entry["exempt_until"] == _local("2026-09-19T06:45:00").isoformat() - def test_unreadable_backend_leaves_the_file_alone(self) -> None: - write_entry(to_entry(_record(), today="2026-09-19", refreshed_at=5.0)) - with patch("wake_alarm._session.fetch_firebase_logs", return_value=None): - assert refresh(NOW) is None - assert read_entry() is not None - def test_write_failure_is_none(self) -> None: with ( patch("wake_alarm._session.fetch_firebase_logs", return_value=[]), diff --git a/wake_alarm/tests/test_session_legs.py b/wake_alarm/tests/test_session_legs.py new file mode 100644 index 0000000..60cf54f --- /dev/null +++ b/wake_alarm/tests/test_session_legs.py @@ -0,0 +1,30 @@ +"""The leg names and labels the PC reads must equal the phone's enum. + +``fixtures/session_legs.json`` is one literal both sides assert: this test +against ``_session_legs.LEGS`` and ``phone_app/test/services/ +session_legs_fixture_test.dart`` against ``SessionLeg``. Each side's own +round-trip cannot catch a naming drift with its sibling. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from wake_alarm._session_legs import LEGS, next_step + +FIXTURE = Path(__file__).parent / "fixtures" / "session_legs.json" + + +def test_legs_equal_the_shared_fixture() -> None: + legs = json.loads(FIXTURE.read_text(encoding="utf-8"))["legs"] + assert [(leg["name"], leg["label"]) for leg in legs] == list(LEGS) + + +def test_next_step_is_the_first_leg_not_passed() -> None: + assert next_step(()) == "Tap the bathroom tag" + assert next_step(("bathroomIn",)) == "Shower, then tap the bathroom tag again" + # Order of the legs decides, not the order they were passed in. + assert next_step(("showerDone",)) == "Tap the bathroom tag" + assert next_step(("atDesk", "bathroomIn", "showerDone", "dressed")) is None + assert next_step(("unknownLeg",)) == "Tap the bathroom tag" diff --git a/wake_alarm/tests/test_session_payload.py b/wake_alarm/tests/test_session_payload.py index 5dabc19..9eaf1ce 100644 --- a/wake_alarm/tests/test_session_payload.py +++ b/wake_alarm/tests/test_session_payload.py @@ -64,6 +64,26 @@ class TestRecordFromJson: assert record.finished_at is None assert not record.finished + def test_passed_legs_are_the_keys_with_a_timestamp(self) -> None: + passed = { + "bathroomIn": {"at": "2026-09-19T08:05:00.000", "how": "tag"}, + "showerDone": "garbage", + "dressed": {"how": "tag"}, + } + record = record_from_json( + _session("2026-09-19", "active", passed=passed), + device_id="p", + published_at=None, + ) + assert record is not None + assert record.passed_legs == ("bathroomIn",) + for bad in (None, [], "x"): + record = record_from_json( + _session(passed=bad), device_id="p", published_at=None + ) + assert record is not None + assert record.passed_legs == () + def test_rejects_every_malformed_shape(self) -> None: bad = [ "text", diff --git a/wake_alarm/tests/test_session_retry.py b/wake_alarm/tests/test_session_retry.py new file mode 100644 index 0000000..91d7563 --- /dev/null +++ b/wake_alarm/tests/test_session_retry.py @@ -0,0 +1,118 @@ +"""``refresh`` retries an unreadable backend; ``next_step`` is signed.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime +import json +import logging +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from wake_alarm import _session +from wake_alarm._constants import REFRESH_POLL_SECONDS, REFRESH_RETRY_SECONDS +from wake_alarm._session import read_entry, refresh, to_entry, write_entry +from wake_alarm._session_payload import SessionRecord +from wake_alarm._sync import Alarm + +if TYPE_CHECKING: + from wake_alarm.tests.conftest import FakeClock + +NOW = datetime(2026, 9, 19, 8, 30).astimezone() +TODAY = "2026-09-19" + + +def _live(passed: tuple[str, ...] = ("bathroomIn",)) -> SessionRecord: + return SessionRecord( + date=TODAY, + outcome="in_progress", + started_at="2026-09-19T08:01:00.000", + finished_at=None, + energy=3, + weight_kg=73.8, + re_rings=0, + device_id="p", + published_at=1.0, + passed_legs=passed, + ) + + +@pytest.fixture(autouse=True) +def _alarm() -> object: + with patch("wake_alarm._session.get_alarm", return_value=Alarm(8, 0, "cache")): + yield + + +class TestRefreshRetry: + """2026-11-07: booted without DNS; one try, then a 2-minute gap.""" + + def test_unreadable_backend_leaves_the_file_alone( + self, fake_clock: FakeClock + ) -> None: + write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0)) + with patch("wake_alarm._session.fetch_firebase_logs", return_value=None): + assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None + assert read_entry() is not None + + def test_a_backend_that_recovers_is_retried_into_todays_entry( + self, fake_clock: FakeClock + ) -> None: + with patch( + "wake_alarm._session.fetch_firebase_logs", side_effect=[None, None, []] + ) as fetch: + entry = refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) + assert entry is not None + assert (entry["date"], entry["outcome"]) == (TODAY, "none") + assert read_entry() == entry + assert fetch.call_count == 3 + assert fake_clock.sleeps == [REFRESH_POLL_SECONDS, REFRESH_POLL_SECONDS] + + def test_a_dead_backend_gives_up_after_the_budget_and_says_so( + self, caplog: pytest.LogCaptureFixture, fake_clock: FakeClock + ) -> None: + with ( + patch("wake_alarm._session.fetch_firebase_logs", return_value=None), + caplog.at_level(logging.WARNING), + ): + assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None + assert fake_clock.sleeps == [REFRESH_POLL_SECONDS] * ( + REFRESH_RETRY_SECONDS // REFRESH_POLL_SECONDS + ) + warnings = [r.getMessage() for r in caplog.records if r.levelno >= 30] + assert any(f"{REFRESH_RETRY_SECONDS} s" in w for w in warnings) + + def test_sick_day_cancel_runs_once_before_the_loop( + self, fake_clock: FakeClock + ) -> None: + with ( + patch("wake_alarm._session.fetch_firebase_logs", return_value=None), + patch("wake_alarm._session._cancel_for_sick_day") as cancel, + ): + refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) + cancel.assert_called_once() + + +class TestNextStep: + """The entry names the next leg and signs it.""" + + def test_in_progress_entry_names_the_next_leg(self) -> None: + entry = to_entry(_live(), today=TODAY, refreshed_at=5.0) + assert entry["next_step"] == "Shower, then tap the bathroom tag again" + + def test_next_step_is_null_unless_the_morning_is_live(self) -> None: + done = replace(_live(), outcome="completed", finished_at="2026-09-19T08:40") + assert to_entry(done, today=TODAY, refreshed_at=5.0)["next_step"] is None + failed = replace(_live(), outcome="failed") + assert to_entry(failed, today=TODAY, refreshed_at=5.0)["next_step"] is None + assert to_entry(None, today=TODAY, refreshed_at=5.0)["next_step"] is None + + def test_tampering_next_step_breaks_verification(self) -> None: + write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0)) + assert read_entry() is not None + path = _session.MORNING_SESSION_FILE + tampered = json.loads(path.read_text()) + tampered["next_step"] = None + path.write_text(json.dumps(tampered)) + assert read_entry() is None diff --git a/wake_alarm/tests/test_sick_days_penalty.py b/wake_alarm/tests/test_sick_days_penalty.py index c5247e9..a938ef0 100644 --- a/wake_alarm/tests/test_sick_days_penalty.py +++ b/wake_alarm/tests/test_sick_days_penalty.py @@ -7,6 +7,7 @@ from datetime import date, datetime import json import logging from pathlib import Path +from typing import TYPE_CHECKING from unittest.mock import patch import pytest @@ -32,6 +33,9 @@ from wake_alarm._workday_penalty import ( write_penalty, ) +if TYPE_CHECKING: + from wake_alarm.tests.conftest import FakeClock + TUESDAY = date(2026, 9, 22) AFTER_GRACE = datetime(2026, 9, 22, 9, 0).astimezone() MISS: dict[str, object] = { @@ -131,10 +135,15 @@ class TestRetraction: ) assert not cancel_for_sick_day() - def test_refresh_retracts_even_offline(self) -> None: + def test_refresh_retracts_even_offline(self, fake_clock: FakeClock) -> None: mark_sick(TUESDAY) with patch.object(_session, "fetch_firebase_logs", return_value=None): - assert _session.refresh(AFTER_GRACE) is None + assert ( + _session.refresh( + AFTER_GRACE, sleep=fake_clock.sleep, clock=fake_clock.now + ) + is None + ) assert issued_date() is None