screen-locker/scripts/check_enforcement_armed.py
Krzysztof kuhy Rudnicki 8f911585c1 Serve the lock decision, its history and its health at 127.0.0.1:8770
The only status surface was a GTK tray icon, commented out of ~/.config/i3
on 2026-08-21. So when the weekly count went wrong, nothing on the machine
said so for two weeks -- the same silence that let a deleted timer job stop
enforcement for thirteen days in August.

Three read-only endpoints, and a React 19 UI over them:

  /api/status     the decision, the week, and whether today has a workout
  /api/decisions  the recorded trail, newest first
  /api/health     timers armed, disarm marker, log and trail freshness

The Why view is the one that matters: it renders the run of
"weekly_minimum_met 5/5" ticks that hid this week's bug, without anyone
having to reach for journalctl.

/api/status also carries gaming.workout_today, which is the fact
steam-backlog-enforcer will cut the daily budget on. Deliberately just the
fact -- the enforcer owns the hour values, so the two repos cannot come to
disagree about what a workout day is. It is derived from count_day_credits,
the same rule the locker enforces with.

_compliance_state_word moves from status_view (tkinter) into
_status_projection, and the systemd arming check moves from
scripts/check_enforcement_armed.py into screen_locker/_armed_state.py, so
the server can answer "is enforcement armed" without a second copy of the
logic that would be free to disagree with the gate.

web/ mirrors steam-backlog-enforcer/web but tightens it: that config has no
"strict" in any tsconfig, extends only tseslint recommended (no type-aware
rules at all), and gates neither lint nor typecheck in pre-commit. Here all
three run, on strictTypeChecked + stylisticTypeChecked, with
noUncheckedIndexedAccess and exactOptionalPropertyTypes. 44 tests, 100% of
statements, branches, functions and lines.

typescript stays pinned at 6.0.3: 7.0.2 is out, but every published
typescript-eslint -- stable, canary and rc-v8 -- declares
typescript ">=4.8.4 <6.1.0". The fleet allowlist already carries that entry.

The 250-line cap now covers ts/tsx. It was python/shell/dart only, which
would have made web/ exempt from the gate the rest of the repo lives under
-- the same omission that exempted the whole Flutter app until 2026-08-15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
2026-08-29 22:50:19 +02:00

99 lines
3.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail loudly when the screen locker is not actually armed.
The 2026-08 outage was not a logic bug. Every predicate worked; the timer that
*invokes* them had been disabled by a systemd ordering cycle, and nothing on the
machine considered that worth mentioning. ``systemctl status`` showed no failed
unit, because a job deleted to break a cycle is not a failure -- it is an
absence, and absences do not page anyone.
This script turns that absence into a loud, deterministic failure. It answers
one question -- *would enforcement actually happen?* -- and answers it from
systemd's own view rather than from the unit files on disk, because the files
were correct-looking the entire time the timer sat disabled.
Run it from the periodic sync timer (``workout-sync.service``), so the check
rides along with a unit already proven healthy instead of adding another timer
that could itself silently die.
Output goes through ``sys.stderr.write`` rather than ``print`` to match the
other gate scripts in this directory (see ``check_silent_failures.py``), which
keeps the repo free of T201 waivers.
Usage:
python3 scripts/check_enforcement_armed.py # exit 1 if disarmed
python3 scripts/check_enforcement_armed.py --quiet # only report problems
"""
from __future__ import annotations
import argparse
import logging
import sys
from screen_locker._armed_state import collect_states, systemctl_available
_logger = logging.getLogger(__name__)
def _report(message: str) -> None:
"""Write one line to stderr, so a disarmed locker is visible in the unit.
Args:
message: The line to write.
"""
sys.stderr.write(f"{message}\n")
def main(argv: list[str] | None = None) -> int:
"""Report arming state; return 1 when enforcement would not happen."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--quiet",
action="store_true",
help="print only when something is wrong",
)
args = parser.parse_args(argv)
if not systemctl_available():
# Reachable in containers/CI. Say so explicitly rather than passing:
# "could not check" must never be recorded as "checked and fine".
_logger.error("systemctl not found — the arming check could not run")
_report(
"ERROR: systemctl not found — cannot verify that the screen locker "
"is armed. This check did NOT pass; it did not run."
)
return 1
states = collect_states()
disarmed = [state for state in states if not state.armed]
if disarmed:
_logger.error(
"Screen locker is NOT armed: %s",
", ".join(state.name for state in disarmed),
)
_report(
"ERROR: the screen locker is NOT armed — a day without a workout "
"would pass unenforced."
)
for state in states:
_report(f" {state.describe()}")
_report(
"\nRe-arm with:\n"
" systemctl --user daemon-reload\n"
" systemctl --user enable --now "
+ " ".join(state.name for state in disarmed)
)
return 1
if not args.quiet:
for state in states:
_report(f" {state.describe()}")
_report("Screen locker enforcement is armed.")
return 0
if __name__ == "__main__":
sys.exit(main())