mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
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
157 lines
5.3 KiB
Python
157 lines
5.3 KiB
Python
"""The root daemon as the web API sees it: status, and the two-phase reset.
|
|
|
|
Every call goes through :func:`daemon_call`, which turns the client's
|
|
``DaemonUnreachableError`` / ``CtlError`` into contract errors. Calls block for
|
|
as long as the daemon takes (up to its own timeouts); each request has its
|
|
own thread (``ThreadingHTTPServer``), so a slow op stalls nobody else.
|
|
|
|
The daemon answers the two-phase reset with ``pending_id`` and an extra
|
|
``lapse_after_seconds``; :func:`pending_action` maps that onto the
|
|
contract's ``PendingAction``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict
|
|
from http import HTTPStatus
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
from steam_backlog_enforcer import _ctl_client
|
|
from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError
|
|
from steam_backlog_enforcer._web_errors import (
|
|
ApiError,
|
|
daemon_unreachable,
|
|
from_ctl_error,
|
|
)
|
|
from steam_backlog_enforcer._web_io import Reply, no_content, ok
|
|
from steam_backlog_enforcer.jobs._daemon_jobs import (
|
|
EXIT_FAILED,
|
|
EXIT_OK,
|
|
DaemonOutcome,
|
|
record_daemon_job,
|
|
)
|
|
from steam_backlog_enforcer.jobs._events import now_iso
|
|
from steam_backlog_enforcer.jobs._view import read_job
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from steam_backlog_enforcer._ctl_types import PendingReset
|
|
from steam_backlog_enforcer._web_io import Request
|
|
|
|
_JOURNAL_LINES: Final = 50
|
|
_RESET: Final = "gaming-reset"
|
|
|
|
|
|
def daemon_call[**P, R](fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
|
|
"""Run one control-socket call, mapping its failures to :class:`ApiError`.
|
|
|
|
Raises:
|
|
ApiError: ``daemon_unreachable`` (503) or the daemon's own code.
|
|
"""
|
|
try:
|
|
return fn(*args, **kwargs)
|
|
except DaemonUnreachableError as exc:
|
|
raise daemon_unreachable(str(exc)) from None
|
|
except CtlError as exc:
|
|
raise from_ctl_error(exc) from None
|
|
|
|
|
|
def daemon_view(_request: Request) -> Reply:
|
|
"""``GET /api/daemon`` — ``state: 'unreachable'`` when nobody answers."""
|
|
try:
|
|
info = _ctl_client.status()
|
|
except DaemonUnreachableError:
|
|
return ok(
|
|
{
|
|
"state": "unreachable",
|
|
"started_at": None,
|
|
"pid": None,
|
|
"journal_tail": [],
|
|
"restart_available_at": None,
|
|
"fast_installs": [],
|
|
}
|
|
)
|
|
except CtlError as exc:
|
|
raise from_ctl_error(exc) from None
|
|
try:
|
|
journal = _ctl_client.journal_tail(_JOURNAL_LINES)
|
|
except DaemonUnreachableError, CtlError:
|
|
journal = [] # The status still stands without its log.
|
|
return ok(
|
|
{
|
|
"state": info.state,
|
|
"started_at": info.started_at,
|
|
"pid": info.pid,
|
|
"journal_tail": journal,
|
|
"restart_available_at": info.restart_available_at,
|
|
"fast_installs": [asdict(run) for run in info.fast_installs],
|
|
}
|
|
)
|
|
|
|
|
|
def pending_action(pending: PendingReset) -> dict[str, object]:
|
|
"""The contract ``PendingAction`` for an armed gaming reset."""
|
|
return {
|
|
"id": pending.pending_id,
|
|
"command": _RESET,
|
|
"phrase": pending.phrase,
|
|
"armed_at": pending.armed_at,
|
|
"ready_at": pending.ready_at,
|
|
"heartbeat_interval_seconds": pending.heartbeat_interval_seconds,
|
|
}
|
|
|
|
|
|
def confirm_phrase_of(request: Request) -> str:
|
|
"""The typed phrase from a body (``confirm_phrase``; ``confirmPhrase`` too)."""
|
|
raw = request.body.get("confirm_phrase", request.body.get("confirmPhrase"))
|
|
if raw is None:
|
|
return ""
|
|
if not isinstance(raw, str):
|
|
msg = "confirm_phrase must be a string."
|
|
raise ApiError(msg, code="invalid_params")
|
|
return raw
|
|
|
|
|
|
def heartbeat_view(request: Request) -> Reply:
|
|
"""``POST /api/pending/{id}/heartbeat`` — keep the countdown alive."""
|
|
pending = daemon_call(_ctl_client.gaming_reset_heartbeat, request.args[0])
|
|
return ok(pending_action(pending))
|
|
|
|
|
|
def cancel_pending_view(request: Request) -> Reply:
|
|
"""``DELETE /api/pending/{id}`` — abandon the armed reset."""
|
|
daemon_call(_ctl_client.gaming_reset_cancel, request.args[0])
|
|
return no_content()
|
|
|
|
|
|
def commit_view(request: Request) -> Reply:
|
|
"""``POST /api/pending/{id}/commit`` — run the reset, record it as a job."""
|
|
created_at = now_iso()
|
|
try:
|
|
result = daemon_call(
|
|
_ctl_client.gaming_reset_commit,
|
|
request.args[0],
|
|
confirm_phrase_of(request),
|
|
)
|
|
except ApiError as exc:
|
|
if exc.code == "op_failed":
|
|
failed = DaemonOutcome(ok=False, summary=exc.message, exit_code=EXIT_FAILED)
|
|
record_daemon_job(_RESET, {}, created_at=created_at, outcome=failed)
|
|
raise
|
|
minutes = result.seconds_before / 60
|
|
summary = f"Today's gaming budget reset ({minutes:.0f} min billed before)."
|
|
done = DaemonOutcome(
|
|
ok=True,
|
|
summary=summary,
|
|
exit_code=EXIT_OK,
|
|
data={
|
|
"day_key": result.day_key,
|
|
"seconds_before": result.seconds_before,
|
|
"was_blocked": result.was_blocked,
|
|
"released": result.released,
|
|
},
|
|
)
|
|
job_id = record_daemon_job(_RESET, {}, created_at=created_at, outcome=done)
|
|
return Reply(HTTPStatus.CREATED, read_job(job_id))
|