182 lines
6.8 KiB
C#
182 lines
6.8 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using w4c_workflows.Data;
|
|
using w4c_workflows.Models;
|
|
using w4c_workflows.Models.Nodes;
|
|
|
|
namespace w4c_workflows.Services.Nodes;
|
|
|
|
/// <summary>
|
|
/// In-process sub-workflow invoker backing the <c>core.executeWorkflow</c> node.
|
|
/// Resolves the child workflow in the caller's tenant, runs it through the same
|
|
/// node kernel (recursively), links the child run to its parent and maps the
|
|
/// child's terminal output back to items.
|
|
///
|
|
/// Two guards keep recursion bounded and explainable:
|
|
/// - a depth limit (<see cref="MaxDepth"/>), so a self-growing chain stops;
|
|
/// - an ancestry check, so a workflow that (transitively) calls itself is
|
|
/// rejected immediately instead of running until the depth limit.
|
|
///
|
|
/// One instance is created per parent run: it carries the parent run/task, the
|
|
/// active workflow chain and the shared <see cref="WorkflowsDbContext"/>.
|
|
/// </summary>
|
|
public sealed class SubWorkflowInvoker : ISubWorkflowInvoker
|
|
{
|
|
/// <summary>Nesting depth allowed when the host does not override it.</summary>
|
|
public const int DefaultMaxDepth = 10;
|
|
|
|
private const string ChildTriggerJson = "{\"type\":\"subWorkflow\"}";
|
|
|
|
private readonly WorkflowsDbContext _db;
|
|
private readonly NodeWorkflowRunner _runner;
|
|
private readonly WorkflowRun _parentRun;
|
|
private readonly IReadOnlyDictionary<string, Guid> _taskIds;
|
|
private readonly IReadOnlyList<Guid> _ancestry;
|
|
private readonly ILogger? _logger;
|
|
|
|
public SubWorkflowInvoker(
|
|
WorkflowsDbContext db,
|
|
NodeWorkflowRunner runner,
|
|
WorkflowRun parentRun,
|
|
IReadOnlyDictionary<string, Guid> taskIds,
|
|
IReadOnlyList<Guid> ancestry,
|
|
int maxDepth,
|
|
ILogger? logger = null)
|
|
{
|
|
_db = db;
|
|
_runner = runner;
|
|
_parentRun = parentRun;
|
|
_taskIds = taskIds;
|
|
_ancestry = ancestry;
|
|
MaxDepth = maxDepth < 1 ? DefaultMaxDepth : maxDepth;
|
|
_logger = logger;
|
|
}
|
|
|
|
public int MaxDepth { get; }
|
|
|
|
public async Task<SubWorkflowRunResult> InvokeAsync(
|
|
SubWorkflowInvocation invocation, CancellationToken ct)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(invocation.Workflow))
|
|
return SubWorkflowRunResult.Failed("a sub-workflow reference is required", "invalid_parameter");
|
|
|
|
// Guard: depth. The child would run one level below the current chain, so
|
|
// the chain length is exactly the child's depth.
|
|
if (_ancestry.Count > MaxDepth)
|
|
{
|
|
return SubWorkflowRunResult.Failed(
|
|
$"sub-workflow nesting exceeded the maximum depth of {MaxDepth}",
|
|
"subworkflow_depth_exceeded");
|
|
}
|
|
|
|
var child = await FindWorkflowAsync(invocation.Workflow, ct);
|
|
if (child == null)
|
|
{
|
|
return SubWorkflowRunResult.Failed(
|
|
$"sub-workflow '{invocation.Workflow}' was not found in this tenant",
|
|
"subworkflow_not_found");
|
|
}
|
|
if (!string.Equals(child.Status, WorkflowStatus.Compiled, StringComparison.Ordinal))
|
|
{
|
|
return SubWorkflowRunResult.Failed(
|
|
$"sub-workflow '{child.Name}' is not compiled (status: {child.Status})",
|
|
"subworkflow_not_compiled");
|
|
}
|
|
|
|
// Guard: recursion. A workflow already on the chain would call back into
|
|
// itself; reject it outright rather than burning the depth budget.
|
|
if (_ancestry.Contains(child.Id))
|
|
{
|
|
return SubWorkflowRunResult.Failed(
|
|
$"recursive sub-workflow call to '{child.Name}' detected",
|
|
"subworkflow_recursion");
|
|
}
|
|
|
|
var mode = string.IsNullOrWhiteSpace(invocation.Mode) ? SubWorkflowMode.AllItems : invocation.Mode;
|
|
return string.Equals(mode, SubWorkflowMode.EachItem, StringComparison.OrdinalIgnoreCase)
|
|
? await InvokePerItemAsync(child, invocation, ct)
|
|
: await InvokeOnceAsync(child, invocation.Input, invocation, ct);
|
|
}
|
|
|
|
private async Task<SubWorkflowRunResult> InvokePerItemAsync(
|
|
Workflow child, SubWorkflowInvocation invocation, CancellationToken ct)
|
|
{
|
|
var output = new List<FlowItem>();
|
|
Guid? lastRun = null;
|
|
|
|
foreach (var item in invocation.Input)
|
|
{
|
|
var result = await InvokeOnceAsync(child, new[] { item }, invocation, ct);
|
|
if (!result.Succeeded)
|
|
return result;
|
|
|
|
output.AddRange(result.Items);
|
|
lastRun = result.RunId;
|
|
}
|
|
|
|
return SubWorkflowRunResult.Ok(output, lastRun);
|
|
}
|
|
|
|
private async Task<SubWorkflowRunResult> InvokeOnceAsync(
|
|
Workflow child,
|
|
IReadOnlyList<FlowItem> items,
|
|
SubWorkflowInvocation invocation,
|
|
CancellationToken ct)
|
|
{
|
|
var childRun = new WorkflowRun
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
WorkflowId = child.Id,
|
|
TenantId = _parentRun.TenantId,
|
|
Status = RunStatus.Running,
|
|
TriggerJson = ChildTriggerJson,
|
|
InputJson = FlowItemJson.Serialize(items),
|
|
ParentRunId = _parentRun.Id,
|
|
ParentTaskId = ResolveParentTaskId(invocation.ParentNodeId),
|
|
Depth = _ancestry.Count,
|
|
StartedAt = DateTime.UtcNow,
|
|
};
|
|
|
|
_db.WorkflowRuns.Add(childRun);
|
|
await _db.SaveChangesAsync(ct);
|
|
|
|
_logger?.LogDebug(
|
|
"Sub-workflow '{Child}' started as run {ChildRunId} (parent {ParentRunId}, depth {Depth})",
|
|
child.Name, childRun.Id, _parentRun.Id, childRun.Depth);
|
|
|
|
var outcome = await _runner.ExecuteAsync(
|
|
_db,
|
|
childRun,
|
|
child,
|
|
invocation.WorkingDirectory,
|
|
_ancestry.Append(child.Id).ToList(),
|
|
invocation.RecordHistory,
|
|
ct);
|
|
|
|
if (!outcome.Succeeded)
|
|
{
|
|
return SubWorkflowRunResult.Failed(
|
|
outcome.Error ?? $"sub-workflow '{child.Name}' failed", "subworkflow_failed", childRun.Id);
|
|
}
|
|
|
|
return SubWorkflowRunResult.Ok(FlowItemJson.Parse(outcome.Output), childRun.Id);
|
|
}
|
|
|
|
/// <summary>Resolves a child workflow by Guid id or unique name within the tenant.</summary>
|
|
private async Task<Workflow?> FindWorkflowAsync(string reference, CancellationToken ct)
|
|
{
|
|
var query = _db.Workflows
|
|
.Include(w => w.Tasks)
|
|
.Include(w => w.TaskEdges)
|
|
.Where(w => w.TenantId == _parentRun.TenantId);
|
|
|
|
var trimmed = reference.Trim();
|
|
if (Guid.TryParse(trimmed, out var id))
|
|
return await query.FirstOrDefaultAsync(w => w.Id == id, ct);
|
|
|
|
return await query.FirstOrDefaultAsync(w => w.Name == trimmed, ct);
|
|
}
|
|
|
|
private Guid? ResolveParentTaskId(string? nodeId)
|
|
=> nodeId != null && _taskIds.TryGetValue(nodeId, out var taskId) ? taskId : null;
|
|
}
|