w4c-workflows-api/Services/WorkflowRuntimeStore.cs
2026-09-09 16:09:48 +03:00

197 lines
7.8 KiB
C#

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;
}
}