Compare commits

..

2 Commits

Author SHA1 Message Date
cubecraft-agents[bot]
6cf8d7fe5f feat(CUB-55): add SignalR broadcast state method with AgentStatusHub and DTO 2026-04-26 11:25:31 +00:00
cubecraft-agents[bot]
7b7b070dac feat(CUB-56): Agent State Database Migration 2026-04-26 10:18:06 +00:00
22 changed files with 829 additions and 542 deletions

21
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
## .NET
bin/
obj/
*.user
*.suo
*.cache
*.dll
*.pdb
## IDE
.vs/
.idea/
*.swp
*~
## OS
.DS_Store
Thumbs.db
## Environment
.env

View File

@@ -0,0 +1,88 @@
using ControlCenter.Api.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ControlCenter.Api.Configurations;
/// <summary>
/// EF Core entity type configuration for the agents table.
/// Enforces snake_case naming, required fields, and index design.
/// </summary>
public class AgentConfiguration : IEntityTypeConfiguration<Agent>
{
public void Configure(EntityTypeBuilder<Agent> builder)
{
// Table name — snake_case
builder.ToTable("agents");
// Primary key
builder.HasKey(a => a.Id);
builder.Property(a => a.Id)
.HasColumnName("id")
.ValueGeneratedOnAdd();
// Status — stored as PostgreSQL enum via Npgsql
builder.Property(a => a.Status)
.HasColumnName("status")
.HasColumnType("agent_status")
.IsRequired();
// Task — nullable text
builder.Property(a => a.Task)
.HasColumnName("task")
.HasColumnType("text");
// Progress — nullable integer (0100)
builder.Property(a => a.Progress)
.HasColumnName("progress");
// Session key — required, not null
builder.Property(a => a.SessionKey)
.HasColumnName("session_key")
.HasColumnType("text")
.IsRequired();
// Channel — required, not null
builder.Property(a => a.Channel)
.HasColumnName("channel")
.HasColumnType("text")
.IsRequired();
// Last activity — required, defaults to now()
builder.Property(a => a.LastActivity)
.HasColumnName("last_activity")
.HasColumnType("timestamptz")
.IsRequired();
// Created at — auto-set on insert
builder.Property(a => a.CreatedAt)
.HasColumnName("created_at")
.HasColumnType("timestamptz")
.IsRequired()
.HasDefaultValueSql("now()");
// Updated at — auto-set on insert and update
builder.Property(a => a.UpdatedAt)
.HasColumnName("updated_at")
.HasColumnType("timestamptz")
.IsRequired()
.HasDefaultValueSql("now()");
// Indexes
// Sessions are looked up by session_key frequently
builder.HasIndex(a => a.SessionKey)
.HasDatabaseName("ix_agents_session_key")
.IsUnique();
// Agents are filtered by channel for channel-specific queries
builder.HasIndex(a => a.Channel)
.HasDatabaseName("ix_agents_channel");
// Agents are filtered by status for fleet health monitoring
builder.HasIndex(a => a.Status)
.HasDatabaseName("ix_agents_status");
// Check constraint: progress must be 0100 if present
builder.ToTable(t => t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)"));
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@ControlCenter.Api_HostAddress = http://localhost:5178
GET {{ControlCenter.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,29 @@
using ControlCenter.Api.Configurations;
using ControlCenter.Api.Entities;
using Microsoft.EntityFrameworkCore;
namespace ControlCenter.Api.Data;
/// <summary>
/// EF Core DbContext for the Control Center database.
/// All table and column names use snake_case via explicit HasColumnName configuration.
/// </summary>
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<Agent> Agents => Set<Agent>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Apply all entity type configurations from the Configurations namespace
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AgentConfiguration).Assembly);
// Map the AgentStatus enum to a PostgreSQL enum type named "agent_status"
// This must be called after ApplyConfigurations to ensure the model is built
// before the enum mapping is applied.
modelBuilder.HasPostgresEnum<AgentStatus>();
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using ControlCenter.Api.Entities;
namespace ControlCenter.Api.Data;
/// <summary>
/// Design-time factory for AppDbContext, used by EF Core tools (dotnet ef)
/// to create migrations without requiring a running application.
/// </summary>
public class AppDbContextFactory : Microsoft.EntityFrameworkCore.Design.IDesignTimeDbContextFactory<AppDbContext>
{
public AppDbContext CreateDbContext(string[] args)
{
var optionsBuilder = new DbContextOptionsBuilder<AppDbContext>();
// Connection string for design-time operations (migrations).
// In production, this comes from appsettings / environment variables.
var connectionString = "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
optionsBuilder.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
});
return new AppDbContext(optionsBuilder.Options);
}
}

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; }
}

