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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user