SDT/src/DevTool.Engine/Core/WorkflowExecutor.cs
2026-03-01 20:52:56 -06:00

277 lines
13 KiB
C#

using Sdt.Config;
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);
}
}
var runResult = await _actionRunner.RunStepAsync(
step,
projectRoot,
onOutput,
envOverrides,
cancellationToken).ConfigureAwait(false);
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);
}
}