diff --git a/Controllers/WorkflowsController.cs b/Controllers/WorkflowsController.cs index f372a76..d2a3b4c 100644 --- a/Controllers/WorkflowsController.cs +++ b/Controllers/WorkflowsController.cs @@ -86,8 +86,9 @@ public class WorkflowsController : ControllerBase }) .ToListAsync(ct); - // Latest run per workflow (status + finish time) so the list can surface - // the last execution in one column without a round-trip per row. + // Latest run per workflow (status, time + duration) and total run count so + // the list can surface the last execution and execution count without a + // round-trip per row. var ids = workflows.Select(w => w.Id).ToList(); var lastRuns = await _db.WorkflowRuns .Where(r => r.TenantId == TenantId && ids.Contains(r.WorkflowId)) @@ -95,6 +96,7 @@ public class WorkflowsController : ControllerBase .Select(g => new { WorkflowId = g.Key, + RunCount = g.Count(), Status = g .OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue) .ThenByDescending(r => r.Id) @@ -137,8 +139,12 @@ public class WorkflowsController : ControllerBase w.Version, w.taskCount, w.upToDate, + runCount = last?.RunCount ?? 0, lastRunStatus = last?.Status, lastRunAt = last?.FinishedAt ?? last?.StartedAt, + lastRunDuration = last is { StartedAt: DateTime s, FinishedAt: DateTime f } + ? (f - s).TotalSeconds + : (double?)null, lastRunError = last?.Error, }; }); @@ -429,4 +435,48 @@ public class WorkflowsController : ControllerBase return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); } } + + /// + /// Clears the run history for one workflow: deletes every run (and, via the + /// FK cascade, its task runs). The workflow definition, its tasks and its + /// trigger are untouched — only the history is wiped. Returns the number of + /// runs removed. + /// + [HttpDelete("{id:guid}/runs")] + [RequireScope("manage")] + public async Task ClearRuns(Guid id, CancellationToken ct) + { + var owned = await _db.Workflows.AnyAsync(w => w.Id == id && w.TenantId == TenantId, ct); + if (!owned) + return NotFound(new { error = "Workflow not found." }); + + var runIds = await _db.WorkflowRuns + .Where(r => r.WorkflowId == id && r.TenantId == TenantId) + .Select(r => r.Id) + .ToListAsync(ct); + + if (runIds.Count > 0) + { + // Remove task runs explicitly first so EF does not depend on the DB + // cascade for the (already configured) FK; then remove the runs. + var taskRuns = await _db.TaskRuns + .Where(t => runIds.Contains(t.RunId)) + .ToListAsync(ct); + _db.TaskRuns.RemoveRange(taskRuns); + + var runs = await _db.WorkflowRuns + .Where(r => runIds.Contains(r.Id)) + .ToListAsync(ct); + _db.WorkflowRuns.RemoveRange(runs); + + await _db.SaveChangesAsync(ct); + } + + _logger.LogInformation( + "Cleared {Count} run(s) for workflow {WorkflowId} (tenant {TenantId})", + runIds.Count, id, TenantId); + return Ok(new ClearRunsResponse(runIds.Count)); + } + + public sealed record ClearRunsResponse(int Cleared); } diff --git a/w4c-workflows-api.Tests/WorkflowsControllerListAndClearTests.cs b/w4c-workflows-api.Tests/WorkflowsControllerListAndClearTests.cs new file mode 100644 index 0000000..b4ab049 --- /dev/null +++ b/w4c-workflows-api.Tests/WorkflowsControllerListAndClearTests.cs @@ -0,0 +1,155 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using w4c_workflows.Controllers; +using w4c_workflows.Data; +using w4c_workflows.Models; +using w4c_workflows.Services; +using w4c_workflows.Services.Runs; +using Xunit; + +using ClearRunsResponse = w4c_workflows.Controllers.WorkflowsController.ClearRunsResponse; + +namespace w4c_workflows.Tests; + +[Collection("WorkflowsPostgres")] +public class WorkflowsControllerListAndClearTests +{ + private const string YamlContent = "name: linear\nmode: function\nlanguage: shell\n"; + + private sealed class FakeWorkflowSource : IWorkflowSource + { + public Task> ListAsync(CancellationToken ct) + => Task.FromResult>(Array.Empty()); + + public Task ReadAsync(string path, CancellationToken ct) + => Task.FromResult(YamlContent); + + public WorkflowSourceState GetState() => new(null, false); + } + + private readonly WorkflowsPostgresFixture _fixture; + + public WorkflowsControllerListAndClearTests(WorkflowsPostgresFixture fixture) + { + _fixture = fixture; + } + + private static WorkflowsController Controller(WorkflowsDbContext db, string tenantId) + { + var sync = new WorkflowSyncService( + db, + new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry())), + new FakeWorkflowSource(), + NullLogger.Instance); + + var controller = new WorkflowsController( + db, + sync, + new FakeWorkflowSource(), + new MermaidGeneratorService(), + new WorkflowHtmlRenderer(NullLogger.Instance), + new RenderTokenService(new ConfigurationBuilder().Build(), NullLogger.Instance), + new RunLauncher(db, NullLogger.Instance), + new DurableStateStore(db), + NullLogger.Instance) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, + }; + controller.HttpContext.Items["TenantId"] = tenantId; + return controller; + } + + private static string Tenant() => "t" + Guid.NewGuid().ToString("N")[..12]; + + [Fact] + public async Task List_returns_run_count_and_last_run_duration() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml); + + // Two finished runs + one in-flight run (started longest ago so the latest + // run by time is the finished one) for the same workflow. + WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Succeeded, + startedAt: DateTime.UtcNow.AddMinutes(-5), finishedAt: DateTime.UtcNow.AddMinutes(-4)); + WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Failed, + startedAt: DateTime.UtcNow.AddMinutes(-2), finishedAt: DateTime.UtcNow.AddMinutes(-1), error: "boom"); + WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Running, startedAt: DateTime.UtcNow.AddMinutes(-10)); + + // A workflow with no runs must default to 0 / null, not throw. + var empty = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.DurableYaml, "workflows/b.yaml"); + + var result = Assert.IsType(await Controller(db, tenantId).List(default)); + var json = JsonSerializer.Serialize(result.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var first = root.GetProperty("workflows").EnumerateArray() + .Single(w => w.GetProperty("id").GetGuid() == workflow.Id); + // The latest run is the failed one (started -2m), so duration = 60s. + Assert.Equal(3, first.GetProperty("runCount").GetInt32()); + Assert.Equal(RunStatus.Failed, first.GetProperty("lastRunStatus").GetString()); + Assert.Equal(60, first.GetProperty("lastRunDuration").GetDouble(), 1); + + var second = root.GetProperty("workflows").EnumerateArray() + .Single(w => w.GetProperty("id").GetGuid() == empty.Id); + Assert.Equal(0, second.GetProperty("runCount").GetInt32()); + Assert.Equal(JsonValueKind.Null, second.GetProperty("lastRunDuration").ValueKind); + Assert.Equal(JsonValueKind.Null, second.GetProperty("lastRunStatus").ValueKind); + } + + [Fact] + public async Task ClearRuns_deletes_runs_and_task_runs_for_the_workflow() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml); + var done = WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Succeeded, + DateTime.UtcNow.AddMinutes(-5), DateTime.UtcNow.AddMinutes(-4)); + var failed = WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Failed, + DateTime.UtcNow.AddMinutes(-2), DateTime.UtcNow.AddMinutes(-1), error: "boom"); + + var root = WorkflowDataHelpers.TaskByKey(workflow, "root"); + db.TaskRuns.AddRange( + new TaskRun { Id = Guid.NewGuid(), RunId = done.Id, TaskId = root.Id, Attempt = 1, Status = TaskRunStatus.Succeeded }, + new TaskRun { Id = Guid.NewGuid(), RunId = failed.Id, TaskId = root.Id, Attempt = 1, Status = TaskRunStatus.Failed }); + db.SaveChanges(); + + var result = Assert.IsType(await Controller(db, tenantId).ClearRuns(workflow.Id, default)); + var body = Assert.IsType(result.Value); + Assert.Equal(2, body.Cleared); + + Assert.Empty(await db.WorkflowRuns.Where(r => r.WorkflowId == workflow.Id).ToListAsync()); + Assert.Empty(await db.TaskRuns.Where(t => t.RunId == done.Id || t.RunId == failed.Id).ToListAsync()); + // The workflow definition itself is untouched. + Assert.True(await db.Workflows.AnyAsync(w => w.Id == workflow.Id)); + } + + [Fact] + public async Task ClearRuns_404s_for_foreign_workflow() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var other = Tenant(); + var workflow = WorkflowDataHelpers.CompileAndSave(db, other, WorkflowDataHelpers.LinearYaml); + + var result = await Controller(db, tenantId).ClearRuns(workflow.Id, default); + Assert.IsType(result); + } + + [Fact] + public async Task ClearRuns_is_idempotent_when_no_runs_exist() + { + var tenantId = Tenant(); + await using var db = _fixture.CreateContext(); + var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml); + + var result = Assert.IsType(await Controller(db, tenantId).ClearRuns(workflow.Id, default)); + var body = Assert.IsType(result.Value); + Assert.Equal(0, body.Cleared); + } +}