screen-locker/screen_locker/_armed_state.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

151 lines
5.0 KiB
Python

"""Whether systemd would actually run the locker, read from systemd itself.
The 2026-08 outage was not a logic bug. Every predicate worked; the timer that
*invokes* them had been disabled by an 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 module answers one question -- *would enforcement actually happen?* -- 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.
It lives in the package rather than in ``scripts/`` because two surfaces need
the same answer: ``scripts/check_enforcement_armed.py`` (the gate that rides
along with ``workout-sync.service``) and the Health view of the local web UI.
Two copies of this logic would be free to disagree about whether the locker is
alive, which is the failure it exists to detect.
"""
from __future__ import annotations
from dataclasses import dataclass
import logging
import shutil
import subprocess
_logger = logging.getLogger(__name__)
# Every unit that must be armed for a workout-less day to end in a lock.
# workout-locker.service itself is WantedBy=graphical-session.target and so is
# only ever a login one-shot; the timers are what make enforcement recur.
REQUIRED_TIMERS = (
"early-bird-workout-check.timer",
"workout-locker.timer",
)
_TIMEOUT_SECONDS = 10
@dataclass(frozen=True)
class TimerState:
"""What systemd currently believes about one timer."""
name: str
enabled: bool
enabled_raw: str
scheduled: bool
@property
def armed(self) -> bool:
"""True only when the timer is both enabled and actually scheduled.
Both halves matter. ``is-enabled`` alone passes for a timer whose job
systemd deleted to break an ordering cycle; ``list-timers`` alone passes
for a transient ``start``ed timer that will not survive a reboot.
Returns:
True when this timer would really fire.
"""
return self.enabled and self.scheduled
def describe(self) -> str:
"""Return a one-line human summary of this timer's state.
Returns:
An ``OK``/``DISARMED`` line naming every reason it is not armed.
"""
if self.armed:
return f"OK {self.name}: enabled and scheduled"
problems = []
if not self.enabled:
problems.append(f"not enabled (is-enabled={self.enabled_raw or 'unknown'})")
if not self.scheduled:
problems.append("no next trigger in list-timers")
return f"DISARMED {self.name}: {', '.join(problems)}"
def systemctl_available() -> bool:
"""Return whether ``systemctl`` can be run at all.
Reachable in containers and CI. Callers must treat False as "did not
check", never as "checked and fine".
Returns:
True when ``systemctl`` is on PATH.
"""
return shutil.which("systemctl") is not None
def _run(args: list[str]) -> tuple[int, str]:
"""Run ``args``, returning ``(returncode, stdout)``. Never raises.
Args:
args: The argument vector, passed without a shell.
Returns:
The process return code and its stdout; ``(1, "")`` if it could not run.
"""
try:
# Explicit argument list, never shell=True.
proc = subprocess.run(
args,
capture_output=True,
text=True,
timeout=_TIMEOUT_SECONDS,
check=False,
)
except OSError, subprocess.SubprocessError:
# A check that cannot run must not look like a check that passed.
_logger.exception("Could not run %s", " ".join(args))
return 1, ""
return proc.returncode, proc.stdout
def _scheduled_timers() -> set[str]:
"""Return the timer names systemd currently has a next trigger for.
Returns:
The subset of :data:`REQUIRED_TIMERS` with a scheduled next run.
"""
_, out = _run(["systemctl", "--user", "list-timers", "--all", "--no-pager"])
scheduled: set[str] = set()
for line in out.splitlines():
for name in REQUIRED_TIMERS:
# A timer with no next trigger prints "n/a" in the NEXT column.
if name in line and " n/a " not in f" {line} ":
scheduled.add(name)
return scheduled
def collect_states() -> list[TimerState]:
"""Return the current arming state of every required timer.
Returns:
One :class:`TimerState` per entry in :data:`REQUIRED_TIMERS`.
"""
scheduled = _scheduled_timers()
states = []
for name in REQUIRED_TIMERS:
code, out = _run(["systemctl", "--user", "is-enabled", name])
raw = out.strip()
states.append(
TimerState(
name=name,
enabled=code == 0 and raw == "enabled",
enabled_raw=raw,
scheduled=name in scheduled,
)
)
return states