Initial: workflows API control plane + tests + YAML workflow definitions
This commit is contained in:
commit
6042171fc2
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
|
|
@ -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
|
||||
52
Controllers/HealthController.cs
Normal file
52
Controllers/HealthController.cs
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Liveness — the process is up.</summary>
|
||||
[HttpGet("/health")]
|
||||
public IActionResult Health() => Ok(new { status = "ok" });
|
||||
|
||||
/// <summary>Readiness — Postgres + Redis are reachable.</summary>
|
||||
[HttpGet("/health/ready")]
|
||||
public async Task<IActionResult> Ready(CancellationToken ct)
|
||||
{
|
||||
var checks = new Dictionary<string, string>();
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
113
Controllers/KeysController.cs
Normal file
113
Controllers/KeysController.cs
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using w4c_workflows.Services;
|
||||
|
||||
namespace w4c_workflows.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/keys")]
|
||||
public class KeysController : ControllerBase
|
||||
{
|
||||
private static readonly string[] AllScopes = { "manage", "run", "read" };
|
||||
|
||||
private readonly ApiKeyService _keys;
|
||||
private readonly ILogger<KeysController> _logger;
|
||||
|
||||
public KeysController(ApiKeyService keys, ILogger<KeysController> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[HttpPost("bootstrap")]
|
||||
public async Task<IActionResult> 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." });
|
||||
}
|
||||
|
||||
/// <summary>Mints a new operator key (rotation).</summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> 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." });
|
||||
}
|
||||
|
||||
/// <summary>Lists the tenant's keys (hashes are never exposed).</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Revokes an operator key.</summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> 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<string> { "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<string>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
141
Controllers/RunsController.cs
Normal file
141
Controllers/RunsController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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<RunSummary> 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");
|
||||
|
||||
/// <summary>
|
||||
/// Lists the tenant's runs, newest first, optionally filtered to one workflow.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[RequireScope("read")]
|
||||
public async Task<IActionResult> 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));
|
||||
}
|
||||
|
||||
/// <summary>Returns one run plus its task-run summary.</summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequireScope("read")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>Returns the task runs of one run, in dispatch order.</summary>
|
||||
[HttpGet("{id:guid}/tasks")]
|
||||
[RequireScope("read")]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
}
|
||||
33
Controllers/RuntimesController.cs
Normal file
33
Controllers/RuntimesController.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using w4c_workflows.Filters;
|
||||
using w4c_workflows.Services.Execution;
|
||||
|
||||
namespace w4c_workflows.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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,
|
||||
}));
|
||||
}
|
||||
133
Controllers/WebhooksController.cs
Normal file
133
Controllers/WebhooksController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Webhook receiver for workflows with <c>trigger.type = webhook</c>. The route
|
||||
/// is PUBLIC (external callers have no operator key); access is governed by an
|
||||
/// optional shared secret (<c>Workflows:WebhookSecret</c>) verified against the
|
||||
/// <c>X-Webhook-Secret</c> header. When no secret is configured the endpoint is
|
||||
/// open — acceptable for v1 dev, hardened in step 13.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("/h")]
|
||||
public class WebhooksController : ControllerBase
|
||||
{
|
||||
private readonly WorkflowsDbContext _db;
|
||||
private readonly IRunLauncher _launcher;
|
||||
private readonly ILogger<WebhooksController> _logger;
|
||||
private readonly string? _sharedSecret;
|
||||
|
||||
public WebhooksController(
|
||||
WorkflowsDbContext db,
|
||||
IRunLauncher launcher,
|
||||
IConfiguration config,
|
||||
ILogger<WebhooksController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_launcher = launcher;
|
||||
_logger = logger;
|
||||
_sharedSecret = string.IsNullOrWhiteSpace(config["Workflows:WebhookSecret"])
|
||||
? null
|
||||
: config["Workflows:WebhookSecret"];
|
||||
}
|
||||
|
||||
/// <summary>Receives a webhook at <c>/h/{path}</c> and fires every matching workflow.</summary>
|
||||
[HttpPost("{**path}")]
|
||||
public async Task<IActionResult> 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<Guid>(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<List<Workflow>> 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<Workflow>();
|
||||
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<string?> 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;
|
||||
}
|
||||
}
|
||||
415
Controllers/WorkflowsController.cs
Normal file
415
Controllers/WorkflowsController.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Workflow definitions + git sync. All endpoints authenticate with the
|
||||
/// tenant's operator key (see AuthMiddleware).
|
||||
/// </summary>
|
||||
[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<WorkflowsController> _logger;
|
||||
|
||||
public WorkflowsController(
|
||||
WorkflowsDbContext db,
|
||||
WorkflowSyncService sync,
|
||||
IWorkflowSource source,
|
||||
MermaidGeneratorService mermaid,
|
||||
WorkflowHtmlRenderer html,
|
||||
RenderTokenService renderTokens,
|
||||
IRunLauncher launcher,
|
||||
DurableStateStore durable,
|
||||
ILogger<WorkflowsController> 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");
|
||||
|
||||
/// <summary>Lists compiled workflows with git sync status + latest run.</summary>
|
||||
[HttpGet]
|
||||
[RequireScope("read")]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>POST /api/workflows/sync</c>.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
[RequireScope("read")]
|
||||
public async Task<IActionResult> 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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Returns Mermaid source for a workflow diagram type.</summary>
|
||||
[HttpGet("{id:guid}/diagrams/{type}")]
|
||||
[RequireScope("read")]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mints a short-lived signed token that authorizes the iframe HTML preview
|
||||
/// (<c>GET /api/workflows/{id}/html?token=...</c>). 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.
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}/html-token")]
|
||||
[RequireScope("read")]
|
||||
public async Task<IActionResult> 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) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a workflow as a self-contained, non-Mermaid HTML document (rich
|
||||
/// detail: success chain, compensation edges, full task inventory). Served as
|
||||
/// <c>text/html</c> so ANY client can embed it in an <c><iframe></c> —
|
||||
/// no client-side diagram library is needed. Auth: either the operator-key
|
||||
/// header (API use) or a signed <c>?token=</c> (iframe use).
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}/html")]
|
||||
[RequireScope("read")]
|
||||
public async Task<IActionResult> 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<WorkflowDefinition>(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");
|
||||
}
|
||||
|
||||
/// <summary>Re-reads workflows/*.yaml from git, compiles, and updates the DB.</summary>
|
||||
[HttpPost("sync")]
|
||||
[RequireScope("manage")]
|
||||
public async Task<IActionResult> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Manual/event trigger: starts a run of a compiled workflow immediately.
|
||||
/// The run is created <c>pending</c>; the run lifecycle engine (step 9)
|
||||
/// dispatches and drives it. Returns 202 with the run id.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/run")]
|
||||
[RequireScope("run")]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>{actionName}</c>, NOT <c>{action}</c> — MVC
|
||||
/// reserves <c>action</c> as a route value, so <c>{action}</c> in an
|
||||
/// attribute template makes the endpoint unreachable at runtime (404 on the
|
||||
/// very route OpenAPI advertises).
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/trigger/{actionName}")]
|
||||
[RequireScope("manage")]
|
||||
public async Task<IActionResult> 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);
|
||||
|
||||
/// <summary>
|
||||
/// Durable resume: continues a <c>durable</c>/<c>handler</c> 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
|
||||
/// <c>StartTaskId</c> so history shows each execution.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/instances/{instanceId}/resume")]
|
||||
[RequireScope("run")]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
}
|
||||
350
Data/Migrations/20260828130001_InitialCreate.Designer.cs
generated
Normal file
350
Data/Migrations/20260828130001_InitialCreate.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ScopesJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("ApiKeys", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.DurableState", b =>
|
||||
{
|
||||
b.Property<string>("InstanceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CheckpointAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StateJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("InstanceId");
|
||||
|
||||
b.HasIndex("CorrelationId");
|
||||
|
||||
b.ToTable("DurableStates", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.TaskRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Attempt")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<int>("RetryCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("RunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CompiledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GitSha")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntryJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("EnvJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("NextId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("OnErrorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
258
Data/Migrations/20260828130001_InitialCreate.cs
Normal file
258
Data/Migrations/20260828130001_InitialCreate.cs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace w4c_workflows.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "workflows");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ApiKeys",
|
||||
schema: "workflows",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TenantId = table.Column<string>(type: "text", nullable: false),
|
||||
KeyHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
Label = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
ScopesJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
LastUsedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
RevokedAt = table.Column<DateTime>(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<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
TaskId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
StateJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
CheckpointAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
CorrelationId = table.Column<string>(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<Guid>(type: "uuid", nullable: false),
|
||||
TenantId = table.Column<string>(type: "text", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Path = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
|
||||
GitSha = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
TriggerJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
Target = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
CompiledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
WorkflowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ParentId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
NextId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
OnErrorId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
Language = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
Mode = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
EntryJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
EnvJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
Order = table.Column<int>(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<Guid>(type: "uuid", nullable: false),
|
||||
WorkflowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TenantId = table.Column<string>(type: "text", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
TriggerJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
InputJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
OutputJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
Error = table.Column<string>(type: "text", nullable: true),
|
||||
CorrelationId = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
|
||||
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
FinishedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
|
||||
RunId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TaskId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Attempt = table.Column<int>(type: "integer", nullable: false),
|
||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
InputJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
OutputJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||
Error = table.Column<string>(type: "text", nullable: true),
|
||||
RetryCount = table.Column<int>(type: "integer", nullable: false),
|
||||
StartedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
FinishedAt = table.Column<DateTime>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
355
Data/Migrations/20260828134158_AddTaskKey.Designer.cs
generated
Normal file
355
Data/Migrations/20260828134158_AddTaskKey.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ScopesJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("ApiKeys", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.DurableState", b =>
|
||||
{
|
||||
b.Property<string>("InstanceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CheckpointAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StateJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("InstanceId");
|
||||
|
||||
b.HasIndex("CorrelationId");
|
||||
|
||||
b.ToTable("DurableStates", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.TaskRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Attempt")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<int>("RetryCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("RunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CompiledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GitSha")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntryJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("EnvJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("NextId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("OnErrorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
32
Data/Migrations/20260828134158_AddTaskKey.cs
Normal file
32
Data/Migrations/20260828134158_AddTaskKey.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace w4c_workflows.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaskKey : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Key",
|
||||
schema: "workflows",
|
||||
table: "Tasks",
|
||||
type: "character varying(100)",
|
||||
maxLength: 100,
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Key",
|
||||
schema: "workflows",
|
||||
table: "Tasks");
|
||||
}
|
||||
}
|
||||
}
|
||||
358
Data/Migrations/20260828144910_AddRunStartTaskId.Designer.cs
generated
Normal file
358
Data/Migrations/20260828144910_AddRunStartTaskId.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ScopesJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("ApiKeys", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.DurableState", b =>
|
||||
{
|
||||
b.Property<string>("InstanceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CheckpointAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StateJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("InstanceId");
|
||||
|
||||
b.HasIndex("CorrelationId");
|
||||
|
||||
b.ToTable("DurableStates", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.TaskRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Attempt")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<int>("RetryCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("RunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CompiledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GitSha")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid?>("StartTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntryJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("EnvJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("NextId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("OnErrorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Data/Migrations/20260828144910_AddRunStartTaskId.cs
Normal file
31
Data/Migrations/20260828144910_AddRunStartTaskId.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace w4c_workflows.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRunStartTaskId : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "StartTaskId",
|
||||
schema: "workflows",
|
||||
table: "WorkflowRuns",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StartTaskId",
|
||||
schema: "workflows",
|
||||
table: "WorkflowRuns");
|
||||
}
|
||||
}
|
||||
}
|
||||
367
Data/Migrations/20260828150443_AddSagaCompensation.Designer.cs
generated
Normal file
367
Data/Migrations/20260828150443_AddSagaCompensation.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ScopesJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("ApiKeys", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.DurableState", b =>
|
||||
{
|
||||
b.Property<string>("InstanceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CheckpointAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StateJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("InstanceId");
|
||||
|
||||
b.HasIndex("CorrelationId");
|
||||
|
||||
b.ToTable("DurableStates", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.TaskRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Attempt")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<bool>("IsCompensation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("NextAttemptAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<int>("RetryCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("RunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CompiledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GitSha")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CompensationPlanJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid?>("StartTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntryJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("EnvJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("NextId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("OnErrorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
56
Data/Migrations/20260828150443_AddSagaCompensation.cs
Normal file
56
Data/Migrations/20260828150443_AddSagaCompensation.cs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace w4c_workflows.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSagaCompensation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CompensationPlanJson",
|
||||
schema: "workflows",
|
||||
table: "WorkflowRuns",
|
||||
type: "jsonb",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCompensation",
|
||||
schema: "workflows",
|
||||
table: "TaskRuns",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "NextAttemptAt",
|
||||
schema: "workflows",
|
||||
table: "TaskRuns",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
370
Data/Migrations/20260828152534_AddTriggerEnabled.Designer.cs
generated
Normal file
370
Data/Migrations/20260828152534_AddTriggerEnabled.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ScopesJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("ApiKeys", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.DurableState", b =>
|
||||
{
|
||||
b.Property<string>("InstanceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CheckpointAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StateJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("InstanceId");
|
||||
|
||||
b.HasIndex("CorrelationId");
|
||||
|
||||
b.ToTable("DurableStates", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.TaskRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Attempt")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<bool>("IsCompensation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("NextAttemptAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<int>("RetryCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("RunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CompiledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GitSha")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TriggerEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CompensationPlanJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid?>("StartTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntryJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("EnvJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("NextId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("OnErrorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Data/Migrations/20260828152534_AddTriggerEnabled.cs
Normal file
31
Data/Migrations/20260828152534_AddTriggerEnabled.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace w4c_workflows.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTriggerEnabled : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TriggerEnabled",
|
||||
schema: "workflows",
|
||||
table: "Workflows",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TriggerEnabled",
|
||||
schema: "workflows",
|
||||
table: "Workflows");
|
||||
}
|
||||
}
|
||||
}
|
||||
374
Data/Migrations/20260829101824_AddWorkflowVersion.Designer.cs
generated
Normal file
374
Data/Migrations/20260829101824_AddWorkflowVersion.Designer.cs
generated
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ScopesJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("ApiKeys", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.DurableState", b =>
|
||||
{
|
||||
b.Property<string>("InstanceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CheckpointAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StateJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("InstanceId");
|
||||
|
||||
b.HasIndex("CorrelationId");
|
||||
|
||||
b.ToTable("DurableStates", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.TaskRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Attempt")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<bool>("IsCompensation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("NextAttemptAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<int>("RetryCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("RunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CompiledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GitSha")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TriggerEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CompensationPlanJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid?>("StartTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntryJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("EnvJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("NextId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("OnErrorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
31
Data/Migrations/20260829101824_AddWorkflowVersion.cs
Normal file
31
Data/Migrations/20260829101824_AddWorkflowVersion.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace w4c_workflows.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddWorkflowVersion : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Version",
|
||||
schema: "workflows",
|
||||
table: "Workflows",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "1.0.0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Version",
|
||||
schema: "workflows",
|
||||
table: "Workflows");
|
||||
}
|
||||
}
|
||||
}
|
||||
371
Data/Migrations/WorkflowsDbContextModelSnapshot.cs
Normal file
371
Data/Migrations/WorkflowsDbContextModelSnapshot.cs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
// <auto-generated />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("KeyHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Label")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("RevokedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ScopesJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TenantId");
|
||||
|
||||
b.ToTable("ApiKeys", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.DurableState", b =>
|
||||
{
|
||||
b.Property<string>("InstanceId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<DateTime>("CheckpointAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("StateJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("TaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("InstanceId");
|
||||
|
||||
b.HasIndex("CorrelationId");
|
||||
|
||||
b.ToTable("DurableStates", "workflows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("w4c_workflows.Models.TaskRun", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Attempt")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<bool>("IsCompensation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("NextAttemptAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<int>("RetryCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("RunId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("CompiledAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GitSha")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Target")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("TriggerEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("CompensationPlanJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("CorrelationId")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("InputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("OutputJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid?>("StartTaskId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("TenantId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("TriggerJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("EntryJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("EnvJson")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<string>("Mode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<Guid?>("NextId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("OnErrorId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
133
Data/WorkflowsDbContext.cs
Normal file
133
Data/WorkflowsDbContext.cs
Normal file
|
|
@ -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<WorkflowsDbContext> 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<Workflow> Workflows => Set<Workflow>();
|
||||
public DbSet<WorkflowTask> Tasks => Set<WorkflowTask>();
|
||||
public DbSet<WorkflowRun> WorkflowRuns => Set<WorkflowRun>();
|
||||
public DbSet<TaskRun> TaskRuns => Set<TaskRun>();
|
||||
public DbSet<DurableState> DurableStates => Set<DurableState>();
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
|
||||
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<Workflow>(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<WorkflowTask>(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<WorkflowRun>(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<TaskRun>(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<DurableState>(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<ApiKey>(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);
|
||||
});
|
||||
}
|
||||
}
|
||||
31
Data/WorkflowsDbContextFactory.cs
Normal file
31
Data/WorkflowsDbContextFactory.cs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace w4c_workflows.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public class WorkflowsDbContextFactory : IDesignTimeDbContextFactory<WorkflowsDbContext>
|
||||
{
|
||||
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<WorkflowsDbContext>()
|
||||
.UseNpgsql(connectionString)
|
||||
.Options;
|
||||
|
||||
return new WorkflowsDbContext(options);
|
||||
}
|
||||
}
|
||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
|
|
@ -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"]
|
||||
58
Filters/RequireScopeAttribute.cs
Normal file
58
Filters/RequireScopeAttribute.cs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace w4c_workflows.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Authorization filter that checks the operator-key scopes resolved by
|
||||
/// <see cref="Middleware.AuthMiddleware"/>. Scopes are stored in
|
||||
/// <c>HttpContext.Items["Scopes"]</c> as an <c>IReadOnlyList<string></c>.
|
||||
///
|
||||
/// Usage: <c>[RequireScope("manage")]</c> 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.
|
||||
/// </summary>
|
||||
[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<string>;
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
139
Middleware/AuthMiddleware.cs
Normal file
139
Middleware/AuthMiddleware.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
using w4c_workflows.Services;
|
||||
|
||||
namespace w4c_workflows.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <key>).
|
||||
/// Used by both the frontend and the per-tenant worker.
|
||||
/// Resolved identity is exposed via HttpContext.Items: TenantId, Scopes,
|
||||
/// AuthKind ("jwt" | "operator").
|
||||
/// </summary>
|
||||
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<AuthMiddleware> _logger;
|
||||
|
||||
public AuthMiddleware(RequestDelegate next, IConfiguration config, ILogger<AuthMiddleware> 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 <iframe>, which cannot send the operator-key Authorization
|
||||
// header. It therefore accepts a short-lived signed token in ?token= —
|
||||
// minted by /html-token with the operator key. Without a token it falls
|
||||
// through to the normal operator-key check below (API use).
|
||||
if (IsHtmlRenderPath(path))
|
||||
{
|
||||
var token = context.Request.Query["token"].ToString();
|
||||
if (!string.IsNullOrEmpty(token))
|
||||
{
|
||||
var renderTokens = context.RequestServices.GetRequiredService<RenderTokenService>();
|
||||
if (renderTokens.TryVerify(token, out var tenantId, out var workflowId))
|
||||
{
|
||||
context.Items["TenantId"] = tenantId;
|
||||
context.Items["AuthKind"] = "render-token";
|
||||
context.Items["RenderWorkflowId"] = workflowId;
|
||||
await _next(context);
|
||||
return;
|
||||
}
|
||||
// A token was supplied but is invalid/expired — fail rather than
|
||||
// silently falling through to the header check.
|
||||
await Unauthorized(context, "Invalid or expired render token");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else — operator key surface.
|
||||
var services = context.RequestServices;
|
||||
var keys = services.GetRequiredService<ApiKeyService>();
|
||||
var op = await keys.VerifyAsync(context.Request.Headers.Authorization.ToString(), context.RequestAborted);
|
||||
if (op == null)
|
||||
{
|
||||
await Unauthorized(context, "Valid operator key required");
|
||||
return;
|
||||
}
|
||||
|
||||
context.Items["TenantId"] = op.TenantId;
|
||||
context.Items["AuthKind"] = "operator";
|
||||
context.Items["Scopes"] = op.Scopes;
|
||||
context.Items["OperatorKeyId"] = op.KeyId;
|
||||
_logger.LogDebug("Operator-key auth for tenant {TenantId}", op.TenantId);
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
|
||||
private static bool IsPublic(PathString path)
|
||||
{
|
||||
foreach (var publicPath in PublicPaths)
|
||||
{
|
||||
if (path.StartsWithSegments(publicPath))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches the backend HTML render route: <c>/api/workflows/{guid}/html</c>.
|
||||
/// Exactly five segments ("" + api/workflows/{id}/html) with an html tail.
|
||||
/// </summary>
|
||||
private static bool IsHtmlRenderPath(PathString path)
|
||||
{
|
||||
var value = path.Value;
|
||||
if (string.IsNullOrEmpty(value)) return false;
|
||||
var segments = value.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
return segments.Length == 4
|
||||
&& segments[0] == "api"
|
||||
&& segments[1] == "workflows"
|
||||
&& segments[3] == "html";
|
||||
}
|
||||
|
||||
private static Task Unauthorized(HttpContext context, string message)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
context.Response.ContentType = "application/json";
|
||||
return context.Response.WriteAsync($"{{\"error\":\"{message}\"}}");
|
||||
}
|
||||
}
|
||||
219
Models/Entities.cs
Normal file
219
Models/Entities.cs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace w4c_workflows.Models;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Well-known string constants. These are stored as plain text columns (not PG
|
||||
// enums) so new languages / modes / scopes can be added without a migration.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public static class WorkflowMode
|
||||
{
|
||||
public const string Function = "function";
|
||||
public const string Durable = "durable";
|
||||
public const string Handler = "handler";
|
||||
}
|
||||
|
||||
public static class WorkflowStatus
|
||||
{
|
||||
public const string Compiled = "compiled";
|
||||
public const string Invalid = "invalid";
|
||||
}
|
||||
|
||||
public static class RunStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Running = "running";
|
||||
public const string Succeeded = "succeeded";
|
||||
public const string Failed = "failed";
|
||||
public const string Compensating = "compensating";
|
||||
public const string Dead = "dead";
|
||||
}
|
||||
|
||||
public static class TaskRunStatus
|
||||
{
|
||||
public const string Pending = "pending";
|
||||
public const string Running = "running";
|
||||
public const string Succeeded = "succeeded";
|
||||
public const string Failed = "failed";
|
||||
public const string Compensated = "compensated";
|
||||
public const string Dead = "dead";
|
||||
}
|
||||
|
||||
public static class TriggerType
|
||||
{
|
||||
public const string Cron = "cron";
|
||||
public const string Interval = "interval";
|
||||
public const string Webhook = "webhook";
|
||||
public const string Event = "event"; // manual / external event
|
||||
public const string Queue = "queue"; // handler-mode stream subscription
|
||||
}
|
||||
|
||||
public static class ApiKeyScope
|
||||
{
|
||||
public const string Manage = "manage";
|
||||
public const string Run = "run";
|
||||
public const string Read = "read";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A compiled workflow definition. The authoritative source is a YAML file in
|
||||
/// git; this row is the compiled snapshot keyed by <c>git_sha</c>.
|
||||
/// </summary>
|
||||
public class Workflow
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Path { get; set; }
|
||||
public string? GitSha { get; set; }
|
||||
public required string Status { get; set; } // compiled | invalid
|
||||
public required string Mode { get; set; } // function | durable | handler
|
||||
public string? TriggerJson { get; set; } // jsonb: { type, cron, interval, webhookPath, stream }
|
||||
/// <summary>
|
||||
/// Whether the workflow's auto-trigger (cron/interval/webhook/handler) is
|
||||
/// enabled. Toggled from the Workflows UI; the trigger scheduler skips
|
||||
/// disabled workflows. Manual "run now" is unaffected.
|
||||
/// </summary>
|
||||
public bool TriggerEnabled { get; set; } = true;
|
||||
public required string Target { get; set; } // execution host
|
||||
/// <summary>
|
||||
/// User-facing build/version of the definition (e.g. "1.0.1"). Read from the
|
||||
/// YAML <c>version:</c> key when present (default "1.0.0"); the UI bumps the
|
||||
/// patch number on each save.
|
||||
/// </summary>
|
||||
public string Version { get; set; } = "1.0.0";
|
||||
public DateTime? CompiledAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public List<WorkflowTask> Tasks { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One node in a workflow's task tree. <c>ParentId</c> groups tasks for
|
||||
/// structure/Mermaid; execution follows <c>NextId</c> (success) and
|
||||
/// <c>OnErrorId</c> (failure). All three are soft references — resolved to task
|
||||
/// ids during YAML compilation and never foreign-key constrained.
|
||||
/// </summary>
|
||||
public class WorkflowTask
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid WorkflowId { get; set; }
|
||||
/// <summary>Original YAML task id ("validate", "enrich", …); "root" for the entry task.</summary>
|
||||
public required string Key { get; set; }
|
||||
public Guid? ParentId { get; set; }
|
||||
public Guid? NextId { get; set; }
|
||||
public Guid? OnErrorId { get; set; }
|
||||
public required string Language { get; set; }
|
||||
public required string Mode { get; set; }
|
||||
public string? EntryJson { get; set; } // jsonb: { file, function }
|
||||
public string? EnvJson { get; set; } // jsonb: { KEY: "value" }
|
||||
public int Order { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Managed-server id (or alias) the task's code is executed on via SSH. Null =>
|
||||
/// run as a local subprocess (the default). Resolved at compile time from the
|
||||
/// task's <c>server</c> or the workflow header's <c>server</c> default.
|
||||
/// </summary>
|
||||
public string? Server { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Set when a task is removed from the workflow YAML (renamed/removed) so its
|
||||
/// historical <see cref="TaskRun"/> rows are preserved instead of being wiped.
|
||||
/// Archived tasks are excluded from active-definition queries via a global query
|
||||
/// filter; sync re-activates a row whose id reappears in the YAML.
|
||||
/// </summary>
|
||||
public DateTime? ArchivedAt { get; set; }
|
||||
|
||||
public Workflow Workflow { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class WorkflowRun
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid WorkflowId { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public string? TriggerJson { get; set; }
|
||||
public string? InputJson { get; set; }
|
||||
public string? OutputJson { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? CorrelationId { get; set; }
|
||||
/// <summary>
|
||||
/// When set (durable resume), execution starts at this task instead of the
|
||||
/// root entry. Null for a normal run, which always starts at the root task.
|
||||
/// </summary>
|
||||
public Guid? StartTaskId { get; set; }
|
||||
/// <summary>
|
||||
/// Remaining saga compensation steps as a JSON array of
|
||||
/// <c>{ taskId, input }</c>, in dispatch order. Only populated while the run
|
||||
/// is <see cref="RunStatus.Compensating"/>; cleared when compensation ends.
|
||||
/// </summary>
|
||||
public string? CompensationPlanJson { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? FinishedAt { get; set; }
|
||||
|
||||
public Workflow Workflow { get; set; } = null!;
|
||||
public List<TaskRun> TaskRuns { get; set; } = new();
|
||||
}
|
||||
|
||||
public class TaskRun
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid RunId { get; set; }
|
||||
public Guid TaskId { get; set; }
|
||||
public int Attempt { get; set; }
|
||||
public required string Status { get; set; }
|
||||
public string? InputJson { get; set; }
|
||||
public string? OutputJson { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public int RetryCount { get; set; }
|
||||
/// <summary>
|
||||
/// When a failed attempt is scheduled for retry, the earliest time the retry
|
||||
/// may be re-dispatched (exponential backoff). Null when no retry is pending.
|
||||
/// </summary>
|
||||
public DateTime? NextAttemptAt { get; set; }
|
||||
/// <summary>
|
||||
/// True when this task run is a saga compensation step (an <c>onError</c>
|
||||
/// target run to undo a completed task), not part of the success chain.
|
||||
/// </summary>
|
||||
public bool IsCompensation { get; set; }
|
||||
public DateTime? StartedAt { get; set; }
|
||||
public DateTime? FinishedAt { get; set; }
|
||||
|
||||
public WorkflowRun Run { get; set; } = null!;
|
||||
public WorkflowTask Task { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checkpoint state for <c>durable</c> and <c>handler</c> workflows. Keyed by
|
||||
/// the long-lived instance id; enables resume via correlation id after an
|
||||
/// external event.
|
||||
/// </summary>
|
||||
public class DurableState
|
||||
{
|
||||
[Key]
|
||||
public required string InstanceId { get; set; }
|
||||
public Guid TaskId { get; set; }
|
||||
public string? StateJson { get; set; }
|
||||
public DateTime CheckpointAt { get; set; }
|
||||
public string? CorrelationId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-tenant operator API key. Only the SHA-256 hash is stored; the raw key is
|
||||
/// shown once at mint time.
|
||||
/// </summary>
|
||||
public class ApiKey
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public required string TenantId { get; set; }
|
||||
public required string KeyHash { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public string? ScopesJson { get; set; } // jsonb array: ["manage","run","read"]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
}
|
||||
53
Models/WorkflowYaml.cs
Normal file
53
Models/WorkflowYaml.cs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
namespace w4c_workflows.Models;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// YAML shape (authoritative source of truth, stored in git under workflows/).
|
||||
// Field names are camelCase in YAML; YamlDotNet maps them via CamelCaseNaming.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
public class WorkflowDefinition
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public string? Version { get; set; } // user-facing build/version, e.g. "1.0.1"
|
||||
public string? Mode { get; set; } // function | durable | handler
|
||||
public string? Language { get; set; } // shell | javascript | typescript | csharp | python
|
||||
public string? Target { get; set; } // execution host (default: "default")
|
||||
/// <summary>Managed-server id (or alias) that task code should run on. A task-level
|
||||
/// <c>server</c> overrides this header default; when set, the worker executes the task
|
||||
/// on that server via SSH instead of a local subprocess.</summary>
|
||||
public string? Server { get; set; }
|
||||
public TriggerDefinition? Trigger { get; set; }
|
||||
public EntryDefinition? Entry { get; set; }
|
||||
public Dictionary<string, string>? Env { get; set; }
|
||||
public List<TaskDefinition>? Tasks { get; set; }
|
||||
}
|
||||
|
||||
public class TaskDefinition
|
||||
{
|
||||
public string? Id { get; set; }
|
||||
public string? Parent { get; set; } // "root" | another task id (organizational)
|
||||
public string? Next { get; set; } // success flow
|
||||
public string? OnError { get; set; } // failure flow (compensation)
|
||||
public string? Language { get; set; } // inherits workflow language when omitted
|
||||
public string? Mode { get; set; } // inherits workflow mode when omitted
|
||||
/// <summary>Target managed server for this task. Overrides the workflow header <c>server</c>.</summary>
|
||||
public string? Server { get; set; }
|
||||
public EntryDefinition? Entry { get; set; }
|
||||
public Dictionary<string, string>? Env { get; set; }
|
||||
}
|
||||
|
||||
public class TriggerDefinition
|
||||
{
|
||||
public string? Type { get; set; } // queue | cron | interval | webhook | event
|
||||
public string? Stream { get; set; } // queue: stream/topic (default wf:{tenant}:events)
|
||||
public string? Cron { get; set; } // cron: "0 */6 * * *"
|
||||
public string? Interval { get; set; } // interval: "30m", "2h", "45s"
|
||||
public string? WebhookPath { get; set; } // webhook: "/h/orders"
|
||||
}
|
||||
|
||||
public class EntryDefinition
|
||||
{
|
||||
public string? File { get; set; }
|
||||
public string? Function { get; set; }
|
||||
}
|
||||
211
Program.cs
Normal file
211
Program.cs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Serilog.Sinks.OpenSearch;
|
||||
using StackExchange.Redis;
|
||||
using Scalar.AspNetCore;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Middleware;
|
||||
using w4c_workflows.Services;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using w4c_workflows.Services.Messaging;
|
||||
using w4c_workflows.Services.Runs;
|
||||
using w4c_workflows.Services.Triggers;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// w4c-workflows-api — control plane for the custom workflows engine.
|
||||
//
|
||||
// Two entrypoints share this image:
|
||||
// - default : HTTP control plane (API, trigger scheduler, Mermaid, keys)
|
||||
// - --worker : per-tenant execution worker (pull/exec/result over Redis)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var runWorker = args.Any(a => a == "--worker");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
var openSearchUrl = builder.Configuration["OpenSearch:Url"] ?? "http://localhost:9200";
|
||||
var appName = builder.Configuration["APP_NAME"] ?? (runWorker ? "workflows-worker" : "workflows-api");
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
// EF Core logs the raw SQL ("Executed DbCommand ...") at Information for
|
||||
// EVERY query. The background pollers (run lifecycle, trigger scheduler,
|
||||
// handler consumer) fire those several times a second and flood the console
|
||||
// / OpenSearch. Keep our own Information logs, but drop the EF command log.
|
||||
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithProperty("App", appName)
|
||||
.WriteTo.Console()
|
||||
.WriteTo.OpenSearch(new OpenSearchSinkOptions(new Uri(openSearchUrl))
|
||||
{
|
||||
IndexFormat = "w4c-workflows-{0:yyyy.MM.dd}",
|
||||
AutoRegisterTemplate = false,
|
||||
BatchAction = OpenOpType.Create,
|
||||
EmitEventFailure = EmitEventFailureHandling.WriteToSelfLog,
|
||||
BufferBaseFilename = Path.Combine(Path.GetTempPath(), "serilog-opensearch-buffer"),
|
||||
})
|
||||
.CreateLogger();
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// A transient failure in ONE background service (worker loop, trigger scheduler,
|
||||
// run lifecycle, handler consumer) must never take the whole control plane down.
|
||||
// Without this, HostOptions.BackgroundServiceExceptionBehavior defaults to
|
||||
// StopHost and a single early exception prevents the API from starting.
|
||||
builder.Services.Configure<HostOptions>(options =>
|
||||
options.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore);
|
||||
|
||||
builder.Services.AddDbContext<WorkflowsDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
builder.Services.AddSingleton<IConnectionMultiplexer>(_ =>
|
||||
ConnectionMultiplexer.Connect(
|
||||
builder.Configuration["Redis:ConnectionString"] ?? "localhost:6379,abortConnect=false"));
|
||||
|
||||
builder.Services.AddScoped<ApiKeyService>();
|
||||
|
||||
// Transport + lease: Redis Streams now, RabbitMQ later behind the same interfaces.
|
||||
builder.Services.AddSingleton<RedisStreamsTransport>();
|
||||
builder.Services.AddSingleton<IJobQueue>(sp => sp.GetRequiredService<RedisStreamsTransport>());
|
||||
builder.Services.AddSingleton<IEventBus>(sp => sp.GetRequiredService<RedisStreamsTransport>());
|
||||
builder.Services.AddSingleton<ILeaseService, LeaseService>();
|
||||
|
||||
// YAML compile pipeline (step 4).
|
||||
builder.Services.AddSingleton<LanguageRegistry>();
|
||||
builder.Services.AddSingleton<WorkflowValidator>();
|
||||
builder.Services.AddSingleton<WorkflowCompiler>();
|
||||
|
||||
// Git sync (step 5): read workflows/*.yaml from the local monorepo checkout.
|
||||
builder.Services.AddSingleton<IWorkflowSource, LocalRepoWorkflowSource>();
|
||||
builder.Services.AddScoped<WorkflowSyncService>();
|
||||
|
||||
// Mermaid diagrams (step 6).
|
||||
builder.Services.AddSingleton<MermaidGeneratorService>();
|
||||
|
||||
// Rich HTML workflow preview (backend-rendered, embeddable in an iframe).
|
||||
builder.Services.AddSingleton<WorkflowHtmlRenderer>();
|
||||
builder.Services.AddSingleton<RenderTokenService>();
|
||||
|
||||
// Runtime registry + worker executors (step 7).
|
||||
// The executors map each language to its execution strategy; the registry
|
||||
// (which also backs GET /api/languages) reports per-runtime availability.
|
||||
builder.Services.AddSingleton<IScriptExecutor>(new SubprocessScriptExecutor("shell", "sh", builder.Configuration));
|
||||
builder.Services.AddSingleton<IScriptExecutor>(new SubprocessScriptExecutor("python", "python3", builder.Configuration));
|
||||
builder.Services.AddSingleton<IScriptExecutor>(new SubprocessScriptExecutor("javascript", "node", builder.Configuration));
|
||||
builder.Services.AddSingleton<IScriptExecutor>(new TypeScriptExecutor(builder.Configuration));
|
||||
builder.Services.AddSingleton<IScriptExecutor>(new CSharpScriptExecutor());
|
||||
// W9: `agent` step type — invokes an LLM agent via chatapi over HTTP (D3), keeping
|
||||
// workflows-api decoupled from BotSharp.
|
||||
builder.Services.AddSingleton<IScriptExecutor>(sp =>
|
||||
new AgentScriptExecutor(
|
||||
sp.GetRequiredService<IHttpClientFactory>(),
|
||||
builder.Configuration,
|
||||
sp.GetRequiredService<ILogger<AgentScriptExecutor>>()));
|
||||
builder.Services.AddSingleton<RuntimeRegistry>();
|
||||
|
||||
// S8: remote (SSH) task execution via the w4c-webapi server-console exec endpoint.
|
||||
// Used only when a task carries a `server` reference; local subprocess stays the default.
|
||||
builder.Services.AddSingleton<RemoteServerExecutor>();
|
||||
|
||||
// Trigger engine (step 8): the scheduler + handler consumer run only in the
|
||||
// control-plane entrypoint, never in the worker. They share the run launcher —
|
||||
// the seam that creates pending runs for the run lifecycle engine (step 9).
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ITriggerState, RedisTriggerState>();
|
||||
builder.Services.AddScoped<IRunLauncher, RunLauncher>();
|
||||
|
||||
// Run lifecycle engine (step 9): dispatch pending runs + consume task.results +
|
||||
// advance the nextId chain + durable checkpoint/resume.
|
||||
builder.Services.AddSingleton<RunLifecycleEngine>();
|
||||
builder.Services.AddScoped<TaskDispatcher>();
|
||||
builder.Services.AddScoped<DurableStateStore>();
|
||||
|
||||
if (runWorker)
|
||||
{
|
||||
// Dedicated worker process (scaled/separate deployment).
|
||||
builder.Services.AddHostedService<WorkerHostService>();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Control-plane entrypoint. In single-node / self-hosted deployments one
|
||||
// process does both: it accounts, dispatches, AND runs the worker loop so
|
||||
// dispatched jobs actually execute. This closes the gap where the API
|
||||
// created run + task.run jobs but nothing consumed them, leaving every run
|
||||
// 'running' forever. A dedicated --worker can still be used for scale-out.
|
||||
builder.Services.AddHostedService<WorkerHostService>();
|
||||
builder.Services.AddHostedService<TriggerScheduler>();
|
||||
builder.Services.AddHostedService<HandlerStreamConsumer>();
|
||||
builder.Services.AddHostedService<RunLifecycleService>();
|
||||
}
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>();
|
||||
if (origins is { Length: > 0 })
|
||||
{
|
||||
// Production: restrict to explicitly configured origins.
|
||||
policy.WithOrigins(origins)
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Dev fallback: no origins configured → allow all.
|
||||
policy.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors();
|
||||
app.UseMiddleware<AuthMiddleware>();
|
||||
|
||||
// Ensure the `workflows` schema exists before serving traffic (mirrors the
|
||||
// resilient "ensure schema" pattern from w4c-auth). A migration failure must
|
||||
// not crash the host — health and logs still report the degraded state.
|
||||
try
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<WorkflowsDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
// Additive schema, applied idempotently (raw SQL, no new EF migration) so it is
|
||||
// safe on every startup against both freshly-created and long-lived databases:
|
||||
// • "ArchivedAt" — W7 soft-delete so sync can stop wiping historical TaskRuns.
|
||||
// • "Server" — S8 target managed-server for remote (SSH) task execution.
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"ALTER TABLE workflows.\"Tasks\" ADD COLUMN IF NOT EXISTS \"ArchivedAt\" timestamptz NULL;");
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"ALTER TABLE workflows.\"Tasks\" ADD COLUMN IF NOT EXISTS \"Server\" text NULL;");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Failed to apply EF Core migrations on startup");
|
||||
}
|
||||
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
app.MapControllers();
|
||||
|
||||
if (runWorker)
|
||||
{
|
||||
Log.Logger.Information("w4c-workflows-worker entrypoint selected; worker loop running for tenant {TenantId}",
|
||||
builder.Configuration["Workflows:TenantId"] ?? Environment.GetEnvironmentVariable("WF_TENANT_ID") ?? "default");
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
Log.CloseAndFlush();
|
||||
35
README.md
Normal file
35
README.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# w4c-workflows-api
|
||||
|
||||
Control plane and worker for the custom workflows engine. One Docker image, two entrypoints:
|
||||
|
||||
- **default** — HTTP control plane (API, trigger scheduler, Mermaid, API-key management)
|
||||
- **--worker** — per-tenant execution worker (pull/exec/result over Redis)
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
├── Controllers/ API controllers (health, keys, runs, workflows, webhooks)
|
||||
├── Data/ EF Core DbContext + migrations (PostgreSQL)
|
||||
├── Filters/ Action filters (e.g. RequireScope)
|
||||
├── Middleware/ Auth middleware
|
||||
├── Models/ EF entities + YAML schema models
|
||||
├── Services/ Business logic:
|
||||
│ ├── Execution/ Script executors (shell, python, node, ts, c#, agent)
|
||||
│ ├── Messaging/ Redis Streams transport
|
||||
│ ├── Runs/ Run lifecycle engine, task dispatcher
|
||||
│ └── Triggers/ Cron/schedule trigger scheduler
|
||||
├── workflows/ YAML workflow definitions (synced from source)
|
||||
├── w4c-workflows-api.Tests/ xunit tests (unit + integration with Testcontainers)
|
||||
├── Dockerfile
|
||||
└── Program.cs
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
docker build -t w4c-workflows-api -f Dockerfile .
|
||||
```
|
||||
|
||||
## Running locally
|
||||
|
||||
Requires PostgreSQL and Redis. See `appsettings.json` for connection strings.
|
||||
122
Services/ApiKeyService.cs
Normal file
122
Services/ApiKeyService.cs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>Resolved identity of an operator-key bearer.</summary>
|
||||
public sealed record OperatorPrincipal(Guid KeyId, string TenantId, IReadOnlyList<string> Scopes)
|
||||
{
|
||||
public bool HasScope(string scope) => Scopes.Contains(scope, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mints, verifies, lists and revokes per-tenant operator API keys. Only the
|
||||
/// SHA-256 hash is persisted; the raw key is returned exactly once at mint time.
|
||||
/// </summary>
|
||||
public class ApiKeyService
|
||||
{
|
||||
private const string KeyPrefix = "w4c_";
|
||||
|
||||
private readonly WorkflowsDbContext _db;
|
||||
|
||||
public ApiKeyService(WorkflowsDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a fresh operator key, stores its hash, and returns the raw key
|
||||
/// (shown to the caller once — never persisted).
|
||||
/// </summary>
|
||||
public async Task<(Guid Id, string RawKey)> MintAsync(
|
||||
string tenantId, string label, IReadOnlyList<string> scopes, CancellationToken ct)
|
||||
{
|
||||
var raw = KeyPrefix + Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
var key = new ApiKey
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
KeyHash = Hash(raw),
|
||||
Label = label,
|
||||
ScopesJson = JsonSerializer.Serialize(scopes),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.ApiKeys.Add(key);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return (key.Id, raw);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a bearer token to an active key's tenant + scopes, touching
|
||||
/// last_used_at. Returns null for unknown/revoked keys (caller → 401).
|
||||
/// </summary>
|
||||
public async Task<OperatorPrincipal?> VerifyAsync(string? authHeader, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authHeader) ||
|
||||
!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var raw = authHeader["Bearer ".Length..].Trim();
|
||||
if (string.IsNullOrEmpty(raw))
|
||||
return null;
|
||||
|
||||
var hash = Hash(raw);
|
||||
var key = await _db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.KeyHash == hash && k.RevokedAt == null, ct);
|
||||
if (key == null)
|
||||
return null;
|
||||
|
||||
key.LastUsedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var scopes = ParseScopes(key.ScopesJson);
|
||||
return new OperatorPrincipal(key.Id, key.TenantId, scopes);
|
||||
}
|
||||
|
||||
public Task<List<ApiKey>> ListAsync(string tenantId, CancellationToken ct)
|
||||
=> _db.ApiKeys
|
||||
.Where(k => k.TenantId == tenantId)
|
||||
.OrderByDescending(k => k.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<bool> RevokeAsync(string tenantId, Guid id, CancellationToken ct)
|
||||
{
|
||||
var key = await _db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Id == id && k.TenantId == tenantId, ct);
|
||||
if (key == null)
|
||||
return false;
|
||||
|
||||
key.RevokedAt ??= DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<int> CountActiveAsync(string tenantId, CancellationToken ct)
|
||||
=> _db.ApiKeys.CountAsync(k => k.TenantId == tenantId && k.RevokedAt == null, ct);
|
||||
|
||||
internal static string Hash(string raw) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw))).ToLowerInvariant();
|
||||
|
||||
private static IReadOnlyList<string> ParseScopes(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return Array.Empty<string>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<string[]>(json) ?? Array.Empty<string>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Base64Url(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
22
Services/DeterministicGuid.cs
Normal file
22
Services/DeterministicGuid.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic, name-based GUIDs so recompiling the same YAML yields the same
|
||||
/// entity ids — this makes the git sync (re-compile to DB) idempotent.
|
||||
/// UUIDv5-style: SHA-256 of the seed, truncated to 16 bytes with version bits.
|
||||
/// </summary>
|
||||
public static class DeterministicGuid
|
||||
{
|
||||
public static Guid For(string seed)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(seed));
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
hash.AsSpan(0, 16).CopyTo(bytes);
|
||||
bytes[6] = (byte)((bytes[6] & 0x0F) | 0x50); // version 5
|
||||
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); // RFC 4122 variant
|
||||
return new Guid(bytes);
|
||||
}
|
||||
}
|
||||
46
Services/DurationParser.cs
Normal file
46
Services/DurationParser.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Parses human durations like "45s", "30m", "2h", "1d" (also compound forms
|
||||
/// like "1h30m"). Used by YAML validation and the interval trigger scheduler.
|
||||
/// </summary>
|
||||
public static partial class DurationParser
|
||||
{
|
||||
[GeneratedRegex(@"(?<num>\d+(?:\.\d+)?)(?<unit>ms|s|m|h|d)", RegexOptions.Compiled)]
|
||||
private static partial Regex PartRegex();
|
||||
|
||||
public static bool TryParse(string? text, out TimeSpan result)
|
||||
{
|
||||
result = TimeSpan.Zero;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return false;
|
||||
|
||||
var matches = PartRegex().Matches(text);
|
||||
if (matches.Count == 0)
|
||||
return false;
|
||||
|
||||
var total = TimeSpan.Zero;
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var value = double.Parse(match.Groups["num"].Value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
var unit = match.Groups["unit"].Value;
|
||||
total += unit switch
|
||||
{
|
||||
"ms" => TimeSpan.FromMilliseconds(value),
|
||||
"s" => TimeSpan.FromSeconds(value),
|
||||
"m" => TimeSpan.FromMinutes(value),
|
||||
"h" => TimeSpan.FromHours(value),
|
||||
"d" => TimeSpan.FromDays(value),
|
||||
_ => throw new InvalidOperationException($"Unknown unit {unit}"),
|
||||
};
|
||||
}
|
||||
|
||||
if (total <= TimeSpan.Zero)
|
||||
return false;
|
||||
|
||||
result = total;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
117
Services/Execution/AgentScriptExecutor.cs
Normal file
117
Services/Execution/AgentScriptExecutor.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using System.Diagnostics;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Executes a workflow <c>agent</c> step (W9) by invoking an LLM agent over HTTP
|
||||
/// (D3): POSTs the step's prompt to chatapi <c>/api/agents/{id}/run</c> and returns
|
||||
/// the assistant's content as the task output, so the run lifecycle continues. The
|
||||
/// workflows engine stays decoupled from BotSharp — it only knows chatapi's HTTP
|
||||
/// contract. The agent id is carried in the task's <c>entry.file</c> and the prompt
|
||||
/// in the task's <c>input</c> (the previous task's output).
|
||||
///
|
||||
/// An LLM call may take tens of seconds; this executor blocks synchronously (the
|
||||
/// worker awaits it), matching the engine's existing synchronous step model. If the
|
||||
/// engine later needs non-blocking long-running steps, this executor is the seam to
|
||||
/// hook async/callback semantics into.
|
||||
/// </summary>
|
||||
public sealed class AgentScriptExecutor : IScriptExecutor
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly string _chatApiUrl;
|
||||
private readonly string _chatApiToken;
|
||||
private readonly int _timeoutSeconds;
|
||||
private readonly ILogger<AgentScriptExecutor> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public AgentScriptExecutor(IHttpClientFactory httpFactory, IConfiguration config, ILogger<AgentScriptExecutor> logger)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_chatApiUrl = (config["Workflows:ChatApiUrl"] ?? string.Empty).TrimEnd('/');
|
||||
_chatApiToken = config["Workflows:ChatApiToken"] ?? string.Empty;
|
||||
_timeoutSeconds = ParseInt(config["Workflows:AgentStepTimeoutSeconds"], 120);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Language => "agent";
|
||||
|
||||
public bool IsAvailable() => !string.IsNullOrWhiteSpace(_chatApiUrl);
|
||||
|
||||
public async Task<ExecutionResult> ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
if (!IsAvailable())
|
||||
return Fail("agent step is not configured (Workflows:ChatApiUrl is empty)", sw.Elapsed);
|
||||
|
||||
var agentId = invocation.EntryFile;
|
||||
if (string.IsNullOrWhiteSpace(agentId))
|
||||
return Fail("agent step requires entry.file to carry the agent id", sw.Elapsed);
|
||||
|
||||
var request = new AgentRunRequestDto { Prompt = invocation.Input ?? string.Empty };
|
||||
var body = JsonSerializer.Serialize(request, Json);
|
||||
|
||||
using var client = _httpFactory.CreateClient();
|
||||
using var httpReq = new HttpRequestMessage(HttpMethod.Post, $"{_chatApiUrl}/api/agents/{Uri.EscapeDataString(agentId)}/run")
|
||||
{
|
||||
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
|
||||
};
|
||||
httpReq.Headers.TryAddWithoutValidation("X-Tenant-Id", invocation.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(_chatApiToken))
|
||||
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_chatApiToken}");
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(_timeoutSeconds + 15));
|
||||
using var response = await client.SendAsync(httpReq, HttpCompletionOption.ResponseContentRead, cts.Token);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errText = await response.Content.ReadAsStringAsync(ct);
|
||||
return Fail($"agent '{agentId}' run failed ({(int)response.StatusCode}): {Truncate(errText)}", sw.Elapsed);
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<AgentRunResultDto>(Json, ct)
|
||||
?? new AgentRunResultDto { Content = "" };
|
||||
|
||||
// Return the assistant's content as task output. It must be valid, storable
|
||||
// JSON, so wrap the plain text as a JSON string (same rule as local executors).
|
||||
var output = JsonSerializer.Serialize(result.Content ?? string.Empty, Json);
|
||||
return new ExecutionResult(true, 0, result.Content ?? string.Empty, string.Empty, output, null, sw.Elapsed);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
sw.Stop();
|
||||
return Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private ExecutionResult Fail(string message, TimeSpan elapsed)
|
||||
{
|
||||
_logger.LogWarning("Agent step execution failed: {Message}", message);
|
||||
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, message, elapsed);
|
||||
}
|
||||
|
||||
private static string Truncate(string text)
|
||||
=> text.Length > 400 ? text[..400] + "…" : text;
|
||||
|
||||
private static int ParseInt(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||||
|
||||
private sealed class AgentRunRequestDto
|
||||
{
|
||||
public string Prompt { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class AgentRunResultDto
|
||||
{
|
||||
public string? Content { get; set; }
|
||||
}
|
||||
}
|
||||
212
Services/Execution/CSharpScriptExecutor.cs
Normal file
212
Services/Execution/CSharpScriptExecutor.cs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Executes multi-file C# with the Roslyn compiler: every <c>.cs</c> file under
|
||||
/// the task's working directory is compiled together into an in-memory assembly
|
||||
/// (one syntax tree per file, so per-file <c>using</c> directives and file-scoped
|
||||
/// namespaces work), then the entry method is invoked reflectively.
|
||||
///
|
||||
/// Each execution uses a <see cref="CollectibleAssemblyLoadContext"/> so the
|
||||
/// loaded assembly and all its types can be garbage-collected after the call
|
||||
/// returns. The previous implementation loaded into the default (non-collectible)
|
||||
/// ALC, causing unbounded memory growth in long-running workers.
|
||||
///
|
||||
/// Entry contract (v1): a <c>public static</c> method named after
|
||||
/// <c>entry.function</c> (default <c>Main</c>) that takes a single
|
||||
/// <c>string</c> (the JSON input) or no arguments, returning a value serialized
|
||||
/// to JSON as the task output (sync or <c>Task</c>/<c>Task<T></c>).
|
||||
/// </summary>
|
||||
public class CSharpScriptExecutor : IScriptExecutor
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
// Cached once per process lifetime — the set of runtime assemblies doesn't
|
||||
// change after startup. This avoids re-scanning AppDomain on every compile.
|
||||
private static ImmutableArray<MetadataReference>? _cachedReferences;
|
||||
private static readonly object _referenceLock = new();
|
||||
|
||||
public string Language => "csharp";
|
||||
|
||||
/// <summary>Roslyn ships with the service image — no external runtime to probe.</summary>
|
||||
public bool IsAvailable() => true;
|
||||
|
||||
public Task<ExecutionResult> ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var workingDir = ExecutionHelpers.ResolveWorkingDir(invocation);
|
||||
var sources = EnumerateSources(workingDir);
|
||||
if (sources.Count == 0)
|
||||
return Task.FromResult(Fail("no .cs source files found under the task directory", sw.Elapsed));
|
||||
|
||||
// Compilation is CPU-bound and synchronous; keep it off the request thread.
|
||||
var (assemblyLoadContext, assembly, diagnostics) = Compile(sources);
|
||||
if (assembly == null)
|
||||
{
|
||||
assemblyLoadContext?.Unload();
|
||||
var errors = diagnostics
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error)
|
||||
.Select(d => d.ToString());
|
||||
return Task.FromResult(Fail("C# compilation failed:\n" + string.Join('\n', errors), sw.Elapsed));
|
||||
}
|
||||
|
||||
string? output;
|
||||
try
|
||||
{
|
||||
output = InvokeEntry(assembly, invocation);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Unload the collectible ALC so the assembly and all its types
|
||||
// become eligible for GC. The next GC.Collect will reclaim them.
|
||||
assemblyLoadContext.Unload();
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
return Task.FromResult(new ExecutionResult(true, 0, string.Empty, string.Empty, output, null, sw.Elapsed));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return Task.FromResult(new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed));
|
||||
}
|
||||
}
|
||||
|
||||
private static ExecutionResult Fail(string message, TimeSpan duration)
|
||||
=> new(false, -1, string.Empty, string.Empty, null, message, duration);
|
||||
|
||||
private static IReadOnlyList<SourceFile> EnumerateSources(string workingDir)
|
||||
{
|
||||
// One syntax tree per file; compilation is order-independent, so a
|
||||
// deterministic sort is all that matters for reproducibility.
|
||||
return Directory.EnumerateFiles(workingDir, "*.cs", SearchOption.AllDirectories)
|
||||
.Select(full => new SourceFile(
|
||||
Path.GetRelativePath(workingDir, full),
|
||||
File.ReadAllText(full)))
|
||||
.OrderBy(s => s.Path, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static (AssemblyLoadContext, Assembly?, ImmutableArray<Diagnostic>) Compile(IReadOnlyList<SourceFile> sources)
|
||||
{
|
||||
var syntaxTrees = sources
|
||||
.Select(s => CSharpSyntaxTree.ParseText(s.Source, path: s.Path))
|
||||
.ToArray();
|
||||
|
||||
var compilation = CSharpCompilation.Create(
|
||||
$"wf-cs-{Guid.NewGuid():N}",
|
||||
syntaxTrees,
|
||||
GetCachedReferences(),
|
||||
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true));
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
var emit = compilation.Emit(ms);
|
||||
if (!emit.Success)
|
||||
{
|
||||
var emptyAlc = new AssemblyLoadContext(name: null, isCollectible: true);
|
||||
return (emptyAlc, null, emit.Diagnostics);
|
||||
}
|
||||
|
||||
ms.Position = 0;
|
||||
|
||||
// Each execution gets its own collectible ALC. After Unload() + GC,
|
||||
// the assembly and all its types are reclaimed — no unbounded growth.
|
||||
var alc = new AssemblyLoadContext(name: null, isCollectible: true);
|
||||
var assembly = alc.LoadFromStream(ms);
|
||||
return (alc, assembly, emit.Diagnostics);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the cached set of metadata references for the current runtime.
|
||||
/// The first call scans AppDomain; subsequent calls return the cached
|
||||
/// immutable array. This eliminates the per-compile AppDomain scan that
|
||||
/// compounded the ALC leak.
|
||||
/// </summary>
|
||||
private static ImmutableArray<MetadataReference> GetCachedReferences()
|
||||
{
|
||||
if (_cachedReferences is { } cached)
|
||||
return cached;
|
||||
|
||||
lock (_referenceLock)
|
||||
{
|
||||
if (_cachedReferences is { } cached2)
|
||||
return cached2;
|
||||
|
||||
var references = new List<MetadataReference>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
if (asm.IsDynamic)
|
||||
continue;
|
||||
var location = asm.Location;
|
||||
if (string.IsNullOrEmpty(location) || !File.Exists(location) || !seen.Add(location))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
references.Add(MetadataReference.CreateFromFile(location));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Some assemblies (e.g. reflection-only) cannot be referenced — skip.
|
||||
}
|
||||
}
|
||||
|
||||
_cachedReferences = ImmutableArray.CreateRange(references);
|
||||
return _cachedReferences.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? InvokeEntry(Assembly assembly, TaskInvocation invocation)
|
||||
{
|
||||
var function = string.IsNullOrWhiteSpace(invocation.EntryFunction) ? "Main" : invocation.EntryFunction;
|
||||
|
||||
MethodInfo? method = null;
|
||||
foreach (var type in assembly.GetTypes())
|
||||
{
|
||||
foreach (var candidate in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static))
|
||||
{
|
||||
if (!candidate.IsStatic || !string.Equals(candidate.Name, function, StringComparison.Ordinal))
|
||||
continue;
|
||||
var parameters = candidate.GetParameters();
|
||||
if (parameters.Length == 1 && parameters[0].ParameterType == typeof(string))
|
||||
{
|
||||
method = candidate; // best match — one string parameter
|
||||
break;
|
||||
}
|
||||
method ??= parameters.Length == 0 ? candidate : null;
|
||||
}
|
||||
if (method != null && method.GetParameters().Length == 1)
|
||||
break;
|
||||
}
|
||||
|
||||
if (method == null)
|
||||
throw new InvalidOperationException($"no static entry method '{function}' found in the compiled C# program");
|
||||
|
||||
var arguments = method.GetParameters().Length == 1
|
||||
? new object?[] { invocation.Input ?? "{}" }
|
||||
: null;
|
||||
var result = method.Invoke(null, arguments);
|
||||
|
||||
// Await Task / Task<T> returns so async entries produce a concrete value.
|
||||
if (result is Task task)
|
||||
{
|
||||
task.GetAwaiter().GetResult();
|
||||
result = task.GetType().IsGenericType
|
||||
? task.GetType().GetProperty("Result")!.GetValue(task)
|
||||
: null;
|
||||
}
|
||||
|
||||
return result == null ? null : JsonSerializer.Serialize(result, Json);
|
||||
}
|
||||
|
||||
private sealed record SourceFile(string Path, string Source);
|
||||
}
|
||||
90
Services/Execution/ExecutionHelpers.cs
Normal file
90
Services/Execution/ExecutionHelpers.cs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Shared helpers for the subprocess-backed executors: environment construction,
|
||||
/// working-directory resolution and the stdout → JSON output interpretation.
|
||||
///
|
||||
/// Output contract: a program signals its result by printing one JSON value on
|
||||
/// the last non-empty line of stdout. If that line is not valid JSON the whole
|
||||
/// (trimmed) stdout is wrapped as a JSON string instead, so every output is
|
||||
/// storable JSON.
|
||||
/// </summary>
|
||||
internal static class ExecutionHelpers
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public static string ResolveWorkingDir(TaskInvocation invocation)
|
||||
=> string.IsNullOrWhiteSpace(invocation.WorkingDir)
|
||||
? Directory.GetCurrentDirectory()
|
||||
: Path.GetFullPath(invocation.WorkingDir);
|
||||
|
||||
/// <summary>WF_* runtime context + the workflow/task-declared env, merged.</summary>
|
||||
public static IReadOnlyDictionary<string, string> BuildEnvironment(TaskInvocation invocation)
|
||||
{
|
||||
var env = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["WF_TENANT_ID"] = invocation.TenantId,
|
||||
["WF_RUN_ID"] = invocation.RunId,
|
||||
["WF_TASK_ID"] = invocation.TaskId,
|
||||
["WF_TASK_KEY"] = invocation.TaskKey,
|
||||
["WF_INPUT"] = invocation.Input ?? "{}",
|
||||
};
|
||||
foreach (var (key, value) in invocation.Env)
|
||||
env[key] = value;
|
||||
return env;
|
||||
}
|
||||
|
||||
public static ExecutionResult ToResult(ProcessOutput output, TimeSpan duration)
|
||||
{
|
||||
var success = output.ExitCode == 0;
|
||||
var error = success
|
||||
? null
|
||||
: LastNonEmptyLine(output.Stderr) ?? $"exit code {output.ExitCode}";
|
||||
return new ExecutionResult(
|
||||
success,
|
||||
output.ExitCode,
|
||||
output.Stdout,
|
||||
output.Stderr,
|
||||
success ? ParseOutput(output.Stdout) : null,
|
||||
error,
|
||||
duration);
|
||||
}
|
||||
|
||||
public static string? LastNonEmptyLine(string? text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
return null;
|
||||
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var trimmed = line.Trim('\r', ' ', '\t');
|
||||
if (trimmed.Length > 0)
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ParseOutput(string stdout)
|
||||
{
|
||||
var line = LastNonEmptyLine(stdout);
|
||||
if (line == null)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var _ = JsonDocument.Parse(line);
|
||||
return line; // already valid JSON — store it verbatim
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return JsonSerializer.Serialize(stdout.Trim(), Json);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Services/Execution/ExecutionResult.cs
Normal file
46
Services/Execution/ExecutionResult.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// The result of executing one task. <see cref="Output"/> is always valid JSON
|
||||
/// (a serialized value) or <c>null</c> when the task produced no output; the
|
||||
/// control plane stores it directly into <c>task_runs.output jsonb</c>.
|
||||
/// </summary>
|
||||
public sealed record ExecutionResult(
|
||||
bool Success,
|
||||
int ExitCode,
|
||||
string Stdout,
|
||||
string Stderr,
|
||||
string? Output,
|
||||
string? Error,
|
||||
TimeSpan Duration);
|
||||
|
||||
/// <summary>
|
||||
/// Serializes an <see cref="ExecutionResult"/> into the flat <c>task.result</c>
|
||||
/// event fields the worker publishes back to the control plane.
|
||||
/// </summary>
|
||||
public static class TaskResultMessage
|
||||
{
|
||||
public const string TypeValue = "task.result";
|
||||
|
||||
public static IReadOnlyDictionary<string, string> ToFields(TaskInvocation invocation, ExecutionResult result)
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["type"] = TypeValue,
|
||||
["run_id"] = invocation.RunId,
|
||||
["task_id"] = invocation.TaskId,
|
||||
["attempt"] = invocation.Attempt.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["tenant_id"] = invocation.TenantId,
|
||||
["status"] = result.Success ? "succeeded" : "failed",
|
||||
["exit_code"] = result.ExitCode.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["duration_ms"] = ((long)result.Duration.TotalMilliseconds).ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
if (result.Output != null)
|
||||
fields["output"] = result.Output;
|
||||
if (result.Error != null)
|
||||
fields["error"] = result.Error;
|
||||
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
17
Services/Execution/IScriptExecutor.cs
Normal file
17
Services/Execution/IScriptExecutor.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Executes task code for one language. Implementations are registered in the
|
||||
/// <see cref="RuntimeRegistry"/>; the worker resolves the right executor from a
|
||||
/// job's <c>language</c> field and invokes it inside the sandboxed container.
|
||||
/// </summary>
|
||||
public interface IScriptExecutor
|
||||
{
|
||||
/// <summary>Language id from the runtime registry ("shell", "csharp", …).</summary>
|
||||
string Language { get; }
|
||||
|
||||
/// <summary>Whether the required runtime binary/tool is present on this host.</summary>
|
||||
bool IsAvailable();
|
||||
|
||||
Task<ExecutionResult> ExecuteAsync(TaskInvocation invocation, CancellationToken ct);
|
||||
}
|
||||
104
Services/Execution/ProcessRunner.cs
Normal file
104
Services/Execution/ProcessRunner.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>Raw subprocess output before JSON interpretation.</summary>
|
||||
public sealed record ProcessOutput(int ExitCode, string Stdout, string Stderr);
|
||||
|
||||
/// <summary>
|
||||
/// Runs a subprocess with stdin/stdout/stderr redirection, an optional JSON
|
||||
/// stdin payload, extra environment variables, and a hard timeout. On timeout
|
||||
/// the whole process tree is killed.
|
||||
/// </summary>
|
||||
public static class ProcessRunner
|
||||
{
|
||||
public static async Task<ProcessOutput> RunAsync(
|
||||
string executable,
|
||||
IReadOnlyList<string> args,
|
||||
string workingDir,
|
||||
IReadOnlyDictionary<string, string>? env,
|
||||
string? stdin,
|
||||
TimeSpan timeout,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
WorkingDirectory = workingDir,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
if (env != null)
|
||||
{
|
||||
foreach (var (key, value) in env)
|
||||
psi.Environment[key] = value;
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = psi };
|
||||
if (!process.Start())
|
||||
throw new InvalidOperationException($"failed to start '{executable}'");
|
||||
|
||||
// Start draining stdout/stderr before writing stdin so a chatty child
|
||||
// never blocks on a full pipe.
|
||||
var stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
var stderrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(stdin))
|
||||
{
|
||||
try
|
||||
{
|
||||
await process.StandardInput.WriteAsync(stdin);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// The child exited before reading stdin (e.g. a script that ignores
|
||||
// its input) — a normal condition, not a failure.
|
||||
}
|
||||
}
|
||||
process.StandardInput.Close();
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); } catch { /* already gone */ }
|
||||
await stdoutTask;
|
||||
await stderrTask;
|
||||
throw new TimeoutException($"process exceeded the {timeout.TotalSeconds:0}s time limit");
|
||||
}
|
||||
|
||||
var stdout = await stdoutTask;
|
||||
var stderr = await stderrTask;
|
||||
return new ProcessOutput(process.ExitCode, stdout, stderr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an executable name against PATH (or a literal path) without
|
||||
/// invoking it — a cheap availability probe.
|
||||
/// </summary>
|
||||
public static string? FindInPath(string executable)
|
||||
{
|
||||
if (executable.Contains(Path.DirectorySeparatorChar) || executable.Contains('/'))
|
||||
return File.Exists(executable) ? Path.GetFullPath(executable) : null;
|
||||
|
||||
var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
foreach (var dir in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var full = Path.Combine(dir, executable);
|
||||
if (File.Exists(full))
|
||||
return full;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
198
Services/Execution/RemoteServerExecutor.cs
Normal file
198
Services/Execution/RemoteServerExecutor.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
using System.Diagnostics;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Executes a workflow task on a <em>remote</em> managed server instead of a local
|
||||
/// subprocess (S8). When a task carries a <c>server</c> reference, this executor:
|
||||
/// <list type="number">
|
||||
/// <item>reads the task's entry script from the local worker working dir;</item>
|
||||
/// <item>builds a shell command that writes the script to the target (base64, so the
|
||||
/// script can never inject shell metacharacters) and runs it with the language's
|
||||
/// interpreter;</item>
|
||||
/// <item>POSTs it to w4c-webapi <c>/api/serverconsole/exec</c>, which resolves the
|
||||
/// server via <c>ServerConnectionRegistry</c> and runs it over SSH with
|
||||
/// <c>SshRunner</c> — the same path the /server console uses;</item>
|
||||
/// <item>maps the remote stdout/stderr back into the standard
|
||||
/// <see cref="ExecutionResult"/> contract.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// The local subprocess path stays the default: this executor is only used when
|
||||
/// <see cref="TaskInvocation.Server"/> is non-empty. The w4c-webapi endpoint is
|
||||
/// authenticated with the run's tenant id (<c>X-Tenant-Id</c>, the dev bypass) plus an
|
||||
/// optional service token configured via <c>Workflows:ServerApiToken</c>.
|
||||
/// </summary>
|
||||
public sealed class RemoteServerExecutor
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly string _serverApiUrl;
|
||||
private readonly string _serverApiToken;
|
||||
private readonly int _timeoutSeconds;
|
||||
private readonly ILogger<RemoteServerExecutor> _logger;
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public RemoteServerExecutor(IHttpClientFactory httpFactory, IConfiguration config, ILogger<RemoteServerExecutor> logger)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_serverApiUrl = (config["Workflows:ServerApiUrl"] ?? string.Empty).TrimEnd('/');
|
||||
_serverApiToken = config["Workflows:ServerApiToken"] ?? string.Empty;
|
||||
_timeoutSeconds = ParseInt(config["Workflows:ServerTaskTimeoutSeconds"], 120);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>True when the w4c-webapi server-console URL is configured; otherwise remote execution is unavailable.</summary>
|
||||
public bool IsAvailable() => !string.IsNullOrWhiteSpace(_serverApiUrl);
|
||||
|
||||
public async Task<ExecutionResult> ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
if (!IsAvailable())
|
||||
return Fail($"remote execution is not configured (Workflows:ServerApiUrl is empty)", sw.Elapsed);
|
||||
|
||||
var entryFile = ResolveLocalScriptPath(invocation);
|
||||
if (!File.Exists(entryFile))
|
||||
return Fail($"entry script not found locally: {entryFile}", sw.Elapsed);
|
||||
|
||||
var script = await File.ReadAllTextAsync(entryFile, ct);
|
||||
var command = BuildRemoteCommand(invocation, script, out var remoteErr);
|
||||
if (remoteErr != null)
|
||||
return Fail(remoteErr, sw.Elapsed);
|
||||
|
||||
var env = ExecutionHelpers.BuildEnvironment(invocation);
|
||||
var request = new ServerExecRequestDto
|
||||
{
|
||||
ServerId = invocation.Server!,
|
||||
Cwd = ExecutionHelpers.ResolveWorkingDir(invocation),
|
||||
Command = command,
|
||||
Env = env.ToDictionary(kv => kv.Key, kv => kv.Value),
|
||||
TimeoutSeconds = _timeoutSeconds,
|
||||
};
|
||||
|
||||
var body = JsonSerializer.Serialize(request, Json);
|
||||
using var client = _httpFactory.CreateClient();
|
||||
using var httpReq = new HttpRequestMessage(HttpMethod.Post, $"{_serverApiUrl}/api/serverconsole/exec")
|
||||
{
|
||||
Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json"),
|
||||
};
|
||||
httpReq.Headers.TryAddWithoutValidation("X-Tenant-Id", invocation.TenantId);
|
||||
if (!string.IsNullOrWhiteSpace(_serverApiToken))
|
||||
httpReq.Headers.TryAddWithoutValidation("Authorization", $"Bearer {_serverApiToken}");
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(_timeoutSeconds + 15));
|
||||
using var response = await client.SendAsync(httpReq, HttpCompletionOption.ResponseContentRead, cts.Token);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errText = await response.Content.ReadAsStringAsync(ct);
|
||||
return Fail($"server {invocation.Server} exec returned {(int)response.StatusCode}: {Truncate(errText)}", sw.Elapsed);
|
||||
}
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<ServerExecResultDto>(Json, ct)
|
||||
?? new ServerExecResultDto { ExitCode = -1, Stderr = "empty exec response" };
|
||||
|
||||
// Map the remote result into the same ProcessOutput -> ExecutionResult
|
||||
// contract the local executors use (last non-empty stdout line => JSON output).
|
||||
var output = new ProcessOutput(result.ExitCode, result.Stdout ?? string.Empty, result.Stderr ?? string.Empty);
|
||||
var mapped = ExecutionHelpers.ToResult(output, sw.Elapsed);
|
||||
if (result.TimedOut)
|
||||
return new ExecutionResult(false, result.ExitCode, output.Stdout, output.Stderr, null,
|
||||
$"remote exec timed out after {_timeoutSeconds}s", sw.Elapsed);
|
||||
return mapped;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
sw.Stop();
|
||||
return Fail(ex.Message, sw.Elapsed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the remote shell command: write the script to the target via base64
|
||||
/// (so content can never inject metacharacters) and run it with the interpreter.
|
||||
/// </summary>
|
||||
private static string BuildRemoteCommand(TaskInvocation invocation, string script, out string? error)
|
||||
{
|
||||
error = null;
|
||||
var interpreter = RemoteInterpreter(invocation.Language);
|
||||
if (interpreter == null)
|
||||
{
|
||||
error = $"language '{invocation.Language}' is not supported for remote (SSH) execution";
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var workDir = ExecutionHelpers.ResolveWorkingDir(invocation);
|
||||
var safeWorkDir = ShQuote(workDir);
|
||||
var remoteFile = Path.Combine(workDir, $".w4c-run-{invocation.TaskId[..8]}-{invocation.Attempt}-{SafeName(invocation.EntryFile)}");
|
||||
var b64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(script));
|
||||
|
||||
// mkdir -p <dir> && echo '<b64>' | base64 -d > <file> && <interpreter> <file>
|
||||
return $"mkdir -p {safeWorkDir} "
|
||||
+ $"&& echo {ShQuote(b64)} | base64 -d > {ShQuote(remoteFile)} "
|
||||
+ $"&& {interpreter} {ShQuote(remoteFile)}";
|
||||
}
|
||||
|
||||
private static string? RemoteInterpreter(string language) => language switch
|
||||
{
|
||||
"shell" => "sh",
|
||||
"javascript" => "node",
|
||||
"typescript" => "node",
|
||||
"python" => "python3",
|
||||
"csharp" => null, // requires a compiler + runtime not assumed present on a target
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private string ResolveLocalScriptPath(TaskInvocation invocation)
|
||||
{
|
||||
var workDir = ExecutionHelpers.ResolveWorkingDir(invocation);
|
||||
return Path.Combine(workDir, invocation.EntryFile);
|
||||
}
|
||||
|
||||
private ExecutionResult Fail(string message, TimeSpan elapsed)
|
||||
{
|
||||
_logger.LogWarning("Remote task execution failed: {Message}", message);
|
||||
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, message, elapsed);
|
||||
}
|
||||
|
||||
/// <summary>Single-quotes a value for the remote sh -c evaluation (same semantics as SshRunner.ShellQuote).</summary>
|
||||
private static string ShQuote(string value)
|
||||
=> string.IsNullOrEmpty(value) ? "''" : "'" + value.Replace("'", "'\"'\"'") + "'";
|
||||
|
||||
private static string SafeName(string path)
|
||||
{
|
||||
var name = Path.GetFileName(path);
|
||||
return string.IsNullOrWhiteSpace(name) ? "script" : name.Replace(' ', '_');
|
||||
}
|
||||
|
||||
private static string Truncate(string text)
|
||||
=> text.Length > 400 ? text[..400] + "…" : text;
|
||||
|
||||
private static int ParseInt(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||||
|
||||
private sealed class ServerExecRequestDto
|
||||
{
|
||||
public string ServerId { get; set; } = string.Empty;
|
||||
public string? Cwd { get; set; }
|
||||
public string Command { get; set; } = string.Empty;
|
||||
public Dictionary<string, string>? Env { get; set; }
|
||||
public int? TimeoutSeconds { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ServerExecResultDto
|
||||
{
|
||||
public int ExitCode { get; set; }
|
||||
public string? Stdout { get; set; }
|
||||
public string? Stderr { get; set; }
|
||||
public bool TimedOut { get; set; }
|
||||
}
|
||||
}
|
||||
46
Services/Execution/RuntimeRegistry.cs
Normal file
46
Services/Execution/RuntimeRegistry.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// A language as exposed by <c>GET /api/languages</c>: identity + whether its
|
||||
/// runtime is actually executable on this host (availability is probed at
|
||||
/// startup and cached).
|
||||
/// </summary>
|
||||
public sealed record RuntimeInfo(
|
||||
string Id,
|
||||
string DisplayName,
|
||||
string DefaultFunction,
|
||||
string Extension,
|
||||
bool Available);
|
||||
|
||||
/// <summary>
|
||||
/// Maps each registered language to its <see cref="IScriptExecutor"/>. Built on
|
||||
/// top of <see cref="LanguageRegistry"/> (the source of truth for known
|
||||
/// languages); the executor set is DI-injected so new languages/runtimes plug in
|
||||
/// without touching this class.
|
||||
/// </summary>
|
||||
public class RuntimeRegistry
|
||||
{
|
||||
private readonly IReadOnlyDictionary<string, IScriptExecutor> _executors;
|
||||
private readonly IReadOnlyList<RuntimeInfo> _all;
|
||||
|
||||
public RuntimeRegistry(LanguageRegistry languages, IEnumerable<IScriptExecutor> executors)
|
||||
{
|
||||
_executors = executors.ToDictionary(e => e.Language, StringComparer.Ordinal);
|
||||
_all = languages.All
|
||||
.Select(l => new RuntimeInfo(
|
||||
l.Id,
|
||||
l.DisplayName,
|
||||
l.DefaultFunction,
|
||||
l.Extension,
|
||||
_executors.TryGetValue(l.Id, out var executor) && executor.IsAvailable()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<RuntimeInfo> All => _all;
|
||||
|
||||
public IScriptExecutor? Resolve(string language)
|
||||
=> _executors.TryGetValue(language, out var executor) ? executor : null;
|
||||
|
||||
public bool IsAvailable(string language)
|
||||
=> _executors.TryGetValue(language, out var executor) && executor.IsAvailable();
|
||||
}
|
||||
55
Services/Execution/SubprocessScriptExecutor.cs
Normal file
55
Services/Execution/SubprocessScriptExecutor.cs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a language whose program is a single file executed by an interpreter
|
||||
/// (shell → sh, python → python3, javascript → node). Input is written to stdin
|
||||
/// as JSON; output follows the <see cref="ExecutionHelpers"/> stdout contract.
|
||||
/// </summary>
|
||||
public class SubprocessScriptExecutor : IScriptExecutor
|
||||
{
|
||||
private readonly string _language;
|
||||
private readonly string _executable;
|
||||
private readonly TimeSpan _timeout;
|
||||
|
||||
public SubprocessScriptExecutor(string language, string executable, IConfiguration config)
|
||||
{
|
||||
_language = language;
|
||||
_executable = executable;
|
||||
_timeout = TimeSpan.FromSeconds(ParseInt(config["Workflows:TaskTimeoutSeconds"], 60));
|
||||
}
|
||||
|
||||
public string Language => _language;
|
||||
|
||||
public bool IsAvailable() => ProcessRunner.FindInPath(_executable) != null;
|
||||
|
||||
public async Task<ExecutionResult> ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var workingDir = ExecutionHelpers.ResolveWorkingDir(invocation);
|
||||
var entryPath = Path.Combine(workingDir, invocation.EntryFile);
|
||||
|
||||
var output = await ProcessRunner.RunAsync(
|
||||
_executable,
|
||||
new[] { entryPath },
|
||||
workingDir,
|
||||
ExecutionHelpers.BuildEnvironment(invocation),
|
||||
invocation.Input,
|
||||
_timeout,
|
||||
ct);
|
||||
|
||||
return ExecutionHelpers.ToResult(output, sw.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseInt(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||||
}
|
||||
60
Services/Execution/TaskInvocation.cs
Normal file
60
Services/Execution/TaskInvocation.cs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Wire contract for a <c>task.run</c> job pulled off the tenant's jobs stream.
|
||||
/// The control plane (step 9) enqueues these; the worker reads, executes and
|
||||
/// acks them. Field names are flat Redis Stream entries so the contract is
|
||||
/// transport-agnostic (Redis Streams now, RabbitMQ later).
|
||||
/// </summary>
|
||||
public sealed record TaskInvocation(
|
||||
string Type,
|
||||
string RunId,
|
||||
string TaskId,
|
||||
string TaskKey,
|
||||
string Language,
|
||||
string EntryFile,
|
||||
string? EntryFunction,
|
||||
IReadOnlyDictionary<string, string> Env,
|
||||
string? Input,
|
||||
string? WorkingDir,
|
||||
string TenantId,
|
||||
int Attempt,
|
||||
string? Server = null)
|
||||
{
|
||||
public const string TypeValue = "task.run";
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>Parses a stream message into an invocation, throwing if a required field is missing.</summary>
|
||||
public static TaskInvocation FromFields(IReadOnlyDictionary<string, string> fields)
|
||||
{
|
||||
static string Get(IReadOnlyDictionary<string, string> fields, string key) =>
|
||||
fields.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: throw new InvalidOperationException($"missing required job field '{key}'");
|
||||
|
||||
static int GetInt(IReadOnlyDictionary<string, string> fields, string key, int fallback) =>
|
||||
fields.TryGetValue(key, out var raw) && int.TryParse(raw, out var n) ? n : fallback;
|
||||
|
||||
var env = fields.TryGetValue("env", out var envJson) && !string.IsNullOrWhiteSpace(envJson)
|
||||
? JsonSerializer.Deserialize<Dictionary<string, string>>(envJson, Json) ?? new()
|
||||
: new Dictionary<string, string>();
|
||||
|
||||
return new TaskInvocation(
|
||||
Get(fields, "type"),
|
||||
Get(fields, "run_id"),
|
||||
Get(fields, "task_id"),
|
||||
Get(fields, "task_key"),
|
||||
Get(fields, "language"),
|
||||
Get(fields, "entry_file"),
|
||||
fields.GetValueOrDefault("entry_function"),
|
||||
env,
|
||||
fields.GetValueOrDefault("input"),
|
||||
fields.GetValueOrDefault("working_dir"),
|
||||
Get(fields, "tenant_id"),
|
||||
GetInt(fields, "attempt", 1),
|
||||
fields.GetValueOrDefault("server"));
|
||||
}
|
||||
}
|
||||
47
Services/Execution/TaskResult.cs
Normal file
47
Services/Execution/TaskResult.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// A <c>task.result</c> event as consumed by the run lifecycle engine. The
|
||||
/// worker publishes these via <see cref="TaskResultMessage.ToFields"/>; this
|
||||
/// record is the control-plane parse of the same flat field names.
|
||||
/// </summary>
|
||||
public sealed record TaskResult(
|
||||
string RunId,
|
||||
string TaskId,
|
||||
int Attempt,
|
||||
string TenantId,
|
||||
bool Success,
|
||||
int ExitCode,
|
||||
string? Output,
|
||||
string? Error,
|
||||
long DurationMs)
|
||||
{
|
||||
/// <summary>Parses a stream message into a result, throwing if a required field is missing.</summary>
|
||||
public static TaskResult FromFields(IReadOnlyDictionary<string, string> fields)
|
||||
{
|
||||
static string Get(IReadOnlyDictionary<string, string> fields, string key) =>
|
||||
fields.TryGetValue(key, out var value) && !string.IsNullOrEmpty(value)
|
||||
? value
|
||||
: throw new InvalidOperationException($"missing required result field '{key}'");
|
||||
|
||||
static int GetInt(IReadOnlyDictionary<string, string> fields, string key, int fallback) =>
|
||||
fields.TryGetValue(key, out var raw) && int.TryParse(raw, out var n) ? n : fallback;
|
||||
|
||||
static long GetLong(IReadOnlyDictionary<string, string> fields, string key, long fallback) =>
|
||||
fields.TryGetValue(key, out var raw) && long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : fallback;
|
||||
|
||||
var status = Get(fields, "status");
|
||||
return new TaskResult(
|
||||
Get(fields, "run_id"),
|
||||
Get(fields, "task_id"),
|
||||
GetInt(fields, "attempt", 1),
|
||||
Get(fields, "tenant_id"),
|
||||
string.Equals(status, "succeeded", StringComparison.Ordinal),
|
||||
GetInt(fields, "exit_code", -1),
|
||||
fields.GetValueOrDefault("output"),
|
||||
fields.GetValueOrDefault("error"),
|
||||
GetLong(fields, "duration_ms", 0));
|
||||
}
|
||||
}
|
||||
83
Services/Execution/TaskRunMessage.cs
Normal file
83
Services/Execution/TaskRunMessage.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes a <c>task.run</c> job from the compiled <see cref="WorkflowTask"/>
|
||||
/// into the flat Redis Stream fields the worker understands (see
|
||||
/// <see cref="TaskInvocation.FromFields"/>). This is the control-plane side of
|
||||
/// the wire contract; the worker side deserializes the same field names.
|
||||
/// </summary>
|
||||
public static class TaskRunMessage
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Builds the stream fields for executing <paramref name="task"/> as part of
|
||||
/// <paramref name="run"/>. <paramref name="workingDir"/> is the directory
|
||||
/// containing the task's code files; the entry file is taken from the task's
|
||||
/// <c>EntryJson</c> and stays relative to it.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> ToFields(
|
||||
WorkflowRun run,
|
||||
WorkflowTask task,
|
||||
string? input,
|
||||
string? workingDir,
|
||||
int attempt)
|
||||
{
|
||||
var entry = ParseEntry(task.EntryJson);
|
||||
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["type"] = TaskInvocation.TypeValue,
|
||||
["run_id"] = run.Id.ToString(),
|
||||
["task_id"] = task.Id.ToString(),
|
||||
["task_key"] = task.Key,
|
||||
["language"] = task.Language,
|
||||
["entry_file"] = entry.File ?? string.Empty,
|
||||
["tenant_id"] = run.TenantId,
|
||||
["attempt"] = attempt.ToString(CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entry.Function))
|
||||
fields["entry_function"] = entry.Function;
|
||||
|
||||
var env = DeserializeEnv(task.EnvJson);
|
||||
if (env.Count > 0)
|
||||
fields["env"] = JsonSerializer.Serialize(env, Json);
|
||||
|
||||
if (input != null)
|
||||
fields["input"] = input;
|
||||
if (!string.IsNullOrWhiteSpace(workingDir))
|
||||
fields["working_dir"] = workingDir;
|
||||
if (!string.IsNullOrWhiteSpace(task.Server))
|
||||
fields["server"] = task.Server!;
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static EntryDefinition ParseEntry(string? entryJson)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(entryJson))
|
||||
{
|
||||
var entry = JsonSerializer.Deserialize<EntryDefinition>(entryJson, Json);
|
||||
if (entry != null)
|
||||
return entry;
|
||||
}
|
||||
return new EntryDefinition { File = string.Empty };
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> DeserializeEnv(string? envJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(envJson))
|
||||
return new Dictionary<string, string>();
|
||||
return JsonSerializer.Deserialize<Dictionary<string, string>>(envJson, Json)
|
||||
?? new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
79
Services/Execution/TypeScriptExecutor.cs
Normal file
79
Services/Execution/TypeScriptExecutor.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// Executes TypeScript by bundling the entry file (and its imports) into a
|
||||
/// single CommonJS module with esbuild, then running it under node. This keeps
|
||||
/// multi-file TS support without a persistent watch process.
|
||||
/// </summary>
|
||||
public class TypeScriptExecutor : IScriptExecutor
|
||||
{
|
||||
private readonly string _esbuild;
|
||||
private readonly string _node;
|
||||
private readonly TimeSpan _timeout;
|
||||
|
||||
public TypeScriptExecutor(IConfiguration config)
|
||||
{
|
||||
_esbuild = config["Workflows:EsbuildBinary"] ?? "esbuild";
|
||||
_node = config["Workflows:NodeBinary"] ?? "node";
|
||||
_timeout = TimeSpan.FromSeconds(ParseInt(config["Workflows:TaskTimeoutSeconds"], 60));
|
||||
}
|
||||
|
||||
public string Language => "typescript";
|
||||
|
||||
public bool IsAvailable()
|
||||
=> ProcessRunner.FindInPath(_node) != null && ProcessRunner.FindInPath(_esbuild) != null;
|
||||
|
||||
public async Task<ExecutionResult> ExecuteAsync(TaskInvocation invocation, CancellationToken ct)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var bundle = Path.Combine(Path.GetTempPath(), $"wf-ts-{Guid.NewGuid():N}.cjs");
|
||||
try
|
||||
{
|
||||
var workingDir = ExecutionHelpers.ResolveWorkingDir(invocation);
|
||||
var entryPath = Path.Combine(workingDir, invocation.EntryFile);
|
||||
|
||||
// 1. Bundle with esbuild → CommonJS so node can run it directly.
|
||||
var build = await ProcessRunner.RunAsync(
|
||||
_esbuild,
|
||||
new[] { entryPath, "--bundle", "--platform=node", "--format=cjs", $"--outfile={bundle}" },
|
||||
workingDir,
|
||||
null,
|
||||
null,
|
||||
_timeout,
|
||||
ct);
|
||||
|
||||
if (build.ExitCode != 0)
|
||||
{
|
||||
return new ExecutionResult(
|
||||
false, build.ExitCode, build.Stdout, build.Stderr, null,
|
||||
ExecutionHelpers.LastNonEmptyLine(build.Stderr) ?? "esbuild failed", sw.Elapsed);
|
||||
}
|
||||
|
||||
// 2. Run the bundle under node with the JSON input on stdin.
|
||||
var run = await ProcessRunner.RunAsync(
|
||||
_node,
|
||||
new[] { bundle },
|
||||
workingDir,
|
||||
ExecutionHelpers.BuildEnvironment(invocation),
|
||||
invocation.Input,
|
||||
_timeout,
|
||||
ct);
|
||||
|
||||
return ExecutionHelpers.ToResult(run, sw.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sw.Stop();
|
||||
return new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { File.Delete(bundle); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParseInt(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||||
}
|
||||
220
Services/Execution/WorkerHostService.cs
Normal file
220
Services/Execution/WorkerHostService.cs
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
using System.Diagnostics;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Messaging;
|
||||
|
||||
namespace w4c_workflows.Services.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// The worker loop: a long-lived consumer that pulls <c>task.run</c> jobs off
|
||||
/// every tenant's jobs stream, executes them with the matching
|
||||
/// <see cref="IScriptExecutor"/>, publishes a <c>task.result</c> event back, and
|
||||
/// acks the job. Redelivery of crashed jobs is handled by claiming pending
|
||||
/// messages past an idle threshold (at-least-once; idempotency is the control
|
||||
/// plane's job via correlation_id + attempt).
|
||||
///
|
||||
/// The worker is not pinned to one static tenant: it enumerates the tenants that
|
||||
/// have <em>workflows</em> (and therefore may have queued jobs) on each tick. This
|
||||
/// lets a single control-plane process also act as the worker for its own tenants
|
||||
/// (the self-hosted / single-node case), while still being usable as a dedicated
|
||||
/// <c>--worker</c> deployment that fans out across many tenants.
|
||||
/// </summary>
|
||||
public class WorkerHostService : BackgroundService
|
||||
{
|
||||
private readonly IJobQueue _jobs;
|
||||
private readonly IEventBus _events;
|
||||
private readonly RuntimeRegistry _runtimes;
|
||||
private readonly RemoteServerExecutor _remote;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<WorkerHostService> _logger;
|
||||
|
||||
private readonly string _consumer;
|
||||
private readonly string _resultStream;
|
||||
private readonly int _batchSize;
|
||||
private readonly TimeSpan _pollDelay;
|
||||
private readonly TimeSpan _claimIdle;
|
||||
|
||||
public WorkerHostService(
|
||||
IJobQueue jobs,
|
||||
IEventBus events,
|
||||
RuntimeRegistry runtimes,
|
||||
RemoteServerExecutor remote,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IConfiguration config,
|
||||
ILogger<WorkerHostService> logger)
|
||||
{
|
||||
_jobs = jobs;
|
||||
_events = events;
|
||||
_runtimes = runtimes;
|
||||
_remote = remote;
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
|
||||
_consumer = $"{Environment.MachineName}-{Guid.NewGuid():N}"[..28];
|
||||
_resultStream = config["Workflows:ResultStream"] ?? Streams.Results;
|
||||
_batchSize = ParseInt(config["Workflows:WorkerBatchSize"], 10);
|
||||
_pollDelay = TimeSpan.FromMilliseconds(ParseInt(config["Workflows:WorkerPollDelayMs"], 500));
|
||||
_claimIdle = TimeSpan.FromSeconds(ParseInt(config["Workflows:WorkerClaimIdleSeconds"], 30));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("Worker starting as consumer {Consumer}", _consumer);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processed = await ProcessBatchAsync(stoppingToken);
|
||||
if (processed == 0)
|
||||
await Task.Delay(_pollDelay, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Worker loop error");
|
||||
try { await Task.Delay(_pollDelay, stoppingToken); } catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Worker stopped");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the tenants that have compiled workflows (the only tenants that can
|
||||
/// have queued job messages) and processes their jobs streams. Ensures each
|
||||
/// stream's consumer group exists before reading so a fresh worker picks up
|
||||
/// only new messages (backlog replay is intentionally not attempted — a
|
||||
/// stalled run is recovered by the control-plane timeout sweep instead).
|
||||
/// </summary>
|
||||
private async Task<int> ProcessBatchAsync(CancellationToken ct)
|
||||
{
|
||||
var tenants = await ListTenantIdsAsync(ct);
|
||||
var processed = 0;
|
||||
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
if (processed >= _batchSize)
|
||||
break;
|
||||
|
||||
await _jobs.EnsureGroupAsync(tenant, ct);
|
||||
|
||||
var messages = await _jobs.ReadGroupAsync(tenant, _consumer, _batchSize, ct);
|
||||
if (messages.Count == 0)
|
||||
messages = await _jobs.ClaimPendingAsync(tenant, _consumer, _claimIdle, _batchSize, ct);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await ExecuteJobAsync(tenant, message, ct);
|
||||
processed++;
|
||||
if (processed >= _batchSize)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> ListTenantIdsAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<WorkflowsDbContext>();
|
||||
return await db.Workflows
|
||||
.Where(w => w.Status == WorkflowStatus.Compiled)
|
||||
.Select(w => w.TenantId)
|
||||
.Distinct()
|
||||
.OrderBy(t => t)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async Task ExecuteJobAsync(string tenant, StreamMessage message, CancellationToken ct)
|
||||
{
|
||||
TaskInvocation invocation;
|
||||
try
|
||||
{
|
||||
invocation = TaskInvocation.FromFields(message.Fields);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Dead-lettering malformed job {MessageId}: {Error}", message.Id, ex.Message);
|
||||
await _jobs.DeadLetterAsync(tenant, message.Id, message.Fields, "malformed", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (invocation.Type != TaskInvocation.TypeValue)
|
||||
{
|
||||
// Foreign message on our stream — ack and move on.
|
||||
await _jobs.AckAsync(tenant, message.Id, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// A job's tenant must match the stream it was read from; a mismatch means
|
||||
// the control plane enqueued under a different tenant key and must be
|
||||
// dead-lettered rather than silently dropped.
|
||||
if (!string.Equals(invocation.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning("Job {MessageId} tenant {JobTenant} != stream tenant {StreamTenant}; dead-lettering",
|
||||
message.Id, invocation.TenantId, tenant);
|
||||
await _jobs.DeadLetterAsync(tenant, message.Id, message.Fields, "tenant_mismatch", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
ExecutionResult result;
|
||||
|
||||
// S8: a task with a `server` reference runs remotely over SSH (via the
|
||||
// w4c-webapi server-console exec endpoint). The local runtime availability
|
||||
// probe is irrelevant — the target server owns its own runtime. Without a
|
||||
// `server` field the local subprocess path is unchanged (the default).
|
||||
if (!string.IsNullOrWhiteSpace(invocation.Server))
|
||||
{
|
||||
result = await _remote.ExecuteAsync(invocation, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var executor = _runtimes.Resolve(invocation.Language);
|
||||
if (executor == null || !executor.IsAvailable())
|
||||
{
|
||||
var reason = executor == null
|
||||
? $"unknown language '{invocation.Language}'"
|
||||
: $"runtime for language '{invocation.Language}' is unavailable on this worker";
|
||||
await PublishResultAsync(invocation,
|
||||
new ExecutionResult(false, -1, string.Empty, string.Empty, null, reason, TimeSpan.Zero), ct);
|
||||
await _jobs.AckAsync(tenant, message.Id, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
result = await executor.ExecuteAsync(invocation, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result = new ExecutionResult(false, -1, string.Empty, string.Empty, null, ex.Message, sw.Elapsed);
|
||||
}
|
||||
}
|
||||
sw.Stop();
|
||||
|
||||
await PublishResultAsync(invocation, result, ct);
|
||||
await _jobs.AckAsync(tenant, message.Id, ct);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Executed task {TaskKey} (run {RunId}, attempt {Attempt}, tenant {Tenant}) → {Status} in {Ms}ms",
|
||||
invocation.TaskKey, invocation.RunId, invocation.Attempt, tenant,
|
||||
result.Success ? "succeeded" : "failed", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
private Task PublishResultAsync(TaskInvocation invocation, ExecutionResult result, CancellationToken ct)
|
||||
=> _events.PublishAsync(invocation.TenantId, _resultStream, TaskResultMessage.ToFields(invocation, result), ct);
|
||||
|
||||
private static int ParseInt(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||||
}
|
||||
49
Services/JwtValidator.cs
Normal file
49
Services/JwtValidator.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the main w4c JWT (signed with the shared Auth:JwtSigningKey) and
|
||||
/// extracts the tenant id. Used exclusively by the /api/keys* surface so a
|
||||
/// logged-in tenant can mint/rotate/revoke its own operator keys.
|
||||
/// </summary>
|
||||
public static class JwtValidator
|
||||
{
|
||||
static JwtValidator()
|
||||
{
|
||||
// Keep inbound claims exactly as issued ("tenant_id", "sub", ...).
|
||||
// JwtSecurityTokenHandler remaps claims to WS-Federation URIs by
|
||||
// default, which makes FindFirstValue("tenant_id") return null.
|
||||
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
|
||||
}
|
||||
|
||||
public static ClaimsPrincipal? Validate(string? authHeader, string signingKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signingKey) || string.IsNullOrEmpty(authHeader) ||
|
||||
!authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(signingKey));
|
||||
return new JwtSecurityTokenHandler().ValidateToken(
|
||||
authHeader["Bearer ".Length..].Trim(),
|
||||
new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
}, out _);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Services/LanguageRegistry.cs
Normal file
33
Services/LanguageRegistry.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>Describes an executable language in the (extensible) runtime registry.</summary>
|
||||
public sealed record LanguageInfo(string Id, string DisplayName, string DefaultFunction, string Extension);
|
||||
|
||||
/// <summary>
|
||||
/// The set of languages the engine can execute. This is the single source of
|
||||
/// truth for YAML validation and the <c>GET /api/languages</c> endpoint; the
|
||||
/// worker maps each <see cref="LanguageInfo.Id"/> to an executor in step 7.
|
||||
/// </summary>
|
||||
public class LanguageRegistry
|
||||
{
|
||||
private readonly IReadOnlyList<LanguageInfo> _languages;
|
||||
|
||||
public LanguageRegistry()
|
||||
{
|
||||
_languages = new[]
|
||||
{
|
||||
new LanguageInfo("shell", "Shell", "main", ".sh"),
|
||||
new LanguageInfo("javascript", "JavaScript", "handler", ".js"),
|
||||
new LanguageInfo("typescript", "TypeScript", "handler", ".ts"),
|
||||
new LanguageInfo("csharp", "C#", "Main", ".cs"),
|
||||
new LanguageInfo("python", "Python", "main", ".py"),
|
||||
new LanguageInfo("agent", "Agent", "run", ""),
|
||||
};
|
||||
}
|
||||
|
||||
public IReadOnlyList<LanguageInfo> All => _languages;
|
||||
|
||||
public bool IsKnown(string id) => _languages.Any(l => l.Id == id);
|
||||
|
||||
public LanguageInfo? Get(string id) => _languages.FirstOrDefault(l => l.Id == id);
|
||||
}
|
||||
54
Services/LeaseService.cs
Normal file
54
Services/LeaseService.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using StackExchange.Redis;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>Distributed lease over Redis for in-flight run state.</summary>
|
||||
public interface ILeaseService
|
||||
{
|
||||
Task<bool> AcquireAsync(string tenantId, string runId, string owner, TimeSpan ttl);
|
||||
Task<bool> RenewAsync(string tenantId, string runId, string owner, TimeSpan ttl);
|
||||
Task<bool> ReleaseAsync(string tenantId, string runId, string owner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lease / distributed lock over Redis for in-flight run state:
|
||||
/// wf:{tenant}:leases:{runId} → owner id, with a TTL.
|
||||
/// Acquire is atomic (SET NX PX); release compares the owner before deleting so
|
||||
/// a stale owner can never release someone else's lease.
|
||||
/// </summary>
|
||||
public class LeaseService : ILeaseService
|
||||
{
|
||||
// Compare-and-delete: only delete if the value still matches the owner.
|
||||
private const string ReleaseScript =
|
||||
"if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
|
||||
|
||||
private readonly IDatabase _db;
|
||||
|
||||
public LeaseService(IConnectionMultiplexer redis)
|
||||
{
|
||||
_db = redis.GetDatabase();
|
||||
}
|
||||
|
||||
private static RedisKey KeyFor(string tenantId, string runId) => $"wf:{tenantId}:leases:{runId}";
|
||||
|
||||
/// <summary>Atomically acquires the lease; returns false if already held.</summary>
|
||||
public Task<bool> AcquireAsync(string tenantId, string runId, string owner, TimeSpan ttl)
|
||||
=> _db.StringSetAsync(KeyFor(tenantId, runId), owner, ttl, When.NotExists);
|
||||
|
||||
/// <summary>Extends the TTL if (and only if) the caller still owns the lease.</summary>
|
||||
public async Task<bool> RenewAsync(string tenantId, string runId, string owner, TimeSpan ttl)
|
||||
{
|
||||
var current = await _db.StringGetAsync(KeyFor(tenantId, runId));
|
||||
if (current != owner)
|
||||
return false;
|
||||
return await _db.KeyExpireAsync(KeyFor(tenantId, runId), ttl);
|
||||
}
|
||||
|
||||
/// <summary>Releases the lease only if the caller owns it.</summary>
|
||||
public async Task<bool> ReleaseAsync(string tenantId, string runId, string owner)
|
||||
{
|
||||
var result = await _db.ScriptEvaluateAsync(
|
||||
ReleaseScript, new RedisKey[] { KeyFor(tenantId, runId) }, new RedisValue[] { owner });
|
||||
return (long)result == 1;
|
||||
}
|
||||
}
|
||||
197
Services/MermaidGeneratorService.cs
Normal file
197
Services/MermaidGeneratorService.cs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
using System.Text.Json;
|
||||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates Mermaid diagram source for a compiled workflow. The frontend
|
||||
/// renders the returned source with the existing `mermaid` library; the backend
|
||||
/// stays the single source of truth for graph layout.
|
||||
///
|
||||
/// Task labels use the YAML <see cref="WorkflowTask.Key"/> (never the hashed
|
||||
/// <see cref="WorkflowTask.Id"/>), and every class diagram reflects
|
||||
/// <see cref="WorkflowTask.Language"/> (not a runtime/host — that is
|
||||
/// <see cref="Workflow.Target"/>).
|
||||
/// </summary>
|
||||
public class MermaidGeneratorService
|
||||
{
|
||||
public static readonly string[] Types = { "flowchart", "sequence", "state", "class" };
|
||||
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public string Generate(string type, Workflow workflow, IReadOnlyList<WorkflowTask> tasks)
|
||||
=> type switch
|
||||
{
|
||||
"flowchart" => Flowchart(tasks),
|
||||
"sequence" => Sequence(tasks),
|
||||
"state" => State(tasks),
|
||||
"class" => Class(tasks),
|
||||
_ => throw new ArgumentException($"Unknown diagram type '{type}'.", nameof(type)),
|
||||
};
|
||||
|
||||
private static WorkflowTask Root(IReadOnlyList<WorkflowTask> tasks)
|
||||
=> tasks.First(t => t.Key == "root");
|
||||
|
||||
private static string NodeLabel(WorkflowTask t) => $"{t.Key}<br/>({t.Language})";
|
||||
|
||||
// ------------------------------------------------------------------ flowchart
|
||||
|
||||
private static string Flowchart(IReadOnlyList<WorkflowTask> tasks)
|
||||
{
|
||||
var lines = new List<string> { "flowchart TD" };
|
||||
|
||||
foreach (var task in tasks.OrderBy(t => t.Order))
|
||||
lines.Add($" {task.Key}[\"{NodeLabel(task)}\"]");
|
||||
|
||||
lines.Add(string.Empty);
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.NextId != null)
|
||||
lines.Add($" {task.Key} --> {Key(tasks, task.NextId.Value)}");
|
||||
}
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.OnErrorId != null)
|
||||
lines.Add($" {task.Key} -.->|error| {Key(tasks, task.OnErrorId.Value)}");
|
||||
}
|
||||
|
||||
lines.Add(string.Empty);
|
||||
lines.Add(" classDef root fill:#fde68a,stroke:#d97706,stroke-width:2px");
|
||||
lines.Add(" class root root");
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ sequence
|
||||
|
||||
private static string Sequence(IReadOnlyList<WorkflowTask> tasks)
|
||||
{
|
||||
var lines = new List<string> { "sequenceDiagram", " autonumber" };
|
||||
|
||||
foreach (var task in tasks.OrderBy(t => t.Order))
|
||||
lines.Add($" participant {task.Key} as {task.Key} [{task.Language}]");
|
||||
|
||||
lines.Add(string.Empty);
|
||||
|
||||
var happyPath = Chain(tasks);
|
||||
for (var i = 0; i < happyPath.Count - 1; i++)
|
||||
lines.Add($" {happyPath[i].Key}->>{happyPath[i + 1].Key}: {happyPath[i + 1].Key}");
|
||||
|
||||
var errorEdges = tasks.Where(t => t.OnErrorId != null).ToList();
|
||||
if (errorEdges.Count > 0)
|
||||
{
|
||||
lines.Add(string.Empty);
|
||||
lines.Add(" alt on error");
|
||||
foreach (var task in errorEdges)
|
||||
lines.Add($" {task.Key}->>{Key(tasks, task.OnErrorId!.Value)}: compensate");
|
||||
lines.Add(" end");
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ state
|
||||
|
||||
private static string State(IReadOnlyList<WorkflowTask> tasks)
|
||||
{
|
||||
var lines = new List<string> { "stateDiagram-v2" };
|
||||
|
||||
// Task keys are arbitrary YAML identifiers and may collide with mermaid
|
||||
// state-diagram reserved words ("root", "note", ...) that crash the
|
||||
// renderer. Alias every state to a generated, guaranteed-safe id (s0,
|
||||
// s1, ...) while keeping the human-readable task key as the state label.
|
||||
var nodes = tasks.OrderBy(t => t.Order).ToList();
|
||||
var idOf = new Dictionary<Guid, string>();
|
||||
for (var i = 0; i < nodes.Count; i++)
|
||||
{
|
||||
var id = $"s{i}";
|
||||
idOf[nodes[i].Id] = id;
|
||||
lines.Add($" state \"{nodes[i].Key}\" as {id}");
|
||||
}
|
||||
|
||||
var root = Root(tasks);
|
||||
lines.Add(string.Empty);
|
||||
lines.Add($" [*] --> {idOf[root.Id]}");
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
var from = idOf[task.Id];
|
||||
if (task.NextId != null && idOf.TryGetValue(task.NextId.Value, out var next))
|
||||
lines.Add($" {from} --> {next}");
|
||||
else if (task.OnErrorId == null)
|
||||
lines.Add($" {from} --> [*]");
|
||||
}
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.OnErrorId != null && idOf.TryGetValue(task.OnErrorId.Value, out var onError))
|
||||
lines.Add($" {idOf[task.Id]} --> {onError}: error");
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ class
|
||||
|
||||
private static string Class(IReadOnlyList<WorkflowTask> tasks)
|
||||
{
|
||||
var lines = new List<string> { "classDiagram" };
|
||||
|
||||
foreach (var task in tasks.OrderBy(t => t.Order))
|
||||
{
|
||||
lines.Add($" class {task.Key} {{");
|
||||
lines.Add($" <<{task.Language}>>");
|
||||
lines.Add($" {FunctionOf(task)}");
|
||||
lines.Add($" mode: {task.Mode}");
|
||||
lines.Add($" entry: {FileOf(task)}");
|
||||
lines.Add(" }");
|
||||
}
|
||||
|
||||
lines.Add(string.Empty);
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.NextId != null)
|
||||
lines.Add($" {task.Key} --> {Key(tasks, task.NextId.Value)} : next");
|
||||
}
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.OnErrorId != null)
|
||||
lines.Add($" {task.Key} ..> {Key(tasks, task.OnErrorId.Value)} : error");
|
||||
}
|
||||
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
/// <summary>The linear success chain starting at root.</summary>
|
||||
private static IReadOnlyList<WorkflowTask> Chain(IReadOnlyList<WorkflowTask> tasks)
|
||||
{
|
||||
var byId = tasks.ToDictionary(t => t.Id);
|
||||
var chain = new List<WorkflowTask>();
|
||||
var visited = new HashSet<Guid>();
|
||||
|
||||
var cursor = Root(tasks);
|
||||
while (cursor != null && visited.Add(cursor.Id))
|
||||
{
|
||||
chain.Add(cursor);
|
||||
cursor = cursor.NextId != null && byId.TryGetValue(cursor.NextId.Value, out var next) ? next : null;
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static string Key(IReadOnlyList<WorkflowTask> tasks, Guid id)
|
||||
=> tasks.FirstOrDefault(t => t.Id == id)?.Key ?? id.ToString();
|
||||
|
||||
private static string FileOf(WorkflowTask task)
|
||||
=> JsonSerializer.Deserialize<EntryDefinition>(task.EntryJson ?? "{}", Json)?.File ?? "?";
|
||||
|
||||
private static string FunctionOf(WorkflowTask task)
|
||||
{
|
||||
var entry = JsonSerializer.Deserialize<EntryDefinition>(task.EntryJson ?? "{}", Json);
|
||||
var fn = string.IsNullOrWhiteSpace(entry?.Function) ? "run" : entry.Function;
|
||||
return $"+{fn}()";
|
||||
}
|
||||
}
|
||||
67
Services/Messaging/Abstractions.cs
Normal file
67
Services/Messaging/Abstractions.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
namespace w4c_workflows.Services.Messaging;
|
||||
|
||||
/// <summary>A single entry pulled off a Redis stream / (later) RabbitMQ queue.</summary>
|
||||
public sealed record StreamMessage(string Id, IReadOnlyDictionary<string, string> Fields);
|
||||
|
||||
/// <summary>
|
||||
/// Default event stream for <c>handler</c>-mode subscriptions when the YAML
|
||||
/// trigger does not name an explicit <c>stream</c>.
|
||||
/// </summary>
|
||||
public static class Streams
|
||||
{
|
||||
public const string DefaultEvents = "wf:{tenant}:events";
|
||||
|
||||
/// <summary>Worker → control-plane result events (<c>task.result</c>).</summary>
|
||||
public const string Results = "wf:{tenant}:results";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Job dispatch for <c>task.run</c> messages. The v1 implementation is Redis
|
||||
/// Streams with consumer groups; a RabbitMQ transport can be swapped in behind
|
||||
/// this interface without touching the engine.
|
||||
/// </summary>
|
||||
public interface IJobQueue
|
||||
{
|
||||
/// <summary>Ensures the tenant's jobs consumer group exists.</summary>
|
||||
Task EnsureGroupAsync(string tenantId, CancellationToken ct);
|
||||
|
||||
/// <summary>Enqueues a job; returns the stream entry id.</summary>
|
||||
Task<string> EnqueueAsync(string tenantId, IReadOnlyDictionary<string, string> fields, CancellationToken ct);
|
||||
|
||||
/// <summary>Reads new (undelivered) jobs for a consumer.</summary>
|
||||
Task<IReadOnlyList<StreamMessage>> ReadGroupAsync(string tenantId, string consumer, int count, CancellationToken ct);
|
||||
|
||||
/// <summary>Claims jobs pending for longer than <paramref name="minIdle"/> (redelivery).</summary>
|
||||
Task<IReadOnlyList<StreamMessage>> ClaimPendingAsync(string tenantId, string consumer, TimeSpan minIdle, int count, CancellationToken ct);
|
||||
|
||||
Task<long> AckAsync(string tenantId, string messageId, CancellationToken ct);
|
||||
|
||||
/// <summary>Copies a failed job to the DLQ and acks it out of the group.</summary>
|
||||
Task DeadLetterAsync(string tenantId, string messageId, IReadOnlyDictionary<string, string> fields, string reason, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a dead-letter entry without a source job to ack. Used by the
|
||||
/// control-plane saga path: a task that exhausted retries (or a failed
|
||||
/// compensation) has no live jobs-stream entry left to acknowledge.
|
||||
/// </summary>
|
||||
Task PublishDeadLetterAsync(string tenantId, IReadOnlyDictionary<string, string> fields, string reason, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event publish/subscribe (Redis Streams now, RabbitMQ later). A
|
||||
/// <c>handler</c>-mode workflow subscribes to a named stream via
|
||||
/// <c>trigger.stream</c>; the trigger engine consumes it through a consumer
|
||||
/// group and runs the downstream <c>nextId</c> chain per event.
|
||||
/// </summary>
|
||||
public interface IEventBus
|
||||
{
|
||||
Task<string> PublishAsync(string tenantId, string stream, IReadOnlyDictionary<string, string> fields, CancellationToken ct);
|
||||
|
||||
Task EnsureGroupAsync(string tenantId, string stream, CancellationToken ct);
|
||||
|
||||
Task<IReadOnlyList<StreamMessage>> ReadGroupAsync(string tenantId, string stream, string consumer, int count, CancellationToken ct);
|
||||
|
||||
Task<IReadOnlyList<StreamMessage>> ClaimPendingAsync(string tenantId, string stream, string consumer, TimeSpan minIdle, int count, CancellationToken ct);
|
||||
|
||||
Task<long> AckAsync(string tenantId, string stream, string messageId, CancellationToken ct);
|
||||
}
|
||||
162
Services/Messaging/RedisStreamsTransport.cs
Normal file
162
Services/Messaging/RedisStreamsTransport.cs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
using StackExchange.Redis;
|
||||
|
||||
namespace w4c_workflows.Services.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Redis Streams implementation of <see cref="IJobQueue"/> and <see cref="IEventBus"/>.
|
||||
///
|
||||
/// Streams per tenant:
|
||||
/// - wf:{tenant}:jobs — task.run dispatch (consumer group `workers`)
|
||||
/// - wf:{tenant}:events — default handler-mode event stream
|
||||
/// - wf:{tenant}:dlq — dead-lettered messages
|
||||
///
|
||||
/// Delivery is at-least-once; the engine enforces idempotency via
|
||||
/// correlation_id + a per-task idempotency key.
|
||||
/// </summary>
|
||||
public class RedisStreamsTransport : IJobQueue, IEventBus
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly string _jobsTemplate;
|
||||
private readonly string _eventsTemplate;
|
||||
private readonly string _dlqTemplate;
|
||||
private readonly string _group;
|
||||
|
||||
public RedisStreamsTransport(IConnectionMultiplexer redis, IConfiguration config)
|
||||
{
|
||||
_db = redis.GetDatabase();
|
||||
_jobsTemplate = config["Workflows:WorkerStream"] ?? "wf:{tenant}:jobs";
|
||||
_eventsTemplate = config["Workflows:EventStream"] ?? "wf:{tenant}:events";
|
||||
_dlqTemplate = config["Workflows:DlqStream"] ?? "wf:{tenant}:dlq";
|
||||
_group = config["Workflows:ConsumerGroup"] ?? "workers";
|
||||
}
|
||||
|
||||
private string JobsStream(string tenantId) => _jobsTemplate.Replace("{tenant}", tenantId);
|
||||
private string EventStream(string tenantId, string stream) => Resolve(stream, tenantId);
|
||||
private string DlqStream(string tenantId) => _dlqTemplate.Replace("{tenant}", tenantId);
|
||||
|
||||
private static string Resolve(string stream, string tenantId)
|
||||
=> stream.Replace("{tenant}", tenantId);
|
||||
|
||||
// ------------------------------------------------------------------ IJobQueue
|
||||
|
||||
public Task EnsureGroupAsync(string tenantId, CancellationToken ct)
|
||||
=> EnsureGroupCoreAsync(JobsStream(tenantId), ct);
|
||||
|
||||
public Task<string> EnqueueAsync(string tenantId, IReadOnlyDictionary<string, string> fields, CancellationToken ct)
|
||||
=> AddAsync(JobsStream(tenantId), fields);
|
||||
|
||||
public Task<IReadOnlyList<StreamMessage>> ReadGroupAsync(string tenantId, string consumer, int count, CancellationToken ct)
|
||||
=> ReadGroupCoreAsync(JobsStream(tenantId), consumer, count);
|
||||
|
||||
public Task<IReadOnlyList<StreamMessage>> ClaimPendingAsync(string tenantId, string consumer, TimeSpan minIdle, int count, CancellationToken ct)
|
||||
=> ClaimPendingCoreAsync(JobsStream(tenantId), consumer, minIdle, count);
|
||||
|
||||
public Task<long> AckAsync(string tenantId, string messageId, CancellationToken ct)
|
||||
=> _db.StreamAcknowledgeAsync(JobsStream(tenantId), _group, messageId);
|
||||
|
||||
public Task DeadLetterAsync(string tenantId, string messageId, IReadOnlyDictionary<string, string> fields, string reason, CancellationToken ct)
|
||||
=> DeadLetterCoreAsync(JobsStream(tenantId), DlqStream(tenantId), messageId, fields, reason);
|
||||
|
||||
public Task PublishDeadLetterAsync(string tenantId, IReadOnlyDictionary<string, string> fields, string reason, CancellationToken ct)
|
||||
=> AddAsync(DlqStream(tenantId), DeadLetterFields(fields, reason, sourceId: null));
|
||||
|
||||
// ------------------------------------------------------------------ IEventBus
|
||||
|
||||
public Task<string> PublishAsync(string tenantId, string stream, IReadOnlyDictionary<string, string> fields, CancellationToken ct)
|
||||
=> AddAsync(EventStream(tenantId, stream), fields);
|
||||
|
||||
public Task EnsureGroupAsync(string tenantId, string stream, CancellationToken ct)
|
||||
=> EnsureGroupCoreAsync(EventStream(tenantId, stream), ct);
|
||||
|
||||
public Task<IReadOnlyList<StreamMessage>> ReadGroupAsync(string tenantId, string stream, string consumer, int count, CancellationToken ct)
|
||||
=> ReadGroupCoreAsync(EventStream(tenantId, stream), consumer, count);
|
||||
|
||||
public Task<IReadOnlyList<StreamMessage>> ClaimPendingAsync(string tenantId, string stream, string consumer, TimeSpan minIdle, int count, CancellationToken ct)
|
||||
=> ClaimPendingCoreAsync(EventStream(tenantId, stream), consumer, minIdle, count);
|
||||
|
||||
public Task<long> AckAsync(string tenantId, string stream, string messageId, CancellationToken ct)
|
||||
=> _db.StreamAcknowledgeAsync(EventStream(tenantId, stream), _group, messageId);
|
||||
|
||||
// ------------------------------------------------------------------ core
|
||||
|
||||
private async Task<string> AddAsync(string key, IReadOnlyDictionary<string, string> fields)
|
||||
{
|
||||
var entries = fields.Select(kv => new NameValueEntry(kv.Key, kv.Value)).ToArray();
|
||||
var id = await _db.StreamAddAsync(key, entries);
|
||||
return id.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the consumer group idempotently. The group starts at new messages
|
||||
/// (`>`) so a fresh worker never replays stale history; redelivery of
|
||||
/// in-flight messages is handled by <see cref="ClaimPendingCoreAsync"/>.
|
||||
/// </summary>
|
||||
private async Task EnsureGroupCoreAsync(string key, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _db.StreamCreateConsumerGroupAsync(key, _group, StreamPosition.NewMessages, createStream: true);
|
||||
}
|
||||
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Group already exists — fine.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<StreamMessage>> ReadGroupCoreAsync(string key, string consumer, int count)
|
||||
{
|
||||
var entries = await _db.StreamReadGroupAsync(key, _group, consumer, StreamPosition.NewMessages, count);
|
||||
return entries.Select(ToMessage).ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<StreamMessage>> ClaimPendingCoreAsync(string key, string consumer, TimeSpan minIdle, int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _db.StreamAutoClaimAsync(
|
||||
key, _group, consumer, (long)minIdle.TotalMilliseconds, StreamPosition.Beginning, count);
|
||||
return result.ClaimedEntries.Select(ToMessage).ToList();
|
||||
}
|
||||
catch (RedisServerException)
|
||||
{
|
||||
// Consumer group may not exist yet, or the stream is empty.
|
||||
return Array.Empty<StreamMessage>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeadLetterCoreAsync(string key, string dlq, string messageId,
|
||||
IReadOnlyDictionary<string, string> fields, string reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
await AddAsync(dlq, DeadLetterFields(fields, reason, messageId));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Ack the original so it leaves the pending list regardless of whether
|
||||
// the DLQ write succeeded (the failure is already captured in logs).
|
||||
await _db.StreamAcknowledgeAsync(key, _group, messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> DeadLetterFields(
|
||||
IReadOnlyDictionary<string, string> fields, string reason, string? sourceId)
|
||||
{
|
||||
var dlqFields = new Dictionary<string, string>(fields)
|
||||
{
|
||||
["dlq.reason"] = reason,
|
||||
["dlq.at"] = DateTime.UtcNow.ToString("O"),
|
||||
};
|
||||
if (sourceId != null)
|
||||
dlqFields["dlq.source_id"] = sourceId;
|
||||
return dlqFields;
|
||||
}
|
||||
|
||||
private static StreamMessage ToMessage(StreamEntry entry)
|
||||
{
|
||||
var dict = new Dictionary<string, string>(entry.Values.Length);
|
||||
foreach (var value in entry.Values)
|
||||
dict[value.Name.ToString()] = value.Value.ToString();
|
||||
return new StreamMessage(entry.Id.ToString(), dict);
|
||||
}
|
||||
}
|
||||
128
Services/RenderTokenService.cs
Normal file
128
Services/RenderTokenService.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Issues and verifies short-lived signed render tokens that let any client load
|
||||
/// the workflow HTML preview in an <c><iframe></c>. An iframe cannot send
|
||||
/// the operator-key <c>Authorization</c> header, so the flow is:
|
||||
/// <list type="bullet">
|
||||
/// <item>the frontend calls <c>GET /api/workflows/{id}/html-token</c> with the
|
||||
/// operator key to mint a token, then</item>
|
||||
/// <item>points the iframe at <c>GET /api/workflows/{id}/html?token=...</c>.</item>
|
||||
/// </list>
|
||||
/// The token is HMAC-SHA256 signed, binds the tenant + workflow id, and expires,
|
||||
/// so a leaked URL is short-lived and cannot be replayed against another workflow.
|
||||
/// </summary>
|
||||
public sealed class RenderTokenService
|
||||
{
|
||||
private readonly byte[]? _key;
|
||||
private readonly ILogger<RenderTokenService> _logger;
|
||||
|
||||
public RenderTokenService(IConfiguration config, ILogger<RenderTokenService> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
var signingKey = config["Auth:JwtSigningKey"];
|
||||
if (string.IsNullOrWhiteSpace(signingKey))
|
||||
{
|
||||
// No signing key → render tokens are disabled. The /html-token
|
||||
// endpoint will return 503; the /html endpoint without a token
|
||||
// falls through to normal operator-key auth. This prevents
|
||||
// trivially forgeable tokens from being issued.
|
||||
_logger.LogError(
|
||||
"Auth:JwtSigningKey is unset — render tokens are DISABLED. " +
|
||||
"Set the key to enable iframe HTML previews.");
|
||||
_key = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_key = Encoding.UTF8.GetBytes(signingKey);
|
||||
}
|
||||
}
|
||||
|
||||
public string Issue(string tenantId, Guid workflowId, TimeSpan ttl)
|
||||
{
|
||||
if (_key == null)
|
||||
throw new InvalidOperationException(
|
||||
"Cannot issue render tokens: Auth:JwtSigningKey is not configured.");
|
||||
|
||||
var exp = DateTimeOffset.UtcNow.Add(ttl).ToUnixTimeSeconds();
|
||||
var payload = $"{tenantId}|{workflowId:N}|{exp}";
|
||||
var payloadB64 = Base64Url.Encode(Encoding.UTF8.GetBytes(payload));
|
||||
var sig = Sign(payloadB64);
|
||||
return $"{payloadB64}.{sig}";
|
||||
}
|
||||
|
||||
public bool TryVerify(string token, out string tenantId, out Guid workflowId)
|
||||
{
|
||||
tenantId = string.Empty;
|
||||
workflowId = Guid.Empty;
|
||||
|
||||
if (_key == null)
|
||||
return false; // tokens disabled — always reject
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return false;
|
||||
|
||||
var parts = token.Split('.');
|
||||
if (parts.Length != 2)
|
||||
return false;
|
||||
|
||||
var payloadB64 = parts[0];
|
||||
var sig = parts[1];
|
||||
if (!FixedTimeEquals(sig, Sign(payloadB64)))
|
||||
return false;
|
||||
|
||||
byte[] payloadBytes;
|
||||
try
|
||||
{
|
||||
payloadBytes = Base64Url.Decode(payloadB64);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var payload = Encoding.UTF8.GetString(payloadBytes);
|
||||
var seg = payload.Split('|');
|
||||
if (seg.Length != 3)
|
||||
return false;
|
||||
|
||||
if (!long.TryParse(seg[2], out var exp))
|
||||
return false;
|
||||
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp)
|
||||
return false;
|
||||
|
||||
tenantId = seg[0];
|
||||
return Guid.TryParseExact(seg[1], "N", out workflowId);
|
||||
}
|
||||
|
||||
private string Sign(string payloadB64)
|
||||
{
|
||||
using var hmac = new HMACSHA256(_key!); // non-null: callers guard with _key == null checks
|
||||
return Base64Url.Encode(hmac.ComputeHash(Encoding.UTF8.GetBytes(payloadB64)));
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string a, string b)
|
||||
=> CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
|
||||
}
|
||||
|
||||
/// <summary>Base64-url (RFC 4648 §5) encoding without padding.</summary>
|
||||
public static class Base64Url
|
||||
{
|
||||
public static string Encode(byte[] data)
|
||||
=> Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
|
||||
public static byte[] Decode(string s)
|
||||
{
|
||||
var b64 = s.Replace('-', '+').Replace('_', '/');
|
||||
switch (b64.Length % 4)
|
||||
{
|
||||
case 2: b64 += "=="; break;
|
||||
case 3: b64 += "="; break;
|
||||
}
|
||||
return Convert.FromBase64String(b64);
|
||||
}
|
||||
}
|
||||
70
Services/Runs/DurableStateStore.cs
Normal file
70
Services/Runs/DurableStateStore.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Persists <c>durable</c>/<c>handler</c> checkpoint state. The checkpoint
|
||||
/// records the instance's position (<c>TaskId</c> = the next task to run) and
|
||||
/// the input to feed it (<c>StateJson</c>), enabling resume after an external
|
||||
/// event. The instance id is a run id for <c>durable</c> workflows and a stable
|
||||
/// per-workflow id for <c>handler</c> workflows.
|
||||
/// </summary>
|
||||
public class DurableStateStore
|
||||
{
|
||||
private readonly WorkflowsDbContext _db;
|
||||
|
||||
public DurableStateStore(WorkflowsDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task CheckpointAsync(
|
||||
string instanceId,
|
||||
Guid taskId,
|
||||
string? stateJson,
|
||||
string? correlationId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var existing = await _db.DurableStates
|
||||
.FirstOrDefaultAsync(d => d.InstanceId == instanceId, ct);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (existing == null)
|
||||
{
|
||||
_db.DurableStates.Add(new DurableState
|
||||
{
|
||||
InstanceId = instanceId,
|
||||
TaskId = taskId,
|
||||
StateJson = stateJson,
|
||||
CheckpointAt = now,
|
||||
CorrelationId = correlationId,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.TaskId = taskId;
|
||||
existing.StateJson = stateJson;
|
||||
existing.CheckpointAt = now;
|
||||
if (!string.IsNullOrWhiteSpace(correlationId))
|
||||
existing.CorrelationId = correlationId;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public Task<DurableState?> GetAsync(string instanceId, CancellationToken ct)
|
||||
=> _db.DurableStates.FirstOrDefaultAsync(d => d.InstanceId == instanceId, ct);
|
||||
|
||||
public async Task ClearAsync(string instanceId, CancellationToken ct)
|
||||
{
|
||||
var existing = await _db.DurableStates
|
||||
.FirstOrDefaultAsync(d => d.InstanceId == instanceId, ct);
|
||||
if (existing == null)
|
||||
return;
|
||||
|
||||
_db.DurableStates.Remove(existing);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
74
Services/Runs/RunLauncher.cs
Normal file
74
Services/Runs/RunLauncher.cs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services.Runs;
|
||||
|
||||
/// <summary>Request to start a workflow run.</summary>
|
||||
public sealed record LaunchRequest(
|
||||
string TenantId,
|
||||
Guid WorkflowId,
|
||||
string? TriggerJson,
|
||||
string? InputJson,
|
||||
string? CorrelationId,
|
||||
Guid? StartTaskId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Starts a run for a compiled workflow by creating a <c>pending</c>
|
||||
/// <see cref="WorkflowRun"/> row. This is the seam between the trigger engine
|
||||
/// (step 8 — decides WHEN to run) and the run lifecycle engine (step 9 — owns
|
||||
/// dispatch via Redis, worker result consumption, chain advancement and durable
|
||||
/// checkpoint/resume). The lifecycle engine picks up <c>pending</c> runs and
|
||||
/// drives them to completion, so the trigger engine never touches the jobs
|
||||
/// stream.
|
||||
/// </summary>
|
||||
public interface IRunLauncher
|
||||
{
|
||||
Task<Guid> LaunchAsync(LaunchRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
public class RunLauncher : IRunLauncher
|
||||
{
|
||||
private readonly WorkflowsDbContext _db;
|
||||
private readonly ILogger<RunLauncher> _logger;
|
||||
|
||||
public RunLauncher(WorkflowsDbContext db, ILogger<RunLauncher> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Guid> LaunchAsync(LaunchRequest request, CancellationToken ct)
|
||||
{
|
||||
var workflow = await _db.Workflows
|
||||
.FirstOrDefaultAsync(w =>
|
||||
w.Id == request.WorkflowId
|
||||
&& w.TenantId == request.TenantId
|
||||
&& w.Status == WorkflowStatus.Compiled, ct);
|
||||
|
||||
if (workflow == null)
|
||||
throw new InvalidOperationException(
|
||||
$"workflow {request.WorkflowId} not found or not compiled for tenant {request.TenantId}");
|
||||
|
||||
var run = new WorkflowRun
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkflowId = workflow.Id,
|
||||
TenantId = request.TenantId,
|
||||
Status = RunStatus.Pending,
|
||||
TriggerJson = request.TriggerJson,
|
||||
InputJson = request.InputJson,
|
||||
CorrelationId = request.CorrelationId,
|
||||
StartTaskId = request.StartTaskId,
|
||||
};
|
||||
|
||||
_db.WorkflowRuns.Add(run);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Launched pending run {RunId} for workflow {WorkflowId} (tenant {TenantId}, correlation {CorrelationId})",
|
||||
run.Id, workflow.Id, request.TenantId, request.CorrelationId);
|
||||
|
||||
return run.Id;
|
||||
}
|
||||
}
|
||||
647
Services/Runs/RunLifecycleEngine.cs
Normal file
647
Services/Runs/RunLifecycleEngine.cs
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using w4c_workflows.Services.Messaging;
|
||||
|
||||
namespace w4c_workflows.Services.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// The run lifecycle state machine, extracted from the hosted service so it is
|
||||
/// unit-testable without a running host. Owns the two async halves of a run:
|
||||
///
|
||||
/// <list type="number">
|
||||
/// <item><b>Dispatch</b> — picks up <c>pending</c> runs, marks them
|
||||
/// <c>running</c>, and enqueues the start task (root, or <c>StartTaskId</c>
|
||||
/// for a durable resume) as a <c>task.run</c> job.</item>
|
||||
/// <item><b>Advance</b> — consumes <c>task.result</c> events from
|
||||
/// <c>wf:{tenant}:results</c>, updates the TaskRun, and follows the success
|
||||
/// <c>nextId</c> chain until the run is <c>succeeded</c> or <c>failed</c>.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// On a task failure the saga policy (step 10) applies: retry with exponential
|
||||
/// backoff, then reverse-compensate the succeeded prefix via <c>onError</c>
|
||||
/// targets, then dead-letter unhandled failures to <c>wf:{tenant}:dlq</c>.
|
||||
///
|
||||
/// <c>durable</c>/<c>handler</c> modes checkpoint after each transition so the
|
||||
/// instance can resume. A per-run Redis lease prevents double-dispatch across
|
||||
/// control-plane replicas. Delivery is at-least-once; duplicate results are
|
||||
/// ignored because each TaskRun is keyed by (run, task, attempt).
|
||||
/// </summary>
|
||||
public class RunLifecycleEngine
|
||||
{
|
||||
private readonly IEventBus _events;
|
||||
private readonly ILeaseService _leases;
|
||||
private readonly ILogger<RunLifecycleEngine> _logger;
|
||||
|
||||
private readonly string _owner;
|
||||
private readonly int _batchSize;
|
||||
private readonly TimeSpan _claimIdle;
|
||||
private readonly TimeSpan _leaseTtl;
|
||||
|
||||
private readonly int _maxRetries;
|
||||
private readonly TimeSpan _retryBaseDelay;
|
||||
private readonly TimeSpan _retryMaxDelay;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
|
||||
private static readonly JsonSerializerOptions PlanJson = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public RunLifecycleEngine(
|
||||
IEventBus events,
|
||||
ILeaseService leases,
|
||||
IConfiguration config,
|
||||
ILogger<RunLifecycleEngine> logger)
|
||||
{
|
||||
_events = events;
|
||||
_leases = leases;
|
||||
_logger = logger;
|
||||
|
||||
_owner = $"runs-{Environment.MachineName}-{Guid.NewGuid():N}"[..28];
|
||||
_batchSize = ParseInt(config["Workflows:LifecycleBatchSize"], 10);
|
||||
_claimIdle = TimeSpan.FromSeconds(ParseInt(config["Workflows:LifecycleClaimIdleSeconds"], 30));
|
||||
_leaseTtl = TimeSpan.FromSeconds(ParseInt(config["Workflows:LifecycleLeaseTtlSeconds"], 60));
|
||||
|
||||
_maxRetries = ParseNonNegative(config["Workflows:MaxRetries"], 3);
|
||||
_retryBaseDelay = TimeSpan.FromMilliseconds(ParseNonNegative(config["Workflows:RetryBaseDelayMs"], 1000));
|
||||
_retryMaxDelay = TimeSpan.FromMilliseconds(ParseNonNegative(config["Workflows:RetryMaxDelayMs"], 30000));
|
||||
_runTimeout = TimeSpan.FromSeconds(ParseNonNegative(config["Workflows:RunTimeoutSeconds"], 300));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ dispatch
|
||||
|
||||
public async Task<int> DispatchPendingAsync(WorkflowsDbContext db, TaskDispatcher dispatcher, CancellationToken ct)
|
||||
{
|
||||
var pending = await db.WorkflowRuns
|
||||
.Where(r => r.Status == RunStatus.Pending)
|
||||
.OrderBy(r => r.Id)
|
||||
.Take(_batchSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var dispatched = 0;
|
||||
foreach (var run in pending)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
return dispatched;
|
||||
|
||||
if (!await _leases.AcquireAsync(run.TenantId, run.Id.ToString(), _owner, _leaseTtl))
|
||||
continue; // another replica owns this run
|
||||
|
||||
try
|
||||
{
|
||||
if (await DispatchRunAsync(db, dispatcher, run, ct))
|
||||
dispatched++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to dispatch run {RunId} (tenant {TenantId})", run.Id, run.TenantId);
|
||||
await FailRunAsync(db, run, $"dispatch error: {ex.Message}", ct);
|
||||
}
|
||||
}
|
||||
|
||||
return dispatched;
|
||||
}
|
||||
|
||||
private async Task<bool> DispatchRunAsync(WorkflowsDbContext db, TaskDispatcher dispatcher, WorkflowRun run, CancellationToken ct)
|
||||
{
|
||||
var workflow = await db.Workflows
|
||||
.Include(w => w.Tasks)
|
||||
.FirstOrDefaultAsync(w => w.Id == run.WorkflowId && w.TenantId == run.TenantId, ct);
|
||||
|
||||
if (workflow == null || workflow.Status != WorkflowStatus.Compiled)
|
||||
{
|
||||
_logger.LogWarning("Run {RunId} references missing/uncompiled workflow {WorkflowId}; failing it",
|
||||
run.Id, run.WorkflowId);
|
||||
await FailRunAsync(db, run, "workflow is missing or not compiled", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
var startTask = run.StartTaskId != null
|
||||
? workflow.Tasks.FirstOrDefault(t => t.Id == run.StartTaskId)
|
||||
: workflow.Tasks.FirstOrDefault(t => t.Key == "root");
|
||||
|
||||
if (startTask == null)
|
||||
{
|
||||
_logger.LogWarning("Run {RunId} has no start task (StartTaskId {StartTaskId}); failing it",
|
||||
run.Id, run.StartTaskId);
|
||||
await FailRunAsync(db, run, "start task not found in workflow", ct);
|
||||
return false;
|
||||
}
|
||||
|
||||
run.Status = RunStatus.Running;
|
||||
run.StartedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct); // persist running before enqueue so a result can never race a pending run
|
||||
|
||||
await dispatcher.DispatchAsync(run, startTask, workflow.Path, run.InputJson, 1, ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- retries
|
||||
|
||||
/// <summary>
|
||||
/// Re-dispatches failed task attempts whose exponential backoff has elapsed.
|
||||
/// Called by the hosted service on each tick. The retry is idempotent: it
|
||||
/// only fires when the next attempt's TaskRun does not already exist, so a
|
||||
/// duplicate tick (or a second replica) cannot double-dispatch the same
|
||||
/// attempt.
|
||||
///
|
||||
/// A per-TaskRun Redis lease prevents concurrent replicas from
|
||||
/// double-dispatching the same retry. This mirrors the per-run lease in
|
||||
/// <see cref="DispatchPendingAsync"/>. Each retry is committed individually
|
||||
/// so a mid-batch failure never leaves some dispatched but
|
||||
/// <c>NextAttemptAt</c> unchanged.
|
||||
/// </summary>
|
||||
public async Task<int> RetryDueAsync(WorkflowsDbContext db, TaskDispatcher dispatcher, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var due = await db.TaskRuns
|
||||
.Where(t => t.Status == TaskRunStatus.Failed
|
||||
&& t.NextAttemptAt != null
|
||||
&& t.NextAttemptAt <= now
|
||||
&& !t.IsCompensation)
|
||||
.OrderBy(t => t.NextAttemptAt)
|
||||
.Take(_batchSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var retried = 0;
|
||||
foreach (var failed in due)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
break;
|
||||
|
||||
// Per-TaskRun lease: prevents two replicas from dispatching the
|
||||
// same retry concurrently. Uses "taskrun" as the lease namespace
|
||||
// (distinct from the "wf:{tenant}:leases:{run}" keys used by
|
||||
// DispatchPendingAsync). The key is globally unique per TaskRun.
|
||||
if (!await _leases.AcquireAsync("taskrun", failed.Id.ToString(), _owner, _leaseTtl))
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
var run = await db.WorkflowRuns
|
||||
.Include(r => r.Workflow).ThenInclude(w => w.Tasks)
|
||||
.FirstOrDefaultAsync(r => r.Id == failed.RunId, ct);
|
||||
|
||||
// A stale schedule (the run already finished) is simply cleared.
|
||||
if (run == null || run.Status != RunStatus.Running)
|
||||
{
|
||||
failed.NextAttemptAt = null;
|
||||
await db.SaveChangesAsync(ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
var task = run.Workflow.Tasks.FirstOrDefault(t => t.Id == failed.TaskId);
|
||||
if (task == null)
|
||||
{
|
||||
failed.NextAttemptAt = null;
|
||||
await db.SaveChangesAsync(ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
var nextAttempt = failed.Attempt + 1;
|
||||
var already = await db.TaskRuns.AnyAsync(
|
||||
t => t.RunId == failed.RunId && t.TaskId == failed.TaskId && t.Attempt == nextAttempt, ct);
|
||||
if (already)
|
||||
{
|
||||
failed.NextAttemptAt = null;
|
||||
await db.SaveChangesAsync(ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
await dispatcher.DispatchAsync(run, task, run.Workflow.Path, failed.InputJson, nextAttempt, ct);
|
||||
failed.NextAttemptAt = null;
|
||||
|
||||
// Commit per-retry: if the next iteration fails, this retry is
|
||||
// already dispatched and NextAttemptAt is cleared — no double-dispatch.
|
||||
await db.SaveChangesAsync(ct);
|
||||
retried++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retry TaskRun {TaskRunId} (run {RunId})", failed.Id, failed.RunId);
|
||||
// Don't clear NextAttemptAt — the next tick will retry.
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _leases.ReleaseAsync("taskrun", failed.Id.ToString(), _owner);
|
||||
}
|
||||
}
|
||||
|
||||
return retried;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fails runs that have been <c>running</c>/<c>compensating</c> for longer
|
||||
/// than <c>RunTimeoutSeconds</c> without completing. This is the safety net
|
||||
/// for runs orphaned by a worker outage, a crash mid-chain, or a job queued
|
||||
/// before a worker consumer group existed (which Redis Streams never replays).
|
||||
/// Without it such runs would show as "running" forever.
|
||||
/// </summary>
|
||||
public async Task<int> TimeoutStaleRunsAsync(WorkflowsDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (_runTimeout <= TimeSpan.Zero)
|
||||
return 0;
|
||||
|
||||
var cutoff = DateTime.UtcNow - _runTimeout;
|
||||
var stale = await db.WorkflowRuns
|
||||
.Where(r => (r.Status == RunStatus.Running || r.Status == RunStatus.Compensating)
|
||||
&& r.StartedAt != null && r.StartedAt < cutoff)
|
||||
.OrderBy(r => r.StartedAt)
|
||||
.Take(_batchSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var run in stale)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
// Mark every in-flight task run of the stale run as dead so the run
|
||||
// detail is consistent (no dangling "running" tasks under a failed run).
|
||||
var inFlight = await db.TaskRuns
|
||||
.Where(t => t.RunId == run.Id && t.Status == TaskRunStatus.Running)
|
||||
.ToListAsync(ct);
|
||||
foreach (var taskRun in inFlight)
|
||||
{
|
||||
taskRun.Status = TaskRunStatus.Dead;
|
||||
taskRun.Error = string.IsNullOrEmpty(taskRun.Error)
|
||||
? $"timed out after {_runTimeout.TotalSeconds:0}s"
|
||||
: taskRun.Error;
|
||||
taskRun.FinishedAt ??= DateTime.UtcNow;
|
||||
}
|
||||
|
||||
run.Status = RunStatus.Failed;
|
||||
run.Error = $"run timed out after {_runTimeout.TotalSeconds:0}s";
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
|
||||
_logger.LogWarning(
|
||||
"Timed out stale run {RunId} (tenant {TenantId}, started {StartedAt}) after {Seconds}s",
|
||||
run.Id, run.TenantId, run.StartedAt, _runTimeout.TotalSeconds);
|
||||
}
|
||||
|
||||
if (stale.Count > 0)
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return stale.Count;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ results
|
||||
|
||||
public async Task<int> ProcessResultsAsync(
|
||||
WorkflowsDbContext db,
|
||||
TaskDispatcher dispatcher,
|
||||
DurableStateStore store,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var tenants = await db.WorkflowRuns
|
||||
.Where(r => r.Status == RunStatus.Running || r.Status == RunStatus.Compensating)
|
||||
.Select(r => r.TenantId)
|
||||
.Distinct()
|
||||
.ToListAsync(ct);
|
||||
|
||||
var processed = 0;
|
||||
foreach (var tenant in tenants)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
return processed;
|
||||
|
||||
await _events.EnsureGroupAsync(tenant, Streams.Results, ct);
|
||||
|
||||
var messages = await _events.ReadGroupAsync(tenant, Streams.Results, _owner, _batchSize, ct);
|
||||
if (messages.Count == 0)
|
||||
messages = await _events.ClaimPendingAsync(tenant, Streams.Results, _owner, _claimIdle, _batchSize, ct);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
await ProcessResultAsync(db, dispatcher, store, tenant, message, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to process result {MessageId} for tenant {TenantId}", message.Id, tenant);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _events.AckAsync(tenant, Streams.Results, message.Id, ct);
|
||||
}
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private async Task ProcessResultAsync(
|
||||
WorkflowsDbContext db,
|
||||
TaskDispatcher dispatcher,
|
||||
DurableStateStore store,
|
||||
string tenant,
|
||||
StreamMessage message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
TaskResult result;
|
||||
try
|
||||
{
|
||||
result = TaskResult.FromFields(message.Fields);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Malformed result {MessageId}: {Error}", message.Id, ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.Equals(result.TenantId, tenant, StringComparison.Ordinal)
|
||||
|| !Guid.TryParse(result.RunId, out var runId)
|
||||
|| !Guid.TryParse(result.TaskId, out var taskId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var taskRun = await db.TaskRuns
|
||||
.FirstOrDefaultAsync(t => t.RunId == runId && t.TaskId == taskId && t.Attempt == result.Attempt, ct);
|
||||
|
||||
if (taskRun == null)
|
||||
{
|
||||
_logger.LogDebug("Ignoring orphan result for run {RunId} task {TaskId} attempt {Attempt}",
|
||||
runId, taskId, result.Attempt);
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskRun.Status is TaskRunStatus.Succeeded or TaskRunStatus.Failed
|
||||
or TaskRunStatus.Compensated or TaskRunStatus.Dead)
|
||||
{
|
||||
_logger.LogDebug("Ignoring duplicate result for taskRun {TaskRunId}", taskRun.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
var run = await db.WorkflowRuns
|
||||
.Include(r => r.Workflow).ThenInclude(w => w.Tasks)
|
||||
.FirstOrDefaultAsync(r => r.Id == runId, ct);
|
||||
|
||||
if (run == null || run.Status is not (RunStatus.Running or RunStatus.Compensating))
|
||||
{
|
||||
_logger.LogDebug("Ignoring result for non-running run {RunId} (status {Status})", runId, run?.Status);
|
||||
return;
|
||||
}
|
||||
|
||||
var task = run.Workflow.Tasks.FirstOrDefault(t => t.Id == taskId);
|
||||
if (task == null)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// A result that arrives while the run is compensating is a compensation
|
||||
// step's completion, not a normal chain advance.
|
||||
if (run.Status == RunStatus.Compensating)
|
||||
{
|
||||
await HandleCompensationResultAsync(db, dispatcher, store, run, task, taskRun, result, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
taskRun.Status = TaskRunStatus.Succeeded;
|
||||
taskRun.OutputJson = result.Output;
|
||||
taskRun.FinishedAt = now;
|
||||
await HandleTaskSucceededAsync(db, dispatcher, store, run, task, taskRun, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
taskRun.Status = TaskRunStatus.Failed;
|
||||
taskRun.Error = result.Error;
|
||||
taskRun.FinishedAt = now;
|
||||
await HandleTaskFailedAsync(db, dispatcher, store, run, task, taskRun, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task HandleTaskSucceededAsync(
|
||||
WorkflowsDbContext db,
|
||||
TaskDispatcher dispatcher,
|
||||
DurableStateStore store,
|
||||
WorkflowRun run,
|
||||
WorkflowTask task,
|
||||
TaskRun taskRun,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (task.NextId == null)
|
||||
{
|
||||
// Terminal task on the success chain — the run is complete.
|
||||
run.Status = RunStatus.Succeeded;
|
||||
run.OutputJson = taskRun.OutputJson;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
if (run.Workflow.Mode != WorkflowMode.Function)
|
||||
await store.ClearAsync(InstanceIdFor(run), ct);
|
||||
await _leases.ReleaseAsync(run.TenantId, run.Id.ToString(), _owner);
|
||||
return;
|
||||
}
|
||||
|
||||
var nextTask = run.Workflow.Tasks.FirstOrDefault(t => t.Id == task.NextId);
|
||||
if (nextTask == null)
|
||||
{
|
||||
await FailRunAsync(db, run, $"success edge target {task.NextId} is missing from the compiled workflow", ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// Durable/handler modes checkpoint before each transition so the instance
|
||||
// can resume from this exact point (next task + its input).
|
||||
if (run.Workflow.Mode != WorkflowMode.Function)
|
||||
await store.CheckpointAsync(InstanceIdFor(run), nextTask.Id, taskRun.OutputJson, run.CorrelationId, ct);
|
||||
|
||||
await dispatcher.DispatchAsync(run, nextTask, run.Workflow.Path, taskRun.OutputJson, 1, ct);
|
||||
}
|
||||
|
||||
private async Task HandleTaskFailedAsync(
|
||||
WorkflowsDbContext db,
|
||||
TaskDispatcher dispatcher,
|
||||
DurableStateStore store,
|
||||
WorkflowRun run,
|
||||
WorkflowTask task,
|
||||
TaskRun taskRun,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// 1. Retry with exponential backoff before compensating.
|
||||
if (taskRun.Attempt <= _maxRetries)
|
||||
{
|
||||
taskRun.NextAttemptAt = DateTime.UtcNow.Add(BackoffFor(taskRun.Attempt));
|
||||
_logger.LogInformation(
|
||||
"Task {TaskKey} attempt {Attempt} failed; retrying at {When} (run {RunId})",
|
||||
task.Key, taskRun.Attempt, taskRun.NextAttemptAt, run.Id);
|
||||
return; // run stays Running; RetryDueAsync re-dispatches once due.
|
||||
}
|
||||
|
||||
// 2. Retries exhausted — compensate, else dead-letter.
|
||||
var plan = await BuildCompensationPlanAsync(db, run, task, taskRun, ct);
|
||||
if (plan.Count > 0)
|
||||
{
|
||||
run.Status = RunStatus.Compensating;
|
||||
run.Error = taskRun.Error;
|
||||
run.CompensationPlanJson = SerializePlan(plan);
|
||||
await DispatchCompensationAsync(db, dispatcher, run, plan[0], ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Unhandled: dead-letter and mark the run dead.
|
||||
taskRun.Status = TaskRunStatus.Dead;
|
||||
await dispatcher.DeadLetterTaskAsync(run, task, taskRun, ct);
|
||||
run.Status = RunStatus.Dead;
|
||||
run.Error = taskRun.Error;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
if (run.Workflow.Mode != WorkflowMode.Function)
|
||||
await store.ClearAsync(InstanceIdFor(run), ct);
|
||||
await _leases.ReleaseAsync(run.TenantId, run.Id.ToString(), _owner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the ordered saga compensation plan:
|
||||
/// <list type="number">
|
||||
/// <item>The failing task's own <c>onError</c> edge (its error handler).</item>
|
||||
/// <item>Each already-succeeded task's <c>onError</c> target, walked in
|
||||
/// reverse completion order to undo side effects.</item>
|
||||
/// </list>
|
||||
/// Duplicate targets are run once (first occurrence wins).
|
||||
/// </summary>
|
||||
private async Task<List<CompensationStep>> BuildCompensationPlanAsync(
|
||||
WorkflowsDbContext db,
|
||||
WorkflowRun run,
|
||||
WorkflowTask failingTask,
|
||||
TaskRun failedRun,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var plan = new List<CompensationStep>();
|
||||
var seen = new HashSet<Guid>();
|
||||
|
||||
if (failingTask.OnErrorId is Guid ownError && seen.Add(ownError))
|
||||
plan.Add(new CompensationStep(ownError, failedRun.InputJson));
|
||||
|
||||
var succeeded = await db.TaskRuns
|
||||
.Where(t => t.RunId == run.Id && t.Status == TaskRunStatus.Succeeded && !t.IsCompensation)
|
||||
.OrderByDescending(t => t.FinishedAt)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var tr in succeeded)
|
||||
{
|
||||
var task = run.Workflow.Tasks.FirstOrDefault(t => t.Id == tr.TaskId);
|
||||
if (task?.OnErrorId is Guid target && seen.Add(target))
|
||||
plan.Add(new CompensationStep(target, tr.OutputJson));
|
||||
}
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
private async Task HandleCompensationResultAsync(
|
||||
WorkflowsDbContext db,
|
||||
TaskDispatcher dispatcher,
|
||||
DurableStateStore store,
|
||||
WorkflowRun run,
|
||||
WorkflowTask task,
|
||||
TaskRun taskRun,
|
||||
TaskResult result,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var plan = DeserializePlan(run.CompensationPlanJson);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
taskRun.Status = TaskRunStatus.Compensated;
|
||||
taskRun.OutputJson = result.Output;
|
||||
taskRun.FinishedAt = DateTime.UtcNow;
|
||||
|
||||
if (plan.Count > 0)
|
||||
plan.RemoveAt(0); // the in-flight step just completed
|
||||
|
||||
if (plan.Count > 0)
|
||||
{
|
||||
run.CompensationPlanJson = SerializePlan(plan);
|
||||
await DispatchCompensationAsync(db, dispatcher, run, plan[0], ct);
|
||||
return;
|
||||
}
|
||||
|
||||
// All compensations done — the run is compensated but still failed.
|
||||
run.CompensationPlanJson = null;
|
||||
run.Status = RunStatus.Failed;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
if (run.Workflow.Mode != WorkflowMode.Function)
|
||||
await store.ClearAsync(InstanceIdFor(run), ct);
|
||||
await _leases.ReleaseAsync(run.TenantId, run.Id.ToString(), _owner);
|
||||
return;
|
||||
}
|
||||
|
||||
// A compensation step that fails is itself dead-lettered; the run is dead.
|
||||
taskRun.Status = TaskRunStatus.Dead;
|
||||
taskRun.Error = result.Error;
|
||||
taskRun.FinishedAt = DateTime.UtcNow;
|
||||
await dispatcher.DeadLetterTaskAsync(run, task, taskRun, ct);
|
||||
|
||||
run.CompensationPlanJson = null;
|
||||
run.Status = RunStatus.Dead;
|
||||
run.Error = taskRun.Error;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
if (run.Workflow.Mode != WorkflowMode.Function)
|
||||
await store.ClearAsync(InstanceIdFor(run), ct);
|
||||
await _leases.ReleaseAsync(run.TenantId, run.Id.ToString(), _owner);
|
||||
}
|
||||
|
||||
private async Task DispatchCompensationAsync(
|
||||
WorkflowsDbContext db,
|
||||
TaskDispatcher dispatcher,
|
||||
WorkflowRun run,
|
||||
CompensationStep step,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var task = run.Workflow.Tasks.FirstOrDefault(t => t.Id == step.TaskId);
|
||||
if (task == null)
|
||||
{
|
||||
// Dangling compensation reference (should be impossible post-compile,
|
||||
// but a hand-edited DB could break it): skip straight to the next step.
|
||||
_logger.LogWarning("Compensation target {TaskId} missing from workflow {WorkflowId}", step.TaskId, run.WorkflowId);
|
||||
var plan = DeserializePlan(run.CompensationPlanJson);
|
||||
if (plan.Count > 0)
|
||||
plan.RemoveAt(0);
|
||||
run.CompensationPlanJson = plan.Count > 0 ? SerializePlan(plan) : null;
|
||||
return;
|
||||
}
|
||||
|
||||
await dispatcher.DispatchAsync(run, task, run.Workflow.Path, step.Input, 1, ct, isCompensation: true);
|
||||
}
|
||||
|
||||
private TimeSpan BackoffFor(int failedAttempt)
|
||||
{
|
||||
if (_retryBaseDelay <= TimeSpan.Zero)
|
||||
return TimeSpan.Zero;
|
||||
var ms = _retryBaseDelay.TotalMilliseconds * Math.Pow(2, failedAttempt - 1);
|
||||
return TimeSpan.FromMilliseconds(Math.Min(ms, _retryMaxDelay.TotalMilliseconds));
|
||||
}
|
||||
|
||||
private static List<CompensationStep> DeserializePlan(string? planJson)
|
||||
=> planJson == null
|
||||
? new List<CompensationStep>()
|
||||
: JsonSerializer.Deserialize<List<CompensationStep>>(planJson, PlanJson) ?? new List<CompensationStep>();
|
||||
|
||||
private static string SerializePlan(List<CompensationStep> plan)
|
||||
=> JsonSerializer.Serialize(plan, PlanJson);
|
||||
|
||||
private sealed record CompensationStep(Guid TaskId, string? Input);
|
||||
|
||||
private async Task FailRunAsync(WorkflowsDbContext db, WorkflowRun run, string error, CancellationToken ct)
|
||||
{
|
||||
run.Status = RunStatus.Failed;
|
||||
run.Error = error;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await _leases.ReleaseAsync(run.TenantId, run.Id.ToString(), _owner);
|
||||
}
|
||||
|
||||
private static string InstanceIdFor(WorkflowRun run)
|
||||
=> run.Workflow.Mode == WorkflowMode.Handler
|
||||
? $"handler:{run.WorkflowId}"
|
||||
: run.Id.ToString();
|
||||
|
||||
private static int ParseInt(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||||
|
||||
private static int ParseNonNegative(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value >= 0 ? value : fallback;
|
||||
}
|
||||
70
Services/Runs/RunLifecycleService.cs
Normal file
70
Services/Runs/RunLifecycleService.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using w4c_workflows.Data;
|
||||
|
||||
namespace w4c_workflows.Services.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts <see cref="RunLifecycleEngine"/> as a control-plane background service.
|
||||
/// Each tick it dispatches pending runs and consumes task results across every
|
||||
/// tenant with a <c>running</c> run. All state-machine logic lives in the engine
|
||||
/// so it is unit-testable without a running host.
|
||||
/// </summary>
|
||||
public class RunLifecycleService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly RunLifecycleEngine _engine;
|
||||
private readonly ILogger<RunLifecycleService> _logger;
|
||||
private readonly int _pollDelayMs;
|
||||
|
||||
public RunLifecycleService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
RunLifecycleEngine engine,
|
||||
IConfiguration config,
|
||||
ILogger<RunLifecycleService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_engine = engine;
|
||||
_logger = logger;
|
||||
_pollDelayMs = ParseInt(config["Workflows:LifecyclePollDelayMs"], 500);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("RunLifecycleService started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var processed = 0;
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<WorkflowsDbContext>();
|
||||
var dispatcher = scope.ServiceProvider.GetRequiredService<TaskDispatcher>();
|
||||
var store = scope.ServiceProvider.GetRequiredService<DurableStateStore>();
|
||||
|
||||
processed += await _engine.RetryDueAsync(db, dispatcher, stoppingToken);
|
||||
processed += await _engine.DispatchPendingAsync(db, dispatcher, stoppingToken);
|
||||
processed += await _engine.ProcessResultsAsync(db, dispatcher, store, stoppingToken);
|
||||
processed += await _engine.TimeoutStaleRunsAsync(db, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Run lifecycle loop error");
|
||||
}
|
||||
|
||||
if (processed == 0 && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(_pollDelayMs, stoppingToken); }
|
||||
catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("RunLifecycleService stopped");
|
||||
}
|
||||
|
||||
private static int ParseInt(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||||
}
|
||||
133
Services/Runs/TaskDispatcher.cs
Normal file
133
Services/Runs/TaskDispatcher.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using w4c_workflows.Services.Messaging;
|
||||
|
||||
namespace w4c_workflows.Services.Runs;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the <see cref="TaskRun"/> row for an attempt and enqueues the
|
||||
/// corresponding <c>task.run</c> job on the tenant's stream. The row is
|
||||
/// persisted <em>before</em> the job is enqueued so a fast worker result can
|
||||
/// never race a missing TaskRun. The working directory is derived from the
|
||||
/// workflow's path under the worker's code root (a shared checkout volume in
|
||||
/// deployment).
|
||||
/// </summary>
|
||||
public class TaskDispatcher
|
||||
{
|
||||
private readonly WorkflowsDbContext _db;
|
||||
private readonly IJobQueue _jobs;
|
||||
private readonly string _workerRoot;
|
||||
private readonly ILogger<TaskDispatcher> _logger;
|
||||
|
||||
public TaskDispatcher(
|
||||
WorkflowsDbContext db,
|
||||
IJobQueue jobs,
|
||||
IConfiguration config,
|
||||
ILogger<TaskDispatcher> logger)
|
||||
{
|
||||
_db = db;
|
||||
_jobs = jobs;
|
||||
_logger = logger;
|
||||
_workerRoot = ResolveWorkerRoot(config);
|
||||
}
|
||||
|
||||
public async Task<Guid> DispatchAsync(
|
||||
WorkflowRun run,
|
||||
WorkflowTask task,
|
||||
string workflowPath,
|
||||
string? input,
|
||||
int attempt,
|
||||
CancellationToken ct,
|
||||
bool isCompensation = false)
|
||||
{
|
||||
var taskRun = new TaskRun
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RunId = run.Id,
|
||||
TaskId = task.Id,
|
||||
Attempt = attempt,
|
||||
Status = TaskRunStatus.Running,
|
||||
InputJson = input,
|
||||
RetryCount = attempt - 1,
|
||||
IsCompensation = isCompensation,
|
||||
StartedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.TaskRuns.Add(taskRun);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var fields = TaskRunMessage.ToFields(run, task, input, ResolveWorkingDir(workflowPath), attempt);
|
||||
await _jobs.EnqueueAsync(run.TenantId, fields, ct);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Dispatched task {TaskKey} (run {RunId}, attempt {Attempt}, compensation {IsCompensation}) for tenant {TenantId}",
|
||||
task.Key, run.Id, attempt, isCompensation, run.TenantId);
|
||||
|
||||
return taskRun.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dead-letters a failed task (retries exhausted, or a failed compensation)
|
||||
/// by reconstructing its <c>task.run</c> fields and publishing them to the
|
||||
/// tenant DLQ. The control-plane failure path has no live jobs-stream entry
|
||||
/// left to acknowledge, so this uses a publish (not copy+ack) write.
|
||||
/// </summary>
|
||||
public async Task DeadLetterTaskAsync(
|
||||
WorkflowRun run, WorkflowTask task, TaskRun taskRun, CancellationToken ct)
|
||||
{
|
||||
var fields = TaskRunMessage.ToFields(
|
||||
run, task, taskRun.InputJson, ResolveWorkingDir(run.Workflow.Path), taskRun.Attempt);
|
||||
|
||||
var dlqFields = new Dictionary<string, string>(fields)
|
||||
{
|
||||
["dlq.error"] = taskRun.Error ?? string.Empty,
|
||||
};
|
||||
|
||||
await _jobs.PublishDeadLetterAsync(run.TenantId, dlqFields, "max_retries_exceeded", ct);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Dead-lettered task {TaskKey} (run {RunId}, attempt {Attempt})",
|
||||
task.Key, run.Id, taskRun.Attempt);
|
||||
}
|
||||
|
||||
public string ResolveWorkingDir(string workflowPath)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(workflowPath) ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(dir))
|
||||
return _workerRoot;
|
||||
return Path.Combine(_workerRoot, dir.Replace('/', Path.DirectorySeparatorChar));
|
||||
}
|
||||
|
||||
private static string ResolveWorkerRoot(IConfiguration config)
|
||||
{
|
||||
// Explicit override wins: a dedicated worker mount (e.g. a shared checkout
|
||||
// volume in deployment) or an explicit repo root.
|
||||
var workerRoot = config["Workflows:WorkerRoot"];
|
||||
if (!string.IsNullOrWhiteSpace(workerRoot))
|
||||
return Path.GetFullPath(workerRoot);
|
||||
|
||||
var repoRoot = config["SourceCode:RepoRoot"];
|
||||
if (!string.IsNullOrWhiteSpace(repoRoot))
|
||||
return Path.GetFullPath(repoRoot);
|
||||
|
||||
// Dev fallback: resolve the monorepo/git root the same way the control
|
||||
// plane's WorkflowSource does, so the worker and the source reader agree
|
||||
// on where workflow code files live. Without this, a worker started from
|
||||
// the service directory would resolve files under <cwd>/workflows/...,
|
||||
// which is wrong (the code files sit at the repo root).
|
||||
var start = Directory.GetCurrentDirectory();
|
||||
var walk = Path.GetFullPath(start);
|
||||
while (true)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(walk, ".git")))
|
||||
return Path.GetFullPath(walk);
|
||||
var parent = Directory.GetParent(walk)?.FullName;
|
||||
if (string.IsNullOrEmpty(parent) || parent == walk)
|
||||
break;
|
||||
walk = parent;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(start);
|
||||
}
|
||||
}
|
||||
188
Services/Triggers/CronExpression.cs
Normal file
188
Services/Triggers/CronExpression.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using System.Globalization;
|
||||
|
||||
namespace w4c_workflows.Services.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal 5-field cron parser (minute hour day-of-month month day-of-week)
|
||||
/// supporting <c>*</c>, <c>,</c> (list), <c>-</c> (range) and <c>/</c> (step), plus
|
||||
/// numeric values. Enough for workflow scheduling (<c>"0 */6 * * *"</c>,
|
||||
/// <c>"0 9 * * 1-5"</c>); no seconds/year fields.
|
||||
///
|
||||
/// Day-of-week 0 and 7 both mean Sunday. Day matching uses AND semantics across
|
||||
/// day-of-month and day-of-week, so <c>"0 9 * * 1-5"</c> runs Mon–Fri and
|
||||
/// <c>"0 0 1 * *"</c> runs on the 1st of the month (the <c>*</c> field matches all
|
||||
/// values and therefore never filters).
|
||||
/// </summary>
|
||||
public sealed class CronExpression
|
||||
{
|
||||
private readonly CronField _minute;
|
||||
private readonly CronField _hour;
|
||||
private readonly CronField _dayOfMonth;
|
||||
private readonly CronField _month;
|
||||
private readonly CronField _dayOfWeek;
|
||||
|
||||
private CronExpression(CronField minute, CronField hour, CronField dayOfMonth, CronField month, CronField dayOfWeek)
|
||||
{
|
||||
_minute = minute;
|
||||
_hour = hour;
|
||||
_dayOfMonth = dayOfMonth;
|
||||
_month = month;
|
||||
_dayOfWeek = dayOfWeek;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a 5-field cron expression. On success <paramref name="parsed"/> is
|
||||
/// set and <paramref name="error"/> is null; otherwise the reverse.
|
||||
/// </summary>
|
||||
public static bool TryParse(string? expression, out CronExpression? parsed, out string? error)
|
||||
{
|
||||
parsed = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
error = "cron expression is empty";
|
||||
return false;
|
||||
}
|
||||
|
||||
var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (fields.Length != 5)
|
||||
{
|
||||
error = $"cron expression must have 5 fields, got {fields.Length}";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!CronField.TryParse(fields[0], 0, 59, out var minute, out error)) return false;
|
||||
if (!CronField.TryParse(fields[1], 0, 23, out var hour, out error)) return false;
|
||||
if (!CronField.TryParse(fields[2], 1, 31, out var dayOfMonth, out error)) return false;
|
||||
if (!CronField.TryParse(fields[3], 1, 12, out var month, out error)) return false;
|
||||
// 0–7: 7 is the Sunday alias and is folded into 0 below.
|
||||
if (!CronField.TryParse(fields[4], 0, 7, out var dayOfWeek, out error)) return false;
|
||||
dayOfWeek!.AliasValue(7, 0);
|
||||
|
||||
parsed = new CronExpression(minute!, hour!, dayOfMonth!, month!, dayOfWeek);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Whether the given UTC instant matches this cron expression.</summary>
|
||||
public bool Matches(DateTime utc) =>
|
||||
_minute.Matches(utc.Minute)
|
||||
&& _hour.Matches(utc.Hour)
|
||||
&& _dayOfMonth.Matches(utc.Day)
|
||||
&& _month.Matches(utc.Month)
|
||||
&& _dayOfWeek.Matches((int)utc.DayOfWeek);
|
||||
|
||||
/// <summary>
|
||||
/// The first occurrence strictly after <paramref name="after"/>, truncated to
|
||||
/// the minute and evaluated in UTC. Returns null when no occurrence exists
|
||||
/// within a bounded 5-year horizon (a guard against malformed expressions
|
||||
/// such as a 31st-of-the-month-only schedule that never matches).
|
||||
/// </summary>
|
||||
public DateTimeOffset? GetNextOccurrence(DateTimeOffset after)
|
||||
{
|
||||
var utc = after.UtcDateTime;
|
||||
var cursor = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, utc.Minute, 0, DateTimeKind.Utc)
|
||||
.AddMinutes(1);
|
||||
var horizon = cursor.AddYears(5);
|
||||
|
||||
while (cursor <= horizon)
|
||||
{
|
||||
if (Matches(cursor))
|
||||
return new DateTimeOffset(cursor, TimeSpan.Zero);
|
||||
cursor = cursor.AddMinutes(1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>One field of a cron expression, as a fixed-size allowed-value set.</summary>
|
||||
private sealed class CronField
|
||||
{
|
||||
private readonly int _min;
|
||||
private readonly bool[] _allowed;
|
||||
|
||||
private CronField(int min, bool[] allowed)
|
||||
{
|
||||
_min = min;
|
||||
_allowed = allowed;
|
||||
}
|
||||
|
||||
public bool Matches(int value) => value >= _min && value - _min < _allowed.Length && _allowed[value - _min];
|
||||
|
||||
/// <summary>Folds one value into another (Sunday alias 7 → 0).</summary>
|
||||
public void AliasValue(int from, int to)
|
||||
{
|
||||
if (from >= _min && from - _min < _allowed.Length)
|
||||
_allowed[to - _min] |= _allowed[from - _min];
|
||||
}
|
||||
|
||||
public static bool TryParse(string text, int min, int max, out CronField? field, out string? error)
|
||||
{
|
||||
field = null;
|
||||
error = null;
|
||||
|
||||
var allowed = new bool[max - min + 1];
|
||||
foreach (var part in text.Split(','))
|
||||
{
|
||||
if (part.Length == 0)
|
||||
{
|
||||
error = $"empty cron field segment in '{text}'";
|
||||
return false;
|
||||
}
|
||||
|
||||
var step = 1;
|
||||
var range = part;
|
||||
if (part.Contains('/'))
|
||||
{
|
||||
var pieces = part.Split('/');
|
||||
if (pieces.Length != 2 || !TryParseNumber(pieces[1], out step) || step <= 0)
|
||||
{
|
||||
error = $"invalid step in cron field segment '{part}'";
|
||||
return false;
|
||||
}
|
||||
range = pieces[0];
|
||||
}
|
||||
|
||||
int lo, hi;
|
||||
if (range == "*")
|
||||
{
|
||||
lo = min;
|
||||
hi = max;
|
||||
}
|
||||
else if (range.Contains('-'))
|
||||
{
|
||||
var pieces = range.Split('-');
|
||||
if (pieces.Length != 2 || !TryParseNumber(pieces[0], out lo) || !TryParseNumber(pieces[1], out hi))
|
||||
{
|
||||
error = $"invalid range in cron field segment '{part}'";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (TryParseNumber(range, out lo))
|
||||
{
|
||||
hi = lo;
|
||||
}
|
||||
else
|
||||
{
|
||||
error = $"invalid value '{range}' in cron field";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (lo < min || hi > max || lo > hi)
|
||||
{
|
||||
error = $"value out of range [{min},{max}] in cron field segment '{part}'";
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var value = lo; value <= hi; value += step)
|
||||
allowed[value - min] = true;
|
||||
}
|
||||
|
||||
field = new CronField(min, allowed);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryParseNumber(string text, out int value)
|
||||
=> int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value);
|
||||
}
|
||||
}
|
||||
129
Services/Triggers/HandlerStreamConsumer.cs
Normal file
129
Services/Triggers/HandlerStreamConsumer.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Messaging;
|
||||
using w4c_workflows.Services.Runs;
|
||||
|
||||
namespace w4c_workflows.Services.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Queue-trigger consumer for <c>handler</c>-mode workflows. A handler workflow
|
||||
/// subscribes to a stream (<c>trigger.stream</c>, default <c>wf:{tenant}:events</c>)
|
||||
/// and, on each event, starts a run whose input is the event payload.
|
||||
///
|
||||
/// v1 consumes each event as its own run; the long-lived-instance semantics
|
||||
/// (one durable instance, state accumulation across events, checkpoint/resume)
|
||||
/// are layered on by the run lifecycle engine in step 9, which reuses the same
|
||||
/// correlation id scheme (<c>handler:{workflowId}:{messageId}</c>).
|
||||
/// </summary>
|
||||
public class HandlerStreamConsumer : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IEventBus _events;
|
||||
private readonly ILogger<HandlerStreamConsumer> _logger;
|
||||
|
||||
private readonly string _consumer;
|
||||
private readonly int _pollDelayMs;
|
||||
private readonly int _batchSize;
|
||||
private readonly TimeSpan _claimIdle;
|
||||
|
||||
public HandlerStreamConsumer(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IEventBus events,
|
||||
IConfiguration config,
|
||||
ILogger<HandlerStreamConsumer> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_events = events;
|
||||
_logger = logger;
|
||||
|
||||
_consumer = $"handler-{Environment.MachineName}-{Guid.NewGuid():N}"[..28];
|
||||
_pollDelayMs = ParseInt(config["Workflows:HandlerPollDelayMs"], 500);
|
||||
_batchSize = ParseInt(config["Workflows:HandlerBatchSize"], 10);
|
||||
_claimIdle = TimeSpan.FromSeconds(ParseInt(config["Workflows:HandlerClaimIdleSeconds"], 30));
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("HandlerStreamConsumer started as consumer {Consumer}", _consumer);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var processed = await ProcessAsync(stoppingToken);
|
||||
if (processed == 0)
|
||||
await Task.Delay(_pollDelayMs, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Handler stream consumer loop error");
|
||||
try { await Task.Delay(_pollDelayMs, stoppingToken); } catch (OperationCanceledException) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("HandlerStreamConsumer stopped");
|
||||
}
|
||||
|
||||
private async Task<int> ProcessAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<WorkflowsDbContext>();
|
||||
var launcher = scope.ServiceProvider.GetRequiredService<IRunLauncher>();
|
||||
|
||||
var handlers = await db.Workflows
|
||||
.Where(w => w.Status == WorkflowStatus.Compiled && w.Mode == WorkflowMode.Handler)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var processed = 0;
|
||||
foreach (var workflow in handlers)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
return processed;
|
||||
if (!workflow.TriggerEnabled)
|
||||
continue; // auto-trigger toggled off from the UI
|
||||
|
||||
var spec = TriggerSpec.Parse(workflow.TriggerJson, out _);
|
||||
if (spec == null)
|
||||
continue;
|
||||
|
||||
var stream = string.IsNullOrWhiteSpace(spec.Stream) ? Streams.DefaultEvents : spec.Stream;
|
||||
await _events.EnsureGroupAsync(workflow.TenantId, stream, ct);
|
||||
|
||||
var messages = await _events.ReadGroupAsync(workflow.TenantId, stream, _consumer, _batchSize, ct);
|
||||
if (messages.Count == 0)
|
||||
messages = await _events.ClaimPendingAsync(workflow.TenantId, stream, _consumer, _claimIdle, _batchSize, ct);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await DispatchAsync(launcher, workflow, stream, message, ct);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
private async Task DispatchAsync(IRunLauncher launcher, Workflow workflow, string stream, StreamMessage message, CancellationToken ct)
|
||||
{
|
||||
var input = SerializeEventInput(message.Fields);
|
||||
var correlation = $"handler:{workflow.Id}:{message.Id}";
|
||||
|
||||
await launcher.LaunchAsync(
|
||||
new LaunchRequest(workflow.TenantId, workflow.Id, workflow.TriggerJson, input, correlation), ct);
|
||||
await _events.AckAsync(workflow.TenantId, stream, message.Id, ct);
|
||||
}
|
||||
|
||||
/// <summary>Serializes a stream message's fields as a JSON object (the event payload).</summary>
|
||||
internal static string SerializeEventInput(IReadOnlyDictionary<string, string> fields)
|
||||
=> JsonSerializer.Serialize(fields, JsonSerializerOptions.Web);
|
||||
|
||||
private static int ParseInt(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||||
}
|
||||
50
Services/Triggers/ITriggerState.cs
Normal file
50
Services/Triggers/ITriggerState.cs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
using System.Globalization;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace w4c_workflows.Services.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Stores the last time a scheduled trigger (cron/interval) fired, per
|
||||
/// workflow. Redis-backed in v1 (key <c>wf:{tenant}:triggers:{workflowId}:lastFire</c>);
|
||||
/// ephemeral by design — losing it only re-fires a workflow once, which is safe
|
||||
/// because delivery is at-least-once and the run lifecycle dedups via
|
||||
/// correlation id.
|
||||
/// </summary>
|
||||
public interface ITriggerState
|
||||
{
|
||||
Task<DateTimeOffset?> GetLastFireAsync(string tenantId, Guid workflowId, CancellationToken ct);
|
||||
Task SetLastFireAsync(string tenantId, Guid workflowId, DateTimeOffset at, CancellationToken ct);
|
||||
}
|
||||
|
||||
public class RedisTriggerState : ITriggerState
|
||||
{
|
||||
private readonly IDatabase _db;
|
||||
private readonly TimeSpan _ttl;
|
||||
|
||||
public RedisTriggerState(IConnectionMultiplexer redis, IConfiguration config)
|
||||
{
|
||||
_db = redis.GetDatabase();
|
||||
_ttl = TimeSpan.FromDays(ParseInt(config["Workflows:TriggerStateTtlDays"], 90));
|
||||
}
|
||||
|
||||
public async Task<DateTimeOffset?> GetLastFireAsync(string tenantId, Guid workflowId, CancellationToken ct)
|
||||
{
|
||||
var value = await _db.StringGetAsync(KeyFor(tenantId, workflowId));
|
||||
if (value.IsNullOrEmpty)
|
||||
return null;
|
||||
|
||||
return DateTimeOffset.TryParse(value.ToString(), CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.RoundtripKind, out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
public Task SetLastFireAsync(string tenantId, Guid workflowId, DateTimeOffset at, CancellationToken ct)
|
||||
=> _db.StringSetAsync(KeyFor(tenantId, workflowId), at.ToString("O"), _ttl);
|
||||
|
||||
private static RedisKey KeyFor(string tenantId, Guid workflowId)
|
||||
=> $"wf:{tenantId}:triggers:{workflowId}:lastFire";
|
||||
|
||||
private static int ParseInt(string? text, int fallback)
|
||||
=> int.TryParse(text, out var value) && value > 0 ? value : fallback;
|
||||
}
|
||||
36
Services/Triggers/TriggerDue.cs
Normal file
36
Services/Triggers/TriggerDue.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Pure due-check for scheduled triggers. Extracted from the scheduler so the
|
||||
/// cron/interval timing rules are unit-testable without Redis/Postgres.
|
||||
/// </summary>
|
||||
public static class TriggerDue
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether a cron/interval trigger should fire now. A workflow with no
|
||||
/// recorded last fire fires once on first discovery (the scheduler's initial
|
||||
/// pass), then again when its schedule next elapses.
|
||||
/// </summary>
|
||||
public static bool IsDue(TriggerSpec spec, DateTimeOffset? lastFire, DateTimeOffset now)
|
||||
{
|
||||
if (lastFire == null)
|
||||
return true;
|
||||
|
||||
if (spec.Type == TriggerType.Interval)
|
||||
{
|
||||
var interval = spec.Interval ?? TimeSpan.Zero;
|
||||
return interval > TimeSpan.Zero && lastFire.Value + interval <= now;
|
||||
}
|
||||
|
||||
if (spec.Type == TriggerType.Cron
|
||||
&& CronExpression.TryParse(spec.Cron, out var cron, out _))
|
||||
{
|
||||
var next = cron!.GetNextOccurrence(lastFire.Value);
|
||||
return next.HasValue && next.Value <= now;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
119
Services/Triggers/TriggerScheduler.cs
Normal file
119
Services/Triggers/TriggerScheduler.cs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Runs;
|
||||
|
||||
namespace w4c_workflows.Services.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Scheduled-trigger engine (cron + interval), modeled on w4c-webapi's
|
||||
/// <see cref="AlertScheduler"/>. On each tick it loads every tenant's compiled
|
||||
/// workflows that declare a <c>cron</c> or <c>interval</c> trigger, fires the
|
||||
/// ones whose schedule has elapsed since the last fire, and records the fire
|
||||
/// time for dedup. An error in one workflow never breaks the cycle for others.
|
||||
/// </summary>
|
||||
public class TriggerScheduler : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ITriggerState _state;
|
||||
private readonly TimeProvider _time;
|
||||
private readonly ILogger<TriggerScheduler> _logger;
|
||||
private readonly int _pollSeconds;
|
||||
|
||||
public TriggerScheduler(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ITriggerState state,
|
||||
TimeProvider time,
|
||||
IConfiguration config,
|
||||
ILogger<TriggerScheduler> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_state = state;
|
||||
_time = time;
|
||||
_logger = logger;
|
||||
_pollSeconds = config.GetValue("Workflows:SchedulerPollSeconds", 30);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("TriggerScheduler started (poll {Interval}s)", _pollSeconds);
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_pollSeconds));
|
||||
|
||||
try
|
||||
{
|
||||
// First pass after a short warm-up so the app can serve initial requests.
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
await EvaluateAllAsync(stoppingToken);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
await EvaluateAllAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
// Graceful shutdown — the host is stopping.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A transient env/DB error must never take the whole control plane
|
||||
// down (HostOptions.BackgroundServiceExceptionBehavior defaults to
|
||||
// StopHost). Log and keep the scheduler alive for the next tick.
|
||||
_logger.LogError(ex, "TriggerScheduler loop failed; will retry on next tick");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EvaluateAllAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<WorkflowsDbContext>();
|
||||
var launcher = scope.ServiceProvider.GetRequiredService<IRunLauncher>();
|
||||
var now = _time.GetUtcNow();
|
||||
|
||||
var workflows = await db.Workflows
|
||||
.Where(w => w.Status == WorkflowStatus.Compiled && w.TriggerJson != null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var workflow in workflows)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
return;
|
||||
if (!workflow.TriggerEnabled)
|
||||
continue; // auto-trigger toggled off from the UI; manual runs still work
|
||||
await EvaluateWorkflowAsync(workflow, launcher, now, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EvaluateWorkflowAsync(Workflow workflow, IRunLauncher launcher, DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
var spec = TriggerSpec.Parse(workflow.TriggerJson, out var error);
|
||||
if (spec == null)
|
||||
{
|
||||
_logger.LogWarning("Skipping unparseable trigger for workflow {WorkflowId}: {Error}", workflow.Id, error);
|
||||
return;
|
||||
}
|
||||
if (spec.Type != TriggerType.Cron && spec.Type != TriggerType.Interval)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var lastFire = await _state.GetLastFireAsync(workflow.TenantId, workflow.Id, ct);
|
||||
if (!TriggerDue.IsDue(spec, lastFire, now))
|
||||
return;
|
||||
|
||||
var correlation = $"trigger:{workflow.Id}:{now.ToUnixTimeSeconds()}";
|
||||
await launcher.LaunchAsync(
|
||||
new LaunchRequest(workflow.TenantId, workflow.Id, workflow.TriggerJson, null, correlation), ct);
|
||||
await _state.SetLastFireAsync(workflow.TenantId, workflow.Id, now, ct);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Trigger {Type} fired for workflow {WorkflowId} (tenant {TenantId})",
|
||||
spec.Type, workflow.Id, workflow.TenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Trigger evaluation failed for workflow {WorkflowId} (tenant {TenantId})",
|
||||
workflow.Id, workflow.TenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
66
Services/Triggers/TriggerSpec.cs
Normal file
66
Services/Triggers/TriggerSpec.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services.Triggers;
|
||||
|
||||
/// <summary>
|
||||
/// Typed trigger model parsed from <c>Workflow.TriggerJson</c>. The compiler
|
||||
/// stores the trigger exactly as it appeared in YAML (camelCase, nulls omitted);
|
||||
/// this record normalizes it for the trigger engine: the interval string becomes
|
||||
/// a parsed <see cref="TimeSpan"/> and the type is guaranteed non-null.
|
||||
/// </summary>
|
||||
public sealed record TriggerSpec(
|
||||
string Type,
|
||||
string? Cron,
|
||||
TimeSpan? Interval,
|
||||
string? WebhookPath,
|
||||
string? Stream)
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Parses stored trigger JSON. Returns null (with a non-null
|
||||
/// <paramref name="error"/>) when the JSON is absent, malformed, or the
|
||||
/// trigger lacks a valid type/interval.
|
||||
/// </summary>
|
||||
public static TriggerSpec? Parse(string? json, out string? error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
TriggerDefinition def;
|
||||
try
|
||||
{
|
||||
def = JsonSerializer.Deserialize<TriggerDefinition>(json, Json) ?? new TriggerDefinition();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(def.Type))
|
||||
{
|
||||
error = "trigger.type is missing";
|
||||
return null;
|
||||
}
|
||||
|
||||
TimeSpan? interval = null;
|
||||
if (!string.IsNullOrWhiteSpace(def.Interval))
|
||||
{
|
||||
if (!DurationParser.TryParse(def.Interval, out var parsed))
|
||||
{
|
||||
error = $"trigger.interval '{def.Interval}' is not a valid duration";
|
||||
return null;
|
||||
}
|
||||
interval = parsed;
|
||||
}
|
||||
|
||||
return new TriggerSpec(def.Type, def.Cron, interval, def.WebhookPath, def.Stream);
|
||||
}
|
||||
}
|
||||
156
Services/WorkflowCompiler.cs
Normal file
156
Services/WorkflowCompiler.cs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using w4c_workflows.Models;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>A successfully compiled workflow + its task tree (EF entities, detached).</summary>
|
||||
public sealed record CompiledWorkflow(Workflow Workflow, IReadOnlyList<WorkflowTask> Tasks);
|
||||
|
||||
public sealed class CompileResult
|
||||
{
|
||||
public List<string> Errors { get; } = new();
|
||||
public bool Success => Errors.Count == 0;
|
||||
public CompiledWorkflow? Workflow { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses and compiles a workflow YAML definition into the EF entity model.
|
||||
/// Ids are deterministic (tenant + path + task id) so re-syncing the same file
|
||||
/// upserts instead of duplicating. The compiler performs no DB writes — the
|
||||
/// sync service persists the result.
|
||||
/// </summary>
|
||||
public class WorkflowCompiler
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly WorkflowValidator _validator;
|
||||
private readonly IDeserializer _yaml;
|
||||
|
||||
public WorkflowCompiler(WorkflowValidator validator)
|
||||
{
|
||||
_validator = validator;
|
||||
_yaml = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties()
|
||||
.Build();
|
||||
}
|
||||
|
||||
public CompileResult Compile(string yaml, string path, string tenantId)
|
||||
{
|
||||
var result = new CompileResult();
|
||||
|
||||
WorkflowDefinition def;
|
||||
try
|
||||
{
|
||||
def = _yaml.Deserialize<WorkflowDefinition>(yaml) ?? new WorkflowDefinition();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"YAML parse error: {ex.Message}");
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Errors.AddRange(_validator.Validate(def));
|
||||
if (!result.Success)
|
||||
return result;
|
||||
|
||||
result.Workflow = Build(def, path, tenantId);
|
||||
return result;
|
||||
}
|
||||
|
||||
private CompiledWorkflow Build(WorkflowDefinition def, string path, string tenantId)
|
||||
{
|
||||
var graph = WorkflowGraph.Compute(def);
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var workflowId = DeterministicGuid.For($"wf::{tenantId}::{path}");
|
||||
var workflow = new Workflow
|
||||
{
|
||||
Id = workflowId,
|
||||
TenantId = tenantId,
|
||||
Name = def.Name!,
|
||||
Path = path,
|
||||
Status = WorkflowStatus.Compiled,
|
||||
Mode = def.Mode!,
|
||||
Version = string.IsNullOrWhiteSpace(def.Version) ? "1.0.0" : def.Version!,
|
||||
Target = string.IsNullOrWhiteSpace(def.Target) ? "default" : def.Target,
|
||||
TriggerJson = def.Trigger == null ? null : JsonSerializer.Serialize(def.Trigger, Json),
|
||||
CompiledAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
|
||||
var tasks = new List<WorkflowTask>();
|
||||
|
||||
// Root task = the workflow entry (language/mode/entry/env from the header).
|
||||
var rootId = DeterministicGuid.For($"task::{tenantId}::{path}::root");
|
||||
var headId = graph.HeadId == null
|
||||
? (Guid?)null
|
||||
: DeterministicGuid.For($"task::{tenantId}::{path}::{graph.HeadId}");
|
||||
tasks.Add(new WorkflowTask
|
||||
{
|
||||
Id = rootId,
|
||||
WorkflowId = workflowId,
|
||||
Key = "root",
|
||||
ParentId = null,
|
||||
NextId = headId,
|
||||
OnErrorId = null,
|
||||
Language = def.Language!,
|
||||
Mode = def.Mode!,
|
||||
EntryJson = JsonSerializer.Serialize(new EntryDefinition
|
||||
{
|
||||
File = def.Entry!.File,
|
||||
Function = def.Entry.Function,
|
||||
}, Json),
|
||||
EnvJson = SerializeEnv(def.Env),
|
||||
Server = NormalizeServer(def.Server),
|
||||
Order = 0,
|
||||
});
|
||||
|
||||
for (var i = 0; i < graph.Tasks.Count; i++)
|
||||
{
|
||||
var task = graph.Tasks[i];
|
||||
var taskId = DeterministicGuid.For($"task::{tenantId}::{path}::{task.Id}");
|
||||
|
||||
tasks.Add(new WorkflowTask
|
||||
{
|
||||
Id = taskId,
|
||||
WorkflowId = workflowId,
|
||||
Key = task.Id!,
|
||||
ParentId = string.IsNullOrEmpty(task.Parent) || task.Parent == "root"
|
||||
? rootId
|
||||
: DeterministicGuid.For($"task::{tenantId}::{path}::{task.Parent}"),
|
||||
NextId = string.IsNullOrEmpty(task.Next)
|
||||
? null
|
||||
: DeterministicGuid.For($"task::{tenantId}::{path}::{task.Next}"),
|
||||
OnErrorId = string.IsNullOrEmpty(task.OnError)
|
||||
? null
|
||||
: DeterministicGuid.For($"task::{tenantId}::{path}::{task.OnError}"),
|
||||
Language = task.Language ?? def.Language!,
|
||||
Mode = task.Mode ?? def.Mode!,
|
||||
EntryJson = JsonSerializer.Serialize(new EntryDefinition
|
||||
{
|
||||
File = task.Entry!.File,
|
||||
Function = task.Entry.Function,
|
||||
}, Json),
|
||||
EnvJson = SerializeEnv(task.Env),
|
||||
Server = NormalizeServer(task.Server ?? def.Server),
|
||||
Order = i + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return new CompiledWorkflow(workflow, tasks);
|
||||
}
|
||||
|
||||
private static string? NormalizeServer(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? SerializeEnv(Dictionary<string, string>? env)
|
||||
=> env == null || env.Count == 0 ? null : JsonSerializer.Serialize(env, Json);
|
||||
}
|
||||
88
Services/WorkflowGraph.cs
Normal file
88
Services/WorkflowGraph.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Precomputes the execution graph from a parsed YAML definition so validation
|
||||
/// and compilation share one deterministic interpretation:
|
||||
/// - <see cref="HeadId"/> is the entry of the success chain (the top-level
|
||||
/// task with no incoming `next` that is not purely a compensation target).
|
||||
/// - <see cref="Chain"/> is the linear happy path root → head → … → terminal.
|
||||
/// - <see cref="CompensationOnly"/> marks tasks that are only ever reached as
|
||||
/// an `onError` target (they have no `next` of their own).
|
||||
/// </summary>
|
||||
internal sealed class WorkflowGraph
|
||||
{
|
||||
public required IReadOnlyList<TaskDefinition> Tasks { get; init; }
|
||||
public required IReadOnlyDictionary<string, TaskDefinition> ById { get; init; }
|
||||
public required IReadOnlyDictionary<string, int> IncomingNext { get; init; }
|
||||
public required IReadOnlySet<string> OnErrorTargets { get; init; }
|
||||
public required IReadOnlySet<string> CompensationOnly { get; init; }
|
||||
public string? HeadId { get; init; }
|
||||
public required IReadOnlyList<string> Chain { get; init; }
|
||||
|
||||
public static WorkflowGraph Compute(WorkflowDefinition def)
|
||||
{
|
||||
var tasks = def.Tasks ?? new List<TaskDefinition>();
|
||||
var byId = new Dictionary<string, TaskDefinition>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var task in tasks)
|
||||
if (!string.IsNullOrEmpty(task.Id))
|
||||
byId[task.Id] = task;
|
||||
|
||||
var incomingNext = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var onErrorTargets = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(task.Next) && byId.ContainsKey(task.Next))
|
||||
incomingNext[task.Next] = incomingNext.GetValueOrDefault(task.Next) + 1;
|
||||
if (!string.IsNullOrEmpty(task.OnError) && byId.ContainsKey(task.OnError))
|
||||
onErrorTargets.Add(task.OnError);
|
||||
}
|
||||
|
||||
// A compensation-only task is an onError target that does not continue
|
||||
// the success chain (no `next`). It lives outside the happy path.
|
||||
var compensationOnly = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.Id == null) continue;
|
||||
if (onErrorTargets.Contains(task.Id) && string.IsNullOrEmpty(task.Next))
|
||||
compensationOnly.Add(task.Id);
|
||||
}
|
||||
|
||||
// Head: top-level (parent == root), no incoming `next`, not compensation-only.
|
||||
string? headId = null;
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.Id == null) continue;
|
||||
var parent = string.IsNullOrEmpty(task.Parent) ? "root" : task.Parent;
|
||||
if (!string.Equals(parent, "root", StringComparison.Ordinal)) continue;
|
||||
if (incomingNext.GetValueOrDefault(task.Id) > 0) continue;
|
||||
if (compensationOnly.Contains(task.Id)) continue;
|
||||
headId = task.Id;
|
||||
break;
|
||||
}
|
||||
|
||||
// Walk the success chain (each node has at most one `next` after validation).
|
||||
var chain = new List<string>();
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var cursor = headId;
|
||||
while (cursor != null && visited.Add(cursor) && byId.TryGetValue(cursor, out var task))
|
||||
{
|
||||
chain.Add(cursor);
|
||||
cursor = string.IsNullOrEmpty(task.Next) ? null : task.Next;
|
||||
}
|
||||
|
||||
return new WorkflowGraph
|
||||
{
|
||||
Tasks = tasks,
|
||||
ById = byId,
|
||||
IncomingNext = incomingNext,
|
||||
OnErrorTargets = onErrorTargets,
|
||||
CompensationOnly = compensationOnly,
|
||||
HeadId = headId,
|
||||
Chain = chain,
|
||||
};
|
||||
}
|
||||
}
|
||||
358
Services/WorkflowHtmlRenderer.cs
Normal file
358
Services/WorkflowHtmlRenderer.cs
Normal file
|
|
@ -0,0 +1,358 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a compiled workflow into a self-contained, non-Mermaid HTML document
|
||||
/// that any client (the SPA, a standalone page, an email, a README) can embed in
|
||||
/// an <c><iframe></c> — the backend owns the layout, so no client-side
|
||||
/// Mermaid/CSS graph library is required.
|
||||
///
|
||||
/// Instead of a terse node graph it produces a *rich, detailed* read: the success
|
||||
/// chain as a stepped timeline, the failure/compensation edges, and a full task
|
||||
/// inventory (entry, language, mode, env, parent/next/onError references). All
|
||||
/// styling is inline in the document (CSS variables for light/dark), so the
|
||||
/// output is portable and themeable.
|
||||
/// </summary>
|
||||
public sealed class WorkflowHtmlRenderer
|
||||
{
|
||||
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private readonly Microsoft.Extensions.Logging.ILogger<WorkflowHtmlRenderer> _logger;
|
||||
|
||||
public WorkflowHtmlRenderer(Microsoft.Extensions.Logging.ILogger<WorkflowHtmlRenderer> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Render(Workflow workflow, IReadOnlyList<WorkflowTask> tasks, WorkflowDefinition? def, bool dark)
|
||||
{
|
||||
var sb = new StringBuilder(1 << 14);
|
||||
var t = tasks.OrderBy(x => x.Order).ToList();
|
||||
var byId = t.ToDictionary(x => x.Id);
|
||||
var chain = HappyPathChain(t, byId);
|
||||
var errors = t.Where(x => x.OnErrorId != null).ToList();
|
||||
var trigger = ParseTrigger(workflow.TriggerJson);
|
||||
|
||||
var theme = dark ? "dark" : "light";
|
||||
var title = $"{workflow.Name} — workflow";
|
||||
|
||||
sb.Append("<!DOCTYPE html>\n<html lang=\"en\" data-theme=\"").Append(theme).Append("\">\n");
|
||||
sb.Append("<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
|
||||
sb.Append("<title>").Append(Html(title)).Append("</title>\n");
|
||||
sb.Append("<style>\n");
|
||||
sb.Append(Css);
|
||||
sb.Append("\n</style>\n</head>\n<body>\n<main class=\"wf\">\n");
|
||||
|
||||
// ---- Header ---------------------------------------------------------
|
||||
sb.Append("<header class=\"wf-head\">\n");
|
||||
sb.Append("<div class=\"wf-head-row\">\n");
|
||||
sb.Append("<h1 class=\"wf-name\">").Append(Html(workflow.Name)).Append("</h1>\n");
|
||||
sb.Append("<div class=\"wf-badges\">\n");
|
||||
Badge(sb, workflow.Mode, "badge-mode");
|
||||
Badge(sb, $"v{workflow.Version}", "badge-version");
|
||||
Badge(sb, $"target: {workflow.Target}", "badge-target");
|
||||
Badge(sb, workflow.Status, workflow.Status == WorkflowStatus.Compiled ? "badge-ok" : "badge-warn");
|
||||
if (workflow.TriggerEnabled && trigger != null)
|
||||
Badge(sb, $"trigger: {trigger.Type}", "badge-info");
|
||||
sb.Append("</div>\n");
|
||||
sb.Append("</div>\n");
|
||||
|
||||
if (def != null && !string.IsNullOrWhiteSpace(def.Description))
|
||||
sb.Append("<p class=\"wf-desc\">").Append(Html(def.Description)).Append("</p>\n");
|
||||
|
||||
sb.Append("<dl class=\"wf-meta\">\n");
|
||||
MetaItem(sb, "Language", def?.Language ?? FirstLanguage(t) ?? "—");
|
||||
MetaItem(sb, "Entry", def?.Entry is { } e ? $"{e.File} :: {e.Function ?? "main"}()" : (EntrySummary(t) ?? "—"));
|
||||
MetaItem(sb, "Tasks", t.Count.ToString());
|
||||
MetaItem(sb, "Success chain", chain.Count.ToString());
|
||||
MetaItem(sb, "Compensation", errors.Count.ToString());
|
||||
if (workflow.UpdatedAt is { } upd)
|
||||
MetaItem(sb, "Updated", upd.ToUniversalTime().ToString("yyyy-MM-dd HH:mm 'UTC'"));
|
||||
sb.Append("</dl>\n");
|
||||
|
||||
if (trigger != null)
|
||||
{
|
||||
sb.Append("<div class=\"wf-trigger\">\n<div class=\"wf-trigger-title\">Auto-trigger</div>\n");
|
||||
sb.Append("<code>").Append(Html(JsonSerializer.Serialize(trigger, Json))).Append("</code>\n</div>\n");
|
||||
}
|
||||
sb.Append("</header>\n");
|
||||
|
||||
// ---- Success chain --------------------------------------------------
|
||||
sb.Append("<section class=\"wf-sec\">\n");
|
||||
sb.Append("<h2 class=\"wf-sec-title\">Success flow</h2>\n");
|
||||
if (chain.Count == 0)
|
||||
{
|
||||
sb.Append("<p class=\"wf-empty\">No tasks defined.</p>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("<div class=\"wf-flow\">\n");
|
||||
for (var i = 0; i < chain.Count; i++)
|
||||
{
|
||||
var task = chain[i];
|
||||
var last = i == chain.Count - 1;
|
||||
sb.Append("<div class=\"wf-step\">\n");
|
||||
sb.Append("<div class=\"wf-rail\"><span class=\"wf-dot\"></span>").Append(last ? "" : "<span class=\"wf-line\"></span>").Append("</div>\n");
|
||||
sb.Append("<div class=\"wf-card\">\n");
|
||||
sb.Append("<div class=\"wf-card-head\">\n");
|
||||
sb.Append("<span class=\"wf-step-num\">").Append(i + 1).Append("</span>\n");
|
||||
sb.Append("<span class=\"wf-key\">").Append(Html(task.Key)).Append("</span>\n");
|
||||
Raw(sb, Chip(task.Mode, "chip-mode"));
|
||||
Raw(sb, Chip(task.Language, $"chip-lang lang-{LangClass(task.Language)}"));
|
||||
sb.Append("</div>\n");
|
||||
CardDetail(sb, task, byId);
|
||||
sb.Append("</div>\n");
|
||||
sb.Append("</div>\n");
|
||||
}
|
||||
sb.Append("</div>\n");
|
||||
}
|
||||
sb.Append("</section>\n");
|
||||
|
||||
// ---- Error handling ------------------------------------------------
|
||||
sb.Append("<section class=\"wf-sec\">\n");
|
||||
sb.Append("<h2 class=\"wf-sec-title\">Error handling & compensation</h2>\n");
|
||||
if (errors.Count == 0)
|
||||
{
|
||||
sb.Append("<p class=\"wf-empty\">No <code>onError</code> targets — the chain fails hard.</p>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append("<div class=\"wf-errors\">\n");
|
||||
foreach (var task in errors.OrderBy(x => x.Order))
|
||||
{
|
||||
if (task.OnErrorId == null || !byId.TryGetValue(task.OnErrorId.Value, out var target)) continue;
|
||||
sb.Append("<div class=\"wf-err-card\">\n");
|
||||
sb.Append("<code class=\"wf-err-src\">").Append(Html(task.Key)).Append("</code>\n");
|
||||
sb.Append("<span class=\"wf-err-arrow\">⟶ onError ⟶</span>\n");
|
||||
sb.Append("<code class=\"wf-err-dst\">").Append(Html(target.Key)).Append("</code>\n");
|
||||
sb.Append("<span class=\"wf-err-lang\">").Append(Html(target.Language)).Append("</span>\n");
|
||||
sb.Append("</div>\n");
|
||||
}
|
||||
sb.Append("</div>\n");
|
||||
}
|
||||
sb.Append("</section>\n");
|
||||
|
||||
// ---- Task inventory -------------------------------------------------
|
||||
sb.Append("<section class=\"wf-sec\">\n");
|
||||
sb.Append("<h2 class=\"wf-sec-title\">Task inventory</h2>\n");
|
||||
sb.Append("<div class=\"wf-table-wrapper\">\n");
|
||||
sb.Append("<table class=\"wf-table\">\n");
|
||||
sb.Append("<thead><tr><th>#</th><th>Key</th><th>Mode</th><th>Language</th><th>Entry</th><th>Function</th><th>Parent</th><th>Next</th><th>On error</th><th>Env</th></tr></thead>\n");
|
||||
sb.Append("<tbody>\n");
|
||||
for (var i = 0; i < t.Count; i++)
|
||||
{
|
||||
var task = t[i];
|
||||
var entry = EntryOf(task);
|
||||
sb.Append("<tr>");
|
||||
sb.Append("<td>").Append(i).Append("</td>");
|
||||
sb.Append("<td><code>").Append(Html(task.Key)).Append("</code></td>");
|
||||
sb.Append("<td>").Append(Html(task.Mode)).Append("</td>");
|
||||
sb.Append("<td><span class=\"chip-lang lang-").Append(LangClass(task.Language)).Append("\">").Append(Html(task.Language)).Append("</span></td>");
|
||||
sb.Append("<td>").Append(Html(entry.File ?? "—")).Append("</td>");
|
||||
sb.Append("<td>").Append(Html(string.IsNullOrWhiteSpace(entry.Function) ? "main" : entry.Function)).Append("()</td>");
|
||||
sb.Append("<td>").Append(Html(KeyById(task.ParentId, byId) ?? "—")).Append("</td>");
|
||||
sb.Append("<td>").Append(Html(KeyById(task.NextId, byId) ?? "—")).Append("</td>");
|
||||
sb.Append("<td>").Append(Html(KeyById(task.OnErrorId, byId) ?? "—")).Append("</td>");
|
||||
sb.Append("<td>").Append(EnvSummary(task)).Append("</td>");
|
||||
sb.Append("</tr>\n");
|
||||
}
|
||||
sb.Append("</tbody>\n</table>\n</div>\n");
|
||||
sb.Append("</section>\n");
|
||||
|
||||
sb.Append("<footer class=\"wf-foot\">Rendered by the w4c-workflows engine at ")
|
||||
.Append(DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm 'UTC'"))
|
||||
.Append(" · path <code>").Append(Html(workflow.Path)).Append("</code></footer>\n");
|
||||
|
||||
sb.Append("</main>\n");
|
||||
sb.Append("<script>\n(function(){\n var cards=document.querySelectorAll('.wf-card');\n for (var i=0;i<cards.length;i++){cards[i].addEventListener('click',function(){this.classList.toggle('wf-open');});}\n})();\n</script>\n");
|
||||
sb.Append("</body>\n</html>\n");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ CSS
|
||||
|
||||
private const string Css = @"
|
||||
:root{--bg:#f4f5f7;--panel:#ffffff;--panel2:#eef1f5;--border:#d8dce3;--text:#1c2330;--muted:#5f6b7a;--accent:#3b6fe0;--ok:#2e7d32;--warn:#b26a00;--info:#0f6f8f;--error:#c0392b;--root:#fde68a;--code:#1e2a3a;}
|
||||
[data-theme='dark']{--bg:#121417;--panel:#1b1f26;--panel2:#222834;--border:#2c333f;--text:#e7eaef;--muted:#97a1af;--accent:#5b8cff;--ok:#4caf6d;--warn:#e0a24c;--info:#4fb3d9;--error:#e56a5a;--root:#6a5a2a;--code:#c3d2e6;}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased}
|
||||
main.wf{max-width:1180px;margin:0 auto;padding:20px}
|
||||
h1,h2{margin:0;font-weight:700}
|
||||
code{font-family:ui-monospace,SFMono-Regular,'SF Mono',Menlo,Consolas,monospace;font-size:12.5px}
|
||||
.wf-head{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:20px 22px;box-shadow:0 1px 2px rgba(0,0,0,.04)}
|
||||
.wf-head-row{display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between}
|
||||
.wf-name{font-size:24px;letter-spacing:-.01em}
|
||||
.wf-desc{margin:10px 0 0;color:var(--muted)}
|
||||
.wf-badges{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.badge{display:inline-flex;align-items:center;padding:3px 10px;border-radius:999px;font-size:12px;font-weight:600;border:1px solid transparent}
|
||||
.badge-mode{background:var(--accent);color:#fff}
|
||||
.badge-version{background:var(--panel2);border-color:var(--border);color:var(--text)}
|
||||
.badge-target{background:var(--panel2);border-color:var(--border);color:var(--muted)}
|
||||
.badge-ok{background:rgba(76,175,109,.16);color:var(--ok)}
|
||||
.badge-warn{background:rgba(224,162,76,.18);color:var(--warn)}
|
||||
.badge-info{background:rgba(79,179,217,.16);color:var(--info)}
|
||||
.wf-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px 18px;margin:16px 0 0;padding:14px 0 0;border-top:1px dashed var(--border)}
|
||||
.wf-meta .m-label{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted)}
|
||||
.wf-meta .m-value{font-size:14px;margin-top:2px}
|
||||
.wf-trigger{margin-top:14px;background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:10px 12px}
|
||||
.wf-trigger-title{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin-bottom:4px}
|
||||
.wf-trigger code{color:var(--info)}
|
||||
.wf-sec{margin-top:26px}
|
||||
.wf-sec-title{font-size:17px;margin-bottom:14px}
|
||||
.wf-empty{color:var(--muted);background:var(--panel);border:1px dashed var(--border);border-radius:8px;padding:16px}
|
||||
.wf-flow{display:flex;flex-direction:column}
|
||||
.wf-step{display:flex;gap:12px;min-width:0}
|
||||
.wf-rail{display:flex;flex-direction:column;align-items:center;width:22px;flex:0 0 22px}
|
||||
.wf-dot{width:14px;height:14px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 22%,transparent);flex:0 0 auto;margin-top:20px}
|
||||
.wf-step:first-child .wf-dot{background:var(--root);box-shadow:0 0 0 3px rgba(253,230,138,.3)}
|
||||
.wf-line{width:2px;background:var(--border);flex:1 1 auto;margin-top:4px}
|
||||
.wf-card{flex:1;background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:12px 14px;margin:0 0 14px;cursor:pointer;transition:box-shadow .12s,border-color .12s}
|
||||
.wf-step:last-child .wf-card{margin-bottom:0}
|
||||
.wf-card:hover{border-color:var(--accent);box-shadow:0 3px 10px rgba(0,0,0,.07)}
|
||||
.wf-card-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.wf-step-num{display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;border-radius:50%;background:var(--panel2);border:1px solid var(--border);font-size:12px;font-weight:700;color:var(--muted)}
|
||||
.wf-key{font-weight:700;font-size:15px}
|
||||
.chip{display:inline-flex;align-items:center;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:600;border:1px solid var(--border);background:var(--panel2);color:var(--muted)}
|
||||
.chip-mode{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,var(--border))}
|
||||
.chip-lang{color:var(--text)}
|
||||
.lang-javascript{background:#f7df1e;color:#1c2330}
|
||||
.lang-typescript{background:#3178c6;color:#fff}
|
||||
.lang-python{background:#3776ab;color:#fff}
|
||||
.lang-csharp{background:#68217a;color:#fff}
|
||||
.lang-shell{background:#4eaa25;color:#fff}
|
||||
.lang-plaintext{background:var(--panel2);color:var(--muted)}
|
||||
.wf-card-detail{display:flex;flex-wrap:wrap;gap:18px;margin-top:10px;color:var(--muted)}
|
||||
.wf-card-detail .d-item{min-width:120px}
|
||||
.wf-card-detail .d-label{font-size:10px;text-transform:uppercase;letter-spacing:.05em}
|
||||
.wf-card-detail .d-value{font-size:13px;margin-top:2px;color:var(--text)}
|
||||
.wf-links{margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;font-size:12px}
|
||||
.wf-link{color:var(--muted)}
|
||||
.wf-link b{color:var(--accent);font-weight:600}
|
||||
.wf-envs{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}
|
||||
.wf-env{background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:2px 7px;font-size:11.5px}
|
||||
.wf-env code{font-size:11px}
|
||||
.wf-errors{display:flex;flex-direction:column;gap:10px}
|
||||
.wf-err-card{display:flex;align-items:center;flex-wrap:wrap;gap:10px;background:rgba(229,106,90,.07);border:1px dashed color-mix(in srgb,var(--error) 55%,var(--border));border-radius:10px;padding:12px 14px}
|
||||
.wf-err-src,.wf-err-dst{font-size:13px;font-weight:700}
|
||||
.wf-err-arrow{color:var(--error);font-size:12px;text-transform:uppercase;letter-spacing:.04em}
|
||||
.wf-err-lang{color:var(--muted);font-size:12px}
|
||||
.wf-table-wrapper{overflow-x:auto;border:1px solid var(--border);border-radius:10px;background:var(--panel)}
|
||||
.wf-table{width:100%;border-collapse:collapse;min-width:760px;font-size:12.5px}
|
||||
.wf-table thead th{background:var(--panel2);text-align:left;padding:9px 10px;font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);border-bottom:1px solid var(--border);position:sticky;top:0}
|
||||
.wf-table tbody td{padding:8px 10px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
.wf-table tbody tr:last-child td{border-bottom:none}
|
||||
.wf-table tbody tr:hover{background:color-mix(in srgb,var(--accent) 6%,transparent)}
|
||||
.wf-foot{margin-top:26px;padding-top:14px;border-top:1px solid var(--border);color:var(--muted);font-size:12px}
|
||||
.wf-foot code{color:var(--muted)}
|
||||
";
|
||||
|
||||
// ------------------------------------------------------------- builders
|
||||
|
||||
private static void Badge(StringBuilder sb, string label, string cls)
|
||||
{
|
||||
sb.Append("<span class=\"badge ").Append(cls).Append("\">").Append(Html(label)).Append("</span>\n");
|
||||
}
|
||||
|
||||
private static void MetaItem(StringBuilder sb, string label, string value)
|
||||
{
|
||||
sb.Append("<div><div class=\"m-label\">").Append(Html(label)).Append("</div><div class=\"m-value\">").Append(Html(value)).Append("</div></div>\n");
|
||||
}
|
||||
|
||||
private static void CardDetail(StringBuilder sb, WorkflowTask task, IReadOnlyDictionary<Guid, WorkflowTask> byId)
|
||||
{
|
||||
var entry = EntryOf(task);
|
||||
sb.Append("<div class=\"wf-card-detail\">\n");
|
||||
DetailItem(sb, "Entry file", entry.File ?? "—");
|
||||
DetailItem(sb, "Function", string.IsNullOrWhiteSpace(entry.Function) ? "main()" : $"{entry.Function}()");
|
||||
DetailItem(sb, "Mode", task.Mode);
|
||||
sb.Append("</div>\n");
|
||||
|
||||
var links = new List<string>();
|
||||
if (task.NextId != null) links.Add($"next <b>{Html(KeyById(task.NextId, byId) ?? "?")}</b>");
|
||||
if (task.OnErrorId != null) links.Add($"onError <b>{Html(KeyById(task.OnErrorId, byId) ?? "?")}</b>");
|
||||
if (links.Count > 0)
|
||||
sb.Append("<div class=\"wf-links\">").Append(string.Join(" · ", links)).Append("</div>\n");
|
||||
|
||||
var env = EnvOf(task);
|
||||
if (env is { Count: > 0 })
|
||||
{
|
||||
sb.Append("<div class=\"wf-envs\">\n");
|
||||
foreach (var kv in env.OrderBy(x => x.Key))
|
||||
sb.Append("<span class=\"wf-env\"><code>").Append(Html(kv.Key)).Append("</code> = <code>").Append(Html(kv.Value)).Append("</code></span>\n");
|
||||
sb.Append("</div>\n");
|
||||
}
|
||||
}
|
||||
|
||||
private static void DetailItem(StringBuilder sb, string label, string value)
|
||||
{
|
||||
sb.Append("<div class=\"d-item\"><div class=\"d-label\">").Append(Html(label)).Append("</div><div class=\"d-value\">").Append(Html(value)).Append("</div></div>\n");
|
||||
}
|
||||
|
||||
private static void Raw(StringBuilder sb, string s) => sb.Append(s);
|
||||
|
||||
private static string Chip(string label, string cls)
|
||||
=> $"<span class=\"chip {cls}\">{Html(label)}</span>";
|
||||
|
||||
// ------------------------------------------------------------- data
|
||||
|
||||
private static List<WorkflowTask> HappyPathChain(IReadOnlyList<WorkflowTask> tasks, IReadOnlyDictionary<Guid, WorkflowTask> byId)
|
||||
{
|
||||
var root = tasks.FirstOrDefault(x => x.Key == "root") ?? tasks.OrderBy(x => x.Order).FirstOrDefault();
|
||||
var chain = new List<WorkflowTask>();
|
||||
var visited = new HashSet<Guid>();
|
||||
var cur = root;
|
||||
while (cur != null && visited.Add(cur.Id))
|
||||
{
|
||||
chain.Add(cur);
|
||||
cur = cur.NextId != null && byId.TryGetValue(cur.NextId.Value, out var next) ? next : null;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static string? KeyById(Guid? id, IReadOnlyDictionary<Guid, WorkflowTask> byId)
|
||||
=> id != null && byId.TryGetValue(id.Value, out var t) ? t.Key : null;
|
||||
|
||||
private static EntryDefinition EntryOf(WorkflowTask task)
|
||||
=> JsonSerializer.Deserialize<EntryDefinition>(task.EntryJson ?? "{}", Json) ?? new EntryDefinition();
|
||||
|
||||
private static Dictionary<string, string>? EnvOf(WorkflowTask task)
|
||||
=> JsonSerializer.Deserialize<Dictionary<string, string>>(task.EnvJson ?? "{}", Json);
|
||||
|
||||
private static string EnvSummary(WorkflowTask task)
|
||||
{
|
||||
var env = EnvOf(task);
|
||||
return env is { Count: > 0 } ? $"{env.Count} var" : "—";
|
||||
}
|
||||
|
||||
private static TriggerDefinition? ParseTrigger(string? json)
|
||||
=> string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<TriggerDefinition>(json, Json);
|
||||
|
||||
private static string? FirstLanguage(IReadOnlyList<WorkflowTask> tasks)
|
||||
=> tasks.Select(x => x.Language).FirstOrDefault(x => !string.IsNullOrWhiteSpace(x));
|
||||
|
||||
private static string? EntrySummary(IReadOnlyList<WorkflowTask> tasks)
|
||||
{
|
||||
var root = tasks.FirstOrDefault(x => x.Key == "root");
|
||||
if (root == null) return null;
|
||||
var e = EntryOf(root);
|
||||
return $"{e.File ?? "—"} :: {e.Function ?? "main"}()";
|
||||
}
|
||||
|
||||
private static string LangClass(string? lang)
|
||||
=> lang switch
|
||||
{
|
||||
"javascript" => "javascript",
|
||||
"typescript" => "typescript",
|
||||
"python" => "python",
|
||||
"csharp" => "csharp",
|
||||
"shell" => "shell",
|
||||
_ => "plaintext",
|
||||
};
|
||||
|
||||
private static string Html(string? s) =>
|
||||
System.Net.WebUtility.HtmlEncode(s ?? string.Empty);
|
||||
}
|
||||
128
Services/WorkflowSource.cs
Normal file
128
Services/WorkflowSource.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>A workflow definition file discovered in the git source.</summary>
|
||||
public sealed record WorkflowFile(string Path);
|
||||
|
||||
/// <summary>Current git state of the workflow source (for "sync status vs git").</summary>
|
||||
public sealed record WorkflowSourceState(string? HeadSha, bool Dirty);
|
||||
|
||||
/// <summary>
|
||||
/// Reads workflow YAML definitions from the authoritative git source. v1 uses
|
||||
/// the same local monorepo checkout the Source Code explorer reads
|
||||
/// (<c>SourceCode:RepoRoot</c>); a Forgejo API transport can be swapped in
|
||||
/// behind this interface later.
|
||||
/// </summary>
|
||||
public interface IWorkflowSource
|
||||
{
|
||||
Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct);
|
||||
Task<string> ReadAsync(string path, CancellationToken ct);
|
||||
WorkflowSourceState GetState();
|
||||
}
|
||||
|
||||
public class LocalRepoWorkflowSource : IWorkflowSource
|
||||
{
|
||||
private const string WorkflowsDir = "workflows";
|
||||
|
||||
private readonly string _repoRoot;
|
||||
private readonly ILogger<LocalRepoWorkflowSource> _logger;
|
||||
|
||||
public LocalRepoWorkflowSource(IConfiguration config, IWebHostEnvironment env, ILogger<LocalRepoWorkflowSource> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_repoRoot = ResolveRepoRoot(config, env);
|
||||
_logger.LogInformation("Workflow source repo root resolved to {Root}", _repoRoot);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var dir = Path.Combine(_repoRoot, WorkflowsDir);
|
||||
var files = new List<WorkflowFile>();
|
||||
|
||||
if (!Directory.Exists(dir))
|
||||
return Task.FromResult<IReadOnlyList<WorkflowFile>>(files);
|
||||
|
||||
foreach (var full in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var ext = Path.GetExtension(full);
|
||||
if (ext is not (".yaml" or ".yml"))
|
||||
continue;
|
||||
var rel = Path.GetRelativePath(_repoRoot, full).Replace(Path.DirectorySeparatorChar, '/');
|
||||
files.Add(new WorkflowFile(rel));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<WorkflowFile>>(files.OrderBy(f => f.Path, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
public async Task<string> ReadAsync(string path, CancellationToken ct)
|
||||
=> await File.ReadAllTextAsync(Path.Combine(_repoRoot, path.Replace('/', Path.DirectorySeparatorChar)), ct);
|
||||
|
||||
public WorkflowSourceState GetState()
|
||||
{
|
||||
var head = RunGit("rev-parse", "HEAD");
|
||||
var status = RunGit("status", "--porcelain");
|
||||
return new WorkflowSourceState(
|
||||
string.IsNullOrWhiteSpace(head) ? null : head,
|
||||
!string.IsNullOrEmpty(status));
|
||||
}
|
||||
|
||||
private string? RunGit(params string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("git")
|
||||
{
|
||||
WorkingDirectory = _repoRoot,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
};
|
||||
foreach (var arg in args)
|
||||
psi.ArgumentList.Add(arg);
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process == null)
|
||||
return null;
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
if (!process.WaitForExit(5000))
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
return process.ExitCode == 0 ? output.Trim() : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "git invocation failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveRepoRoot(IConfiguration config, IWebHostEnvironment env)
|
||||
{
|
||||
var configured = config["SourceCode:RepoRoot"];
|
||||
if (!string.IsNullOrWhiteSpace(configured) && Directory.Exists(configured))
|
||||
return Path.GetFullPath(configured);
|
||||
|
||||
var start = AppContext.BaseDirectory;
|
||||
if (!string.IsNullOrEmpty(env.ContentRootPath) && Directory.Exists(env.ContentRootPath))
|
||||
start = env.ContentRootPath;
|
||||
|
||||
var walk = Path.GetFullPath(start);
|
||||
while (true)
|
||||
{
|
||||
if (Directory.Exists(Path.Combine(walk, ".git")))
|
||||
return Path.GetFullPath(walk);
|
||||
var parent = Directory.GetParent(walk)?.FullName;
|
||||
if (string.IsNullOrEmpty(parent) || parent == walk)
|
||||
break;
|
||||
walk = parent;
|
||||
}
|
||||
|
||||
return Path.GetFullPath(Directory.GetCurrentDirectory());
|
||||
}
|
||||
}
|
||||
201
Services/WorkflowSyncService.cs
Normal file
201
Services/WorkflowSyncService.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
public sealed record SyncError(string Path, IReadOnlyList<string> Errors);
|
||||
|
||||
public sealed class SyncResult
|
||||
{
|
||||
public string? HeadSha { get; set; }
|
||||
public bool Dirty { get; set; }
|
||||
public int Compiled { get; set; }
|
||||
public int Removed { get; set; }
|
||||
public List<SyncError> Errors { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads <c>workflows/*.yaml</c> from the git source, compiles each to the
|
||||
/// tenant's DB state, and removes workflows whose files were deleted. Ids are
|
||||
/// deterministic, so re-syncing upserts instead of duplicating. Files that fail
|
||||
/// to compile are reported in <see cref="SyncResult.Errors"/> and not persisted.
|
||||
/// </summary>
|
||||
public class WorkflowSyncService
|
||||
{
|
||||
private readonly WorkflowsDbContext _db;
|
||||
private readonly WorkflowCompiler _compiler;
|
||||
private readonly IWorkflowSource _source;
|
||||
private readonly ILogger<WorkflowSyncService> _logger;
|
||||
|
||||
public WorkflowSyncService(
|
||||
WorkflowsDbContext db,
|
||||
WorkflowCompiler compiler,
|
||||
IWorkflowSource source,
|
||||
ILogger<WorkflowSyncService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_compiler = compiler;
|
||||
_source = source;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SyncResult> SyncAsync(string tenantId, CancellationToken ct)
|
||||
{
|
||||
var state = _source.GetState();
|
||||
var files = await _source.ListAsync(ct);
|
||||
|
||||
var result = new SyncResult { HeadSha = state.HeadSha, Dirty = state.Dirty };
|
||||
|
||||
var existing = await _db.Workflows
|
||||
.Include(w => w.Tasks)
|
||||
.Where(w => w.TenantId == tenantId)
|
||||
.ToListAsync(ct);
|
||||
var existingByPath = existing.ToDictionary(w => w.Path, StringComparer.Ordinal);
|
||||
|
||||
var seenPaths = new HashSet<string>(StringComparer.Ordinal);
|
||||
var seenNames = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
seenPaths.Add(file.Path);
|
||||
|
||||
string content;
|
||||
try
|
||||
{
|
||||
content = await _source.ReadAsync(file.Path, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to read workflow file {Path}", file.Path);
|
||||
result.Errors.Add(new SyncError(file.Path, new[] { $"read error: {ex.Message}" }));
|
||||
continue;
|
||||
}
|
||||
|
||||
var compiled = _compiler.Compile(content, file.Path, tenantId);
|
||||
if (!compiled.Success)
|
||||
{
|
||||
result.Errors.Add(new SyncError(file.Path, compiled.Errors));
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = compiled.Workflow!.Workflow.Name;
|
||||
if (!seenNames.Add(name))
|
||||
{
|
||||
result.Errors.Add(new SyncError(file.Path, new[] { $"duplicate workflow name '{name}'" }));
|
||||
continue;
|
||||
}
|
||||
|
||||
var existingWorkflow = existingByPath.GetValueOrDefault(file.Path);
|
||||
await UpsertAsync(existingWorkflow, compiled.Workflow!, state.HeadSha, ct);
|
||||
result.Compiled++;
|
||||
}
|
||||
|
||||
// Remove workflows whose files no longer exist in git.
|
||||
foreach (var workflow in existing)
|
||||
{
|
||||
if (seenPaths.Contains(workflow.Path)) continue;
|
||||
await RemoveWorkflowAsync(workflow, ct);
|
||||
result.Removed++;
|
||||
_logger.LogInformation("Removed workflow {Name} ({Path}) for tenant {TenantId} — file deleted",
|
||||
workflow.Name, workflow.Path, tenantId);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task UpsertAsync(Workflow? existing, CompiledWorkflow compiled, string? gitSha, CancellationToken ct)
|
||||
{
|
||||
var wf = compiled.Workflow;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
wf.GitSha = gitSha;
|
||||
_db.Workflows.Add(wf);
|
||||
_db.Tasks.AddRange(compiled.Tasks);
|
||||
return;
|
||||
}
|
||||
|
||||
existing.Name = wf.Name;
|
||||
existing.Status = wf.Status;
|
||||
existing.Mode = wf.Mode;
|
||||
existing.Target = wf.Target;
|
||||
existing.Version = wf.Version;
|
||||
existing.TriggerJson = wf.TriggerJson;
|
||||
existing.GitSha = gitSha;
|
||||
existing.CompiledAt = now;
|
||||
existing.UpdatedAt = now;
|
||||
|
||||
var existingTasks = existing.Tasks.ToDictionary(t => t.Id);
|
||||
foreach (var task in compiled.Tasks)
|
||||
{
|
||||
if (existingTasks.TryGetValue(task.Id, out var existingTask))
|
||||
{
|
||||
ApplyTask(existingTask, task);
|
||||
}
|
||||
else
|
||||
{
|
||||
// The task may exist as an archived row (removed, then re-added with the
|
||||
// same id). Re-activate it in place instead of inserting a duplicate row
|
||||
// (deterministic id => primary-key collision). Historical TaskRuns stay
|
||||
// linked and future runs use this definition again.
|
||||
var archived = await _db.Tasks
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(t => t.Id == task.Id, ct);
|
||||
if (archived != null)
|
||||
{
|
||||
ApplyTask(archived, task);
|
||||
archived.ArchivedAt = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
_db.Tasks.Add(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks removed from the definition no longer wipe their historical TaskRuns.
|
||||
// Each stale task is archived (preserving run history for audit/debug) and
|
||||
// excluded from active-definition queries via the WorkflowTask global query
|
||||
// filter. The row is kept so its TaskRuns' Task->TaskRun Restrict FK holds.
|
||||
var newIds = compiled.Tasks.Select(t => t.Id).ToHashSet();
|
||||
foreach (var stale in existing.Tasks.Where(t => !newIds.Contains(t.Id)).ToList())
|
||||
{
|
||||
stale.ArchivedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyTask(WorkflowTask target, WorkflowTask source)
|
||||
{
|
||||
target.Key = source.Key;
|
||||
target.ParentId = source.ParentId;
|
||||
target.NextId = source.NextId;
|
||||
target.OnErrorId = source.OnErrorId;
|
||||
target.Language = source.Language;
|
||||
target.Mode = source.Mode;
|
||||
target.EntryJson = source.EntryJson;
|
||||
target.EnvJson = source.EnvJson;
|
||||
target.Server = source.Server;
|
||||
target.Order = source.Order;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a workflow definition whose source file is gone. The schema has
|
||||
/// <c>WorkflowRun->Workflow</c> (Restrict) and <c>TaskRun->Task</c>
|
||||
/// (Restrict), so an executed workflow can't be removed outright: its
|
||||
/// <c>WorkflowRun</c> rows are deleted first (which cascades their
|
||||
/// <c>TaskRun</c> attempts via <c>RunId</c>), then the workflow and its task
|
||||
/// tree (which cascades via <c>WorkflowId</c>).
|
||||
/// </summary>
|
||||
private async Task RemoveWorkflowAsync(Workflow workflow, CancellationToken ct)
|
||||
{
|
||||
await _db.WorkflowRuns
|
||||
.Where(r => r.WorkflowId == workflow.Id)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
_db.Workflows.Remove(workflow);
|
||||
}
|
||||
}
|
||||
222
Services/WorkflowValidator.cs
Normal file
222
Services/WorkflowValidator.cs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using w4c_workflows.Models;
|
||||
|
||||
namespace w4c_workflows.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a parsed <see cref="WorkflowDefinition"/>. Pure and side-effect
|
||||
/// free: it returns a list of human-readable errors (empty = valid). The
|
||||
/// compiler calls this before building the EF entities.
|
||||
/// </summary>
|
||||
public partial class WorkflowValidator
|
||||
{
|
||||
private static readonly string[] Modes = { WorkflowMode.Function, WorkflowMode.Durable, WorkflowMode.Handler };
|
||||
private static readonly string[] TriggerTypes = { TriggerType.Cron, TriggerType.Interval, TriggerType.Webhook, TriggerType.Event, TriggerType.Queue };
|
||||
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9._-]+$", RegexOptions.Compiled)]
|
||||
private static partial Regex IdentifierRegex();
|
||||
|
||||
private readonly LanguageRegistry _languages;
|
||||
|
||||
public WorkflowValidator(LanguageRegistry languages)
|
||||
{
|
||||
_languages = languages;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> Validate(WorkflowDefinition def)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
ValidateHeader(def, errors);
|
||||
ValidateTrigger(def, errors);
|
||||
|
||||
var tasks = def.Tasks ?? new List<TaskDefinition>();
|
||||
ValidateTasks(def, tasks, errors);
|
||||
ValidateFlow(tasks, errors);
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private void ValidateHeader(WorkflowDefinition def, List<string> errors)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(def.Name))
|
||||
{
|
||||
errors.Add("name is required");
|
||||
}
|
||||
else if (def.Name.Length > 200 || !IdentifierRegex().IsMatch(def.Name))
|
||||
{
|
||||
errors.Add($"name '{def.Name}' is invalid: use 1-200 chars of letters, digits, '.', '_', '-'");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(def.Mode))
|
||||
errors.Add("mode is required (function | durable | handler)");
|
||||
else if (!Modes.Contains(def.Mode))
|
||||
errors.Add($"mode '{def.Mode}' is invalid: expected one of {string.Join(", ", Modes)}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(def.Language))
|
||||
errors.Add("language is required (shell | javascript | typescript | csharp | python)");
|
||||
else if (!_languages.IsKnown(def.Language))
|
||||
errors.Add($"language '{def.Language}' is not supported");
|
||||
|
||||
if (def.Entry == null || string.IsNullOrWhiteSpace(def.Entry.File))
|
||||
errors.Add("entry.file is required");
|
||||
}
|
||||
|
||||
private void ValidateTrigger(WorkflowDefinition def, List<string> errors)
|
||||
{
|
||||
var trigger = def.Trigger;
|
||||
if (trigger == null)
|
||||
return; // no auto-trigger → manual (event) only; valid
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trigger.Type) || !TriggerTypes.Contains(trigger.Type))
|
||||
{
|
||||
errors.Add($"trigger.type is invalid: expected one of {string.Join(", ", TriggerTypes)}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (def.Mode == WorkflowMode.Handler)
|
||||
{
|
||||
// Handler mode is a stream subscription — its trigger must be a queue.
|
||||
if (trigger.Type != TriggerType.Queue)
|
||||
errors.Add("handler mode requires trigger.type = 'queue'");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (trigger.Type)
|
||||
{
|
||||
case TriggerType.Cron:
|
||||
if (string.IsNullOrWhiteSpace(trigger.Cron) || trigger.Cron.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length != 5)
|
||||
errors.Add("trigger.cron is required and must be a 5-field cron expression");
|
||||
break;
|
||||
case TriggerType.Interval:
|
||||
if (!DurationParser.TryParse(trigger.Interval, out _))
|
||||
errors.Add("trigger.interval is required and must be a duration like '30m' or '2h'");
|
||||
break;
|
||||
case TriggerType.Webhook:
|
||||
if (string.IsNullOrWhiteSpace(trigger.WebhookPath) || !trigger.WebhookPath.StartsWith('/'))
|
||||
errors.Add("trigger.webhookPath is required and must start with '/'");
|
||||
break;
|
||||
case TriggerType.Queue:
|
||||
errors.Add("queue trigger is only valid for handler mode");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateTasks(WorkflowDefinition def, IReadOnlyList<TaskDefinition> tasks, List<string> errors)
|
||||
{
|
||||
var ids = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < tasks.Count; i++)
|
||||
{
|
||||
var task = tasks[i];
|
||||
var label = $"tasks[{i}]";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(task.Id))
|
||||
{
|
||||
errors.Add($"{label}.id is required");
|
||||
continue;
|
||||
}
|
||||
if (string.Equals(task.Id, "root", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"{label}.id 'root' is reserved");
|
||||
continue;
|
||||
}
|
||||
if (!IdentifierRegex().IsMatch(task.Id) || task.Id.Length > 100)
|
||||
{
|
||||
errors.Add($"{label}.id '{task.Id}' is invalid");
|
||||
continue;
|
||||
}
|
||||
if (!ids.Add(task.Id))
|
||||
{
|
||||
errors.Add($"{label}.id '{task.Id}' is duplicated");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (task.Language != null && !_languages.IsKnown(task.Language))
|
||||
errors.Add($"{label}.language '{task.Language}' is not supported");
|
||||
|
||||
if (task.Mode != null && !Modes.Contains(task.Mode))
|
||||
errors.Add($"{label}.mode '{task.Mode}' is invalid: expected one of {string.Join(", ", Modes)}");
|
||||
|
||||
if (task.Entry == null || string.IsNullOrWhiteSpace(task.Entry.File))
|
||||
errors.Add($"{label}.entry.file is required");
|
||||
}
|
||||
|
||||
// Reference resolution: parent/next/onError must point at an existing
|
||||
// task (or "root" for parent).
|
||||
var knownIds = ids;
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.Id == null) continue;
|
||||
var label = $"task '{task.Id}'";
|
||||
|
||||
var parent = string.IsNullOrEmpty(task.Parent) ? "root" : task.Parent;
|
||||
if (!string.Equals(parent, "root", StringComparison.Ordinal) && !knownIds.Contains(parent))
|
||||
errors.Add($"{label}.parent '{parent}' does not reference a known task or 'root'");
|
||||
|
||||
if (!string.IsNullOrEmpty(task.Next))
|
||||
{
|
||||
if (!knownIds.Contains(task.Next))
|
||||
errors.Add($"{label}.next '{task.Next}' does not reference a known task");
|
||||
else if (string.Equals(task.Next, task.Id, StringComparison.Ordinal))
|
||||
errors.Add($"{label}.next must not reference itself");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(task.OnError))
|
||||
{
|
||||
if (!knownIds.Contains(task.OnError))
|
||||
errors.Add($"{label}.onError '{task.OnError}' does not reference a known task");
|
||||
else if (string.Equals(task.OnError, task.Id, StringComparison.Ordinal))
|
||||
errors.Add($"{label}.onError must not reference itself");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateFlow(IReadOnlyList<TaskDefinition> tasks, List<string> errors)
|
||||
{
|
||||
if (tasks.Count == 0)
|
||||
return; // root-only workflow
|
||||
|
||||
// Skip flow validation if the reference/structural checks already failed —
|
||||
// the graph would be incomplete and produce misleading errors.
|
||||
if (errors.Count > 0)
|
||||
return;
|
||||
|
||||
var graph = WorkflowGraph.Compute(new WorkflowDefinition { Tasks = tasks.ToList() });
|
||||
|
||||
// Linear chain: each task is the `next` target of at most one other task.
|
||||
foreach (var (taskId, incoming) in graph.IncomingNext)
|
||||
{
|
||||
if (incoming > 1)
|
||||
errors.Add($"task '{taskId}' has {incoming} incoming 'next' edges; the success chain must be linear");
|
||||
}
|
||||
|
||||
if (graph.HeadId == null)
|
||||
{
|
||||
errors.Add("no chain head: expected a top-level task (parent: root) that starts the success chain");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle detection on the success chain.
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var cursor = graph.HeadId;
|
||||
while (cursor != null)
|
||||
{
|
||||
if (!seen.Add(cursor))
|
||||
{
|
||||
errors.Add($"cycle detected in the success chain at task '{cursor}'");
|
||||
return;
|
||||
}
|
||||
graph.ById.TryGetValue(cursor, out var task);
|
||||
cursor = string.IsNullOrEmpty(task?.Next) ? null : task.Next;
|
||||
}
|
||||
|
||||
// Every task must be either on the success chain or compensation-only.
|
||||
var chained = new HashSet<string>(graph.Chain, StringComparer.Ordinal);
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
if (task.Id == null) continue;
|
||||
if (chained.Contains(task.Id) || graph.CompensationOnly.Contains(task.Id)) continue;
|
||||
errors.Add($"task '{task.Id}' is unreachable: it is not on the success chain and not an onError target");
|
||||
}
|
||||
}
|
||||
}
|
||||
67
appsettings.json
Normal file
67
appsettings.json
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Urls": "http://*:5259",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=w4c;Username=w4c;Password=w4c"
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6379,password=w4c,abortConnect=false"
|
||||
},
|
||||
"OpenSearch": {
|
||||
"Url": "http://localhost:9200"
|
||||
},
|
||||
"Auth": {
|
||||
"JwtSigningKey": ""
|
||||
},
|
||||
"Cors": {
|
||||
"Origins": []
|
||||
},
|
||||
"Workflows": {
|
||||
"WorkerStream": "wf:{tenant}:jobs",
|
||||
"EventStream": "wf:{tenant}:events",
|
||||
"DlqStream": "wf:{tenant}:dlq",
|
||||
"ResultStream": "wf:{tenant}:results",
|
||||
"ConsumerGroup": "workers",
|
||||
"MaxRetries": 3,
|
||||
"RunTimeoutSeconds": 300,
|
||||
"TaskTimeoutSeconds": 60,
|
||||
"EsbuildBinary": "esbuild",
|
||||
"NodeBinary": "node",
|
||||
"TenantId": "",
|
||||
"WorkerBatchSize": 10,
|
||||
"WorkerPollDelayMs": 500,
|
||||
"WorkerClaimIdleSeconds": 30,
|
||||
"SchedulerPollSeconds": 30,
|
||||
"TriggerStateTtlDays": 90,
|
||||
"HandlerPollDelayMs": 500,
|
||||
"HandlerBatchSize": 10,
|
||||
"HandlerClaimIdleSeconds": 30,
|
||||
"WebhookSecret": "",
|
||||
"WorkerRoot": "",
|
||||
"ServerApiUrl": "",
|
||||
"ServerApiToken": "",
|
||||
"ServerTaskTimeoutSeconds": 120,
|
||||
"ChatApiUrl": "",
|
||||
"ChatApiToken": "",
|
||||
"AgentStepTimeoutSeconds": 120,
|
||||
"LifecyclePollDelayMs": 500,
|
||||
"LifecycleBatchSize": 10,
|
||||
"LifecycleClaimIdleSeconds": 30,
|
||||
"LifecycleLeaseTtlSeconds": 60,
|
||||
"RetryBaseDelayMs": 1000,
|
||||
"RetryMaxDelayMs": 30000
|
||||
},
|
||||
"Forgejo": {
|
||||
"BaseUrl": "https://forgejo.wiz4chat.com",
|
||||
"AccessToken": ""
|
||||
},
|
||||
"SourceCode": {
|
||||
"RepoRoot": ""
|
||||
}
|
||||
}
|
||||
136
w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs
Normal file
136
w4c-workflows-api.Tests/CSharpScriptExecutorTests.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System.Text.Json;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class CSharpScriptExecutorTests
|
||||
{
|
||||
private readonly CSharpScriptExecutor _executor = new();
|
||||
|
||||
[Fact]
|
||||
public async Task Executes_program_with_string_input()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("Program.cs", """
|
||||
using System.Text.Json;
|
||||
public static class Program
|
||||
{
|
||||
public static object Main(string input)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(input);
|
||||
var n = doc.RootElement.GetProperty("n").GetInt32();
|
||||
return new { squared = n * n };
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
Invocation.For("csharp", "Program.cs", "{\"n\":5}", dir.Path), default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.NotNull(result.Output);
|
||||
using var output = JsonDocument.Parse(result.Output!);
|
||||
Assert.Equal(25, output.RootElement.GetProperty("squared").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Executes_multi_file_program()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("Helper.cs", """
|
||||
public static class Helper
|
||||
{
|
||||
public static int Add(int a, int b) => a + b;
|
||||
}
|
||||
""");
|
||||
dir.Write("Program.cs", """
|
||||
using System.Text.Json;
|
||||
public static class Program
|
||||
{
|
||||
public static object Main(string input)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(input);
|
||||
var a = doc.RootElement.GetProperty("a").GetInt32();
|
||||
var b = doc.RootElement.GetProperty("b").GetInt32();
|
||||
return new { sum = Helper.Add(a, b) };
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
Invocation.For("csharp", "Program.cs", "{\"a\":2,\"b\":3}", dir.Path), default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
using var output = JsonDocument.Parse(result.Output!);
|
||||
Assert.Equal(5, output.RootElement.GetProperty("sum").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Supports_custom_entry_function_name()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("Program.cs", """
|
||||
public static class Program
|
||||
{
|
||||
public static object Transform(string input) => new { echo = input };
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
Invocation.For("csharp", "Program.cs", "{\"x\":1}", dir.Path, entryFunction: "Transform"), default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
using var output = JsonDocument.Parse(result.Output!);
|
||||
Assert.Equal("{\"x\":1}", output.RootElement.GetProperty("echo").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Supports_async_entry()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("Program.cs", """
|
||||
using System.Threading.Tasks;
|
||||
public static class Program
|
||||
{
|
||||
public static async Task<object> Main(string input)
|
||||
{
|
||||
await Task.Yield();
|
||||
return new { ok = true };
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
Invocation.For("csharp", "Program.cs", "{}", dir.Path), default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Contains("\"ok\":true", result.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_with_compile_error()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("Program.cs", "public static class Program { public static object Main(string input) { return ; } }");
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
Invocation.For("csharp", "Program.cs", "{}", dir.Path), default);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("compilation failed", result.Error, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fails_when_entry_method_missing()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("Program.cs", "public static class Program { public static int NotTheEntry() => 1; }");
|
||||
|
||||
var result = await _executor.ExecuteAsync(
|
||||
Invocation.For("csharp", "Program.cs", "{}", dir.Path), default);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("no static entry method", result.Error);
|
||||
}
|
||||
}
|
||||
88
w4c-workflows-api.Tests/CronExpressionTests.cs
Normal file
88
w4c-workflows-api.Tests/CronExpressionTests.cs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
using w4c_workflows.Services.Triggers;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class CronExpressionTests
|
||||
{
|
||||
private static DateTimeOffset Utc(int year, int month, int day, int hour, int minute)
|
||||
=> new(year, month, day, hour, minute, 0, TimeSpan.Zero);
|
||||
|
||||
[Theory]
|
||||
[InlineData("* * * * *", 2026, 8, 28, 14, 21, true)]
|
||||
[InlineData("*/15 * * * *", 2026, 8, 28, 14, 0, true)]
|
||||
[InlineData("*/15 * * * *", 2026, 8, 28, 14, 1, false)]
|
||||
[InlineData("0 */6 * * *", 2026, 8, 28, 6, 0, true)]
|
||||
[InlineData("0 */6 * * *", 2026, 8, 28, 12, 0, true)]
|
||||
[InlineData("0 */6 * * *", 2026, 8, 28, 13, 0, false)]
|
||||
[InlineData("0 9 * * 1-5", 2026, 8, 28, 9, 0, true)] // Friday
|
||||
[InlineData("0 9 * * 1-5", 2026, 8, 29, 9, 0, false)] // Saturday
|
||||
[InlineData("0 0 1 * *", 2026, 8, 1, 0, 0, true)]
|
||||
[InlineData("0 0 1 * *", 2026, 8, 2, 0, 0, false)]
|
||||
[InlineData("0 9 * * 0", 2026, 8, 30, 9, 0, true)] // Sunday via 0
|
||||
[InlineData("0 9 * * 7", 2026, 8, 30, 9, 0, true)] // Sunday via alias 7
|
||||
[InlineData("30 4,16 * * *", 2026, 8, 28, 16, 30, true)]
|
||||
[InlineData("30 4,16 * * *", 2026, 8, 28, 12, 30, false)]
|
||||
[InlineData("15-30 * * * *", 2026, 8, 28, 14, 22, true)]
|
||||
[InlineData("15-30 * * * *", 2026, 8, 28, 14, 31, false)]
|
||||
public void Matches_standard_5_field_expressions(
|
||||
string expr, int y, int m, int d, int h, int min, bool expected)
|
||||
{
|
||||
Assert.True(CronExpression.TryParse(expr, out var cron, out var error), error);
|
||||
Assert.Equal(expected, cron!.Matches(new DateTime(y, m, d, h, min, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextOccurrence_returns_next_matching_minute()
|
||||
{
|
||||
Assert.True(CronExpression.TryParse("0 9 * * *", out var cron, out _));
|
||||
var next = cron!.GetNextOccurrence(Utc(2026, 8, 28, 10, 0));
|
||||
Assert.Equal(Utc(2026, 8, 29, 9, 0), next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextOccurrence_skips_to_next_day_for_weekday_schedule()
|
||||
{
|
||||
Assert.True(CronExpression.TryParse("0 9 * * 1-5", out var cron, out _));
|
||||
// Friday 10:00 → next is Monday 09:00.
|
||||
var next = cron!.GetNextOccurrence(Utc(2026, 8, 28, 10, 0));
|
||||
Assert.Equal(Utc(2026, 8, 31, 9, 0), next);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextOccurrence_is_strictly_after_the_given_instant()
|
||||
{
|
||||
Assert.True(CronExpression.TryParse("* * * * *", out var cron, out _));
|
||||
var after = Utc(2026, 8, 28, 14, 0);
|
||||
var next = cron!.GetNextOccurrence(after);
|
||||
Assert.Equal(Utc(2026, 8, 28, 14, 1), next);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("0 9 * *")] // 4 fields
|
||||
[InlineData("0 9 * * * *")] // 6 fields
|
||||
[InlineData("60 * * * *")] // minute out of range
|
||||
[InlineData("0 24 * * *")] // hour out of range
|
||||
[InlineData("0 9 32 * *")] // day-of-month out of range
|
||||
[InlineData("0 9 * 13 *")] // month out of range
|
||||
[InlineData("0 9 * * 8")] // day-of-week out of range
|
||||
[InlineData("0 9 * * *")] // valid — ensure it is NOT rejected
|
||||
public void TryParse_validates_field_count_and_ranges(string expr)
|
||||
{
|
||||
var result = CronExpression.TryParse(expr, out var cron, out _);
|
||||
if (expr == "0 9 * * *")
|
||||
Assert.True(result);
|
||||
else
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextOccurrence_returns_null_for_unmatchable_schedule()
|
||||
{
|
||||
// Feb 30 never occurs — no match within the bounded horizon.
|
||||
Assert.True(CronExpression.TryParse("0 0 30 2 *", out var cron, out _));
|
||||
Assert.Null(cron!.GetNextOccurrence(Utc(2026, 8, 28, 0, 0)));
|
||||
}
|
||||
}
|
||||
102
w4c-workflows-api.Tests/ExecutionContractTests.cs
Normal file
102
w4c-workflows-api.Tests/ExecutionContractTests.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using w4c_workflows.Services.Execution;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class ExecutionContractTests
|
||||
{
|
||||
[Fact]
|
||||
public void TaskInvocation_parses_a_full_job_field_set()
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["type"] = "task.run",
|
||||
["run_id"] = "run-1",
|
||||
["task_id"] = "task-1",
|
||||
["task_key"] = "validate",
|
||||
["language"] = "python",
|
||||
["entry_file"] = "validate.py",
|
||||
["entry_function"] = "main",
|
||||
["env"] = "{\"FOO\":\"bar\"}",
|
||||
["input"] = "{\"n\":5}",
|
||||
["working_dir"] = "/tmp/wf",
|
||||
["tenant_id"] = "t1",
|
||||
["attempt"] = "2",
|
||||
};
|
||||
|
||||
var inv = TaskInvocation.FromFields(fields);
|
||||
|
||||
Assert.Equal("task.run", inv.Type);
|
||||
Assert.Equal("run-1", inv.RunId);
|
||||
Assert.Equal("validate", inv.TaskKey);
|
||||
Assert.Equal("python", inv.Language);
|
||||
Assert.Equal("validate.py", inv.EntryFile);
|
||||
Assert.Equal("main", inv.EntryFunction);
|
||||
Assert.Equal("bar", inv.Env["FOO"]);
|
||||
Assert.Equal("{\"n\":5}", inv.Input);
|
||||
Assert.Equal("/tmp/wf", inv.WorkingDir);
|
||||
Assert.Equal("t1", inv.TenantId);
|
||||
Assert.Equal(2, inv.Attempt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskInvocation_defaults_attempt_and_optional_fields()
|
||||
{
|
||||
var fields = new Dictionary<string, string>
|
||||
{
|
||||
["type"] = "task.run",
|
||||
["run_id"] = "r",
|
||||
["task_id"] = "t",
|
||||
["task_key"] = "k",
|
||||
["language"] = "shell",
|
||||
["entry_file"] = "a.sh",
|
||||
["tenant_id"] = "t1",
|
||||
};
|
||||
|
||||
var inv = TaskInvocation.FromFields(fields);
|
||||
|
||||
Assert.Equal(1, inv.Attempt);
|
||||
Assert.Null(inv.EntryFunction);
|
||||
Assert.Null(inv.Input);
|
||||
Assert.Empty(inv.Env);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskInvocation_rejects_missing_required_field()
|
||||
{
|
||||
var fields = new Dictionary<string, string> { ["type"] = "task.run" };
|
||||
Assert.Throws<InvalidOperationException>(() => TaskInvocation.FromFields(fields));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskResultMessage_round_trips_result_fields()
|
||||
{
|
||||
var inv = Invocation.For("shell", "a.sh", input: "{\"x\":1}");
|
||||
var result = new ExecutionResult(
|
||||
true, 0, "out", "err", "{\"ok\":true}", null, TimeSpan.FromMilliseconds(123));
|
||||
|
||||
var fields = TaskResultMessage.ToFields(inv, result);
|
||||
|
||||
Assert.Equal("task.result", fields["type"]);
|
||||
Assert.Equal("run-1", fields["run_id"]);
|
||||
Assert.Equal("succeeded", fields["status"]);
|
||||
Assert.Equal("0", fields["exit_code"]);
|
||||
Assert.Equal("{\"ok\":true}", fields["output"]);
|
||||
Assert.False(fields.ContainsKey("error"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TaskResultMessage_includes_error_on_failure()
|
||||
{
|
||||
var inv = Invocation.For("shell", "a.sh");
|
||||
var result = new ExecutionResult(
|
||||
false, 3, string.Empty, "boom", null, "boom", TimeSpan.FromMilliseconds(10));
|
||||
|
||||
var fields = TaskResultMessage.ToFields(inv, result);
|
||||
|
||||
Assert.Equal("failed", fields["status"]);
|
||||
Assert.Equal("3", fields["exit_code"]);
|
||||
Assert.Equal("boom", fields["error"]);
|
||||
Assert.False(fields.ContainsKey("output"));
|
||||
}
|
||||
}
|
||||
130
w4c-workflows-api.Tests/Fakes.cs
Normal file
130
w4c-workflows-api.Tests/Fakes.cs
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
using w4c_workflows.Services;
|
||||
using w4c_workflows.Services.Messaging;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
/// <summary>In-memory <see cref="IJobQueue"/> capturing enqueued <c>task.run</c> fields.</summary>
|
||||
internal sealed class FakeJobQueue : IJobQueue
|
||||
{
|
||||
public List<(string TenantId, IReadOnlyDictionary<string, string> Fields)> Enqueued { get; } = new();
|
||||
public List<(string TenantId, IReadOnlyDictionary<string, string> Fields, string Reason)> DeadLettered { get; } = new();
|
||||
|
||||
public Task EnsureGroupAsync(string tenantId, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<string> EnqueueAsync(string tenantId, IReadOnlyDictionary<string, string> fields, CancellationToken ct)
|
||||
{
|
||||
Enqueued.Add((tenantId, fields));
|
||||
return Task.FromResult(Enqueued.Count.ToString());
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StreamMessage>> ReadGroupAsync(string tenantId, string consumer, int count, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<StreamMessage>>(Array.Empty<StreamMessage>());
|
||||
|
||||
public Task<IReadOnlyList<StreamMessage>> ClaimPendingAsync(string tenantId, string consumer, TimeSpan minIdle, int count, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<StreamMessage>>(Array.Empty<StreamMessage>());
|
||||
|
||||
public Task<long> AckAsync(string tenantId, string messageId, CancellationToken ct) => Task.FromResult(0L);
|
||||
|
||||
public Task DeadLetterAsync(string tenantId, string messageId, IReadOnlyDictionary<string, string> fields, string reason, CancellationToken ct)
|
||||
{
|
||||
DeadLettered.Add((tenantId, fields, reason));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task PublishDeadLetterAsync(string tenantId, IReadOnlyDictionary<string, string> fields, string reason, CancellationToken ct)
|
||||
{
|
||||
DeadLettered.Add((tenantId, fields, reason));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public List<IReadOnlyDictionary<string, string>> EnqueuedFor(string tenantId)
|
||||
=> Enqueued.Where(x => x.TenantId == tenantId).Select(x => x.Fields).ToList();
|
||||
|
||||
public List<(IReadOnlyDictionary<string, string> Fields, string Reason)> DeadLetteredFor(string tenantId)
|
||||
=> DeadLettered.Where(x => x.TenantId == tenantId).Select(x => (x.Fields, x.Reason)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="IEventBus"/>. Stream names resolve the <c>{tenant}</c>
|
||||
/// placeholder exactly like the real Redis transport; tests seed result messages
|
||||
/// with <see cref="SeedResultAsync"/> and the engine reads + acks them.
|
||||
/// </summary>
|
||||
internal sealed class FakeEventBus : IEventBus
|
||||
{
|
||||
private readonly Dictionary<string, Queue<StreamMessage>> _messages = new();
|
||||
private int _nextId = 1;
|
||||
|
||||
public List<string> Acked { get; } = new();
|
||||
public List<(string TenantId, string Stream, IReadOnlyDictionary<string, string> Fields)> Published { get; } = new();
|
||||
|
||||
public void SeedResult(string tenantId, IReadOnlyDictionary<string, string> fields)
|
||||
=> Seed(Streams.Results.Replace("{tenant}", tenantId), fields);
|
||||
|
||||
public void Seed(string streamKey, IReadOnlyDictionary<string, string> fields)
|
||||
{
|
||||
if (!_messages.TryGetValue(streamKey, out var queue))
|
||||
{
|
||||
queue = new Queue<StreamMessage>();
|
||||
_messages[streamKey] = queue;
|
||||
}
|
||||
queue.Enqueue(new StreamMessage((_nextId++).ToString(), fields));
|
||||
}
|
||||
|
||||
private static string Resolve(string stream, string tenantId) => stream.Replace("{tenant}", tenantId);
|
||||
|
||||
public Task<string> PublishAsync(string tenantId, string stream, IReadOnlyDictionary<string, string> fields, CancellationToken ct)
|
||||
{
|
||||
Published.Add((tenantId, stream, fields));
|
||||
return Task.FromResult((_nextId++).ToString());
|
||||
}
|
||||
|
||||
public Task EnsureGroupAsync(string tenantId, string stream, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<IReadOnlyList<StreamMessage>> ReadGroupAsync(string tenantId, string stream, string consumer, int count, CancellationToken ct)
|
||||
{
|
||||
var key = Resolve(stream, tenantId);
|
||||
var list = new List<StreamMessage>();
|
||||
if (_messages.TryGetValue(key, out var queue))
|
||||
while (list.Count < count && queue.Count > 0)
|
||||
list.Add(queue.Dequeue());
|
||||
return Task.FromResult<IReadOnlyList<StreamMessage>>(list);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StreamMessage>> ClaimPendingAsync(string tenantId, string stream, string consumer, TimeSpan minIdle, int count, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<StreamMessage>>(Array.Empty<StreamMessage>());
|
||||
|
||||
public Task<long> AckAsync(string tenantId, string stream, string messageId, CancellationToken ct)
|
||||
{
|
||||
Acked.Add(messageId);
|
||||
return Task.FromResult(1L);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>In-memory <see cref="ILeaseService"/> keyed by tenant + run id.</summary>
|
||||
internal sealed class FakeLeaseService : ILeaseService
|
||||
{
|
||||
private readonly Dictionary<string, string> _held = new();
|
||||
|
||||
public Task<bool> AcquireAsync(string tenantId, string runId, string owner, TimeSpan ttl)
|
||||
{
|
||||
var key = $"{tenantId}:{runId}";
|
||||
if (_held.ContainsKey(key))
|
||||
return Task.FromResult(false);
|
||||
_held[key] = owner;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> RenewAsync(string tenantId, string runId, string owner, TimeSpan ttl)
|
||||
=> Task.FromResult(_held.TryGetValue($"{tenantId}:{runId}", out var held) && held == owner);
|
||||
|
||||
public Task<bool> ReleaseAsync(string tenantId, string runId, string owner)
|
||||
{
|
||||
var key = $"{tenantId}:{runId}";
|
||||
if (_held.TryGetValue(key, out var held) && held == owner)
|
||||
{
|
||||
_held.Remove(key);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
104
w4c-workflows-api.Tests/MermaidGeneratorServiceTests.cs
Normal file
104
w4c-workflows-api.Tests/MermaidGeneratorServiceTests.cs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class MermaidGeneratorServiceTests
|
||||
{
|
||||
private const string Tenant = "t1";
|
||||
private const string Path = "workflows/main-task.yaml";
|
||||
|
||||
private static readonly string ExampleYaml = """
|
||||
name: main-task
|
||||
mode: handler
|
||||
language: typescript
|
||||
trigger:
|
||||
type: queue
|
||||
stream: orders
|
||||
entry: { file: index.ts, function: handler }
|
||||
tasks:
|
||||
- id: validate
|
||||
parent: root
|
||||
next: enrich
|
||||
onError: compensate
|
||||
language: python
|
||||
entry: { file: validate.py }
|
||||
- id: enrich
|
||||
parent: root
|
||||
next: persist
|
||||
language: csharp
|
||||
entry: { file: Enrich.cs }
|
||||
- id: persist
|
||||
parent: root
|
||||
language: shell
|
||||
entry: { file: persist.sh }
|
||||
- id: compensate
|
||||
parent: root
|
||||
language: python
|
||||
entry: { file: compensate.py }
|
||||
""";
|
||||
|
||||
private static CompiledWorkflow Compile()
|
||||
=> new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry()))
|
||||
.Compile(ExampleYaml, Path, Tenant).Workflow!;
|
||||
|
||||
[Fact]
|
||||
public void Flowchart_renders_next_solid_and_onError_dashed()
|
||||
{
|
||||
var compiled = Compile();
|
||||
var source = new MermaidGeneratorService().Generate("flowchart", compiled.Workflow, compiled.Tasks);
|
||||
|
||||
Assert.Contains("flowchart TD", source);
|
||||
Assert.Contains("root --> validate", source);
|
||||
Assert.Contains("validate --> enrich", source);
|
||||
Assert.Contains("validate -.->|error| compensate", source);
|
||||
Assert.Contains("class root root", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sequence_renders_happy_path_and_alt_error_block()
|
||||
{
|
||||
var compiled = Compile();
|
||||
var source = new MermaidGeneratorService().Generate("sequence", compiled.Workflow, compiled.Tasks);
|
||||
|
||||
Assert.Contains("sequenceDiagram", source);
|
||||
Assert.Contains("root->>validate", source);
|
||||
Assert.Contains("validate->>enrich", source);
|
||||
Assert.Contains("alt on error", source);
|
||||
Assert.Contains("validate->>compensate", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void State_renders_entry_terminal_and_error_transitions()
|
||||
{
|
||||
var compiled = Compile();
|
||||
var source = new MermaidGeneratorService().Generate("state", compiled.Workflow, compiled.Tasks);
|
||||
|
||||
Assert.Contains("stateDiagram-v2", source);
|
||||
Assert.Contains("[*] --> root", source);
|
||||
Assert.Contains("persist --> [*]", source);
|
||||
Assert.Contains("validate --> compensate: error", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Class_renders_language_not_runtime()
|
||||
{
|
||||
var compiled = Compile();
|
||||
var source = new MermaidGeneratorService().Generate("class", compiled.Workflow, compiled.Tasks);
|
||||
|
||||
Assert.Contains("classDiagram", source);
|
||||
Assert.Contains("<<typescript>>", source); // root language
|
||||
Assert.Contains("<<python>>", source); // validate language
|
||||
Assert.Contains("entry: index.ts", source);
|
||||
Assert.DoesNotContain("runtime", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_type_throws()
|
||||
{
|
||||
var compiled = Compile();
|
||||
var generator = new MermaidGeneratorService();
|
||||
Assert.Throws<ArgumentException>(() => generator.Generate("pie", compiled.Workflow, compiled.Tasks));
|
||||
}
|
||||
}
|
||||
86
w4c-workflows-api.Tests/RunLauncherTests.cs
Normal file
86
w4c-workflows-api.Tests/RunLauncherTests.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services;
|
||||
using w4c_workflows.Services.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
[Collection("WorkflowsPostgres")]
|
||||
public class RunLauncherTests
|
||||
{
|
||||
private const string CronYaml = """
|
||||
name: nightly
|
||||
mode: function
|
||||
language: shell
|
||||
trigger:
|
||||
type: cron
|
||||
cron: "0 9 * * *"
|
||||
entry: { file: nightly.sh }
|
||||
""";
|
||||
|
||||
private readonly WorkflowsPostgresFixture _fixture;
|
||||
|
||||
public RunLauncherTests(WorkflowsPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private static Workflow CompileAndSave(WorkflowsDbContext db, string tenantId, string yaml)
|
||||
{
|
||||
var compiler = new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry()));
|
||||
var result = compiler.Compile(yaml, "workflows/nightly.yaml", tenantId);
|
||||
Assert.True(result.Success, string.Join("\n", result.Errors));
|
||||
|
||||
db.Workflows.Add(result.Workflow!.Workflow);
|
||||
db.Tasks.AddRange(result.Workflow.Tasks);
|
||||
db.SaveChanges();
|
||||
return result.Workflow.Workflow;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Launch_creates_a_pending_run_with_trigger_input_and_correlation()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = CompileAndSave(db, tenantId, CronYaml);
|
||||
|
||||
var launcher = new RunLauncher(db, NullLogger<RunLauncher>.Instance);
|
||||
var runId = await launcher.LaunchAsync(
|
||||
new LaunchRequest(tenantId, workflow.Id, workflow.TriggerJson, "{\"x\":1}", "corr-1"), default);
|
||||
|
||||
var run = await db.WorkflowRuns.SingleAsync(r => r.Id == runId);
|
||||
Assert.Equal(workflow.Id, run.WorkflowId);
|
||||
Assert.Equal(tenantId, run.TenantId);
|
||||
Assert.Equal(RunStatus.Pending, run.Status);
|
||||
Assert.Equal(workflow.TriggerJson, run.TriggerJson);
|
||||
Assert.Equal("{\"x\":1}", run.InputJson);
|
||||
Assert.Equal("corr-1", run.CorrelationId);
|
||||
Assert.Null(run.StartedAt);
|
||||
Assert.Null(run.FinishedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Launch_rejects_unknown_workflow()
|
||||
{
|
||||
await using var db = _fixture.CreateContext();
|
||||
var launcher = new RunLauncher(db, NullLogger<RunLauncher>.Instance);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
launcher.LaunchAsync(new LaunchRequest("t", Guid.NewGuid(), null, null, null), default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Launch_rejects_workflow_for_another_tenant()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = CompileAndSave(db, tenantId, CronYaml);
|
||||
|
||||
var launcher = new RunLauncher(db, NullLogger<RunLauncher>.Instance);
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
launcher.LaunchAsync(new LaunchRequest("other-tenant", workflow.Id, null, null, null), default));
|
||||
}
|
||||
}
|
||||
443
w4c-workflows-api.Tests/RunLifecycleEngineTests.cs
Normal file
443
w4c-workflows-api.Tests/RunLifecycleEngineTests.cs
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using w4c_workflows.Services.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
[Collection("WorkflowsPostgres")]
|
||||
public class RunLifecycleEngineTests
|
||||
{
|
||||
private readonly WorkflowsPostgresFixture _fixture;
|
||||
|
||||
public RunLifecycleEngineTests(WorkflowsPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private static RunLifecycleEngine NewEngine(FakeEventBus events, FakeLeaseService leases, int maxRetries = 3, int retryBaseDelayMs = 0)
|
||||
=> new(events, leases,
|
||||
new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Workflows:MaxRetries"] = maxRetries.ToString(),
|
||||
["Workflows:RetryBaseDelayMs"] = retryBaseDelayMs.ToString(),
|
||||
})
|
||||
.Build(),
|
||||
NullLogger<RunLifecycleEngine>.Instance);
|
||||
|
||||
private static TaskDispatcher NewDispatcher(WorkflowsDbContext db, FakeJobQueue jobs)
|
||||
=> new(db, jobs, new ConfigurationBuilder().Build(), NullLogger<TaskDispatcher>.Instance);
|
||||
|
||||
private static IReadOnlyDictionary<string, string> ResultFields(
|
||||
WorkflowRun run, WorkflowTask task, int attempt, bool success, string? output = null, string? error = null)
|
||||
{
|
||||
var invocation = new TaskInvocation(
|
||||
TaskInvocation.TypeValue, run.Id.ToString(), task.Id.ToString(), task.Key,
|
||||
task.Language, "x", null, new Dictionary<string, string>(), null, null, run.TenantId, attempt);
|
||||
var result = new ExecutionResult(success, success ? 0 : 1, string.Empty, string.Empty, output, error, TimeSpan.Zero);
|
||||
return TaskResultMessage.ToFields(invocation, result);
|
||||
}
|
||||
|
||||
/// <summary>The engine is multi-tenant: it scans the whole DB. The shared Postgres
|
||||
/// fixture may contain other tests' tenants, so assert on this tenant's jobs only.</summary>
|
||||
private static List<IReadOnlyDictionary<string, string>> EnqueuedFor(FakeJobQueue jobs, string tenantId)
|
||||
=> jobs.Enqueued.Where(x => x.TenantId == tenantId).Select(x => x.Fields).ToList();
|
||||
|
||||
private static string Tenant() => "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_marks_run_running_and_enqueues_root()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow, "{\"in\":1}");
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var engine = NewEngine(new FakeEventBus(), new FakeLeaseService());
|
||||
|
||||
var dispatched = await engine.DispatchPendingAsync(db, NewDispatcher(db, jobs), default);
|
||||
|
||||
Assert.True(dispatched >= 1);
|
||||
|
||||
var reloaded = await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id);
|
||||
Assert.Equal(RunStatus.Running, reloaded.Status);
|
||||
Assert.NotNull(reloaded.StartedAt);
|
||||
|
||||
var fields = Assert.Single(EnqueuedFor(jobs, tenantId));
|
||||
Assert.Equal("root", fields["task_key"]);
|
||||
Assert.Equal("{\"in\":1}", fields["input"]);
|
||||
Assert.Equal("1", fields["attempt"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Full_chain_runs_to_completion()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow);
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var events = new FakeEventBus();
|
||||
var engine = NewEngine(events, new FakeLeaseService());
|
||||
var dispatcher = NewDispatcher(db, jobs);
|
||||
var store = new DurableStateStore(db);
|
||||
|
||||
await engine.DispatchPendingAsync(db, dispatcher, default);
|
||||
|
||||
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||
var a = WorkflowDataHelpers.TaskByKey(workflow, "a");
|
||||
var b = WorkflowDataHelpers.TaskByKey(workflow, "b");
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, root, 1, true, "{\"out\":1}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
var mine = EnqueuedFor(jobs, tenantId);
|
||||
Assert.Equal(2, mine.Count);
|
||||
Assert.Equal("a", mine[1]["task_key"]);
|
||||
Assert.Equal("{\"out\":1}", mine[1]["input"]);
|
||||
Assert.Equal(RunStatus.Running, (await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id)).Status);
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, a, 1, true, "{\"out\":2}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
mine = EnqueuedFor(jobs, tenantId);
|
||||
Assert.Equal(3, mine.Count);
|
||||
Assert.Equal("b", mine[2]["task_key"]);
|
||||
Assert.Equal("{\"out\":2}", mine[2]["input"]);
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, b, 1, true, "{\"out\":3}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
var final = await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id);
|
||||
Assert.Equal(RunStatus.Succeeded, final.Status);
|
||||
Assert.Equal("{\"out\":3}", final.OutputJson);
|
||||
Assert.NotNull(final.FinishedAt);
|
||||
Assert.Equal(3, EnqueuedFor(jobs, tenantId).Count); // no further dispatch
|
||||
|
||||
var taskRuns = await db.TaskRuns.Where(t => t.RunId == run.Id).ToListAsync();
|
||||
Assert.Equal(3, taskRuns.Count);
|
||||
Assert.All(taskRuns, t => Assert.Equal(TaskRunStatus.Succeeded, t.Status));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_task_without_compensation_is_dead_lettered()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow);
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var events = new FakeEventBus();
|
||||
var engine = NewEngine(events, new FakeLeaseService(), maxRetries: 0);
|
||||
var dispatcher = NewDispatcher(db, jobs);
|
||||
var store = new DurableStateStore(db);
|
||||
|
||||
await engine.DispatchPendingAsync(db, dispatcher, default);
|
||||
|
||||
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||
events.SeedResult(tenantId, ResultFields(run, root, 1, false, error: "boom"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
var final = await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id);
|
||||
Assert.Equal(RunStatus.Dead, final.Status);
|
||||
Assert.Equal("boom", final.Error);
|
||||
Assert.NotNull(final.FinishedAt);
|
||||
|
||||
var rootRun = await db.TaskRuns.SingleAsync(t => t.RunId == run.Id && t.TaskId == root.Id);
|
||||
Assert.Equal(TaskRunStatus.Dead, rootRun.Status);
|
||||
|
||||
var dlq = Assert.Single(jobs.DeadLetteredFor(tenantId));
|
||||
Assert.Equal("max_retries_exceeded", dlq.Reason);
|
||||
Assert.Equal("boom", dlq.Fields["dlq.error"]);
|
||||
Assert.Equal("root", dlq.Fields["task_key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_task_retries_with_backoff_then_dead_letters()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow);
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var events = new FakeEventBus();
|
||||
var engine = NewEngine(events, new FakeLeaseService(), maxRetries: 2);
|
||||
var dispatcher = NewDispatcher(db, jobs);
|
||||
var store = new DurableStateStore(db);
|
||||
|
||||
await engine.DispatchPendingAsync(db, dispatcher, default);
|
||||
|
||||
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||
|
||||
// Attempt 1 fails → retry scheduled; run stays Running.
|
||||
events.SeedResult(tenantId, ResultFields(run, root, 1, false, error: "boom"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
Assert.Equal(RunStatus.Running, (await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id)).Status);
|
||||
Assert.NotNull((await db.TaskRuns.SingleAsync(t => t.RunId == run.Id && t.Attempt == 1)).NextAttemptAt);
|
||||
|
||||
// Retry fires attempt 2.
|
||||
await engine.RetryDueAsync(db, dispatcher, default);
|
||||
Assert.Equal(2, jobs.EnqueuedFor(tenantId).Count);
|
||||
|
||||
// Attempt 2 fails → retry scheduled again.
|
||||
events.SeedResult(tenantId, ResultFields(run, root, 2, false, error: "boom"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
// Retry fires attempt 3 (maxRetries=2 → 3 total attempts).
|
||||
await engine.RetryDueAsync(db, dispatcher, default);
|
||||
Assert.Equal(3, jobs.EnqueuedFor(tenantId).Count);
|
||||
|
||||
// Attempt 3 fails → retries exhausted → dead-letter.
|
||||
events.SeedResult(tenantId, ResultFields(run, root, 3, false, error: "boom"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
Assert.Equal(RunStatus.Dead, (await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id)).Status);
|
||||
Assert.Single(jobs.DeadLetteredFor(tenantId));
|
||||
|
||||
var attempts = await db.TaskRuns.Where(t => t.RunId == run.Id).OrderBy(t => t.Attempt).ToListAsync();
|
||||
Assert.Equal(3, attempts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_task_with_onError_runs_compensation_and_marks_run_failed()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.SagaYaml, "workflows/saga.yaml");
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow);
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var events = new FakeEventBus();
|
||||
var engine = NewEngine(events, new FakeLeaseService(), maxRetries: 0);
|
||||
var dispatcher = NewDispatcher(db, jobs);
|
||||
var store = new DurableStateStore(db);
|
||||
|
||||
await engine.DispatchPendingAsync(db, dispatcher, default);
|
||||
|
||||
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||
var a = WorkflowDataHelpers.TaskByKey(workflow, "a");
|
||||
var compA = WorkflowDataHelpers.TaskByKey(workflow, "compensate_a");
|
||||
|
||||
// root succeeds → dispatches a.
|
||||
events.SeedResult(tenantId, ResultFields(run, root, 1, true, "{\"out\":1}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
// a fails → retries exhausted → run compensates via a.onError.
|
||||
events.SeedResult(tenantId, ResultFields(run, a, 1, false, error: "a boom"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
Assert.Equal(RunStatus.Compensating, (await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id)).Status);
|
||||
Assert.Equal("compensate_a", jobs.EnqueuedFor(tenantId)[^1]["task_key"]);
|
||||
|
||||
// Compensation succeeds → run Failed (compensated).
|
||||
events.SeedResult(tenantId, ResultFields(run, compA, 1, true, "{}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
var final = await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id);
|
||||
Assert.Equal(RunStatus.Failed, final.Status);
|
||||
Assert.Equal("a boom", final.Error);
|
||||
|
||||
var compRun = await db.TaskRuns.SingleAsync(t => t.RunId == run.Id && t.TaskId == compA.Id);
|
||||
Assert.Equal(TaskRunStatus.Compensated, compRun.Status);
|
||||
Assert.True(compRun.IsCompensation);
|
||||
|
||||
Assert.Empty(jobs.DeadLetteredFor(tenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reverse_compensation_walks_succeeded_prefix_in_reverse()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.SagaYaml, "workflows/saga.yaml");
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow);
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var events = new FakeEventBus();
|
||||
var engine = NewEngine(events, new FakeLeaseService(), maxRetries: 0);
|
||||
var dispatcher = NewDispatcher(db, jobs);
|
||||
var store = new DurableStateStore(db);
|
||||
|
||||
await engine.DispatchPendingAsync(db, dispatcher, default);
|
||||
|
||||
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||
var a = WorkflowDataHelpers.TaskByKey(workflow, "a");
|
||||
var b = WorkflowDataHelpers.TaskByKey(workflow, "b");
|
||||
var c = WorkflowDataHelpers.TaskByKey(workflow, "c");
|
||||
|
||||
// Drive the success chain root → a → b → c.
|
||||
events.SeedResult(tenantId, ResultFields(run, root, 1, true, "{\"out\":1}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
events.SeedResult(tenantId, ResultFields(run, a, 1, true, "{\"out\":2}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
events.SeedResult(tenantId, ResultFields(run, b, 1, true, "{\"out\":3}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
// c fails → plan = [compensate_c (own onError), compensate_b, compensate_a].
|
||||
events.SeedResult(tenantId, ResultFields(run, c, 1, false, error: "c boom"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
var compC = WorkflowDataHelpers.TaskByKey(workflow, "compensate_c");
|
||||
var compB = WorkflowDataHelpers.TaskByKey(workflow, "compensate_b");
|
||||
var compA = WorkflowDataHelpers.TaskByKey(workflow, "compensate_a");
|
||||
|
||||
Assert.Equal("compensate_c", jobs.EnqueuedFor(tenantId)[^1]["task_key"]);
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, compC, 1, true, "{}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
Assert.Equal("compensate_b", jobs.EnqueuedFor(tenantId)[^1]["task_key"]);
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, compB, 1, true, "{}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
Assert.Equal("compensate_a", jobs.EnqueuedFor(tenantId)[^1]["task_key"]);
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, compA, 1, true, "{}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
Assert.Equal(RunStatus.Failed, (await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id)).Status);
|
||||
Assert.Empty(jobs.DeadLetteredFor(tenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failed_compensation_dead_letters_the_run()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.SagaYaml, "workflows/saga.yaml");
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow);
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var events = new FakeEventBus();
|
||||
var engine = NewEngine(events, new FakeLeaseService(), maxRetries: 0);
|
||||
var dispatcher = NewDispatcher(db, jobs);
|
||||
var store = new DurableStateStore(db);
|
||||
|
||||
await engine.DispatchPendingAsync(db, dispatcher, default);
|
||||
|
||||
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||
var a = WorkflowDataHelpers.TaskByKey(workflow, "a");
|
||||
var compA = WorkflowDataHelpers.TaskByKey(workflow, "compensate_a");
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, root, 1, true, "{\"out\":1}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, a, 1, false, error: "a boom"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
// The compensation step itself fails → dead-letter.
|
||||
events.SeedResult(tenantId, ResultFields(run, compA, 1, false, error: "compensation boom"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
var final = await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id);
|
||||
Assert.Equal(RunStatus.Dead, final.Status);
|
||||
Assert.Equal("compensation boom", final.Error);
|
||||
|
||||
var compRun = await db.TaskRuns.SingleAsync(t => t.RunId == run.Id && t.TaskId == compA.Id);
|
||||
Assert.Equal(TaskRunStatus.Dead, compRun.Status);
|
||||
|
||||
var dlq = Assert.Single(jobs.DeadLetteredFor(tenantId));
|
||||
Assert.Equal("max_retries_exceeded", dlq.Reason);
|
||||
Assert.Equal("compensation boom", dlq.Fields["dlq.error"]);
|
||||
Assert.Equal("compensate_a", dlq.Fields["task_key"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Durable_run_checkpoints_then_clears_on_completion()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.DurableYaml, "workflows/durable.yaml");
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow);
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var events = new FakeEventBus();
|
||||
var engine = NewEngine(events, new FakeLeaseService());
|
||||
var dispatcher = NewDispatcher(db, jobs);
|
||||
var store = new DurableStateStore(db);
|
||||
|
||||
await engine.DispatchPendingAsync(db, dispatcher, default);
|
||||
|
||||
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||
var a = WorkflowDataHelpers.TaskByKey(workflow, "a");
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, root, 1, true, "{\"s\":1}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
// Checkpoint recorded: instance = run id, next task = a, input = root output.
|
||||
var checkpoint = await db.DurableStates.SingleAsync(d => d.InstanceId == run.Id.ToString());
|
||||
Assert.Equal(a.Id, checkpoint.TaskId);
|
||||
Assert.Equal("{\"s\":1}", checkpoint.StateJson);
|
||||
Assert.Equal("test-correlation", checkpoint.CorrelationId);
|
||||
|
||||
events.SeedResult(tenantId, ResultFields(run, a, 1, true, "{\"s\":2}"));
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
var final = await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id);
|
||||
Assert.Equal(RunStatus.Succeeded, final.Status);
|
||||
Assert.False(await db.DurableStates.AnyAsync(d => d.InstanceId == run.Id.ToString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resume_dispatches_the_start_task_instead_of_root()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
var a = WorkflowDataHelpers.TaskByKey(workflow, "a");
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow, "{\"resume\":true}", a.Id);
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var engine = NewEngine(new FakeEventBus(), new FakeLeaseService());
|
||||
|
||||
await engine.DispatchPendingAsync(db, NewDispatcher(db, jobs), default);
|
||||
|
||||
var fields = Assert.Single(EnqueuedFor(jobs, tenantId));
|
||||
Assert.Equal("a", fields["task_key"]);
|
||||
Assert.Equal("{\"resume\":true}", fields["input"]);
|
||||
|
||||
var reloaded = await db.WorkflowRuns.SingleAsync(r => r.Id == run.Id);
|
||||
Assert.Equal(RunStatus.Running, reloaded.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_result_is_ignored()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow);
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var events = new FakeEventBus();
|
||||
var engine = NewEngine(events, new FakeLeaseService());
|
||||
var dispatcher = NewDispatcher(db, jobs);
|
||||
var store = new DurableStateStore(db);
|
||||
|
||||
await engine.DispatchPendingAsync(db, dispatcher, default);
|
||||
|
||||
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||
var rootResult = ResultFields(run, root, 1, true, "{\"out\":1}");
|
||||
|
||||
events.SeedResult(tenantId, rootResult);
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
Assert.Equal(2, EnqueuedFor(jobs, tenantId).Count); // root + a
|
||||
|
||||
// Replay the exact same root result — must be a no-op.
|
||||
events.SeedResult(tenantId, rootResult);
|
||||
await engine.ProcessResultsAsync(db, dispatcher, store, default);
|
||||
|
||||
Assert.Equal(2, EnqueuedFor(jobs, tenantId).Count); // no new dispatch
|
||||
var rootRun = await db.TaskRuns.SingleAsync(t => t.TaskId == root.Id);
|
||||
Assert.Equal(TaskRunStatus.Succeeded, rootRun.Status);
|
||||
}
|
||||
}
|
||||
124
w4c-workflows-api.Tests/RunsControllerTests.cs
Normal file
124
w4c-workflows-api.Tests/RunsControllerTests.cs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using w4c_workflows.Controllers;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
[Collection("WorkflowsPostgres")]
|
||||
public class RunsControllerTests
|
||||
{
|
||||
private readonly WorkflowsPostgresFixture _fixture;
|
||||
|
||||
public RunsControllerTests(WorkflowsPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private static RunsController Controller(WorkflowsDbContext db, string tenantId)
|
||||
{
|
||||
var controller = new RunsController(db)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
controller.HttpContext.Items["TenantId"] = tenantId;
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static string Tenant() => "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
|
||||
[Fact]
|
||||
public async Task List_returns_runs_newest_first_scoped_to_tenant()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
|
||||
var older = WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Succeeded,
|
||||
startedAt: DateTime.UtcNow.AddMinutes(-10), finishedAt: DateTime.UtcNow.AddMinutes(-9));
|
||||
var newer = WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Failed,
|
||||
startedAt: DateTime.UtcNow.AddMinutes(-1), finishedAt: DateTime.UtcNow, error: "boom");
|
||||
|
||||
// A second tenant's runs must never leak into the response.
|
||||
var other = Tenant();
|
||||
var otherWorkflow = WorkflowDataHelpers.CompileAndSave(db, other, WorkflowDataHelpers.LinearYaml);
|
||||
WorkflowDataHelpers.AddRun(db, otherWorkflow, RunStatus.Running, startedAt: DateTime.UtcNow);
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(await Controller(db, tenantId).List(null, 50, 0, default));
|
||||
var body = Assert.IsType<RunsController.RunListResponse>(result.Value);
|
||||
|
||||
Assert.Equal(2, body.Total);
|
||||
Assert.Equal(new[] { newer.Id, older.Id }, body.Runs.Select(r => r.Id).ToArray());
|
||||
Assert.Equal("linear", body.Runs[0].WorkflowName);
|
||||
Assert.Equal("boom", body.Runs[0].Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task List_filters_by_workflow()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var wf1 = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml, "workflows/a.yaml");
|
||||
var wf2 = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.DurableYaml, "workflows/b.yaml");
|
||||
|
||||
WorkflowDataHelpers.AddRun(db, wf1, RunStatus.Succeeded, DateTime.UtcNow);
|
||||
WorkflowDataHelpers.AddRun(db, wf2, RunStatus.Failed, DateTime.UtcNow);
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(await Controller(db, tenantId).List(wf1.Id, 50, 0, default));
|
||||
var body = Assert.IsType<RunsController.RunListResponse>(result.Value);
|
||||
|
||||
Assert.Equal(1, body.Total);
|
||||
Assert.Equal(wf1.Id, body.Runs[0].WorkflowId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_returns_detail_and_404s_for_foreign_run()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
var run = WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Succeeded,
|
||||
DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow, error: null);
|
||||
|
||||
var controller = Controller(db, tenantId);
|
||||
var result = Assert.IsType<OkObjectResult>(await controller.Get(run.Id, default));
|
||||
var detail = Assert.IsType<RunsController.RunDetail>(result.Value);
|
||||
Assert.Equal(run.Id, detail.Id);
|
||||
Assert.Equal(workflow.Id, detail.WorkflowId);
|
||||
Assert.Equal("linear", detail.WorkflowName);
|
||||
|
||||
// A run owned by another tenant is not visible.
|
||||
var other = Tenant();
|
||||
var otherWorkflow = WorkflowDataHelpers.CompileAndSave(db, other, WorkflowDataHelpers.LinearYaml);
|
||||
var foreignRun = WorkflowDataHelpers.AddRun(db, otherWorkflow, RunStatus.Succeeded, DateTime.UtcNow);
|
||||
Assert.IsType<NotFoundObjectResult>(await controller.Get(foreignRun.Id, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tasks_returns_task_runs_in_dispatch_order()
|
||||
{
|
||||
var tenantId = Tenant();
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
var run = WorkflowDataHelpers.AddRun(db, workflow, RunStatus.Running, DateTime.UtcNow);
|
||||
|
||||
var root = WorkflowDataHelpers.TaskByKey(workflow, "root");
|
||||
var a = WorkflowDataHelpers.TaskByKey(workflow, "a");
|
||||
db.TaskRuns.AddRange(
|
||||
new TaskRun { Id = Guid.NewGuid(), RunId = run.Id, TaskId = root.Id, Attempt = 1, Status = TaskRunStatus.Succeeded, StartedAt = DateTime.UtcNow.AddMinutes(-2), FinishedAt = DateTime.UtcNow.AddMinutes(-1) },
|
||||
new TaskRun { Id = Guid.NewGuid(), RunId = run.Id, TaskId = a.Id, Attempt = 1, Status = TaskRunStatus.Failed, Error = "boom", IsCompensation = true, StartedAt = DateTime.UtcNow.AddMinutes(-1), FinishedAt = DateTime.UtcNow });
|
||||
db.SaveChanges();
|
||||
|
||||
var result = Assert.IsType<OkObjectResult>(await Controller(db, tenantId).Tasks(run.Id, default));
|
||||
var tasks = Assert.IsType<List<RunsController.TaskRunSummary>>(result.Value);
|
||||
|
||||
Assert.Equal(2, tasks.Count);
|
||||
Assert.Equal("root", tasks[0].TaskKey);
|
||||
Assert.Equal("a", tasks[1].TaskKey);
|
||||
Assert.True(tasks[1].IsCompensation);
|
||||
Assert.Equal("boom", tasks[1].Error);
|
||||
}
|
||||
}
|
||||
57
w4c-workflows-api.Tests/RuntimeRegistryTests.cs
Normal file
57
w4c-workflows-api.Tests/RuntimeRegistryTests.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using Microsoft.Extensions.Configuration;
|
||||
using w4c_workflows.Services;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class RuntimeRegistryTests
|
||||
{
|
||||
private static IConfiguration Config => new ConfigurationBuilder().Build();
|
||||
|
||||
private static RuntimeRegistry NewRegistry()
|
||||
{
|
||||
IScriptExecutor[] executors =
|
||||
{
|
||||
new SubprocessScriptExecutor("shell", "sh", Config),
|
||||
new SubprocessScriptExecutor("python", "python3", Config),
|
||||
new SubprocessScriptExecutor("javascript", "node", Config),
|
||||
new TypeScriptExecutor(Config),
|
||||
new CSharpScriptExecutor(),
|
||||
};
|
||||
return new RuntimeRegistry(new LanguageRegistry(), executors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Lists_all_six_languages_in_declared_order()
|
||||
{
|
||||
// W9 adds the 'agent' step type (executed via chatapi) to the registry.
|
||||
Assert.Equal(
|
||||
new[] { "shell", "javascript", "typescript", "csharp", "python", "agent" },
|
||||
NewRegistry().All.Select(r => r.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolves_every_subprocess_language_to_an_executor()
|
||||
{
|
||||
var registry = NewRegistry();
|
||||
foreach (var id in new[] { "shell", "javascript", "typescript", "csharp", "python" })
|
||||
Assert.NotNull(registry.Resolve(id));
|
||||
// 'agent' is executor-backed by DI registration in Program.cs, not this manual array;
|
||||
// its availability depends on the ChatApiUrl config being set.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_unknown_language()
|
||||
{
|
||||
Assert.Null(NewRegistry().Resolve("cobol"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CSharp_runtime_is_always_available()
|
||||
{
|
||||
var csharp = NewRegistry().All.Single(r => r.Id == "csharp");
|
||||
Assert.True(csharp.Available);
|
||||
Assert.True(NewRegistry().IsAvailable("csharp"));
|
||||
}
|
||||
}
|
||||
129
w4c-workflows-api.Tests/SubprocessScriptExecutorTests.cs
Normal file
129
w4c-workflows-api.Tests/SubprocessScriptExecutorTests.cs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class SubprocessScriptExecutorTests
|
||||
{
|
||||
private static SubprocessScriptExecutor Shell(int timeoutSeconds = 60)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Workflows:TaskTimeoutSeconds"] = timeoutSeconds.ToString(),
|
||||
})
|
||||
.Build();
|
||||
return new SubprocessScriptExecutor("shell", "sh", config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shell_reads_stdin_and_returns_json_output()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("task.sh", """
|
||||
#!/bin/sh
|
||||
read input
|
||||
echo "{\"ok\":true,\"value\":$input}"
|
||||
""");
|
||||
|
||||
var result = await Shell().ExecuteAsync(
|
||||
Invocation.For("shell", "task.sh", "{\"x\":1}", dir.Path), default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Equal(0, result.ExitCode);
|
||||
using var output = JsonDocument.Parse(result.Output!);
|
||||
Assert.True(output.RootElement.GetProperty("ok").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shell_nonzero_exit_reports_stderr_as_error()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("task.sh", """
|
||||
#!/bin/sh
|
||||
echo "boom" 1>&2
|
||||
exit 3
|
||||
""");
|
||||
|
||||
var result = await Shell().ExecuteAsync(
|
||||
Invocation.For("shell", "task.sh", "{}", dir.Path), default);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(3, result.ExitCode);
|
||||
Assert.Contains("boom", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shell_non_json_stdout_is_wrapped_as_string()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("task.sh", "#!/bin/sh\necho \"hello world\"\n");
|
||||
|
||||
var result = await Shell().ExecuteAsync(
|
||||
Invocation.For("shell", "task.sh", "{}", dir.Path), default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
using var output = JsonDocument.Parse(result.Output!);
|
||||
Assert.Equal("hello world", output.RootElement.GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shell_is_killed_when_it_exceeds_the_time_limit()
|
||||
{
|
||||
using var dir = new TempDir();
|
||||
dir.Write("task.sh", "#!/bin/sh\nsleep 5\n");
|
||||
|
||||
var result = await Shell(timeoutSeconds: 1).ExecuteAsync(
|
||||
Invocation.For("shell", "task.sh", "{}", dir.Path), default);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("time limit", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Python_reads_stdin_and_returns_json_output()
|
||||
{
|
||||
var executor = new SubprocessScriptExecutor("python", "python3", new ConfigurationBuilder().Build());
|
||||
if (!executor.IsAvailable())
|
||||
return; // python3 not installed on this host — availability is covered by RuntimeRegistryTests
|
||||
|
||||
using var dir = new TempDir();
|
||||
dir.Write("task.py", """
|
||||
import sys, json
|
||||
print(json.dumps({"echo": sys.stdin.read().strip()}))
|
||||
""");
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
Invocation.For("python", "task.py", "{\"x\":1}", dir.Path), default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Contains("\\\"x\\\":1", result.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Javascript_runs_under_node()
|
||||
{
|
||||
var executor = new SubprocessScriptExecutor("javascript", "node", new ConfigurationBuilder().Build());
|
||||
if (!executor.IsAvailable())
|
||||
return; // node not installed on this host — availability is covered by RuntimeRegistryTests
|
||||
|
||||
using var dir = new TempDir();
|
||||
dir.Write("task.js", """
|
||||
let input = "";
|
||||
process.stdin.on("data", (c) => (input += c));
|
||||
process.stdin.on("end", () => {
|
||||
const data = JSON.parse(input);
|
||||
console.log(JSON.stringify({ doubled: data.n * 2 }));
|
||||
});
|
||||
""");
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
Invocation.For("javascript", "task.js", "{\"n\":21}", dir.Path), default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
using var output = JsonDocument.Parse(result.Output!);
|
||||
Assert.Equal(42, output.RootElement.GetProperty("doubled").GetInt32());
|
||||
}
|
||||
}
|
||||
54
w4c-workflows-api.Tests/TaskDispatcherTests.cs
Normal file
54
w4c-workflows-api.Tests/TaskDispatcherTests.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using w4c_workflows.Services.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
[Collection("WorkflowsPostgres")]
|
||||
public class TaskDispatcherTests
|
||||
{
|
||||
private readonly WorkflowsPostgresFixture _fixture;
|
||||
|
||||
public TaskDispatcherTests(WorkflowsPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private static TaskDispatcher NewDispatcher(WorkflowsDbContext db, FakeJobQueue jobs)
|
||||
=> new(db, jobs, new ConfigurationBuilder().Build(), NullLogger<TaskDispatcher>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Dispatch_creates_task_run_and_enqueues_job()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
var run = WorkflowDataHelpers.AddPendingRun(db, workflow, "{\"input\":1}");
|
||||
|
||||
var jobs = new FakeJobQueue();
|
||||
var dispatcher = NewDispatcher(db, jobs);
|
||||
|
||||
var taskRunId = await dispatcher.DispatchAsync(
|
||||
run, WorkflowDataHelpers.TaskByKey(workflow, "root"), workflow.Path, run.InputJson, 1, default);
|
||||
|
||||
// The TaskRun is persisted before the job is enqueued.
|
||||
var taskRun = await db.TaskRuns.SingleAsync(t => t.Id == taskRunId);
|
||||
Assert.Equal(run.Id, taskRun.RunId);
|
||||
Assert.Equal(TaskRunStatus.Running, taskRun.Status);
|
||||
Assert.Equal(1, taskRun.Attempt);
|
||||
Assert.Equal("{\"input\":1}", taskRun.InputJson);
|
||||
|
||||
var (tenant, fields) = Assert.Single(jobs.Enqueued);
|
||||
Assert.Equal(tenantId, tenant);
|
||||
Assert.Equal(TaskInvocation.TypeValue, fields["type"]);
|
||||
Assert.Equal(run.Id.ToString(), fields["run_id"]);
|
||||
Assert.Equal("root", fields["task_key"]);
|
||||
Assert.Equal("root.sh", fields["entry_file"]);
|
||||
Assert.EndsWith("workflows", fields["working_dir"]);
|
||||
}
|
||||
}
|
||||
47
w4c-workflows-api.Tests/TaskResultTests.cs
Normal file
47
w4c-workflows-api.Tests/TaskResultTests.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using w4c_workflows.Services.Execution;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class TaskResultTests
|
||||
{
|
||||
private static TaskInvocation Invocation() => new(
|
||||
TaskInvocation.TypeValue, "run-1", "task-1", "root",
|
||||
"shell", "a.sh", null, new Dictionary<string, string>(), "{}", null, "tenant-1", 1);
|
||||
|
||||
[Fact]
|
||||
public void Parses_success_fields_published_by_the_worker()
|
||||
{
|
||||
var result = new ExecutionResult(true, 0, "stdout", string.Empty, "{\"ok\":true}", null, TimeSpan.FromMilliseconds(42));
|
||||
var parsed = TaskResult.FromFields(TaskResultMessage.ToFields(Invocation(), result));
|
||||
|
||||
Assert.True(parsed.Success);
|
||||
Assert.Equal("run-1", parsed.RunId);
|
||||
Assert.Equal("task-1", parsed.TaskId);
|
||||
Assert.Equal(1, parsed.Attempt);
|
||||
Assert.Equal("tenant-1", parsed.TenantId);
|
||||
Assert.Equal(0, parsed.ExitCode);
|
||||
Assert.Equal("{\"ok\":true}", parsed.Output);
|
||||
Assert.Null(parsed.Error);
|
||||
Assert.Equal(42, parsed.DurationMs);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_failure_fields_with_error()
|
||||
{
|
||||
var result = new ExecutionResult(false, 1, string.Empty, "boom", null, "boom", TimeSpan.Zero);
|
||||
var parsed = TaskResult.FromFields(TaskResultMessage.ToFields(Invocation(), result));
|
||||
|
||||
Assert.False(parsed.Success);
|
||||
Assert.Equal(1, parsed.ExitCode);
|
||||
Assert.Equal("boom", parsed.Error);
|
||||
Assert.Null(parsed.Output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throws_when_required_fields_are_missing()
|
||||
{
|
||||
var fields = new Dictionary<string, string> { ["type"] = "task.result" };
|
||||
Assert.Throws<InvalidOperationException>(() => TaskResult.FromFields(fields));
|
||||
}
|
||||
}
|
||||
86
w4c-workflows-api.Tests/TaskRunMessageTests.cs
Normal file
86
w4c-workflows-api.Tests/TaskRunMessageTests.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class TaskRunMessageTests
|
||||
{
|
||||
private static WorkflowRun NewRun() => new()
|
||||
{
|
||||
Id = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
WorkflowId = Guid.NewGuid(),
|
||||
TenantId = "t1",
|
||||
Status = RunStatus.Running,
|
||||
};
|
||||
|
||||
private static WorkflowTask NewTask() => new()
|
||||
{
|
||||
Id = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
WorkflowId = Guid.NewGuid(),
|
||||
Key = "root",
|
||||
Language = "shell",
|
||||
Mode = WorkflowMode.Function,
|
||||
EntryJson = "{\"file\":\"root.sh\",\"function\":\"main\"}",
|
||||
EnvJson = "{\"FOO\":\"bar\"}",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Serializes_fields_the_worker_can_parse_back()
|
||||
{
|
||||
var fields = TaskRunMessage.ToFields(
|
||||
NewRun(), NewTask(), "{\"x\":1}", "/repo/workflows", 2);
|
||||
|
||||
var invocation = TaskInvocation.FromFields(fields);
|
||||
|
||||
Assert.Equal(TaskInvocation.TypeValue, invocation.Type);
|
||||
Assert.Equal("11111111-1111-1111-1111-111111111111", invocation.RunId);
|
||||
Assert.Equal("22222222-2222-2222-2222-222222222222", invocation.TaskId);
|
||||
Assert.Equal("root", invocation.TaskKey);
|
||||
Assert.Equal("shell", invocation.Language);
|
||||
Assert.Equal("root.sh", invocation.EntryFile);
|
||||
Assert.Equal("main", invocation.EntryFunction);
|
||||
Assert.Equal("{\"x\":1}", invocation.Input);
|
||||
Assert.Equal("/repo/workflows", invocation.WorkingDir);
|
||||
Assert.Equal("t1", invocation.TenantId);
|
||||
Assert.Equal(2, invocation.Attempt);
|
||||
Assert.Equal("bar", invocation.Env["FOO"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Omits_optional_fields_when_absent()
|
||||
{
|
||||
var task = NewTask();
|
||||
task.EntryJson = "{\"file\":\"a.sh\"}"; // no function
|
||||
task.EnvJson = null;
|
||||
|
||||
var fields = TaskRunMessage.ToFields(NewRun(), task, null, null, 1);
|
||||
var invocation = TaskInvocation.FromFields(fields);
|
||||
|
||||
Assert.Null(invocation.EntryFunction);
|
||||
Assert.Null(invocation.Input);
|
||||
Assert.Null(invocation.WorkingDir);
|
||||
Assert.Empty(invocation.Env);
|
||||
Assert.Equal(1, invocation.Attempt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Carries_server_reference_when_set_and_omits_when_absent()
|
||||
{
|
||||
// S8: a task targeting a managed server serializes the server id through the
|
||||
// wire contract so the worker can execute it remotely instead of locally.
|
||||
var withServer = NewTask();
|
||||
withServer.Server = "srv-07";
|
||||
var fields = TaskRunMessage.ToFields(NewRun(), withServer, null, null, 1);
|
||||
|
||||
Assert.Equal("srv-07", fields["server"]);
|
||||
var invocation = TaskInvocation.FromFields(fields);
|
||||
Assert.Equal("srv-07", invocation.Server);
|
||||
|
||||
// No server field => local subprocess path (the default) => null.
|
||||
var withoutServer = NewTask();
|
||||
var bareFields = TaskRunMessage.ToFields(NewRun(), withoutServer, null, null, 1);
|
||||
Assert.False(bareFields.ContainsKey("server"));
|
||||
Assert.Null(TaskInvocation.FromFields(bareFields).Server);
|
||||
}
|
||||
}
|
||||
54
w4c-workflows-api.Tests/TestHelpers.cs
Normal file
54
w4c-workflows-api.Tests/TestHelpers.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
using w4c_workflows.Services.Execution;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
/// <summary>A self-cleaning temp directory for executor tests.</summary>
|
||||
internal sealed class TempDir : IDisposable
|
||||
{
|
||||
public string Path { get; }
|
||||
|
||||
public TempDir()
|
||||
{
|
||||
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "wf-test-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(Path);
|
||||
}
|
||||
|
||||
public string Write(string relativePath, string content)
|
||||
{
|
||||
var full = System.IO.Path.Combine(Path, relativePath);
|
||||
var dir = System.IO.Path.GetDirectoryName(full);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(full, content);
|
||||
return full;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(Path, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Invocation
|
||||
{
|
||||
/// <summary>Builds a minimal task invocation for a single-file language.</summary>
|
||||
public static TaskInvocation For(
|
||||
string language,
|
||||
string entryFile,
|
||||
string? input = null,
|
||||
string? workingDir = null,
|
||||
string? entryFunction = null)
|
||||
=> new(
|
||||
TaskInvocation.TypeValue,
|
||||
"run-1",
|
||||
"task-1",
|
||||
"task-key",
|
||||
language,
|
||||
entryFile,
|
||||
entryFunction,
|
||||
new Dictionary<string, string>(),
|
||||
input,
|
||||
workingDir,
|
||||
"tenant-1",
|
||||
1);
|
||||
}
|
||||
66
w4c-workflows-api.Tests/TriggerDueTests.cs
Normal file
66
w4c-workflows-api.Tests/TriggerDueTests.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Triggers;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class TriggerDueTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 28, 14, 30, 0, TimeSpan.Zero);
|
||||
|
||||
private static TriggerSpec Interval(string interval) =>
|
||||
TriggerSpec.Parse($"{{\"type\":\"interval\",\"interval\":\"{interval}\"}}", out _)!;
|
||||
|
||||
private static TriggerSpec Cron(string expr) =>
|
||||
TriggerSpec.Parse($"{{\"type\":\"cron\",\"cron\":\"{expr}\"}}", out _)!;
|
||||
|
||||
[Fact]
|
||||
public void Fires_on_first_discovery_when_no_last_fire()
|
||||
{
|
||||
Assert.True(TriggerDue.IsDue(Interval("30m"), null, Now));
|
||||
Assert.True(TriggerDue.IsDue(Cron("0 9 * * *"), null, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interval_not_due_before_elapse()
|
||||
{
|
||||
var lastFire = Now.AddMinutes(-10);
|
||||
Assert.False(TriggerDue.IsDue(Interval("30m"), lastFire, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interval_due_once_elapsed()
|
||||
{
|
||||
var lastFire = Now.AddMinutes(-31);
|
||||
Assert.True(TriggerDue.IsDue(Interval("30m"), lastFire, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Interval_zero_is_never_due()
|
||||
{
|
||||
var spec = new TriggerSpec(TriggerType.Interval, null, TimeSpan.Zero, null, null);
|
||||
Assert.False(TriggerDue.IsDue(spec, Now.AddHours(-1), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cron_due_when_next_occurrence_has_passed()
|
||||
{
|
||||
// 9:00 daily; last fired yesterday 09:00, now past today's 09:00.
|
||||
var lastFire = new DateTimeOffset(2026, 8, 27, 9, 0, 0, TimeSpan.Zero);
|
||||
Assert.True(TriggerDue.IsDue(Cron("0 9 * * *"), lastFire, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cron_not_due_when_next_occurrence_in_future()
|
||||
{
|
||||
var lastFire = Now.AddMinutes(-1); // fired 1 minute ago; next daily 09:00 is future
|
||||
Assert.False(TriggerDue.IsDue(Cron("0 9 * * *"), lastFire, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_type_is_never_due()
|
||||
{
|
||||
var spec = new TriggerSpec("webhook", null, null, "/h/x", null);
|
||||
Assert.False(TriggerDue.IsDue(spec, Now.AddHours(-1), Now));
|
||||
}
|
||||
}
|
||||
86
w4c-workflows-api.Tests/TriggerSpecTests.cs
Normal file
86
w4c-workflows-api.Tests/TriggerSpecTests.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services.Triggers;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class TriggerSpecTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parses_cron_trigger()
|
||||
{
|
||||
var spec = TriggerSpec.Parse("{\"type\":\"cron\",\"cron\":\"0 */6 * * *\"}", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.NotNull(spec);
|
||||
Assert.Equal(TriggerType.Cron, spec!.Type);
|
||||
Assert.Equal("0 */6 * * *", spec.Cron);
|
||||
Assert.Null(spec.Interval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_interval_trigger_into_a_timespan()
|
||||
{
|
||||
var spec = TriggerSpec.Parse("{\"type\":\"interval\",\"interval\":\"30m\"}", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.NotNull(spec);
|
||||
Assert.Equal(TriggerType.Interval, spec!.Type);
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), spec.Interval);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_webhook_trigger()
|
||||
{
|
||||
var spec = TriggerSpec.Parse("{\"type\":\"webhook\",\"webhookPath\":\"/h/orders\"}", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Equal("/h/orders", spec!.WebhookPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parses_queue_trigger_with_stream()
|
||||
{
|
||||
var spec = TriggerSpec.Parse("{\"type\":\"queue\",\"stream\":\"orders\"}", out var error);
|
||||
|
||||
Assert.Null(error);
|
||||
Assert.Equal(TriggerType.Queue, spec!.Type);
|
||||
Assert.Equal("orders", spec.Stream);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_json_returns_null_without_error()
|
||||
{
|
||||
var spec = TriggerSpec.Parse(null, out var error);
|
||||
|
||||
Assert.Null(spec);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Invalid_interval_returns_null_with_error()
|
||||
{
|
||||
var spec = TriggerSpec.Parse("{\"type\":\"interval\",\"interval\":\"soon\"}", out var error);
|
||||
|
||||
Assert.Null(spec);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_json_returns_null_with_error()
|
||||
{
|
||||
var spec = TriggerSpec.Parse("{not json", out var error);
|
||||
|
||||
Assert.Null(spec);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_type_returns_null_with_error()
|
||||
{
|
||||
var spec = TriggerSpec.Parse("{\"cron\":\"0 9 * * *\"}", out var error);
|
||||
|
||||
Assert.Null(spec);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
}
|
||||
36
w4c-workflows-api.Tests/TypeScriptExecutorTests.cs
Normal file
36
w4c-workflows-api.Tests/TypeScriptExecutorTests.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using w4c_workflows.Services.Execution;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class TypeScriptExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Bundles_entry_with_imports_and_runs_under_node()
|
||||
{
|
||||
var executor = new TypeScriptExecutor(new ConfigurationBuilder().Build());
|
||||
if (!executor.IsAvailable())
|
||||
return; // esbuild/node not installed on this host — availability is covered by RuntimeRegistryTests
|
||||
|
||||
using var dir = new TempDir();
|
||||
dir.Write("helper.ts", "export function double(n: number): number { return n * 2; }\n");
|
||||
dir.Write("task.ts", """
|
||||
import { double } from "./helper";
|
||||
let input = "";
|
||||
process.stdin.on("data", (c) => (input += c));
|
||||
process.stdin.on("end", () => {
|
||||
const data = JSON.parse(input);
|
||||
console.log(JSON.stringify({ doubled: double(data.n) }));
|
||||
});
|
||||
""");
|
||||
|
||||
var result = await executor.ExecuteAsync(
|
||||
Invocation.For("typescript", "task.ts", "{\"n\":21}", dir.Path), default);
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
using var output = JsonDocument.Parse(result.Output!);
|
||||
Assert.Equal(42, output.RootElement.GetProperty("doubled").GetInt32());
|
||||
}
|
||||
}
|
||||
293
w4c-workflows-api.Tests/WorkflowCompilerTests.cs
Normal file
293
w4c-workflows-api.Tests/WorkflowCompilerTests.cs
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
public class WorkflowCompilerTests
|
||||
{
|
||||
private const string Tenant = "t1";
|
||||
private const string Path = "workflows/main-task.yaml";
|
||||
|
||||
private static readonly string ExampleYaml = """
|
||||
name: main-task
|
||||
description: Root handler consuming order events
|
||||
mode: handler
|
||||
language: typescript
|
||||
target: default
|
||||
trigger:
|
||||
type: queue
|
||||
stream: orders
|
||||
entry: { file: index.ts, function: handler }
|
||||
env: { FOO: "bar" }
|
||||
tasks:
|
||||
- id: validate
|
||||
parent: root
|
||||
next: enrich
|
||||
onError: compensate
|
||||
language: python
|
||||
entry: { file: validate.py }
|
||||
- id: enrich
|
||||
parent: root
|
||||
next: persist
|
||||
language: csharp
|
||||
entry: { file: Enrich.cs }
|
||||
- id: persist
|
||||
parent: root
|
||||
language: shell
|
||||
entry: { file: persist.sh }
|
||||
- id: compensate
|
||||
parent: root
|
||||
language: python
|
||||
entry: { file: compensate.py }
|
||||
""";
|
||||
|
||||
private static WorkflowCompiler NewCompiler()
|
||||
=> new(new WorkflowValidator(new LanguageRegistry()));
|
||||
|
||||
private static Guid TaskId(string id) => DeterministicGuid.For($"task::{Tenant}::{Path}::{id}");
|
||||
|
||||
[Fact]
|
||||
public void Compiles_plan_example_with_correct_flow()
|
||||
{
|
||||
var result = NewCompiler().Compile(ExampleYaml, Path, Tenant);
|
||||
|
||||
Assert.True(result.Success, string.Join("\n", result.Errors));
|
||||
var wf = result.Workflow!.Workflow;
|
||||
var tasks = result.Workflow!.Tasks;
|
||||
|
||||
Assert.Equal("main-task", wf.Name);
|
||||
Assert.Equal(WorkflowMode.Handler, wf.Mode);
|
||||
Assert.Equal("compiled", wf.Status);
|
||||
|
||||
// 1 root + 4 declared tasks.
|
||||
Assert.Equal(5, tasks.Count);
|
||||
|
||||
var root = tasks.Single(t => t.Order == 0);
|
||||
var validate = tasks.Single(t => t.Id == TaskId("validate"));
|
||||
var enrich = tasks.Single(t => t.Id == TaskId("enrich"));
|
||||
var persist = tasks.Single(t => t.Id == TaskId("persist"));
|
||||
var compensate = tasks.Single(t => t.Id == TaskId("compensate"));
|
||||
|
||||
Assert.Null(root.ParentId);
|
||||
Assert.Equal(validate.Id, root.NextId);
|
||||
|
||||
Assert.Equal(root.Id, validate.ParentId);
|
||||
Assert.Equal(enrich.Id, validate.NextId);
|
||||
Assert.Equal(compensate.Id, validate.OnErrorId);
|
||||
|
||||
Assert.Equal(enrich.NextId, persist.Id);
|
||||
Assert.Null(persist.NextId); // terminal
|
||||
|
||||
// Compensation task is organizational (parent: root), not on the chain.
|
||||
Assert.Equal(root.Id, compensate.ParentId);
|
||||
Assert.Null(compensate.NextId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compiles_are_idempotent_same_ids_every_time()
|
||||
{
|
||||
var compiler = NewCompiler();
|
||||
var first = compiler.Compile(ExampleYaml, Path, Tenant);
|
||||
var second = compiler.Compile(ExampleYaml, Path, Tenant);
|
||||
|
||||
Assert.Equal(first.Workflow!.Workflow.Id, second.Workflow!.Workflow.Id);
|
||||
Assert.Equal(
|
||||
first.Workflow!.Tasks.Select(t => t.Id).OrderBy(x => x),
|
||||
second.Workflow!.Tasks.Select(t => t.Id).OrderBy(x => x));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compiles_root_only_workflow()
|
||||
{
|
||||
var yaml = """
|
||||
name: ping
|
||||
mode: function
|
||||
language: shell
|
||||
entry: { file: ping.sh }
|
||||
""";
|
||||
|
||||
var result = NewCompiler().Compile(yaml, "workflows/ping.yaml", Tenant);
|
||||
|
||||
Assert.True(result.Success, string.Join("\n", result.Errors));
|
||||
Assert.Single(result.Workflow!.Tasks);
|
||||
Assert.Null(result.Workflow!.Tasks[0].NextId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("", "name is required")]
|
||||
[InlineData("mode: function\nlanguage: shell\nentry: { file: a.sh }", "name is required")]
|
||||
[InlineData("name: x\nlanguage: shell\nentry: { file: a.sh }", "mode is required")]
|
||||
[InlineData("name: x\nmode: function\nentry: { file: a.sh }", "language is required")]
|
||||
[InlineData("name: x\nmode: function\nlanguage: cobol\nentry: { file: a.sh }", "language 'cobol' is not supported")]
|
||||
public void Rejects_invalid_header(string yaml, string expectedError)
|
||||
{
|
||||
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, e => e.Contains(expectedError, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_unknown_next_reference()
|
||||
{
|
||||
var yaml = """
|
||||
name: x
|
||||
mode: function
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
next: ghost
|
||||
entry: { file: a.sh }
|
||||
""";
|
||||
|
||||
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
||||
Assert.Contains(result.Errors, e => e.Contains("next 'ghost' does not reference", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_duplicate_task_ids()
|
||||
{
|
||||
var yaml = """
|
||||
name: x
|
||||
mode: function
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
entry: { file: a.sh }
|
||||
- id: a
|
||||
entry: { file: b.sh }
|
||||
""";
|
||||
|
||||
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
||||
Assert.Contains(result.Errors, e => e.Contains("'a' is duplicated", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_cycle_in_success_chain()
|
||||
{
|
||||
// root -> a -> b -> c -> b (b↔c cycle; b also has 2 incoming edges)
|
||||
var yaml = """
|
||||
name: x
|
||||
mode: function
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
next: b
|
||||
entry: { file: a.sh }
|
||||
- id: b
|
||||
next: c
|
||||
entry: { file: b.sh }
|
||||
- id: c
|
||||
next: b
|
||||
entry: { file: c.sh }
|
||||
""";
|
||||
|
||||
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(result.Errors, e =>
|
||||
e.Contains("cycle", StringComparison.OrdinalIgnoreCase) ||
|
||||
e.Contains("linear", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_handler_mode_with_non_queue_trigger()
|
||||
{
|
||||
var yaml = """
|
||||
name: x
|
||||
mode: handler
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
trigger:
|
||||
type: cron
|
||||
cron: "0 * * * *"
|
||||
""";
|
||||
|
||||
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
||||
Assert.Contains(result.Errors, e => e.Contains("handler mode requires trigger.type = 'queue'", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rejects_unreachable_task()
|
||||
{
|
||||
// a -> b, but d is disconnected (not on the chain, not a compensation target).
|
||||
var yaml = """
|
||||
name: x
|
||||
mode: function
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
next: b
|
||||
entry: { file: a.sh }
|
||||
- id: b
|
||||
entry: { file: b.sh }
|
||||
- id: d
|
||||
parent: root
|
||||
entry: { file: d.sh }
|
||||
""";
|
||||
|
||||
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
||||
Assert.Contains(result.Errors, e => e.Contains("unreachable", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolves_server_reference_from_header_default_and_task_override()
|
||||
{
|
||||
// S8: a workflow-level `server` becomes the default for every task; an explicit
|
||||
// task-level `server` overrides it. Absent everywhere => null (local subprocess).
|
||||
var yaml = """
|
||||
name: srv-flow
|
||||
mode: function
|
||||
language: shell
|
||||
server: srv-default
|
||||
entry: { file: a.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
next: b
|
||||
entry: { file: a.sh }
|
||||
- id: b
|
||||
server: srv-optimized
|
||||
next: c
|
||||
entry: { file: b.sh }
|
||||
- id: c
|
||||
entry: { file: c.sh }
|
||||
""";
|
||||
|
||||
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
||||
|
||||
Assert.True(result.Success, string.Join("\n", result.Errors));
|
||||
var tasks = result.Workflow!.Tasks;
|
||||
|
||||
// Root + 3 declared tasks.
|
||||
var root = tasks.Single(t => t.Order == 0);
|
||||
var a = tasks.Single(t => t.Key == "a");
|
||||
var b = tasks.Single(t => t.Key == "b");
|
||||
var c = tasks.Single(t => t.Key == "c");
|
||||
|
||||
Assert.Equal("srv-default", root.Server); // header default applies to root
|
||||
Assert.Equal("srv-default", a.Server); // header default when no override
|
||||
Assert.Equal("srv-optimized", b.Server); // task-level override wins
|
||||
Assert.Equal("srv-default", c.Server); // header default for a chain task
|
||||
|
||||
// No server anywhere => null keeps the local subprocess default.
|
||||
var bare = NewCompiler().Compile(ExampleYaml, Path, Tenant);
|
||||
Assert.All(bare.Workflow!.Tasks, t => Assert.Null(t.Server));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DurationParser_parses_common_forms()
|
||||
{
|
||||
Assert.True(DurationParser.TryParse("45s", out var s));
|
||||
Assert.Equal(TimeSpan.FromSeconds(45), s);
|
||||
Assert.True(DurationParser.TryParse("30m", out var m));
|
||||
Assert.Equal(TimeSpan.FromMinutes(30), m);
|
||||
Assert.True(DurationParser.TryParse("2h", out var h));
|
||||
Assert.Equal(TimeSpan.FromHours(2), h);
|
||||
Assert.True(DurationParser.TryParse("1h30m", out var compound));
|
||||
Assert.Equal(TimeSpan.FromMinutes(90), compound);
|
||||
Assert.False(DurationParser.TryParse("soon", out _));
|
||||
}
|
||||
}
|
||||
133
w4c-workflows-api.Tests/WorkflowDataHelpers.cs
Normal file
133
w4c-workflows-api.Tests/WorkflowDataHelpers.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
/// <summary>Shared compile-and-persist helpers for lifecycle/dispatcher tests.</summary>
|
||||
internal static class WorkflowDataHelpers
|
||||
{
|
||||
public const string LinearYaml = """
|
||||
name: linear
|
||||
mode: function
|
||||
language: shell
|
||||
entry: { file: root.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
parent: root
|
||||
next: b
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
- id: b
|
||||
parent: root
|
||||
language: shell
|
||||
entry: { file: b.sh }
|
||||
""";
|
||||
|
||||
public const string DurableYaml = """
|
||||
name: durable
|
||||
mode: durable
|
||||
language: shell
|
||||
entry: { file: root.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
parent: root
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
""";
|
||||
|
||||
/// <summary>Three-task success chain where every chained task declares its own
|
||||
/// compensation (<c>onError</c>) target, for saga reverse-compensation tests.</summary>
|
||||
public const string SagaYaml = """
|
||||
name: saga
|
||||
mode: function
|
||||
language: shell
|
||||
entry: { file: root.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
parent: root
|
||||
next: b
|
||||
onError: compensate_a
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
- id: b
|
||||
parent: root
|
||||
next: c
|
||||
onError: compensate_b
|
||||
language: shell
|
||||
entry: { file: b.sh }
|
||||
- id: c
|
||||
parent: root
|
||||
onError: compensate_c
|
||||
language: shell
|
||||
entry: { file: c.sh }
|
||||
- id: compensate_a
|
||||
parent: root
|
||||
language: shell
|
||||
entry: { file: compensate_a.sh }
|
||||
- id: compensate_b
|
||||
parent: root
|
||||
language: shell
|
||||
entry: { file: compensate_b.sh }
|
||||
- id: compensate_c
|
||||
parent: root
|
||||
language: shell
|
||||
entry: { file: compensate_c.sh }
|
||||
""";
|
||||
|
||||
public static Workflow CompileAndSave(WorkflowsDbContext db, string tenantId, string yaml, string path = "workflows/linear.yaml")
|
||||
{
|
||||
var compiler = new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry()));
|
||||
var result = compiler.Compile(yaml, path, tenantId);
|
||||
Assert.True(result.Success, string.Join("\n", result.Errors));
|
||||
|
||||
var workflow = result.Workflow!.Workflow;
|
||||
workflow.Tasks = result.Workflow.Tasks.ToList();
|
||||
|
||||
db.Workflows.Add(workflow);
|
||||
db.SaveChanges();
|
||||
return workflow;
|
||||
}
|
||||
|
||||
public static WorkflowTask TaskByKey(Workflow workflow, string key)
|
||||
=> workflow.Tasks.Single(t => t.Key == key);
|
||||
|
||||
public static WorkflowRun AddPendingRun(WorkflowsDbContext db, Workflow workflow, string? input = null, Guid? startTaskId = null)
|
||||
{
|
||||
var run = new WorkflowRun
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkflowId = workflow.Id,
|
||||
TenantId = workflow.TenantId,
|
||||
Status = RunStatus.Pending,
|
||||
TriggerJson = "{\"type\":\"event\"}",
|
||||
InputJson = input,
|
||||
CorrelationId = "test-correlation",
|
||||
StartTaskId = startTaskId,
|
||||
};
|
||||
db.WorkflowRuns.Add(run);
|
||||
db.SaveChanges();
|
||||
return run;
|
||||
}
|
||||
|
||||
/// <summary>Adds a run in a terminal/in-flight status with an explicit start time.</summary>
|
||||
public static WorkflowRun AddRun(WorkflowsDbContext db, Workflow workflow, string status, DateTime? startedAt = null, DateTime? finishedAt = null, string? error = null)
|
||||
{
|
||||
var run = new WorkflowRun
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkflowId = workflow.Id,
|
||||
TenantId = workflow.TenantId,
|
||||
Status = status,
|
||||
TriggerJson = "{\"type\":\"event\"}",
|
||||
CorrelationId = $"corr-{Guid.NewGuid():N}",
|
||||
StartedAt = startedAt,
|
||||
FinishedAt = finishedAt,
|
||||
Error = error,
|
||||
};
|
||||
db.WorkflowRuns.Add(run);
|
||||
db.SaveChanges();
|
||||
return run;
|
||||
}
|
||||
}
|
||||
272
w4c-workflows-api.Tests/WorkflowSyncIntegrationTests.cs
Normal file
272
w4c-workflows-api.Tests/WorkflowSyncIntegrationTests.cs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
[Collection("WorkflowsPostgres")]
|
||||
public class WorkflowSyncIntegrationTests
|
||||
{
|
||||
private const string ExampleYaml = """
|
||||
name: main-task
|
||||
mode: handler
|
||||
language: typescript
|
||||
trigger:
|
||||
type: queue
|
||||
stream: orders
|
||||
entry: { file: index.ts, function: handler }
|
||||
tasks:
|
||||
- id: validate
|
||||
parent: root
|
||||
next: enrich
|
||||
onError: compensate
|
||||
language: python
|
||||
entry: { file: validate.py }
|
||||
- id: enrich
|
||||
parent: root
|
||||
next: persist
|
||||
language: csharp
|
||||
entry: { file: Enrich.cs }
|
||||
- id: persist
|
||||
parent: root
|
||||
language: shell
|
||||
entry: { file: persist.sh }
|
||||
- id: compensate
|
||||
parent: root
|
||||
language: python
|
||||
entry: { file: compensate.py }
|
||||
""";
|
||||
|
||||
private readonly WorkflowsPostgresFixture _fixture;
|
||||
|
||||
public WorkflowSyncIntegrationTests(WorkflowsPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private WorkflowSyncService NewSync(string tenantId, FakeWorkflowSource source)
|
||||
{
|
||||
var compiler = new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry()));
|
||||
return new WorkflowSyncService(
|
||||
_fixture.CreateContext(), compiler, source, NullLogger<WorkflowSyncService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_compiles_and_persists_workflow_with_task_tree()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
|
||||
|
||||
var result = await NewSync(tenantId, source).SyncAsync(tenantId, default);
|
||||
|
||||
Assert.Equal(1, result.Compiled);
|
||||
Assert.Empty(result.Errors);
|
||||
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId);
|
||||
Assert.Equal("main-task", workflow.Name);
|
||||
Assert.Equal("compiled", workflow.Status);
|
||||
Assert.Equal("abc123", workflow.GitSha);
|
||||
Assert.Equal(5, workflow.Tasks.Count);
|
||||
|
||||
var root = workflow.Tasks.Single(t => t.Order == 0);
|
||||
var validate = workflow.Tasks.Single(t => t.Order == 1);
|
||||
Assert.Equal(validate.Id, root.NextId);
|
||||
Assert.NotNull(validate.OnErrorId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_is_idempotent_across_repeat_syncs()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
|
||||
var sync = () => NewSync(tenantId, source).SyncAsync(tenantId, default);
|
||||
|
||||
await sync();
|
||||
var second = await sync();
|
||||
|
||||
Assert.Equal(1, second.Compiled);
|
||||
await using var db = _fixture.CreateContext();
|
||||
Assert.Equal(1, await db.Workflows.CountAsync(w => w.TenantId == tenantId));
|
||||
Assert.Equal(5, await db.Tasks.CountAsync(t => t.Workflow.TenantId == tenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_reconciles_tasks_when_chain_changes()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
var source = new FakeWorkflowSource
|
||||
{
|
||||
["workflows/main-task.yaml"] = """
|
||||
name: main-task
|
||||
mode: function
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
next: b
|
||||
entry: { file: a.sh }
|
||||
- id: b
|
||||
entry: { file: b.sh }
|
||||
""",
|
||||
};
|
||||
|
||||
await NewSync(tenantId, source).SyncAsync(tenantId, default);
|
||||
|
||||
// Add a third task to the chain.
|
||||
source["workflows/main-task.yaml"] = """
|
||||
name: main-task
|
||||
mode: function
|
||||
language: shell
|
||||
entry: { file: a.sh }
|
||||
tasks:
|
||||
- id: a
|
||||
next: b
|
||||
entry: { file: a.sh }
|
||||
- id: b
|
||||
next: c
|
||||
entry: { file: b.sh }
|
||||
- id: c
|
||||
entry: { file: c.sh }
|
||||
""";
|
||||
|
||||
await NewSync(tenantId, source).SyncAsync(tenantId, default);
|
||||
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId);
|
||||
Assert.Equal(4, workflow.Tasks.Count); // root + a + b + c
|
||||
Assert.Contains(workflow.Tasks, t => t.Order == 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_removes_workflow_when_file_deleted()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
|
||||
|
||||
await NewSync(tenantId, source).SyncAsync(tenantId, default);
|
||||
source.Clear();
|
||||
|
||||
var result = await NewSync(tenantId, source).SyncAsync(tenantId, default);
|
||||
|
||||
Assert.Equal(1, result.Removed);
|
||||
await using var db = _fixture.CreateContext();
|
||||
Assert.Equal(0, await db.Workflows.CountAsync(w => w.TenantId == tenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_removing_a_task_archives_it_and_preserves_its_taskruns()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
|
||||
|
||||
await NewSync(tenantId, source).SyncAsync(tenantId, default);
|
||||
|
||||
// Give the 'enrich' task a historical run before it is removed from the YAML.
|
||||
Guid enrichId;
|
||||
await using (var db = _fixture.CreateContext())
|
||||
{
|
||||
var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId);
|
||||
var enrich = workflow.Tasks.Single(t => t.Order == 2); // 'enrich' (csharp)
|
||||
enrichId = enrich.Id;
|
||||
var run = new WorkflowRun
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
WorkflowId = workflow.Id,
|
||||
TenantId = tenantId,
|
||||
Status = RunStatus.Succeeded,
|
||||
};
|
||||
db.WorkflowRuns.Add(run);
|
||||
await db.SaveChangesAsync();
|
||||
db.TaskRuns.Add(new TaskRun
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RunId = run.Id,
|
||||
TaskId = enrichId,
|
||||
Attempt = 1,
|
||||
Status = TaskRunStatus.Succeeded,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// Remove 'enrich' from the YAML (simulates a rename/removal of the task).
|
||||
source["workflows/main-task.yaml"] = """
|
||||
name: main-task
|
||||
mode: handler
|
||||
language: typescript
|
||||
trigger:
|
||||
type: queue
|
||||
stream: orders
|
||||
entry: { file: index.ts, function: handler }
|
||||
tasks:
|
||||
- id: validate
|
||||
parent: root
|
||||
next: persist
|
||||
onError: compensate
|
||||
language: python
|
||||
entry: { file: validate.py }
|
||||
- id: persist
|
||||
parent: root
|
||||
language: shell
|
||||
entry: { file: persist.sh }
|
||||
- id: compensate
|
||||
parent: root
|
||||
language: python
|
||||
entry: { file: compensate.py }
|
||||
""";
|
||||
|
||||
await NewSync(tenantId, source).SyncAsync(tenantId, default);
|
||||
|
||||
await using var after = _fixture.CreateContext();
|
||||
// The historical TaskRun survives — sync no longer wipes it.
|
||||
Assert.Equal(1, await after.TaskRuns.CountAsync(t => t.TaskId == enrichId));
|
||||
// The removed task row is archived (soft-deleted), not deleted, and excluded
|
||||
// from active-definition queries.
|
||||
var archived = await after.Tasks.IgnoreQueryFilters().SingleAsync(t => t.Id == enrichId);
|
||||
Assert.NotNull(archived.ArchivedAt);
|
||||
Assert.False(await after.Tasks.AnyAsync(t => t.Id == enrichId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_reports_errors_and_skips_invalid_files()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
var source = new FakeWorkflowSource
|
||||
{
|
||||
["workflows/broken.yaml"] = "name: broken\nmode: function\nlanguage: cobol\nentry: { file: x.sh }",
|
||||
};
|
||||
|
||||
var result = await NewSync(tenantId, source).SyncAsync(tenantId, default);
|
||||
|
||||
Assert.Equal(0, result.Compiled);
|
||||
Assert.Single(result.Errors);
|
||||
Assert.Contains("not supported", result.Errors[0].Errors.Single());
|
||||
|
||||
await using var db = _fixture.CreateContext();
|
||||
Assert.Equal(0, await db.Workflows.CountAsync(w => w.TenantId == tenantId));
|
||||
}
|
||||
|
||||
private sealed class FakeWorkflowSource : IWorkflowSource
|
||||
{
|
||||
private readonly Dictionary<string, string> _files = new(StringComparer.Ordinal);
|
||||
|
||||
public string this[string path]
|
||||
{
|
||||
get => _files[path];
|
||||
set => _files[path] = value;
|
||||
}
|
||||
|
||||
public void Clear() => _files.Clear();
|
||||
|
||||
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<WorkflowFile>>(
|
||||
_files.Keys.Select(p => new WorkflowFile(p)).OrderBy(p => p.Path, StringComparer.Ordinal).ToList());
|
||||
|
||||
public Task<string> ReadAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult(_files[path]);
|
||||
|
||||
public WorkflowSourceState GetState() => new("abc123", false);
|
||||
}
|
||||
}
|
||||
92
w4c-workflows-api.Tests/WorkflowsControllerDetailTests.cs
Normal file
92
w4c-workflows-api.Tests/WorkflowsControllerDetailTests.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using w4c_workflows.Controllers;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Services;
|
||||
using w4c_workflows.Services.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
[Collection("WorkflowsPostgres")]
|
||||
public class WorkflowsControllerDetailTests
|
||||
{
|
||||
private const string YamlContent = "name: linear\nmode: function\nlanguage: shell\n";
|
||||
|
||||
private sealed class FakeWorkflowSource : IWorkflowSource
|
||||
{
|
||||
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<WorkflowFile>>(Array.Empty<WorkflowFile>());
|
||||
|
||||
public Task<string> ReadAsync(string path, CancellationToken ct)
|
||||
=> Task.FromResult(YamlContent);
|
||||
|
||||
public WorkflowSourceState GetState() => new(null, false);
|
||||
}
|
||||
|
||||
private readonly WorkflowsPostgresFixture _fixture;
|
||||
|
||||
public WorkflowsControllerDetailTests(WorkflowsPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private static WorkflowsController Controller(WorkflowsDbContext db, string tenantId)
|
||||
{
|
||||
var sync = new WorkflowSyncService(
|
||||
db,
|
||||
new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry())),
|
||||
new FakeWorkflowSource(),
|
||||
NullLogger<WorkflowSyncService>.Instance);
|
||||
|
||||
var controller = new WorkflowsController(
|
||||
db,
|
||||
sync,
|
||||
new FakeWorkflowSource(),
|
||||
new MermaidGeneratorService(),
|
||||
new WorkflowHtmlRenderer(NullLogger<WorkflowHtmlRenderer>.Instance),
|
||||
new RenderTokenService(new ConfigurationBuilder().Build(), NullLogger<RenderTokenService>.Instance),
|
||||
new RunLauncher(db, NullLogger<RunLauncher>.Instance),
|
||||
new DurableStateStore(db),
|
||||
NullLogger<WorkflowsController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
controller.HttpContext.Items["TenantId"] = tenantId;
|
||||
return controller;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_returns_definition_task_tree_and_raw_yaml()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
|
||||
var result = await Controller(db, tenantId).Get(workflow.Id, default);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var json = JsonSerializer.Serialize(ok.Value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal(workflow.Id, root.GetProperty("id").GetGuid());
|
||||
Assert.Equal("linear", root.GetProperty("name").GetString());
|
||||
Assert.Equal(YamlContent, root.GetProperty("yaml").GetString());
|
||||
Assert.Equal(3, root.GetProperty("tasks").GetArrayLength()); // root + a + b
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_404s_for_foreign_workflow()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
await using var db = _fixture.CreateContext();
|
||||
var other = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, other, WorkflowDataHelpers.LinearYaml);
|
||||
|
||||
var result = await Controller(db, tenantId).Get(workflow.Id, default);
|
||||
Assert.IsType<NotFoundObjectResult>(result);
|
||||
}
|
||||
}
|
||||
102
w4c-workflows-api.Tests/WorkflowsControllerTriggerTests.cs
Normal file
102
w4c-workflows-api.Tests/WorkflowsControllerTriggerTests.cs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using w4c_workflows.Controllers;
|
||||
using w4c_workflows.Data;
|
||||
using w4c_workflows.Models;
|
||||
using w4c_workflows.Services;
|
||||
using w4c_workflows.Services.Runs;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
[Collection("WorkflowsPostgres")]
|
||||
public class WorkflowsControllerTriggerTests
|
||||
{
|
||||
private sealed class FakeWorkflowSource : IWorkflowSource
|
||||
{
|
||||
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<WorkflowFile>>(Array.Empty<WorkflowFile>());
|
||||
|
||||
public Task<string> ReadAsync(string path, CancellationToken ct) => Task.FromResult(string.Empty);
|
||||
|
||||
public WorkflowSourceState GetState() => new(null, false);
|
||||
}
|
||||
|
||||
private readonly WorkflowsPostgresFixture _fixture;
|
||||
|
||||
public WorkflowsControllerTriggerTests(WorkflowsPostgresFixture fixture)
|
||||
{
|
||||
_fixture = fixture;
|
||||
}
|
||||
|
||||
private static WorkflowsController Controller(WorkflowsDbContext db, string tenantId)
|
||||
{
|
||||
var sync = new WorkflowSyncService(
|
||||
db,
|
||||
new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry())),
|
||||
new FakeWorkflowSource(),
|
||||
NullLogger<WorkflowSyncService>.Instance);
|
||||
|
||||
var controller = new WorkflowsController(
|
||||
db,
|
||||
sync,
|
||||
new FakeWorkflowSource(),
|
||||
new MermaidGeneratorService(),
|
||||
new WorkflowHtmlRenderer(NullLogger<WorkflowHtmlRenderer>.Instance),
|
||||
new RenderTokenService(new ConfigurationBuilder().Build(), NullLogger<RenderTokenService>.Instance),
|
||||
new RunLauncher(db, NullLogger<RunLauncher>.Instance),
|
||||
new DurableStateStore(db),
|
||||
NullLogger<WorkflowsController>.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
controller.HttpContext.Items["TenantId"] = tenantId;
|
||||
return controller;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Toggle_disables_and_enables_the_trigger()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
Assert.True(workflow.TriggerEnabled);
|
||||
|
||||
var controller = Controller(db, tenantId);
|
||||
|
||||
var disable = Assert.IsType<OkObjectResult>(await controller.SetTriggerEnabled(workflow.Id, "disable", default));
|
||||
var disabled = Assert.IsType<WorkflowsController.TriggerStateResponse>(disable.Value);
|
||||
Assert.False(disabled.TriggerEnabled);
|
||||
Assert.False((await db.Workflows.SingleAsync(w => w.Id == workflow.Id)).TriggerEnabled);
|
||||
|
||||
var enable = Assert.IsType<OkObjectResult>(await controller.SetTriggerEnabled(workflow.Id, "enable", default));
|
||||
var enabled = Assert.IsType<WorkflowsController.TriggerStateResponse>(enable.Value);
|
||||
Assert.True(enabled.TriggerEnabled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Toggle_rejects_unknown_action()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
await using var db = _fixture.CreateContext();
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, tenantId, WorkflowDataHelpers.LinearYaml);
|
||||
|
||||
var result = await Controller(db, tenantId).SetTriggerEnabled(workflow.Id, "pause", default);
|
||||
Assert.IsType<BadRequestObjectResult>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Toggle_404s_for_foreign_workflow()
|
||||
{
|
||||
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
await using var db = _fixture.CreateContext();
|
||||
var other = "t" + Guid.NewGuid().ToString("N")[..12];
|
||||
var workflow = WorkflowDataHelpers.CompileAndSave(db, other, WorkflowDataHelpers.LinearYaml);
|
||||
|
||||
var result = await Controller(db, tenantId).SetTriggerEnabled(workflow.Id, "disable", default);
|
||||
Assert.IsType<NotFoundObjectResult>(result);
|
||||
}
|
||||
}
|
||||
59
w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs
Normal file
59
w4c-workflows-api.Tests/WorkflowsPostgresFixture.cs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
using Microsoft.EntityFrameworkCore;
|
||||
using Testcontainers.PostgreSql;
|
||||
using w4c_workflows.Data;
|
||||
using Xunit;
|
||||
|
||||
namespace w4c_workflows.Tests;
|
||||
|
||||
[CollectionDefinition("WorkflowsPostgres")]
|
||||
public class WorkflowsPostgresCollection : ICollectionFixture<WorkflowsPostgresFixture>
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Postgres 17 container per collection; applies the EF Core migration once
|
||||
/// and hands out fresh (scoped-like) DbContext instances per test.
|
||||
/// </summary>
|
||||
public sealed class WorkflowsPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private PostgreSqlContainer? _container;
|
||||
private string _connectionString = "";
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17-alpine")
|
||||
.WithDatabase("w4c_workflows_test")
|
||||
.WithUsername("w4c")
|
||||
.WithPassword("w4c_test_pw")
|
||||
.Build();
|
||||
await _container.StartAsync();
|
||||
|
||||
_connectionString = _container.GetConnectionString() + ";Timezone=UTC";
|
||||
|
||||
await using var context = CreateContext();
|
||||
await context.Database.MigrateAsync();
|
||||
|
||||
// Mirror the production startup ordering (Program.cs): the add-on columns for
|
||||
// W7 (ArchivedAt) and S8 (Server) are applied by idempotent raw SQL AFTER the
|
||||
// EF migrations — no new EF migration is introduced (the project's house style).
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"ALTER TABLE workflows.\"Tasks\" ADD COLUMN IF NOT EXISTS \"ArchivedAt\" timestamptz NULL;");
|
||||
await context.Database.ExecuteSqlRawAsync(
|
||||
"ALTER TABLE workflows.\"Tasks\" ADD COLUMN IF NOT EXISTS \"Server\" text NULL;");
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_container is not null)
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
public WorkflowsDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<WorkflowsDbContext>()
|
||||
.UseNpgsql(_connectionString)
|
||||
.Options;
|
||||
return new WorkflowsDbContext(options);
|
||||
}
|
||||
}
|
||||
27
w4c-workflows-api.Tests/w4c-workflows-api.Tests.csproj
Normal file
27
w4c-workflows-api.Tests/w4c-workflows-api.Tests.csproj
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net11.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<RootNamespace>w4c_workflows.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<!-- Unify EF Core with w4c-workflows-api (10.0.10) so the referenced
|
||||
workflows-api.dll is not shadowed by the older transitive 10.0.4 the
|
||||
Npgsql provider package pulls in. -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.9.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../w4c-workflows-api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
29
w4c-workflows-api.csproj
Normal file
29
w4c-workflows-api.csproj
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net11.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>w4c_workflows</RootNamespace>
|
||||
<AssemblyName>w4c-workflows-api</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="11.0.0-preview.6.26359.118" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="11.0.0-preview.6.26359.118" />
|
||||
<!-- Roslyn 5.0.0 matches the transitive pin from Microsoft.EntityFrameworkCore.Design
|
||||
10.0.10 (Workspaces 5.0.0); bumping Roslyn independently triggers NU1608. -->
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.OpenSearch" Version="2.0.0" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.24" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Loading…
Reference in a new issue