steam-backlog-enforcer/steam_backlog_enforcer/_bonus_incident.py
Krzysztof kuhy Rudnicki 63321f16cc
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Pay an hour of gaming budget for a LeetCode solve
The budget was binary: 8h with a counted workout, 6h without. There was no
base/bonus structure anywhere -- resolve_budget_seconds returned one of two
absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that
appeared in no line of code. A second earner could not be bolted onto that
shape without coupling the two.

It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7
or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to
7h, and a day that earns nothing drops 6h to 5h.

The two earners are read independently and added. That is what "must not
interfere with the workout" means in code: an unreadable LeetCode ledger leaves
a workout day at 7h, not 5h, and there is a test asserting exactly that.

LeetCode is read from leetcode-guard's ledger directly -- it answers even when
leetcode-guard is not running, which is most of the day -- and falls back to its
loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces
gatelock's canonicalisation in eight lines because gatelock lives in the user's
site-packages and this daemon runs as root, and a pinned vector fails loudly if
that ever drifts.

Failing to get an answer grants nothing AND raises an incident: desktop
notification, warning log, and a line in leetcode_bonus_incidents.log
(gitignored, so it never becomes an uncommitted change), rate-limited to once
per reason per day. A silently missing hour looks exactly like an unearned one.

resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate
the second resolution seam this module's docstring warns about. rules_for calls
it once and carries the breakdown on PlaytimeRules, so /api/budget reports why
today is what it is without resolving again.

Known limitation, accepted: leetcode-guard only writes credits from its lock
window's poll loop, so a voluntary extra solve after the day is settled never
reaches the ledger.

Verified live against real services -- all four cases (5/6/7/8h), the endpoint
fallback, the fail-closed path with its notification, and the rate limit.
1782 tests, 100% branch coverage.

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

89 lines
3.3 KiB
Python

"""Telling the user, immediately, that a budget bonus could not be checked.
An honest "you have not solved anything yet today" is not an incident -- it is
the gate working. This module fires only when the answer could not be obtained
at all, which silently costs an hour and looks identical to not having earned
it.
Three channels, because each covers the others' blind spot:
1. A desktop notification, which reaches the user in seconds.
2. A warning in the journal, which survives a missed notification.
3. A line in ``leetcode_bonus_incidents.log`` at the repo root, which survives
a journal rotation and sits where the user does their work.
The log file is deliberately a ``.log``: ``.gitignore`` already covers that
extension, so an incident never becomes an uncommitted change, and the
``md-naming`` pre-commit hook (which matches markdown only) never sees it. It
is written by a root daemon into a user-owned checkout, so removing it needs
``sudo``.
Rate-limited to one report per distinct reason per day. The enforcer ticks
frequently, and without that a single stopped service would produce a
notification storm and an unbounded file.
"""
from __future__ import annotations
from datetime import datetime
import logging
from pathlib import Path
from typing import Final
from steam_backlog_enforcer._playtime_notify import notify_desktop_user
logger = logging.getLogger(__name__)
# Resolved from __file__, never the working directory: the shipped unit template
# still names /opt/steam-backlog-enforcer, and only the copy install.sh rewrites
# points at the checkout.
INCIDENT_LOG: Final = (
Path(__file__).resolve().parents[1] / "leetcode_bonus_incidents.log"
)
_TITLE: Final = "Gaming budget: LeetCode bonus not applied"
# reason -> the local day it was last reported on.
_reported: dict[str, str] = {}
def reset_reported() -> None:
"""Forget which incidents have been reported, so the next one fires."""
_reported.clear()
def report_leetcode_incident(reason: str, *, fix: str) -> None:
"""Report that the LeetCode bonus could not be checked.
Args:
reason: What went wrong, in the user's terms.
fix: The command that would diagnose or repair it.
"""
today = datetime.now().astimezone().date().isoformat()
if _reported.get(reason) == today:
# Already told them today. Still worth a debug line so the journal
# shows the condition persisting rather than appearing to clear.
logger.debug("LeetCode bonus still unavailable: %s", reason)
return
_reported[reason] = today
body = f"{reason}. Budget is 1h lower until this is fixed. Try: {fix}"
logger.warning("LeetCode bonus not applied - %s. Try: %s", reason, fix)
notify_desktop_user(_TITLE, body)
_append_incident(f"{datetime.now().astimezone().isoformat()} {body}")
def _append_incident(line: str) -> None:
"""Append one line to the incident log, or explain why it could not.
Args:
line: The already-formatted incident line.
"""
try:
with INCIDENT_LOG.open("a", encoding="utf-8") as handle:
handle.write(f"{line}\n")
except OSError as exc:
# The notification and the journal line have already gone out, so this
# is a degraded report rather than a lost one -- but say so.
logger.warning("Could not append to %s (%s)", INCIDENT_LOG, exc)