"""The host_agent plugin's MCP tools and its get_overview findings.""" from __future__ import annotations from datetime import timedelta from sqlalchemy import select from steward.hosts import service as host_svc from steward.mcp._context import mcp_app from steward.mcp.tools._shape import age_s, db_session, iso, now_utc from steward.mcp.tools.overview import finding from steward.models.hosts import Host from steward.models.users import UserRole from .models import HostAgentRegistration # A registration this old that has never checked in is a failed install, not one in progress. PENDING_GRACE = timedelta(minutes=30) def agent_state(reg, now, stale_after_s: int) -> str: """The agent's state: reporting, stale (stopped checking in) or pending (never has).""" if reg.last_seen_at is None: return "pending" return "reporting" if (now - reg.last_seen_at).total_seconds() <= stale_after_s else "stale" async def _agents(db): return (await db.execute( select(HostAgentRegistration, Host) .join(Host, Host.id == HostAgentRegistration.host_id) .order_by(Host.name) )).all() async def list_agents() -> dict: """Every host agent Steward has registered, with its version and whether it is reporting. `state` is "reporting", "stale" (it checked in before but its last check-in is older than the stale window, `stale_after_s`) or "pending" (registered, never checked in, usually an install that is still running or that failed). Each row carries the agent version, kernel, distro, arch and the IP the agent reports. get_host shows an agent's latest metrics. """ now = now_utc() stale_after = host_svc.agent_stale_after_seconds(mcp_app().config) async with db_session() as db: rows = await _agents(db) agents = [ { "host_id": h.id, "host_name": h.name, "state": agent_state(reg, now, stale_after), "last_seen_at": iso(reg.last_seen_at), "last_seen_age_s": age_s(reg.last_seen_at, now), "agent_version": reg.agent_version, "kernel": reg.kernel, "distro": reg.distro, "arch": reg.arch, "reported_ip": reg.host_ip, "registered_at": iso(reg.created_at), } for reg, h in rows ] counts = {s: sum(1 for a in agents if a["state"] == s) for s in ("reporting", "stale", "pending")} return {"stale_after_s": stale_after, "counts": counts, "agents": agents, "total": len(agents)} async def mcp_overview(db) -> list[dict]: """Agents registered more than PENDING_GRACE ago that have never checked in. Agents that stopped reporting are already flagged by the core overview from their metrics, so they are not repeated here. """ now = now_utc() return [ finding("warning", "agent_never_reported", h.name, "host agent was registered but has never checked in; the install may have failed", since=reg.created_at, ids={"host_id": h.id}, next="list_agents()") for reg, h in await _agents(db) if reg.last_seen_at is None and now - reg.created_at > PENDING_GRACE ] def register_tools(register) -> None: register(list_agents, role=UserRole.viewer, read_only=True)