367 lines
17 KiB
C#
367 lines
17 KiB
C#
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<WorkflowExecutionResult> ExecuteAsync(
|
|
WorkflowDefinition rootWorkflow,
|
|
IReadOnlyDictionary<string, WorkflowDefinition> allWorkflows,
|
|
DevToolConfig config,
|
|
string projectRoot,
|
|
Func<string, InstallPlan, Task<bool>> confirmInstallAsync,
|
|
Action<string, bool> onOutput,
|
|
Action<RunEvent>? onEvent = null,
|
|
IReadOnlyDictionary<string, string>? envOverrides = null,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var results = new List<WorkflowStepResult>();
|
|
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<string>();
|
|
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<string> 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;
|
|
}
|
|
}
|