src/Write-ScoutProgress.ps1
|
#Requires -Version 7.0 Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' <# .SYNOPSIS Live, resilient progress reporting for AzureScout (AB#405). .DESCRIPTION Interactive runs use PowerShell's standard Write-Progress display. The experimental built-in multi-line renderer can be enabled with AZURESCOUT_NATIVE_PROGRESS=1; its in-process .NET timer owns the spinner and elapsed clock while the PowerShell thread is blocked. The renderer has no external module dependency and never changes PowerShell repository trust. It presents a bordered, multi-phase ledger: finished phases remain visible while the active phase keeps a moving spinner, progress bar, status and elapsed clock. It uses bright foreground colours only (never a coloured background) and falls back to native Write-Progress or log-friendly information records in redirected, CI, or suppressed hosts. Rendering failures can never cause Azure work to execute twice. #> function Test-ScoutNativeLiveHost { [CmdletBinding()] param([switch] $Force) # The custom multi-line renderer remains available for opt-in testing, but ordinary product # runs use PowerShell's own progress UI. The custom renderer can interleave nested phase # transitions with host output and leave contradictory/stale rows on screen; native # Write-Progress has the lifecycle semantics users already expect. if ($env:AZURESCOUT_NATIVE_PROGRESS -ne '1') { $script:ScoutNativeProgressDecision = 'disabled: using standard PowerShell progress (set AZURESCOUT_NATIVE_PROGRESS=1 to opt in)' return $false } if ($ProgressPreference -eq 'SilentlyContinue') { $script:ScoutNativeProgressDecision = 'disabled: ProgressPreference is SilentlyContinue' return $false } if ($Force) { $script:ScoutNativeProgressDecision = 'eligible: forced by caller' return $true } if ($env:CI -or $env:TF_BUILD -or $env:GITHUB_ACTIONS -or $env:SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) { $script:ScoutNativeProgressDecision = 'disabled: CI environment detected' return $false } if (-not [Environment]::UserInteractive) { $script:ScoutNativeProgressDecision = 'disabled: process is not user-interactive' return $false } try { if ([Console]::IsOutputRedirected) { $script:ScoutNativeProgressDecision = 'disabled: console output is redirected' return $false } } catch { $script:ScoutNativeProgressDecision = 'disabled: console redirection state is unavailable' return $false } if ($null -eq $Host -or $Host.Name -in @('Default Host', 'ServerRemoteHost')) { $hostLabel = if ($null -eq $Host) { '<null>' } else { $Host.Name } $script:ScoutNativeProgressDecision = "disabled: unsupported host '$hostLabel'" return $false } $script:ScoutNativeProgressDecision = "eligible: interactive $($Host.Name) host" return $true } function Write-ScoutProgressDiagnostic { [CmdletBinding()] param([Parameter(Mandatory)] [string] $Message) try { if (Get-Command Write-AZSCLog -ErrorAction SilentlyContinue) { Write-AZSCLog -Level 'DEBUG' -Message "Live progress: $Message" } } catch { Write-Verbose "AzureScout could not write the live-progress diagnostic: $($_.Exception.Message)" } } function Initialize-ScoutNativeProgressRenderer { [CmdletBinding()] param() if ('AzureScout.NativeProgressRenderer' -as [type]) { return $true } $source = @' using System; using System.Diagnostics; using System.Text; using System.Threading; namespace AzureScout { public static class NativeProgressRenderer { private static readonly object Gate = new object(); private static readonly string[] Frames = new[] { "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏" }; private const string BrightCyan = "\u001b[96;1m"; private const string BrightGreen = "\u001b[92;1m"; private const string BrightRed = "\u001b[91;1m"; private const string BrightYellow = "\u001b[93;1m"; private const string Dim = "\u001b[90m"; private const string Reset = "\u001b[0m"; private static Timer _timer; private static Stopwatch _stopwatch; private static string _activity = "Azure Scout"; private static string _status = "Starting..."; private static string _rootActivity = "Azure Scout"; private static string _taskKey = String.Empty; private static int _percent = -1; private static int _frame; private static int _renderCount; private static int _liveRow = -1; private static bool _ansi; private static bool _active; private static bool _hasLiveRow; public static bool IsActive { get { lock (Gate) { return _active; } } } public static int RenderCount { get { lock (Gate) { return _renderCount; } } } public static void Start(string activity, string status, int percent, bool ansi) { lock (Gate) { DisposeTimer(); _activity = Clean(activity, "Azure Scout"); _rootActivity = _activity; _status = Clean(status, "Starting..."); _taskKey = "1|" + _activity; _percent = ClampPercent(percent); _ansi = ansi; _frame = 0; _renderCount = 0; _liveRow = -1; _hasLiveRow = true; _stopwatch = Stopwatch.StartNew(); _active = true; WriteHeader(); RenderLocked(); _timer = new Timer(RenderTick, null, TimeSpan.FromMilliseconds(125), TimeSpan.FromMilliseconds(125)); } } public static void Update(string activity, string status, int percent, bool completed, int id, int parentId) { lock (Gate) { if (!_active) return; string nextActivity = Clean(activity, _activity); string nextKey = id.ToString() + "|" + nextActivity; if (!String.Equals(nextKey, _taskKey, StringComparison.OrdinalIgnoreCase)) { FinishLiveRow(false, "phase changed"); _activity = nextActivity; _taskKey = nextKey; _hasLiveRow = true; _liveRow = GetCursorTop(); } else { _activity = nextActivity; } _status = Clean(status, _status); _percent = completed ? 100 : ClampPercent(percent); if (completed) { FinishLiveRow(true, _status); } else { RenderLocked(); } } } public static void Stop(bool succeeded, string status) { lock (Gate) { if (!_active) return; _active = false; DisposeTimer(); if (_stopwatch != null) _stopwatch.Stop(); try { string elapsed = _stopwatch == null ? "00:00:00" : _stopwatch.Elapsed.ToString(@"hh\:mm\:ss"); string finalStatus = Clean(status, succeeded ? "Complete" : "Failed"); if (_hasLiveRow) FinishLiveRow(succeeded, finalStatus); WriteFooter(succeeded, _rootActivity, finalStatus, elapsed); } catch { // Progress must never affect the operation result. } } } private static void RenderTick(object state) { lock (Gate) { if (!_active) return; try { _renderCount++; RenderLocked(); } catch { _active = false; DisposeTimer(); } } } private static void RenderLocked() { if (!_active || !_hasLiveRow) return; int currentTop = GetCursorTop(); if (_liveRow < 0 || currentTop != _liveRow) { // A warning or host message was written while the timer owned the live row. // Move the renderer to the new cursor row so that message remains visible. _liveRow = currentTop; } int width = GetWidth(); string frame = Frames[_frame++ % Frames.Length]; string activity = Truncate(_activity, 26); string bar = BuildBar(_percent, 16, _frame); string percent = _percent < 0 ? " --%" : String.Format("{0,3}%", _percent); string elapsed = _stopwatch == null ? "00:00:00" : _stopwatch.Elapsed.ToString(@"hh\:mm\:ss"); string body = String.Format("{0} {1} [{2}] {3} {4} {5}", frame, activity, bar, percent, elapsed, _status); string plain = PanelLine(body, width); string rendered = _ansi ? BrightCyan + plain + Reset : plain; // Return to column zero after every tick. Normal PowerShell host output can then // replace the live row cleanly; the next tick detects the cursor-row change and // continues beneath it instead of appending progress text to the warning. Console.Write("\r" + rendered + "\r"); _liveRow = GetCursorTop(); } private static void FinishLiveRow(bool succeeded, string status) { if (!_hasLiveRow) return; string elapsed = _stopwatch == null ? "00:00:00" : _stopwatch.Elapsed.ToString(@"hh\:mm\:ss"); string symbol = succeeded ? "✓" : "→"; string body = String.Format("{0} {1} — {2} ({3})", symbol, _activity, Clean(status, _status), elapsed); string plain = PanelLine(body, GetWidth()); string colour = succeeded ? BrightGreen : BrightYellow; string rendered = _ansi ? colour + plain + Reset : plain; Console.Write("\r" + rendered + Environment.NewLine); _hasLiveRow = false; _liveRow = GetCursorTop(); } private static void WriteHeader() { int width = GetWidth(); string title = " Azure Scout — live progress "; int remaining = Math.Max(1, width - title.Length - 2); string plain = "╭" + title + new string('─', remaining) + "╮"; plain = Truncate(plain, width); Console.WriteLine(_ansi ? BrightCyan + plain + Reset : plain); _liveRow = GetCursorTop(); } private static void WriteFooter(bool succeeded, string activity, string status, string elapsed) { int width = GetWidth(); string summary = String.Format(" {0} {1}: {2}; elapsed {3} ", succeeded ? "✓" : "✗", activity, status, elapsed); summary = Truncate(summary, Math.Max(1, width - 2)); int remaining = Math.Max(1, width - summary.Length - 2); string plain = "╰" + summary + new string('─', remaining) + "╯"; plain = Truncate(plain, width); string colour = succeeded ? BrightGreen : BrightRed; Console.WriteLine(_ansi ? colour + plain + Reset : plain); _liveRow = GetCursorTop(); } private static string PanelLine(string body, int width) { int innerWidth = Math.Max(1, width - 4); string inner = Truncate(Clean(body, String.Empty), innerWidth); return "│ " + inner + Spaces(Math.Max(0, innerWidth - inner.Length)) + " │"; } private static string BuildBar(int percent, int width, int frame) { if (percent < 0) { int position = frame % width; return new string('─', position) + "●" + new string('─', Math.Max(0, width - position - 1)); } int filled = (int)Math.Round(width * (Math.Min(100, Math.Max(0, percent)) / 100.0)); return new string('█', filled) + new string('─', width - filled); } private static int ClampPercent(int percent) { return percent < 0 ? -1 : Math.Min(100, Math.Max(0, percent)); } private static string Clean(string value, string fallback) { if (String.IsNullOrWhiteSpace(value)) return fallback ?? String.Empty; var builder = new StringBuilder(value.Length); foreach (char character in value) { if (character == '\r' || character == '\n' || character == '\u001b') builder.Append(' '); else if (!Char.IsControl(character)) builder.Append(character); } return builder.ToString().Trim(); } private static string Truncate(string value, int width) { if (width <= 0) return String.Empty; if (String.IsNullOrEmpty(value) || value.Length <= width) return value ?? String.Empty; if (width == 1) return "…"; return value.Substring(0, width - 1) + "…"; } private static int GetWidth() { try { return Math.Max(72, Console.WindowWidth); } catch { return 120; } } private static int GetCursorTop() { try { return Console.CursorTop; } catch { return -1; } } private static string Spaces(int count) { return count <= 0 ? String.Empty : new string(' ', count); } private static void DisposeTimer() { if (_timer == null) return; try { _timer.Change(Timeout.Infinite, Timeout.Infinite); } catch { } try { _timer.Dispose(); } catch { } _timer = null; } } } '@ try { Add-Type -TypeDefinition $source -Language CSharp -ErrorAction Stop return $true } catch { Write-Verbose "AzureScout native progress renderer could not initialize: $($_.Exception.Message)" return $false } } function Start-ScoutNativeProgressHost { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Activity, [string] $Status = 'Starting...', [ValidateRange(0, 100)] [int] $PercentComplete = 0, [int] $Id = 1, [Parameter(Mandatory)] [scriptblock] $Operation, [object[]] $ArgumentList = @() ) $state = [pscustomobject]@{ Started = $false; Error = $null; Output = @() } $hadActive = Test-Path Variable:script:ScoutNativeProgressActive $previousActive = if ($hadActive) { $script:ScoutNativeProgressActive } else { $false } $supportsAnsi = $false try { $supportsAnsi = [bool]$Host.UI.SupportsVirtualTerminal } catch { $supportsAnsi = $false } try { [AzureScout.NativeProgressRenderer]::Start($Activity, $Status, $PercentComplete, $supportsAnsi) $script:ScoutNativeProgressActive = $true $state.Started = $true Write-ScoutProgressDiagnostic -Message ( "renderer started; activity={0}; host={1}; virtualTerminal={2}" -f $Activity, $Host.Name, $supportsAnsi ) try { $state.Output = @(& $Operation @ArgumentList) } catch { $state.Error = $_ } } finally { try { [AzureScout.NativeProgressRenderer]::Stop( ($null -eq $state.Error), $(if ($null -eq $state.Error) { 'Complete' } else { 'Failed' }) ) } catch { Write-Verbose "AzureScout progress summary could not be rendered: $($_.Exception.Message)" } if ($hadActive) { $script:ScoutNativeProgressActive = $previousActive } else { Remove-Variable -Name ScoutNativeProgressActive -Scope Script -ErrorAction SilentlyContinue } } if ($null -ne $state.Error) { $state.Error.Exception.Data['ScoutProgressOperationStarted'] = $true throw $state.Error } return $state.Output } function Invoke-ScoutProgressOperation { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Activity, [string] $Status = 'Starting...', [ValidateRange(0, 100)] [int] $PercentComplete = 0, [int] $Id = 1, [Parameter(Mandatory)] [scriptblock] $Operation, [object[]] $ArgumentList = @() ) if (-not (Test-ScoutNativeLiveHost)) { $decision = if (Get-Variable -Name ScoutNativeProgressDecision -Scope Script -ErrorAction SilentlyContinue) { [string]$script:ScoutNativeProgressDecision } else { 'disabled: live console unavailable' } Write-ScoutProgressDiagnostic -Message $decision return (& $Operation @ArgumentList) } if (-not (Initialize-ScoutNativeProgressRenderer)) { Write-ScoutProgressDiagnostic -Message 'disabled: built-in renderer failed to initialize' return (& $Operation @ArgumentList) } try { return Start-ScoutNativeProgressHost -Activity $Activity -Status $Status ` -PercentComplete $PercentComplete -Id $Id -Operation $Operation -ArgumentList $ArgumentList } catch { if ($_.Exception.Data.Contains('ScoutProgressOperationStarted')) { throw } Write-Verbose "AzureScout live progress could not start; using native Write-Progress: $($_.Exception.Message)" return (& $Operation @ArgumentList) } } function Write-ScoutProgress { [CmdletBinding()] param( [Parameter(Mandatory)] [string] $Activity, [string] $Status = 'Working...', [string] $CurrentOperation, [int] $PercentComplete = -1, [int] $Id = 1, [int] $ParentId = -1, [switch] $Completed ) $displayStatus = if ([string]::IsNullOrWhiteSpace($CurrentOperation)) { $Status } elseif ([string]::IsNullOrWhiteSpace($Status)) { $CurrentOperation } else { '{0} — {1}' -f $Status, $CurrentOperation } if ((Test-Path Variable:script:ScoutNativeProgressActive) -and $script:ScoutNativeProgressActive) { try { [AzureScout.NativeProgressRenderer]::Update( $Activity, $displayStatus, $PercentComplete, $Completed.IsPresent, $Id, $ParentId ) return } catch { Write-Verbose "Write-ScoutProgress: live update failed, falling back to Write-Progress: $($_.Exception.Message)" } } try { $progressParams = @{ Activity = $Activity; Status = $displayStatus; Id = $Id } if ($ParentId -ge 0) { $progressParams.ParentId = $ParentId } if ($Completed) { $progressParams.Completed = $true } elseif ($PercentComplete -ge 0) { $progressParams.PercentComplete = [Math]::Min(100, $PercentComplete) } Write-Progress @progressParams } catch { Write-Verbose "Write-ScoutProgress: Write-Progress failed, continuing without a progress bar: $($_.Exception.Message)" } if (-not $Completed -and $ProgressPreference -eq 'SilentlyContinue') { try { $pctText = if ($PercentComplete -ge 0) { "$PercentComplete%" } else { '...' } Write-Information "[$Activity] $pctText $displayStatus" -InformationAction Continue } catch { Write-Verbose "Write-ScoutProgress: log-line fallback failed: $($_.Exception.Message)" } } } |