From 7b7b070dacb60168bd5eaeebc4e297d7b959fcb7 Mon Sep 17 00:00:00 2001 From: "cubecraft-agents[bot]" <3458173+cubecraft-agents[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 10:18:06 +0000 Subject: [PATCH] feat(CUB-56): Agent State Database Migration --- backend/.gitignore | 21 +++++ backend/Configurations/AgentConfiguration.cs | 88 +++++++++++++++++ backend/ControlCenter.Api.csproj | 18 ++++ backend/ControlCenter.Api.http | 6 ++ backend/Data/AppDbContext.cs | 29 ++++++ backend/Data/AppDbContextFactory.cs | 27 ++++++ backend/Entities/Agent.cs | 59 ++++++++++++ backend/Entities/AgentStatus.cs | 13 +++ ...260426101703_CreateAgentsTable.Designer.cs | 94 +++++++++++++++++++ .../20260426101703_CreateAgentsTable.cs | 61 ++++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 91 ++++++++++++++++++ backend/Program.cs | 31 ++++++ backend/Properties/launchSettings.json | 23 +++++ backend/appsettings.Development.json | 8 ++ backend/appsettings.json | 12 +++ 15 files changed, 581 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/Configurations/AgentConfiguration.cs create mode 100644 backend/ControlCenter.Api.csproj create mode 100644 backend/ControlCenter.Api.http create mode 100644 backend/Data/AppDbContext.cs create mode 100644 backend/Data/AppDbContextFactory.cs create mode 100644 backend/Entities/Agent.cs create mode 100644 backend/Entities/AgentStatus.cs create mode 100644 backend/Migrations/20260426101703_CreateAgentsTable.Designer.cs create mode 100644 backend/Migrations/20260426101703_CreateAgentsTable.cs create mode 100644 backend/Migrations/AppDbContextModelSnapshot.cs create mode 100644 backend/Program.cs create mode 100644 backend/Properties/launchSettings.json create mode 100644 backend/appsettings.Development.json create mode 100644 backend/appsettings.json diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..c2c0c5e --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,21 @@ +## .NET +bin/ +obj/ +*.user +*.suo +*.cache +*.dll +*.pdb + +## IDE +.vs/ +.idea/ +*.swp +*~ + +## OS +.DS_Store +Thumbs.db + +## Environment +.env \ No newline at end of file diff --git a/backend/Configurations/AgentConfiguration.cs b/backend/Configurations/AgentConfiguration.cs new file mode 100644 index 0000000..0f9d304 --- /dev/null +++ b/backend/Configurations/AgentConfiguration.cs @@ -0,0 +1,88 @@ +using ControlCenter.Api.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ControlCenter.Api.Configurations; + +/// +/// EF Core entity type configuration for the agents table. +/// Enforces snake_case naming, required fields, and index design. +/// +public class AgentConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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 (0–100) + 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 0–100 if present + builder.ToTable(t => t.HasCheckConstraint("ck_agents_progress_range", "progress IS NULL OR (progress >= 0 AND progress <= 100)")); + } +} \ No newline at end of file diff --git a/backend/ControlCenter.Api.csproj b/backend/ControlCenter.Api.csproj new file mode 100644 index 0000000..d2896da --- /dev/null +++ b/backend/ControlCenter.Api.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/backend/ControlCenter.Api.http b/backend/ControlCenter.Api.http new file mode 100644 index 0000000..f621eb8 --- /dev/null +++ b/backend/ControlCenter.Api.http @@ -0,0 +1,6 @@ +@ControlCenter.Api_HostAddress = http://localhost:5178 + +GET {{ControlCenter.Api_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/backend/Data/AppDbContext.cs b/backend/Data/AppDbContext.cs new file mode 100644 index 0000000..3277f00 --- /dev/null +++ b/backend/Data/AppDbContext.cs @@ -0,0 +1,29 @@ +using ControlCenter.Api.Configurations; +using ControlCenter.Api.Entities; +using Microsoft.EntityFrameworkCore; + +namespace ControlCenter.Api.Data; + +/// +/// EF Core DbContext for the Control Center database. +/// All table and column names use snake_case via explicit HasColumnName configuration. +/// +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Agents => Set(); + + 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(); + + base.OnModelCreating(modelBuilder); + } +} \ No newline at end of file diff --git a/backend/Data/AppDbContextFactory.cs b/backend/Data/AppDbContextFactory.cs new file mode 100644 index 0000000..7b82843 --- /dev/null +++ b/backend/Data/AppDbContextFactory.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using ControlCenter.Api.Entities; + +namespace ControlCenter.Api.Data; + +/// +/// Design-time factory for AppDbContext, used by EF Core tools (dotnet ef) +/// to create migrations without requiring a running application. +/// +public class AppDbContextFactory : Microsoft.EntityFrameworkCore.Design.IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var optionsBuilder = new DbContextOptionsBuilder(); + + // 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); + } +} \ No newline at end of file diff --git a/backend/Entities/Agent.cs b/backend/Entities/Agent.cs new file mode 100644 index 0000000..67be593 --- /dev/null +++ b/backend/Entities/Agent.cs @@ -0,0 +1,59 @@ +namespace ControlCenter.Api.Entities; + +/// +/// Represents an agent's current state in the Control Center. +/// Each row tracks one agent session's status, task, and activity. +/// +public class Agent +{ + /// + /// Primary key — UUID generated on insert. + /// + public Guid Id { get; set; } + + /// + /// Current operational status of the agent. + /// Stored as an enum in PostgreSQL via Npgsql. + /// + public AgentStatus Status { get; set; } = AgentStatus.Idle; + + /// + /// Description of the agent's current task, if any. + /// Nullable — not all agents have an active task. + /// + public string? Task { get; set; } + + /// + /// Task progress percentage (0–100). + /// Nullable — progress is only meaningful when an agent has a trackable task. + /// + public int? Progress { get; set; } + + /// + /// Full session key, e.g. "agent:otto:telegram:direct:8787451565". + /// Not null — every agent row must be associated with a 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; + + /// + /// Timestamp of the agent's last activity. + /// Default: current UTC timestamp on insert. + /// + public DateTime LastActivity { get; set; } = DateTime.UtcNow; + + /// + /// Row creation timestamp. Set automatically on insert. + /// + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + /// + /// Row last-update timestamp. Updated automatically on any modification. + /// + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} \ No newline at end of file diff --git a/backend/Entities/AgentStatus.cs b/backend/Entities/AgentStatus.cs new file mode 100644 index 0000000..3846fcc --- /dev/null +++ b/backend/Entities/AgentStatus.cs @@ -0,0 +1,13 @@ +namespace ControlCenter.Api.Entities; + +/// +/// Agent operational status enum. +/// Maps to the agent_status enum type in PostgreSQL. +/// +public enum AgentStatus +{ + Active = 0, + Idle = 1, + Thinking = 2, + Error = 3 +} \ No newline at end of file diff --git a/backend/Migrations/20260426101703_CreateAgentsTable.Designer.cs b/backend/Migrations/20260426101703_CreateAgentsTable.Designer.cs new file mode 100644 index 0000000..8b99837 --- /dev/null +++ b/backend/Migrations/20260426101703_CreateAgentsTable.Designer.cs @@ -0,0 +1,94 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Channel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("channel"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LastActivity") + .HasColumnType("timestamptz") + .HasColumnName("last_activity"); + + b.Property("Progress") + .HasColumnType("integer") + .HasColumnName("progress"); + + b.Property("SessionKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_key"); + + b.Property("Status") + .HasColumnType("agent_status") + .HasColumnName("status"); + + b.Property("Task") + .HasColumnType("text") + .HasColumnName("task"); + + b.Property("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 + } + } +} diff --git a/backend/Migrations/20260426101703_CreateAgentsTable.cs b/backend/Migrations/20260426101703_CreateAgentsTable.cs new file mode 100644 index 0000000..4b38049 --- /dev/null +++ b/backend/Migrations/20260426101703_CreateAgentsTable.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ControlCenter.Api.Migrations +{ + /// + public partial class CreateAgentsTable : Migration + { + /// + 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(type: "uuid", nullable: false), + status = table.Column(type: "agent_status", nullable: false), + task = table.Column(type: "text", nullable: true), + progress = table.Column(type: "integer", nullable: true), + session_key = table.Column(type: "text", nullable: false), + channel = table.Column(type: "text", nullable: false), + last_activity = table.Column(type: "timestamptz", nullable: false), + created_at = table.Column(type: "timestamptz", nullable: false, defaultValueSql: "now()"), + updated_at = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "agents"); + } + } +} diff --git a/backend/Migrations/AppDbContextModelSnapshot.cs b/backend/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..c32b60a --- /dev/null +++ b/backend/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,91 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Channel") + .IsRequired() + .HasColumnType("text") + .HasColumnName("channel"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamptz") + .HasColumnName("created_at") + .HasDefaultValueSql("now()"); + + b.Property("LastActivity") + .HasColumnType("timestamptz") + .HasColumnName("last_activity"); + + b.Property("Progress") + .HasColumnType("integer") + .HasColumnName("progress"); + + b.Property("SessionKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("session_key"); + + b.Property("Status") + .HasColumnType("agent_status") + .HasColumnName("status"); + + b.Property("Task") + .HasColumnType("text") + .HasColumnName("task"); + + b.Property("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 + } + } +} diff --git a/backend/Program.cs b/backend/Program.cs new file mode 100644 index 0000000..80fb10e --- /dev/null +++ b/backend/Program.cs @@ -0,0 +1,31 @@ +using ControlCenter.Api.Data; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddOpenApi(); + +// Register DbContext with PostgreSQL +builder.Services.AddDbContext(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(); + +app.Run(); \ No newline at end of file diff --git a/backend/Properties/launchSettings.json b/backend/Properties/launchSettings.json new file mode 100644 index 0000000..5a40284 --- /dev/null +++ b/backend/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/backend/appsettings.Development.json b/backend/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/backend/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/backend/appsettings.json b/backend/appsettings.json new file mode 100644 index 0000000..0181f7d --- /dev/null +++ b/backend/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=control_center;Username=postgres;Password=postgres" + } +} -- 2.53.0