using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using w4c_workflows.Data; using w4c_workflows.Filters; using w4c_workflows.Models; namespace w4c_workflows.Controllers; /// /// Run history + detail. Authenticated with the tenant's operator key (see /// AuthMiddleware). The run lifecycle engine (step 9) is the writer; this /// surface is the read side backing the Workflows UI (step 11). /// [ApiController] [Route("api/runs")] public class RunsController : ControllerBase { public sealed record RunSummary( Guid Id, Guid WorkflowId, string WorkflowName, string Status, string? CorrelationId, DateTime? StartedAt, DateTime? FinishedAt, string? Error); public sealed record RunDetail( Guid Id, Guid WorkflowId, string WorkflowName, string WorkflowMode, string Status, string? TriggerJson, string? InputJson, string? OutputJson, string? Error, string? CorrelationId, DateTime? StartedAt, DateTime? FinishedAt); public sealed record RunListResponse(int Total, IReadOnlyList Runs); public sealed record TaskRunSummary( Guid Id, Guid TaskId, string TaskKey, int Attempt, string Status, int RetryCount, bool IsCompensation, string? Error, DateTime? StartedAt, DateTime? FinishedAt); private readonly WorkflowsDbContext _db; public RunsController(WorkflowsDbContext db) { _db = db; } private string TenantId => (string?)HttpContext.Items["TenantId"] ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); /// /// Lists the tenant's runs, newest first, optionally filtered to one workflow. /// [HttpGet] [RequireScope("read")] public async Task List( [FromQuery] Guid? workflowId, [FromQuery] int limit = 50, [FromQuery] int offset = 0, CancellationToken ct = default) { limit = Math.Clamp(limit, 1, 200); offset = Math.Max(0, offset); var query = _db.WorkflowRuns.Where(r => r.TenantId == TenantId); if (workflowId != null) query = query.Where(r => r.WorkflowId == workflowId); var total = await query.CountAsync(ct); var runs = await query .OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue) .ThenByDescending(r => r.Id) .Skip(offset) .Take(limit) .Join(_db.Workflows, r => r.WorkflowId, w => w.Id, (r, w) => new { r, w.Name }) .Select(x => new RunSummary( x.r.Id, x.r.WorkflowId, x.Name, x.r.Status, x.r.CorrelationId, x.r.StartedAt, x.r.FinishedAt, x.r.Error)) .ToListAsync(ct); return Ok(new RunListResponse(total, runs)); } /// Returns one run plus its task-run summary. [HttpGet("{id:guid}")] [RequireScope("read")] public async Task Get(Guid id, CancellationToken ct) { var detail = await _db.WorkflowRuns .Where(r => r.Id == id && r.TenantId == TenantId) .Join(_db.Workflows, r => r.WorkflowId, w => w.Id, (r, w) => new { r, w.Name, w.Mode }) .Select(x => new RunDetail( x.r.Id, x.r.WorkflowId, x.Name, x.Mode, x.r.Status, x.r.TriggerJson, x.r.InputJson, x.r.OutputJson, x.r.Error, x.r.CorrelationId, x.r.StartedAt, x.r.FinishedAt)) .FirstOrDefaultAsync(ct); if (detail == null) return NotFound(new { error = "Run not found." }); return Ok(detail); } /// Returns the task runs of one run, in dispatch order. [HttpGet("{id:guid}/tasks")] [RequireScope("read")] public async Task Tasks(Guid id, CancellationToken ct) { var owned = await _db.WorkflowRuns.AnyAsync(r => r.Id == id && r.TenantId == TenantId, ct); if (!owned) return NotFound(new { error = "Run not found." }); var tasks = await _db.TaskRuns .Where(t => t.RunId == id) .OrderBy(t => t.StartedAt) .ThenBy(t => t.Attempt) .Join(_db.Tasks, t => t.TaskId, task => task.Id, (t, task) => new { t, task.Key }) .Select(x => new TaskRunSummary( x.t.Id, x.t.TaskId, x.Key, x.t.Attempt, x.t.Status, x.t.RetryCount, x.t.IsCompensation, x.t.Error, x.t.StartedAt, x.t.FinishedAt)) .ToListAsync(ct); return Ok(tasks); } }