Commit Graph

18 Commits

Author SHA1 Message Date
dc22bd557c fast install: never report a failed run as success; queue the manifest first
The BG3 fast install of 2026-10-23 downloaded 160 GB, was refused by the
installer, and the web job still ended on a full bar, "Done!" and Succeeded
while Steam restarted the download from 0%.

- A failed run's reason is read from the installer's own run log (its ERROR
  lines). A waiting job prints "Failed: fast install of <game>: <reason>",
  which fails the job; `install` no longer says "Done!" after it.
- Daemon-started failures stay on the Dashboard's Fast install card (and in
  ctl status, `error`) until a new run starts or the game is installed.
- install_game writes Steam's "needs downloading" manifest before the
  installer runs: it only finishes a manifest it finds, so fresh installs
  were refused for want of one.
- Command building moved to _fast_install_cmd (250-line cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYmCezqhUjUmMisF5YtM4F
2026-10-24 08:36:57 +02:00
0d93539944 total block: two-unit countdown (2w 2d … 45s), ticking every second
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.5 (1M context) <noreply@anthropic.com>
2026-10-23 20:59:41 +02:00
1bdf360193 pick keeps already-picked games; fast install reports progress
pick and pick-manual no longer uninstall games that are already allowed:
the outgoing assignment is kept as a released pick (installed, no lock)
until 'pick --replace', done/check, or abandon-pick. The allowed set is
capped at max_manual_picks + 1; a pick over it is refused before the
candidate list / confirmation, naming the games. status and the web
Picks card list kept games with an Abandon button.

Fast install now tracks the installer log from the spawn offset: web
jobs drive the progress bar (per-depot %, phase) and echo a line every
30 s; daemon-started runs are exposed via ctl status / /api/daemon and
shown on the Dashboard.

Also: web deps bumped for dependency freshness (react-query 5.104.5,
typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
2026-10-23 20:14:12 +02:00
c31ae2170f fix: fast install ignores other downloads; install offers any allowed game
- Fast install no longer refuses while Steam downloads something else:
  the installer leaves via `steam -shutdown`, which Steam resumes from.
  Only a running game still refuses.
- Non-daemon detached runs (web jobs) wait for the installer with the
  same stall reaper the daemon uses, and log to a per-uid file: the
  shared root-owned log made every job spawn raise.
- `install` offers every allowed game that is not fully installed
  (assignment + manual picks), so a skipped fast install can be started
  later; it takes over Steam's slow download.
- Uninstall progress names only removed games; protected runtimes are
  logged as kept instead of appearing as the last "uninstalled" item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fHzMuPSHq68S2AcPqGe3Q
2026-10-16 15:21:00 +02:00
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
341bc784ac web control UI: all 24 commands in React, ctl.sock daemon, jobs, library browser; earned_time 0.7.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 16:07:42 +02:00
8e19e2f5bf Timed store window that survives hosts reinstalls; whitelist exceptions stay visible
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 13:43:32 +02:00
a191c8e459 Release a game after one new achievement instead of 100% completion
Requiring 100% locked the user onto one game for weeks, which proved
ineffective. The assignment and every manual pick now release as soon as one
achievement is unlocked after the game was assigned (Steam unlock_time vs the
pick's started_at / State.current_assigned_at).

- finished_app_ids still means 100% complete; a game released below 100% gets
  released_at plus a 7-day cooldown and returns to the pool.
- Next pick: least recently assigned first, then shortest HLTB time.
- Releasing never evicts: released/expired picks stay installed and playable
  until the user explicitly chooses another game (new pick-manual, or
  accepting the next assignment); with no replacement the current game stays.
- Tampering detection judges a released game by unlocks after its release.
- The daemon reloads every State field each tick so its sweep save cannot
  overwrite cooldowns or assignment times written by the CLI.
- State.load backfills assignment times once and persists them; load-time
  migrations moved to _state_migrations.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhzaTAWtRfJk8gre6NLSka
2026-09-26 16:46:03 +02:00
f1c8f04287 Install games with steam-game-installer (depotdownloader) for speed, falling back to steam://install
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 18:45:27 +02:00
30fcaacc5d Deduplicate snapshot loading, pick application and library hiding into shared helpers
require_snapshot_games / snapshot_game_name (_snapshot), apply_configured_pick (_actions) and hide_others_and_report (library_hider) replace copies spread across the CLI, MCP and stats paths. Tests retargeted to the new owners; test_main_pick_manual split to stay under 250 lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01D55DvFEHiwcPN59kwPqwiU
2026-09-25 18:44:07 +02:00
7323c896f6 Import split-out names from the modules that own them; drop the four lazy facades
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
hltb, _cmd_done, _whitelist and _actions each kept a module __getattr__
that served names moved out in the 250-line split, via importlib to
dodge the resulting import cycle. pylint cannot see through that, and
graded every such import as no-name-in-module (26 of them). The 32
import statements now name the real module; with nothing left going
through the facades, they are gone.

Also the pylint hook on 4.0.8 with the source-only scope config, the
three implicit-booleaness disables the reference repos carry, the
http.server do_GET admission, and the third-party pins the hook was
missing (aiohttp, websockets, howlongtobeatpy). Gate still 8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
2026-09-12 20:22:44 +02:00
47f77f5f1e Make serve re-runnable, and cover it to 100%
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 serve rework was sitting untracked: a /proc-based port guard, the
start-or-defer decision, and a stale-bundle rebuild. It had no tests, and it
changed cmd_serve(_config, state) to cmd_serve(argv) without updating the one
test that called it, so the suite was red and coverage was 251 lines short.

Tests, all against fakes:
- _fake_proc builds a synthetic /proc. _NET_TCP is derived from _PROC at
  import time, so patch_proc redirects both; patching _PROC alone would have
  had these tests parse the host's real process table.
- _serve_guard is split across two test files to stay under the 250-line cap.
  os is replaced wholesale in every termination test, so no signal can reach a
  real process even if the guard's self-protection were removed.
- _web_build mocks subprocess entirely and redirects every path constant into
  tmp_path. _no_subprocess now blocks _web_build.subprocess.run too: an
  unguarded test would fire a real `npm run build`, rewriting web/dist and
  perturbing frontend_is_stale() for every later test in the run.

Source changes, both required rather than incidental:
- _serve_guard's wildcard match was a `# noqa: S104` on a "0.0.0.0" literal,
  which the no-noqa hook rejects outright. It is now the all-zero hex column
  /proc actually prints, so the dotted-quad literal never appears.
- main/__init__.py's serve dispatch arm was itself uncovered; it now has a
  test asserting the raw argv passes through unparsed.

Also drops the document.hidden guard from useBudgetPoll: it skipped the very
first load, so the budget panel sat on "Reading budget state…" forever in a
background tab, which is every tab under browser automation.

Verified: 1712 python tests, 100% branch coverage; 128 vitest, 100%. The
guard's /proc parser was checked read-only against a real listener on :8000
and resolved the same pid as `ss`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MKFzwzwRU1JD7SFLHtQBsz
2026-08-29 18:52:11 +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
d0e8ed580c Auto-detect completed manual picks before enforcing the pick cap
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 14:53:04 +02:00
356530a01c Grade against 3.14 too, not just run on it
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
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10 -- three tools judging an interpreter nothing
runs any more, and ruff withholding the pyupgrade fixes that only become safe
at the newer target. `pin_ci_python.py` rewrites exactly these three knobs.

What the stale target was hiding: `datetime.timezone.utc` where `datetime.UTC`
is now correct (UP017), `socket.timeout` where `TimeoutError` is (UP041),
deprecated `typing` imports (UP035), PEP 695 `type` aliases (UP040), and ruff's
stable formatter applying PEP 758 -- `except (A, B):` becomes `except A, B:`.
That last one makes the source 3.14-only at the syntax level, which is what
`requires-python = ">=3.14"` already promised; kuhy chose it deliberately over
holding ruff a release back (2026-08-29).

ruff, ruff-format, mypy --strict and pylint all clean at the new target. The
two modified web/ TypeScript files are another session's in-flight work and
are deliberately left unstaged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:44:25 +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
737b49246c Allow abandoning a manual pick at any time; cap picks at 2
The 4-day grace window left kuhy stuck: three picks locked in, the window
closed on two of them, so nothing could be abandoned and no new pick could
be added.

- abandon-pick now works however old the pick is. MANUAL_GRACE_DAYS,
  manual_pick_grace_remaining() and can_abandon_manual_pick() are deleted;
  abandon_manual_pick()'s existence check is the single remaining guard
  (the previous post-guard and its "race" test became unreachable).
- Config.max_manual_picks 3 -> 2. kuhy's config.json does not set the key,
  so the default change is sufficient.
- status/lock/MCP surfaces report informational age ("picked N day(s) ago",
  age_days) instead of a grace countdown. Note this is a sign flip, not a
  rename: age is now - started, where grace was deadline - now.

Verified live: abandoned Montaro (495890) and Battle Bruise (618510), both
previously refused as EXPIRED; pick-manual 3164500 now reaches its confirm
prompt at 1/2 slots used. 1344 tests, 100.00% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
2026-08-24 15:41:32 +02:00
45ff4eb7c2 Split main.py (1076 lines) into a main/ package
main.py became main/ with __main__.py, so the invocation string baked into
run.sh and the systemd unit (`python -m steam_backlog_enforcer.main`) stays
byte-identical and nothing needs redeploying. Commands are grouped into
sibling modules (status, install, picks, abandon, misc), with _shared.py as
a leaf holding the lock helpers and constants both need, and _registry.py
holding the command tables. main/__init__.py keeps every public and private
name importable, so `from steam_backlog_enforcer.main import X` is unchanged.

The subtle part is patch targets. The test suite builds them as
f"{PKG}.{name}", so a grep for the literal module path found nothing -- but
once a command moves to a submodule it resolves its dependencies from that
module's globals, and patching the main package's re-export silently stops
biting. Measured which of the 36 patched symbols moved: 8 could have passed
vacuously. Rather than repair PKG per class, the five test_main*.py files
(all cap violators anyway) were regrouped to mirror the production modules,
so each file has exactly one correct PKG. Verified by mutation: breaking a
line in status/misc/picks/_shared each makes the corresponding tests fail.

test_web_server.py patched main_mod.serve, which no longer intercepts
cmd_serve; repointed at main.misc and confirmed it fails when the call is
removed. Added tests for the __main__ shim, which coverage would otherwise
report at 0% under fail_under = 100.

Shared test builders live in tests/_main_helpers.py, reachable because the
Phase 0 name-tests-test exclude allows tests/_*.py.

1290 tests pass; every main/ module reports 100% coverage; `main`, `status`,
`bogus` and `--status` produce byte-identical output and exit codes to
pre-refactor. 55 -> 49 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 19:56:34 +02:00