diff --git a/Controllers/AboutController.cs b/Controllers/AboutController.cs
new file mode 100644
index 0000000..a305f42
--- /dev/null
+++ b/Controllers/AboutController.cs
@@ -0,0 +1,29 @@
+using Microsoft.AspNetCore.Mvc;
+using w4c_workflows.Services;
+
+namespace w4c_workflows.Controllers;
+
+///
+/// Self-description of this workflows-api instance. Public (no operator key):
+/// the frontend uses it to show the platform runtime's name/version in the
+/// runtime selector, and self-hosted runtimes report the same shape when they
+/// connect. Contains no sensitive data.
+///
+[ApiController]
+[Route("api/about")]
+public class AboutController : ControllerBase
+{
+ private readonly RuntimeSelfInfoProvider _info;
+
+ public AboutController(RuntimeSelfInfoProvider info)
+ {
+ _info = info;
+ }
+
+ [HttpGet]
+ public IActionResult Get()
+ {
+ var i = _info.Get();
+ return Ok(new { i.Name, i.Version, i.StartedAt, i.MultiTenant });
+ }
+}
diff --git a/Controllers/WorkflowRuntimesController.cs b/Controllers/WorkflowRuntimesController.cs
new file mode 100644
index 0000000..8bbb887
--- /dev/null
+++ b/Controllers/WorkflowRuntimesController.cs
@@ -0,0 +1,191 @@
+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,
+ };
+ }
+}
diff --git a/Data/WorkflowsDbContext.cs b/Data/WorkflowsDbContext.cs
index de1059a..3130adb 100644
--- a/Data/WorkflowsDbContext.cs
+++ b/Data/WorkflowsDbContext.cs
@@ -39,6 +39,7 @@ public class WorkflowsDbContext : DbContext
public DbSet DurableStates => Set();
public DbSet ApiKeys => Set();
public DbSet WorkflowRepos => Set();
+ public DbSet WorkflowRuntimes => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -154,5 +155,21 @@ public class WorkflowsDbContext : DbContext
e.Property(r => r.TenantId).HasMaxLength(120).IsRequired();
e.Property(r => r.RepoName).HasMaxLength(120).IsRequired();
});
+
+ modelBuilder.Entity(e =>
+ {
+ e.HasKey(r => r.Id);
+ e.Property(r => r.Id).ValueGeneratedOnAdd();
+ e.Property(r => r.TenantId).HasMaxLength(120).IsRequired();
+ e.Property(r => r.Label).HasMaxLength(200).IsRequired();
+ e.Property(r => r.Kind).HasMaxLength(32).IsRequired();
+ e.Property(r => r.Endpoint).HasMaxLength(500);
+ e.Property(r => r.ApiKeyHash).HasMaxLength(64);
+ e.Property(r => r.SecretHash).HasMaxLength(64);
+ e.Property(r => r.Status).HasMaxLength(32).IsRequired().HasDefaultValue(WorkflowRuntimeStatus.Unknown);
+ e.Property(r => r.LastError).HasColumnType("text");
+ e.Property(r => r.InfoJson).HasColumnType("jsonb");
+ e.HasIndex(r => r.TenantId);
+ });
}
}
diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs
index bd7e716..30ac021 100644
--- a/Middleware/AuthMiddleware.cs
+++ b/Middleware/AuthMiddleware.cs
@@ -17,6 +17,7 @@ public class AuthMiddleware
{
new("/health"),
new("/health/ready"),
+ new("/api/about"), // self-info: name/version/multiTenant, no operator key needed
new("/openapi"),
new("/scalar"), // interactive API explorer (Scalar) — no operator key needed
new("/api/scalar"), // Scalar reference exposed under /api/scalar/ — no operator key needed
diff --git a/Models/Entities.cs b/Models/Entities.cs
index 184f2c3..ca809eb 100644
--- a/Models/Entities.cs
+++ b/Models/Entities.cs
@@ -57,6 +57,25 @@ public static class ApiKeyScope
public const string Read = "read";
}
+///
+/// The kind of a workflow runtime (which workflow-api instance executes a
+/// tenant's workflows).
+///
+public static class WorkflowRuntimeKind
+{
+ /// The built-in platform runtime (multi-tenant, git/Forgejo-backed).
+ public const string Platform = "platform";
+ /// A tenant-owned workflows-api instance (self-hosted, Lite mode).
+ public const string SelfHosted = "self-hosted";
+}
+
+public static class WorkflowRuntimeStatus
+{
+ public const string Online = "online";
+ public const string Offline = "offline";
+ public const string Unknown = "unknown";
+}
+
///
/// A compiled workflow definition. The authoritative source is a YAML file in
/// git; this row is the compiled snapshot keyed by git_sha.
@@ -238,3 +257,47 @@ public class ApiKey
public DateTime? LastUsedAt { get; set; }
public DateTime? RevokedAt { get; set; }
}
+
+///
+/// A workflow runtime — a workflow-api instance that can execute a tenant's
+/// workflows. There is always one virtual
+/// runtime per tenant (the built-in multi-tenant engine); a tenant may additionally
+/// connect instances.
+///
+/// Connectivity model:
+/// - platform: local, git/Forgejo-backed, shared filesystem (no endpoint).
+/// - self-hosted (inbound/legacy): +
+/// — the tenant exposes a public URL and the platform calls it directly.
+/// - self-hosted (outbound/runner): only — the runtime
+/// dials out to the platform over WebSocket and holds the channel; no inbound URL.
+///
+public class WorkflowRuntime
+{
+ public Guid Id { get; set; }
+ public required string TenantId { get; set; }
+ /// User-facing name (e.g. "Platform" or "my-server").
+ public required string Label { get; set; }
+ /// : "platform" | "self-hosted".
+ public required string Kind { get; set; }
+ /// Legacy/inbound URL; null for the platform runtime or outbound-only runtimes.
+ public string? Endpoint { get; set; }
+ /// SHA-256 hash of the inbound API key (legacy migration path).
+ public string? ApiKeyHash { get; set; }
+ /// SHA-256 hash of the outbound channel secret (runner model).
+ public string? SecretHash { get; set; }
+ /// : "online" | "offline" | "unknown".
+ public string Status { get; set; } = WorkflowRuntimeStatus.Unknown;
+ public DateTime? LastSeenAt { get; set; }
+ public string? LastError { get; set; }
+ /// Cached self-info json: { name, version, startedAt, multiTenant }.
+ public string? InfoJson { get; set; }
+ ///
+ /// True for the tenant's default self-hosted runtime (used when the
+ /// user has not picked a specific self-hosted runtime). The platform runtime
+ /// is the implicit global fallback (identified by ), not a
+ /// default among self-hosted runtimes, so its is false.
+ ///
+ public bool IsDefault { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime UpdatedAt { get; set; }
+}
diff --git a/Program.cs b/Program.cs
index 397910c..a1bfbe5 100644
--- a/Program.cs
+++ b/Program.cs
@@ -167,6 +167,11 @@ builder.Services.AddSingleton(sp =>
sp.GetRequiredService>()));
builder.Services.AddSingleton();
+// Workflow runtime registry (which workflow-api instance runs a tenant's
+// workflows): self-info provider (GET /api/about) + the per-tenant runtime store.
+builder.Services.AddSingleton();
+builder.Services.AddScoped();
+
// S8: remote (SSH) task execution via the w4c-webapi server-console exec endpoint.
// Used only when a task carries a `server` reference; local subprocess stays the default.
builder.Services.AddSingleton();
@@ -320,6 +325,55 @@ try
"CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRepos\" (" +
"\"TenantId\" text NOT NULL, \"RepoName\" text NOT NULL, \"UpdatedAt\" timestamptz NULL, " +
"CONSTRAINT \"PK_WorkflowRepos\" PRIMARY KEY (\"TenantId\"));");
+ // Workflow runtime registry: the platform runtime is seeded per tenant on
+ // first access (see WorkflowRuntimeStore); self-hosted runtimes are added
+ // when a tenant connects their own workflows-api instance.
+ await db.Database.ExecuteSqlRawAsync(
+ "CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRuntimes\" (" +
+ "\"Id\" uuid NOT NULL, " +
+ "\"TenantId\" text NOT NULL, " +
+ "\"Label\" text NOT NULL, " +
+ "\"Kind\" text NOT NULL, " +
+ "\"Endpoint\" text NULL, " +
+ "\"ApiKeyHash\" text NULL, " +
+ "\"SecretHash\" text NULL, " +
+ "\"Status\" text NOT NULL DEFAULT 'unknown', " +
+ "\"LastSeenAt\" timestamptz NULL, " +
+ "\"LastError\" text NULL, " +
+ "\"InfoJson\" jsonb NULL, " +
+ "\"IsDefault\" boolean NOT NULL DEFAULT FALSE, " +
+ "\"CreatedAt\" timestamptz NOT NULL, " +
+ "\"UpdatedAt\" timestamptz NOT NULL, " +
+ "CONSTRAINT \"PK_WorkflowRuntimes\" PRIMARY KEY (\"Id\"));");
+ await db.Database.ExecuteSqlRawAsync(
+ "CREATE INDEX IF NOT EXISTS \"IX_WorkflowRuntimes_TenantId\" ON workflows.\"WorkflowRuntimes\" (\"TenantId\");");
+ }
+ else
+ {
+ // Lite mode (self-hosted): SQLite has no schemas and maps Guid→TEXT,
+ // DateTime→TEXT, bool→INTEGER. The platform runtime row is irrelevant here
+ // (a self-hosted runtime talks to the platform over the outbound channel),
+ // but the table must still exist so the registry service can be used
+ // uniformly; it is left empty in Lite mode.
+ await db.Database.ExecuteSqlRawAsync(
+ "CREATE TABLE IF NOT EXISTS \"WorkflowRuntimes\" (" +
+ "\"Id\" TEXT NOT NULL, " +
+ "\"TenantId\" TEXT NOT NULL, " +
+ "\"Label\" TEXT NOT NULL, " +
+ "\"Kind\" TEXT NOT NULL, " +
+ "\"Endpoint\" TEXT NULL, " +
+ "\"ApiKeyHash\" TEXT NULL, " +
+ "\"SecretHash\" TEXT NULL, " +
+ "\"Status\" TEXT NOT NULL DEFAULT 'unknown', " +
+ "\"LastSeenAt\" TEXT NULL, " +
+ "\"LastError\" TEXT NULL, " +
+ "\"InfoJson\" TEXT NULL, " +
+ "\"IsDefault\" INTEGER NOT NULL DEFAULT 0, " +
+ "\"CreatedAt\" TEXT NOT NULL, " +
+ "\"UpdatedAt\" TEXT NOT NULL, " +
+ "CONSTRAINT \"PK_WorkflowRuntimes\" PRIMARY KEY (\"Id\"));");
+ await db.Database.ExecuteSqlRawAsync(
+ "CREATE INDEX IF NOT EXISTS \"IX_WorkflowRuntimes_TenantId\" ON \"WorkflowRuntimes\" (\"TenantId\");");
}
}
catch (Exception ex)
diff --git a/Services/ForgejoWorkflowRepoService.cs b/Services/ForgejoWorkflowRepoService.cs
index 9fec2af..0d731de 100644
--- a/Services/ForgejoWorkflowRepoService.cs
+++ b/Services/ForgejoWorkflowRepoService.cs
@@ -20,6 +20,13 @@ namespace w4c_workflows.Services;
///
public sealed class ForgejoWorkflowRepoService
{
+ ///
+ /// Hard ceiling for any single git invocation. Without it, `git pull`/`git clone`
+ /// against an unreachable remote (e.g. a stale Forgejo base URL) hangs forever,
+ /// blocking the request that triggered it. See .
+ ///
+ private static readonly TimeSpan GitOperationTimeout = TimeSpan.FromSeconds(20);
+
private readonly string _forgejoBase;
private readonly string? _forgejoToken;
private readonly string? _forgejoAdminToken;
@@ -192,6 +199,14 @@ public sealed class ForgejoWorkflowRepoService
if (Directory.Exists(Path.Combine(tenantDir, ".git")))
{
+ // The clone already exists, but its remote `origin` may point at an old
+ // Forgejo base (e.g. after the server location changed from
+ // forgejo.wiz4chat.com to localhost). Pulling from a dead origin hangs
+ // indefinitely (no timeout), blocking every request that runs the
+ // post-auth middleware. Reconcile origin to the configured base so
+ // subsequent pulls target the live server. Best-effort: never fail the
+ // request over a remote rewrite.
+ await ReconcileOriginAsync(tenantDir, login, repoName, ct);
_logger.LogDebug("Local clone for login {Login} already exists at {Dir}", login, tenantDir);
return tenantDir;
}
@@ -231,9 +246,26 @@ public sealed class ForgejoWorkflowRepoService
if (process == null)
throw new InvalidOperationException("git clone failed to start");
- var stdout = await process.StandardOutput.ReadToEndAsync(ct);
- var stderr = await process.StandardError.ReadToEndAsync(ct);
- await process.WaitForExitAsync(ct);
+ using var cloneTimeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cloneTimeout.CancelAfter(GitOperationTimeout);
+
+ var stdoutTask = process.StandardOutput.ReadToEndAsync();
+ var stderrTask = process.StandardError.ReadToEndAsync();
+
+ try
+ {
+ await process.WaitForExitAsync(cloneTimeout.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ TryKill(process);
+ _logger.LogWarning("Clone {FullName} timed out after {Timeout}s", fullName, GitOperationTimeout.TotalSeconds);
+ throw new InvalidOperationException(
+ $"git clone of {fullName} timed out after {GitOperationTimeout.TotalSeconds}s.");
+ }
+
+ var stdout = await stdoutTask;
+ var stderr = await stderrTask;
if (process.ExitCode != 0)
{
@@ -248,6 +280,11 @@ public sealed class ForgejoWorkflowRepoService
// Clean up any baked-in extra header from clone config.
UnsetLocalConfig(tenantDir, "http.extraheader");
+ // Pin the remote to the configured Forgejo base (git infers origin from
+ // the clone URL, but an explicit set-url guarantees it matches the current
+ // configuration even if the base URL changed between clones).
+ await RunGitAsync(tenantDir, ct, "remote", "set-url", "origin", cloneUrl);
+
// NOTE: we deliberately do NOT seed shared example templates into a fresh
// workflow repo. Workflows are strictly per-tenant: a tenant starts with
// exactly the content of their own repo (empty for a freshly-created repo)
@@ -329,6 +366,12 @@ public sealed class ForgejoWorkflowRepoService
public string TenantCloneDir(string tenantId, string login, string? repoName = null)
=> Path.GetFullPath(Path.Combine(_copiesRoot, Sanitize(tenantId), OwnerRepoSlug(login, repoName)));
+ ///
+ /// Runs git with a hard wall-clock timeout. Default git has no connect/transfer
+ /// timeout, so a dead or unreachable remote (e.g. a stale Forgejo base URL after
+ /// the server moved) would otherwise block the calling request forever. On timeout
+ /// the whole process tree is killed and null is returned instead of hanging.
+ ///
private async Task RunGitAsync(string workDir, CancellationToken ct, params string[] args)
{
try
@@ -346,10 +389,26 @@ public sealed class ForgejoWorkflowRepoService
using var process = Process.Start(psi);
if (process == null) return null;
- var stdout = await process.StandardOutput.ReadToEndAsync(ct);
- var stderr = await process.StandardError.ReadToEndAsync(ct);
- await process.WaitForExitAsync(ct);
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(GitOperationTimeout);
+ var stdoutTask = process.StandardOutput.ReadToEndAsync();
+ var stderrTask = process.StandardError.ReadToEndAsync();
+
+ try
+ {
+ await process.WaitForExitAsync(timeout.Token);
+ }
+ catch (OperationCanceledException)
+ {
+ // Timed out (or the request was aborted) — kill the process tree so a
+ // hung git transport can't leak or keep a subsequent request blocked.
+ TryKill(process);
+ return null;
+ }
+
+ var stdout = await stdoutTask;
+ var stderr = await stderrTask;
return process.ExitCode == 0 ? stdout : stderr;
}
catch (Exception ex)
@@ -359,6 +418,50 @@ public sealed class ForgejoWorkflowRepoService
}
}
+ ///
+ /// Ensures the clone's remote `origin` points at the currently-configured Forgejo
+ /// base. Used when a clone already exists but may have been created against an old
+ /// Forgejo location (e.g. forgejo.wiz4chat.com → localhost). A stale origin makes
+ /// `git pull` hang against a dead host. Best-effort and non-blocking on failure.
+ ///
+ private async Task ReconcileOriginAsync(string tenantDir, string login, string? repoName, CancellationToken ct)
+ {
+ var fullName = RepoFullNameForLogin(login, repoName);
+ var desired = $"{_forgejoBase}/{fullName}.git";
+
+ try
+ {
+ var current = await RunGitAsync(tenantDir, ct, "remote", "get-url", "origin");
+ if (string.IsNullOrWhiteSpace(current))
+ return;
+ current = current.Trim();
+ if (current.Equals(desired, StringComparison.OrdinalIgnoreCase))
+ return;
+
+ _logger.LogInformation(
+ "Reconciling workflow repo origin {Dir}: {Current} -> {Desired}",
+ tenantDir, current, desired);
+ await RunGitAsync(tenantDir, ct, "remote", "set-url", "origin", desired);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Could not reconcile origin for {Dir}", tenantDir);
+ }
+ }
+
+ private static void TryKill(Process process)
+ {
+ try
+ {
+ if (!process.HasExited)
+ process.Kill(entireProcessTree: true);
+ }
+ catch
+ {
+ // Best-effort cleanup.
+ }
+ }
+
private async Task SendAdminAsync(
HttpMethod method, string path, object? body, CancellationToken ct)
{
diff --git a/Services/RuntimeSelfInfo.cs b/Services/RuntimeSelfInfo.cs
new file mode 100644
index 0000000..0ea942b
--- /dev/null
+++ b/Services/RuntimeSelfInfo.cs
@@ -0,0 +1,72 @@
+using System.Diagnostics;
+using System.Reflection;
+
+namespace w4c_workflows.Services;
+
+///
+/// Static self-description of this workflows-api instance, served at
+/// GET /api/about. Used by the platform to show a runtime's name/version in
+/// the runtime selector and by self-hosted runtimes to report themselves when they
+/// connect over the outbound channel.
+///
+public sealed record RuntimeSelfInfo(
+ string Name,
+ string Version,
+ DateTimeOffset StartedAt,
+ bool MultiTenant);
+
+///
+/// Produces from configuration + process state.
+/// name ← Runtime:Name (default APP_NAME / app name),
+/// version ← assembly informational version, startedAt ← process
+/// start, multiTenant ← !UseLiteMode (overridable via
+/// Runtime:MultiTenant).
+///
+public sealed class RuntimeSelfInfoProvider
+{
+ private readonly RuntimeSelfInfo _info;
+
+ public RuntimeSelfInfoProvider(IConfiguration config)
+ {
+ var name = FirstNonEmpty(
+ config["Runtime:Name"],
+ config["APP_NAME"],
+ "w4c-workflows-api")!;
+
+ var version =
+ Assembly.GetExecutingAssembly()
+ .GetCustomAttribute()?.InformationalVersion
+ ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
+ ?? "unknown";
+
+ DateTimeOffset startedAt;
+ try
+ {
+ startedAt = Process.GetCurrentProcess().StartTime.ToUniversalTime();
+ }
+ catch
+ {
+ startedAt = DateTimeOffset.UtcNow;
+ }
+
+ var liteMode = config.GetValue("UseLiteMode");
+ var multiTenant = !liteMode;
+ if (config["Runtime:MultiTenant"] is { Length: > 0 } raw &&
+ bool.TryParse(raw, out var parsed))
+ {
+ multiTenant = parsed;
+ }
+
+ _info = new RuntimeSelfInfo(name, version, startedAt, multiTenant);
+ }
+
+ public RuntimeSelfInfo Get() => _info;
+
+ private static string? FirstNonEmpty(params string?[] values)
+ {
+ foreach (var value in values)
+ if (!string.IsNullOrWhiteSpace(value))
+ return value.Trim();
+ return null;
+ }
+}
diff --git a/Services/WorkflowRuntimeStore.cs b/Services/WorkflowRuntimeStore.cs
new file mode 100644
index 0000000..80dd3ff
--- /dev/null
+++ b/Services/WorkflowRuntimeStore.cs
@@ -0,0 +1,196 @@
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using w4c_workflows.Data;
+using w4c_workflows.Models;
+
+namespace w4c_workflows.Services;
+
+///
+/// Persistence + lifecycle for the tenant's workflow runtimes (the registry
+/// behind /api/runtimes). Every tenant always has a virtual
+/// platform runtime (seeded on first access); a tenant may additionally
+/// connect self-hosted runtimes, either inbound (public URL + API key) or
+/// outbound (runner channel — see Phase 2). Only the platform runtime is
+/// multi-tenant; self-hosted runtimes are Lite-mode single-tenant instances.
+///
+public sealed class WorkflowRuntimeStore
+{
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+
+ private readonly WorkflowsDbContext _db;
+ private readonly RuntimeSelfInfoProvider _selfInfo;
+
+ public WorkflowRuntimeStore(WorkflowsDbContext db, RuntimeSelfInfoProvider selfInfo)
+ {
+ _db = db;
+ _selfInfo = selfInfo;
+ }
+
+ ///
+ /// Lists the tenant's runtimes: the platform runtime first (always present),
+ /// then the tenant's self-hosted runtimes (default first, then by label).
+ /// Refreshes the platform runtime's cached self-info on every call so the
+ /// version shown in the UI tracks deploys/restarts.
+ ///
+ public async Task> ListAsync(string tenantId, CancellationToken ct = default)
+ {
+ var platform = await EnsurePlatformAsync(tenantId, ct);
+ var selfHosted = await _db.WorkflowRuntimes
+ .AsNoTracking()
+ .Where(r => r.TenantId == tenantId && r.Kind != WorkflowRuntimeKind.Platform)
+ .OrderByDescending(r => r.IsDefault)
+ .ThenBy(r => r.Label)
+ .ToListAsync(ct);
+
+ var result = new List(1 + selfHosted.Count) { platform };
+ result.AddRange(selfHosted);
+ return result;
+ }
+
+ public Task GetAsync(string tenantId, Guid id, CancellationToken ct = default)
+ => _db.WorkflowRuntimes
+ .AsNoTracking()
+ .FirstOrDefaultAsync(r => r.Id == id && r.TenantId == tenantId, ct);
+
+ ///
+ /// Creates a self-hosted runtime in inbound mode (the legacy direct-URL
+ /// model): the tenant exposes a public URL and the platform calls it directly.
+ /// The API key is hashed (never stored in plaintext). If this is the tenant's
+ /// first self-hosted runtime it becomes the default. Idempotent per endpoint:
+ /// an existing self-hosted runtime with the same endpoint is returned as-is
+ /// instead of inserting a duplicate.
+ ///
+ public async Task CreateInboundAsync(
+ string tenantId, string label, string endpoint, string? apiKey, CancellationToken ct = default)
+ {
+ var cleanEndpoint = (endpoint ?? string.Empty).Trim().TrimEnd('/');
+ if (string.IsNullOrWhiteSpace(cleanEndpoint))
+ throw new ArgumentException("Endpoint is required", nameof(endpoint));
+ if (!Uri.TryCreate(cleanEndpoint, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
+ throw new ArgumentException("Endpoint must be an absolute http(s) URL", nameof(endpoint));
+
+ // Idempotent: never create a duplicate row for the same endpoint (e.g. the
+ // legacy-config migration racing across two tabs).
+ var existing = await _db.WorkflowRuntimes
+ .FirstOrDefaultAsync(r => r.TenantId == tenantId
+ && r.Kind == WorkflowRuntimeKind.SelfHosted
+ && r.Endpoint == cleanEndpoint, ct);
+ if (existing != null)
+ return existing;
+
+ var hasSelfHosted = await _db.WorkflowRuntimes
+ .AnyAsync(r => r.TenantId == tenantId && r.Kind == WorkflowRuntimeKind.SelfHosted, ct);
+
+ var now = DateTime.UtcNow;
+ var runtime = new WorkflowRuntime
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ Label = string.IsNullOrWhiteSpace(label) ? "Self-hosted" : label.Trim(),
+ Kind = WorkflowRuntimeKind.SelfHosted,
+ Endpoint = cleanEndpoint,
+ ApiKeyHash = string.IsNullOrWhiteSpace(apiKey) ? null : ApiKeyService.Hash(apiKey.Trim()),
+ Status = WorkflowRuntimeStatus.Unknown,
+ IsDefault = !hasSelfHosted,
+ CreatedAt = now,
+ UpdatedAt = now,
+ };
+
+ _db.WorkflowRuntimes.Add(runtime);
+ await _db.SaveChangesAsync(ct);
+ return runtime;
+ }
+
+ ///
+ /// Deletes a self-hosted runtime. The platform runtime can never be deleted.
+ /// When the deleted runtime was the default, the oldest remaining self-hosted
+ /// runtime (if any) becomes the default.
+ ///
+ public async Task DeleteAsync(string tenantId, Guid id, CancellationToken ct = default)
+ {
+ var runtime = await _db.WorkflowRuntimes
+ .FirstOrDefaultAsync(r => r.Id == id && r.TenantId == tenantId, ct);
+ if (runtime == null)
+ return false;
+ if (runtime.Kind == WorkflowRuntimeKind.Platform)
+ throw new InvalidOperationException("The platform runtime cannot be deleted.");
+
+ var wasDefault = runtime.IsDefault;
+ _db.WorkflowRuntimes.Remove(runtime);
+ await _db.SaveChangesAsync(ct);
+
+ if (wasDefault)
+ {
+ var next = await _db.WorkflowRuntimes
+ .OrderBy(r => r.CreatedAt)
+ .FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Kind == WorkflowRuntimeKind.SelfHosted, ct);
+ if (next != null)
+ {
+ next.IsDefault = true;
+ await _db.SaveChangesAsync(ct);
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Seeds (or refreshes) the tenant's virtual platform runtime. The platform
+ /// runtime is always because it is
+ /// local to this process.
+ ///
+ private async Task EnsurePlatformAsync(string tenantId, CancellationToken ct)
+ {
+ var now = DateTime.UtcNow;
+ var infoJson = JsonSerializer.Serialize(_selfInfo.Get(), JsonOptions);
+
+ var platform = await _db.WorkflowRuntimes
+ .FirstOrDefaultAsync(r => r.TenantId == tenantId && r.Kind == WorkflowRuntimeKind.Platform, ct);
+
+ if (platform == null)
+ {
+ platform = new WorkflowRuntime
+ {
+ Id = Guid.NewGuid(),
+ TenantId = tenantId,
+ Label = "Platform",
+ Kind = WorkflowRuntimeKind.Platform,
+ Status = WorkflowRuntimeStatus.Online,
+ // IsDefault is left false: the platform runtime is the implicit
+ // fallback (kind == "platform"), not a default among the tenant's
+ // self-hosted runtimes.
+ IsDefault = false,
+ InfoJson = infoJson,
+ LastSeenAt = now,
+ CreatedAt = now,
+ UpdatedAt = now,
+ };
+ _db.WorkflowRuntimes.Add(platform);
+ await _db.SaveChangesAsync(ct);
+ return platform;
+ }
+
+ // Refresh cached self-info (version/startedAt change across deploys) and
+ // keep the platform runtime always-online.
+ var changed = false;
+ if (!string.Equals(platform.InfoJson, infoJson, StringComparison.Ordinal))
+ {
+ platform.InfoJson = infoJson;
+ changed = true;
+ }
+ if (platform.Status != WorkflowRuntimeStatus.Online)
+ {
+ platform.Status = WorkflowRuntimeStatus.Online;
+ changed = true;
+ }
+ platform.LastSeenAt = now;
+ platform.UpdatedAt = now;
+ if (changed)
+ await _db.SaveChangesAsync(ct);
+
+ return platform;
+ }
+}
diff --git a/w4c-workflows-api.Tests/WorkflowRuntimeStoreTests.cs b/w4c-workflows-api.Tests/WorkflowRuntimeStoreTests.cs
new file mode 100644
index 0000000..d8eafb7
--- /dev/null
+++ b/w4c-workflows-api.Tests/WorkflowRuntimeStoreTests.cs
@@ -0,0 +1,159 @@
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using w4c_workflows.Data;
+using w4c_workflows.Models;
+using w4c_workflows.Services;
+using Xunit;
+
+namespace w4c_workflows.Tests;
+
+[Collection("WorkflowsPostgres")]
+public class WorkflowRuntimeStoreTests
+{
+ private readonly WorkflowsPostgresFixture _fixture;
+
+ public WorkflowRuntimeStoreTests(WorkflowsPostgresFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private static WorkflowRuntimeStore Store(WorkflowsDbContext db) =>
+ new(db, new RuntimeSelfInfoProvider(new ConfigurationBuilder().Build()));
+
+ private static string Tenant() => "t" + Guid.NewGuid().ToString("N")[..12];
+
+ private static string Sha256(string raw) =>
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
+
+ [Fact]
+ public async Task List_seeds_one_platform_runtime_per_tenant()
+ {
+ var tenantId = Tenant();
+ await using var db = _fixture.CreateContext();
+ var store = Store(db);
+
+ var first = await store.ListAsync(tenantId);
+ var platform = Assert.Single(first);
+ Assert.Equal(WorkflowRuntimeKind.Platform, platform.Kind);
+ Assert.Equal(WorkflowRuntimeStatus.Online, platform.Status);
+ Assert.False(platform.IsDefault); // platform is the implicit fallback, not a "default" self-hosted runtime
+ Assert.NotNull(platform.InfoJson);
+
+ // Idempotent: listing again must NOT create a second platform runtime.
+ var second = await store.ListAsync(tenantId);
+ var platformAgain = Assert.Single(second);
+ Assert.Equal(platform.Id, platformAgain.Id);
+ }
+
+ [Fact]
+ public async Task Platform_runtime_is_isolated_per_tenant()
+ {
+ var tenantA = Tenant();
+ var tenantB = Tenant();
+ await using var db = _fixture.CreateContext();
+ var store = Store(db);
+
+ var a = await store.ListAsync(tenantA);
+ var b = await store.ListAsync(tenantB);
+
+ var platformA = Assert.Single(a);
+ var platformB = Assert.Single(b);
+ Assert.NotEqual(platformA.Id, platformB.Id);
+
+ // Tenant A must never see tenant B's runtimes.
+ await store.CreateInboundAsync(tenantB, "B engine", "https://b.example.com", "secret-b");
+ var aAfter = await store.ListAsync(tenantA);
+ Assert.Single(aAfter);
+ }
+
+ [Fact]
+ public async Task CreateInboundAsync_hashes_key_and_sets_default_on_first()
+ {
+ var tenantId = Tenant();
+ await using var db = _fixture.CreateContext();
+ var store = Store(db);
+
+ var first = await store.CreateInboundAsync(tenantId, "my-server", "https://wf.example.com/", "raw-key");
+ Assert.Equal(WorkflowRuntimeKind.SelfHosted, first.Kind);
+ Assert.Equal("https://wf.example.com", first.Endpoint); // trailing slash trimmed
+ Assert.True(first.IsDefault);
+ Assert.NotNull(first.ApiKeyHash);
+ Assert.NotEqual("raw-key", first.ApiKeyHash);
+ Assert.Equal(Sha256("raw-key"), first.ApiKeyHash);
+
+ // Second self-hosted runtime is not default.
+ var second = await store.CreateInboundAsync(tenantId, "second", "https://wf2.example.com", null);
+ Assert.False(second.IsDefault);
+ Assert.Null(second.ApiKeyHash);
+ }
+
+ [Fact]
+ public async Task CreateInboundAsync_rejects_invalid_endpoint()
+ {
+ var tenantId = Tenant();
+ await using var db = _fixture.CreateContext();
+ var store = Store(db);
+
+ await Assert.ThrowsAsync(
+ () => store.CreateInboundAsync(tenantId, "bad", "not-a-url", null));
+ await Assert.ThrowsAsync(
+ () => store.CreateInboundAsync(tenantId, "bad", "ftp://example.com", null));
+ }
+
+ [Fact]
+ public async Task DeleteAsync_removes_self_hosted_and_reassigns_default()
+ {
+ var tenantId = Tenant();
+ await using var db = _fixture.CreateContext();
+ var store = Store(db);
+
+ var first = await store.CreateInboundAsync(tenantId, "first", "https://a.example.com", null);
+ var second = await store.CreateInboundAsync(tenantId, "second", "https://b.example.com", null);
+ Assert.True(first.IsDefault);
+ Assert.False(second.IsDefault);
+
+ // Deleting the default reassigns it to the remaining self-hosted runtime.
+ Assert.True(await store.DeleteAsync(tenantId, first.Id));
+ var remaining = await store.GetAsync(tenantId, second.Id);
+ Assert.NotNull(remaining);
+ Assert.True(remaining!.IsDefault);
+ }
+
+ [Fact]
+ public async Task CreateInboundAsync_is_idempotent_per_endpoint()
+ {
+ var tenantId = Tenant();
+ await using var db = _fixture.CreateContext();
+ var store = Store(db);
+
+ var first = await store.CreateInboundAsync(tenantId, "first", "https://wf.example.com/", "key");
+ var second = await store.CreateInboundAsync(tenantId, "second", "https://wf.example.com", "other");
+
+ // Same normalized endpoint → same row, no duplicate.
+ Assert.Equal(first.Id, second.Id);
+
+ var all = await store.ListAsync(tenantId);
+ Assert.Equal(2, all.Count); // platform + the single self-hosted runtime
+ Assert.Single(all, r => r.Kind == WorkflowRuntimeKind.SelfHosted);
+ }
+
+ [Fact]
+ public async Task DeleteAsync_never_deletes_platform_runtime()
+ {
+ var tenantId = Tenant();
+ await using var db = _fixture.CreateContext();
+ var store = Store(db);
+
+ var runtimes = await store.ListAsync(tenantId);
+ var platform = Assert.Single(runtimes);
+
+ await Assert.ThrowsAsync(
+ () => store.DeleteAsync(tenantId, platform.Id));
+
+ // Platform is still there.
+ var after = await store.ListAsync(tenantId);
+ Assert.Single(after);
+ }
+}
diff --git a/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs b/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs
index 65134e0..4dac6ff 100644
--- a/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs
+++ b/w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs
@@ -48,6 +48,26 @@ public sealed class WorkflowsPostgresFixture : IAsyncLifetime
"CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRepos\" (" +
"\"TenantId\" text NOT NULL, \"RepoName\" text NOT NULL, \"UpdatedAt\" timestamptz NULL, " +
"CONSTRAINT \"PK_WorkflowRepos\" PRIMARY KEY (\"TenantId\"));");
+ // Workflow runtime registry (mirrors Program.cs startup ordering).
+ await context.Database.ExecuteSqlRawAsync(
+ "CREATE TABLE IF NOT EXISTS workflows.\"WorkflowRuntimes\" (" +
+ "\"Id\" uuid NOT NULL, " +
+ "\"TenantId\" text NOT NULL, " +
+ "\"Label\" text NOT NULL, " +
+ "\"Kind\" text NOT NULL, " +
+ "\"Endpoint\" text NULL, " +
+ "\"ApiKeyHash\" text NULL, " +
+ "\"SecretHash\" text NULL, " +
+ "\"Status\" text NOT NULL DEFAULT 'unknown', " +
+ "\"LastSeenAt\" timestamptz NULL, " +
+ "\"LastError\" text NULL, " +
+ "\"InfoJson\" jsonb NULL, " +
+ "\"IsDefault\" boolean NOT NULL DEFAULT FALSE, " +
+ "\"CreatedAt\" timestamptz NOT NULL, " +
+ "\"UpdatedAt\" timestamptz NOT NULL, " +
+ "CONSTRAINT \"PK_WorkflowRuntimes\" PRIMARY KEY (\"Id\"));");
+ await context.Database.ExecuteSqlRawAsync(
+ "CREATE INDEX IF NOT EXISTS \"IX_WorkflowRuntimes_TenantId\" ON workflows.\"WorkflowRuntimes\" (\"TenantId\");");
}
public async Task DisposeAsync()