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) { 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 }); } } /// /// 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) { 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 }); } } }