commit 6042171fc238f95f073b55f68d8b84694693ff71 Author: Vitali sharp8n Date: Tue Sep 1 19:37:53 2026 +0300 Initial: workflows API control plane + tests + YAML workflow definitions diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c200be7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# .NET +bin/ +obj/ +*.user +*.suo +*.userosscache +*.sln.docstates +*.userprefs +[Dd]ebug/ +[Rr]elease/ +x64/ +x86/ +[Bb]uild/ +*.sln.ide/ +.vs/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Docker +.env diff --git a/Controllers/HealthController.cs b/Controllers/HealthController.cs new file mode 100644 index 0000000..c012eb2 --- /dev/null +++ b/Controllers/HealthController.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using StackExchange.Redis; +using w4c_workflows.Data; + +namespace w4c_workflows.Controllers; + +[ApiController] +public class HealthController : ControllerBase +{ + private readonly WorkflowsDbContext _db; + private readonly IConnectionMultiplexer _redis; + + public HealthController(WorkflowsDbContext db, IConnectionMultiplexer redis) + { + _db = db; + _redis = redis; + } + + /// Liveness — the process is up. + [HttpGet("/health")] + public IActionResult Health() => Ok(new { status = "ok" }); + + /// Readiness — Postgres + Redis are reachable. + [HttpGet("/health/ready")] + public async Task Ready(CancellationToken ct) + { + var checks = new Dictionary(); + + try + { + await _db.Database.CanConnectAsync(ct); + checks["postgres"] = "ok"; + } + catch (Exception ex) + { + checks["postgres"] = $"error: {ex.Message}"; + } + + try + { + checks["redis"] = _redis.IsConnected ? "ok" : "disconnected"; + } + catch (Exception ex) + { + checks["redis"] = $"error: {ex.Message}"; + } + + var healthy = checks.Values.All(v => v == "ok"); + return healthy ? Ok(new { status = "ok", checks }) : StatusCode(503, new { status = "degraded", checks }); + } +} diff --git a/Controllers/KeysController.cs b/Controllers/KeysController.cs new file mode 100644 index 0000000..5016c0e --- /dev/null +++ b/Controllers/KeysController.cs @@ -0,0 +1,113 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using w4c_workflows.Services; + +namespace w4c_workflows.Controllers; + +/// +/// Per-tenant operator API keys. This whole surface authenticates with the +/// tenant's MAIN JWT (see AuthMiddleware) so a logged-in owner can mint, list, +/// rotate and revoke the keys that the frontend/worker then use everywhere else. +/// +[ApiController] +[Route("api/keys")] +public class KeysController : ControllerBase +{ + private static readonly string[] AllScopes = { "manage", "run", "read" }; + + private readonly ApiKeyService _keys; + private readonly ILogger _logger; + + public KeysController(ApiKeyService keys, ILogger logger) + { + _keys = keys; + _logger = logger; + } + + private string TenantId => (string?)HttpContext.Items["TenantId"] + ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); + + public sealed record MintRequest(string? Label = null, string[]? Scopes = null); + + /// + /// Mints the tenant's first operator key. Idempotent-by-guard: fails with + /// 409 once any active key exists — subsequent keys are created via + /// POST /api/keys (rotate). + /// + [HttpPost("bootstrap")] + public async Task Bootstrap(CancellationToken ct) + { + if (await _keys.CountActiveAsync(TenantId, ct) > 0) + return Conflict(new { error = "Operator keys already exist — use POST /api/keys to rotate." }); + + var (id, raw) = await _keys.MintAsync(TenantId, "default", AllScopes, ct); + _logger.LogInformation("Bootstrapped operator key {KeyId} for tenant {TenantId}", id, TenantId); + return Ok(new { id, key = raw, scopes = AllScopes, note = "Store this key now — it is shown only once." }); + } + + /// Mints a new operator key (rotation). + [HttpPost] + public async Task Mint([FromBody] MintRequest? request, CancellationToken ct) + { + var label = string.IsNullOrWhiteSpace(request?.Label) ? "default" : request.Label.Trim(); + var scopes = NormalizeScopes(request?.Scopes); + + var (id, raw) = await _keys.MintAsync(TenantId, label, scopes, ct); + _logger.LogInformation("Minted operator key {KeyId} for tenant {TenantId}", id, TenantId); + return Ok(new { id, key = raw, scopes, note = "Store this key now — it is shown only once." }); + } + + /// Lists the tenant's keys (hashes are never exposed). + [HttpGet] + public async Task List(CancellationToken ct) + { + var keys = await _keys.ListAsync(TenantId, ct); + var result = keys.Select(k => new + { + k.Id, + k.Label, + scopes = ParseScopes(k.ScopesJson), + k.CreatedAt, + k.LastUsedAt, + k.RevokedAt, + active = k.RevokedAt == null, + }); + return Ok(result); + } + + /// Revokes an operator key. + [HttpDelete("{id:guid}")] + public async Task Revoke(Guid id, CancellationToken ct) + { + var revoked = await _keys.RevokeAsync(TenantId, id, ct); + if (!revoked) + return NotFound(new { error = "Key not found." }); + + _logger.LogInformation("Revoked operator key {KeyId} for tenant {TenantId}", id, TenantId); + return NoContent(); + } + + private static string[] NormalizeScopes(string[]? requested) + { + if (requested == null || requested.Length == 0) + return AllScopes; + + var valid = new HashSet { "manage", "run", "read" }; + var normalized = requested.Where(s => valid.Contains(s)).Distinct().ToArray(); + return normalized.Length == 0 ? AllScopes : normalized; + } + + private static string[] ParseScopes(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return Array.Empty(); + try + { + return JsonSerializer.Deserialize(json) ?? Array.Empty(); + } + catch + { + return Array.Empty(); + } + } +} diff --git a/Controllers/RunsController.cs b/Controllers/RunsController.cs new file mode 100644 index 0000000..ceb4efc --- /dev/null +++ b/Controllers/RunsController.cs @@ -0,0 +1,141 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using w4c_workflows.Data; +using w4c_workflows.Filters; +using w4c_workflows.Models; + +namespace w4c_workflows.Controllers; + +/// +/// Run history + detail. Authenticated with the tenant's operator key (see +/// AuthMiddleware). The run lifecycle engine (step 9) is the writer; this +/// surface is the read side backing the Workflows UI (step 11). +/// +[ApiController] +[Route("api/runs")] +public class RunsController : ControllerBase +{ + public sealed record RunSummary( + Guid Id, + Guid WorkflowId, + string WorkflowName, + string Status, + string? CorrelationId, + DateTime? StartedAt, + DateTime? FinishedAt, + string? Error); + + public sealed record RunDetail( + Guid Id, + Guid WorkflowId, + string WorkflowName, + string WorkflowMode, + string Status, + string? TriggerJson, + string? InputJson, + string? OutputJson, + string? Error, + string? CorrelationId, + DateTime? StartedAt, + DateTime? FinishedAt); + + public sealed record RunListResponse(int Total, IReadOnlyList Runs); + + public sealed record TaskRunSummary( + Guid Id, + Guid TaskId, + string TaskKey, + int Attempt, + string Status, + int RetryCount, + bool IsCompensation, + string? Error, + DateTime? StartedAt, + DateTime? FinishedAt); + + private readonly WorkflowsDbContext _db; + + public RunsController(WorkflowsDbContext db) + { + _db = db; + } + + private string TenantId => (string?)HttpContext.Items["TenantId"] + ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); + + /// + /// Lists the tenant's runs, newest first, optionally filtered to one workflow. + /// + [HttpGet] + [RequireScope("read")] + public async Task List( + [FromQuery] Guid? workflowId, + [FromQuery] int limit = 50, + [FromQuery] int offset = 0, + CancellationToken ct = default) + { + limit = Math.Clamp(limit, 1, 200); + offset = Math.Max(0, offset); + + var query = _db.WorkflowRuns.Where(r => r.TenantId == TenantId); + if (workflowId != null) + query = query.Where(r => r.WorkflowId == workflowId); + + var total = await query.CountAsync(ct); + + var runs = await query + .OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue) + .ThenByDescending(r => r.Id) + .Skip(offset) + .Take(limit) + .Join(_db.Workflows, r => r.WorkflowId, w => w.Id, (r, w) => new { r, w.Name }) + .Select(x => new RunSummary( + x.r.Id, x.r.WorkflowId, x.Name, x.r.Status, x.r.CorrelationId, + x.r.StartedAt, x.r.FinishedAt, x.r.Error)) + .ToListAsync(ct); + + return Ok(new RunListResponse(total, runs)); + } + + /// Returns one run plus its task-run summary. + [HttpGet("{id:guid}")] + [RequireScope("read")] + public async Task Get(Guid id, CancellationToken ct) + { + var detail = await _db.WorkflowRuns + .Where(r => r.Id == id && r.TenantId == TenantId) + .Join(_db.Workflows, r => r.WorkflowId, w => w.Id, (r, w) => new { r, w.Name, w.Mode }) + .Select(x => new RunDetail( + x.r.Id, x.r.WorkflowId, x.Name, x.Mode, x.r.Status, x.r.TriggerJson, + x.r.InputJson, x.r.OutputJson, x.r.Error, x.r.CorrelationId, + x.r.StartedAt, x.r.FinishedAt)) + .FirstOrDefaultAsync(ct); + + if (detail == null) + return NotFound(new { error = "Run not found." }); + + return Ok(detail); + } + + /// Returns the task runs of one run, in dispatch order. + [HttpGet("{id:guid}/tasks")] + [RequireScope("read")] + public async Task Tasks(Guid id, CancellationToken ct) + { + var owned = await _db.WorkflowRuns.AnyAsync(r => r.Id == id && r.TenantId == TenantId, ct); + if (!owned) + return NotFound(new { error = "Run not found." }); + + var tasks = await _db.TaskRuns + .Where(t => t.RunId == id) + .OrderBy(t => t.StartedAt) + .ThenBy(t => t.Attempt) + .Join(_db.Tasks, t => t.TaskId, task => task.Id, (t, task) => new { t, task.Key }) + .Select(x => new TaskRunSummary( + x.t.Id, x.t.TaskId, x.Key, x.t.Attempt, x.t.Status, x.t.RetryCount, + x.t.IsCompensation, x.t.Error, x.t.StartedAt, x.t.FinishedAt)) + .ToListAsync(ct); + + return Ok(tasks); + } +} diff --git a/Controllers/RuntimesController.cs b/Controllers/RuntimesController.cs new file mode 100644 index 0000000..a9ad465 --- /dev/null +++ b/Controllers/RuntimesController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; +using w4c_workflows.Filters; +using w4c_workflows.Services.Execution; + +namespace w4c_workflows.Controllers; + +/// +/// The runtime registry surface: which execution languages are available on the +/// worker host. Authenticates with the tenant operator key like the rest of the +/// control plane. +/// +[ApiController] +[Route("api/languages")] +public class RuntimesController : ControllerBase +{ + private readonly RuntimeRegistry _runtimes; + + public RuntimesController(RuntimeRegistry runtimes) + { + _runtimes = runtimes; + } + + [HttpGet] + [RequireScope("read")] + public IActionResult List() => Ok(_runtimes.All.Select(r => new + { + r.Id, + r.DisplayName, + r.DefaultFunction, + r.Extension, + r.Available, + })); +} diff --git a/Controllers/WebhooksController.cs b/Controllers/WebhooksController.cs new file mode 100644 index 0000000..62b4bde --- /dev/null +++ b/Controllers/WebhooksController.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using w4c_workflows.Data; +using w4c_workflows.Models; +using w4c_workflows.Services.Runs; +using w4c_workflows.Services.Triggers; + +namespace w4c_workflows.Controllers; + +/// +/// Webhook receiver for workflows with trigger.type = webhook. The route +/// is PUBLIC (external callers have no operator key); access is governed by an +/// optional shared secret (Workflows:WebhookSecret) verified against the +/// X-Webhook-Secret header. When no secret is configured the endpoint is +/// open — acceptable for v1 dev, hardened in step 13. +/// +[ApiController] +[Route("/h")] +public class WebhooksController : ControllerBase +{ + private readonly WorkflowsDbContext _db; + private readonly IRunLauncher _launcher; + private readonly ILogger _logger; + private readonly string? _sharedSecret; + + public WebhooksController( + WorkflowsDbContext db, + IRunLauncher launcher, + IConfiguration config, + ILogger logger) + { + _db = db; + _launcher = launcher; + _logger = logger; + _sharedSecret = string.IsNullOrWhiteSpace(config["Workflows:WebhookSecret"]) + ? null + : config["Workflows:WebhookSecret"]; + } + + /// Receives a webhook at /h/{path} and fires every matching workflow. + [HttpPost("{**path}")] + public async Task Receive(string? path, CancellationToken ct) + { + var webhookPath = "/h/" + (path ?? string.Empty); + + if (!Authorized()) + return Unauthorized(new { error = "Invalid webhook secret." }); + + var matches = await FindMatchesAsync(webhookPath, ct); + if (matches.Count == 0) + return NotFound(new { error = $"No webhook workflow registered for path '{webhookPath}'." }); + + var input = await ReadInputAsync(ct); + var correlation = Request.Headers["X-Request-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString("N"); + + var runIds = new List(matches.Count); + foreach (var workflow in matches) + { + runIds.Add(await _launcher.LaunchAsync( + new LaunchRequest(workflow.TenantId, workflow.Id, workflow.TriggerJson, input, $"webhook:{correlation}"), ct)); + } + + _logger.LogInformation("Webhook {Path} fired {Count} workflow(s) for tenant(s) {Tenants}", + webhookPath, matches.Count, string.Join(",", matches.Select(m => m.TenantId).Distinct())); + + return Accepted(new { runs = runIds }); + } + + private bool Authorized() + { + // When no secret is configured, reject all webhook requests. An empty + // secret previously meant "open" — acceptable for dev but a security + // hole in production. Callers must set Workflows:WebhookSecret. Log a + // warning so this fail-closed state is not silent. + if (_sharedSecret == null) + { + _logger.LogWarning("Workflows:WebhookSecret is not configured; the webhook endpoint is disabled and every request to /h/* will be rejected with 401."); + return false; + } + + var provided = Request.Headers["X-Webhook-Secret"].FirstOrDefault(); + return provided != null && CryptographicOperationsEquals(provided, _sharedSecret); + } + + private async Task> FindMatchesAsync(string webhookPath, CancellationToken ct) + { + var candidates = await _db.Workflows + .Where(w => w.Status == WorkflowStatus.Compiled && w.TriggerJson != null) + .ToListAsync(ct); + + var matches = new List(); + foreach (var workflow in candidates) + { + var spec = TriggerSpec.Parse(workflow.TriggerJson, out _); + if (spec?.Type == TriggerType.Webhook + && string.Equals(spec.WebhookPath, webhookPath, StringComparison.Ordinal)) + matches.Add(workflow); + } + return matches; + } + + private async Task ReadInputAsync(CancellationToken ct) + { + using var reader = new StreamReader(Request.Body); + var body = (await reader.ReadToEndAsync(ct)).Trim(); + if (body.Length == 0) + return null; + + try + { + using var _ = JsonDocument.Parse(body); + return body; // already valid JSON — pass through verbatim + } + catch (JsonException) + { + return JsonSerializer.Serialize(body); // wrap non-JSON bodies as a JSON string + } + } + + private static bool CryptographicOperationsEquals(string a, string b) + { + var aBytes = System.Text.Encoding.UTF8.GetBytes(a); + var bBytes = System.Text.Encoding.UTF8.GetBytes(b); + if (aBytes.Length != bBytes.Length) + return false; + + var result = 0; + for (var i = 0; i < aBytes.Length; i++) + result |= aBytes[i] ^ bBytes[i]; + return result == 0; + } +} diff --git a/Controllers/WorkflowsController.cs b/Controllers/WorkflowsController.cs new file mode 100644 index 0000000..14298fc --- /dev/null +++ b/Controllers/WorkflowsController.cs @@ -0,0 +1,415 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using w4c_workflows.Data; +using w4c_workflows.Filters; +using w4c_workflows.Models; +using w4c_workflows.Services; +using w4c_workflows.Services.Runs; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace w4c_workflows.Controllers; + +/// +/// Workflow definitions + git sync. All endpoints authenticate with the +/// tenant's operator key (see AuthMiddleware). +/// +[ApiController] +[Route("api/workflows")] +public class WorkflowsController : ControllerBase +{ + private readonly WorkflowsDbContext _db; + private readonly WorkflowSyncService _sync; + private readonly IWorkflowSource _source; + private readonly MermaidGeneratorService _mermaid; + private readonly WorkflowHtmlRenderer _html; + private readonly RenderTokenService _renderTokens; + private readonly IRunLauncher _launcher; + private readonly DurableStateStore _durable; + private readonly IDeserializer _yaml; + private readonly ILogger _logger; + + public WorkflowsController( + WorkflowsDbContext db, + WorkflowSyncService sync, + IWorkflowSource source, + MermaidGeneratorService mermaid, + WorkflowHtmlRenderer html, + RenderTokenService renderTokens, + IRunLauncher launcher, + DurableStateStore durable, + ILogger logger) + { + _db = db; + _sync = sync; + _source = source; + _mermaid = mermaid; + _html = html; + _renderTokens = renderTokens; + _launcher = launcher; + _durable = durable; + _logger = logger; + _yaml = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .IgnoreUnmatchedProperties() + .Build(); + } + + private string TenantId => (string?)HttpContext.Items["TenantId"] + ?? throw new InvalidOperationException("TenantId not resolved by auth middleware"); + + /// Lists compiled workflows with git sync status + latest run. + [HttpGet] + [RequireScope("read")] + public async Task List(CancellationToken ct) + { + var state = _source.GetState(); + var workflows = await _db.Workflows + .Where(w => w.TenantId == TenantId) + .OrderBy(w => w.Name) + .Select(w => new + { + w.Id, + w.Name, + w.Path, + w.GitSha, + w.Status, + w.Mode, + w.Target, + w.TriggerEnabled, + w.TriggerJson, + w.CompiledAt, + w.UpdatedAt, + w.Version, + taskCount = w.Tasks.Count, + upToDate = w.GitSha == state.HeadSha, + }) + .ToListAsync(ct); + + // Latest run per workflow (status + finish time) so the list can surface + // the last execution in one column without a round-trip per row. + var ids = workflows.Select(w => w.Id).ToList(); + var lastRuns = await _db.WorkflowRuns + .Where(r => r.TenantId == TenantId && ids.Contains(r.WorkflowId)) + .GroupBy(r => r.WorkflowId) + .Select(g => new + { + WorkflowId = g.Key, + Status = g + .OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue) + .ThenByDescending(r => r.Id) + .Select(r => r.Status) + .FirstOrDefault(), + StartedAt = g + .OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue) + .ThenByDescending(r => r.Id) + .Select(r => r.StartedAt) + .FirstOrDefault(), + FinishedAt = g + .OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue) + .ThenByDescending(r => r.Id) + .Select(r => r.FinishedAt) + .FirstOrDefault(), + Error = g + .OrderByDescending(r => r.StartedAt ?? r.FinishedAt ?? DateTime.MinValue) + .ThenByDescending(r => r.Id) + .Select(r => r.Error) + .FirstOrDefault(), + }) + .ToDictionaryAsync(x => x.WorkflowId, ct); + + var result = workflows.Select(w => + { + lastRuns.TryGetValue(w.Id, out var last); + return new + { + w.Id, + w.Name, + w.Path, + w.GitSha, + w.Status, + w.Mode, + w.Target, + w.TriggerEnabled, + w.TriggerJson, + w.CompiledAt, + w.UpdatedAt, + w.Version, + w.taskCount, + w.upToDate, + lastRunStatus = last?.Status, + lastRunAt = last?.FinishedAt ?? last?.StartedAt, + lastRunError = last?.Error, + }; + }); + + return Ok(new { headSha = state.HeadSha, dirty = state.Dirty, workflows = result }); + } + + /// + /// Returns the workflow definition, its compiled task tree, and the raw YAML + /// source from git (for the Monaco task editor). The YAML is the + /// authoritative source; edits are saved back via the Source Code API and + /// recompiled with POST /api/workflows/sync. + /// + [HttpGet("{id:guid}")] + [RequireScope("read")] + public async Task Get(Guid id, CancellationToken ct) + { + var workflow = await _db.Workflows + .Include(w => w.Tasks) + .FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct); + if (workflow == null) + return NotFound(new { error = "Workflow not found." }); + + string? yaml = null; + try + { + yaml = await _source.ReadAsync(workflow.Path, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read workflow source {Path}", workflow.Path); + } + + return Ok(new + { + workflow.Id, + workflow.Name, + workflow.Path, + workflow.Mode, + workflow.Target, + workflow.Status, + workflow.TriggerEnabled, + workflow.TriggerJson, + workflow.CompiledAt, + workflow.UpdatedAt, + workflow.GitSha, + workflow.Version, + yaml, + tasks = workflow.Tasks + .OrderBy(t => t.Order) + .Select(t => new + { + t.Id, + t.Key, + t.ParentId, + t.NextId, + t.OnErrorId, + t.Language, + t.Mode, + t.EntryJson, + t.EnvJson, + t.Order, + }), + }); + } + + /// Returns Mermaid source for a workflow diagram type. + [HttpGet("{id:guid}/diagrams/{type}")] + [RequireScope("read")] + public async Task Diagram(Guid id, string type, CancellationToken ct) + { + if (!MermaidGeneratorService.Types.Contains(type)) + return BadRequest(new { error = $"Unknown diagram type '{type}'. Expected one of {string.Join(", ", MermaidGeneratorService.Types)}." }); + + var workflow = await _db.Workflows + .Include(w => w.Tasks) + .FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct); + if (workflow == null) + return NotFound(new { error = "Workflow not found." }); + + var source = _mermaid.Generate(type, workflow, workflow.Tasks); + return Ok(new { type, source }); + } + + /// + /// Mints a short-lived signed token that authorizes the iframe HTML preview + /// (GET /api/workflows/{id}/html?token=...). Requires the operator key + /// (default auth); an iframe cannot send that header, so the token is the + /// drop-in replacement. The token binds tenant + workflow id and expires. + /// + [HttpGet("{id:guid}/html-token")] + [RequireScope("read")] + public async Task GetHtmlToken(Guid id, CancellationToken ct) + { + var workflow = await _db.Workflows + .FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct); + if (workflow == null) + return NotFound(new { error = "Workflow not found." }); + + var ttl = TimeSpan.FromHours(1); + string token; + try + { + token = _renderTokens.Issue(TenantId, id, ttl); + } + catch (InvalidOperationException) + { + return StatusCode(StatusCodes.Status503ServiceUnavailable, new + { + error = "Render token service is not configured (Auth:JwtSigningKey is missing).", + }); + } + return Ok(new { token, expiresAt = DateTimeOffset.UtcNow.Add(ttl) }); + } + + /// + /// Renders a workflow as a self-contained, non-Mermaid HTML document (rich + /// detail: success chain, compensation edges, full task inventory). Served as + /// text/html so ANY client can embed it in an <iframe> — + /// no client-side diagram library is needed. Auth: either the operator-key + /// header (API use) or a signed ?token= (iframe use). + /// + [HttpGet("{id:guid}/html")] + [RequireScope("read")] + public async Task Html(Guid id, CancellationToken ct, string? theme = null) + { + // When the request came through a signed render token, make sure the + // token is for this exact workflow (the middleware already proved the + // signature + expiry and resolved the tenant). + if (HttpContext.Items["AuthKind"] as string == "render-token") + { + var tokenWorkflow = HttpContext.Items["RenderWorkflowId"] as Guid?; + if (tokenWorkflow != id) + return StatusCode(StatusCodes.Status403Forbidden, new { error = "Render token does not match this workflow." }); + } + + var workflow = await _db.Workflows + .Include(w => w.Tasks) + .FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct); + if (workflow == null) + return NotFound(new { error = "Workflow not found." }); + if (!string.Equals(workflow.Status, WorkflowStatus.Compiled, StringComparison.Ordinal)) + return BadRequest(new { error = $"Cannot render a '{workflow.Status}' workflow." }); + + // Best-effort: read the YAML to enrich the header (description, workflow + // env, entry). The compiled tasks are still the source of truth for the + // graph/detail; a read/parse failure only degrades the header fields. + WorkflowDefinition? def = null; + try + { + var yaml = await _source.ReadAsync(workflow.Path, ct); + def = _yaml.Deserialize(yaml) ?? new WorkflowDefinition(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read YAML for workflow {Path}; rendering header without description", workflow.Path); + } + + var dark = string.Equals(theme, "dark", StringComparison.OrdinalIgnoreCase); + var html = _html.Render(workflow, workflow.Tasks, def, dark); + return Content(html, "text/html"); + } + + /// Re-reads workflows/*.yaml from git, compiles, and updates the DB. + [HttpPost("sync")] + [RequireScope("manage")] + public async Task Sync(CancellationToken ct) + { + var result = await _sync.SyncAsync(TenantId, ct); + _logger.LogInformation( + "Workflow sync for tenant {TenantId}: {Compiled} compiled, {Removed} removed, {Errors} errors", + TenantId, result.Compiled, result.Removed, result.Errors.Count); + + return Ok(new + { + result.HeadSha, + result.Dirty, + result.Compiled, + result.Removed, + errors = result.Errors.Select(e => new { path = e.Path, messages = e.Errors }), + }); + } + + public sealed record RunRequest(string? Input = null); + + /// + /// Manual/event trigger: starts a run of a compiled workflow immediately. + /// The run is created pending; the run lifecycle engine (step 9) + /// dispatches and drives it. Returns 202 with the run id. + /// + [HttpPost("{id:guid}/run")] + [RequireScope("run")] + public async Task Run(Guid id, [FromBody] RunRequest? request, CancellationToken ct) + { + var workflow = await _db.Workflows + .FirstOrDefaultAsync(w => + w.Id == id && w.TenantId == TenantId && w.Status == WorkflowStatus.Compiled, ct); + if (workflow == null) + return NotFound(new { error = "Workflow not found." }); + + var triggerJson = System.Text.Json.JsonSerializer.Serialize(new { type = TriggerType.Event }); + var correlation = $"manual:{Guid.NewGuid():N}"; + var runId = await _launcher.LaunchAsync( + new LaunchRequest(TenantId, id, triggerJson, request?.Input, correlation), ct); + + return Accepted(new { runId }); + } + + /// + /// Enables or disables the workflow's auto-trigger (cron/interval/webhook/ + /// handler). Manual "run now" is unaffected. The trigger scheduler skips + /// disabled workflows. + /// + /// NOTE: the route token is {actionName}, NOT {action} — MVC + /// reserves action as a route value, so {action} in an + /// attribute template makes the endpoint unreachable at runtime (404 on the + /// very route OpenAPI advertises). + /// + [HttpPost("{id:guid}/trigger/{actionName}")] + [RequireScope("manage")] + public async Task SetTriggerEnabled(Guid id, string actionName, CancellationToken ct) + { + if (actionName is not ("enable" or "disable")) + return BadRequest(new { error = $"Unknown trigger action '{actionName}'. Expected 'enable' or 'disable'." }); + + var workflow = await _db.Workflows + .FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId, ct); + if (workflow == null) + return NotFound(new { error = "Workflow not found." }); + + workflow.TriggerEnabled = actionName == "enable"; + await _db.SaveChangesAsync(ct); + + _logger.LogInformation("Trigger {Action}d for workflow {WorkflowId} (tenant {TenantId})", + actionName, id, TenantId); + return Ok(new TriggerStateResponse(id, workflow.TriggerEnabled)); + } + + public sealed record TriggerStateResponse(Guid Id, bool TriggerEnabled); + + public sealed record ResumeRequest(string? Input = null); + + /// + /// Durable resume: continues a durable/handler instance from its + /// last checkpoint (the checkpointed next task) with the supplied input + /// (falling back to the checkpointed input). Creates a new run starting at + /// StartTaskId so history shows each execution. + /// + [HttpPost("{id:guid}/instances/{instanceId}/resume")] + [RequireScope("run")] + public async Task Resume(Guid id, string instanceId, [FromBody] ResumeRequest? request, CancellationToken ct) + { + var workflow = await _db.Workflows + .Include(w => w.Tasks) + .FirstOrDefaultAsync(w => w.Id == id && w.TenantId == TenantId && w.Status == WorkflowStatus.Compiled, ct); + if (workflow == null) + return NotFound(new { error = "Workflow not found." }); + + var checkpoint = await _durable.GetAsync(instanceId, ct); + if (checkpoint == null) + return NotFound(new { error = $"No durable checkpoint for instance '{instanceId}'." }); + + var resumeTask = workflow.Tasks.FirstOrDefault(t => t.Id == checkpoint.TaskId); + if (resumeTask == null) + return BadRequest(new { error = $"Checkpointed task '{checkpoint.TaskId}' does not belong to this workflow." }); + + var input = request?.Input ?? checkpoint.StateJson; + var correlation = $"resume:{instanceId}:{Guid.NewGuid():N}"; + var runId = await _launcher.LaunchAsync( + new LaunchRequest(TenantId, id, workflow.TriggerJson, input, correlation, checkpoint.TaskId), ct); + + return Accepted(new { runId, resumedTaskId = checkpoint.TaskId }); + } +} diff --git a/Data/Migrations/20260828130001_InitialCreate.Designer.cs b/Data/Migrations/20260828130001_InitialCreate.Designer.cs new file mode 100644 index 0000000..969dd00 --- /dev/null +++ b/Data/Migrations/20260828130001_InitialCreate.Designer.cs @@ -0,0 +1,350 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using w4c_workflows.Data; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + [DbContext(typeof(WorkflowsDbContext))] + [Migration("20260828130001_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workflows") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("w4c_workflows.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopesJson") + .HasColumnType("jsonb"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ApiKeys", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.DurableState", b => + { + b.Property("InstanceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CheckpointAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("InstanceId"); + + b.HasIndex("CorrelationId"); + + b.ToTable("DurableStates", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.HasIndex("TaskId"); + + b.ToTable("TaskRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompiledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Workflows", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TenantId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("WorkflowRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryJson") + .HasColumnType("jsonb"); + + b.Property("EnvJson") + .HasColumnType("jsonb"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("NextId") + .HasColumnType("uuid"); + + b.Property("OnErrorId") + .HasColumnType("uuid"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowId"); + + b.ToTable("Tasks", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.HasOne("w4c_workflows.Models.WorkflowRun", "Run") + .WithMany("TaskRuns") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("w4c_workflows.Models.WorkflowTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany() + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Navigation("TaskRuns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260828130001_InitialCreate.cs b/Data/Migrations/20260828130001_InitialCreate.cs new file mode 100644 index 0000000..2af84ed --- /dev/null +++ b/Data/Migrations/20260828130001_InitialCreate.cs @@ -0,0 +1,258 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "workflows"); + + migrationBuilder.CreateTable( + name: "ApiKeys", + schema: "workflows", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "text", nullable: false), + KeyHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Label = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + ScopesJson = table.Column(type: "jsonb", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastUsedAt = table.Column(type: "timestamp with time zone", nullable: true), + RevokedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "DurableStates", + schema: "workflows", + columns: table => new + { + InstanceId = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + TaskId = table.Column(type: "uuid", nullable: false), + StateJson = table.Column(type: "jsonb", nullable: true), + CheckpointAt = table.Column(type: "timestamp with time zone", nullable: false), + CorrelationId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DurableStates", x => x.InstanceId); + }); + + migrationBuilder.CreateTable( + name: "Workflows", + schema: "workflows", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Path = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + GitSha = table.Column(type: "character varying(64)", maxLength: 64, nullable: true), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Mode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + TriggerJson = table.Column(type: "jsonb", nullable: true), + Target = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + CompiledAt = table.Column(type: "timestamp with time zone", nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Workflows", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Tasks", + schema: "workflows", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkflowId = table.Column(type: "uuid", nullable: false), + ParentId = table.Column(type: "uuid", nullable: true), + NextId = table.Column(type: "uuid", nullable: true), + OnErrorId = table.Column(type: "uuid", nullable: true), + Language = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Mode = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + EntryJson = table.Column(type: "jsonb", nullable: true), + EnvJson = table.Column(type: "jsonb", nullable: true), + Order = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tasks", x => x.Id); + table.ForeignKey( + name: "FK_Tasks_Workflows_WorkflowId", + column: x => x.WorkflowId, + principalSchema: "workflows", + principalTable: "Workflows", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "WorkflowRuns", + schema: "workflows", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + WorkflowId = table.Column(type: "uuid", nullable: false), + TenantId = table.Column(type: "text", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + TriggerJson = table.Column(type: "jsonb", nullable: true), + InputJson = table.Column(type: "jsonb", nullable: true), + OutputJson = table.Column(type: "jsonb", nullable: true), + Error = table.Column(type: "text", nullable: true), + CorrelationId = table.Column(type: "character varying(128)", maxLength: 128, nullable: true), + StartedAt = table.Column(type: "timestamp with time zone", nullable: true), + FinishedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_WorkflowRuns", x => x.Id); + table.ForeignKey( + name: "FK_WorkflowRuns_Workflows_WorkflowId", + column: x => x.WorkflowId, + principalSchema: "workflows", + principalTable: "Workflows", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "TaskRuns", + schema: "workflows", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RunId = table.Column(type: "uuid", nullable: false), + TaskId = table.Column(type: "uuid", nullable: false), + Attempt = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + InputJson = table.Column(type: "jsonb", nullable: true), + OutputJson = table.Column(type: "jsonb", nullable: true), + Error = table.Column(type: "text", nullable: true), + RetryCount = table.Column(type: "integer", nullable: false), + StartedAt = table.Column(type: "timestamp with time zone", nullable: true), + FinishedAt = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TaskRuns", x => x.Id); + table.ForeignKey( + name: "FK_TaskRuns_Tasks_TaskId", + column: x => x.TaskId, + principalSchema: "workflows", + principalTable: "Tasks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TaskRuns_WorkflowRuns_RunId", + column: x => x.RunId, + principalSchema: "workflows", + principalTable: "WorkflowRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ApiKeys_TenantId", + schema: "workflows", + table: "ApiKeys", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_DurableStates_CorrelationId", + schema: "workflows", + table: "DurableStates", + column: "CorrelationId"); + + migrationBuilder.CreateIndex( + name: "IX_TaskRuns_RunId", + schema: "workflows", + table: "TaskRuns", + column: "RunId"); + + migrationBuilder.CreateIndex( + name: "IX_TaskRuns_TaskId", + schema: "workflows", + table: "TaskRuns", + column: "TaskId"); + + migrationBuilder.CreateIndex( + name: "IX_Tasks_WorkflowId", + schema: "workflows", + table: "Tasks", + column: "WorkflowId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkflowRuns_CorrelationId", + schema: "workflows", + table: "WorkflowRuns", + column: "CorrelationId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkflowRuns_TenantId", + schema: "workflows", + table: "WorkflowRuns", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_WorkflowRuns_WorkflowId", + schema: "workflows", + table: "WorkflowRuns", + column: "WorkflowId"); + + migrationBuilder.CreateIndex( + name: "IX_Workflows_TenantId", + schema: "workflows", + table: "Workflows", + column: "TenantId"); + + migrationBuilder.CreateIndex( + name: "IX_Workflows_TenantId_Name", + schema: "workflows", + table: "Workflows", + columns: new[] { "TenantId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys", + schema: "workflows"); + + migrationBuilder.DropTable( + name: "DurableStates", + schema: "workflows"); + + migrationBuilder.DropTable( + name: "TaskRuns", + schema: "workflows"); + + migrationBuilder.DropTable( + name: "Tasks", + schema: "workflows"); + + migrationBuilder.DropTable( + name: "WorkflowRuns", + schema: "workflows"); + + migrationBuilder.DropTable( + name: "Workflows", + schema: "workflows"); + } + } +} diff --git a/Data/Migrations/20260828134158_AddTaskKey.Designer.cs b/Data/Migrations/20260828134158_AddTaskKey.Designer.cs new file mode 100644 index 0000000..7995f1f --- /dev/null +++ b/Data/Migrations/20260828134158_AddTaskKey.Designer.cs @@ -0,0 +1,355 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using w4c_workflows.Data; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + [DbContext(typeof(WorkflowsDbContext))] + [Migration("20260828134158_AddTaskKey")] + partial class AddTaskKey + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workflows") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("w4c_workflows.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopesJson") + .HasColumnType("jsonb"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ApiKeys", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.DurableState", b => + { + b.Property("InstanceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CheckpointAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("InstanceId"); + + b.HasIndex("CorrelationId"); + + b.ToTable("DurableStates", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.HasIndex("TaskId"); + + b.ToTable("TaskRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompiledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Workflows", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TenantId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("WorkflowRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryJson") + .HasColumnType("jsonb"); + + b.Property("EnvJson") + .HasColumnType("jsonb"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("NextId") + .HasColumnType("uuid"); + + b.Property("OnErrorId") + .HasColumnType("uuid"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowId"); + + b.ToTable("Tasks", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.HasOne("w4c_workflows.Models.WorkflowRun", "Run") + .WithMany("TaskRuns") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("w4c_workflows.Models.WorkflowTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany() + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Navigation("TaskRuns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260828134158_AddTaskKey.cs b/Data/Migrations/20260828134158_AddTaskKey.cs new file mode 100644 index 0000000..7b9a10d --- /dev/null +++ b/Data/Migrations/20260828134158_AddTaskKey.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + /// + public partial class AddTaskKey : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Key", + schema: "workflows", + table: "Tasks", + type: "character varying(100)", + maxLength: 100, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Key", + schema: "workflows", + table: "Tasks"); + } + } +} diff --git a/Data/Migrations/20260828144910_AddRunStartTaskId.Designer.cs b/Data/Migrations/20260828144910_AddRunStartTaskId.Designer.cs new file mode 100644 index 0000000..9f49685 --- /dev/null +++ b/Data/Migrations/20260828144910_AddRunStartTaskId.Designer.cs @@ -0,0 +1,358 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using w4c_workflows.Data; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + [DbContext(typeof(WorkflowsDbContext))] + [Migration("20260828144910_AddRunStartTaskId")] + partial class AddRunStartTaskId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workflows") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("w4c_workflows.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopesJson") + .HasColumnType("jsonb"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ApiKeys", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.DurableState", b => + { + b.Property("InstanceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CheckpointAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("InstanceId"); + + b.HasIndex("CorrelationId"); + + b.ToTable("DurableStates", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.HasIndex("TaskId"); + + b.ToTable("TaskRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompiledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Workflows", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("StartTaskId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TenantId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("WorkflowRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryJson") + .HasColumnType("jsonb"); + + b.Property("EnvJson") + .HasColumnType("jsonb"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("NextId") + .HasColumnType("uuid"); + + b.Property("OnErrorId") + .HasColumnType("uuid"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowId"); + + b.ToTable("Tasks", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.HasOne("w4c_workflows.Models.WorkflowRun", "Run") + .WithMany("TaskRuns") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("w4c_workflows.Models.WorkflowTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany() + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Navigation("TaskRuns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260828144910_AddRunStartTaskId.cs b/Data/Migrations/20260828144910_AddRunStartTaskId.cs new file mode 100644 index 0000000..d740762 --- /dev/null +++ b/Data/Migrations/20260828144910_AddRunStartTaskId.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + /// + public partial class AddRunStartTaskId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "StartTaskId", + schema: "workflows", + table: "WorkflowRuns", + type: "uuid", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "StartTaskId", + schema: "workflows", + table: "WorkflowRuns"); + } + } +} diff --git a/Data/Migrations/20260828150443_AddSagaCompensation.Designer.cs b/Data/Migrations/20260828150443_AddSagaCompensation.Designer.cs new file mode 100644 index 0000000..dd84965 --- /dev/null +++ b/Data/Migrations/20260828150443_AddSagaCompensation.Designer.cs @@ -0,0 +1,367 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using w4c_workflows.Data; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + [DbContext(typeof(WorkflowsDbContext))] + [Migration("20260828150443_AddSagaCompensation")] + partial class AddSagaCompensation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workflows") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("w4c_workflows.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopesJson") + .HasColumnType("jsonb"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ApiKeys", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.DurableState", b => + { + b.Property("InstanceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CheckpointAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("InstanceId"); + + b.HasIndex("CorrelationId"); + + b.ToTable("DurableStates", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("IsCompensation") + .HasColumnType("boolean"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.HasIndex("TaskId"); + + b.ToTable("TaskRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompiledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Workflows", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompensationPlanJson") + .HasColumnType("jsonb"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("StartTaskId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TenantId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("WorkflowRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryJson") + .HasColumnType("jsonb"); + + b.Property("EnvJson") + .HasColumnType("jsonb"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("NextId") + .HasColumnType("uuid"); + + b.Property("OnErrorId") + .HasColumnType("uuid"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowId"); + + b.ToTable("Tasks", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.HasOne("w4c_workflows.Models.WorkflowRun", "Run") + .WithMany("TaskRuns") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("w4c_workflows.Models.WorkflowTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany() + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Navigation("TaskRuns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260828150443_AddSagaCompensation.cs b/Data/Migrations/20260828150443_AddSagaCompensation.cs new file mode 100644 index 0000000..77405a1 --- /dev/null +++ b/Data/Migrations/20260828150443_AddSagaCompensation.cs @@ -0,0 +1,56 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + /// + public partial class AddSagaCompensation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CompensationPlanJson", + schema: "workflows", + table: "WorkflowRuns", + type: "jsonb", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsCompensation", + schema: "workflows", + table: "TaskRuns", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "NextAttemptAt", + schema: "workflows", + table: "TaskRuns", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CompensationPlanJson", + schema: "workflows", + table: "WorkflowRuns"); + + migrationBuilder.DropColumn( + name: "IsCompensation", + schema: "workflows", + table: "TaskRuns"); + + migrationBuilder.DropColumn( + name: "NextAttemptAt", + schema: "workflows", + table: "TaskRuns"); + } + } +} diff --git a/Data/Migrations/20260828152534_AddTriggerEnabled.Designer.cs b/Data/Migrations/20260828152534_AddTriggerEnabled.Designer.cs new file mode 100644 index 0000000..33de402 --- /dev/null +++ b/Data/Migrations/20260828152534_AddTriggerEnabled.Designer.cs @@ -0,0 +1,370 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using w4c_workflows.Data; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + [DbContext(typeof(WorkflowsDbContext))] + [Migration("20260828152534_AddTriggerEnabled")] + partial class AddTriggerEnabled + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workflows") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("w4c_workflows.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopesJson") + .HasColumnType("jsonb"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ApiKeys", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.DurableState", b => + { + b.Property("InstanceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CheckpointAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("InstanceId"); + + b.HasIndex("CorrelationId"); + + b.ToTable("DurableStates", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("IsCompensation") + .HasColumnType("boolean"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.HasIndex("TaskId"); + + b.ToTable("TaskRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompiledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerEnabled") + .HasColumnType("boolean"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Workflows", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompensationPlanJson") + .HasColumnType("jsonb"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("StartTaskId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TenantId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("WorkflowRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryJson") + .HasColumnType("jsonb"); + + b.Property("EnvJson") + .HasColumnType("jsonb"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("NextId") + .HasColumnType("uuid"); + + b.Property("OnErrorId") + .HasColumnType("uuid"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowId"); + + b.ToTable("Tasks", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.HasOne("w4c_workflows.Models.WorkflowRun", "Run") + .WithMany("TaskRuns") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("w4c_workflows.Models.WorkflowTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany() + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Navigation("TaskRuns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260828152534_AddTriggerEnabled.cs b/Data/Migrations/20260828152534_AddTriggerEnabled.cs new file mode 100644 index 0000000..54791d2 --- /dev/null +++ b/Data/Migrations/20260828152534_AddTriggerEnabled.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + /// + public partial class AddTriggerEnabled : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "TriggerEnabled", + schema: "workflows", + table: "Workflows", + type: "boolean", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "TriggerEnabled", + schema: "workflows", + table: "Workflows"); + } + } +} diff --git a/Data/Migrations/20260829101824_AddWorkflowVersion.Designer.cs b/Data/Migrations/20260829101824_AddWorkflowVersion.Designer.cs new file mode 100644 index 0000000..4cbbfbd --- /dev/null +++ b/Data/Migrations/20260829101824_AddWorkflowVersion.Designer.cs @@ -0,0 +1,374 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using w4c_workflows.Data; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + [DbContext(typeof(WorkflowsDbContext))] + [Migration("20260829101824_AddWorkflowVersion")] + partial class AddWorkflowVersion + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workflows") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("w4c_workflows.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopesJson") + .HasColumnType("jsonb"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ApiKeys", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.DurableState", b => + { + b.Property("InstanceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CheckpointAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("InstanceId"); + + b.HasIndex("CorrelationId"); + + b.ToTable("DurableStates", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("IsCompensation") + .HasColumnType("boolean"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.HasIndex("TaskId"); + + b.ToTable("TaskRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompiledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerEnabled") + .HasColumnType("boolean"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Workflows", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompensationPlanJson") + .HasColumnType("jsonb"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("StartTaskId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TenantId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("WorkflowRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryJson") + .HasColumnType("jsonb"); + + b.Property("EnvJson") + .HasColumnType("jsonb"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("NextId") + .HasColumnType("uuid"); + + b.Property("OnErrorId") + .HasColumnType("uuid"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowId"); + + b.ToTable("Tasks", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.HasOne("w4c_workflows.Models.WorkflowRun", "Run") + .WithMany("TaskRuns") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("w4c_workflows.Models.WorkflowTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany() + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Navigation("TaskRuns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260829101824_AddWorkflowVersion.cs b/Data/Migrations/20260829101824_AddWorkflowVersion.cs new file mode 100644 index 0000000..2d09023 --- /dev/null +++ b/Data/Migrations/20260829101824_AddWorkflowVersion.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + /// + public partial class AddWorkflowVersion : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Version", + schema: "workflows", + table: "Workflows", + type: "text", + nullable: false, + defaultValue: "1.0.0"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Version", + schema: "workflows", + table: "Workflows"); + } + } +} diff --git a/Data/Migrations/WorkflowsDbContextModelSnapshot.cs b/Data/Migrations/WorkflowsDbContextModelSnapshot.cs new file mode 100644 index 0000000..82c9aa9 --- /dev/null +++ b/Data/Migrations/WorkflowsDbContextModelSnapshot.cs @@ -0,0 +1,371 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using w4c_workflows.Data; + +#nullable disable + +namespace w4c_workflows.Data.Migrations +{ + [DbContext(typeof(WorkflowsDbContext))] + partial class WorkflowsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("workflows") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("w4c_workflows.Models.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("KeyHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ScopesJson") + .HasColumnType("jsonb"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.ToTable("ApiKeys", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.DurableState", b => + { + b.Property("InstanceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CheckpointAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("StateJson") + .HasColumnType("jsonb"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("InstanceId"); + + b.HasIndex("CorrelationId"); + + b.ToTable("DurableStates", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("IsCompensation") + .HasColumnType("boolean"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("RetryCount") + .HasColumnType("integer"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TaskId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.HasIndex("TaskId"); + + b.ToTable("TaskRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompiledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GitSha") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Target") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerEnabled") + .HasColumnType("boolean"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TenantId"); + + b.HasIndex("TenantId", "Name") + .IsUnique(); + + b.ToTable("Workflows", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompensationPlanJson") + .HasColumnType("jsonb"); + + b.Property("CorrelationId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InputJson") + .HasColumnType("jsonb"); + + b.Property("OutputJson") + .HasColumnType("jsonb"); + + b.Property("StartTaskId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TriggerJson") + .HasColumnType("jsonb"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("TenantId"); + + b.HasIndex("WorkflowId"); + + b.ToTable("WorkflowRuns", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryJson") + .HasColumnType("jsonb"); + + b.Property("EnvJson") + .HasColumnType("jsonb"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("NextId") + .HasColumnType("uuid"); + + b.Property("OnErrorId") + .HasColumnType("uuid"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("WorkflowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WorkflowId"); + + b.ToTable("Tasks", "workflows"); + }); + + modelBuilder.Entity("w4c_workflows.Models.TaskRun", b => + { + b.HasOne("w4c_workflows.Models.WorkflowRun", "Run") + .WithMany("TaskRuns") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("w4c_workflows.Models.WorkflowTask", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Run"); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany() + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowTask", b => + { + b.HasOne("w4c_workflows.Models.Workflow", "Workflow") + .WithMany("Tasks") + .HasForeignKey("WorkflowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Workflow"); + }); + + modelBuilder.Entity("w4c_workflows.Models.Workflow", b => + { + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("w4c_workflows.Models.WorkflowRun", b => + { + b.Navigation("TaskRuns"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/WorkflowsDbContext.cs b/Data/WorkflowsDbContext.cs new file mode 100644 index 0000000..94ad91f --- /dev/null +++ b/Data/WorkflowsDbContext.cs @@ -0,0 +1,133 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using w4c_workflows.Models; + +namespace w4c_workflows.Data; + +public class WorkflowsDbContext : DbContext +{ + public WorkflowsDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + + // Additive schema (WorkflowTask.ArchivedAt for W7, WorkflowTask.Server for S8) is + // applied by idempotent raw SQL at startup — the project's house rule forbids new + // EF migrations. Suppress EF Core 10's "pending model changes" error so + // MigrateAsync (which only applies recorded migrations) does not reject those + // migration-less columns on a long-lived database. Runtime queries are unaffected. + optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); + } + + public DbSet Workflows => Set(); + public DbSet Tasks => Set(); + public DbSet WorkflowRuns => Set(); + public DbSet TaskRuns => Set(); + public DbSet DurableStates => Set(); + public DbSet ApiKeys => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Separate schema so the workflow tables never collide with the rest of + // the shared w4c database. + modelBuilder.HasDefaultSchema("workflows"); + + modelBuilder.Entity(e => + { + e.HasKey(w => w.Id); + e.Property(w => w.Id).ValueGeneratedOnAdd(); + e.Property(w => w.Name).HasMaxLength(200).IsRequired(); + e.Property(w => w.Path).HasMaxLength(500).IsRequired(); + e.Property(w => w.GitSha).HasMaxLength(64); + e.Property(w => w.Status).HasMaxLength(32).IsRequired(); + e.Property(w => w.Mode).HasMaxLength(32).IsRequired(); + e.Property(w => w.Target).HasMaxLength(100).IsRequired(); + e.Property(w => w.TriggerJson).HasColumnType("jsonb"); + e.HasIndex(w => new { w.TenantId, w.Name }).IsUnique(); + e.HasIndex(w => w.TenantId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(t => t.Id); + e.Property(t => t.Id).ValueGeneratedOnAdd(); + e.Property(t => t.Key).HasMaxLength(100).IsRequired(); + e.Property(t => t.Language).HasMaxLength(32).IsRequired(); + e.Property(t => t.Mode).HasMaxLength(32).IsRequired(); + e.Property(t => t.EntryJson).HasColumnType("jsonb"); + e.Property(t => t.EnvJson).HasColumnType("jsonb"); + e.HasOne(t => t.Workflow) + .WithMany(w => w.Tasks) + .HasForeignKey(t => t.WorkflowId) + .OnDelete(DeleteBehavior.Cascade); + e.HasIndex(t => t.WorkflowId); + + // Preserve historical TaskRuns when a task is removed from the YAML: + // removed tasks are archived (ArchivedAt != null) rather than deleted, and + // every "active workflow definition" query excludes them automatically. + e.HasQueryFilter(t => t.ArchivedAt == null); + }); + + modelBuilder.Entity(e => + { + e.HasKey(r => r.Id); + e.Property(r => r.Id).ValueGeneratedOnAdd(); + e.Property(r => r.Status).HasMaxLength(32).IsRequired(); + e.Property(r => r.TriggerJson).HasColumnType("jsonb"); + e.Property(r => r.InputJson).HasColumnType("jsonb"); + e.Property(r => r.OutputJson).HasColumnType("jsonb"); + e.Property(r => r.Error).HasColumnType("text"); + e.Property(r => r.CorrelationId).HasMaxLength(128); + e.Property(r => r.StartTaskId); + e.Property(r => r.CompensationPlanJson).HasColumnType("jsonb"); + e.HasOne(r => r.Workflow) + .WithMany() + .HasForeignKey(r => r.WorkflowId) + .OnDelete(DeleteBehavior.Restrict); + e.HasIndex(r => r.TenantId); + e.HasIndex(r => r.CorrelationId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(t => t.Id); + e.Property(t => t.Id).ValueGeneratedOnAdd(); + e.Property(t => t.Status).HasMaxLength(32).IsRequired(); + e.Property(t => t.InputJson).HasColumnType("jsonb"); + e.Property(t => t.OutputJson).HasColumnType("jsonb"); + e.Property(t => t.Error).HasColumnType("text"); + e.HasOne(t => t.Run) + .WithMany(r => r.TaskRuns) + .HasForeignKey(t => t.RunId) + .OnDelete(DeleteBehavior.Cascade); + e.HasOne(t => t.Task) + .WithMany() + .HasForeignKey(t => t.TaskId) + .OnDelete(DeleteBehavior.Restrict); + e.HasIndex(t => t.RunId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(d => d.InstanceId); + e.Property(d => d.InstanceId).HasMaxLength(128); + e.Property(d => d.StateJson).HasColumnType("jsonb"); + e.Property(d => d.CorrelationId).HasMaxLength(128); + e.HasIndex(d => d.CorrelationId); + }); + + modelBuilder.Entity(e => + { + e.HasKey(k => k.Id); + e.Property(k => k.Id).ValueGeneratedOnAdd(); + e.Property(k => k.TenantId).IsRequired(); + e.Property(k => k.KeyHash).HasMaxLength(64).IsRequired(); + e.Property(k => k.Label).HasMaxLength(200).IsRequired(); + e.Property(k => k.ScopesJson).HasColumnType("jsonb"); + e.HasIndex(k => k.TenantId); + }); + } +} diff --git a/Data/WorkflowsDbContextFactory.cs b/Data/WorkflowsDbContextFactory.cs new file mode 100644 index 0000000..7360d09 --- /dev/null +++ b/Data/WorkflowsDbContextFactory.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace w4c_workflows.Data; + +/// +/// Design-time factory so `dotnet ef` builds the context directly from +/// appsettings.json instead of running the web host (Program.cs applies +/// migrations at startup, which must not execute during tooling runs). +/// +public class WorkflowsDbContextFactory : IDesignTimeDbContextFactory +{ + public WorkflowsDbContext CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: true) + .AddEnvironmentVariables() + .Build(); + + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? "Host=localhost;Port=5432;Database=w4c;Username=w4c;Password=w4c"; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + + return new WorkflowsDbContext(options); + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..569e3f8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# w4c-workflows — one image, two entrypoints: +# - api : control plane (default entrypoint) +# - worker : per-tenant execution worker (override command with ["--worker"]) +# +# The worker sandbox needs node, python and esbuild at runtime. The C# executor +# (Roslyn) ships inside the published app as a NuGet package — no SDK needed in +# the runtime image. Build on the SDK image and copy the app into the runtime. +FROM mcr.microsoft.com/dotnet/sdk:11.0-preview AS build +WORKDIR /src +COPY *.csproj . +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app + +FROM mcr.microsoft.com/dotnet/aspnet:11.0-preview +WORKDIR /app +COPY --from=build /app . + +# Worker sandbox runtimes: python3 + node + esbuild. The api image does not +# strictly need them, but keeping them makes the image a true drop-in worker. +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 nodejs npm ca-certificates \ + && npm install -g esbuild \ + && rm -rf /var/lib/apt/lists/* + +# Run as an unprivileged user (defense-in-depth: the container is the sandbox +# boundary and subprocess/Roslyn execute as this user). /tmp stays world-writable +# for the TypeScript executor's esbuild bundle; the workflow code checkout is +# mounted read-only (see compose) and only read, never written. +RUN useradd --uid 10001 --create-home --shell /usr/sbin/nologin appuser +USER appuser + +EXPOSE 5259 +ENV ASPNETCORE_URLS=http://+:5259 +ENTRYPOINT ["dotnet", "w4c-workflows-api.dll"] diff --git a/Filters/RequireScopeAttribute.cs b/Filters/RequireScopeAttribute.cs new file mode 100644 index 0000000..3d86c0e --- /dev/null +++ b/Filters/RequireScopeAttribute.cs @@ -0,0 +1,58 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace w4c_workflows.Filters; + +/// +/// Authorization filter that checks the operator-key scopes resolved by +/// . Scopes are stored in +/// HttpContext.Items["Scopes"] as an IReadOnlyList<string>. +/// +/// Usage: [RequireScope("manage")] on a controller or action method. +/// Multiple attributes are OR-combined (any listed scope satisfies the check). +/// When no scopes are present (JWT auth, render-token auth), the check passes +/// — those surfaces have their own auth gates. +/// +[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true)] +public class RequireScopeAttribute : Attribute, IAsyncActionFilter +{ + private readonly string _scope; + + public RequireScopeAttribute(string scope) + { + _scope = scope; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + // JWT auth and render-token auth don't carry scopes — they have their + // own authorization gates, so skip the scope check. + var authKind = context.HttpContext.Items["AuthKind"] as string; + if (authKind is "jwt" or "render-token") + { + await next(); + return; + } + + var scopes = context.HttpContext.Items["Scopes"] as IReadOnlyList; + // A key with no recorded scope set predates scope enforcement (older/seed keys). + // Treat it as unlimited rather than silently locking the operator out; a key that + // actually declares a scope set is still enforced below. + if (scopes == null || scopes.Count == 0) + { + await next(); + return; + } + + if (!scopes.Contains(_scope, StringComparer.Ordinal)) + { + context.Result = new ObjectResult(new { error = $"Operator key requires the '{_scope}' scope." }) + { + StatusCode = StatusCodes.Status403Forbidden, + }; + return; + } + + await next(); + } +} diff --git a/Middleware/AuthMiddleware.cs b/Middleware/AuthMiddleware.cs new file mode 100644 index 0000000..819c96b --- /dev/null +++ b/Middleware/AuthMiddleware.cs @@ -0,0 +1,139 @@ +using w4c_workflows.Services; + +namespace w4c_workflows.Middleware; + +/// +/// Two auth surfaces for the control plane: +/// - /api/keys* → the tenant's main JWT (shared Auth:JwtSigningKey). Lets a +/// logged-in tenant mint/rotate/revoke its operator keys. +/// - everything else → per-tenant operator key (Authorization: Bearer ). +/// Used by both the frontend and the per-tenant worker. +/// Resolved identity is exposed via HttpContext.Items: TenantId, Scopes, +/// AuthKind ("jwt" | "operator"). +/// +public class AuthMiddleware +{ + private static readonly PathString[] PublicPaths = + { + new("/health"), + new("/health/ready"), + new("/openapi"), + new("/scalar"), // interactive API explorer (Scalar) — no operator key needed + new("/h"), // webhook receiver (external callers have no operator key) + }; + + private readonly RequestDelegate _next; + private readonly string _signingKey; + private readonly ILogger _logger; + + public AuthMiddleware(RequestDelegate next, IConfiguration config, ILogger logger) + { + _next = next; + _signingKey = config["Auth:JwtSigningKey"] ?? string.Empty; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path; + + if (IsPublic(path)) + { + await _next(context); + return; + } + + // /api/keys* — main JWT surface. + if (path.StartsWithSegments("/api/keys")) + { + var principal = JwtValidator.Validate(context.Request.Headers.Authorization.ToString(), _signingKey); + var tenantId = principal?.FindFirst("tenant_id")?.Value; + if (principal == null || string.IsNullOrEmpty(tenantId)) + { + await Unauthorized(context, "Valid main JWT required for key management"); + return; + } + + context.Items["TenantId"] = tenantId; + context.Items["AuthKind"] = "jwt"; + await _next(context); + return; + } + + // The rich HTML preview endpoint (/api/workflows/{id}/html) is reached + // from an