using Microsoft.EntityFrameworkCore; using w4c_workflows.Data; using w4c_workflows.Models; using w4c_workflows.Models.Nodes; namespace w4c_workflows.Services.Nodes; /// /// In-process sub-workflow invoker backing the core.executeWorkflow 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 (), 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 . /// public sealed class SubWorkflowInvoker : ISubWorkflowInvoker { /// Nesting depth allowed when the host does not override it. 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 _taskIds; private readonly IReadOnlyList _ancestry; private readonly ILogger? _logger; public SubWorkflowInvoker( WorkflowsDbContext db, NodeWorkflowRunner runner, WorkflowRun parentRun, IReadOnlyDictionary taskIds, IReadOnlyList 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 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 InvokePerItemAsync( Workflow child, SubWorkflowInvocation invocation, CancellationToken ct) { var output = new List(); 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 InvokeOnceAsync( Workflow child, IReadOnlyList 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); } /// Resolves a child workflow by Guid id or unique name within the tenant. private async Task 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; }