193 lines
7.7 KiB
C#
193 lines
7.7 KiB
C#
using ControlCenter.Hubs;
|
|
using ControlCenter.Models;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
|
|
namespace ControlCenter.Services;
|
|
|
|
/// <summary>
|
|
/// Service that maps Linear agents to minion sprites and positions
|
|
/// in the Control Center dashboard.
|
|
///
|
|
/// <para>Static mappings define where each minion appears:</para>
|
|
/// <list type="bullet">
|
|
/// <item>Dev side: Rex (0), Dex (1), Hex (2)</item>
|
|
/// <item>Business side: Larry (0), Mel (1), Buzz (2)</item>
|
|
/// </list>
|
|
///
|
|
/// <para>Dynamic state is derived from the agent's <see cref="AgentStatus"/>:</para>
|
|
/// <list type="bullet">
|
|
/// <item><c>Active</c> → <see cref="MinionState.Active"/></item>
|
|
/// <item><c>Idle</c> → <see cref="MinionState.Idle"/></item>
|
|
/// <item><c>Thinking</c> → <see cref="MinionState.Thinking"/></item>
|
|
/// <item><c>Error</c> → <see cref="MinionState.Error"/></item>
|
|
/// </list>
|
|
///
|
|
/// <para>State updates are published through the <see cref="AgentStatusHub"/>
|
|
/// SignalR hub so that connected clients can animate minion sprites
|
|
/// in real time.</para>
|
|
/// </summary>
|
|
public class AgentMinionMapperService
|
|
{
|
|
private readonly ILogger<AgentMinionMapperService> _logger;
|
|
private readonly IHubContext<AgentStatusHub, IAgentStatusClient> _hubContext;
|
|
|
|
/// <summary>
|
|
/// Static agent-to-minion mapping table. Defines which side and position
|
|
/// each agent's minion occupies on the dashboard.
|
|
/// </summary>
|
|
private static readonly Dictionary<string, AgentMinionMapping> 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"),
|
|
};
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="AgentStatus"/> string values to <see cref="MinionState"/>.
|
|
/// </summary>
|
|
private static readonly Dictionary<string, MinionState> StatusToMinionState = new()
|
|
{
|
|
["active"] = MinionState.Active,
|
|
["idle"] = MinionState.Idle,
|
|
["thinking"] = MinionState.Thinking,
|
|
["error"] = MinionState.Error,
|
|
};
|
|
|
|
public AgentMinionMapperService(
|
|
ILogger<AgentMinionMapperService> logger,
|
|
IHubContext<AgentStatusHub, IAgentStatusClient> hubContext)
|
|
{
|
|
_logger = logger;
|
|
_hubContext = hubContext;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the minion mapping for a given agent ID.
|
|
/// Returns null if the agent is not mapped to a minion position.
|
|
/// </summary>
|
|
/// <param name="agentId">The agent identifier, e.g. "rex", "dex".</param>
|
|
/// <returns>The mapping record, or null if unmapped.</returns>
|
|
public AgentMinionMapping? GetMapping(string agentId)
|
|
{
|
|
return Mappings.GetValueOrDefault(agentId?.ToLowerInvariant() ?? string.Empty);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all minion mappings, ordered by side then position index.
|
|
/// </summary>
|
|
/// <returns>All mappings, sorted for consistent display order.</returns>
|
|
public IReadOnlyList<AgentMinionMapping> GetAllMappings()
|
|
{
|
|
return Mappings.Values
|
|
.OrderBy(m => m.Side)
|
|
.ThenBy(m => m.PositionIndex)
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts an agent status string to a <see cref="MinionState"/>.
|
|
/// Falls back to <see cref="MinionState.Idle"/> for unrecognized statuses.
|
|
/// </summary>
|
|
/// <param name="status">Agent status string: "active", "idle", "thinking", or "error".</param>
|
|
/// <returns>The corresponding minion state.</returns>
|
|
public MinionState StatusToState(string status)
|
|
{
|
|
return StatusToMinionState.GetValueOrDefault(
|
|
status?.ToLowerInvariant() ?? string.Empty,
|
|
MinionState.Idle);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Publishes a minion state update through SignalR when an agent's
|
|
/// status changes. Only publishes for agents that have a minion mapping.
|
|
///
|
|
/// <para>This is the primary integration point: the
|
|
/// <see cref="GatewayEventBridgeService"/> calls this method
|
|
/// whenever it detects a status change from the OpenClaw Gateway.</para>
|
|
/// </summary>
|
|
/// <param name="agentId">The agent whose status changed, e.g. "dex".</param>
|
|
/// <param name="status">The new status string: "active", "idle", "thinking", or "error".</param>
|
|
/// <returns>A task that completes when the SignalR message has been sent.</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the current minion state for all mapped agents, suitable
|
|
/// for building an initial fleet snapshot.
|
|
/// </summary>
|
|
/// <returns>All minion mappings with their current (idle) state.</returns>
|
|
public IReadOnlyList<MinionStateUpdate> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts a status string to an <see cref="AgentStatusUpdate"/>
|
|
/// for SignalR push. Uses the mapping table for display names and roles.
|
|
/// </summary>
|
|
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
|
|
);
|
|
}
|
|
} |