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;
///
/// Workflow definitions + git sync. All endpoints authenticate with the
/// tenant's operator key (see AuthMiddleware).
///
[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 _logger;
public WorkflowsController(
WorkflowsDbContext db,
WorkflowSyncService sync,
IWorkflowSource source,
MermaidGeneratorService mermaid,
WorkflowHtmlRenderer html,
RenderTokenService renderTokens,
IRunLauncher launcher,
DurableStateStore durable,
ILogger 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");
/// Lists compiled workflows with git sync status + latest run.
[HttpGet]
[RequireScope("read")]
public async Task 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);
// Latest run per workflow (status + finish time) so the list can surface
// the last execution in one column 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))
.GroupBy(r => r.WorkflowId)
.Select(g => new
{
WorkflowId = g.Key,
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,
lastRunStatus = last?.Status,
lastRunAt = last?.FinishedAt ?? last?.StartedAt,
lastRunError = last?.Error,
};
});
return Ok(new { headSha = state.HeadSha, dirty = state.Dirty, workflows = result });
}
///
/// 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 POST /api/workflows/sync.
///
[HttpGet("{id:guid}")]
[RequireScope("read")]
public async Task 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,
}),
});
}
/// Returns Mermaid source for a workflow diagram type.
[HttpGet("{id:guid}/diagrams/{type}")]
[RequireScope("read")]
public async Task 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 });
}
///
/// Mints a short-lived signed token that authorizes the iframe HTML preview
/// (GET /api/workflows/{id}/html?token=...). 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.
///
[HttpGet("{id:guid}/html-token")]
[RequireScope("read")]
public async Task 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) });
}
///
/// Renders a workflow as a self-contained, non-Mermaid HTML document (rich
/// detail: success chain, compensation edges, full task inventory). Served as
/// text/html so ANY client can embed it in an <iframe> —
/// no client-side diagram library is needed. Auth: either the operator-key
/// header (API use) or a signed ?token= (iframe use).
///
[HttpGet("{id:guid}/html")]
[RequireScope("read")]
public async Task 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(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");
}
/// Re-reads workflows/*.yaml from git, compiles, and updates the DB.
[HttpPost("sync")]
[RequireScope("manage")]
public async Task Sync(CancellationToken ct)
{
var result = await _sync.SyncAsync(TenantId, ct);
_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);
///
/// Manual/event trigger: starts a run of a compiled workflow immediately.
/// The run is created pending; the run lifecycle engine (step 9)
/// dispatches and drives it. Returns 202 with the run id.
///
[HttpPost("{id:guid}/run")]
[RequireScope("run")]
public async Task Run(Guid id, [FromBody] RunRequest? request, CancellationToken ct)
{
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 });
}
///
/// 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 {actionName}, NOT {action} — MVC
/// reserves action as a route value, so {action} in an
/// attribute template makes the endpoint unreachable at runtime (404 on the
/// very route OpenAPI advertises).
///
[HttpPost("{id:guid}/trigger/{actionName}")]
[RequireScope("manage")]
public async Task 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);
///
/// Durable resume: continues a durable/handler 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
/// StartTaskId so history shows each execution.
///
[HttpPost("{id:guid}/instances/{instanceId}/resume")]
[RequireScope("run")]
public async Task Resume(Guid id, string instanceId, [FromBody] ResumeRequest? request, CancellationToken ct)
{
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 });
}
}