Commit Graph

9 Commits

Author SHA1 Message Date
f75dcd09e2 tests: block real kills/Steam spawns (a stale demo test killed the live game); enforce --demo is budget-only
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:15:11 +02:00
c8bd9b21ff budget: resolve gaming time from the shared earned_time registry
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 (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 19:01:27 +02:00
abf59d768b Reading hour from book-guard: +1h gaming, 8h cap; base 5h->4h from 2026-10-01
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 19:12:21 +02:00
63321f16cc Pay an hour of gaming budget for a LeetCode solve
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
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
1faf211767 Show which games make up the gaming budget, and bill osu!lazer
The budget tab answered "how much did I play" but not "what did I play":
playtime_state.json and playtime_history.json each stored one aggregate
seconds figure per gaming day, so the 14-day chart was a row of featureless
bars. Separately, qualifying_pids had exactly two sources — the SteamAppId
env var and a hardcoded 16-launcher frozenset — so osu!lazer, which sets no
SteamAppId and matches no launcher name, was never billed at all.

qualifying_pids now returns pid -> attribution key instead of a bare pid set.
The app id was already in hand and thrown away on its first line; it now
survives into PlaytimeState.per_game, alongside a user-editable
counted_processes list in config.json seeded with osu!lazer.

Two things the parts/whole relationship forces:

  - per_game only ever sums to AT MOST seconds, never equals it. backdate
    refunds the idle grace on every engaged-to-paused edge, and
    _state_or_recover synthesises a full budget with no breakdown. The
    refund is debited from last_credited_key, and the remainder is rendered
    as "Unattributed" rather than stored — one path then covers legacy days,
    focus-probe failures and drift alike.

  - counted_processes is kept apart from LAUNCHER_PROCESS_NAMES. That
    frozenset is also the total-block kill list, and it routes through
    kill_and_uninstall_launchers, which pacman -R's the owning package.
    Counted entries are killed on total block, never uninstalled.

Both storage formats stay at schema 1: load_state already filters to
dataclass fields, so a record without per_game migrates for free, and the
history reader has to accept both a bare float and an object regardless, so
a version bump would distinguish nothing.

Chart segments use the shared design system's categorical ramp (--cat-1..6),
capped at six games plus "Other" because a seventh distinguishable hue does
not exist under simulated CVD. The red over-budget fill is gone: colour now
encodes which game, and the dashed budget line alone says over or under.

Snapshot persistence moves to _snapshot.py to keep config.py under the
250-line cap. Three modules bound config paths at import time, which
bypasses the conftest redirect — they now reach them through the module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
2026-08-29 18:32:03 +02:00
fcae46faf1 Show the gaming budget in the web UI
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 07:17:42 +02:00
b3e629e3f0 Bill gaming time only while the user is actually playing
A running game process was treated as proof of play. On 2026-08-28 the budget
billed ~2h23m while a gatelock screen locker covered the display and the
machine could not be touched at all -- the game was simply still resident.

A tick now bills only when all three hold, evaluated together rather than
short-circuited so the audit log can answer "was the screen ALSO locked?":

  * no gatelock guard owns the screen  (_screen_hold, via /proc/locks)
  * the focused window belongs to a qualifying process or its ancestry
  * some input source has been used within idle_grace_seconds (default 300)

Any probe that cannot answer BILLS the tick and is recorded in `degraded`.
A broken detector must never become an unlimited-gaming exploit.

Three findings from measuring this machine rather than assuming:

  * /dev/input/js0 here is an *ASRock LED Controller* -- the kernel exposes
    the motherboard RGB device as a joystick. A presence check would have
    disabled the idle criterion permanently, so _controller_input watches for
    real events instead (skipping joydev's synthetic open burst).
  * Aseprite's window carries no _NET_WM_PID at all, so focus attribution
    falls back to XRes, which asks the server who owns the window rather than
    trusting the client to have announced itself.
  * /proc/locks reports gatelock's hold without taking it; gatelock's own
    read_claim_if_held detects held-ness by acquiring, which would race a
    guard every 3 seconds.

Idle pauses also refund the grace period once per walk-away, on the
engaged->paused edge only, never across a day rollover, and never on the first
tick after a restart -- the only place the counter moves backwards.

Everything is recorded to /var/log/steam-backlog-enforcer/budget.jsonl, on
change plus a heartbeat rather than one line per 3s tick. Records name the
qualifying PIDs: reconstructing this incident from journald alone was only
possible by inferring accrual from the spacing of two warning notifications.

Also: refuse to bounce Steam while a game is running. ensure_steam_debug_port
relaunches Steam when the CDP port is closed, which kills the running game --
that is how restarting the daemon to deploy this change ended a live session.
It now defers exactly as it already did for in-flight game updates.

Talks to X through ctypes over one cached connection: at a 3s tick, forking
xprintidle and xprop would cost ~40 forks a minute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019CEsG6BzHtYR9cHcdxPZqG
2026-08-28 19:24:14 +02:00
b5b4b35c07 Split total-block, playtime and MCP by responsibility
Each of these was one file doing several jobs, so they are split along
what they actually do rather than at arbitrary line offsets:

- _total_block: the lock file and the status derived from it stay in
  _total_block; making Steam *not installed* moves to _total_block_purge,
  making it *unreachable* to _total_block_hosts / _total_block_iptables.
  The generic pacman verbs (owner, installed, remove) become _pacman,
  since nothing about them is total-block-specific. Domain and process
  name tables become data modules.
- _playtime: budget state and persistence (_playtime_state), pure
  transitions (_playtime_budget), cmdline process detection
  (_playtime_procs), the warn/cutoff/kill ladder (_playtime_cutoff),
  desktop notifications (_playtime_notify), process-tree walking and
  killing (_playtime_kill), mountinfo parsing (_playtime_mountinfo) and
  the shared privileged-command helper (_playtime_run).
- _mcp: read-only tools (_mcp_query) split from mutating ones
  (_mcp_actions, _mcp_gaming_time), so "what can this server change?" is
  answerable from one file. _mcp keeps the server object and entry point.

No re-export shims: callers import from the module that defines the name.
That keeps a stale patch target loud (AttributeError) instead of letting
it pass while intercepting nothing.

`ruff check --select F821,F401` is run after every extraction -- an earlier
mechanical attempt moved functions without the module-level names they
referenced, which imported fine and only failed when called (349 errors),
so it was reverted and redone by hand.

The autouse fixtures in conftest are now imported by name and held by
__all__; ruff --unsafe-fixes had deleted them as unused, since pytest
resolves fixtures by name.

1324 tests pass at 100.00% coverage in the clean venv; `main status` is
byte-identical to a worktree at HEAD. 49 -> 39 files over the cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
2026-08-23 20:52:08 +02:00
0c05d71008 Add a daily gaming-time budget with a binary-level cutoff
Accumulates time spent in Steam games and other launchers, and once the
daily budget (default 8h) is spent, shuts Steam down, kills the games, and
makes them unlaunchable until the next 06:00.

The gaming day starts at 06:00 local rather than midnight so a late session
counts against the day it began in; rollover is checked lazily against a
stored day key, so it survives suspend, reboot and daemon restarts with no
timer of its own. Elapsed time comes from wall-clock deltas clamped to two
tick intervals, which keeps suspends, missed ticks and NTP steps honest.

The block is a read-only bind mount of a refusal stub over all four launcher
entry points -- /usr/bin/steam is only a two-line exec wrapper, so masking it
alone leaves bin_steam.sh, Steam's own steam.sh and lutris live. A bind mount
mutates no bytes, umount restores exactly, and a reboot clears it
unconditionally; chmod/rename would leave Steam broken if the daemon died
mid-block. /proc/self/mountinfo is the authority on what is masked, never the
saved state, which is what makes a stale block release even if the state file
was deleted. The mount is deferred one tick past the cutoff so
`steam -shutdown` still works and games close cleanly.

Launchers are matched by cmdline, not comm: /usr/bin/lutris is a python3
script, so the kernel records its comm as "python3" and the existing scan
never sees it. The kill set is wider than the budget predicate, walking down
from each launcher, because a Lutris-launched Wine game carries no SteamAppId.

State is chattr +i'd -- ~/.config is user-writable, so root ownership alone
would not stop `rm` from resetting the counter -- and a missing state file
while mounts are live fails closed rather than lifting the block.

Also fixes a pre-existing bug: do_enforce returned when no game was assigned,
exiting the process. Since the budget is accounted for from inside that loop,
it silently stopped enforcing whenever a game was finished but not rescanned.

Verified on the desktop with a real game: AoE2 counted 18 processes, tripped
at the 60s demo budget, all processes killed, all four paths masked and
refusing, then gaming-unblock restored them byte-identical with
`pacman -Qkk steam lutris` reporting 0 altered files.

1322 tests pass at 100.00% branch coverage; pacman validated the ALPM hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbEaXapjMSkxyzgvbdkUFb
2026-08-04 20:00:08 +02:00