#5834 "The host agent is always on, and the MCP sees a host's maintenance", step 1 of milestone 520 "The MCP sees what Steward has grown". The host agent is a core system (operator, 2026-10-15: "it's very core to the functionality of Steward as a whole and shouldn't be considered something that can be toggled off"). ALWAYS_ON_PLUGINS in core settings makes to_plugins_cfg report it enabled whatever its stored row says, so it always loads and its MCP tools are always listed. Settings -> Plugins shows it as "Built in: always on" with no switch, and a save keeps it enabled. Its code and URLs stay under plugins/host_agent: every installed agent posts to /plugins/host_agent/ingest. Maintenance through the MCP: - list_maintenance_plans: each plan's group, weekly slot, reboot policy, next slot, and its latest run (hosts, how far it got, who it skipped and why, how it ended). - get_maintenance_job(host | job): a host's jobs, newest and running first, or one job: state, packages updated, reboot, drained by, why it failed, when its current state times out. - run_maintenance(host, reboot="if_required" | "never"): admin, as the host page's Update now; refuses a busy host or one with no helper; audited "<user> via MCP". - The overview names a host whose latest job in the last week failed or timed out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
46 lines
2.3 KiB
Python
46 lines
2.3 KiB
Python
"""The host_agent plugin's MCP tools."""
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace as NS
|
|
from unittest.mock import MagicMock
|
|
|
|
import plugins.host_agent as agent_plugin
|
|
from plugins.host_agent.mcp_tools import agent_state
|
|
|
|
NOW = datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def test_agent_state():
|
|
assert agent_state(NS(last_seen_at=None), NOW, 180) == "pending"
|
|
assert agent_state(NS(last_seen_at=NOW - timedelta(seconds=60)), NOW, 180) == "reporting"
|
|
assert agent_state(NS(last_seen_at=NOW - timedelta(minutes=10)), NOW, 180) == "stale"
|
|
|
|
|
|
def test_plugin_exposes_the_mcp_hooks():
|
|
reg = MagicMock()
|
|
agent_plugin.register_mcp_tools(reg)
|
|
assert [c.args[0].__name__ for c in reg.call_args_list] == [
|
|
"list_agents", "list_maintenance_plans", "get_maintenance_job", "run_maintenance"]
|
|
roles = {c.args[0].__name__: c.kwargs["role"] for c in reg.call_args_list}
|
|
assert roles["run_maintenance"].value == "admin" # as Update now on the page
|
|
assert roles["get_maintenance_job"].value == "viewer"
|
|
assert callable(agent_plugin.mcp_overview)
|
|
|
|
|
|
def test_a_job_row_says_what_it_did_and_when_it_gives_up():
|
|
from plugins.host_agent.mcp_tools import _job_row
|
|
job = NS(id="j1", host_id="h1", state="rebooting", state_at=NOW - timedelta(minutes=5),
|
|
reboot_policy="if_required", packages=12, reboot_needed=True, back_at=None,
|
|
node_id="n1", drain_host_id="h2", detail=None, created_by=None, run_id="r1",
|
|
created_at=NOW - timedelta(minutes=20), finished_at=None)
|
|
row = _job_row(job, {"h1": "web-1", "h2": "mgr-1"}, NOW)
|
|
assert (row["host"], row["drained_by"], row["label"], row["active"]) == (
|
|
"web-1", "mgr-1", "Rebooting", True)
|
|
assert row["started_by"] == "maintenance plan" and row["state_age_s"] == 300
|
|
assert row["gives_up_at"] == (NOW + timedelta(minutes=25)).isoformat().replace("+00:00", "Z")
|
|
|
|
done = NS(**{**job.__dict__, "state": "done", "back_at": NOW, "finished_at": NOW,
|
|
"created_by": "u1", "run_id": None})
|
|
row = _job_row(done, {"h1": "web-1", "u1": "alice"}, NOW)
|
|
assert row["summary"] == "Done: 12 packages updated, rebooted"
|
|
assert row["started_by"] == "alice" and row["gives_up_at"] is None and not row["active"]
|