Private/Get-DeepestErrorMessage.ps1

function Get-DeepestErrorMessage {
    <#
    .SYNOPSIS
        Recursively walks the update run step hierarchy and returns the deepest non-empty errorMessage from any Error/Failed step.
    .DESCRIPTION
        The ARM payload nests errorMessage on whichever leaf actually threw. Older parents in the
        chain may carry a duplicate or summarised message, or no message at all. This helper
        applies a coalesce(e8Msg..e1Msg) pattern: walk the tree, prefer the deepest non-empty
        errorMessage on an Error/Failed-status step. Used by Format-AzLocalUpdateRun in v0.7.96
        to surface a dedicated ErrorMessage column so operators can triage failed runs without
        clicking through to the Azure portal.
 
        v0.8.80: when -IncludeDescription is supplied the helper returns a hashtable
        @{ Msg = ''; Description = '' } instead of a bare string. The description is the
        step's `description` field captured at the SAME depth as Msg, so renderers that
        want to display both (the human-readable line AND the raw trace) can do so. The
        default (bare-string) return shape is preserved for back-compat with the existing
        unit tests.
    .NOTES
        Returns an empty string (or @{Msg='';Description=''} with -IncludeDescription) when
        no error message is found (callers can if-guard cheaply).
 
        Consumed by Format-AzLocalUpdateRun -> Get-AzLocalUpdateRuns -> the Step.08
        monitor renderer (Export-AzLocalUpdateRunMonitorReport).
 
        Parallel walker: Resolve-AzLocalUpdateRunDeepestError (Private/) walks the same
        tree shape but returns a richer hashtable, used by Get-AzLocalUpdateRunFailures
        for the Step.09 fleet-update-status renderer. If you change the deepest-step
        contract here, update Resolve-AzLocalUpdateRunDeepestError too so the two
        pipelines stay in sync.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $false)]
        [array]$Steps,

        [Parameter(Mandatory = $false)]
        [int]$MaxDepth = 20,

        [Parameter(Mandatory = $false)]
        [switch]$IncludeDescription
    )

    $emptyResult = if ($IncludeDescription) { @{ Msg = ''; Description = '' } } else { '' }
    if (-not $Steps -or $Steps.Count -eq 0 -or $MaxDepth -le 0) { return $emptyResult }

    foreach ($step in $Steps) {
        if ($step.status -notin @('Error', 'Failed')) { continue }

        if ($step.steps -and $step.steps.Count -gt 0) {
            $deeper = Get-DeepestErrorMessage -Steps $step.steps -MaxDepth ($MaxDepth - 1) -IncludeDescription:$IncludeDescription
            if ($IncludeDescription) {
                if ($deeper.Msg) { return $deeper }
            }
            elseif ($deeper) { return $deeper }
        }
        if ($step.PSObject.Properties['errorMessage'] -and $step.errorMessage) {
            $msg = [string]$step.errorMessage
            if ($IncludeDescription) {
                $desc = ''
                if ($step.PSObject.Properties['description'] -and $step.description) {
                    $desc = [string]$step.description
                }
                return @{ Msg = $msg; Description = $desc }
            }
            return $msg
        }
    }
    return $emptyResult
}