wake-alarm/AGENTS.md
Krzysztof kuhy Rudnicki 97d32c803d Phase 6: vmbox becomes the gate for the system scripts (item 10)
scripts/vmbox/harness.sh provisions a disposable Arch guest, runs the REAL
install.sh in it, and adjudicates 8 cases on a host-side exit code. Plus
DOCS-vmbox.md, which states what a pass does and does not mean.

Covered: system-python imports, timer enabled, wake-alarm.service still
`static` (an [Install] there fires the alarm at *login*), the shutdown wrapper
shadowing /usr/bin/shutdown, the alarm-cache shape jq expects, a trigger tick,
the inhibitor, and the re-fire path. With --window it also opens the real
fullscreen Tk window and screenshots it from the host -- confirmed showing
"WAKE UP!", round 1/4 and a live flash challenge.

Not covered, and said plainly in the doc rather than implied away: audio (the
guest has no pactl/paplay), the phone, GPU and real outputs, and anything
bootloader-related (guest is BIOS, this machine is UEFI).

**The VM earned its place immediately by finding two live bugs that the host
could not reach and the unit suite passed straight through.**

1. The inhibitor claimed a block it did not have. Popen succeeding only means
   the binary launched; logind can still refuse -- the guest's polkit denies a
   non-interactive session outright ("Failed to inhibit: Access denied", exit
   1) while the alarm cheerfully logged the power key as blocked.
   take_power_key_inhibitor() now waits INHIBIT_CONFIRM_SECONDS and confirms
   the child survived, returning None and logging the refusal otherwise.

2. An unsigned session record was written and then rejected on read. With no
   HMAC key on the machine (/etc/gatelock absent, as in the guest),
   verify_entry_hmac returns False for a *missing* key exactly as it does for
   a forgery -- so every session record was unreadable and item 2's power-off
   defence silently did not exist. An entry with no "hmac" field is now
   honoured with a loud warning; a present-but-wrong signature is still
   rejected, since that one really is tampering.

Two harness traps worth recording, both hit while building it:

- `vm run` reports the *VM's* verdict, not the guest command's exit code, so a
  guest script exiting 1 still gives rc=0. A gate built on that fails OPEN.
  guest_cases.sh prints ALL-CASES-PASSED only when every case passed and the
  harness greps for it; verified by breaking a case on purpose and watching
  the gate go red.
- `vm share`/`vm repo` key off the path basename, so REPO_DIR must be absolute
  and symlink-resolved. A relative "scripts/vmbox/../.." makes rsync write
  into /home and fail with permissions errors that look like a guest fault.

Guest checks live in real .py files beside the shell, not heredocs, so they
get linted like everything else -- ruff's autofix gutted an early heredoc
version (stripped the assignments AND the print) exactly as TODO-hardening
warned, and only running it showed the file had stopped producing output.

Also drops three stale fan references the guest install run surfaced:
install.sh still announced "Fans will ramp to 100%" and README.md still
described the fan ramp, three commits after item 14 deleted the feature.

Splits carried: test_inhibit.py -> + test_inhibit_cleanup.py,
test_alarm_session.py -> + test_alarm_session_refire.py.

455 tests pass, 100% branch coverage. Harness: 8/8 cases, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
2026-08-27 20:49:16 +02:00

245 lines
13 KiB
Markdown

