Add Dialyzer typespecs to WebSocket agent code and Agents context

- Add @spec to AgentSocket callbacks (connect/3, id/1)
- Add @spec to AgentChannel callbacks (join/3, handle_info/2, handle_in/3)
- Add @spec to AgentChannel private function build_jobs_for_agent/1
- Add @spec to Agents.verify_agent_token/1
- Add @spec to Agents.update_agent_token_heartbeat/3
- Add @spec to Agents.list_agent_polling_targets/1

These specs provide strict type checking for the critical WebSocket
agent communication code implemented in this session.
This commit is contained in:
Graham McIntire 2026-01-17 10:37:27 -06:00
parent bdde71462a
commit e1b9976219
No known key found for this signature in database
3 changed files with 11 additions and 1 deletions

View file

@ -102,6 +102,8 @@ defmodule Towerops.Agents do
{1, nil}
"""
@spec update_agent_token_heartbeat(Ecto.UUID.t(), String.t() | nil, map() | nil) ::
{non_neg_integer(), nil | [term()]}
def update_agent_token_heartbeat(agent_token_id, ip_address, metadata \\ nil) do
now = DateTime.truncate(DateTime.utc_now(), :second)
@ -133,6 +135,7 @@ defmodule Towerops.Agents do
{:error, :invalid_token}
"""
@spec verify_agent_token(String.t()) :: {:ok, AgentToken.t()} | {:error, :invalid_token}
def verify_agent_token(token) do
with {:ok, token_string} <- AgentToken.verify_token(token),
%AgentToken{} = agent_token <-
@ -238,6 +241,7 @@ defmodule Towerops.Agents do
[%Equipment{snmp_enabled: true, site: %Site{}, snmp_device: %Device{sensors: [...], interfaces: [...]}}]
"""
@spec list_agent_polling_targets(Ecto.UUID.t()) :: [Equipment.t()]
def list_agent_polling_targets(agent_token_id) do
# Use SQL to filter equipment by effective agent assignment
# This avoids loading all equipment into memory and filtering in application code

View file

@ -33,6 +33,8 @@ defmodule ToweropsWeb.AgentChannel do
require Logger
@impl true
@spec join(String.t(), map(), Phoenix.Socket.t()) ::
{:ok, Phoenix.Socket.t()} | {:error, map()}
def join("agent:" <> _agent_id, %{"token" => token}, socket) do
# Verify agent token from join payload
case Agents.verify_agent_token(token) do
@ -60,6 +62,7 @@ defmodule ToweropsWeb.AgentChannel do
end
@impl true
@spec handle_info(:send_jobs, Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()}
def handle_info(:send_jobs, socket) do
jobs = build_jobs_for_agent(socket.assigns.agent_token_id)
job_list = %AgentJobList{jobs: jobs}
@ -70,6 +73,7 @@ defmodule ToweropsWeb.AgentChannel do
end
@impl true
@spec handle_in(String.t(), map(), Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()}
def handle_in("result", %{"binary" => binary_b64}, socket) do
binary = Base.decode64!(binary_b64)
result = SnmpResult.decode(binary)
@ -77,7 +81,6 @@ defmodule ToweropsWeb.AgentChannel do
{:noreply, socket}
end
@impl true
def handle_in("heartbeat", %{"binary" => binary_b64}, socket) do
binary = Base.decode64!(binary_b64)
heartbeat = AgentHeartbeat.decode(binary)
@ -108,6 +111,7 @@ defmodule ToweropsWeb.AgentChannel do
# Private helpers
@spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()]
defp build_jobs_for_agent(agent_token_id) do
agent_token_id
|> Agents.list_agent_polling_targets()

View file

@ -13,12 +13,14 @@ defmodule ToweropsWeb.AgentSocket do
channel "agent:*", ToweropsWeb.AgentChannel
@impl true
@spec connect(map(), Phoenix.Socket.t(), map()) :: {:ok, Phoenix.Socket.t()}
def connect(_params, socket, _connect_info) do
# Allow all connections - authentication happens at channel join
{:ok, socket}
end
@impl true
@spec id(Phoenix.Socket.t()) :: String.t() | nil
def id(socket) do
# Return nil before authentication (channel join will set agent_token_id)
case Map.get(socket.assigns, :agent_token_id) do