steam-backlog-enforcer/steam_backlog_enforcer/_actions.py
Krzysztof kuhy Rudnicki 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

171 lines
6.1 KiB
Python

"""Stdout-free, state-only core operations shared by the CLI and MCP server.
Every function here is safe to call from a stdio MCP server, where STDOUT
carries the JSON-RPC protocol and any stray write corrupts the stream. That
means: no ``print``/``_echo``/``sys.stdout`` writes, no ``input()``, and no
``sys.exit()``. The interactive CLI (``main.py``) reuses these same functions so
there is a single tested implementation of the underlying behaviour.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from steam_backlog_enforcer._allowed_games import (
active_manual_picks,
allowed_games,
)
from steam_backlog_enforcer._assignment_progress import record_assignment
from steam_backlog_enforcer._pick_keep import (
allowed_cap_refusal,
keep_outgoing_assignment,
)
# Marks these two as an intentional re-export (they're imported from
# _allowed_games above, not defined here) -- without this, mypy's
# --no-implicit-reexport flags every downstream `from _actions import
# active_manual_picks` as an error. Everything else in this file is
# accessible normally; this only needs to list names imported-then-reexported.
if TYPE_CHECKING:
from steam_backlog_enforcer.config import Config, State
# How long an abandoned pick stays out of the auto-assignment pool, so that
# ``scan`` does not immediately hand back the game the user just rejected.
ABANDON_COOLDOWN_DAYS = 30
def find_manual_pick(state: State, app_id: int) -> dict[str, Any] | None:
"""Return the active manual pick for *app_id*, or ``None``.
Args:
state: The loaded enforcer state.
app_id: The Steam app id to look for.
Returns:
The matching active pick entry, or ``None`` if *app_id* is not one.
"""
return next(
(p for p in active_manual_picks(state) if p.get("app_id") == app_id),
None,
)
def is_manual_pick_locked(state: State) -> bool:
"""Return ``True`` if any manual pick is currently holding the lock.
With several picks allowed at once the lock releases only when every one
of them is released (one new achievement), finished or expired.
Args:
state: The loaded enforcer state.
Returns:
Whether the manual-pick lock is active right now.
"""
return bool(active_manual_picks(state))
def allowed_app_ids(state: State) -> set[int]:
"""Return every app id the enforcer must leave installed and visible.
This is the single source of truth for "may exist" across the uninstall
guard, the process killer, the library hider and the auto-installer. It is
the union of the active manual picks and the current assignment, so two
manually-picked games survive an enforcement pass while everything else is
still removed.
Args:
state: The loaded enforcer state.
Returns:
The set of allowed app ids (empty when nothing is assigned).
"""
return {app_id for app_id, _ in allowed_games(state)}
def manual_pick_slots_left(state: State, max_picks: int) -> int:
"""Return how many further manual picks fit under *max_picks*.
Args:
state: The loaded enforcer state.
max_picks: Configured cap (``Config.max_manual_picks``).
Returns:
Remaining slots, never negative.
"""
return max(0, max_picks - len(active_manual_picks(state)))
def apply_configured_pick(
config: Config, state: State, app_id: int, game_name: str
) -> str | None:
""":func:`apply_manual_pick` under the cap the configuration sets."""
return apply_manual_pick(
state, app_id, game_name, max_picks=config.max_manual_picks
)
def apply_manual_pick(
state: State,
app_id: int,
game_name: str,
*,
max_picks: int = 1,
) -> str | None:
"""Add *app_id* to the manual picks and persist ``state``.
This is the non-interactive, side-effect-scoped core of the CLI's
``pick-manual`` command. It mutates and saves ``State`` only; it deliberately
does **not** run the destructive post-assignment cascade (uninstalling other
games, installing the pick, hiding the library) that the CLI performs after
its interactive ``YES`` confirmation. Keeping this state-only means an
automated caller (the MCP server) can never wipe installed games.
The games already allowed are kept, like plain ``pick``: released,
finished and expired picks stay, and the outgoing assignment is parked as
a released pick. So the pick is refused when it would push the allowed
set over ``max_picks + 1`` (:func:`allowed_cap_refusal`). Re-picking a
kept game turns its entry back into an active pick instead of adding one.
Args:
state: The enforcer state to mutate and save.
app_id: The Steam app id to lock in.
game_name: Human-readable name for the picked game.
max_picks: How many picks may be active at once; the allowed set
may hold one more game (the assignment).
Returns:
``None`` on success, or a message explaining why the pick was refused
(already picked, no active slot left, or the allowed set is full), in
which case nothing was changed.
"""
active = active_manual_picks(state)
if any(p.get("app_id") == app_id for p in active):
return f"{game_name} (AppID={app_id}) is already one of your manual picks."
if len(active) >= max_picks:
names = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in active)
return (
f"You already have {len(active)} manual pick(s) locked in "
f"(cap is {max_picks}): {names}."
)
refused = allowed_cap_refusal(state, max_picks, adding=app_id)
if refused is not None:
return refused
now = datetime.now(UTC).isoformat()
# A kept entry for this game is replaced by the active one, not doubled.
state.manual_picks = [p for p in state.manual_picks if p.get("app_id") != app_id]
if state.current_app_id != app_id:
keep_outgoing_assignment(state)
state.manual_picks.append(
{"app_id": app_id, "game_name": game_name, "started_at": now}
)
record_assignment(state, app_id, game_name, at=now)
state.save()
return None