# CLAUDE.md — wake_alarm
## What this does
A wake alarm: on alarm days (`ALARM_DAYS` in `_constants.py` — currently
**every day**, `frozenset({0..6})`), the machine hibernates overnight and
wakes itself via `rtcwake` at the configured alarm time. `wake-alarm.service`
then opens a fullscreen Tk window that must be dismissed (with a typed challenge — see
`_challenges.py`), plays audio through every real sink, and optionally
toggles a TP-Link Tapo P110 smart plug via `python-kasa`.
## Scheduling — a per-minute timer, with hibernate as the fallback
The PC is **always on**. The alarm time comes from the phone, which can change
it at any hour, so nothing about the schedule may be baked in ahead of time.
**Primary path — `wake-alarm-trigger.timer`:**
1. The timer ticks every minute (`OnCalendar=*:*:00`, `Persistent=true`) and
runs `python -m wake_alarm._trigger` — a short, stateless check-and-fire.
2. `_trigger` resolves the alarm via `_sync.get_alarm()`, builds today's fire
instant in **local** time, and fires iff `now` is in
`[fire_time, fire_time + TRIGGER_CATCHUP_MINUTES)`. The catch-up window is
what makes a suspended or just-booted machine still fire.
3. Firing = `systemctl --user start morning-routine.service`. **Never**
`wake-alarm.service` directly — see the orchestrator section below.
Starting an already-active unit is a no-op, so an alarm on screen needs no
extra dedupe state; the `was_alarm_dismissed_today()` /
`was_workout_logged_today()` gates cover the rest.
A timer, not a resident daemon: each tick is a fresh process, so there is no
long-lived scheduler to die silently and no wall-clock jump to handle across
suspend/resume.
**Fallback path — hibernate (only when the PC really is powered down):**
`shutdown-wrapper.sh` (installed to `/usr/local/bin/shutdown`, shadowing
`/usr/bin/shutdown` via PATH order) still intercepts shutdown/poweroff, reads
the same synced alarm, and sets `rtcwake`. `sleep-hook.sh` (in
`/usr/lib/systemd/system-sleep/`) fires on resume (`$1 == post`) and starts
`morning-routine.service`. This is now a backstop, not the main mechanism.
If you change `WAKE_AFTER_HOURS` in `_constants.py`, you must also update the
duplicate constant in `shutdown-wrapper.sh` (`WAKE_AFTER_HOURS=8`) — they are
not wired together, by design (the shell wrapper has no Python runtime
available at the point it intercepts `shutdown`).
**The wrapper no longer talks to the network or handles a token.** It reads
`~/.config/wake_alarm/alarm_cache.json` — the cache `_sync.py` already
refreshes every minute — and falls back to `WAKE_AFTER_HOURS` if that file is
absent, malformed, or more than 2 days old. This removed the duplicated token
search order (`read_sync_token()` used to mirror `SYNC_CONFIG_FILE` /
`SYNC_TOKEN_FALLBACK_FILES`), and it is what makes the wrapper survive the
move to Firebase unchanged: an ID token expires in about an hour, and a shell
script at shutdown time has no safe way to refresh one. The only coupling left
is the cache's `{"hour","minute","fetched_at"}` shape — keep the reader in the
wrapper in step with `_write_cache()` in `_sync.py`.
## `wake-alarm.service` must never auto-start
`wake-alarm.service` deliberately has **no `[Install]` section** and nothing
starts it automatically. `_should_run_alarm()` gates on day / dismissed /
workout-logged but has **no time gate**, so a
`WantedBy=graphical-session.target` there fires the alarm at *login* time on
any alarm day — which is every day now that the machine no longer reboots into
the alarm. `install.sh` explicitly runs `systemctl --user disable
wake-alarm.service` to clear the stale symlink from older installs.
The unit survives only as a manual test handle. Do not "fix" its missing
auto-start.
## Reading the phone's alarm
`_sync.py` is the only reader of the remote alarm. `shutdown-wrapper.sh` reads
`_sync.py`'s local cache instead of the remote file, so there is no second
remote reader to keep in step.
- Source of truth: a `crdt_sync` log written by `phone_app/` under
`wake-alarm-sync/devices/<device-id>/alarm.json`. The value lives at
`.alarm.fields.alarm_hour[0]` / `.alarm_minute[0]`. **The same logical path
on both backends** — Firebase's namespace mirrors the repo layout — so only
the transport differs, never the parse.
- **The Firebase reader discovers device directories; it is not pinned to
`devices/phone/`.** The phone's device id is migrating from the fixed
constant `phone` to a per-install uuid, and a reader pinned to the old path
would silently stop seeing the alarm the moment the phone migrated — then
fall back to `FALLBACK_ALARM_HOUR`, i.e. wake at the wrong time. So
`_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every device's
`alarm.json`, and picks the newest by the hour field's **HLC** (not by
listing order), with `device_id` breaking exact ties so two devices stamped
in the same millisecond don't alternate between ticks. A device whose
payload doesn't parse is skipped rather than allowed to win.
`SYNC_ALARM_PATH` remains as a last-resort read for a backend that lists
nothing — keep it until every install has migrated. The **GitHub** fallback
still reads the single fixed path; it is the mirror, not the primary.
- Payload parsing/ranking lives in `_alarm_payload.py`, split out of `_sync.py`
so the wire format is testable without network or disk (and to keep both
files under the 500-line cap). `_sync.py` owns fetching, caching and
fallback; `_alarm_payload.py` owns "what did the phone actually say".
- **Firebase first, GitHub as the cutover mirror** (`fetch_remote_alarm()` is
just `_fetch_firebase_alarm() or _fetch_github_alarm()`). Retiring the
mirror is deleting the GitHub branch plus `SYNC_API_BASE`/`SYNC_REPO_*`.
- Firebase config: the shared `~/.config/crdt-sync/firebase.json`; the
refresh token is cached per app under `SYNC_APP_NAME` (`wake_alarm`), so
this app's credential is separate from its siblings'. The config file is
checked **before** anything is constructed, so an unconfigured machine
never touches the network.
- GitHub token (fallback path only): `~/.config/wake_alarm/sync.json` key
`token`, else a sibling app's raw
`~/.config/{diet_guard,screen_locker}/sync_token`. Needs `repo` scope — the
repo is private, and a token without access returns **404, not 403**.
- `crdt_sync` is imported at module scope but **guarded by
`try/except ImportError`**, exactly like `python-kasa` in `_smart_plug.py`:
the service runs `/usr/bin/python`, not a venv, so a dependency that failed
to install must degrade the read to the GitHub path rather than crash the
per-minute trigger. The GitHub path stays stdlib-only for that reason.
Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the dev venv.
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch →
stale cache → `FALLBACK_ALARM_HOUR`. The cache
(`~/.config/wake_alarm/alarm_cache.json`) is what bounds remote reads to
~288/day despite the per-minute tick, and what keeps a network blip from
costing a wake-up.
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute,
so a persistent condition logged loudly is 1440 identical journal lines a
day. `_trigger` emits the single WARNING that matters: it is firing *now* on
a non-phone alarm time.
Check what the PC currently resolves: `python -m wake_alarm._sync --show`.
Check what it would do right now: `python -m wake_alarm._trigger --dry-run`.
## Module map
`_alarm.py` owns the window and the dismiss flow and nothing else. The full
map of which module answers which question, the HDMI-silence root cause, and
the timing constants that must not collide are in **`DOCS-architecture.md`**.
Read it before changing the escalation or the failsafe.
## Cross-repo coupling — not a bug
`_constants.py`'s `WORKOUT_LOG_FILE` points at
`~/screen-locker/screen_locker/workout_log.json` — a file owned by the
separate, already-standalone `screen-locker` repo
(https://github.com/kuhyx/screen-locker). This is intentional: the alarm
reads whether today's workout was already logged by screen-locker to decide
whether the morning routine should also lock the workout screen. If this
path ever raises `ModuleNotFoundError`-style confusion, the bug is almost
certainly in the **orchestrator** (`morning_routine` in testsAndMisc), not
here — see the next section.
## The morning_routine orchestrator lives elsewhere
`morning_routine._orchestrator` (in `testsAndMisc/python_pkg/morning_routine/`)
runs this package and `screen_locker` as two sequential subprocesses. When
either package is extracted to its own repo, **the orchestrator's module
reference must be updated in the same change** — this exact mistake
(orchestrator left pointing at `python_pkg.screen_locker.screen_lock` after
screen-locker's extraction on 2026-05-28) caused a month-long silent
production failure where the alarm fired and dismissed correctly but the
workout lock crashed with `ModuleNotFoundError` on every run. Once both
`wake_alarm` and `screen_locker` are pip-installed system-wide, the
orchestrator needs no `PYTHONPATH`/`cwd` plumbing — plain
`subprocess.run([sys.executable, "-m", module, "--production"])` resolves
both.
## Production dependency installation — read this before adding any dependency
`wake-alarm.service` runs `/usr/bin/python` directly — **not** a venv. Any
new non-stdlib dependency (this package itself, `gatelock`, `python-kasa`,
anything added later) must be installed into system Python's *user*
site-packages:
```bash
/usr/bin/python3 -m pip install --user --break-system-packages -e .
```
**Always run `install.sh` (or `pip install -e`) from a durable clone, not a
scratch directory.** `install.sh` does `pip install -e "$REPO_DIR"` —
editable, so a later `git pull` in that same clone updates the running
production code with no reinstall needed. The clone must live somewhere
permanent (this repo's convention: `~/wake-alarm`, mirroring `~/screen-locker`
for the screen-locker package) — if you `pip install -e` from `/tmp/...` or
run `pip install "wake_alarm @ git+https://..."` as a one-off, you get a
non-editable snapshot frozen at that commit, and the next `git push` here
silently does **not** reach the running service.
`install.sh` already does this. **If you add a dependency and only install it
into a dev venv, the production service will silently fail with
`ModuleNotFoundError` on its next run** — this exact gap caused a 3-day
diet_guard production outage (2026-06-19 to 2026-06-22) for the sibling
`gatelock` migration. Always verify against
`/usr/bin/python3 -c "import <new_dep>"`, not just the dev venv, before
considering a dependency change done.
## Operational gotchas
- **`python-kasa` is optional at runtime** (`_smart_plug.py` catches
`ImportError` and disables smart-plug control with a warning log), but it
*is* a hard dependency for this repo's own tooling (mypy/pylint/tests need
it importable) — see `pyproject.toml`/`requirements.txt`.
- **The `wave` module needs special pylint handling.** `_audio.py` opens WAV
files in write mode; pylint's stdlib stub infers the read-mode overload and
wrongly flags `setnchannels`/`setsampwidth`/`setframerate`/`writeframes` as
missing. See the `generated-members` list in `pyproject.toml`'s
`[tool.pylint.typecheck]` — don't remove it if pylint starts complaining
about `wave.Wave_write`.
- **`wake_state.json` is runtime state, not tracked.** It holds the
HMAC-signed dismissal record for the current alarm day. It used to be
accidentally committed in the monorepo; it is gitignored here and must
stay that way.
## Commands
- Run tests: `python -m pytest wake_alarm/tests/ --cov=wake_alarm --cov-branch --cov-fail-under=100`
- Lint: `pre-commit run --all-files`
- Test the lock manually (safe, closeable): `python -m wake_alarm._alarm --demo`
- Show the resolved alarm: `python -m wake_alarm._sync --show` (add
`--no-cache` to force a live GitHub read)
- Show what the next tick would do: `python -m wake_alarm._trigger --dry-run`
- Watch it live: `journalctl --user -fu wake-alarm-trigger.service`
- Install for production: `bash install.sh`
- Gate the shell/system scripts: `bash scripts/vmbox/harness.sh`
(disposable Arch VM; what it covers and what it cannot is in
**`DOCS-vmbox.md`** — notably *not* audio, the phone, or the
bootloader). A sandbox pass never proves the host is fine.
Note the dev venv lacks `gatelock`, so anything importing `_state` (including
`_trigger`) must be run with `/usr/bin/python3`, not `.venv/bin/python`.
## Do NOT
- Don't add a dependency without doing the production install-path check
above. `_sync.py`'s **GitHub** path is stdlib-only for exactly this reason —
don't "improve" it to `requests`, and don't make the `crdt_sync` import
unconditional: the `try/except ImportError` guard is what keeps a failed
install from killing the trigger.
- Don't forget the orchestrator when changing this package's module path or
invocation — see "The morning_routine orchestrator lives elsewhere" above.
- Don't give `wake-alarm.service` an `[Install]` section or enable it. It
fires at login time, not alarm time — see the section above.
- Don't make the trigger start `wake-alarm.service`; it must start
`morning-routine.service` so the workout lock still runs.
- Don't raise the log level of the routine degraded-read paths in `_sync` —
the per-minute tick turns any WARNING there into journal spam.
- Don't commit `wake_state.json`.