using ControlCenter.Hubs;
using ControlCenter.Models;
using Microsoft.AspNetCore.SignalR;
namespace ControlCenter.Services;
///
/// Service that maps Linear agents to minion sprites and positions
/// in the Control Center dashboard.
///
/// Static mappings define where each minion appears:
///
/// - Dev side: Rex (0), Dex (1), Hex (2)
/// - Business side: Larry (0), Mel (1), Buzz (2)
///
///
/// Dynamic state is derived from the agent's :
///
/// - Active →
/// - Idle →
/// - Thinking →
/// - Error →
///
///
/// State updates are published through the
/// SignalR hub so that connected clients can animate minion sprites
/// in real time.
///
public class AgentMinionMapperService
{
private readonly ILogger _logger;
private readonly IHubContext _hubContext;
///
/// Static agent-to-minion mapping table. Defines which side and position
/// each agent's minion occupies on the dashboard.
///
private static readonly Dictionary Mappings = new()
{
// ── Dev Side ──────────────────────────────────
["rex"] = new AgentMinionMapping("rex", MinionSide.Dev, 0, "Rex"),
["dex"] = new AgentMinionMapping("dex", MinionSide.Dev, 1, "Dex"),
["hex"] = new AgentMinionMapping("hex", MinionSide.Dev, 2, "Hex"),
// ── Business Side ─────────────────────────────
["larry"] = new AgentMinionMapping("larry", MinionSide.Business, 0, "Larry"),
["mel"] = new AgentMinionMapping("mel", MinionSide.Business, 1, "Mel"),
["buzz"] = new AgentMinionMapping("buzz", MinionSide.Business, 2, "Buzz"),
};
///
/// Maps string values to .
///
private static readonly Dictionary StatusToMinionState = new()
{
["active"] = MinionState.Active,
["idle"] = MinionState.Idle,
["thinking"] = MinionState.Thinking,
["error"] = MinionState.Error,
};
public AgentMinionMapperService(
ILogger logger,
IHubContext hubContext)
{
_logger = logger;
_hubContext = hubContext;
}
///
/// Gets the minion mapping for a given agent ID.
/// Returns null if the agent is not mapped to a minion position.
///
/// The agent identifier, e.g. "rex", "dex".
/// The mapping record, or null if unmapped.
public AgentMinionMapping? GetMapping(string agentId)
{
return Mappings.GetValueOrDefault(agentId?.ToLowerInvariant() ?? string.Empty);
}
///
/// Gets all minion mappings, ordered by side then position index.
///
/// All mappings, sorted for consistent display order.
public IReadOnlyList GetAllMappings()
{
return Mappings.Values
.OrderBy(m => m.Side)
.ThenBy(m => m.PositionIndex)
.ToList();
}
///
/// Converts an agent status string to a .
/// Falls back to for unrecognized statuses.
///
/// Agent status string: "active", "idle", "thinking", or "error".
/// The corresponding minion state.
public MinionState StatusToState(string status)
{
return StatusToMinionState.GetValueOrDefault(
status?.ToLowerInvariant() ?? string.Empty,
MinionState.Idle);
}
///
/// Publishes a minion state update through SignalR when an agent's
/// status changes. Only publishes for agents that have a minion mapping.
///
/// This is the primary integration point: the
/// calls this method
/// whenever it detects a status change from the OpenClaw Gateway.
///
/// The agent whose status changed, e.g. "dex".
/// The new status string: "active", "idle", "thinking", or "error".
/// A task that completes when the SignalR message has been sent.
public async Task PublishMinionStateUpdateAsync(string agentId, string status)
{
var mapping = GetMapping(agentId);
if (mapping is null)
{
_logger.LogDebug("No minion mapping for agent {AgentId}; skipping state update", agentId);
return;
}
var minionState = StatusToState(status);
var update = new MinionStateUpdate(
AgentId: mapping.AgentId,
DisplayName: mapping.DisplayName,
Side: mapping.Side,
PositionIndex: mapping.PositionIndex,
State: minionState,
Timestamp: DateTime.UtcNow.ToString("o")
);
// Broadcast to the fleet group (all subscribers)
await _hubContext.Clients.Group(AgentStatusHub.FleetGroupName)
.AgentStatusChanged(ToAgentStatusUpdate(agentId, status));
// Also push to the specific agent's group
var agentGroup = AgentStatusHub.AgentGroupName(agentId);
await _hubContext.Clients.Group(agentGroup)
.AgentStatusChanged(ToAgentStatusUpdate(agentId, status));
_logger.LogInformation(
"Minion state update: {AgentId} → {State} (Side: {Side}, Position: {Index})",
agentId, minionState, mapping.Side, mapping.PositionIndex);
}
///
/// Gets the current minion state for all mapped agents, suitable
/// for building an initial fleet snapshot.
///
/// All minion mappings with their current (idle) state.
public IReadOnlyList GetFullMinionState()
{
return Mappings.Values
.OrderBy(m => m.Side)
.ThenBy(m => m.PositionIndex)
.Select(m => new MinionStateUpdate(
AgentId: m.AgentId,
DisplayName: m.DisplayName,
Side: m.Side,
PositionIndex: m.PositionIndex,
State: MinionState.Idle,
Timestamp: DateTime.UtcNow.ToString("o")))
.ToList();
}
///
/// Converts a status string to an
/// for SignalR push. Uses the mapping table for display names and roles.
///
private AgentStatusUpdate ToAgentStatusUpdate(string agentId, string status)
{
var mapping = GetMapping(agentId);
var displayName = mapping?.DisplayName ?? char.ToUpperInvariant(agentId[0]) + agentId[1..];
return new AgentStatusUpdate(
AgentId: agentId,
DisplayName: displayName,
Role: mapping is not null
? $"{mapping.Side} Agent"
: "Agent",
Status: status,
CurrentTask: null,
SessionKey: string.Empty,
Channel: string.Empty,
LastActivity: DateTime.UtcNow.ToString("o"),
ErrorMessage: status == "error" ? "Agent encountered an error" : null
);
}
}