File: Commands\PipelineCommandBase.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.CommandLine;
using System.Diagnostics;
using System.Globalization;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Telemetry;
using Aspire.Cli.Utils;
using Aspire.Hosting;
using Spectre.Console;
 
namespace Aspire.Cli.Commands;
 
internal abstract class PipelineCommandBase : BaseCommand
{
    private const string CustomChoiceValue = "__CUSTOM_CHOICE";
 
    protected readonly IDotNetCliRunner _runner;
    protected readonly IProjectLocator _projectLocator;
    protected readonly AspireCliTelemetry _telemetry;
    protected readonly IDotNetSdkInstaller _sdkInstaller;
 
    private readonly IFeatures _features;
    private readonly ICliHostEnvironment _hostEnvironment;
 
    protected readonly Option<string?> _logLevelOption = new("--log-level")
    {
        Description = "Set the minimum log level for pipeline logging (trace, debug, information, warning, error, critical). The default is 'information'."
    };
 
    protected readonly Option<string?> _environmentOption = new("--environment", "-e")
    {
        Description = "The environment to use for the operation. The default is 'Production'."
    };
 
    protected abstract string OperationCompletedPrefix { get; }
    protected abstract string OperationFailedPrefix { get; }
 
    private static bool IsCompletionStateComplete(string completionState) =>
        completionState is CompletionStates.Completed or CompletionStates.CompletedWithWarning or CompletionStates.CompletedWithError;
 
    private static bool IsCompletionStateError(string completionState) =>
        completionState == CompletionStates.CompletedWithError;
 
    private static bool IsCompletionStateWarning(string completionState) =>
        completionState == CompletionStates.CompletedWithWarning;
 
    protected PipelineCommandBase(string name, string description, IDotNetCliRunner runner, IInteractionService interactionService, IProjectLocator projectLocator, AspireCliTelemetry telemetry, IDotNetSdkInstaller sdkInstaller, IFeatures features, ICliUpdateNotifier updateNotifier, CliExecutionContext executionContext, ICliHostEnvironment hostEnvironment)
        : base(name, description, features, updateNotifier, executionContext, interactionService)
    {
        ArgumentNullException.ThrowIfNull(runner);
        ArgumentNullException.ThrowIfNull(projectLocator);
        ArgumentNullException.ThrowIfNull(telemetry);
        ArgumentNullException.ThrowIfNull(sdkInstaller);
        ArgumentNullException.ThrowIfNull(hostEnvironment);
        ArgumentNullException.ThrowIfNull(features);
 
        _runner = runner;
        _projectLocator = projectLocator;
        _telemetry = telemetry;
        _sdkInstaller = sdkInstaller;
        _hostEnvironment = hostEnvironment;
        _features = features;
 
        var projectOption = new Option<FileInfo?>("--project")
        {
            Description = PublishCommandStrings.ProjectArgumentDescription
        };
        Options.Add(projectOption);
 
        var outputPath = new Option<string?>("--output-path", "-o")
        {
            Description = GetOutputPathDescription()
        };
        Options.Add(outputPath);
 
        Options.Add(_logLevelOption);
        Options.Add(_environmentOption);
 
        // In the publish and deploy commands we forward all unrecognized tokens
        // through to the underlying tooling when we launch the app host.
        TreatUnmatchedTokensAsErrors = false;
    }
 
    protected abstract string GetOutputPathDescription();
    protected abstract string[] GetRunArguments(string? fullyQualifiedOutputPath, string[] unmatchedTokens, ParseResult parseResult);
    protected abstract string GetCanceledMessage();
    protected abstract string GetProgressMessage(ParseResult parseResult);
 
    protected override async Task<int> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        // Send terminal infinite progress bar start sequence
        StartTerminalProgressBar();
 
        // Check if the .NET SDK is available
        if (!await SdkInstallHelper.EnsureSdkInstalledAsync(_sdkInstaller, InteractionService, _features, _hostEnvironment, cancellationToken))
        {
            // Send terminal progress bar stop sequence
            StopTerminalProgressBar();
            return ExitCodeConstants.SdkNotInstalled;
        }
 
