wf real integ.

This commit is contained in:
Vitali sharp8n 2026-09-09 16:09:48 +03:00
parent 990a528d67
commit 0ea4277022
11 changed files with 911 additions and 6 deletions

View file

@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Mvc;
using w4c_workflows.Services;
namespace w4c_workflows.Controllers;
/// <summary>
/// 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.
/// </summary>
[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 });
}
}

View file

@ -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;
/// <summary>
/// Workflow runtime registry surface (<c>/api/runtimes</c>): which workflow-api
/// instance executes a tenant's workflows. Distinct from
/// <see cref="RuntimesController"/> (<c>/api/languages</c>), which reports
/// execution <em>languages</em>. Authenticates with the tenant operator key.
///
/// Phase 1 covers the platform runtime (always present) and self-hosted runtimes
/// in <b>inbound</b> mode (public URL + API key). Phase 2 adds the outbound
/// runner channel (registration token + WebSocket relay).
/// </summary>
[ApiController]
[Route("api/runtimes")]
public class WorkflowRuntimesController : ControllerBase
{
private readonly WorkflowRuntimeStore _store;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<WorkflowRuntimesController> _logger;
public WorkflowRuntimesController(
WorkflowRuntimeStore store,
IHttpClientFactory httpClientFactory,
ILogger<WorkflowRuntimesController> 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);
/// <summary>
/// Lists the tenant's runtimes: the platform runtime first (always present),
/// then self-hosted runtimes with their cached self-info + online/offline state.
/// </summary>
[HttpGet]
[RequireScope("read")]
public async Task<IActionResult> List(CancellationToken ct)
{
var runtimes = await _store.ListAsync(TenantId, ct);
return Ok(runtimes.Select(ToDto));
}
/// <summary>
/// Connects a self-hosted runtime in inbound mode (legacy direct-URL model).
/// The API key is stored hashed and never returned.
/// </summary>
[HttpPost]
[RequireScope("manage")]
public async Task<IActionResult> 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 });
}
}
/// <summary>Removes a self-hosted runtime. The platform runtime cannot be deleted.</summary>
[HttpDelete("{id:guid}")]
[RequireScope("manage")]
public async Task<IActionResult> 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);
/// <summary>
/// Tests connectivity to a self-hosted runtime (inbound mode) by hitting its
/// <c>/health</c> endpoint. Always returns 200 with a result object (never
/// throws on a down/unreachable engine).
/// </summary>
[HttpPost("{id:guid}/test")]
[RequireScope("manage")]
public async Task<IActionResult> 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,
};
}
}

View file

@ -39,6 +39,7 @@ public class WorkflowsDbContext : DbContext
public DbSet<DurableState> DurableStates => Set<DurableState>();
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
public DbSet<TenantWorkflowRepo> WorkflowRepos => Set<TenantWorkflowRepo>();
public DbSet<WorkflowRuntime> WorkflowRuntimes => Set<WorkflowRuntime>();
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<WorkflowRuntime>(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);
});
}
}

View file

@ -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/<svc> — no operator key needed

View file

@ -57,6 +57,25 @@ public static class ApiKeyScope
public const string Read = "read";
}
/// <summary>
/// The kind of a workflow runtime (which workflow-api instance executes a
/// tenant's workflows).
/// </summary>
public static class WorkflowRuntimeKind
{
/// <summary>The built-in platform runtime (multi-tenant, git/Forgejo-backed).</summary>
public const string Platform = "platform";
/// <summary>A tenant-owned workflows-api instance (self-hosted, Lite mode).</summary>
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";
}
/// <summary>
/// A compiled workflow definition. The authoritative source is a YAML file in
/// git; this row is the compiled snapshot keyed by <c>git_sha</c>.
@ -238,3 +257,47 @@ public class ApiKey
public DateTime? LastUsedAt { get; set; }
public DateTime? RevokedAt { get; set; }
}
/// <summary>
/// A workflow runtime — a workflow-api instance that can execute a tenant's
/// workflows. There is always one virtual <see cref="WorkflowRuntimeKind.Platform"/>
/// runtime per tenant (the built-in multi-tenant engine); a tenant may additionally
/// connect <see cref="WorkflowRuntimeKind.SelfHosted"/> instances.
///
/// Connectivity model:
/// - <c>platform</c>: local, git/Forgejo-backed, shared filesystem (no endpoint).
/// - <c>self-hosted</c> (inbound/legacy): <see cref="Endpoint"/> + <see cref="ApiKeyHash"/>
/// — the tenant exposes a public URL and the platform calls it directly.
/// - <c>self-hosted</c> (outbound/runner): <see cref="SecretHash"/> only — the runtime
/// dials out to the platform over WebSocket and holds the channel; no inbound URL.
/// </summary>
public class WorkflowRuntime
{
public Guid Id { get; set; }
public required string TenantId { get; set; }
/// <summary>User-facing name (e.g. "Platform" or "my-server").</summary>
public required string Label { get; set; }
/// <summary><see cref="WorkflowRuntimeKind"/>: "platform" | "self-hosted".</summary>
public required string Kind { get; set; }
/// <summary>Legacy/inbound URL; null for the platform runtime or outbound-only runtimes.</summary>
public string? Endpoint { get; set; }
/// <summary>SHA-256 hash of the inbound API key (legacy migration path).</summary>
public string? ApiKeyHash { get; set; }
/// <summary>SHA-256 hash of the outbound channel secret (runner model).</summary>
public string? SecretHash { get; set; }
/// <summary><see cref="WorkflowRuntimeStatus"/>: "online" | "offline" | "unknown".</summary>
public string Status { get; set; } = WorkflowRuntimeStatus.Unknown;
public DateTime? LastSeenAt { get; set; }
public string? LastError { get; set; }
/// <summary>Cached self-info json: { name, version, startedAt, multiTenant }.</summary>
public string? InfoJson { get; set; }
/// <summary>
/// True for the tenant's default <em>self-hosted</em> runtime (used when the
/// user has not picked a specific self-hosted runtime). The platform runtime
/// is the implicit global fallback (identified by <see cref="Kind"/>), not a
/// default among self-hosted runtimes, so its <see cref="IsDefault"/> is false.
/// </summary>
public bool IsDefault { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}

View file

@ -167,6 +167,11 @@ builder.Services.AddSingleton<IScriptExecutor>(sp =>
sp.GetRequiredService<ILogger<AgentScriptExecutor>>()));
builder.Services.AddSingleton<RuntimeRegistry>();
// 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<RuntimeSelfInfoProvider>();
builder.Services.AddScoped<WorkflowRuntimeStore>();
// 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<RemoteServerExecutor>();
@ -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)