59
backend/Entities/Agent.cs Normal file
View File

@@ -0,0 +1,59 @@
namespace ControlCenter.Api.Entities;
/// <summary>
/// Represents an agent's current state in the Control Center.
/// Each row tracks one agent session's status, task, and activity.
/// </summary>
public class Agent
{
/// <summary>
/// Primary key — UUID generated on insert.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Current operational status of the agent.
/// Stored as an enum in PostgreSQL via Npgsql.
/// </summary>
public AgentStatus Status { get; set; } = AgentStatus.Idle;
/// <summary>
/// Description of the agent's current task, if any.
/// Nullable — not all agents have an active task.
/// </summary>
public string? Task { get; set; }
/// <summary>
/// Task progress percentage (0100).
/// Nullable — progress is only meaningful when an agent has a trackable task.
/// </summary>
public int? Progress { get; set; }
/// <summary>
/// Full session key, e.g. "agent:otto:telegram:direct:8787451565".
/// Not null — every agent row must be associated with a 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>
/// Timestamp of the agent's last activity.
/// Default: current UTC timestamp on insert.
/// </summary>
public DateTime LastActivity { get; set; } = DateTime.UtcNow;
/// <summary>
/// Row creation timestamp. Set automatically on insert.
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Row last-update timestamp. Updated automatically on any modification.
/// </summary>
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -0,0 +1,13 @@
namespace ControlCenter.Api.Entities;
/// <summary>
/// Agent operational status enum.
/// Maps to the agent_status enum type in PostgreSQL.
/// </summary>
public enum AgentStatus
{
Active = 0,
Idle = 1,
Thinking = 2,
Error = 3
}

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

@@ -0,0 +1,94 @@
// <auto-generated />
using System;
using ControlCenter.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ControlCenter.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260426101703_CreateAgentsTable")]
partial class CreateAgentsTable
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Channel")
.IsRequired()
.HasColumnType("text")
.HasColumnName("channel");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTime>("LastActivity")
.HasColumnType("timestamptz")
.HasColumnName("last_activity");
b.Property<int?>("Progress")
.HasColumnType("integer")
.HasColumnName("progress");
b.Property<string>("SessionKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("session_key");
b.Property<int>("Status")
.HasColumnType("agent_status")
.HasColumnName("status");
b.Property<string>("Task")
.HasColumnType("text")
.HasColumnName("task");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("Channel")
.HasDatabaseName("ix_agents_channel");
b.HasIndex("SessionKey")
.IsUnique()
.HasDatabaseName("ix_agents_session_key");
b.HasIndex("Status")
.HasDatabaseName("ix_agents_status");
b.ToTable("agents", null, t =>
{
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
});
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ControlCenter.Api.Migrations
{
/// <inheritdoc />
public partial class CreateAgentsTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:Enum:agent_status", "active,idle,thinking,error");
migrationBuilder.CreateTable(
name: "agents",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
status = table.Column<int>(type: "agent_status", nullable: false),
task = table.Column<string>(type: "text", nullable: true),
progress = table.Column<int>(type: "integer", nullable: true),
session_key = table.Column<string>(type: "text", nullable: false),
channel = table.Column<string>(type: "text", nullable: false),
last_activity = table.Column<DateTime>(type: "timestamptz", nullable: false),
created_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()"),
updated_at = table.Column<DateTime>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_agents", x => x.id);
table.CheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
});
migrationBuilder.CreateIndex(
name: "ix_agents_channel",
table: "agents",
column: "channel");
migrationBuilder.CreateIndex(
name: "ix_agents_session_key",
table: "agents",
column: "session_key",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_agents_status",
table: "agents",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "agents");
}
}
}

