142 lines
4.7 KiB
C#
142 lines
4.7 KiB
C#
|
|
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);
|
||
|
|
}
|
||
|
|
}
|