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;
|
AgentId = null;
|
||||||
State = SeatState.Open;
|
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.Modules.OrgBoard.Domain;
|
||||||
|
using TeamUp.SharedKernel.Access;
|
||||||
|
|
||||||
namespace TeamUp.Modules.OrgBoard.Endpoints;
|
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 BoardColumn(string Status, IReadOnlyList<TaskResponse> Items);
|
||||||
|
|
||||||
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> Columns);
|
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}/move", MoveTask).RequireAuthorization();
|
||||||
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
|
group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization();
|
||||||
group.MapGet("/cartable", Cartable).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(
|
private static TaskResponse ToResponse(WorkItem item) => new(
|
||||||
@@ -225,4 +230,127 @@ internal static class OrgBoardEndpoints
|
|||||||
|
|
||||||
return (item, team, null);
|
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 />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
@@ -23,6 +24,57 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
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 =>
|
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
|||||||
public DbSet<Organization> Organizations => Set<Organization>();
|
public DbSet<Organization> Organizations => Set<Organization>();
|
||||||
public DbSet<Team> Teams => Set<Team>();
|
public DbSet<Team> Teams => Set<Team>();
|
||||||
public DbSet<Seat> Seats => Set<Seat>();
|
public DbSet<Seat> Seats => Set<Seat>();
|
||||||
|
public DbSet<Agent> Agents => Set<Agent>();
|
||||||
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
@@ -40,6 +41,16 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
|
|||||||
seat.HasIndex(s => s.TeamId);
|
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 =>
|
modelBuilder.Entity<WorkItem>(workItem =>
|
||||||
{
|
{
|
||||||
workItem.ToTable("work_items");
|
workItem.ToTable("work_items");
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- Analyzer rules that fight idiomatic test code:
|
<!-- Analyzer rules that fight idiomatic test code:
|
||||||
CA1707 underscored test names · CA1711 xUnit [CollectionDefinition] "Collection" suffix
|
CA1707 underscored test names · CA1711 xUnit [CollectionDefinition] "Collection" suffix
|
||||||
· xUnit1051 TestContext cancellation token (not needed for these short tests). -->
|
· xUnit1051 TestContext cancellation token (not needed for these short tests). -->
|
||||||
<NoWarn>$(NoWarn);CA1707;CA1711;xUnit1051</NoWarn>
|
<NoWarn>$(NoWarn);CA1707;CA1711;CA1861;xUnit1051</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<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