mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
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
89 lines
3.3 KiB
Python
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)
|