using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.EntityFrameworkCore; using TeamUp.Modules.Assembler.Domain; using TeamUp.Modules.Assembler.Persistence; using TeamUp.SharedKernel.Ai; using TeamUp.SharedKernel.Modularity; namespace TeamUp.Modules.Assembler.Endpoints; internal static class AssemblerEndpoints { public static void Map(IEndpointRouteBuilder endpoints) { var group = endpoints.MapGroup("/api/assembler").WithTags("Assembler"); group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("assembler"))); group.MapPost("/runs", CreateRun).RequireAuthorization(); group.MapGet("/runs/{id:guid}", GetRun).RequireAuthorization(); group.MapGet("/agent-activity", GetAgentActivity).RequireAuthorization(); } // The live pulse behind each agent's face: the latest run status per agent. The client passes the // ids of the AI seats it is showing (it already holds them) and composes the on-screen face state — // this keeps the module boundary clean (Assembler owns runs; it never reaches into seats/teams). private static async Task GetAgentActivity( string? agentIds, AssemblerDbContext db, CancellationToken ct) { var ids = (agentIds ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(s => Guid.TryParse(s, out var g) ? g : (Guid?)null) .Where(g => g.HasValue) .Select(g => g!.Value) .Distinct() .ToList(); if (ids.Count == 0) { return Results.Ok(Array.Empty()); } // Latest run per agent. Project the few columns we need, then pick the newest per agent in // memory — at dogfood scale this is a small set and avoids brittle GroupBy translation. var runs = await db.AgentRuns .Where(r => r.AgentId != null && ids.Contains(r.AgentId!.Value)) .Select(r => new { AgentId = r.AgentId!.Value, r.Status, r.WorkItemId, r.CreatedAtUtc, r.CompletedAtUtc, }) .ToListAsync(ct); var activity = runs .GroupBy(r => r.AgentId) .Select(g => g.OrderByDescending(r => r.CreatedAtUtc).First()) .Select(r => new AgentActivityResponse( r.AgentId, r.Status.ToString(), r.WorkItemId, r.CompletedAtUtc ?? r.CreatedAtUtc)) .ToList(); return Results.Ok(activity); } // Dispatch a task to an AI seat: record a queued AgentRun and enqueue the job. The worker // drains it off the request path. Shares AgentRunDispatcher with the board triggers. private static async Task CreateRun( CreateRunRequest request, IAgentDispatcher dispatcher, AssemblerDbContext db, CancellationToken ct) { var runId = await dispatcher.DispatchAsync(request.SeatId, request.WorkItemId, ct); var run = await db.AgentRuns.FirstAsync(r => r.Id == runId, ct); return Results.Ok(ToResponse(run)); } private static async Task GetRun(Guid id, AssemblerDbContext db, CancellationToken ct) { var run = await db.AgentRuns.FirstOrDefaultAsync(r => r.Id == id, ct); return run is null ? Results.NotFound() : Results.Ok(ToResponse(run)); } private static RunResponse ToResponse(AgentRun run) => new( run.Id, run.SeatId, run.WorkItemId, run.AgentId, run.Status.ToString(), run.ActionType, run.ActionRisk, run.Prompt, run.Output, run.Error, run.Trace, run.ResultJson, run.LatencyMs, run.CreatedAtUtc, run.CompletedAtUtc); }