Initial: workflows API control plane + tests + YAML workflow definitions

This commit is contained in:
Vitali sharp8n 2026-09-01 19:37:53 +03:00
commit 6042171fc2
96 changed files with 12701 additions and 0 deletions

25
.gitignore vendored Normal file
View 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

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

View 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>();
}
}
}

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

View 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,
}));
}

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

View 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>&lt;iframe&gt;</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 });
}
}

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

View 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");
}
}
}

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

View 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");
}
}
}

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

View 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");
}
}
}

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

View 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");
}
}
}

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

View 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");
}
}
}

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

View 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");
}
}
}

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

View 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
View 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"]

View 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&lt;string&gt;</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();
}
}

View 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
View 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
View 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
View 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
View 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
View 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('/', '_');
}

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

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

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

View 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&lt;T&gt;</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);
}

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

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

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

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

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

View 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();
}

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

View 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"));
}
}

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

View 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>();
}
}

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

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

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

View 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}()";
}
}

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

View 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
/// (`&gt;`) 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);
}
}

View 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>&lt;iframe&gt;</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);
}
}

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

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

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

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

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

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

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

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

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

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

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

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

View 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>&lt;iframe&gt;</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 &amp; 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
View 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());
}
}

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

View 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
View 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": ""
}
}

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

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

View 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"));
}
}

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

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

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

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

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

View 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"));
}
}

View 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());
}
}

View 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"]);
}
}

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

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

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

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

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

View 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());
}
}

View 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 _));
}
}

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

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

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

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

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

View 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
View 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>