M3: Agent bound to a seat — configure an AI seat
OrgBoard: Agent entity (name, monogram, autonomy dial, ApiConfigId + optional fallback,
skill keys, docs) + AddAgents migration; one agent per seat. References Skills by key and
the BYOK config by id — never reaches into those modules.
Endpoints: POST/GET /api/orgboard/seats (create/list seats), POST/GET
/api/orgboard/seats/{id}/agent (configure/read the agent) — ConfigureAgents at [team, org].
Configuring an agent flips the seat to the AI state and points it at the agent; audited.
Verified: build green; ArchitectureTests 8/8; IntegrationTests 27/27 incl. the M3 acceptance
flow — owner adds a BYOK config, then configures "Aria" (gated autonomy, skills, that config)
on a seat, flipping it to AI, with the key never exposed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
using TeamUp.SharedKernel.Access;
|
||||
using TeamUp.SharedKernel.Domain;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// The AI staffing an open seat: identity (name, monogram) + matched skill atoms + autonomy +
|
||||
/// the model config + docs. References Skills by key and the BYOK ApiConfig by id — never reaches
|
||||
/// into those modules' tables. One agent per seat.
|
||||
/// </summary>
|
||||
internal sealed class Agent : Entity
|
||||
{
|
||||
public Guid SeatId { get; private set; }
|
||||
public string Name { get; private set; } = null!;
|
||||
public string? Monogram { get; private set; }
|
||||
public Autonomy Autonomy { get; private set; }
|
||||
public Guid ApiConfigId { get; private set; }
|
||||
public Guid? FallbackApiConfigId { get; private set; }
|
||||
public List<string> SkillKeys { get; private set; } = [];
|
||||
public List<string> Docs { get; private set; } = [];
|
||||
public DateTimeOffset CreatedAtUtc { get; private set; }
|
||||
public DateTimeOffset UpdatedAtUtc { get; private set; }
|
||||
|
||||
private Agent()
|
||||
{
|
||||
}
|
||||
|
||||
public Agent(Guid seatId, DateTimeOffset createdAtUtc)
|
||||
{
|
||||
SeatId = seatId;
|
||||
CreatedAtUtc = createdAtUtc;
|
||||
UpdatedAtUtc = createdAtUtc;
|
||||
}
|
||||
|
||||
public void Configure(
|
||||
string name,
|
||||
string? monogram,
|
||||
Autonomy autonomy,
|
||||
Guid apiConfigId,
|
||||
Guid? fallbackApiConfigId,
|
||||
List<string> skillKeys,
|
||||
List<string> docs,
|
||||
DateTimeOffset nowUtc)
|
||||
{
|
||||
Name = name;
|
||||
Monogram = monogram;
|
||||
Autonomy = autonomy;
|
||||
ApiConfigId = apiConfigId;
|
||||
FallbackApiConfigId = fallbackApiConfigId;
|
||||
SkillKeys = skillKeys;
|
||||
Docs = docs;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
}
|
||||
@@ -38,4 +38,11 @@ internal sealed class Seat : Entity
|
||||
AgentId = null;
|
||||
State = SeatState.Open;
|
||||
}
|
||||
|
||||
public void AssignAgent(Guid agentId)
|
||||
{
|
||||
AgentId = agentId;
|
||||
MemberId = null;
|
||||
State = SeatState.Ai;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using TeamUp.Modules.OrgBoard.Domain;
|
||||
using TeamUp.SharedKernel.Access;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
||||
|
||||
@@ -30,3 +31,27 @@ internal sealed record TaskResponse(
|
||||
internal sealed record BoardColumn(string Status, IReadOnlyList<TaskResponse> Items);
|
||||
|
||||
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> Columns);
|
||||
|
||||
internal sealed record CreateSeatRequest(Guid TeamId, string RoleName);
|
||||
|
||||
internal sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
|
||||
|
||||
internal sealed record ConfigureAgentRequest(
|
||||
string Name,
|
||||
string? Monogram,
|
||||
Autonomy Autonomy,
|
||||
Guid ApiConfigId,
|
||||
Guid? FallbackApiConfigId,
|
||||
List<string> SkillKeys,
|
||||
List<string> Docs);
|
||||
|
||||
internal sealed record AgentResponse(
|
||||
Guid Id,
|
||||
Guid SeatId,
|
||||
string Name,
|
||||
string? Monogram,
|
||||
string Autonomy,
|
||||
Guid ApiConfigId,
|
||||
Guid? FallbackApiConfigId,
|
||||
List<string> SkillKeys,
|
||||
List<string> Docs);
|
||||
|
||||
@@ -25,6 +25,11 @@ internal static class OrgBoardEndpoints
|
||||
group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization();
|
||||
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
|
||||
group.MapGet("/cartable", Cartable).RequireAuthorization();
|
||||
|
||||
group.MapPost("/seats", CreateSeat).RequireAuthorization();
|
||||
group.MapGet("/seats", ListSeats).RequireAuthorization();
|
||||
group.MapPost("/seats/{id:guid}/agent", ConfigureAgent).RequireAuthorization();
|
||||
group.MapGet("/seats/{id:guid}/agent", GetAgent).RequireAuthorization();
|
||||
}
|
||||
|
||||
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||
@@ -225,4 +230,127 @@ internal static class OrgBoardEndpoints
|
||||
|
||||
return (item, team, null);
|
||||
}
|
||||
|
||||
private static SeatResponse ToSeat(Seat seat) =>
|
||||
new(seat.Id, seat.TeamId, seat.RoleName, seat.State.ToString(), seat.MemberId, seat.AgentId);
|
||||
|
||||
private static AgentResponse ToAgent(Agent agent) => new(
|
||||
agent.Id, agent.SeatId, agent.Name, agent.Monogram, agent.Autonomy.ToString(),
|
||||
agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.Docs);
|
||||
|
||||
private static async Task<IResult> CreateSeat(
|
||||
CreateSeatRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct);
|
||||
if (team is null)
|
||||
{
|
||||
return Results.NotFound("Team not found.");
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.RoleName))
|
||||
{
|
||||
return Results.BadRequest("RoleName is required.");
|
||||
}
|
||||
|
||||
var seat = new Seat(team.Id, request.RoleName.Trim(), SeatState.Open, clock.GetUtcNow());
|
||||
db.Seats.Add(seat);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("seat.created", "Seat", seat.Id, user.MemberId, request.RoleName), ct);
|
||||
return Results.Ok(ToSeat(seat));
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListSeats(
|
||||
Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == teamId, ct);
|
||||
if (team is null)
|
||||
{
|
||||
return Results.NotFound("Team not found.");
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var seats = await db.Seats.Where(s => s.TeamId == teamId).OrderBy(s => s.CreatedAtUtc).ToListAsync(ct);
|
||||
return Results.Ok(seats.Select(ToSeat).ToList());
|
||||
}
|
||||
|
||||
private static async Task<IResult> ConfigureAgent(
|
||||
Guid id, ConfigureAgentRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (seat is null)
|
||||
{
|
||||
return Results.NotFound("Seat not found.");
|
||||
}
|
||||
|
||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct);
|
||||
if (team is null)
|
||||
{
|
||||
return Results.NotFound("Team not found.");
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name) || request.ApiConfigId == Guid.Empty)
|
||||
{
|
||||
return Results.BadRequest("Name and apiConfigId are required.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct);
|
||||
var isNew = agent is null;
|
||||
agent ??= new Agent(seat.Id, now);
|
||||
agent.Configure(
|
||||
request.Name.Trim(), request.Monogram, request.Autonomy, request.ApiConfigId,
|
||||
request.FallbackApiConfigId, request.SkillKeys ?? [], request.Docs ?? [], now);
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
db.Agents.Add(agent);
|
||||
}
|
||||
|
||||
seat.AssignAgent(agent.Id);
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(new AuditEvent("agent.configured", "Agent", agent.Id, user.MemberId, agent.Name), ct);
|
||||
return Results.Ok(ToAgent(agent));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetAgent(
|
||||
Guid id, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
|
||||
{
|
||||
var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (seat is null)
|
||||
{
|
||||
return Results.NotFound("Seat not found.");
|
||||
}
|
||||
|
||||
var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct);
|
||||
if (team is null)
|
||||
{
|
||||
return Results.NotFound("Team not found.");
|
||||
}
|
||||
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct);
|
||||
return agent is null
|
||||
? Results.NotFound("Seat has no agent configured.")
|
||||
: Results.Ok(ToAgent(agent));
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+217
@@ -0,0 +1,217 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(OrgBoardDbContext))]
|
||||
[Migration("20260609200923_AddAgents")]
|
||||
partial class AddAgents
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("orgboard")
|
||||
.HasAnnotation("ProductVersion", "10.0.8")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ApiConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Autonomy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Docs")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Guid?>("FallbackApiConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Monogram")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<Guid>("SeatId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("SkillKeys")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeatId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("agents", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("organizations", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AgentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("MemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("RoleName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId");
|
||||
|
||||
b.ToTable("seats", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OrganizationId");
|
||||
|
||||
b.ToTable("teams", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssigneeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("AssigneeKind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CreatedByMemberId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("character varying(16)");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TeamId");
|
||||
|
||||
b.HasIndex("AssigneeKind", "AssigneeId");
|
||||
|
||||
b.ToTable("work_items", "orgboard");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAgents : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "agents",
|
||||
schema: "orgboard",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SeatId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
|
||||
Monogram = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
|
||||
Autonomy = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
ApiConfigId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
FallbackApiConfigId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
SkillKeys = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
Docs = table.Column<List<string>>(type: "text[]", nullable: false),
|
||||
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_agents", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_agents_SeatId",
|
||||
schema: "orgboard",
|
||||
table: "agents",
|
||||
column: "SeatId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "agents",
|
||||
schema: "orgboard");
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
@@ -1,5 +1,6 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
@@ -23,6 +24,57 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ApiConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Autonomy")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("Docs")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<Guid?>("FallbackApiConfigId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Monogram")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("character varying(8)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("character varying(120)");
|
||||
|
||||
b.Property<Guid>("SeatId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.PrimitiveCollection<List<string>>("SkillKeys")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SeatId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("agents", "orgboard");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
||||
@@ -10,6 +10,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<Team> Teams => Set<Team>();
|
||||
public DbSet<Seat> Seats => Set<Seat>();
|
||||
public DbSet<Agent> Agents => Set<Agent>();
|
||||
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
@@ -40,6 +41,16 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
||||
seat.HasIndex(s => s.TeamId);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Agent>(agent =>
|
||||
{
|
||||
agent.ToTable("agents");
|
||||
agent.HasKey(a => a.Id);
|
||||
agent.Property(a => a.Name).HasMaxLength(120).IsRequired();
|
||||
agent.Property(a => a.Monogram).HasMaxLength(8);
|
||||
agent.Property(a => a.Autonomy).HasConversion<string>().HasMaxLength(20);
|
||||
agent.HasIndex(a => a.SeatId).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<WorkItem>(workItem =>
|
||||
{
|
||||
workItem.ToTable("work_items");
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- Analyzer rules that fight idiomatic test code:
|
||||
CA1707 underscored test names · CA1711 xUnit [CollectionDefinition] "Collection" suffix
|
||||
· xUnit1051 TestContext cancellation token (not needed for these short tests). -->
|
||||
<NoWarn>$(NoWarn);CA1707;CA1711;xUnit1051</NoWarn>
|
||||
<NoWarn>$(NoWarn);CA1707;CA1711;CA1861;xUnit1051</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace TeamUp.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// M3 acceptance: an owner adds a BYOK config, then configures an AI seat ("Aria", gated autonomy,
|
||||
/// a skill, that config) — flipping the seat to AI — without the key ever being exposed.
|
||||
/// </summary>
|
||||
public sealed class SeatConfigTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||
{
|
||||
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||
|
||||
private sealed record OrganizationResponse(Guid Id, string Name);
|
||||
|
||||
private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name);
|
||||
|
||||
private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint);
|
||||
|
||||
private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId);
|
||||
|
||||
private sealed record AgentResponse(
|
||||
Guid Id, Guid SeatId, string Name, string? Monogram, string Autonomy,
|
||||
Guid ApiConfigId, Guid? FallbackApiConfigId, List<string> SkillKeys, List<string> Docs);
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_configures_an_ai_seat_with_skills_autonomy_and_byok_config()
|
||||
{
|
||||
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
||||
using var anon = factory.CreateClient();
|
||||
|
||||
var owner = await Bootstrap(anon);
|
||||
using var client = Authed(factory, owner.Token);
|
||||
|
||||
await PostOk<OrganizationResponse>(client, "/api/orgboard/organizations",
|
||||
new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
|
||||
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams",
|
||||
new { organizationId = owner.OrganizationId, name = "IPNOPS" });
|
||||
|
||||
var config = await PostOk<ApiConfigDto>(client, "/api/integrations/api-configs", new
|
||||
{
|
||||
organizationId = owner.OrganizationId,
|
||||
name = "Vertex-Pro",
|
||||
provider = "stub",
|
||||
model = "gemini-pro",
|
||||
apiKey = "sk-byok-secret",
|
||||
});
|
||||
|
||||
// Create an open seat, then configure an AI agent on it.
|
||||
var seat = await PostOk<SeatResponse>(client, "/api/orgboard/seats",
|
||||
new { teamId = team.Id, roleName = "Product Owner" });
|
||||
Assert.Equal("Open", seat.State);
|
||||
|
||||
var agent = await PostOk<AgentResponse>(client, $"/api/orgboard/seats/{seat.Id}/agent", new
|
||||
{
|
||||
name = "Aria",
|
||||
monogram = "AR",
|
||||
autonomy = "Gated",
|
||||
apiConfigId = config.Id,
|
||||
skillKeys = new[] { "spec-writing", "story-breakdown" },
|
||||
docs = new[] { "product-docs" },
|
||||
});
|
||||
Assert.Equal("Aria", agent.Name);
|
||||
Assert.Equal("Gated", agent.Autonomy);
|
||||
Assert.Equal(config.Id, agent.ApiConfigId);
|
||||
Assert.Contains("spec-writing", agent.SkillKeys);
|
||||
|
||||
// Reading it back returns the same configuration.
|
||||
var fetched = await client.GetFromJsonAsync<AgentResponse>($"/api/orgboard/seats/{seat.Id}/agent");
|
||||
Assert.Equal(agent.Id, fetched!.Id);
|
||||
|
||||
// The seat is now an AI seat pointing at the agent.
|
||||
var seats = await client.GetFromJsonAsync<List<SeatResponse>>($"/api/orgboard/seats?teamId={team.Id}");
|
||||
var aiSeat = seats!.Single(s => s.Id == seat.Id);
|
||||
Assert.Equal("Ai", aiSeat.State);
|
||||
Assert.Equal(agent.Id, aiSeat.AgentId);
|
||||
}
|
||||
|
||||
private static async Task<BootstrapResponse> Bootstrap(HttpClient client)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new
|
||||
{
|
||||
organizationName = "AliaSaaS",
|
||||
ownerEmail = "owner@alia.test",
|
||||
ownerDisplayName = "Owner",
|
||||
ownerPassword = "Passw0rd!",
|
||||
});
|
||||
var owner = await response.Content.ReadFromJsonAsync<BootstrapResponse>();
|
||||
Assert.NotNull(owner);
|
||||
return owner!;
|
||||
}
|
||||
|
||||
private static HttpClient Authed(TeamUpWebFactory factory, string token)
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<T> PostOk<T>(HttpClient client, string url, object body)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(url, body);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var value = await response.Content.ReadFromJsonAsync<T>();
|
||||
Assert.NotNull(value);
|
||||
return value!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user