Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import binascii
import concurrent.futures
import functools
from collections import deque
from dataclasses import dataclass
from datetime import datetime, timezone
import hashlib
Expand Down Expand Up @@ -214,10 +215,15 @@ async def _lifespan(app: "FastAPI"):
# Reap idle/dead keep-alive PTY sessions in the background (30-min TTL).
pty_reaper_task = asyncio.create_task(run_reaper(PTY_REGISTRY))

# Periodic authenticated self-test (feeds the ``dashboard`` component on
# /api/status). The loop exits immediately when httpx is unavailable.
selftest_task = asyncio.create_task(_dashboard_selftest_loop())

try:
yield
finally:
pty_reaper_task.cancel()
selftest_task.cancel()
await PTY_REGISTRY.close_all()
if cron_stop is not None:
cron_stop.set()
Expand Down Expand Up @@ -625,6 +631,137 @@ async def _token_auth_seam(request: Request, call_next):
return await token_auth_middleware(request, call_next)


# ---------------------------------------------------------------------------
# Dashboard component health — in-process error/self-test counters that feed
# the ``components`` dict on ``/api/status``. That endpoint is in
# ``PUBLIC_API_PATHS``, so everything exported from here must be counts and
# enums only: no exception messages, no request paths, no tokens.
# ---------------------------------------------------------------------------

_DASHBOARD_HEALTH_WINDOW_SECONDS = 300.0


class DashboardHealth:
"""Module-level holder for dashboard-process health signals.

Tracks unhandled exceptions / 5xx responses seen by the outermost HTTP
middleware (rolling window) and the result of the periodic authenticated
self-test. ``last_error_path`` and ``last_error_type`` are internal
diagnostics for logs/debuggers — :meth:`snapshot` deliberately exports
neither (public-payload no-secrets contract).
"""

def __init__(self, window_seconds: float = _DASHBOARD_HEALTH_WINDOW_SECONDS) -> None:
self.window_seconds = window_seconds
self._error_times: "deque[float]" = deque(maxlen=256)
self.last_error_type: Optional[str] = None
self.last_error_path: Optional[str] = None # internal-only, never serialized
self.last_error_at: Optional[float] = None
self.selftest_status: str = "unknown" # unknown | ok | failing
self.selftest_http_status: Optional[int] = None
self.selftest_at: Optional[float] = None

def record_error(self, exc_type: str, path: str) -> None:
now = time.time()
self._error_times.append(now)
self.last_error_type = exc_type
self.last_error_path = path
self.last_error_at = now

def record_selftest(self, passed: bool, http_status: Optional[int]) -> None:
self.selftest_status = "ok" if passed else "failing"
self.selftest_http_status = http_status
self.selftest_at = time.time()

def recent_error_count(self) -> int:
cutoff = time.time() - self.window_seconds
while self._error_times and self._error_times[0] < cutoff:
self._error_times.popleft()
return len(self._error_times)

def snapshot(self) -> Dict[str, Any]:
"""Public component payload: status enum + counts + timestamps only."""
errors = self.recent_error_count()
status = "degraded" if (errors or self.selftest_status == "failing") else "ok"
return {
"status": status,
"recent_unhandled_errors": errors,
"last_error_at": self.last_error_at,
"selftest": self.selftest_status,
}


DASHBOARD_HEALTH = DashboardHealth()


@app.middleware("http")
async def _dashboard_health_middleware(request: Request, call_next):
"""Outermost middleware: count unhandled exceptions and 5xx responses.

Registered after ``_token_auth_seam`` so it is the outermost layer
(Starlette middleware is outermost-last) — nothing below can raise past
it unseen. Records into :data:`DASHBOARD_HEALTH` and re-raises; never
swallows or alters the response.
"""
try:
response = await call_next(request)
except Exception as exc:
DASHBOARD_HEALTH.record_error(type(exc).__name__, request.url.path)
raise
if response.status_code >= 500:
DASHBOARD_HEALTH.record_error(f"http_{response.status_code}", request.url.path)
return response


