feat(CUB-55): add SignalR broadcast state method with AgentStatusHub and DTO

This commit is contained in:
cubecraft-agents[bot]
2026-04-26 11:25:31 +00:00
parent 7b7b070dac
commit 6cf8d7fe5f
3 changed files with 237 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
namespace ControlCenter.Api.Dtos;
/// <summary>
/// Data transfer object for broadcasting agent status updates
/// to all connected SignalR clients.
/// </summary>
public class AgentStatusUpdateDto
{
/// <summary>
/// Agent identifier, e.g. "otto", "dex", "rex".
/// Not null — every update must identify the agent it refers to.
/// </summary>
public string AgentId { get; set; } = string.Empty;
/// <summary>
/// Human-readable display name, e.g. "Otto", "Dex".
/// Not null — used by clients to render agent cards.
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// Role description, e.g. "Orchestrator Agent", "Backend Specialist".
/// Not null — provides context for the agent's function.
/// </summary>
public string Role { get; set; } = string.Empty;
/// <summary>
/// Current operational status of the agent.
/// Maps to <see cref="Entities.AgentStatus"/> values as lowercase strings:
/// "active", "idle", "thinking", "error".
/// </summary>
public string Status { get; set; } = string.Empty;
/// <summary>
/// Description of the agent's current task, if any.
/// Null when the agent is idle with no active task.
/// </summary>
public string? CurrentTask { get; set; }
/// <summary>
/// Task progress percentage (0100).
/// Null when progress is not trackable for the current task.
/// </summary>
public int? TaskProgress { get; set; }
/// <summary>
/// Elapsed time string for the current task, e.g. "04m 12s".
/// Null when no task is active.
/// </summary>
public string? TaskElapsed { get; set; }
/// <summary>
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
/// Not null — uniquely identifies the agent session.
/// </summary>
public string SessionKey { get; set; } = string.Empty;
/// <summary>
/// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack".
/// Not null — every agent session operates on exactly one channel.
/// </summary>
public string Channel { get; set; } = string.Empty;
/// <summary>
/// ISO 8601 timestamp of the agent's last activity.
/// Not null — used by clients to detect stale connections.
/// </summary>
public string LastActivity { get; set; } = string.Empty;
/// <summary>
/// Error message when the agent status is "error".
/// Null when the agent is not in an error state.
/// </summary>
public string? ErrorMessage { get; set; }
}

View File

@@ -0,0 +1,155 @@
using ControlCenter.Api.Dtos;
using Microsoft.AspNetCore.SignalR;
namespace ControlCenter.Api.Hubs;
/// <summary>
/// SignalR hub for broadcasting agent status updates to connected clients.
///
/// <para>
/// Clients call <see cref="SendStatusUpdate"/> to broadcast a status change,
/// and the hub relays it to all connected clients via the
/// <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
/// </para>
///
/// <para>
/// Server-side code should use <see cref="AgentStatusHubExtensions.PushStatusUpdateAsync"/>
/// via <c>IHubContext&lt;AgentStatusHub, IAgentStatusClient&gt;</c> for background-service broadcasts.
/// </para>
///
/// <para>
/// Architecture note: This hub bridges OpenClaw Gateway events to SignalR clients.
/// A background service 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;
/// <summary>
/// Initializes a new instance of the <see cref="AgentStatusHub"/> class.
/// </summary>
/// <param name="logger">Logger for diagnostic output.</param>
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 update is relayed
/// through the <see cref="IAgentStatusClient.AgentStatusChanged"/> callback.
/// </para>
/// </summary>
/// <param name="update">The agent status update payload to broadcast.</param>
public async Task SendStatusUpdate(AgentStatusUpdateDto update)
{
_logger.LogInformation(
"Broadcasting status update for agent {AgentId}: {Status}",
update.AgentId, update.Status);
await Clients.All.AgentStatusChanged(update);
}
/// <summary>
/// Adds the calling connection to the fleet group.
/// Once joined, the client will receive all agent status updates.
/// </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>
/// Overrides <see cref="Hub{T}.OnDisconnectedAsync"/> to log disconnections.
/// 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>
/// Strongly-typed client interface for the AgentStatus SignalR hub.
/// Defines the methods the server can invoke on connected clients
/// to push real-time agent status updates.
/// </summary>
public interface IAgentStatusClient
{
/// <summary>
/// Pushes an agent status change to all subscribed clients.
/// Fired whenever an agent's operational status changes
/// (e.g., idle → active, active → thinking, active → error).
/// </summary>
/// <param name="update">The full status update payload.</param>
/// <returns>A Task that completes when the client has processed the update.</returns>
Task AgentStatusChanged(AgentStatusUpdateDto update);
}
/// <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
/// or other server-side code that detects an agent state change,
/// using the injected <c>IHubContext&lt;AgentStatusHub, IAgentStatusClient&gt;</c>.
/// </para>
/// </summary>
public static class AgentStatusHubExtensions
{
/// <summary>
/// Pushes an agent status update to all connected clients.
///
/// <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 clients.</returns>
public static async Task PushStatusUpdateAsync(
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
AgentStatusUpdateDto update)
{
await hubContext.Clients.All.AgentStatusChanged(update);
}
/// <summary>
/// Pushes an agent status update to clients subscribed to the fleet group.
/// </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 the fleet group.</returns>
public static async Task PushStatusUpdateToFleetAsync(
this IHubContext<AgentStatusHub, IAgentStatusClient> hubContext,
AgentStatusUpdateDto update)
{
await hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
.AgentStatusChanged(update);
}
}

View File

@@ -1,4 +1,5 @@
using ControlCenter.Api.Data; using ControlCenter.Api.Data;
using ControlCenter.Api.Hubs;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -6,6 +7,9 @@ var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
// Register SignalR for real-time agent status updates
builder.Services.AddSignalR();
// Register DbContext with PostgreSQL // Register DbContext with PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options =>
{ {
@@ -28,4 +32,7 @@ if (app.Environment.IsDevelopment())
app.UseHttpsRedirection(); app.UseHttpsRedirection();
// Map SignalR hubs
app.MapHub<AgentStatusHub>("/hubs/agent-status");
app.Run(); app.Run();