        var buildOutputCollector = new OutputCollector();
        var operationOutputCollector = new OutputCollector();
 
        (bool IsCompatibleAppHost, bool SupportsBackchannel, string? AspireHostingVersion)? appHostCompatibilityCheck = null;
 
        try
        {
            using var activity = _telemetry.ActivitySource.StartActivity(this.Name);
 
            var passedAppHostProjectFile = parseResult.GetValue<FileInfo?>("--project");
            var effectiveAppHostFile = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, createSettingsFile: true, cancellationToken);
 
            if (effectiveAppHostFile is null)
            {
                // Send terminal progress bar stop sequence
                StopTerminalProgressBar();
                return ExitCodeConstants.FailedToFindProject;
            }
 
            var isSingleFileAppHost = effectiveAppHostFile.Extension != ".csproj";
 
            var env = new Dictionary<string, string>();
 
            // Set interactivity enabled based on host environment capabilities
            if (!_hostEnvironment.SupportsInteractiveInput)
            {
                env[KnownConfigNames.InteractivityEnabled] = "false";
            }
 
            var waitForDebugger = parseResult.GetValue<bool?>("--wait-for-debugger") ?? false;
            if (waitForDebugger)
            {
                env[KnownConfigNames.WaitForDebugger] = "true";
            }
 
            if (isSingleFileAppHost)
            {
                // TODO: Add logic to read SDK version from *.cs file.
                appHostCompatibilityCheck = (true, true, VersionHelper.GetDefaultTemplateVersion());
            }
            else
            {
                appHostCompatibilityCheck = await AppHostHelper.CheckAppHostCompatibilityAsync(_runner, InteractionService, effectiveAppHostFile, _telemetry, ExecutionContext.WorkingDirectory, cancellationToken);
            }
 
            if (!appHostCompatibilityCheck?.IsCompatibleAppHost ?? throw new InvalidOperationException("IsCompatibleAppHost is null"))
            {
                // Send terminal progress bar stop sequence
                StopTerminalProgressBar();
                return ExitCodeConstants.AppHostIncompatible;
            }
 
            var buildOptions = new DotNetCliRunnerInvocationOptions
            {
                StandardOutputCallback = buildOutputCollector.AppendOutput,
                StandardErrorCallback = buildOutputCollector.AppendError,
            };
 
            if (!isSingleFileAppHost)
            {
                var buildExitCode = await AppHostHelper.BuildAppHostAsync(_runner, InteractionService, effectiveAppHostFile, buildOptions, ExecutionContext.WorkingDirectory, cancellationToken);
 
                if (buildExitCode != 0)
                {
                    // Send terminal progress bar stop sequence
                    StopTerminalProgressBar();
                    InteractionService.DisplayLines(buildOutputCollector.GetLines());
                    InteractionService.DisplayError(InteractionServiceStrings.ProjectCouldNotBeBuilt);
                    return ExitCodeConstants.FailedToBuildArtifacts;
                }
            }
 
            var outputPath = parseResult.GetValue<string?>("--output-path");
            var fullyQualifiedOutputPath = outputPath != null ? Path.GetFullPath(outputPath) : null;
 
            var backchannelCompletionSource = new TaskCompletionSource<IAppHostBackchannel>();
 
            var operationRunOptions = new DotNetCliRunnerInvocationOptions
            {
                StandardOutputCallback = operationOutputCollector.AppendOutput,
                StandardErrorCallback = operationOutputCollector.AppendError,
                NoLaunchProfile = true,
                NoExtensionLaunch = true
            };
 
            var unmatchedTokens = parseResult.UnmatchedTokens.ToArray();
 
            var pendingRun = _runner.RunAsync(
                effectiveAppHostFile,
                false,
                true,
                GetRunArguments(fullyQualifiedOutputPath, unmatchedTokens, parseResult),
                env,
                backchannelCompletionSource,
                operationRunOptions,
                cancellationToken);
 
            // If we use the --wait-for-debugger option we print out the process ID
            // of the apphost so that the user can attach to it.
            if (waitForDebugger)
            {
                InteractionService.DisplayMessage("bug", InteractionServiceStrings.WaitingForDebuggerToAttachToAppHost);
            }
 
