using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TeamUp.Modules.OrgBoard.Domain;
using TeamUp.Modules.OrgBoard.Persistence;
using TeamUp.SharedKernel.Ai;
using TeamUp.SharedKernel.Auditing;
namespace TeamUp.Modules.OrgBoard.Runtime;
///
/// The single V1 event trigger: a task hitting done emits a handoff that creates a QA task
/// (with provenance) for the team's QA AI seat and dispatches a run — the boundary is a pipe, not
/// a gate; the QA agent then acts per its OWN autonomy. Guardrails: QA/Review tasks never
/// re-trigger (no self-cascade), and a task hands off at most once (the duplicate check is the
/// V1 rate limit). The richer event mesh is Phase 1+.
///
internal sealed class QaHandoffTrigger(
OrgBoardDbContext db,
IAgentDispatcher dispatcher,
IAuditLog audit,
TimeProvider clock,
ILogger logger)
{
private const string QaSkillKey = "test-plan-generation";
public async Task OnTaskDoneAsync(WorkItem item, Guid actorMemberId, CancellationToken cancellationToken = default)
{
// No self-cascade: QA's own output never wakes QA again.
if (item.Type is WorkItemType.Test or WorkItemType.Review)
{
return;
}
// At most one handoff per task.
if (await db.WorkItems.AnyAsync(w => w.ParentId == item.Id && w.Type == WorkItemType.Test, cancellationToken))
{
return;
}
// The receiving seat: an AI seat on this team equipped with the QA skill.
var seat = await (
from s in db.Seats
join a in db.Agents on s.Id equals a.SeatId
where s.TeamId == item.TeamId && s.State == SeatState.Ai && a.SkillKeys.Contains(QaSkillKey)
orderby s.CreatedAtUtc
select s).FirstOrDefaultAsync(cancellationToken);
if (seat is null)
{
return; // no QA AI seat — nothing to hand off to
}
var now = clock.GetUtcNow();
var qaTask = new WorkItem(
item.TeamId,
"QA: " + item.Title,
"Handoff: \"" + item.Title + "\" hit done. Draft the test plan.",
WorkItemType.Test,
actorMemberId,
now,
parentId: item.Id);
if (seat.AgentId is { } agentId)
{
qaTask.AssignToAgent(agentId, now);
}
db.WorkItems.Add(qaTask);
await db.SaveChangesAsync(cancellationToken);
var runId = await dispatcher.DispatchAsync(seat.Id, qaTask.Id, cancellationToken);
await audit.WriteAsync(
new AuditEvent("handoff.triggered", "WorkItem", qaTask.Id, actorMemberId,
$"\"{item.Title}\" done → QA run {runId}"),
cancellationToken);
logger.LogInformation(
"PO→QA handoff: task {TaskId} done → QA task {QaTaskId}, run {RunId}.", item.Id, qaTask.Id, runId);
}
}