From a85e8fec7172342f5a4351ae39a1e75fcfe7068f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 28 Aug 2026 19:55:04 +0200 Subject: [PATCH] Keep non-production runs out of the budget audit log Co-Authored-By: Claude Opus 5 (1M context) --- steam_backlog_enforcer/_enforce_loop.py | 2 +- steam_backlog_enforcer/_playtime_log.py | 29 +++++++++++++++++-- steam_backlog_enforcer/_playtime_session.py | 15 ++++++++-- .../tests/test_playtime_log.py | 15 ++++++++++ .../tests/test_playtime_session.py | 16 +++++++++- 5 files changed, 70 insertions(+), 7 deletions(-) diff --git a/steam_backlog_enforcer/_enforce_loop.py b/steam_backlog_enforcer/_enforce_loop.py index 4d587d9..fad15f0 100644 --- a/steam_backlog_enforcer/_enforce_loop.py +++ b/steam_backlog_enforcer/_enforce_loop.py @@ -209,7 +209,7 @@ def do_enforce(config: Config, state: State, *, demo: bool = False) -> None: _echo(" Press Ctrl+C to stop.\n") # One session for the whole daemon: the engagement backdate needs to see # the previous tick's verdict, so it cannot be rebuilt per iteration. - session = new_session() + session = new_session(demo=demo) try: while True: # Reload state from disk so CLI changes (e.g. new game diff --git a/steam_backlog_enforcer/_playtime_log.py b/steam_backlog_enforcer/_playtime_log.py index 1a529cd..88f125e 100644 --- a/steam_backlog_enforcer/_playtime_log.py +++ b/steam_backlog_enforcer/_playtime_log.py @@ -36,6 +36,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) BUDGET_LOG_FILE: Final = Path("/var/log/steam-backlog-enforcer/budget.jsonl") +BUDGET_DEMO_LOG_FILE: Final = Path("/var/log/steam-backlog-enforcer/budget-demo.jsonl") _MAX_BYTES: Final = 5 * 1024 * 1024 _BACKUP_COUNT: Final = 5 @@ -47,14 +48,38 @@ EVENT_DETECTOR_FAILURE: Final = "detector_failure" EVENT_MANUAL_ADJUSTMENT: Final = "manual_adjustment" +def budget_log_path(*, demo: bool) -> Path: + """Return the audit log for a demo or production run. + + Demo runs get their own file for the same reason they get their own state + file: this log is the record used to reconstruct real incidents, and a run + that carries no enforcement weight must not be able to plant records in it + that describe a budget nobody was ever charged. A 60-second demo billing + 700 seconds is indistinguishable, after the fact, from the production + daemon having done something inexplicable. + + Args: + demo: Whether this is a demo run. + + Returns: + The destination JSONL file. + """ + return BUDGET_DEMO_LOG_FILE if demo else BUDGET_LOG_FILE + + class BudgetLog: """Append-only JSONL sink, rotated by size.""" - def __init__(self, *, path: Path = BUDGET_LOG_FILE) -> None: + def __init__(self, *, path: Path) -> None: """Prepare a sink; the file is opened on first write. + *path* is required on purpose. It used to default to the production + log, so a throwaway probe run as root — ``BudgetLog()`` in a scratch + script — wrote invented records straight into the trail this module + exists to keep trustworthy. Naming the destination is now unavoidable. + Args: - path: Destination JSONL file. + path: Destination JSONL file, from :func:`budget_log_path`. """ self._path = path self._sink: logging.Logger | None = None diff --git a/steam_backlog_enforcer/_playtime_session.py b/steam_backlog_enforcer/_playtime_session.py index 9ddccc7..566d0c9 100644 --- a/steam_backlog_enforcer/_playtime_session.py +++ b/steam_backlog_enforcer/_playtime_session.py @@ -19,7 +19,11 @@ from typing import TYPE_CHECKING, Protocol from steam_backlog_enforcer._desktop_env import desktop_uid, resolve_desktop_user from steam_backlog_enforcer._playtime_engagement import EngagementTracker -from steam_backlog_enforcer._playtime_log import BudgetLog, TickJournal +from steam_backlog_enforcer._playtime_log import ( + BudgetLog, + TickJournal, + budget_log_path, +) if TYPE_CHECKING: from steam_backlog_enforcer._engagement_types import EngagementVerdict @@ -70,14 +74,19 @@ class PlaytimeSession: journal: TickRecorder -def new_session() -> PlaytimeSession: +def new_session(*, demo: bool = False) -> PlaytimeSession: """Build the session the enforce loop threads through every tick. + Args: + demo: Whether this is a short-budget demo run. Demo runs journal to + their own file, so they cannot plant records in the production + audit trail. + Returns: A session bound to the desktop user's X display and runtime dir. """ uid = desktop_uid(resolve_desktop_user()) return PlaytimeSession( tracker=EngagementTracker(uid=uid), - journal=TickJournal(BudgetLog()), + journal=TickJournal(BudgetLog(path=budget_log_path(demo=demo))), ) diff --git a/steam_backlog_enforcer/tests/test_playtime_log.py b/steam_backlog_enforcer/tests/test_playtime_log.py index 500c37b..7a1ff45 100644 --- a/steam_backlog_enforcer/tests/test_playtime_log.py +++ b/steam_backlog_enforcer/tests/test_playtime_log.py @@ -21,11 +21,14 @@ from steam_backlog_enforcer._engagement_types import ( EngagementVerdict, ) from steam_backlog_enforcer._playtime_log import ( + BUDGET_DEMO_LOG_FILE, + BUDGET_LOG_FILE, EVENT_DETECTOR_FAILURE, EVENT_HEARTBEAT, EVENT_VERDICT_CHANGE, BudgetLog, TickJournal, + budget_log_path, ) from steam_backlog_enforcer._playtime_state import PlaytimeRules, PlaytimeState @@ -52,6 +55,18 @@ def _records(path: Path) -> list[dict]: return [json.loads(line) for line in path.read_text().splitlines() if line] +class TestBudgetLogPath: + def test_production_run_journals_to_the_real_trail(self) -> None: + assert budget_log_path(demo=False) == BUDGET_LOG_FILE + + def test_demo_run_cannot_reach_the_production_trail(self) -> None: + # A demo bills against a 60-second budget. Letting those records land + # in the production log makes the log unusable for the one job it has: + # reconstructing what the real budget actually did. + assert budget_log_path(demo=True) == BUDGET_DEMO_LOG_FILE + assert budget_log_path(demo=True) != BUDGET_LOG_FILE + + class TestBudgetLog: def test_creates_its_directory_on_first_write(self, tmp_path: Path) -> None: target = tmp_path / "deep" / "nested" / "budget.jsonl" diff --git a/steam_backlog_enforcer/tests/test_playtime_session.py b/steam_backlog_enforcer/tests/test_playtime_session.py index f43d6b9..3d46c38 100644 --- a/steam_backlog_enforcer/tests/test_playtime_session.py +++ b/steam_backlog_enforcer/tests/test_playtime_session.py @@ -5,7 +5,11 @@ from __future__ import annotations from unittest.mock import patch from steam_backlog_enforcer._playtime_engagement import EngagementTracker -from steam_backlog_enforcer._playtime_log import TickJournal +from steam_backlog_enforcer._playtime_log import ( + BUDGET_DEMO_LOG_FILE, + BUDGET_LOG_FILE, + TickJournal, +) from steam_backlog_enforcer._playtime_session import new_session PS = "steam_backlog_enforcer._playtime_session" @@ -31,3 +35,13 @@ class TestNewSession: ): session = new_session() assert "/run/user/1000/" in str(session.tracker._holder_path) + + def test_demo_journals_away_from_the_production_trail(self) -> None: + with ( + patch(f"{PS}.resolve_desktop_user", return_value="kuhy"), + patch(f"{PS}.desktop_uid", return_value=1000), + ): + live = new_session() + demo = new_session(demo=True) + assert demo.journal._log._path == BUDGET_DEMO_LOG_FILE + assert live.journal._log._path == BUDGET_LOG_FILE