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