using System.Diagnostics; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using w4c_workflows.Filters; using w4c_workflows.Models; using w4c_workflows.Services; namespace w4c_workflows.Controllers; /// /// Workflow runtime registry surface (/api/runtimes): which workflow-api /// instance executes a tenant's workflows. Distinct from /// (/api/languages), which reports /// execution languages. Authenticates with the tenant operator key. /// /// Phase 1 covers the platform runtime (always present) and self-hosted runtimes /// in inbound mode (public URL + API key). Phase 2 adds the outbound /// runner channel (registration token + WebSocket relay). /// [ApiController] [Route("api/runtimes")] public class WorkflowRuntimesController : ControllerBase { private readonly WorkflowRuntimeStore _store; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; public WorkflowRuntimesController( WorkflowRuntimeStore store, IHttpClientFactory httpClientFactory, ILogger logger) { _store = store; _httpClientFactory = httpClientFactory; _logger = logger; } private string TenantId => (string?)HttpContext.Items["TenantId"] ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); public sealed record CreateInboundRequest(string? Label = null, string? Endpoint = null, string? ApiKey = null); /// /// Lists the tenant's runtimes: the platform runtime first (always present), /// then self-hosted runtimes with their cached self-info + online/offline state. /// [HttpGet] [RequireScope("read")] public async Task List(CancellationToken ct) { var runtimes = await _store.ListAsync(TenantId, ct); return Ok(runtimes.Select(ToDto)); } /// /// Connects a self-hosted runtime in inbound mode (legacy direct-URL model). /// The API key is stored hashed and never returned. /// [HttpPost] [RequireScope("manage")] public async Task Create([FromBody] CreateInboundRequest? request, CancellationToken ct) { try { var runtime = await _store.CreateInboundAsync( TenantId, request?.Label ?? "Self-hosted", request?.Endpoint ?? string.Empty, request?.ApiKey, ct); _logger.LogInformation("Connected self-hosted runtime {RuntimeId} for tenant {TenantId} (inbound)", runtime.Id, TenantId); return Ok(ToDto(runtime)); } catch (ArgumentException ex) { return BadRequest(new { error = ex.Message }); } } /// Removes a self-hosted runtime. The platform runtime cannot be deleted. [HttpDelete("{id:guid}")] [RequireScope("manage")] public async Task Delete(Guid id, CancellationToken ct) { try { var deleted = await _store.DeleteAsync(TenantId, id, ct); if (!deleted) return NotFound(new { error = "Runtime not found." }); _logger.LogInformation("Removed runtime {RuntimeId} for tenant {TenantId}", id, TenantId); return NoContent(); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } public sealed record TestResultDto(bool Ok, string Message, int? LatencyMs = null); /// /// Tests connectivity to a self-hosted runtime (inbound mode) by hitting its /// /health endpoint. Always returns 200 with a result object (never /// throws on a down/unreachable engine). /// [HttpPost("{id:guid}/test")] [RequireScope("manage")] public async Task Test(Guid id, CancellationToken ct) { var runtime = await _store.GetAsync(TenantId, id, ct); if (runtime == null) return NotFound(new { error = "Runtime not found." }); if (runtime.Kind == WorkflowRuntimeKind.Platform) return Ok(new TestResultDto(true, "Platform runtime is local", 0)); if (string.IsNullOrWhiteSpace(runtime.Endpoint)) return Ok(new TestResultDto(false, "This runtime has no inbound endpoint (outbound runner channel).")); var sw = Stopwatch.StartNew(); try { using var client = _httpClientFactory.CreateClient("runtime-test"); client.Timeout = TimeSpan.FromSeconds(10); using var req = new HttpRequestMessage(HttpMethod.Get, $"{runtime.Endpoint.TrimEnd('/')}/health"); using var resp = await client.SendAsync(req, ct); sw.Stop(); return Ok(new TestResultDto( resp.IsSuccessStatusCode, resp.IsSuccessStatusCode ? $"Runtime reachable in {sw.ElapsedMilliseconds} ms" : $"Runtime returned HTTP {(int)resp.StatusCode}", (int)sw.ElapsedMilliseconds)); } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { sw.Stop(); return Ok(new TestResultDto(false, "Connection timed out", (int)sw.ElapsedMilliseconds)); } catch (Exception ex) { sw.Stop(); return Ok(new TestResultDto(false, ex.Message, (int)sw.ElapsedMilliseconds)); } } private static object ToDto(WorkflowRuntime r) { string? name = null, version = null, startedAt = null; bool? multiTenant = null; if (!string.IsNullOrWhiteSpace(r.InfoJson)) { try { using var doc = JsonDocument.Parse(r.InfoJson); if (doc.RootElement.ValueKind == JsonValueKind.Object) { name = doc.RootElement.TryGetProperty("name", out var n) ? n.GetString() : null; version = doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null; startedAt = doc.RootElement.TryGetProperty("startedAt", out var s) && s.ValueKind == JsonValueKind.String ? s.GetString() : null; multiTenant = doc.RootElement.TryGetProperty("multiTenant", out var m) && m.ValueKind is JsonValueKind.True or JsonValueKind.False ? m.GetBoolean() : null; } } catch (JsonException) { // best-effort self-info } } return new { r.Id, r.Label, r.Kind, r.Endpoint, r.Status, r.IsDefault, r.LastSeenAt, r.LastError, name, version, startedAt, multiTenant, }; } }