View File

@@ -0,0 +1,91 @@
// <auto-generated />
using System;
using ControlCenter.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ControlCenter.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "agent_status", new[] { "active", "idle", "thinking", "error" });
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("ControlCenter.Api.Entities.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Channel")
.IsRequired()
.HasColumnType("text")
.HasColumnName("channel");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<DateTime>("LastActivity")
.HasColumnType("timestamptz")
.HasColumnName("last_activity");
b.Property<int?>("Progress")
.HasColumnType("integer")
.HasColumnName("progress");
b.Property<string>("SessionKey")
.IsRequired()
.HasColumnType("text")
.HasColumnName("session_key");
b.Property<int>("Status")
.HasColumnType("agent_status")
.HasColumnName("status");
b.Property<string>("Task")
.HasColumnType("text")
.HasColumnName("task");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("updated_at")
.HasDefaultValueSql("now()");
b.HasKey("Id");
b.HasIndex("Channel")
.HasDatabaseName("ix_agents_channel");
b.HasIndex("SessionKey")
.IsUnique()
.HasDatabaseName("ix_agents_session_key");
b.HasIndex("Status")
.HasDatabaseName("ix_agents_status");
b.ToTable("agents", null, t =>
{
t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)");
});
});
#pragma warning restore 612, 618
}
}
}

38
backend/Program.cs Normal file
View File

@@ -0,0 +1,38 @@
using ControlCenter.Api.Data;
using ControlCenter.Api.Hubs;
using Microsoft.EntityFrameworkCore;
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<AppDbContext>(options =>
{
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
?? "Host=localhost;Database=control_center;Username=postgres;Password=postgres";
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(AppDbContext).Assembly.FullName);
});
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Map SignalR hubs
app.MapHub<AgentStatusHub>("/hubs/agent-status");
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5178",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7041;http://localhost:5178",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

12
backend/appsettings.json Normal file
View File

@@ -0,0 +1,12 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=control_center;Username=postgres;Password=postgres"
}
}

View File

@@ -1,71 +0,0 @@
<article
class="agent-card"
[class]="'agent-card--' + status()"
[attr.aria-label]="'Agent card: ' + statusLabel() + ' status'"
[style.--agent-status-color]="statusColorVar()"
[style.--agent-status-bg]="statusBgVar()"
>
<!-- Left border accent (4px, matching status color) -->
<div class="agent-card__accent"></div>
<!-- Card body -->
<div class="agent-card__body">
<!-- Header row: status dot + status label + channel + last activity -->
<div class="agent-card__header">
<div class="agent-card__status-row">
<span
class="status-dot agent-card__dot"
[ngClass]="[statusDotClass()]"
[attr.aria-label]="statusLabel() + ' status indicator'"
role="status"
></span>
<span class="agent-card__status-label" [style.color]="statusColorVar()">
{{ statusLabel() }}
</span>
</div>
<div class="agent-card__meta">
@if (channel()) {
<span class="agent-card__channel text-mono">
<mat-icon class="agent-card__channel-icon" fontIcon="forum" [inline]="true"></mat-icon>
{{ channel() }}
</span>
}
<span class="agent-card__last-activity text-mono">{{ lastActivityText() }}</span>
</div>
</div>
<!-- Task description -->
@if (task()) {
<p class="agent-card__task">{{ task() }}</p>
}
<!-- Progress bar -->
@if (showProgress()) {
<div class="agent-card__progress">
<mat-progress-bar
mode="determinate"
[value]="progress()"
[attr.aria-label]="'Task progress: ' + progress() + '%'"
></mat-progress-bar>
<div class="agent-card__progress-info">
<span class="text-mono">{{ progress() }}%</span>
</div>
</div>
}
<!-- Footer: session key (truncated) + jump button -->
<div class="agent-card__footer">
<span class="agent-card__session text-mono" [matTooltip]="sessionKey()">
{{ sessionKey().length > 28 ? sessionKey().substring(0, 28) + '…' : sessionKey() }}
</span>
<button
mat-icon-button
class="agent-card__jump"
[attr.aria-label]="'Jump to agent session'"
matTooltip="Jump to session"
>
<mat-icon>arrow_forward</mat-icon>
</button>
</div>
</div>
</article>

