diff --git a/backend/Dtos/AgentStatusUpdateDto.cs b/backend/Dtos/AgentStatusUpdateDto.cs new file mode 100644 index 0000000..b67e74d --- /dev/null +++ b/backend/Dtos/AgentStatusUpdateDto.cs @@ -0,0 +1,75 @@ +namespace ControlCenter.Api.Dtos; + +/// +/// Data transfer object for broadcasting agent status updates +/// to all connected SignalR clients. +/// +public class AgentStatusUpdateDto +{ + /// + /// Agent identifier, e.g. "otto", "dex", "rex". + /// Not null — every update must identify the agent it refers to. + /// + public string AgentId { get; set; } = string.Empty; + + /// + /// Human-readable display name, e.g. "Otto", "Dex". + /// Not null — used by clients to render agent cards. + /// + public string DisplayName { get; set; } = string.Empty; + + /// + /// Role description, e.g. "Orchestrator Agent", "Backend Specialist". + /// Not null — provides context for the agent's function. + /// + public string Role { get; set; } = string.Empty; + + /// + /// Current operational status of the agent. + /// Maps to values as lowercase strings: + /// "active", "idle", "thinking", "error". + /// + public string Status { get; set; } = string.Empty; + + /// + /// Description of the agent's current task, if any. + /// Null when the agent is idle with no active task. + /// + public string? CurrentTask { get; set; } + + /// + /// Task progress percentage (0–100). + /// Null when progress is not trackable for the current task. + /// + public int? TaskProgress { get; set; } + + /// + /// Elapsed time string for the current task, e.g. "04m 12s". + /// Null when no task is active. + /// + public string? TaskElapsed { get; set; } + + /// + /// Full session key, e.g. "agent:otto:telegram:direct:8787451565". + /// Not null — uniquely identifies the agent session. + /// + public string SessionKey { get; set; } = string.Empty; + + /// + /// Communication channel the agent is operating on, e.g. "telegram", "discord", "slack". + /// Not null — every agent session operates on exactly one channel. + /// + public string Channel { get; set; } = string.Empty; + + /// + /// ISO 8601 timestamp of the agent's last activity. + /// Not null — used by clients to detect stale connections. + /// + public string LastActivity { get; set; } = string.Empty; + + /// + /// Error message when the agent status is "error". + /// Null when the agent is not in an error state. + /// + public string? ErrorMessage { get; set; } +} \ No newline at end of file diff --git a/backend/Hubs/AgentStatusHub.cs b/backend/Hubs/AgentStatusHub.cs new file mode 100644 index 0000000..213804b --- /dev/null +++ b/backend/Hubs/AgentStatusHub.cs @@ -0,0 +1,155 @@ +using ControlCenter.Api.Dtos; +using Microsoft.AspNetCore.SignalR; + +namespace ControlCenter.Api.Hubs; + +/// +/// SignalR hub for broadcasting agent status updates to connected clients. +/// +/// +/// Clients call to broadcast a status change, +/// and the hub relays it to all connected clients via the +/// callback. +/// +/// +/// +/// Server-side code should use +/// via IHubContext<AgentStatusHub, IAgentStatusClient> for background-service broadcasts. +/// +/// +/// +/// 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. +/// +/// +public class AgentStatusHub : Hub +{ + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Logger for diagnostic output. + public AgentStatusHub(ILogger logger) + { + _logger = logger; + } + + /// + /// Broadcasts an agent status update to all connected clients. + /// + /// + /// 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 callback. + /// + /// + /// The agent status update payload to broadcast. + public async Task SendStatusUpdate(AgentStatusUpdateDto update) + { + _logger.LogInformation( + "Broadcasting status update for agent {AgentId}: {Status}", + update.AgentId, update.Status); + + await Clients.All.AgentStatusChanged(update); + } + + /// + /// Adds the calling connection to the fleet group. + /// Once joined, the client will receive all agent status updates. + /// + public async Task JoinFleet() + { + await Groups.AddToGroupAsync(Context.ConnectionId, FleetGroupName); + _logger.LogDebug("Connection {ConnectionId} joined fleet group", Context.ConnectionId); + } + + /// + /// Removes the calling connection from the fleet group. + /// + public async Task LeaveFleet() + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, FleetGroupName); + _logger.LogDebug("Connection {ConnectionId} left fleet group", Context.ConnectionId); + } + + /// + /// Overrides to log disconnections. + /// SignalR automatically removes disconnected connections from all groups. + /// + /// Exception that caused the disconnection, if any. + public override Task OnDisconnectedAsync(Exception? exception) + { + _logger.LogDebug("Connection {ConnectionId} disconnected", Context.ConnectionId); + return base.OnDisconnectedAsync(exception); + } + + /// + /// The SignalR group name for the entire fleet (all agents). + /// + internal const string FleetGroupName = "fleet"; +} + +/// +/// 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. +/// +public interface IAgentStatusClient +{ + /// + /// 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). + /// + /// The full status update payload. + /// A Task that completes when the client has processed the update. + Task AgentStatusChanged(AgentStatusUpdateDto update); +} + +/// +/// Extension methods for pushing real-time agent updates through +/// the of . +/// +/// +/// These methods are intended to be called from background services +/// or other server-side code that detects an agent state change, +/// using the injected IHubContext<AgentStatusHub, IAgentStatusClient>. +/// +/// +public static class AgentStatusHubExtensions +{ + /// + /// Pushes an agent status update to all connected clients. + /// + /// + /// 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"). + /// + /// + /// The hub context injected via DI. + /// The agent status update payload. + /// A Task that completes when the message has been sent to all clients. + public static async Task PushStatusUpdateAsync( + this IHubContext hubContext, + AgentStatusUpdateDto update) + { + await hubContext.Clients.All.AgentStatusChanged(update); + } + + /// + /// Pushes an agent status update to clients subscribed to the fleet group. + /// + /// The hub context injected via DI. + /// The agent status update payload. + /// A Task that completes when the message has been sent to the fleet group. + public static async Task PushStatusUpdateToFleetAsync( + this IHubContext hubContext, + AgentStatusUpdateDto update) + { + await hubContext.Clients.Group(AgentStatusHub.FleetGroupName) + .AgentStatusChanged(update); + } +} \ No newline at end of file diff --git a/backend/Program.cs b/backend/Program.cs index 80fb10e..c45c355 100644 --- a/backend/Program.cs +++ b/backend/Program.cs @@ -1,4 +1,5 @@ using ControlCenter.Api.Data; +using ControlCenter.Api.Hubs; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); @@ -6,6 +7,9 @@ var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddOpenApi(); +// Register SignalR for real-time agent status updates +builder.Services.AddSignalR(); + // Register DbContext with PostgreSQL builder.Services.AddDbContext(options => { @@ -28,4 +32,7 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +// Map SignalR hubs +app.MapHub("/hubs/agent-status"); + app.Run(); \ No newline at end of file