mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 20:47:04 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
154 lines
5.1 KiB
Python
154 lines
5.1 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 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,
|
|
}
|
|
)
|
|
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,
|
|
}
|
|
)
|
|
|
|
|
|
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))
|