View File

@@ -1,277 +0,0 @@
// ============================================================================
// Agent Card Styles — M3 Tactical Dark
// Per spec Section 7.3: Agent Card Component
// Section 7.5: Animation Specs
// ============================================================================
// ---------------------------------------------------------------------------
// Card Shell
// ---------------------------------------------------------------------------
.agent-card {
display: flex;
position: relative;
min-width: var(--cc-card-min-width, 320px);
border-radius: var(--cc-card-border-radius, 16px);
background-color: var(--cc-surface-container);
overflow: hidden;
transition: background-color 150ms ease, box-shadow 150ms ease;
// M3 state layer overlay on hover (8% primary)
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background-color: rgba(255, 255, 255, 0.08);
opacity: 0;
transition: opacity 150ms ease;
pointer-events: none;
}
&:hover::after {
opacity: 1;
}
// Focus-visible outline for keyboard navigation
&:focus-visible {
outline: 3px solid var(--status-active);
outline-offset: 2px;
}
}
// ---------------------------------------------------------------------------
// Left-border accent (4px, matching status color)
// ---------------------------------------------------------------------------
.agent-card__accent {
flex-shrink: 0;
width: 4px;
background-color: var(--agent-status-color);
border-radius: 4px 0 0 4px;
}
// ---------------------------------------------------------------------------
// Card Body
// ---------------------------------------------------------------------------
.agent-card__body {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
padding: var(--cc-card-padding, 20px);
min-width: 0; // Prevent flex blowout for long text
}
// ---------------------------------------------------------------------------
// Header Row
// ---------------------------------------------------------------------------
.agent-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.agent-card__status-row {
display: flex;
align-items: center;
gap: 8px;
}
.agent-card__dot {
flex-shrink: 0;
}
.agent-card__status-label {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.agent-card__meta {
display: flex;
align-items: center;
gap: 12px;
color: var(--cc-on-surface-variant);
font-size: 12px;
}
.agent-card__channel {
display: inline-flex;
align-items: center;
gap: 4px;
}
.agent-card__channel-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
.agent-card__last-activity {
opacity: 0.7;
}
// ---------------------------------------------------------------------------
// Task Description
// ---------------------------------------------------------------------------
.agent-card__task {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: var(--cc-on-surface);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
// ---------------------------------------------------------------------------
// Progress Bar
// ---------------------------------------------------------------------------
.agent-card__progress {
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-card__progress-info {
display: flex;
justify-content: flex-end;
font-size: 12px;
color: var(--cc-on-surface-variant);
}
// Override M3 progress bar track to use status color
.agent-card__progress .mat-mdc-progress-bar {
--mdc-linear-progress-active-indicator-color: var(--agent-status-color);
--mdc-linear-progress-track-color: var(--agent-status-bg);
}
// ---------------------------------------------------------------------------
// Footer Row
// ---------------------------------------------------------------------------
.agent-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto; // Push footer to bottom
}
.agent-card__session {
font-size: 11px;
color: var(--cc-on-surface-variant);
opacity: 0.6;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
// ---------------------------------------------------------------------------
// Quick-Jump Button — M3 FilledTonalIconButton style
// Per spec: → Jump button using M3 FilledTonalIconButton
// ---------------------------------------------------------------------------
.agent-card__jump {
// M3 FilledTonal style: secondary container color
--mdc-icon-button-icon-color: var(--cc-on-surface);
background-color: var(--cc-surface-container-high);
border-radius: 50%;
width: 40px;
height: 40px;
flex-shrink: 0;
// State layer overlay on hover (8% primary)
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.08);
opacity: 0;
transition: opacity 150ms ease;
pointer-events: none;
}
&:hover::before {
opacity: 1;
}
// Focus-visible ring
&:focus-visible {
outline: 3px solid var(--status-active);
outline-offset: 2px;
}
// Active state
&:active {
transform: scale(0.95);
}
.mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
}
// ---------------------------------------------------------------------------
// Status-specific card backgrounds (subtle tint)
// ---------------------------------------------------------------------------
.agent-card--active {
background-color: var(--status-active-bg);
}
.agent-card--thinking {
background-color: var(--status-thinking-bg);
}
.agent-card--error {
background-color: var(--status-error-bg);
}
// Idle and offline use default surface-container
// ---------------------------------------------------------------------------
// Touch-First Targets
// ---------------------------------------------------------------------------
// Minimum touch target: 48px (M3 standard)
.agent-card__jump {
min-width: 48px;
min-height: 48px;
}
// ---------------------------------------------------------------------------
// Accessibility: Reduced Motion
// ---------------------------------------------------------------------------
@media (prefers-reduced-motion: reduce) {
.agent-card {
transition: none;
}
.agent-card__jump {
transition: none;
&:active {
transform: none;
}
}
}
// ---------------------------------------------------------------------------
// Responsive: Mobile adjustments
// ---------------------------------------------------------------------------
@media (max-width: 599px) {
.agent-card {
min-width: unset;
}
.agent-card__body {
padding: 16px;
gap: 8px;
}
}

View File

@@ -1,120 +0,0 @@
// ============================================================================
// Agent Card Component
// Per spec Section 7.3: Agent Card Component Interface
// Section 7.5: Animation Specs
// ============================================================================
import { ChangeDetectionStrategy, Component, input, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { AgentStatus } from '../../models/agent.model';
/**
* AgentCard displays a single agent's status in the Command Hub grid.
*
* Inputs:
* - status: AgentStatus — current agent status
* - task: string — current task description
* - progress: number — task progress percentage (0100)
* - sessionKey: string — full session key
* - channel: string — communication channel (e.g., "telegram")
* - lastActivity: Date — timestamp of last activity
*
* Accessibility:
* - role="article" on the card element
* - aria-labels for status badge and interactive elements
* - focus-visible outlines for keyboard navigation
*/
@Component({
selector: 'app-agent-card',
standalone: true,
imports: [
CommonModule,
MatIconModule,
MatButtonModule,
MatProgressBarModule,
MatTooltipModule,
],
templateUrl: './agent-card.component.html',
styleUrl: './agent-card.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AgentCardComponent {
/** Current agent status */
readonly status = input.required<AgentStatus>();
/** Current task description */
readonly task = input<string>('');
/** Task progress percentage (0100) */
readonly progress = input<number>(0);
/** Full session key */
readonly sessionKey = input.required<string>();
/** Communication channel (e.g., "telegram") */
readonly channel = input<string>('');
/** Timestamp of last activity */
readonly lastActivity = input<Date>(new Date());
/** Map status to CSS custom property name for dynamic color binding */
readonly statusColorVar = computed(() => {
const map: Record<AgentStatus, string> = {
active: 'var(--status-active)',
idle: 'var(--status-idle)',
thinking: 'var(--status-thinking)',
error: 'var(--status-error)',
offline: 'var(--status-offline)',
};
return map[this.status()];
});
/** Map status to background tint CSS variable */
readonly statusBgVar = computed(() => {
const map: Record<AgentStatus, string> = {
active: 'var(--status-active-bg)',
idle: 'var(--status-idle-bg)',
thinking: 'var(--status-thinking-bg)',
error: 'var(--status-error-bg)',
offline: 'var(--cc-surface-container)',
};
return map[this.status()];
});
/** Human-readable status label */
readonly statusLabel = computed(() => {
const map: Record<AgentStatus, string> = {
active: 'Active',
idle: 'Idle',
thinking: 'Thinking',
error: 'Error',
offline: 'Offline',
};
return map[this.status()];
});
/** CSS class for status dot animation */
readonly statusDotClass = computed(() => `status-dot--${this.status()}`);
/** Format last activity as relative time string */
readonly lastActivityText = computed(() => {
const now = Date.now();
const then = this.lastActivity().getTime();
const diffMs = now - then;
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'Just now';
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
return `${Math.floor(diffHr / 24)}d ago`;
});
/** Whether to show progress bar */
readonly showProgress = computed(() => {
const s = this.status();
return (s === 'active' || s === 'thinking') && this.progress() > 0;
});
}

View File

@@ -1 +0,0 @@
export { AgentCardComponent } from './agent-card.component';

View File

@@ -1,88 +1,26 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component } from '@angular/core';
import { AgentCardComponent } from '../../components/agent-card/agent-card.component';
import { AgentCardData } from '../../models/agent.model';
@Component({ @Component({
selector: 'app-hub-page', selector: 'app-hub-page',
standalone: true, standalone: true,
imports: [AgentCardComponent], imports: [],
template: ` template: `
<div class="hub-page"> <div class="hub-page">
<div class="hub-page__grid"> <p class="hub-page__placeholder">Command Hub — Fleet status grid will render here</p>
@for (agent of agents(); track agent.id) {
<app-agent-card
[status]="agent.status"
[task]="agent.currentTask ?? ''"
[progress]="agent.taskProgress ?? 0"
[sessionKey]="agent.sessionKey"
[channel]="agent.channel"
[lastActivity]="agent.lastActivity"
/>
}
</div>
</div> </div>
`, `,
styles: [` styles: [`
.hub-page { .hub-page {
padding: var(--cc-section-padding, 24px); display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
} }
.hub-page__placeholder {
.hub-page__grid { color: var(--cc-on-surface-variant);
display: grid; font-size: 16px;
grid-template-columns: repeat(auto-fill, minmax(var(--cc-card-min-width, 320px), 1fr));
gap: var(--cc-card-gap, 16px);
} }
`], `],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class HubPageComponent { export class HubPageComponent {}
/** Demo agents for development — will be replaced by SignalR data */
protected readonly agents = signal<AgentCardData[]>([
{
id: 'otto',
displayName: 'Otto',
role: 'Orchestrator',
status: 'active',
currentTask: 'Reviewing PR #42',
taskProgress: 65,
taskElapsed: '04m 12s',
sessionKey: 'agent:otto:telegram:direct:8787451565',
channel: 'telegram',
lastActivity: new Date(),
},
{
id: 'rex',
displayName: 'Rex',
role: 'Frontend Specialist',
status: 'thinking',
currentTask: 'Building AgentCard component',
taskProgress: 30,
taskElapsed: '02m 45s',
sessionKey: 'agent:rex:subagent:0cdbf600',
channel: 'telegram',
lastActivity: new Date(Date.now() - 120000),
},
{
id: 'dex',
displayName: 'Dex',
role: 'Backend Engineer',
status: 'idle',
sessionKey: 'agent:dex:slack:channel:C01234567',
channel: 'slack',
lastActivity: new Date(Date.now() - 300000),
},
{
id: 'hex',
displayName: 'Hex',
role: 'Database Architect',
status: 'error',
currentTask: 'Migration failed — rollback',
taskProgress: 0,
taskElapsed: '00m 00s',
sessionKey: 'agent:hex:slack:channel:C01234568',
channel: 'slack',
lastActivity: new Date(Date.now() - 1800000),
errorMessage: 'Connection timeout',
},
]);
}