View file

@ -20,6 +20,13 @@ namespace w4c_workflows.Services;
/// </summary>
public sealed class ForgejoWorkflowRepoService
{
/// <summary>
/// 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 <see cref="RunGitAsync"/>.
/// </summary>
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)));
/// <summary>
/// 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.
/// </summary>
private async Task<string?> 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
}
}
/// <summary>
/// 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.
/// </summary>
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<HttpResponseMessage> SendAdminAsync(
HttpMethod method, string path, object? body, CancellationToken ct)
{

View file

@ -0,0 +1,72 @@
using System.Diagnostics;
using System.Reflection;
namespace w4c_workflows.Services;
/// <summary>
/// Static self-description of this workflows-api instance, served at
/// <c>GET /api/about</c>. 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.
/// </summary>
public sealed record RuntimeSelfInfo(
string Name,
string Version,
DateTimeOffset StartedAt,
bool MultiTenant);
/// <summary>
/// Produces <see cref="RuntimeSelfInfo"/> from configuration + process state.
/// <c>name</c> ← <c>Runtime:Name</c> (default <c>APP_NAME</c> / app name),
/// <c>version</c> ← assembly informational version, <c>startedAt</c> ← process
/// start, <c>multiTenant</c> ← <c>!UseLiteMode</c> (overridable via
/// <c>Runtime:MultiTenant</c>).
/// </summary>
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<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
?? "unknown";
DateTimeOffset startedAt;
try
{
startedAt = Process.GetCurrentProcess().StartTime.ToUniversalTime();
}
catch
{
startedAt = DateTimeOffset.UtcNow;
}
var liteMode = config.GetValue<bool>("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;
}
}

View file

@ -0,0 +1,196 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using w4c_workflows.Data;
using w4c_workflows.Models;
namespace w4c_workflows.Services;
/// <summary>
/// Persistence + lifecycle for the tenant's workflow runtimes (the registry
/// behind <c>/api/runtimes</c>). Every tenant always has a virtual
/// <c>platform</c> runtime (seeded on first access); a tenant may additionally
/// connect <c>self-hosted</c> 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<IReadOnlyList<WorkflowRuntime>> 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<WorkflowRuntime>(1 + selfHosted.Count) { platform };
result.AddRange(selfHosted);
return result;
}
public Task<WorkflowRuntime?> GetAsync(string tenantId, Guid id, CancellationToken ct = default)
=> _db.WorkflowRuntimes
.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == id && r.TenantId == tenantId, ct);
/// <summary>
/// Creates a self-hosted runtime in <b>inbound</b> 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.
/// </summary>
public async Task<WorkflowRuntime> 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;
}
/// <summary>
/// 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.
/// </summary>
public async Task<bool> 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;
}
/// <summary>
/// Seeds (or refreshes) the tenant's virtual platform runtime. The platform
/// runtime is always <see cref="WorkflowRuntimeStatus.Online"/> because it is
/// local to this process.
/// </summary>
private async Task<WorkflowRuntime> 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;
}
}

View file

@ -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<ArgumentException>(
() => store.CreateInboundAsync(tenantId, "bad", "not-a-url", null));
await Assert.ThrowsAsync<ArgumentException>(
() => 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<InvalidOperationException>(
() => store.DeleteAsync(tenantId, platform.Id));
// Platform is still there.
var after = await store.ListAsync(tenantId);
Assert.Single(after);
}
}

View file

@ -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()