# ---------------------------------------------------------------------------
# Authenticated-route self-test: every minute, make one in-process request
# against a cheap DB-touching authenticated route with the real session
# token. Catches the class of failure where liveness looks fine but every
# authenticated request 500s (e.g. wedged state DB).
# ---------------------------------------------------------------------------

_DASHBOARD_SELFTEST_INTERVAL_SECONDS = 60.0
_DASHBOARD_SELFTEST_ROUTE = "/api/sessions?limit=1"


async def _dashboard_selftest_once() -> None:
"""Run one authenticated in-process self-test request and record it."""
try:
import httpx
except ImportError:
return # optional dependency — skip cleanly, leave status "unknown"
try:
transport = httpx.ASGITransport(app=app)
# base_url uses a loopback name so the Host-header middleware accepts
# the request on loopback binds.
async with httpx.AsyncClient(
transport=transport, base_url="http://127.0.0.1"
) as client:
resp = await client.get(
_DASHBOARD_SELFTEST_ROUTE,
headers={_SESSION_HEADER_NAME: _SESSION_TOKEN},
)
DASHBOARD_HEALTH.record_selftest(resp.status_code == 200, resp.status_code)
except Exception:
DASHBOARD_HEALTH.record_selftest(False, None)


async def _dashboard_selftest_loop() -> None:
"""Periodic self-test driver started from the lifespan."""
try:
import httpx # noqa: F401
except ImportError:
_log.debug("httpx unavailable — dashboard self-test disabled")
return
while True:
await asyncio.sleep(_DASHBOARD_SELFTEST_INTERVAL_SECONDS)
# On OAuth-gated binds the legacy session token is not honoured, so
# the probe would false-alarm 401 — skip until the gate is off.
if getattr(app.state, "auth_required", False):
continue
await _dashboard_selftest_once()


# ---------------------------------------------------------------------------
# Config schema — auto-generated from DEFAULT_CONFIG
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -2972,6 +3109,49 @@ async def get_status(profile: Optional[str] = None):
"nous_session_valid": nous_session_valid,
}

# Component-level health rollup. Counts and status enums only — this
# payload is public (PUBLIC_API_PATHS), so no messages, paths, or
# other detail that could carry secrets. The storage probe reuses the
# gateway readiness state_db check (read-only, 1s-bounded) in an
# executor so a wedged DB can't stall the event loop.
components: Dict[str, Any] = {
"gateway": {
"status": "ok" if gateway_running and gateway_state in {"running", "draining"} else "degraded",
"state": gateway_state or ("running" if gateway_running else "stopped"),
},
"dashboard": DASHBOARD_HEALTH.snapshot(),
}
try:
from gateway.readiness import _probe_state_db

storage_check = await asyncio.get_running_loop().run_in_executor(
None, functools.partial(_probe_state_db, get_hermes_home())
)
components["storage"] = {"status": storage_check.get("status", "degraded")}
except Exception:
components["storage"] = {"status": "degraded"}
platform_states = [
str(value.get("state") or value.get("status") or "").lower()
for value in gateway_platforms.values()
if isinstance(value, dict)
]
platforms_ok = all(
state in {"connected", "running", "ok"} for state in platform_states
)
components["platforms"] = {
"status": "ok" if platforms_ok else "degraded",
"configured": len(gateway_platforms),
"connected": sum(
1 for state in platform_states if state in {"connected", "running", "ok"}
),
}
status["components"] = components
status["overall"] = (
"ok"
if all(item.get("status") == "ok" for item in components.values())
else "degraded"
)

# Profile + gateway topology: which profiles exist, whether one
# multiplexed gateway or several per-profile gateways serve them, and
# (gated) which host ports the live gateways' port-binding platforms
Expand Down
Loading