w4c-workflows-api/Services/Nodes/SubWorkflowInvoker.cs
2026-09-12 01:02:46 +03:00

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