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 System.Text;
using System.Text.Json;
using Aspire.Cli.Backchannel;
using Aspire.Cli.Configuration;
using Aspire.Cli.DotNet;
using Aspire.Cli.Exceptions;
using Aspire.Cli.Interaction;
using Aspire.Cli.Projects;
using Aspire.Cli.Resources;
using Aspire.Cli.Utils;
using Aspire.Cli.Utils.Markdown;
using Aspire.Dashboard.Utils;
using Aspire.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Semver;
using Spectre.Console;
using StreamJsonRpc;
 
namespace Aspire.Cli.Commands;
 
internal abstract class PipelineCommandBase : BaseCommand
{
    protected override bool UpdateNotificationsEnabled => true;
 
    private const string CustomChoiceValue = "__CUSTOM_CHOICE";
    private const string ListStepsCapability = "pipeline-steps.v2";
    private const string InvalidListStepsFormatMessage = "The --format option requires either 'table' or 'json'.";
    private const string LegacyInspectOperationError = "Invalid operation specified. Valid operations are 'publish' or 'run'.";
    private const string ListStepsIncompatibleMessage = "The AppHost does not support --list-steps. Update the AppHost to a newer version of Aspire.";
    private const int MinimumHostingMajorVersionForListSteps = 13;
    private const int MinimumHostingMinorVersionForListSteps = 6;
 
    private bool _terminalProgressBarStarted;
 
    protected readonly IDotNetCliRunner _runner;
    protected readonly IProjectLocator _projectLocator;
    protected readonly IAppHostProjectFactory _projectFactory;
 
    private readonly IConfiguration _configuration;
    private readonly IFeatures _features;
    private readonly ICliHostEnvironment _hostEnvironment;
    private readonly ILogger _logger;
    private readonly IAnsiConsole _ansiConsole;
 
    protected static readonly OptionWithLegacy<FileInfo?> s_appHostOption = new("--apphost", "--project", PublishCommandStrings.ProjectArgumentDescription);
 
    private readonly Option<string?> _outputPathOption;
 
    protected static readonly Option<string?> s_pipelineLogLevelOption = new("--pipeline-log-level")
    {
        Description = SharedCommandStrings.PipelineLogLevelOptionDescription
    };
 
    protected static readonly Option<bool> s_includeExceptionDetailsOption = new("--include-exception-details")
    {
        Description = SharedCommandStrings.PipelineIncludeExceptionDetailsOptionDescription
    };
 
    protected static readonly Option<string?> s_environmentOption = new("--environment", "-e")
    {
        Description = SharedCommandStrings.PipelineEnvironmentOptionDescription
    };
 
    protected static readonly Option<bool> s_noBuildOption = new("--no-build")
    {
        Description = PublishCommandStrings.NoBuildArgumentDescription
    };
 
