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

143 lines
4.9 KiB
Python

"""Reading leetcode-guard's ledger for today's accepted submissions.
Credits are verified against the shared HMAC key, reproducing gatelock's
canonicalisation in eight lines rather than importing it: gatelock is installed
into the user's site-packages and this daemon runs as root, so the import is
not available. ``test_leetcode_ledger.py`` pins the canonicalisation to a fixed
vector so the duplication cannot drift silently.
Only ``credit`` entries count. A ``seen`` entry is first-run seeding worth
zero, and a ``charge`` proves only that the day was *settled* -- which also
happens from banked credit, from the escape hatch and from a classified
outage, so it can never stand in for a solve.
The solve time is ``detail["submitted_at"]``, LeetCode's own timestamp, not the
entry's ``day``: ``day`` is stamped by the *harvesting* run, so a problem
solved at 23:50 and harvested the next morning carries the next day's key.
Every unreadable state returns ``None``, never ``False``. "Cannot check" is not
"not solved", and only one of the two is worth waking the user about.
"""
from __future__ import annotations
from datetime import datetime, time
import hashlib
import hmac
import json
import logging
from pathlib import Path
from typing import Any, Final
logger = logging.getLogger(__name__)
# The key every locker signs its state with. Root-owned, world-readable.
HMAC_KEY_FILE: Final = Path("/etc/workout-locker/hmac.key")
_CREDIT: Final = "credit"
def _verified(entry: dict[str, Any], key: bytes) -> bool:
"""Whether a ledger entry's HMAC signature is genuine.
Args:
entry: The full entry, including its ``hmac`` field.
key: The shared signing key.
Returns:
Whether the signature matches. An unverified credit is worth an hour of
gaming time, so a forged one must not count.
"""
stored = entry.get("hmac")
if not isinstance(stored, str):
return False
body = {k: v for k, v in entry.items() if k != "hmac"}
payload = json.dumps(body, sort_keys=True, separators=(",", ":"))
expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(stored, expected)
def _landed_today(entry: dict[str, Any], *, window: tuple[float, float]) -> bool:
"""Whether this credit's accepted submission happened today, locally.
Args:
entry: One verified credit entry.
window: Local midnight and now, as unix seconds.
Returns:
Whether the solve falls inside today. ``detail.submitted_at`` is
LeetCode's own timestamp; ``day`` is the *harvest* day and runs late, so
it is only a fallback -- it can miss a late solve, never invent one.
"""
detail = entry.get("detail")
raw = detail.get("submitted_at") if isinstance(detail, dict) else None
if raw is not None:
try:
stamp = float(str(raw))
except ValueError:
logger.warning(
"LeetCode credit %r has an unparsable submitted_at (%r); "
"falling back to its day key",
entry.get("entry_id"),
raw,
)
else:
start, end = window
return start <= stamp <= end
today = datetime.now().astimezone().date().isoformat()
return entry.get("day") == today
def _today_window() -> tuple[float, float]:
"""Local midnight and now, as unix seconds.
Returns:
The inclusive window a solve must fall in to count for today.
"""
moment = datetime.now().astimezone()
midnight = datetime.combine(moment.date(), time.min, moment.tzinfo)
return midnight.timestamp(), moment.timestamp()
def read_ledger_solved_today(path: Path) -> bool | None:
"""Read leetcode-guard's ledger and count today's verified credits.
Args:
path: The ledger file.
Returns:
True or False when the ledger could be read and verified, ``None`` when
it could not -- which is never "nothing was solved".
"""
try:
key = HMAC_KEY_FILE.read_bytes().strip()
except OSError as exc:
logger.warning("Cannot read the integrity key at %s (%s)", HMAC_KEY_FILE, exc)
return None
if not key:
logger.warning("Integrity key at %s is empty", HMAC_KEY_FILE)
return None
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
logger.warning("Cannot read the LeetCode ledger at %s (%s)", path, exc)
return None
except ValueError as exc:
logger.warning("LeetCode ledger at %s is not valid JSON (%s)", path, exc)
return None
rows = raw.get("entries") if isinstance(raw, dict) else None
if not isinstance(rows, list):
logger.warning("LeetCode ledger at %s has no entries array", path)
return None
window = _today_window()
return any(
isinstance(row, dict)
and row.get("kind") == _CREDIT
and _verified(row, key)
and _landed_today(row, window=window)
for row in rows
)