2026-09-01 16:37:53 +00:00
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using w4c_workflows.Data;
|
|
|
|
|
using w4c_workflows.Filters;
|
|
|
|
|
using w4c_workflows.Models;
|
|
|
|
|
using w4c_workflows.Services;
|
|
|
|
|
using w4c_workflows.Services.Runs;
|
|
|
|
|
using YamlDotNet.Serialization;
|
|
|
|
|
using YamlDotNet.Serialization.NamingConventions;
|
|
|
|
|
|
|
|
|
|
namespace w4c_workflows.Controllers;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Workflow definitions + git sync. All endpoints authenticate with the
|
|
|
|
|
/// tenant's operator key (see AuthMiddleware).
|
|
|
|
|
/// </summary>
|
|
|
|
|
[ApiController]
|
|
|
|
|
[Route("api/workflows")]
|
|
|
|
|
public class WorkflowsController : ControllerBase
|
|
|
|
|
{
|
|
|
|
|
private readonly WorkflowsDbContext _db;
|
|
|
|
|
private readonly WorkflowSyncService _sync;
|
|
|
|
|
private readonly IWorkflowSource _source;
|
|
|
|
|
private readonly MermaidGeneratorService _mermaid;
|
|
|
|
|
private readonly WorkflowHtmlRenderer _html;
|
|
|
|
|
private readonly RenderTokenService _renderTokens;
|
|
|
|
|
private readonly IRunLauncher _launcher;
|
|
|
|
|
private readonly DurableStateStore _durable;
|
|
|
|
|
private readonly IDeserializer _yaml;
|
|
|
|
|
private readonly ILogger<WorkflowsController> _logger;
|
|
|
|
|
|
|
|
|
|
public WorkflowsController(
|
|
|
|
|
WorkflowsDbContext db,
|
|
|
|
|
WorkflowSyncService sync,
|
|
|
|
|
IWorkflowSource source,
|
|
|
|
|
MermaidGeneratorService mermaid,
|
|
|
|
|
WorkflowHtmlRenderer html,
|
|
|
|
|
RenderTokenService renderTokens,
|
|
|
|
|
IRunLauncher launcher,
|
|
|
|
|
DurableStateStore durable,
|
|
|
|
|
ILogger<WorkflowsController> logger)
|
|
|
|
|
{
|
|
|
|
|
_db = db;
|
|
|
|
|
_sync = sync;
|
|
|
|
|
_source = source;
|
|
|
|
|
_mermaid = mermaid;
|
|
|
|
|
_html = html;
|
|
|
|
|
_renderTokens = renderTokens;
|
|
|
|
|
_launcher = launcher;
|
|
|
|
|
_durable = durable;
|
|
|
|
|
_logger = logger;
|
|
|
|
|
_yaml = new DeserializerBuilder()
|
|
|
|
|
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
|
|
|
|
.IgnoreUnmatchedProperties()
|
|
|
|
|
.Build();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private string TenantId => (string?)HttpContext.Items["TenantId"]
|
|
|
|
|
?? throw new InvalidOperationException("TenantId not resolved by auth middleware");
|
|
|
|
|
|
|
|
|
|
/// <summary>Lists compiled workflows with git sync status + latest run.</summary>
|
|
|
|
|
[HttpGet]
|
|
|
|
|
[RequireScope("read")]
|
|
|
|
|
public async Task<IActionResult> List(CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var state = _source.GetState();
|
|
|
|
|
var workflows = await _db.Workflows
|
|
|
|
|
.Where(w => w.TenantId == TenantId)
|
|
|
|
|
.OrderBy(w => w.Name)
|
|
|
|
|
.Select(w => new
|
|
|
|
|
{
|
|
|
|
|
w.Id,
|
|
|
|
|
w.Name,
|
|
|
|
|
w.Path,
|
|
|
|
|
w.GitSha,
|
|
|
|
|
w.Status,
|
|
|
|
|
w.Mode,
|
|
|
|
|
w.Target,
|
|
|
|
|
w.TriggerEnabled,
|
|
|
|
|
w.TriggerJson,
|
|
|
|
|
w.CompiledAt,
|
|
|
|
|
w.UpdatedAt,
|
|
|
|
|
w.Version,
|
|
|
|
|
taskCount = w.Tasks.Count,
|
|
|
|
|
upToDate = w.GitSha == state.HeadSha,
|
|
|
|
|
})
|
|
|
|
|
.ToListAsync(ct);
|
|
|
|
|
|
2026-09-03 18:37:55 +00:00
|
|
|
// 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.
|
2026-09-01 16:37:53 +00:00
|
|
|
var ids = workflows.Select(w => w.Id).ToList();
|
|
|
|
|
var lastRuns = await _db.WorkflowRuns
|
|
|
|
|
.Where(r => r.TenantId == TenantId && ids.Contains(r.WorkflowId))
|
|
|
|
|
.GroupBy(r => r.WorkflowId)
|
|
|
|
|
.Select(g => new
|
|
|
|
|
{
|
|
|
|
|
WorkflowId = g.Key,
|
2026-09-03 18:37:55 +00:00
|
|
|
RunCount = g.Count(),
|
2026-09-01 16:37:53 +00:00
|
|
|
Status = g
|
|
|
|
|
.OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue)
|
|
|
|
|
.ThenByDescending(r => r.Id)
|
|
|
|
|
.Select(r => r.Status)
|
|
|
|
|
.FirstOrDefault(),
|
|
|
|
|
StartedAt = g
|
|
|
|
|
.OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue)
|
|
|
|
|
.ThenByDescending(r => r.Id)
|
|
|
|
|
.Select(r => r.StartedAt)
|
|
|
|
|
.FirstOrDefault(),
|
|
|
|
|
FinishedAt = g
|
|
|
|
|
.OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue)
|
|
|
|
|
.ThenByDescending(r => r.Id)
|
|
|
|
|
.Select(r => r.FinishedAt)
|
|
|
|
|
.FirstOrDefault(),
|
|
|
|
|
Error = g
|
|
|
|
|
.OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue)
|
|
|
|
|
.ThenByDescending(r => r.Id)
|
|
|
|
|
.Select(r => r.Error)
|
|
|
|
|
.FirstOrDefault(),
|
|
|
|
|
})
|
|
|
|
|
.ToDictionaryAsync(x => x.WorkflowId, ct);
|
|
|
|
|
|
|
|
|
|
var result = workflows.Select(w =>
|
|
|
|
|
{
|
|
|
|
|
lastRuns.TryGetValue(w.Id, out var last);
|
|
|
|
|
return new
|
|
|
|
|
{
|
|
|
|
|
w.Id,
|
|
|
|
|
w.Name,
|
|
|
|
|
w.Path,
|
|
|
|
|
w.GitSha,
|
|
|
|
|
w.Status,
|
|
|
|
|
w.Mode,
|
|
|
|
|
w.Target,
|
|
|
|
|
w.TriggerEnabled,
|
|
|
|
|
w.TriggerJson,
|
|
|
|
|
w.CompiledAt,
|
|
|
|
|
w.UpdatedAt,
|
|
|
|
|
w.Version,
|
|
|
|
|
w.taskCount,
|
|
|
|
|
w.upToDate,
|
2026-09-03 18:37:55 +00:00
|
|
|
runCount = last?.RunCount ?? 0,
|
2026-09-01 16:37:53 +00:00
|
|
|
lastRunStatus = last?.Status,
|
|
|
|
|
lastRunAt = last?.FinishedAt ?? last?.StartedAt,
|
2026-09-03 18:37:55 +00:00
|
|
|
lastRunDuration = last is { StartedAt: DateTime s, FinishedAt: DateTime f }
|
|
|
|
|
? (f - s).TotalSeconds
|
|
|
|
|
: (double?)null,
|
2026-09-01 16:37:53 +00:00
|
|
|
lastRunError = last?.Error,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Ok(new { headSha = state.HeadSha, dirty = state.Dirty, workflows = result });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Returns the workflow definition, its compiled task tree, and the raw YAML
|
|
|
|
|
/// source from git (for the Monaco task editor). The YAML is the
|
|
|
|
|
/// authoritative source; edits are saved back via the Source Code API and
|
|
|
|
|
/// recompiled with <c>POST /api/workflows/sync</c>.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpGet("{id:guid}")]
|
|
|
|
|
[RequireScope("read")]
|
|
|
|
|
public async Task<IActionResult> Get(Guid id, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var workflow = await _db.Workflows
|
|
|
|
|
.Include(w => w.Tasks)
|
|
|
|
|
.FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct);
|
|
|
|
|
if (workflow == null)
|
|
|
|
|
return NotFound(new { error = "Workflow not found." });
|
|
|
|
|
|
|
|
|
|
string? yaml = null;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
yaml = await _source.ReadAsync(workflow.Path, ct);
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning(ex, "Failed to read workflow source {Path}", workflow.Path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Ok(new
|
|
|
|
|
{
|
|
|
|
|
workflow.Id,
|
|
|
|
|
workflow.Name,
|
|
|
|
|
workflow.Path,
|
|
|
|
|
workflow.Mode,
|
|
|
|
|
workflow.Target,
|
|
|
|
|
workflow.Status,
|
|
|
|
|
workflow.TriggerEnabled,
|
|
|
|
|
workflow.TriggerJson,
|
|
|
|
|
workflow.CompiledAt,
|
|
|
|
|
workflow.UpdatedAt,
|
|
|
|
|
workflow.GitSha,
|
|
|
|
|
workflow.Version,
|
|
|
|
|
yaml,
|
|
|
|
|
tasks = workflow.Tasks
|
|
|
|
|
.OrderBy(t => t.Order)
|
|
|
|
|
.Select(t => new
|
|
|
|
|
{
|
|
|
|
|
t.Id,
|
|
|
|
|
t.Key,
|
|
|
|
|
t.ParentId,
|
|
|
|
|
t.NextId,
|
|
|
|
|
t.OnErrorId,
|
|
|
|
|
t.Language,
|
|
|
|
|
t.Mode,
|
|
|
|
|
t.EntryJson,
|
|
|
|
|
t.EnvJson,
|
|
|
|
|
t.Order,
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Returns Mermaid source for a workflow diagram type.</summary>
|
|
|
|
|
[HttpGet("{id:guid}/diagrams/{type}")]
|
|
|
|
|
[RequireScope("read")]
|
|
|
|
|
public async Task<IActionResult> Diagram(Guid id, string type, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
if (!MermaidGeneratorService.Types.Contains(type))
|
|
|
|
|
return BadRequest(new { error = $"Unknown diagram type '{type}'. Expected one of {string.Join(", ", MermaidGeneratorService.Types)}." });
|
|
|
|
|
|
|
|
|
|
var workflow = await _db.Workflows
|
|
|
|
|
.Include(w => w.Tasks)
|
|
|
|
|
.FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct);
|
|
|
|
|
if (workflow == null)
|
|
|
|
|
return NotFound(new { error = "Workflow not found." });
|
|
|
|
|
|
|
|
|
|
var source = _mermaid.Generate(type, workflow, workflow.Tasks);
|
|
|
|
|
return Ok(new { type, source });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Mints a short-lived signed token that authorizes the iframe HTML preview
|
|
|
|
|
/// (<c>GET /api/workflows/{id}/html?token=...</c>). Requires the operator key
|
|
|
|
|
/// (default auth); an iframe cannot send that header, so the token is the
|
|
|
|
|
/// drop-in replacement. The token binds tenant + workflow id and expires.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpGet("{id:guid}/html-token")]
|
|
|
|
|
[RequireScope("read")]
|
|
|
|
|
public async Task<IActionResult> GetHtmlToken(Guid id, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
var workflow = await _db.Workflows
|
|
|
|
|
.FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct);
|
|
|
|
|
if (workflow == null)
|
|
|
|
|
return NotFound(new { error = "Workflow not found." });
|
|
|
|
|
|
|
|
|
|
var ttl = TimeSpan.FromHours(1);
|
|
|
|
|
string token;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
token = _renderTokens.Issue(TenantId, id, ttl);
|
|
|
|
|
}
|
|
|
|
|
catch (InvalidOperationException)
|
|
|
|
|
{
|
|
|
|
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new
|
|
|
|
|
{
|
|
|
|
|
error = "Render token service is not configured (Auth:JwtSigningKey is missing).",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return Ok(new { token, expiresAt = DateTimeOffset.UtcNow.Add(ttl) });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Renders a workflow as a self-contained, non-Mermaid HTML document (rich
|
|
|
|
|
/// detail: success chain, compensation edges, full task inventory). Served as
|
|
|
|
|
/// <c>text/html</c> so ANY client can embed it in an <c><iframe></c> —
|
|
|
|
|
/// no client-side diagram library is needed. Auth: either the operator-key
|
|
|
|
|
/// header (API use) or a signed <c>?token=</c> (iframe use).
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpGet("{id:guid}/html")]
|
|
|
|
|
[RequireScope("read")]
|
|
|
|
|
public async Task<IActionResult> Html(Guid id, CancellationToken ct, string? theme = null)
|
|
|
|
|
{
|
|
|
|
|
// When the request came through a signed render token, make sure the
|
|
|
|
|
// token is for this exact workflow (the middleware already proved the
|
|
|
|
|
// signature + expiry and resolved the tenant).
|
|
|
|
|
if (HttpContext.Items["AuthKind"] as string == "render-token")
|
|
|
|
|
{
|
|
|
|
|
var tokenWorkflow = HttpContext.Items["RenderWorkflowId"] as Guid?;
|
|
|
|
|
if (tokenWorkflow != id)
|
|
|
|
|
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Render token does not match this workflow." });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var workflow = await _db.Workflows
|
|
|
|
|
.Include(w => w.Tasks)
|
|
|
|
|
.FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct);
|
|
|
|
|
if (workflow == null)
|
|
|
|
|
return NotFound(new { error = "Workflow not found." });
|
|
|
|
|
if (!string.Equals(workflow.Status, WorkflowStatus.Compiled, StringComparison.Ordinal))
|
|
|
|
|
return BadRequest(new { error = $"Cannot render a '{workflow.Status}' workflow." });
|
|
|
|
|
|
|
|
|
|
// Best-effort: read the YAML to enrich the header (description, workflow
|
|
|
|
|
// env, entry). The compiled tasks are still the source of truth for the
|
|
|
|
|
// graph/detail; a read/parse failure only degrades the header fields.
|
|
|
|
|
WorkflowDefinition? def = null;
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var yaml = await _source.ReadAsync(workflow.Path, ct);
|
|
|
|
|
def = _yaml.Deserialize<WorkflowDefinition>(yaml) ?? new WorkflowDefinition();
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogWarning(ex, "Failed to read YAML for workflow {Path}; rendering header without description", workflow.Path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var dark = string.Equals(theme, "dark", StringComparison.OrdinalIgnoreCase);
|
|
|
|
|
var html = _html.Render(workflow, workflow.Tasks, def, dark);
|
|
|
|
|
return Content(html, "text/html");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Re-reads workflows/*.yaml from git, compiles, and updates the DB.</summary>
|
|
|
|
|
[HttpPost("sync")]
|
|
|
|
|
[RequireScope("manage")]
|
|
|
|
|
public async Task<IActionResult> Sync(CancellationToken ct)
|
|
|
|
|
{
|
2026-09-03 14:44:39 +00:00
|
|
|
var repoName = (string?)HttpContext.Items["WorkflowRepo"] ?? "workflows";
|
|
|
|
|
var result = await _sync.SyncAsync(TenantId, repoName, ct);
|
2026-09-01 16:37:53 +00:00
|
|
|
_logger.LogInformation(
|
|
|
|
|
"Workflow sync for tenant {TenantId}: {Compiled} compiled, {Removed} removed, {Errors} errors",
|
|
|
|
|
TenantId, result.Compiled, result.Removed, result.Errors.Count);
|
|
|
|
|
|
|
|
|
|
return Ok(new
|
|
|
|
|
{
|
|
|
|
|
result.HeadSha,
|
|
|
|
|
result.Dirty,
|
|
|
|
|
result.Compiled,
|
|
|
|
|
result.Removed,
|
|
|
|
|
errors = result.Errors.Select(e => new { path = e.Path, messages = e.Errors }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed record RunRequest(string? Input = null);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Manual/event trigger: starts a run of a compiled workflow immediately.
|
|
|
|
|
/// The run is created <c>pending</c>; the run lifecycle engine (step 9)
|
|
|
|
|
/// dispatches and drives it. Returns 202 with the run id.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpPost("{id:guid}/run")]
|
|
|
|
|
[RequireScope("run")]
|
|
|
|
|
public async Task<IActionResult> Run(Guid id, [FromBody] RunRequest? request, CancellationToken ct)
|
|
|
|
|
{
|
2026-09-03 11:34:50 +00:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var workflow = await _db.Workflows
|
|
|
|
|
.FirstOrDefaultAsync(w =>
|
|
|
|
|
w.Id == id && w.TenantId == TenantId && w.Status == WorkflowStatus.Compiled, ct);
|
|
|
|
|
if (workflow == null)
|
|
|
|
|
return NotFound(new { error = "Workflow not found." });
|
|
|
|
|
|
|
|
|
|
var triggerJson = System.Text.Json.JsonSerializer.Serialize(new { type = TriggerType.Event });
|
|
|
|
|
var correlation = $"manual:{Guid.NewGuid():N}";
|
|
|
|
|
var runId = await _launcher.LaunchAsync(
|
|
|
|
|
new LaunchRequest(TenantId, id, triggerJson, request?.Input, correlation), ct);
|
|
|
|
|
|
|
|
|
|
return Accepted(new { runId });
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogError(ex, "Failed to start workflow run {WorkflowId} for tenant {TenantId}", id, TenantId);
|
|
|
|
|
return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message });
|
|
|
|
|
}
|
2026-09-01 16:37:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Enables or disables the workflow's auto-trigger (cron/interval/webhook/
|
|
|
|
|
/// handler). Manual "run now" is unaffected. The trigger scheduler skips
|
|
|
|
|
/// disabled workflows.
|
|
|
|
|
///
|
|
|
|
|
/// NOTE: the route token is <c>{actionName}</c>, NOT <c>{action}</c> — MVC
|
|
|
|
|
/// reserves <c>action</c> as a route value, so <c>{action}</c> in an
|
|
|
|
|
/// attribute template makes the endpoint unreachable at runtime (404 on the
|
|
|
|
|
/// very route OpenAPI advertises).
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpPost("{id:guid}/trigger/{actionName}")]
|
|
|
|
|
[RequireScope("manage")]
|
|
|
|
|
public async Task<IActionResult> SetTriggerEnabled(Guid id, string actionName, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
if (actionName is not ("enable" or "disable"))
|
|
|
|
|
return BadRequest(new { error = $"Unknown trigger action '{actionName}'. Expected 'enable' or 'disable'." });
|
|
|
|
|
|
|
|
|
|
var workflow = await _db.Workflows
|
|
|
|
|
.FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct);
|
|
|
|
|
if (workflow == null)
|
|
|
|
|
return NotFound(new { error = "Workflow not found." });
|
|
|
|
|
|
|
|
|
|
workflow.TriggerEnabled = actionName == "enable";
|
|
|
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
|
|
|
|
|
|
_logger.LogInformation("Trigger {Action}d for workflow {WorkflowId} (tenant {TenantId})",
|
|
|
|
|
actionName, id, TenantId);
|
|
|
|
|
return Ok(new TriggerStateResponse(id, workflow.TriggerEnabled));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public sealed record TriggerStateResponse(Guid Id, bool TriggerEnabled);
|
|
|
|
|
|
|
|
|
|
public sealed record ResumeRequest(string? Input = null);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Durable resume: continues a <c>durable</c>/<c>handler</c> instance from its
|
|
|
|
|
/// last checkpoint (the checkpointed next task) with the supplied input
|
|
|
|
|
/// (falling back to the checkpointed input). Creates a new run starting at
|
|
|
|
|
/// <c>StartTaskId</c> so history shows each execution.
|
|
|
|
|
/// </summary>
|
|
|
|
|
[HttpPost("{id:guid}/instances/{instanceId}/resume")]
|
|
|
|
|
[RequireScope("run")]
|
|
|
|
|
public async Task<IActionResult> Resume(Guid id, string instanceId, [FromBody] ResumeRequest? request, CancellationToken ct)
|
|
|
|
|
{
|
2026-09-03 11:34:50 +00:00
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
var workflow = await _db.Workflows
|
|
|
|
|
.Include(w => w.Tasks)
|
|
|
|
|
.FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId && w.Status == WorkflowStatus.Compiled, ct);
|
|
|
|
|
if (workflow == null)
|
|
|
|
|
return NotFound(new { error = "Workflow not found." });
|
|
|
|
|
|
|
|
|
|
var checkpoint = await _durable.GetAsync(instanceId, ct);
|
|
|
|
|
if (checkpoint == null)
|
|
|
|
|
return NotFound(new { error = $"No durable checkpoint for instance '{instanceId}'." });
|
|
|
|
|
|
|
|
|
|
var resumeTask = workflow.Tasks.FirstOrDefault(t => t.Id == checkpoint.TaskId);
|
|
|
|
|
if (resumeTask == null)
|
|
|
|
|
return BadRequest(new { error = $"Checkpointed task '{checkpoint.TaskId}' does not belong to this workflow." });
|
|
|
|
|
|
|
|
|
|
var input = request?.Input ?? checkpoint.StateJson;
|
|
|
|
|
var correlation = $"resume:{instanceId}:{Guid.NewGuid():N}";
|
|
|
|
|
var runId = await _launcher.LaunchAsync(
|
|
|
|
|
new LaunchRequest(TenantId, id, workflow.TriggerJson, input, correlation, checkpoint.TaskId), ct);
|
|
|
|
|
|
|
|
|
|
return Accepted(new { runId, resumedTaskId = checkpoint.TaskId });
|
|
|
|
|
}
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
{
|
|
|
|
|
_logger.LogError(ex, "Failed to resume workflow {WorkflowId} instance {InstanceId} for tenant {TenantId}", id, instanceId, TenantId);
|
|
|
|
|
return StatusCode(StatusCodes.Status500InternalServerError, new { error = ex.Message });
|
|
|
|
|
}
|
2026-09-01 16:37:53 +00:00
|
|
|
}
|
2026-09-03 18:37:55 +00:00
|
|
|
|
|
|
|
|
/// <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);
|
2026-09-01 16:37:53 +00:00
|
|
|
}
|