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);
+ }
+}