w4c-workflows-api/Controllers/RunsController.cs

142 lines
4.7 KiB
C#
Raw Normal View History

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using w4c_workflows.Data;
using w4c_workflows.Filters;
using w4c_workflows.Models;
namespace w4c_workflows.Controllers;
/// <summary>
/// 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).
/// </summary>
[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<RunSummary> 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");
/// <summary>
/// Lists the tenant's runs, newest first, optionally filtered to one workflow.
/// </summary>
[HttpGet]
[RequireScope("read")]
public async Task<IActionResult> 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));
}
/// <summary>Returns one run plus its task-run summary.</summary>
[HttpGet("{id:guid}")]
[RequireScope("read")]
public async Task<IActionResult> 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);
}
/// <summary>Returns the task runs of one run, in dispatch order.</summary>
[HttpGet("{id:guid}/tasks")]
[RequireScope("read")]
public async Task<IActionResult> 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);
}
}