steam-backlog-enforcer/steam_backlog_enforcer/_leetcode_bonus.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

162 lines
5.8 KiB
Python

"""Whether a LeetCode problem was solved today, worth an hour of budget.
Deliberately shaped like :mod:`steam_backlog_enforcer._workout_budget`, because
the two earners must behave identically where it matters:
**Fail closed.** Any failure to get an answer -- missing ledger, unparsable
JSON, unreadable integrity key, dead fallback endpoint -- yields *no bonus*,
logged at warning and reported through :mod:`_bonus_incident`. Failing open
would let the coupling be defeated by deleting a file.
**Independent of the workout term.** Nothing here can raise or lower the
workout bonus. The two answers are read separately and summed by
``resolve_budget``, which is what "must not interfere with the workout" means
in code.
**Two transports, one fact.** The ledger is read directly first: it answers
even when leetcode-guard is not running, which is most of the day. The loopback
endpoint is the fallback, and covers a renamed or misconfigured ledger path
rather than a permission problem -- this daemon runs as root, so it already has
strictly more access than the endpoint does. Both carry the same staleness (see
below); the fallback is not a fresher source.
**Known staleness, accepted.** leetcode-guard only writes credits from its lock
window's poll loop, so a *voluntary extra* solve made after the day is settled
never reaches the ledger and earns nothing. The bonus tracks what the day's
gate run recorded.
Reading and verifying the ledger itself lives in
:mod:`steam_backlog_enforcer._leetcode_ledger`; this module owns the two
transports, the memo, and the incident on total failure.
"""
from __future__ import annotations
import http.client
import json
import logging
from pathlib import Path
import time as time_module
from typing import TYPE_CHECKING, Final
from urllib.parse import urlsplit
from steam_backlog_enforcer._bonus_incident import report_leetcode_incident
from steam_backlog_enforcer._leetcode_ledger import read_ledger_solved_today
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
_TIMEOUT_SECONDS: Final = 2.0
_CACHE_TTL_SECONDS: Final = 60.0
# Only successes are cached, matching _workout_budget: a failure is retried on
# the very next tick, so a restarted service takes effect immediately instead
# of costing an hour for another minute.
_cache: dict[str, tuple[float, bool]] = {}
def reset_cache() -> None:
"""Drop the cached answer, forcing the next call to re-ask."""
_cache.clear()
def _fetch_leetcode_today(url: str) -> bool:
"""Read ``leetcode.solved_today`` from leetcode-guard's status API.
Args:
url: The status endpoint, e.g. ``http://127.0.0.1:8771/api/status``.
Returns:
Whether an accepted submission is recorded for today.
Raises:
OSError: The endpoint could not be reached.
ValueError: The response was not usable JSON, not a 200, or said it
could not check.
KeyError: The payload lacked the expected fields.
"""
split = urlsplit(url)
conn = http.client.HTTPConnection(
split.hostname or "127.0.0.1",
split.port or 80,
timeout=_TIMEOUT_SECONDS,
)
try:
conn.request("GET", split.path or "/api/status")
resp = conn.getresponse()
body = resp.read()
if resp.status != http.HTTPStatus.OK:
msg = f"status {resp.status} {resp.reason}"
raise ValueError(msg)
finally:
conn.close()
block = json.loads(body)["leetcode"]
if not block["checked"]:
# The server answered, but said it could not look. That is not a "no".
msg = f"leetcode-guard could not check: {block.get('reason', 'no reason')}"
raise ValueError(msg)
return bool(block["solved_today"])
def leetcode_solved_today(config: Config) -> bool | None:
"""Whether an accepted LeetCode submission is recorded for today.
Args:
config: Loaded user configuration, for the ledger path and the URL.
Returns:
True or False when either transport answered, ``None`` when neither
could. ``None`` is deliberately distinct from ``False`` so the caller
can log which one happened -- and so an unreadable ledger raises an
incident while an honest "not solved yet" does not.
"""
url = config.leetcode_status_url
now = time_module.monotonic()
cached = _cache.get(url)
if cached is not None and now - cached[0] < _CACHE_TTL_SECONDS:
return cached[1]
path = Path(config.leetcode_ledger_path).expanduser()
answer = read_ledger_solved_today(path)
if answer is None:
answer = _fallback_to_endpoint(url, path)
if answer is None:
return None
_cache[url] = (now, answer)
return answer
def _fallback_to_endpoint(url: str, path: Path) -> bool | None:
"""Ask leetcode-guard's status API after the ledger read failed.
Args:
url: The status endpoint.
path: The ledger that could not be read, for the incident message.
Returns:
The answer, or ``None`` when this transport failed too.
"""
try:
answer = _fetch_leetcode_today(url)
except OSError as exc:
report_leetcode_incident(
f"could not read the ledger at {path}, and could not reach "
f"leetcode-guard's status API at {url} ({exc})",
fix="systemctl --user status leetcode-guard-web",
)
return None
except (ValueError, KeyError, TypeError) as exc:
report_leetcode_incident(
f"could not read the ledger at {path}, and leetcode-guard's status "
f"API at {url} returned something unusable ({exc})",
fix="journalctl --user -u leetcode-guard-web -n 50",
)
return None
logger.warning(
"LeetCode ledger at %s was unreadable; used the status API instead", path
)
return answer