steam-backlog-enforcer/steam_backlog_enforcer/_budget_view.py
Krzysztof kuhy Rudnicki e82ddd9a4b playtime: shadow leisure billing (C4); /proc scan from leisure_watch
The enforcer now imports the Steam/launcher/counted-process scan from
leisure_watch.procscan (v0.1.0, shared with the leisure watcher) and drops
its own copies (_playtime_procs, _counted_procs, _total_block_launchers and
enforcer's two scanners). Every counted-process read passes
config.CONFIG_FILE explicitly, as before.

Each tick also bills a shadow leisure budget beside the enforced one, from
the same incoming stamp and delta clamp, before accumulate: paused (held
gatelock grab or i3lock) -> shadow_paused_seconds; else a qualifying game,
a "leisure" now.json, or an untrusted/missing now.json (fail closed) bills
shadow_seconds once, split over shadow_per_source. shadow_budget_seconds is
earned_time.resolve(leisure=True) from the same answers, held as a same-day
high-water mark. Not billed under a total block. Shadow verdict changes go
to budget.jsonl as shadow_change (the live game view skips them); /api/budget
and MCP get_gaming_time carry a "shadow" block. Enforced fields, cutoff,
warnings and existing log records are unchanged.

Also fixes two tests that assumed earned_time < 0.14 internals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 12:37:01 +02:00

251 lines
9.4 KiB
Python

"""One read-only view of the gaming budget, shared by MCP and the web API.
Both transports answer the same question — "how much have I played today, and
is the cutoff on?" — so both compose it here. The MCP tool used to build its
reply inline; a second copy in the HTTP handler would have been a second thing
to keep in step with ``PlaytimeRules``.
Everything here is a read. Changing the budget, resetting it and blocking stay
on the privileged paths, which is why the web server can serve this without
gaining a single mutating verb.
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final
from steam_backlog_enforcer._budget_games import (
billing_label,
history_view,
today_games,
)
from steam_backlog_enforcer._budget_log_tail import last_verdict
from steam_backlog_enforcer._ledger_earners import registry_for
from steam_backlog_enforcer._playtime_block import mounted_targets
from steam_backlog_enforcer._playtime_history import load_history
from steam_backlog_enforcer._playtime_state import (
load_state,
rules_for,
state_path,
)
from steam_backlog_enforcer._shadow_view import shadow_view
from steam_backlog_enforcer.config import Config, State
if TYPE_CHECKING:
from pathlib import Path
import earned_time
from steam_backlog_enforcer._playtime_state import PlaytimeRules, PlaytimeState
READABLE: Final = "ok"
MISSING: Final = "missing"
DENIED: Final = "denied"
CORRUPT: Final = "corrupt"
_STATUS_MESSAGES: Final[dict[str, str]] = {
DENIED: (
"Cannot read the budget state file. It is owned by root, and only "
"becomes world-readable once the enforcer has rewritten it — restart "
"steam-backlog-enforcer.service."
),
MISSING: "No gaming time has been recorded yet today.",
CORRUPT: (
"The budget state file could not be parsed. The enforcer treats that "
"as tampering and will start the day fail-closed."
),
}
_HISTORY_DAYS: Final = 14
def state_access(path: Path) -> str:
"""Classify why the state file did or did not open.
``load_state`` deliberately collapses "missing", "corrupt" and "unreadable"
into ``None`` — its caller is the enforcer, whose recovery is the same for
all three. A display needs to tell them apart, because "you have not gamed
today" and "I am not allowed to look" are opposite messages.
The read is attempted rather than pre-checked with ``os.access``: the
kernel's answer to the actual open is the only one that cannot disagree
with what ``load_state`` just experienced. The file is a couple of hundred
bytes, so reading it twice costs nothing.
Args:
path: The state file.
Returns:
``READABLE``, ``MISSING`` or ``DENIED``.
"""
try:
path.read_bytes()
except FileNotFoundError:
return MISSING
except OSError:
return DENIED
return READABLE
def next_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:
"""Return the next warning threshold that has yet to be reached.
Not ``_playtime_budget.pending_warning``: that returns a threshold already
*crossed* and awaiting its notification, which the daemon fires within one
tick — so an external reader sees ``None`` virtually always. This is the
forward-looking one, the threshold the countdown is heading towards.
Args:
state: Current accounting state.
rules: Policy in force.
Returns:
Seconds-remaining at which the next warning fires, or ``None`` when
every threshold has been passed.
"""
remaining = rules.budget_seconds - state.seconds
upcoming = [
threshold
for threshold in rules.warn_at
if threshold < remaining and threshold not in state.warned_seconds
]
return max(upcoming) if upcoming else None
def build_today(state: PlaytimeState, rules: PlaytimeRules) -> dict[str, Any]:
"""Compose the "today" block from loaded state.
Args:
state: Loaded accounting state.
rules: Policy in force.
Returns:
Serialisable today-block.
"""
remaining = max(0.0, rules.budget_seconds - state.seconds)
fraction = state.seconds / rules.budget_seconds if rules.budget_seconds else 1.0
return {
"gaming_day": state.day_key,
"day_starts_at": "06:00 local",
"seconds_used": round(state.seconds, 1),
"budget_seconds": rules.budget_seconds,
"seconds_remaining": round(remaining, 1),
"fraction_used": round(min(1.0, max(0.0, fraction)), 4),
"blocked": state.is_blocked(),
"blocked_at": state.blocked_at,
"next_warning_seconds": next_warning(state, rules),
"warned_seconds": list(state.warned_seconds),
"games": today_games(state),
"shadow": shadow_view(state),
}
def _most(earner: earned_time.Earner) -> int:
"""Gaming minutes a fully done day pays (earned_time >= 0.5 knows units)."""
return int(getattr(earner, "max_gaming_minutes", earner.gaming_minutes))
def build_rules(rules: PlaytimeRules) -> dict[str, Any]:
"""Compose the rules block.
An explicit allowlist, never ``Config.__dict__``: that carries
``steam_api_key``, and this payload is served over HTTP.
Args:
rules: Policy in force.
Returns:
Serialisable rules block.
"""
return {
"budget_seconds": rules.budget_seconds,
"enforcement": rules.enforcement,
"counts_launchers": rules.count_launchers,
"warn_at": list(rules.warn_at),
"demo": rules.demo,
"masked_launchers": sorted(str(path) for path in mounted_targets()),
# Read off the rules, never re-resolved: resolving a second time here
# is what would let this view report a budget the daemon is not
# enforcing. See _budget_resolve's "one seam".
"budget_reason": rules.budget_reason,
"bonuses": {
"base": rules.base_seconds,
"workout": rules.workout_seconds,
"leetcode": rules.leetcode_seconds,
"reading": rules.reading_seconds,
},
# The registry, in its order: what each earner adds today and what it
# would add once done. The bar renders "+<icon><size>" for every
# entry with nothing earned yet, so a new earner needs no bar change
# beyond an icon -- and no consumer hard-codes a size.
"earners": [
{
"name": earner.name,
"label": earner.label,
"earned_seconds": rules.earned_seconds.get(earner.name, 0.0),
# A full day pays up to the tutor's 60 credited units.
"bonus_seconds": _most(earner) * 60,
}
for earner in registry_for(datetime.now().astimezone().date())
],
}
def build_budget_snapshot(*, demo: bool = False) -> dict[str, Any]:
"""Compose the whole budget view: today, live session, history, rules.
Args:
demo: Whether to read the demo run's state and log.
Returns:
The serialisable payload behind ``GET /api/budget``.
"""
config = Config.load()
rules = rules_for(config, demo=demo)
stored = load_state(demo=demo)
access = state_access(state_path(demo=demo))
# A file that opened but did not parse is the remaining case: the enforcer
# treats that as tampering, and the display should say so rather than
# implying an empty day.
status = access if access != READABLE else (READABLE if stored else CORRUPT)
session = last_verdict(demo=demo)
# Demo runs have no history by design — HistoryWriter skips them, for the
# same reason they get their own state file and log. Serving the production
# days here would plot real 8-hour days against the demo's 60-second budget
# line, painting every one of them over-budget. `today.games` is still
# emitted: the demo's own breakdown is real.
history, legend = ([], []) if demo else history_view(load_history(_HISTORY_DAYS))
return {
"ok": True,
"readable": status == READABLE,
"state_status": status,
"error": _STATUS_MESSAGES.get(status),
"today": build_today(stored, rules) if stored is not None else None,
"session": {
# What the budget is actually charging — a different question from
# the backlog assignment reported as "game_name" below.
"billing_label": billing_label(stored.last_credited_key if stored else ""),
"available": session.available,
"observed_at": session.observed_at,
"state": session.state,
# A single Proton title qualifies a dozen processes — reaper,
# srt-bwrap, wineserver, plugplay.exe. Listing those as "games
# running" would be nonsense, so the headline is the game the
# enforcer actually assigned and the processes are the detail.
"game_name": State.load().current_game_name,
"qualifying_count": len(session.games),
"processes": [
{"pid": game.pid, "name": game.name} for game in session.games
],
},
# Demo runs have no history by design — HistoryWriter skips them, for
# the same reason they get their own state file and log. Serving the
# production days here would plot real 8-hour days against the demo's
# 60-second budget line, painting every one of them over-budget.
"history": history,
"legend": legend,
"rules": build_rules(rules),
}