Compare commits
3 Commits
bcaf85c369
...
1c012de47b
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c012de47b | |||
| ea603c3552 | |||
|
|
82c12554d0 |
72
backend/ControlCenter/Models/AgentMinionMapping.cs
Normal file
72
backend/ControlCenter/Models/AgentMinionMapping.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
namespace ControlCenter.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Defines which side of the Control Center dashboard a minion occupies.
|
||||
/// </summary>
|
||||
public enum MinionSide
|
||||
{
|
||||
/// <summary>Development side — Rex, Dex, Hex.</summary>
|
||||
Dev,
|
||||
|
||||
/// <summary>Business side — Larry, Mel, Buzz.</summary>
|
||||
Business
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Visual state of a minion sprite, derived from the agent's
|
||||
/// <see cref="AgentStatus"/>. Maps Active/Idle/Thinking/Error
|
||||
/// to frontend animation states.
|
||||
/// </summary>
|
||||
public enum MinionState
|
||||
{
|
||||
/// <summary>Agent is actively processing — minion shows working animation.</summary>
|
||||
Active,
|
||||
|
||||
/// <summary>Agent is idle — minion shows idle/patrolling animation.</summary>
|
||||
Idle,
|
||||
|
||||
/// <summary>Agent is thinking (LLM call in flight) — minion shows thinking animation.</summary>
|
||||
Thinking,
|
||||
|
||||
/// <summary>Agent encountered an error — minion shows error/distress animation.</summary>
|
||||
Error
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Static mapping entry that associates an agent ID with a minion's
|
||||
/// display side and position index within that side.
|
||||
///
|
||||
/// <para>Position indices are zero-based within each side. The dev side
|
||||
/// has Rex at 0, Dex at 1, and Hex at 2. The business side has
|
||||
/// Larry at 0, Mel at 1, and Buzz at 2.</para>
|
||||
/// </summary>
|
||||
/// <param name="AgentId">Agent identifier, e.g. "rex", "dex".</param>
|
||||
/// <param name="Side">Which side of the dashboard the minion occupies.</param>
|
||||
/// <param name="PositionIndex">Zero-based position index within the side.</param>
|
||||
/// <param name="DisplayName">Human-readable name, e.g. "Rex".</param>
|
||||
public record AgentMinionMapping(
|
||||
string AgentId,
|
||||
MinionSide Side,
|
||||
int PositionIndex,
|
||||
string DisplayName
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Real-time minion state update pushed to SignalR clients
|
||||
/// when an agent's status changes. Combines the static mapping
|
||||
/// (who/where) with the dynamic state (what the minion is doing).
|
||||
/// </summary>
|
||||
/// <param name="AgentId">Agent identifier, e.g. "rex".</param>
|
||||
/// <param name="DisplayName">Human-readable minion name, e.g. "Rex".</param>
|
||||
/// <param name="Side">Which side of the dashboard — Dev or Business.</param>
|
||||
/// <param name="PositionIndex">Position within the side (0-based).</param>
|
||||
/// <param name="State">Current minion animation state.</param>
|
||||
/// <param name="Timestamp">ISO 8601 timestamp of the state change.</param>
|
||||
public record MinionStateUpdate(
|
||||
string AgentId,
|
||||
string DisplayName,
|
||||
MinionSide Side,
|
||||
int PositionIndex,
|
||||
MinionState State,
|
||||
string Timestamp
|
||||
);
|
||||
@@ -52,6 +52,11 @@ builder.Services.AddSignalR();
|
||||
builder.Services.AddSingleton<GatewayEventBridgeService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GatewayEventBridgeService>());
|
||||
|
||||
// ── Agent-Minion Mapper Service ────────────────────────────
|
||||
// Maps agents to minion sprites/positions and publishes state
|
||||
// updates through SignalR.
|
||||
builder.Services.AddSingleton<AgentMinionMapperService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ── Middleware ──────────────────────────────────────────────
|
||||
|
||||
193
backend/ControlCenter/Services/AgentMinionMapperService.cs
Normal file
193
backend/ControlCenter/Services/AgentMinionMapperService.cs
Normal file
@@ -0,0 +1,193 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user