using Sdt.Config; using Sdt.Runner; namespace Sdt.Core; public sealed class WorkflowExecutor( IWorkflowPlanner planner, IToolProbe toolProbe, IPrereqInstaller installer, IActionRunner actionRunner, IRequirementResolver requirementResolver) { private readonly IWorkflowPlanner _planner = planner; private readonly IToolProbe _toolProbe = toolProbe; private readonly IPrereqInstaller _installer = installer; private readonly IActionRunner _actionRunner = actionRunner; private readonly IRequirementResolver _requirementResolver = requirementResolver; public async Task ExecuteAsync( WorkflowDefinition rootWorkflow, IReadOnlyDictionary allWorkflows, DevToolConfig config, string projectRoot, Func> confirmInstallAsync, Action onOutput, Action? onEvent = null, IReadOnlyDictionary? envOverrides = null, CancellationToken cancellationToken = default) { var results = new List(); var plan = _planner.ResolvePlan(rootWorkflow, allWorkflows); onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowStarted, Message: $"Workflow '{rootWorkflow.Id}' started.", WorkflowId: rootWorkflow.Id)); onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowPlanned, Message: $"Execution plan contains {plan.Count} workflow(s).", WorkflowId: rootWorkflow.Id)); if (plan.Count == 0) { onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowCompleted, Message: "No executable workflow steps were found.", WorkflowId: rootWorkflow.Id, Success: false)); return new WorkflowExecutionResult( Success: false, StopReason: ExecutionStopReason.ValidationFailed, Message: "This workflow has no executable steps.", Steps: results); } foreach (var workflow in plan) { foreach (var step in workflow.Steps) { onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowStepStarted, Message: $"Step '{step.Label}' started.", WorkflowId: workflow.Id, StepId: step.Id)); var requires = _requirementResolver.Resolve(step); foreach (var req in requires) { var probe = await _toolProbe.ProbeAsync(req.Tool, projectRoot, config, envOverrides, cancellationToken).ConfigureAwait(false); onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.ProbeChecked, Message: probe.IsAvailable ? $"Tool '{req.Tool}' is available." : $"Tool '{req.Tool}' is missing.", WorkflowId: workflow.Id, StepId: step.Id, Tool: req.Tool, Success: probe.IsAvailable)); if (probe.IsAvailable) continue; if (!string.IsNullOrWhiteSpace(probe.Details)) { onOutput($"Probe detail [{req.Tool}]: {probe.Details}", false); onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.ProbeFailed, Message: probe.Details, WorkflowId: workflow.Id, StepId: step.Id, Tool: req.Tool, Success: false)); } if (req.InstallPolicy == InstallPolicy.Never) { onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowCompleted, Message: $"Missing prerequisite '{req.Tool}'.", WorkflowId: rootWorkflow.Id, Tool: req.Tool, Success: false)); return new WorkflowExecutionResult( Success: false, StopReason: ExecutionStopReason.MissingPrereq, Message: $"Missing prerequisite '{req.Tool}' for step '{step.Label}'.", Steps: results); } var installPlan = await _installer.GetInstallPlanAsync( req.Tool, projectRoot, config, cancellationToken).ConfigureAwait(false); onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.InstallPlanPrepared, Message: installPlan.Summary, WorkflowId: workflow.Id, StepId: step.Id, Tool: req.Tool, Success: installPlan.Supported)); if (!installPlan.Supported || installPlan.Commands.Count == 0) { onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowCompleted, Message: $"No installer plan available for '{req.Tool}'.", WorkflowId: rootWorkflow.Id, Tool: req.Tool, Success: false)); return new WorkflowExecutionResult( Success: false, StopReason: ExecutionStopReason.MissingPrereq, Message: $"Missing prerequisite '{req.Tool}' and no installer plan is available.", Steps: results); } var approved = req.InstallPolicy == InstallPolicy.Auto ? true : await confirmInstallAsync(req.Tool, installPlan).ConfigureAwait(false); if (!approved) { onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.InstallDeclined, Message: $"Install declined for '{req.Tool}'.", WorkflowId: workflow.Id, StepId: step.Id, Tool: req.Tool, Success: false)); return new WorkflowExecutionResult( Success: false, StopReason: ExecutionStopReason.UserDeclined, Message: $"Install declined for missing prerequisite '{req.Tool}'.", Steps: results); } foreach (var installCommand in installPlan.Commands) { onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.InstallCommandStarted, Message: $"{installCommand.Command} {string.Join(" ", installCommand.Args)}", WorkflowId: workflow.Id, StepId: step.Id, Tool: req.Tool)); var installResult = await _installer.RunInstallAsync( installCommand, projectRoot, onOutput, envOverrides, cancellationToken).ConfigureAwait(false); onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.InstallCommandCompleted, Message: $"Install command exited {installResult.ExitCode}.", WorkflowId: workflow.Id, StepId: step.Id, Tool: req.Tool, Success: installResult.Success, ExitCode: installResult.ExitCode)); if (!installResult.Success) { onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowCompleted, Message: $"Install failed for '{req.Tool}'.", WorkflowId: rootWorkflow.Id, Tool: req.Tool, Success: false)); return new WorkflowExecutionResult( Success: false, StopReason: ExecutionStopReason.InstallFailed, Message: $"Failed to install prerequisite '{req.Tool}'.", Steps: results); } } var verifyProbe = await _toolProbe.ProbeAsync(req.Tool, projectRoot, config, envOverrides, cancellationToken).ConfigureAwait(false); if (!verifyProbe.IsAvailable) { onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowCompleted, Message: $"Tool '{req.Tool}' still missing after install.", WorkflowId: rootWorkflow.Id, Tool: req.Tool, Success: false)); return new WorkflowExecutionResult( Success: false, StopReason: ExecutionStopReason.InstallFailed, Message: $"Prerequisite '{req.Tool}' is still missing after install attempt.", Steps: results); } } RunResult runResult; int retryCount = 0; const int MaxRetries = 1; while (true) { var capturedOutput = new List(); runResult = await _actionRunner.RunStepAsync( step, projectRoot, (line, isErr) => { onOutput(line, isErr); if (isErr) capturedOutput.Add(line); }, envOverrides, cancellationToken).ConfigureAwait(false); if (runResult.Success || retryCount >= MaxRetries) break; var missingTool = DetectMissingToolFromOutput(capturedOutput); if (missingTool == null) break; var policy = config.Tooling?.DefaultInstallPolicy ?? InstallPolicy.Prompt; if (policy == InstallPolicy.Never) { onOutput($"Auto-recovery for '{missingTool}' skipped due to InstallPolicy.Never.", false); break; } onOutput($"Detected missing tool '{missingTool}' during execution. {(policy == InstallPolicy.Auto ? "Auto-installing..." : "Prompting for installation...")}", false); var installPlan = await _installer.GetInstallPlanAsync(missingTool, projectRoot, config, cancellationToken).ConfigureAwait(false); if (!installPlan.Supported || installPlan.Commands.Count == 0) { onOutput($"No installer plan available for auto-recovery of '{missingTool}'.", true); break; } var approved = policy == InstallPolicy.Auto ? true : await confirmInstallAsync(missingTool, installPlan).ConfigureAwait(false); if (!approved) break; bool installSuccess = true; foreach (var installCommand in installPlan.Commands) { var installResult = await _installer.RunInstallAsync(installCommand, projectRoot, onOutput, envOverrides, cancellationToken).ConfigureAwait(false); if (!installResult.Success) { installSuccess = false; break; } } if (!installSuccess) { onOutput($"Auto-recovery install failed for '{missingTool}'.", true); break; } onOutput($"Auto-recovery successful for '{missingTool}'. Retrying step '{step.Label}'...", false); retryCount++; } results.Add(new WorkflowStepResult( workflow.Id, step.Id, string.IsNullOrWhiteSpace(step.Label) ? step.Id : step.Label, runResult)); onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowStepCompleted, Message: $"Step '{step.Label}' exited {runResult.ExitCode}.", WorkflowId: workflow.Id, StepId: step.Id, Success: runResult.Success, ExitCode: runResult.ExitCode)); if (!runResult.Success) { onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowCompleted, Message: $"Step '{step.Label}' failed.", WorkflowId: rootWorkflow.Id, Success: false, ExitCode: runResult.ExitCode)); return new WorkflowExecutionResult( Success: false, StopReason: ExecutionStopReason.CommandFailed, Message: $"Step '{step.Label}' failed with exit code {runResult.ExitCode}.", Steps: results); } } } onEvent?.Invoke(new RunEvent( Category: "workflow", Type: RunEventType.WorkflowCompleted, Message: "Workflow completed successfully.", WorkflowId: rootWorkflow.Id, Success: true)); return new WorkflowExecutionResult( Success: true, StopReason: null, Message: "Workflow completed successfully.", Steps: results); } private static string? DetectMissingToolFromOutput(List output) { foreach (var line in output) { // Python: No module named 'PyInstaller' if (line.Contains("No module named", StringComparison.OrdinalIgnoreCase)) { var match = System.Text.RegularExpressions.Regex.Match(line, "No module named '?(\\w+)'?", System.Text.RegularExpressions.RegexOptions.IgnoreCase); if (match.Success) return match.Groups[1].Value.ToLowerInvariant(); } // Windows: 'cargo' is not recognized as an internal or external command if (line.Contains("is not recognized as an internal or external command", StringComparison.OrdinalIgnoreCase)) { var match = System.Text.RegularExpressions.Regex.Match(line, "'([^']+)' is not recognized", System.Text.RegularExpressions.RegexOptions.IgnoreCase); if (match.Success) return match.Groups[1].Value.ToLowerInvariant(); } // Linux: cargo: command not found if (line.Contains("command not found", StringComparison.OrdinalIgnoreCase)) { var match = System.Text.RegularExpressions.Regex.Match(line, "([^\\s:]+):\\s*command not found", System.Text.RegularExpressions.RegexOptions.IgnoreCase); if (match.Success) return match.Groups[1].Value.ToLowerInvariant(); } } return null; } }