            var backchannel = await InteractionService.ShowStatusAsync($":hammer_and_wrench:  {GetProgressMessage(parseResult)}", async () =>
            {
                return await backchannelCompletionSource.Task.ConfigureAwait(false);
            });
 
            var publishingActivities = backchannel.GetPublishingActivitiesAsync(cancellationToken);
 
            var debugMode = parseResult.GetValue<bool?>("--debug") ?? false;
 
            var noFailuresReported = debugMode switch
            {
                true => await ProcessPublishingActivitiesDebugAsync(publishingActivities, backchannel, cancellationToken),
                false => await ProcessAndDisplayPublishingActivitiesAsync(publishingActivities, backchannel, cancellationToken),
            };
 
            // Send terminal progress bar stop sequence
            StopTerminalProgressBar();
 
            await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false);
            var exitCode = await pendingRun;
 
            if (exitCode == 0 && noFailuresReported)
            {
                return ExitCodeConstants.Success;
            }
 
            if (debugMode)
            {
                InteractionService.DisplayLines(operationOutputCollector.GetLines());
            }
 
            return ExitCodeConstants.FailedToBuildArtifacts;
        }
        catch (OperationCanceledException)
        {
            // Send terminal progress bar stop sequence on cancellation
            StopTerminalProgressBar();
            InteractionService.DisplayError(GetCanceledMessage());
            return ExitCodeConstants.FailedToBuildArtifacts;
        }
        catch (ProjectLocatorException ex)
        {
            // Send terminal progress bar stop sequence on exception
            StopTerminalProgressBar();
            return HandleProjectLocatorException(ex, InteractionService);
        }
        catch (AppHostIncompatibleException ex)
        {
            // Send terminal progress bar stop sequence on exception
            StopTerminalProgressBar();
            return InteractionService.DisplayIncompatibleVersionError(
                ex,
                appHostCompatibilityCheck?.AspireHostingVersion ?? throw new InvalidOperationException(ErrorStrings.AspireHostingVersionNull)
                );
        }
        catch (FailedToConnectBackchannelConnection ex)
        {
            // Send terminal progress bar stop sequence on exception
            StopTerminalProgressBar();
            InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message));
            InteractionService.DisplayLines(operationOutputCollector.GetLines());
            return ExitCodeConstants.FailedToBuildArtifacts;
        }
        catch (Exception ex)
        {
            // Send terminal progress bar stop sequence on exception
            StopTerminalProgressBar();
            InteractionService.DisplayError(string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message));
            return ExitCodeConstants.FailedToBuildArtifacts;
        }
    }
 
    /// <summary>
    /// Conditionally converts markdown to Spectre markup based on the EnableMarkdown flag in the activity data.
    /// </summary>
    /// <param name="text">The text to convert.</param>
    /// <param name="activityData">The publishing activity data containing the EnableMarkdown flag.</param>
    /// <returns>The converted text if markdown is enabled, otherwise the original text.</returns>
    private static string ConvertTextWithMarkdownFlag(string text, PublishingActivityData activityData)
    {
        return activityData.EnableMarkdown ? MarkdownToSpectreConverter.ConvertToSpectre(text) : text.EscapeMarkup();
    }
 
    public async Task<bool> ProcessPublishingActivitiesDebugAsync(IAsyncEnumerable<PublishingActivity> publishingActivities, IAppHostBackchannel backchannel, CancellationToken cancellationToken)
    {
        var stepCounter = 1;
        var steps = new Dictionary<string, string>();
        PublishingActivity? publishingActivity = null;
 
        await foreach (var activity in publishingActivities.WithCancellation(cancellationToken))
        {
            StartTerminalProgressBar();
            if (activity.Type == PublishingActivityTypes.PublishComplete)
            {
                publishingActivity = activity;
                break;
            }
            else if (activity.Type == PublishingActivityTypes.Step)
            {
                if (!steps.TryGetValue(activity.Data.Id, out var stepStatus))
                {
                    // New step - log it
                    var statusText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                    InteractionService.DisplaySubtleMessage($"[[DEBUG]] Step {stepCounter++}: {statusText}", escapeMarkup: false);
                    steps[activity.Data.Id] = activity.Data.CompletionState;
                }
                else if (IsCompletionStateComplete(activity.Data.CompletionState))
                {
                    // Step completed - log completion
                    var status = IsCompletionStateError(activity.Data.CompletionState) ? "FAILED" :
                        IsCompletionStateWarning(activity.Data.CompletionState) ? "WARNING" : "COMPLETED";
                    var statusText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                    InteractionService.DisplaySubtleMessage($"[[DEBUG]] Step {activity.Data.Id}: {status} - {statusText}", escapeMarkup: false);
                    steps[activity.Data.Id] = activity.Data.CompletionState;
                }
            }
            else if (activity.Type == PublishingActivityTypes.Prompt)
            {
                await HandlePromptActivityAsync(activity, backchannel, cancellationToken);
            }
            else if (activity.Type == PublishingActivityTypes.Log)
            {
                // Log activity - display the log message
                var logLevel = activity.Data.LogLevel ?? "Information";
                var message = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                var timestamp = activity.Data.Timestamp?.ToString("HH:mm:ss", CultureInfo.InvariantCulture) ?? DateTimeOffset.UtcNow.ToString("HH:mm:ss", CultureInfo.InvariantCulture);
                
                // Use 3-letter prefixes for log levels
                var logPrefix = logLevel.ToUpperInvariant() switch
                {
                    "DEBUG" => "DBG",
                    "TRACE" => "TRC",
                    "INFORMATION" => "INF",
                    "WARNING" => "WRN",
                    "ERROR" => "ERR",
                    "CRITICAL" => "CRT",
                    _ => "INF"
                };
                
                // Make debug and trace logs more subtle
                var formattedMessage = logLevel.ToUpperInvariant() switch
                {
                    "DEBUG" => $"[[{timestamp}]] [dim][[{logPrefix}]] {message}[/]",
                    "TRACE" => $"[[{timestamp}]] [dim][[{logPrefix}]] {message}[/]",
                    _ => $"[[{timestamp}]] [[{logPrefix}]] {message}"
                };
                
                InteractionService.DisplaySubtleMessage(formattedMessage, escapeMarkup: false);
            }
            else
            {
                // Task activity - log it
                var stepId = activity.Data.StepId;
                if (IsCompletionStateComplete(activity.Data.CompletionState))
                {
                    var status = IsCompletionStateError(activity.Data.CompletionState) ? "FAILED" :
                        IsCompletionStateWarning(activity.Data.CompletionState) ? "WARNING" : "COMPLETED";
                    var statusText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                    InteractionService.DisplaySubtleMessage($"[[DEBUG]] Task {activity.Data.Id} ({stepId}): {status} - {statusText}", escapeMarkup: false);
                    if (!string.IsNullOrEmpty(activity.Data.CompletionMessage))
                    {
                        var completionMessage = ConvertTextWithMarkdownFlag(activity.Data.CompletionMessage, activity.Data);
                        InteractionService.DisplaySubtleMessage($"[[DEBUG]]   {completionMessage}", escapeMarkup: false);
                    }
                }
                else
                {
                    var statusText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                    InteractionService.DisplaySubtleMessage($"[[DEBUG]] Task {activity.Data.Id} ({stepId}): {statusText}", escapeMarkup: false);
                }
            }
        }
 
        var hasErrors = publishingActivity is not null && IsCompletionStateError(publishingActivity.Data.CompletionState);
        var hasWarnings = publishingActivity is not null && IsCompletionStateWarning(publishingActivity.Data.CompletionState);
 
        if (publishingActivity is not null)
        {
            var status = hasErrors ? "FAILED" : hasWarnings ? "WARNING" : "COMPLETED";
            var statusText = ConvertTextWithMarkdownFlag(publishingActivity.Data.StatusText, publishingActivity.Data);
            InteractionService.DisplaySubtleMessage($"[[DEBUG]] {OperationCompletedPrefix}: {status} - {statusText}", escapeMarkup: false);
 
            // Send visual bell notification when operation is complete
            Console.Write("\a");
            Console.Out.Flush();
        }
 
        return !hasErrors;
    }
 
    public async Task<bool> ProcessAndDisplayPublishingActivitiesAsync(IAsyncEnumerable<PublishingActivity> publishingActivities, IAppHostBackchannel backchannel, CancellationToken cancellationToken)
    {
        var stepCounter = 1;
        var steps = new Dictionary<string, StepInfo>();
        var logger = new ConsoleActivityLogger(_hostEnvironment);
        logger.StartSpinner();
        PublishingActivity? publishingActivity = null;
 
        try
        {
            await foreach (var activity in publishingActivities.WithCancellation(cancellationToken))
            {
                StartTerminalProgressBar();
                if (activity.Type == PublishingActivityTypes.PublishComplete)
                {
                    publishingActivity = activity;
                    break;
                }
                else if (activity.Type == PublishingActivityTypes.Step)
                {
                    if (!steps.TryGetValue(activity.Data.Id, out var stepInfo))
                    {
                        var title = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                        stepInfo = new StepInfo
                        {
                            Id = activity.Data.Id,
                            Title = title,
                            Number = stepCounter++,
                            StartTime = DateTime.UtcNow,
                            CompletionState = activity.Data.CompletionState
                        };
 
                        steps[activity.Data.Id] = stepInfo;
                        // Use the stable step Id for logger state tracking (prevents duplicate counting when titles repeat)
                        logger.StartTask(stepInfo.Id, stepInfo.Title, $"Starting {stepInfo.Title}...");
                    }
                    else if (IsCompletionStateComplete(activity.Data.CompletionState))
                    {
                        stepInfo.CompletionState = activity.Data.CompletionState;
                        stepInfo.CompletionText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                        stepInfo.EndTime = DateTime.UtcNow;
                        if (IsCompletionStateError(stepInfo.CompletionState))
                        {
                            logger.Failure(stepInfo.Id, stepInfo.CompletionText);
                        }
                        else if (IsCompletionStateWarning(stepInfo.CompletionState))
                        {
                            logger.Warning(stepInfo.Id, stepInfo.CompletionText);
                        }
                        else
                        {
                            logger.Success(stepInfo.Id, stepInfo.CompletionText);
                        }
                    }
                }
                else if (activity.Type == PublishingActivityTypes.Prompt)
                {
                    await logger.StopSpinnerAsync();
                    await HandlePromptActivityAsync(activity, backchannel, cancellationToken);
                    logger.StartSpinner();
                }
                else if (activity.Type == PublishingActivityTypes.Log)
                {
                    // Log activity - display through logger based on log level
                    var stepId = activity.Data.StepId;
                    if (stepId != null && steps.TryGetValue(stepId, out var stepInfo))
                    {
                        var logLevel = activity.Data.LogLevel ?? "Information";
                        var message = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                        
                        // Add 3-letter prefix to message for consistency
                        var logPrefix = logLevel.ToUpperInvariant() switch
                        {
                            "DEBUG" => "DBG",
                            "TRACE" => "TRC", 
                            "INFORMATION" => "INF",
                            "WARNING" => "WRN",
                            "ERROR" => "ERR",
                            "CRITICAL" => "CRT",
                            _ => "INF"
                        };
                        
                        var prefixedMessage = $"[[{logPrefix}]] {message}";
                        
                        // Map log levels to appropriate console logger methods
                        switch (logLevel.ToUpperInvariant())
                        {
                            case "ERROR":
                            case "CRITICAL":
                                logger.Failure(stepInfo.Id, prefixedMessage);
                                break;
                            case "WARNING":
                            case "WARN":
                                logger.Warning(stepInfo.Id, prefixedMessage);
                                break;
                            case "DEBUG":
                            case "TRACE":
                                // Use a more subtle approach for debug/trace - prefix with dim formatting
                                var subtleMessage = $"[dim]{prefixedMessage}[/]";
                                logger.Info(stepInfo.Id, subtleMessage);
                                break;
                            case "INFORMATION":
                            case "INFO":
                            default:
                                logger.Info(stepInfo.Id, prefixedMessage);
                                break;
                        }
                    }
                }
                else
                {
                    var stepId = activity.Data.StepId;
                    Debug.Assert(stepId != null, "Activity data should have a StepId for task activities.");
 
                    if (!steps.TryGetValue(stepId, out var stepInfo))
                    {
                        throw new InvalidOperationException($"Step '{stepId}' not found for task '{activity.Data.Id}'");
                    }
 
                    var tasks = stepInfo.Tasks;
 
                    if (!tasks.TryGetValue(activity.Data.Id, out var task))
                    {
                        var statusText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                        task = new TaskInfo
                        {
                            Id = activity.Data.Id,
                            StatusText = statusText,
                            StartTime = DateTime.UtcNow,
                            CompletionState = activity.Data.CompletionState
                        };
 
                        tasks[activity.Data.Id] = task;
                        logger.Progress(stepInfo.Id, statusText);
                    }
 
                    task.StatusText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                    task.CompletionState = activity.Data.CompletionState;
 
                    if (IsCompletionStateComplete(activity.Data.CompletionState))
                    {
                        task.CompletionMessage = !string.IsNullOrEmpty(activity.Data.CompletionMessage)
                            ? ConvertTextWithMarkdownFlag(activity.Data.CompletionMessage, activity.Data)
                            : null;
 
                        var duration = DateTime.UtcNow - task.StartTime;
                        var durationStr = $"({duration.TotalSeconds:F1}s)";
 
                        var message = !string.IsNullOrEmpty(task.CompletionMessage)
                            ? $"{task.CompletionMessage} {durationStr}"
                            : $"{task.StatusText} {durationStr}";
 
                        if (IsCompletionStateError(task.CompletionState))
                        {
                            logger.Failure(stepInfo.Id, message);
                        }
                        else if (IsCompletionStateWarning(task.CompletionState))
                        {
                            logger.Warning(stepInfo.Id, message);
                        }
                        else
                        {
                            logger.Success(stepInfo.Id, message);
                        }
 
                        // If this task caused the step to fail, record a candidate failure reason if not already set.
                        if (IsCompletionStateError(task.CompletionState) && string.IsNullOrEmpty(stepInfo.FailureReason))
                        {
                            stepInfo.FailureReason = task.CompletionMessage ?? task.StatusText;
                        }
                    }
                }
            }
 
            if (publishingActivity is not null)
            {
                var hasErrors = IsCompletionStateError(publishingActivity.Data.CompletionState);
                var hasWarnings = IsCompletionStateWarning(publishingActivity.Data.CompletionState);
                // Determine first failed step (if any) for failure detail.
                string? failedStepTitle = null;
                string? failedStepMessage = null;
                if (hasErrors)
                {
                    var failedStep = steps.Values.FirstOrDefault(s => IsCompletionStateError(s.CompletionState));
                    if (failedStep is not null)
                    {
                        failedStepTitle = failedStep.Title;
                        failedStepMessage = failedStep.FailureReason ?? failedStep.CompletionText;
                    }
                }
 
                // Build duration breakdown (sorted by duration desc)
                var now = DateTime.UtcNow;
                var durationRecords = steps.Values.Select(s =>
                {
                    var end = s.EndTime ?? now;
                    var state = s.CompletionState switch
                    {
                        var cs when IsCompletionStateError(cs) => ConsoleActivityLogger.ActivityState.Failure,
                        var cs when IsCompletionStateWarning(cs) => ConsoleActivityLogger.ActivityState.Warning,
                        var cs when cs == CompletionStates.Completed => ConsoleActivityLogger.ActivityState.Success,
                        _ => ConsoleActivityLogger.ActivityState.InProgress
                    };
                    return new ConsoleActivityLogger.StepDurationRecord(
                        s.Id,
                        s.Title,
                        state,
                        end - s.StartTime,
                        s.FailureReason);
                })
                .OrderByDescending(r => r.Duration)
                .ToList();
                logger.SetStepDurations(durationRecords);
 
                // Provide final result to logger and print its structured summary.
                logger.SetFinalResult(!hasErrors);
                logger.WriteSummary();
 
                // Visual bell
                Console.Write("\a");
                Console.Out.Flush();
                return !hasErrors;
            }
 
            return true;
        }
        finally
        {
            await logger.StopSpinnerAsync();
        }
    }
 
    private static string BuildPromptText(PublishingPromptInput input, int inputCount, string statusText, PublishingActivityData activityData)
    {
        if (inputCount > 1)
        {
            // Multi-input: just show the label with markdown conversion
            var labelText = ConvertTextWithMarkdownFlag($"{input.Label}: ", activityData);
            return labelText;
        }
 
        // Single-input: show both StatusText and Label
        var header = statusText ?? string.Empty;
        var label = input.Label ?? string.Empty;
 
        // If StatusText equals Label (case-insensitive), show only the label once
        if (header.Equals(label, StringComparison.OrdinalIgnoreCase))
        {
            return $"[bold]{ConvertTextWithMarkdownFlag(label, activityData)}[/]";
        }
 
        // Show StatusText as header (converted from markdown), then Label on new line
        var convertedHeader = ConvertTextWithMarkdownFlag(header, activityData);
        var convertedLabel = ConvertTextWithMarkdownFlag(label, activityData);
        return $"[bold]{convertedHeader}[/]\n{convertedLabel}: ";
    }
 
    private async Task HandlePromptActivityAsync(PublishingActivity activity, IAppHostBackchannel backchannel, CancellationToken cancellationToken)
    {
        if (activity.Data.IsComplete)
        {
            // Prompt is already completed, nothing to do
            return;
        }
 
        // Check if we have input information
        if (activity.Data.Inputs is not { Count: > 0 } inputs)
        {
            throw new InvalidOperationException("Prompt provided without input data.");
        }
 
        // Check for validation errors. If there are errors then this isn't the first time the user has been prompted.
        var hasValidationErrors = inputs.Any(input => input.ValidationErrors is { Count: > 0 });
 
        // For multiple inputs, display the activity status text as a header.
        // Don't display if there are validation errors. Validation errors means the header has already been displayed.
        if (!hasValidationErrors && inputs.Count > 1)
        {
            var headerText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
            AnsiConsole.MarkupLine($"[bold]{headerText}[/]");
        }
 
        // Handle multiple inputs
        var answers = new PublishingPromptInputAnswer[inputs.Count];
        for (var i = 0; i < inputs.Count; i++)
        {
            var input = inputs[i];
 
            string? result;
 
            // Get prompt for input if there are no validation errors (first time we've asked)
            // or there are validation errors and this input has an error.
            if (!hasValidationErrors || input.ValidationErrors is { Count: > 0 })
            {
                // Build the prompt text based on number of inputs
                var promptText = BuildPromptText(input, inputs.Count, activity.Data.StatusText, activity.Data);
 
                result = await HandleSingleInputAsync(input, promptText, cancellationToken);
            }
            else
            {
                result = input.Value;
            }
 
            answers[i] = new PublishingPromptInputAnswer
            {
                Value = result
            };
        }
 
        // Send all results as an array
        await backchannel.CompletePromptResponseAsync(activity.Data.Id, answers, cancellationToken);
    }
 
    private async Task<string?> HandleSingleInputAsync(PublishingPromptInput input, string promptText, CancellationToken cancellationToken)
    {
        if (!Enum.TryParse<InputType>(input.InputType, ignoreCase: true, out var inputType))
        {
            // Fallback to text if unknown type
            inputType = InputType.Text;
        }
 
        // Display any validation errors.
        if (input.ValidationErrors is { Count: > 0 } errors)
        {
            foreach (var error in errors)
            {
                InteractionService.DisplayError(error);
            }
        }
 
        return inputType switch
        {
            InputType.Text => await InteractionService.PromptForStringAsync(
                promptText,
                defaultValue: input.Value,
                required: input.Required,
                cancellationToken: cancellationToken),
 
            InputType.SecretText => await InteractionService.PromptForStringAsync(
                promptText,
                defaultValue: input.Value,
                isSecret: true,
                required: input.Required,
                cancellationToken: cancellationToken),
 
            InputType.Choice => await HandleSelectInputAsync(input, promptText, cancellationToken),
 
            InputType.Boolean => (await InteractionService.ConfirmAsync(promptText, defaultValue: ParseBooleanValue(input.Value), cancellationToken: cancellationToken)).ToString().ToLowerInvariant(),
 
            InputType.Number => await HandleNumberInputAsync(input, promptText, cancellationToken),
 
            _ => await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value, required: input.Required, cancellationToken: cancellationToken)
        };
    }
 
    private async Task<string?> HandleSelectInputAsync(PublishingPromptInput input, string promptText, CancellationToken cancellationToken)
    {
        if (input.Options is null || input.Options.Count == 0)
        {
            return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value, required: input.Required, cancellationToken: cancellationToken);
        }
 
        // If AllowCustomChoice is enabled then add an "Other" option to the list.
        // CLI doesn't support custom values directly in selection prompts. Instead an "Other" option is added.
        // If "Other" is selected then the user is prompted to enter a custom value as text.
        var options = input.Options.ToList();
        if (input.AllowCustomChoice)
        {
            options.Add(KeyValuePair.Create(CustomChoiceValue, InteractionServiceStrings.CustomChoiceLabel));
        }
 
        // For Choice inputs, we can't directly set a default in PromptForSelectionAsync,
        // but we can reorder the options to put the default first or use a different approach
        var (value, displayText) = await InteractionService.PromptForSelectionAsync(
            promptText,
            options,
            choice => choice.Value,
            cancellationToken);
 
        if (value == CustomChoiceValue)
        {
            return await InteractionService.PromptForStringAsync(promptText, defaultValue: input.Value, required: input.Required, cancellationToken: cancellationToken);
        }
 
        AnsiConsole.MarkupLine($"{promptText} {displayText.EscapeMarkup()}");
 
        return value;
    }
 
    private async Task<string?> HandleNumberInputAsync(PublishingPromptInput input, string promptText, CancellationToken cancellationToken)
    {
        static ValidationResult Validator(string value)
        {
            if (!string.IsNullOrWhiteSpace(value) && !double.TryParse(value, out _))
            {
                return ValidationResult.Error("Please enter a valid number.");
            }
 
            return ValidationResult.Success();
        }
 
        return await InteractionService.PromptForStringAsync(
            promptText,
            defaultValue: input.Value,
            validator: Validator,
            required: input.Required,
            cancellationToken: cancellationToken);
    }
 
    private static bool ParseBooleanValue(string? value)
    {
        return bool.TryParse(value, out var result) && result;
    }
 
    private class StepInfo
    {
        public string Id { get; set; } = string.Empty;
        public string Title { get; set; } = string.Empty;
        public int Number { get; set; }
        public DateTime StartTime { get; set; }
        public DateTime? EndTime { get; set; }
        public string CompletionState { get; set; } = CompletionStates.InProgress;
        public string CompletionText { get; set; } = string.Empty;
        public string? FailureReason { get; set; }
        public Dictionary<string, TaskInfo> Tasks { get; } = [];
    }
 
    private class TaskInfo
    {
        public string Id { get; set; } = string.Empty;
        public string StatusText { get; set; } = string.Empty;
        public DateTime StartTime { get; set; }
        public string CompletionState { get; set; } = CompletionStates.InProgress;
        public string? CompletionMessage { get; set; }
    }
 
    // Removed legacy PublishingOutputRenderer and ProgressContextInfo (spinner & step coloring now handled by ConsoleActivityLogger).
 
    /// <summary>
    /// Starts the terminal infinite progress bar.
    /// </summary>
    private void StartTerminalProgressBar()
    {
        // Skip terminal progress bar in non-interactive environments
        if (!_hostEnvironment.SupportsInteractiveOutput)
        {
            return;
        }
        Console.Write("\u001b]9;4;3\u001b\\");
    }
 
    /// <summary>
    /// Stops the terminal progress bar.
    /// </summary>
    private void StopTerminalProgressBar()
    {
        // Skip terminal progress bar in non-interactive environments
        if (!_hostEnvironment.SupportsInteractiveOutput)
        {
            return;
        }
        Console.Write("\u001b]9;4;0\u001b\\");
    }
}