clean and last run and count

This commit is contained in:
Vitali sharp8n 2026-09-03 21:37:55 +03:00
parent 17d4515961
commit 0d71f39245
2 changed files with 207 additions and 2 deletions

View file

@ -86,8 +86,9 @@ public class WorkflowsController : ControllerBase
}) })
.ToListAsync(ct); .ToListAsync(ct);
// Latest run per workflow (status + finish time) so the list can surface // Latest run per workflow (status, time + duration) and total run count so
// the last execution in one column without a round-trip per row. // 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 ids = workflows.Select(w => w.Id).ToList();
var lastRuns = await _db.WorkflowRuns var lastRuns = await _db.WorkflowRuns
.Where(r => r.TenantId == TenantId && ids.Contains(r.WorkflowId)) .Where(r => r.TenantId == TenantId && ids.Contains(r.WorkflowId))
@ -95,6 +96,7 @@ public class WorkflowsController : ControllerBase
.Select(g => new .Select(g => new
{ {
WorkflowId = g.Key, WorkflowId = g.Key,
RunCount = g.Count(),
Status = g Status = g
.OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue) .OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue)
.ThenByDescending(r => r.Id) .ThenByDescending(r => r.Id)
@ -137,8 +139,12 @@ public class WorkflowsController : ControllerBase
w.Version, w.Version,
w.taskCount, w.taskCount,
w.upToDate, w.upToDate,
runCount = last?.RunCount ?? 0,
lastRunStatus = last?.Status, lastRunStatus = last?.Status,
lastRunAt = last?.FinishedAt ?? last?.StartedAt, lastRunAt = last?.FinishedAt ?? last?.StartedAt,
lastRunDuration = last is { StartedAt: DateTime s, FinishedAt: DateTime f }
? (f - s).TotalSeconds
: (double?)null,
lastRunError = last?.Error, lastRunError = last?.Error,
}; };
}); });
@ -429,4 +435,48 @@ public class WorkflowsController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message }); return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message });
} }
} }
/// <summary>
/// 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.
/// </summary>
[HttpDelete("{id:guid}/runs")]
[RequireScope("manage")]
public async Task<IActionResult> 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);
} }

View file

@ -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<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<WorkflowFile>>(Array.Empty<WorkflowFile>());
public Task<string> 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<WorkflowSyncService>.Instance);
var controller = new WorkflowsController(
db,
sync,
new FakeWorkflowSource(),
new MermaidGeneratorService(),
new WorkflowHtmlRenderer(NullLogger<WorkflowHtmlRenderer>.Instance),
new RenderTokenService(new ConfigurationBuilder().Build(), NullLogger<RenderTokenService>.Instance),
new RunLauncher(db, NullLogger<RunLauncher>.Instance),
new DurableStateStore(db),
NullLogger<WorkflowsController>.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<OkObjectResult>(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<OkObjectResult>(await Controller(db, tenantId).ClearRuns(workflow.Id, default));
var body = Assert.IsType<ClearRunsResponse>(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<NotFoundObjectResult>(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<OkObjectResult>(await Controller(db, tenantId).ClearRuns(workflow.Id, default));
var body = Assert.IsType<ClearRunsResponse>(result.Value);
Assert.Equal(0, body.Cleared);
}
}