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