    protected static readonly Option<bool> s_listStepsOption = new("--list-steps")
    {
        Description = SharedCommandStrings.PipelineListStepsOptionDescription
    };
 
    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, IProjectLocator projectLocator, IFeatures features, ICliHostEnvironment hostEnvironment, IAppHostProjectFactory projectFactory, IConfiguration configuration, ILogger logger, IAnsiConsole ansiConsole, CommonCommandServices services)
        : base(name, description, services)
    {
        _runner = runner;
        _projectLocator = projectLocator;
        _hostEnvironment = hostEnvironment;
        _configuration = configuration;
        _features = features;
        _projectFactory = projectFactory;
        _logger = logger;
        _ansiConsole = ansiConsole;
 
        _outputPathOption = new Option<string?>("--output-path", "-o")
        {
            Description = GetOutputPathDescription()
        };
 
        Options.Add(s_appHostOption);
        Options.Add(_outputPathOption);
        Options.Add(s_pipelineLogLevelOption);
        Options.Add(s_environmentOption);
        Options.Add(s_includeExceptionDetailsOption);
        Options.Add(s_noBuildOption);
        Options.Add(s_listStepsOption);
 
        // 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 Task<string[]> GetRunArgumentsAsync(string? fullyQualifiedOutputPath, string[] unmatchedTokens, string? targetStep, ParseResult parseResult, CancellationToken cancellationToken);
    protected abstract string GetCanceledMessage();
    protected abstract string GetProgressMessage(ParseResult parseResult);
 
    /// <summary>
    /// Gets the target pipeline step name for this invocation.
    /// In list mode, a null target shows all steps.
    /// </summary>
    protected virtual string? GetTargetStepName(ParseResult parseResult) => null;
 
    /// <summary>
    /// Gets command-specific arguments to forward when starting a debug session from the extension context.
    /// Subclasses should override to include their specific positional arguments.
    /// Unmatched tokens are always included automatically.
    /// </summary>
    protected virtual string[] GetCommandArgs(ParseResult parseResult) => [];
 
    protected override bool IsJsonFormatRequested(ParseResult parseResult)
    {
        return base.IsJsonFormatRequested(parseResult) ||
            (parseResult.GetValue(s_listStepsOption) &&
             TryResolveListStepsInvocation(parseResult, out var outputFormat, out _, out _) &&
             outputFormat is OutputFormat.Json);
    }
 
    protected override async Task<CommandResult> ExecuteAsync(ParseResult parseResult, CancellationToken cancellationToken)
    {
        // If running in the extension context (Aspire terminal) without a debug session,
        // intercept and tell VS Code to start a proper debug session for this command.
        var passedAppHostProjectFile = parseResult.GetValue(s_appHostOption);
        var explicitAppHost = passedAppHostProjectFile is not null;
        var listSteps = parseResult.GetValue(s_listStepsOption);
        var unmatchedTokens = parseResult.UnmatchedTokens.ToArray();
        var targetStep = GetTargetStepName(parseResult);
        var outputFormat = OutputFormat.Table;
        if (listSteps && !TryResolveListStepsInvocation(parseResult, out outputFormat, out targetStep, out unmatchedTokens))
        {
            return CommandResult.Failure(CliExitCodes.InvalidCommand, InvalidListStepsFormatMessage);
        }
 
        if (ExtensionHelper.IsExtensionHost(InteractionService, out var extensionInteractionService, out _)
            && string.IsNullOrEmpty(_configuration[KnownConfigNames.ExtensionDebugSessionId])
            && !listSteps)
        {
            // Resolve the apphost project interactively before starting the debug session,
            // so the user is prompted if needed and we can pass it along.
            if (passedAppHostProjectFile is null)
            {
                var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, MultipleAppHostProjectsFoundBehavior.Prompt, createSettingsFile: true, cancellationToken);
                passedAppHostProjectFile = searchResult.SelectedProjectFile;
 
                if (passedAppHostProjectFile is null)
                {
                    return CommandResult.Failure(CliExitCodes.FailedToFindProject);
                }
            }
 
            var commandArgs = GetCommandArgs(parseResult).Concat(parseResult.UnmatchedTokens).ToArray();
 
            extensionInteractionService.DisplayConsolePlainText($"Detected aspire {Name} inside the Aspire extension, starting a debug session in VS Code...");
            await extensionInteractionService.StartDebugSessionAsync(
                ExecutionContext.WorkingDirectory.FullName,
                passedAppHostProjectFile?.FullName,
                debug: true,
                new DebugSessionOptions
                {
                    Command = Name,
                    Args = commandArgs.Length > 0 ? commandArgs : null,
                    AppHostSelectionOrigin = explicitAppHost
                        ? DebugSessionOptions.ExplicitCliAppHostSelectionOrigin
                        : DebugSessionOptions.DefaultDiscoveryAppHostSelectionOrigin,
                    EnvironmentVariables = new Dictionary<string, string>
                    {
                        [KnownConfigNames.AspireHome] = ExecutionContext.AspireHomeDirectory.FullName
                    }
                });
            return CommandResult.Success();
        }
 
        var debugMode = parseResult.GetValue(RootCommand.DebugOption);
        var waitForDebugger = parseResult.GetValue(RootCommand.WaitForDebuggerOption);
        var noBuild = parseResult.GetValue(s_noBuildOption);
        var startDebugSession = ExtensionHelper.IsExtensionHost(InteractionService, out _, out _) && parseResult.GetValue(RootCommand.StartDebugSessionOption);
 
        Task<int>? pendingRun = null;
        PublishContext? publishContext = null;
 
        // Machine-readable output must not contain terminal control sequences.
        if (!listSteps || outputFormat is not OutputFormat.Json)
        {
            StartTerminalProgressBar();
        }
 
        try
        {
            using var activity = Telemetry.StartDiagnosticActivity(this.Name);
 
            var searchResult = await _projectLocator.UseOrFindAppHostProjectFileAsync(passedAppHostProjectFile, MultipleAppHostProjectsFoundBehavior.Prompt, createSettingsFile: true, cancellationToken);
            var effectiveAppHostFile = searchResult.SelectedProjectFile;
 
            if (effectiveAppHostFile is null)
            {
                // Send terminal progress bar stop sequence
                StopTerminalProgressBar();
                return CommandResult.Failure(CliExitCodes.FailedToFindProject);
            }
 
            var project = _projectFactory.GetProject(effectiveAppHostFile);
            if (listSteps)
            {
                var aspireHostingVersion = await project.GetAspireHostingVersionAsync(effectiveAppHostFile, cancellationToken);
                if (IsKnownIncompatibleWithListSteps(aspireHostingVersion))
                {
                    throw new AppHostIncompatibleException(
                        ListStepsIncompatibleMessage,
                        ListStepsCapability,
                        aspireHostingVersion);
                }
            }
 
            var env = new Dictionary<string, string>
            {
                [KnownConfigNames.AspireHome] = ExecutionContext.AspireHomeDirectory.FullName
            };
 
            // Set interactivity enabled based on host environment capabilities
            if (!_hostEnvironment.SupportsInteractiveInput)
            {
                env[KnownConfigNames.InteractivityEnabled] = "false";
            }
 
            if (waitForDebugger)
            {
                env[KnownConfigNames.WaitForDebugger] = "true";
            }
 
            var outputPath = parseResult.GetValue(_outputPathOption);
            var fullyQualifiedOutputPath = outputPath != null ? Path.GetFullPath(outputPath) : null;
 
            var backchannelCompletionSource = new TaskCompletionSource<IAppHostCliBackchannel>();
 
            var runArguments = await GetRunArgumentsAsync(fullyQualifiedOutputPath, unmatchedTokens, targetStep, parseResult, cancellationToken);
 
            if (listSteps)
            {
                runArguments = [.. runArguments, "--operation", "inspect", "--list-steps", "true"];
            }
 
            // Create the publish context and delegate to IAppHostProject
            publishContext = new PublishContext
            {
                AppHostFile = effectiveAppHostFile,
                OutputPath = fullyQualifiedOutputPath,
                EnvironmentVariables = env,
                Arguments = runArguments,
                BackchannelCompletionSource = backchannelCompletionSource,
                WorkingDirectory = ExecutionContext.WorkingDirectory,
                Debug = debugMode,
                StartDebugSession = startDebugSession,
                NoBuild = noBuild
            };
 
            pendingRun = project.PublishAsync(publishContext, 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(KnownEmojis.Bug, InteractionServiceStrings.WaitingForDebuggerToAttachToAppHost);
            }
 
            var backchannel = await InteractionService.ShowStatusAsync(GetProgressMessage(parseResult), (Func<Task<IAppHostCliBackchannel>>)(async () =>
            {
                var completedTask = await Task.WhenAny(backchannelCompletionSource.Task, pendingRun);
                if (completedTask == backchannelCompletionSource.Task)
                {
                    return await backchannelCompletionSource.Task;
                }
 
                // Check if the task faulted with a known exception type that should be propagated directly
                if (completedTask.IsFaulted && completedTask.Exception?.InnerException is DotNetSdkNotInstalledException sdkException)
                {
                    throw sdkException;
                }
 
                // When running in extension context, the extension takes over apphost management.
                // DotNetCliRunner returns Success immediately after delegating to LaunchAppHostAsync,
                // so pendingRun completes before the backchannel is established. In this case,
                // continue waiting for the backchannel rather than throwing.
                if (!completedTask.IsFaulted && await pendingRun == CliExitCodes.Success
                    && ExtensionHelper.IsExtensionHost(InteractionService, out _, out _))
                {
                    return await backchannelCompletionSource.Task;
                }
 
                // Throw an error if the run completed without returning a backchannel.
                // Include possible error if the run task faulted.
                var innerException = completedTask.IsFaulted ? completedTask.Exception : null;
                throw new InvalidOperationException("Run completed without returning a backchannel.", innerException);
            }), emoji: KnownEmojis.HammerAndWrench);
 
            // If --list-steps was specified, get pipeline steps and print them instead of executing
            if (listSteps)
            {
                StopTerminalProgressBar();
                int inspectionExitCode;
 
                try
                {
                    // Check that the AppHost supports this capability before calling
                    var capabilities = await backchannel.GetCapabilitiesAsync(cancellationToken);
                    if (!capabilities.Contains(ListStepsCapability))
                    {
                        throw new AppHostIncompatibleException(
                            ListStepsIncompatibleMessage,
                            ListStepsCapability);
                    }
 
                    var response = await backchannel.GetPipelineStepsAsync(targetStep, cancellationToken);
                    if (outputFormat is OutputFormat.Json)
                    {
                        var json = JsonSerializer.Serialize(response.Steps, JsonSourceGenerationContext.RelaxedEscaping.PipelineStepInfoArray);
                        InteractionService.DisplayRawText(json, ConsoleOutput.Standard);
                    }
                    else
                    {
                        PrintPipelineSteps(response.Steps);
                    }
                }
                finally
                {
                    try
                    {
                        await backchannel.RequestStopAsync(CancellationToken.None).ConfigureAwait(false);
                    }
                    finally
                    {
                        inspectionExitCode = await pendingRun;
                    }
                }
 
                return CommandResult.FromExitCode(inspectionExitCode);
            }
 
            var publishingActivities = backchannel.GetPublishingActivitiesAsync(cancellationToken);
 
            // Check if debug or trace logging is enabled
            var logLevel = parseResult.GetValue(s_pipelineLogLevelOption);
            var isDebugOrTraceLoggingEnabled = logLevel?.Equals("debug", StringComparison.OrdinalIgnoreCase) == true ||
                                                 logLevel?.Equals("trace", StringComparison.OrdinalIgnoreCase) == true;
 
            var noFailuresReported = debugMode switch
            {
                true => await ProcessPublishingActivitiesDebugAsync(publishingActivities, backchannel, cancellationToken),
                false => await ProcessAndDisplayPublishingActivitiesAsync(publishingActivities, backchannel, isDebugOrTraceLoggingEnabled, cancellationToken),
            };
 
            // Send terminal progress bar stop sequence
            StopTerminalProgressBar();
 
            await backchannel.RequestStopAsync(cancellationToken).ConfigureAwait(false);
            var exitCode = await pendingRun;
 
            // If the apphost returned a non-zero exit code, use it directly.
            // This ensures we properly propagate apphost failures (e.g., exceptions, crashes).
            if (exitCode != 0)
            {
                if (debugMode && publishContext?.OutputCollector is { } outputCollector)
                {
                    InteractionService.DisplayLines(outputCollector.GetLines());
                }
                return CommandResult.FromExitCode(exitCode);
            }
 
            // If the apphost exited successfully (0) but reported failures via backchannel,
            // return a failure exit code.
            if (!noFailuresReported)
            {
                return CommandResult.Failure(CliExitCodes.FailedToBuildArtifacts);
            }
 
            // Both apphost exit code and backchannel indicate success
            return CommandResult.Success();
        }
        catch (ExtensionOperationCanceledException)
        {
            // BaseCommand handles extension prompt dismissal without displaying an error notification.
            StopTerminalProgressBar();
            throw;
        }
        catch (OperationCanceledException ex)
        {
            // Send terminal progress bar stop sequence on cancellation
            StopTerminalProgressBar();
            _logger.LogDebug(ex, "Operation was cancelled.");
            var canceledMessage = GetCanceledMessage();
            Telemetry.RecordError(canceledMessage, ex);
            return CommandResult.Failure(CliExitCodes.FailedToBuildArtifacts, canceledMessage);
        }
        catch (ProjectLocatorException ex)
        {
            // Send terminal progress bar stop sequence on exception
            StopTerminalProgressBar();
            return HandleProjectLocatorException(ex, InteractionService, Telemetry);
        }
        catch (DotNetSdkNotInstalledException)
        {
            // SDK not installed - message already displayed by EnsureSdkInstalledAsync
            StopTerminalProgressBar();
            return CommandResult.Failure(CliExitCodes.SdkNotInstalled);
        }
        catch (AppHostIncompatibleException ex)
        {
            // Send terminal progress bar stop sequence on exception
            StopTerminalProgressBar();
            Telemetry.RecordError($"AppHost is incompatible. Required capability: {ex.RequiredCapability}", ex);
            return CommandResult.Failure(CliExitCodes.AppHostIncompatible, ex.Message);
        }
        catch (Exception ex) when (listSteps && HasLegacyInspectOperationError(publishContext?.OutputCollector))
        {
            StopTerminalProgressBar();
            Telemetry.RecordError($"AppHost is incompatible. Required capability: {ListStepsCapability}", ex);
            return CommandResult.Failure(CliExitCodes.AppHostIncompatible, ListStepsIncompatibleMessage);
        }
        catch (FailedToConnectBackchannelConnection ex)
        {
            // Send terminal progress bar stop sequence on exception
            StopTerminalProgressBar();
            Telemetry.RecordError("Failed to connect to AppHost backchannel.", ex);
            var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.ErrorConnectingToAppHost, ex.Message);
            InteractionService.DisplayError(errorMessage);
            if (publishContext?.OutputCollector is { } outputCollector)
            {
                InteractionService.DisplayLines(outputCollector.GetLines());
            }
            return CommandResult.Failure(CliExitCodes.FailedToBuildArtifacts);
        }
        catch (ConnectionLostException ex)
        {
            // Occurs if the apphost RPC channel is lost unexpectedly.
            StopTerminalProgressBar();
            Telemetry.RecordError("Connection to AppHost was lost unexpectedly.", ex);
            var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.AppHostConnectionLost, ex.Message);
            InteractionService.DisplayError(errorMessage);
            if (publishContext?.OutputCollector is { } outputCollector)
            {
                InteractionService.DisplayLines(outputCollector.GetLines());
            }
            return CommandResult.FromExitCode(pendingRun is { } && debugMode ? await pendingRun : CliExitCodes.FailedToBuildArtifacts);
        }
        catch (Exception ex)
        {
            // Send terminal progress bar stop sequence on exception
            StopTerminalProgressBar();
            Telemetry.RecordError("An unexpected error occurred during pipeline execution.", ex);
            var errorMessage = string.Format(CultureInfo.CurrentCulture, InteractionServiceStrings.UnexpectedErrorOccurred, ex.Message);
            InteractionService.DisplayError(errorMessage);
            if (publishContext?.OutputCollector is { } outputCollector)
            {
                InteractionService.DisplayLines(outputCollector.GetLines());
            }
            return CommandResult.Failure(CliExitCodes.FailedToBuildArtifacts);
        }
    }
 
    private static bool IsKnownIncompatibleWithListSteps(string? aspireHostingVersion)
    {
        if (string.IsNullOrWhiteSpace(aspireHostingVersion) ||
            !SemVersion.TryParse(aspireHostingVersion, SemVersionStyles.Any, out var version))
        {
            return false;
        }
 
        return version.Major < MinimumHostingMajorVersionForListSteps ||
            (version.Major == MinimumHostingMajorVersionForListSteps &&
             version.Minor < MinimumHostingMinorVersionForListSteps);
    }
 
    private static bool TryExtractListStepsFormat(
        IReadOnlyList<string> unmatchedTokens,
        out OutputFormat format,
        out string[] remainingTokens)
    {
        format = OutputFormat.Table;
        var remaining = new List<string>(unmatchedTokens.Count);
 
        for (var i = 0; i < unmatchedTokens.Count; i++)
        {
            var token = unmatchedTokens[i];
            string value;
 
            if (token == "--format")
            {
                if (++i >= unmatchedTokens.Count)
                {
                    remainingTokens = [];
                    return false;
                }
 
                value = unmatchedTokens[i];
            }
            else if (token.StartsWith("--format=", StringComparison.Ordinal))
            {
                value = token["--format=".Length..];
            }
            else
            {
                remaining.Add(token);
                continue;
            }
 
            if (value.Equals("json", StringComparison.OrdinalIgnoreCase))
            {
                format = OutputFormat.Json;
            }
            else if (value.Equals("table", StringComparison.OrdinalIgnoreCase))
            {
                format = OutputFormat.Table;
            }
            else
            {
                remainingTokens = [];
                return false;
            }
        }
 
        remainingTokens = [.. remaining];
        return true;
    }
 
    private bool TryResolveListStepsInvocation(
        ParseResult parseResult,
        out OutputFormat outputFormat,
        out string? targetStep,
        out string[] unmatchedTokens)
    {
        targetStep = GetTargetStepName(parseResult);
        unmatchedTokens = parseResult.UnmatchedTokens.ToArray();
 
        // System.CommandLine can bind the unregistered --format token to `do`'s optional step.
        // Put it back before parsing the list-only option, then recover the actual positional step
        // from the tokens that remain after the format pair is removed.
        var formatTokenParsedAsTarget = targetStep is not null && IsFormatToken(targetStep);
        if (formatTokenParsedAsTarget)
        {
            unmatchedTokens = [targetStep!, .. unmatchedTokens];
            targetStep = null;
        }
 
        if (!TryExtractListStepsFormat(unmatchedTokens, out outputFormat, out unmatchedTokens))
        {
            return false;
        }
 
        if (formatTokenParsedAsTarget)
        {
            targetStep = ExtractPositionalTarget(unmatchedTokens, out unmatchedTokens);
        }
 
        return true;
    }
 
    private static bool IsFormatToken(string token) =>
        token == "--format" || token.StartsWith("--format=", StringComparison.Ordinal);
 
    private static string? ExtractPositionalTarget(string[] tokens, out string[] remainingTokens)
    {
        for (var i = 0; i < tokens.Length; i++)
        {
            if (!tokens[i].StartsWith("-", StringComparison.Ordinal))
            {
                remainingTokens = [.. tokens[..i], .. tokens[(i + 1)..]];
                return tokens[i];
            }
        }
 
        remainingTokens = tokens;
        return null;
    }
 
    private static bool HasLegacyInspectOperationError(OutputCollector? outputCollector) =>
        outputCollector?.GetLines().Any(line =>
            line.Line.Contains(LegacyInspectOperationError, StringComparison.Ordinal)) == true;
 
    /// <summary>
    /// Prints pipeline steps in a numbered tree format showing dependencies and tags.
    /// </summary>
    internal void PrintPipelineSteps(PipelineStepInfo[] steps)
    {
        if (steps.Length == 0)
        {
            _ansiConsole.MarkupLine("[dim]No pipeline steps found.[/]");
            return;
        }
 
        for (var i = 0; i < steps.Length; i++)
        {
            var step = steps[i];
 
            _ansiConsole.MarkupLine($"[bold green]{i + 1}.[/] [cyan]{step.Name.EscapeMarkup()}[/]");
 
            var hasDeps = step.DependsOn.Length > 0;
            var hasTags = step.Tags.Length > 0;
 
            if (!hasDeps && !hasTags)
            {
                _ansiConsole.MarkupLine("[dim]   └─ No dependencies[/]");
            }
            else
            {
                if (hasDeps)
                {
                    var connector = hasTags ? "├" : "└";
                    var continuation = hasTags ? "│" : " ";
                    var firstLinePrefix = $"   {connector}─ [blue]Depends on:[/] ";
                    // Build continuation prefix that aligns wrapped items under the first dep value.
                    // Replace the connector with the continuation char and pad the rest with spaces.
                    var visibleWidth = StripMarkup(firstLinePrefix).Length;
                    var continuationPrefix = "   " + continuation + new string(' ', visibleWidth - 4);
                    var wrappedDeps = FormatWithHangingIndent(step.DependsOn, firstLinePrefix, continuationPrefix);
                    _ansiConsole.MarkupLine(wrappedDeps);
                }
 
                if (hasTags)
                {
                    var tagsText = string.Join(", ", step.Tags);
                    _ansiConsole.MarkupLine($"   └─ [yellow]Tags:[/] {tagsText.EscapeMarkup()}");
                }
            }
 
            if (i < steps.Length - 1)
            {
                _ansiConsole.WriteLine();
            }
        }
    }
 
    /// <summary>
    /// Formats a list of items with a prefix on the first line and hanging indent on continuation lines.
    /// Items are comma-separated and wrapped so each line stays readable.
    /// </summary>
    private static string FormatWithHangingIndent(string[] items, string firstLinePrefix, string continuationPrefix, int maxLineLength = 100)
    {
        if (items.Length == 0)
        {
            return firstLinePrefix;
        }
 
        var sb = new StringBuilder();
        sb.Append(firstLinePrefix);
 
        // Track visible length (without markup tags) for line wrapping
        var visiblePrefixLength = StripMarkup(firstLinePrefix).Length;
        var currentLineLength = visiblePrefixLength;
 
        for (var i = 0; i < items.Length; i++)
        {
            var item = items[i].EscapeMarkup();
            var separator = i < items.Length - 1 ? ", " : "";
            var chunk = item + separator;
 
            if (i > 0 && currentLineLength + chunk.Length > maxLineLength)
            {
                sb.AppendLine();
                sb.Append(continuationPrefix);
                currentLineLength = StripMarkup(continuationPrefix).Length;
            }
 
            sb.Append(chunk);
            currentLineLength += chunk.Length;
        }
 
        return sb.ToString();
    }
 
    private static string StripMarkup(string text)
    {
        // Remove Spectre markup tags like [bold], [/], [blue], etc.
        return System.Text.RegularExpressions.Regex.Replace(text, @"\[/?[^\]]*\]", "");
    }
 
    /// <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, IAppHostCliBackchannel 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}", allowMarkup: true);
                    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}", allowMarkup: true);
                    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 (parsedLogLevel, logPrefix) = ParseLogLevel(activity.Data.LogLevel);
                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);
 
                // Make debug and trace logs more subtle
                var formattedMessage = parsedLogLevel switch
                {
                    LogLevel.Debug or LogLevel.Trace => $"[[{timestamp}]] [dim][[{logPrefix}]] {message}[/]",
                    _ => $"[[{timestamp}]] [[{logPrefix}]] {message}"
                };
 
                InteractionService.DisplaySubtleMessage(formattedMessage, allowMarkup: true);
            }
            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}", allowMarkup: true);
                    if (!string.IsNullOrEmpty(activity.Data.CompletionMessage))
                    {
                        var completionMessage = ConvertTextWithMarkdownFlag(activity.Data.CompletionMessage, activity.Data);
                        InteractionService.DisplaySubtleMessage($"[[DEBUG]]   {completionMessage}", allowMarkup: true);
                    }
                }
                else
                {
                    var statusText = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
                    InteractionService.DisplaySubtleMessage($"[[DEBUG]] Task {activity.Data.Id} ({stepId}): {statusText}", allowMarkup: true);
                }
            }
        }
 
        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}", allowMarkup: true);
 
            // Send visual bell notification when operation is complete
            Console.Write("\a");
            Console.Out.Flush();
        }
 
        return !hasErrors;
    }
 
    public async Task<bool> ProcessAndDisplayPublishingActivitiesAsync(IAsyncEnumerable<PublishingActivity> publishingActivities, IAppHostCliBackchannel backchannel, bool isDebugOrTraceLoggingEnabled, CancellationToken cancellationToken)
    {
        var stepCounter = 1;
        var steps = new Dictionary<string, StepInfo>();
        var logger = new ConsoleActivityLogger(_ansiConsole, _hostEnvironment, isDebugOrTraceLoggingEnabled);
        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,
                            ParentStepId = activity.Data.ParentStepId,
                            HierarchyLevel = activity.Data.HierarchyLevel ?? 0
                        };
 
                        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.ParentStepId ??= activity.Data.ParentStepId;
                        stepInfo.HierarchyLevel = activity.Data.HierarchyLevel ?? stepInfo.HierarchyLevel;
                        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 (parsedLogLevel, logPrefix) = ParseLogLevel(activity.Data.LogLevel);
                        var message = ConvertTextWithMarkdownFlag(activity.Data.StatusText, activity.Data);
 
                        var prefixedMessage = $"[[{logPrefix}]] {message}";
 
                        switch (parsedLogLevel)
                        {
                            case LogLevel.Error:
                            case LogLevel.Critical:
                                logger.Failure(stepInfo.Id, prefixedMessage);
                                break;
                            case LogLevel.Warning:
                                logger.Warning(stepInfo.Id, prefixedMessage);
                                break;
                            case LogLevel.Debug:
                            case LogLevel.Trace:
                                logger.Info(stepInfo.Id, prefixedMessage, dim: true);
                                break;
                            case LogLevel.Information:
                            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, ordered by step sequence
                var now = DateTime.UtcNow;
                var earliestStartTime = steps.Count > 0
                    ? steps.Values.Min(s => s.StartTime)
                    : now;
                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,
                        s.ParentStepId,
                        s.HierarchyLevel,
                        s.Number,
                        s.StartTime - earliestStartTime,
                        end - earliestStartTime);
                })
                .OrderBy(r => r.Sequence)
                .ToList();
                logger.SetStepDurations(durationRecords);
 
                // Provide final result to logger and print its structured summary.
                // Pass the pipeline summary if available for successful pipelines
                var pipelineSummary = !hasErrors ? publishingActivity.Data.PipelineSummary : null;
                logger.SetFinalResult(!hasErrors, pipelineSummary);
                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, IAppHostCliBackchannel 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, backchannel, activity.Data.Id, cancellationToken);
            }
            else
            {
                result = input.Value;
            }
 
            answers[i] = new PublishingPromptInputAnswer
            {
                Name = input.Name,
                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, IAppHostCliBackchannel backchannel, string interactionId, CancellationToken cancellationToken)
    {
        // The wire format uses hyphens (e.g. "secret-text") but the enum uses PascalCase (SecretText).
        var normalizedType = input.InputType.Replace("-", "", StringComparison.Ordinal);
        if (!Enum.TryParse<InputType>(normalizedType, ignoreCase: true, out var inputType))
        {
            throw new InvalidOperationException($"Unsupported input type: {input.InputType}");
        }
 
        // Display any validation errors.
        if (input.ValidationErrors is { Count: > 0 } errors)
        {
            foreach (var error in errors)
            {
                InteractionService.DisplayError(error);
            }
        }
 
        var result = inputType switch
        {
            InputType.Text => await InteractionService.PromptForStringAsync(
                promptText,
                binding: PromptBinding.CreateDefault(input.Value),
                required: input.Required,
                cancellationToken: cancellationToken),
 
            InputType.SecretText => await InteractionService.PromptForStringAsync(
                promptText,
                binding: PromptBinding.CreateDefault(input.Value),
                isSecret: true,
                required: input.Required,
                cancellationToken: cancellationToken),
 
            InputType.Choice => await HandleSelectInputAsync(input, promptText, cancellationToken),
 
            InputType.Boolean => (await InteractionService.PromptConfirmAsync(promptText, binding: PromptBinding.CreateDefault(ParseBooleanValue(input.Value)), cancellationToken: cancellationToken)).ToString().ToLowerInvariant(),
 
            InputType.Number => await HandleNumberInputAsync(input, promptText, cancellationToken),
 
            InputType.File => await HandleFileInputAsync(input, promptText, backchannel, interactionId, cancellationToken),
 
            _ => throw new InvalidOperationException($"Unsupported input type: {input.InputType}"),
        };
 
        return result;
    }
 
    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, binding: PromptBinding.CreateDefault(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.EscapeMarkup(),
            cancellationToken: cancellationToken);
 
        if (value == CustomChoiceValue)
        {
            return await InteractionService.PromptForStringAsync(promptText, binding: PromptBinding.CreateDefault(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,
            binding: PromptBinding.CreateDefault(input.Value),
            validator: Validator,
            required: input.Required,
            cancellationToken: cancellationToken);
    }
 
    private async Task<string?> HandleFileInputAsync(PublishingPromptInput input, string promptText, IAppHostCliBackchannel backchannel, string interactionId, CancellationToken cancellationToken)
    {
        if (string.IsNullOrEmpty(input.Name))
        {
            throw new InvalidOperationException("File prompt input is missing a name.");
        }
        var inputName = input.Name;
 
        ValidationResult Validator(string value)
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                return ValidationResult.Success();
            }
 
            string fullPath;
            try
            {
                fullPath = Path.GetFullPath(value);
            }
            catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
            {
                return ValidationResult.Error("Please enter a valid file path.");
            }
 
            if (!File.Exists(fullPath))
            {
                return ValidationResult.Error("File does not exist.");
            }
 
            if (input.MaxFileSize is { } maxSize)
            {
                var fileInfo = new FileInfo(fullPath);
                if (fileInfo.Length > maxSize)
                {
                    return ValidationResult.Error($"'{Path.GetFileName(fullPath)}' exceeds the maximum size of {FormatHelpers.FormatFileSize(maxSize)}.");
                }
            }
 
            if (!string.IsNullOrEmpty(input.FileFilter))
            {
                var fileName = Path.GetFileName(fullPath);
                var filters = input.FileFilter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
                // Only validate against extension filters (e.g. ".pem", ".tar.gz"), skip MIME type patterns (e.g. "image/*").
                var extensionFilters = filters.Where(f => f.StartsWith('.'));
                if (extensionFilters.Any() && !extensionFilters.Any(f => fileName.EndsWith(f, StringComparison.OrdinalIgnoreCase)))
                {
                    return ValidationResult.Error($"'{Path.GetFileName(fullPath)}' does not match the accepted file types ({input.FileFilter}).");
                }
            }
 
            return ValidationResult.Success();
        }
 
        if (input.AllowMultipleFiles)
        {
            // Prompt for files repeatedly until the user provides an empty value.
            var filePaths = new List<string>();
 
            while (true)
            {
                var filePrompt = filePaths.Count == 0
                    ? promptText
                    : $"{promptText} (enter to finish)";
 
                var value = await InteractionService.PromptForFilePathAsync(
                    filePrompt,
                    validator: Validator,
                    required: filePaths.Count == 0 && input.Required,
                    cancellationToken: cancellationToken);
 
                if (string.IsNullOrWhiteSpace(value))
                {
                    break;
                }
 
                filePaths.Add(Path.GetFullPath(value));
            }
 
            return await UploadFilesAsync(filePaths, backchannel, interactionId, inputName, cancellationToken);
        }
 
        var singleValue = await InteractionService.PromptForFilePathAsync(
            promptText,
            binding: PromptBinding.CreateDefault(input.Value),
            validator: Validator,
            required: input.Required,
            cancellationToken: cancellationToken);
 
        if (string.IsNullOrWhiteSpace(singleValue))
        {
            return string.Empty;
        }
 
        return await UploadFilesAsync([Path.GetFullPath(singleValue)], backchannel, interactionId, inputName, cancellationToken);
    }
 
    private static async Task<string> UploadFilesAsync(List<string> filePaths, IAppHostCliBackchannel backchannel, string interactionId, string inputName, CancellationToken cancellationToken)
    {
        if (filePaths.Count == 0)
        {
            return string.Empty;
        }
        if (!int.TryParse(interactionId, CultureInfo.InvariantCulture, out var interactionIdValue))
        {
            throw new InvalidOperationException($"Prompt ID '{interactionId}' is not a valid interaction ID.");
        }
 
        var fileRefs = new List<FileReferenceDto>(filePaths.Count);
 
        foreach (var filePath in filePaths)
        {
            var fullPath = Path.GetFullPath(filePath);
            var fileName = Path.GetFileName(fullPath);
 
            // Upload the file to the AppHost and collect the reference.
            // Matching the same format the dashboard uses: [{"Id":"...","Name":"..."}]
            var uploadResponse = await backchannel.UploadFileAsync(fullPath, fileName, interactionIdValue, inputName, cancellationToken);
            fileRefs.Add(new FileReferenceDto { Id = uploadResponse.FileId, Name = fileName });
        }
 
        return JsonSerializer.Serialize(fileRefs.ToArray(), BackchannelJsonSerializerContext.Default.FileReferenceDtoArray);
    }
 
    private static bool ParseBooleanValue(string? value)
    {
        return bool.TryParse(value, out var result) && result;
    }
 
    private static (LogLevel Level, string Prefix) ParseLogLevel(string? logLevel) => logLevel?.ToUpperInvariant() switch
    {
        "TRACE" => (LogLevel.Trace, "TRC"),
        "DEBUG" => (LogLevel.Debug, "DBG"),
        "INFORMATION" or "INFO" => (LogLevel.Information, "INF"),
        "WARNING" or "WARN" => (LogLevel.Warning, "WRN"),
        "ERROR" => (LogLevel.Error, "ERR"),
        "CRITICAL" => (LogLevel.Critical, "CRT"),
        null or _ => (LogLevel.Information, "INF")
    };
 
    private class StepInfo
    {
        public string Id { get; set; } = string.Empty;
        public string Title { get; set; } = string.Empty;
        public int Number { get; set; }
        public string? ParentStepId { get; set; }
        public int HierarchyLevel { 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;
        }
        _terminalProgressBarStarted = true;
        Console.Write("\u001b]9;4;3\u001b\\");
    }
 
    /// <summary>
    /// Stops the terminal progress bar.
    /// </summary>
    private void StopTerminalProgressBar()
    {
        if (!_terminalProgressBarStarted)
        {
            return;
        }
        _terminalProgressBarStarted = false;
        Console.Write("\u001b]9;4;0\u001b\\");
    }
}