211 lines
8.7 KiB
C#
211 lines
8.7 KiB
C#
using Microsoft.AspNetCore.SignalR;
|
|
|
|
namespace ControlCenter.Hubs;
|
|
|
|
/// <summary>
|
|
/// SignalR hub for real-time agent status updates in the Command Hub.
|
|
///
|
|
/// <para>Usage flow:</para>
|
|
/// <list type="number">
|
|
/// <item>Client connects to <c>/hubs/agent-status</c></item>
|
|
/// <item>Client calls <see cref="JoinFleet"/> to subscribe to all agent updates</item>
|
|
/// <item>Client calls <see cref="JoinAgentGroup"/> to subscribe to a specific agent</item>
|
|
/// <item>Server pushes <see cref="IAgentStatusClient.AgentStatusChanged"/>
|
|
/// and <see cref="IAgentStatusClient.AgentTaskProgress"/> events</item>
|
|
/// <item>Client calls <see cref="GetFleetSnapshot"/> for initial state on connect</item>
|
|
/// </list>
|
|
///
|
|
/// <para>Group naming:</para>
|
|
/// <list type="bullet">
|
|
/// <item>Fleet group: <c>fleet</c> — receives all agent updates</item>
|
|
/// <item>Agent group: <c>agent:{agentId}</c> — receives updates for one agent</item>
|
|
/// </list>
|
|
///
|
|
/// <para>Typed client: <see cref="IAgentStatusClient"/> — all server-to-client
|
|
/// calls go through this interface for compile-time safety.</para>
|
|
///
|
|
/// <para>Architecture note: This hub bridges OpenClaw Gateway WebSocket events
|
|
/// to SignalR clients. A background service (<see cref="Services.GatewayEventBridgeService"/>)
|
|
/// subscribes to Gateway events and pushes them through this hub's extension methods.</para>
|
|
/// </summary>
|
|
public class AgentStatusHub : Hub<IAgentStatusClient>
|
|
{
|
|
private readonly ILogger<AgentStatusHub> _logger;
|
|
|
|
public AgentStatusHub(ILogger<AgentStatusHub> logger)
|
|
{
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Broadcasts an agent status update to all connected clients.
|
|
///
|
|
/// <para>
|
|
/// Any connected client (or server-side caller) can invoke this method
|
|
/// to push a status update to every subscriber. The DTO is converted to
|
|
/// an <see cref="AgentStatusUpdate"/> record and relayed through the
|
|
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="update">The agent status update DTO to broadcast.</param>
|
|
public async Task SendStatusUpdate(AgentStatusUpdateDto update)
|
|
{
|
|
_logger.LogInformation(
|
|
"Broadcasting status update for agent {AgentId}: {Status}",
|
|
update.AgentId, update.Status);
|
|
|
|
var agentUpdate = update.ToUpdate();
|
|
|
|
// Broadcast to all connected clients
|
|
await Clients.All.AgentStatusChanged(agentUpdate);
|
|
|
|
// Also push to the specific agent's group
|
|
var agentGroup = AgentGroupName(update.AgentId);
|
|
await Clients.Group(agentGroup).AgentStatusChanged(agentUpdate);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds the calling connection to the fleet group.
|
|
/// Once joined, the client will receive all agent status changes
|
|
/// and task progress updates across the entire fleet.
|
|
/// </summary>
|
|
public async Task JoinFleet()
|
|
{
|
|
await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName);
|
|
_logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes the calling connection from the fleet group.
|
|
/// </summary>
|
|
public async Task LeaveFleet()
|
|
{
|
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName);
|
|
_logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds the calling connection to a specific agent's group.
|
|
/// Once joined, the client will receive updates only for that agent.
|
|
/// </summary>
|
|
/// <param name="agentId">The agent identifier, e.g. "otto", "dex".</param>
|
|
/// <exception cref="HubException">Thrown if agentId is null or empty.</exception>
|
|
public async Task JoinAgentGroup(string agentId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(agentId))
|
|
throw new HubException("agentId is required");
|
|
|
|
var groupName = AgentGroupName(agentId);
|
|
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
|
|
_logger.LogDebug("Connection {ConnectionId} joined agent group {GroupName}",
|
|
Context.ConnectionId, groupName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes the calling connection from a specific agent's group.
|
|
/// </summary>
|
|
/// <param name="agentId">The agent identifier.</param>
|
|
public async Task LeaveAgentGroup(string agentId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(agentId)) return;
|
|
|
|
var groupName = AgentGroupName(agentId);
|
|
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns a snapshot of the current fleet state.
|
|
/// Called by clients on initial connection to get the full picture
|
|
/// before incremental updates begin arriving.
|
|
/// </summary>
|
|
/// <returns>An array of <see cref="AgentCardData"/> representing all known agents.</returns>
|
|
public Task<AgentCardData[]> GetFleetSnapshot()
|
|
{
|
|
// The fleet state is managed by the GatewayEventBridgeService.
|
|
// For now, return an empty array — the bridge service will push
|
|
// updates as they arrive from the Gateway.
|
|
_logger.LogDebug("Fleet snapshot requested by {ConnectionId}", Context.ConnectionId);
|
|
return Task.FromResult(Array.Empty<AgentCardData>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Overrides <see cref="Hub.OnDisconnectedAsync"/> to perform cleanup.
|
|
/// SignalR automatically removes disconnected connections from all groups.
|
|
/// </summary>
|
|
/// <param name="exception">Exception that caused the disconnection, if any.</param>
|
|
public override Task OnDisconnectedAsync(Exception? exception)
|
|
{
|
|
_logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId);
|
|
return base.OnDisconnectedAsync(exception);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The SignalR group name for the entire fleet (all agents).
|
|
/// </summary>
|
|
internal const string FleetGroupName = "fleet";
|
|
|
|
/// <summary>
|
|
/// Returns the SignalR group name for a specific agent.
|
|
/// Format: <c>agent:{agentId}</c> (lowercase for consistency).
|
|
/// </summary>
|
|
/// <param name="agentId">The agent identifier.</param>
|
|
internal static string AgentGroupName(string agentId) =>
|
|
$"agent:{agentId.ToLowerInvariant()}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extension methods for pushing real-time agent updates through
|
|
/// the <see cref="IHubContext{T}"/> of <see cref="AgentStatusHub"/>.
|
|
///
|
|
/// <para>These methods are intended to be called from background services
|
|
/// (e.g., <see cref="Services.GatewayEventBridgeService"/>) or other
|
|
/// server-side code that detects an agent state change.</para>
|
|
/// </summary>
|
|
public static class AgentStatusHubExtensions
|
|
{
|
|
/// <summary>
|
|
/// Pushes an agent status change to all clients subscribed to
|
|
/// the fleet group and the specific agent's group.
|
|
///
|
|
/// <para>Call this from any background service when an agent's
|
|
/// operational status changes (e.g., the Gateway reports a
|
|
/// session transition from "running" to "done").</para>
|
|
/// </summary>
|
|
/// <param name="hubContext">The hub context injected via DI.</param>
|
|
/// <param name="update">The agent status update payload.</param>
|
|
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
|
public static async Task PushAgentStatusAsync(
|
|
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
|
AgentStatusUpdate update)
|
|
{
|
|
// Broadcast to the fleet group (all subscribers)
|
|
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
|
.AgentStatusChanged(update);
|
|
|
|
// Also push to the specific agent's group
|
|
var agentGroup = AgentStatusHub.AgentGroupName(update.AgentId);
|
|
await hubContext.Clients.Group(agentGroup)
|
|
.AgentStatusChanged(update);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pushes a task progress update to all clients subscribed to
|
|
/// the fleet group and the specific agent's group.
|
|
/// </summary>
|
|
/// <param name="hubContext">The hub context injected via DI.</param>
|
|
/// <param name="progress">The task progress update payload.</param>
|
|
/// <returns>A Task that completes when the message has been sent to all group members.</returns>
|
|
public static async Task PushTaskProgressAsync(
|
|
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
|
|
TaskProgressUpdate progress)
|
|
{
|
|
// Broadcast to the fleet group
|
|
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
|
|
.AgentTaskProgress(progress);
|
|
|
|
// Also push to the specific agent's group
|
|
var agentGroup = AgentStatusHub.AgentGroupName(progress.AgentId);
|
|
await hubContext.Clients.Group(agentGroup)
|
|
.AgentTaskProgress(progress);
|
|
}
|
|
} |