Private/Resolve-AzLocalUpdateRunDeepestError.ps1

function Resolve-AzLocalUpdateRunDeepestError {
    <#
    .SYNOPSIS
        Walks an Azure Local update-run `progress.steps` tree to find the
        deepest non-empty errorMessage and returns its depth, message,
        step name, and the top-level fallback description.
 
    .DESCRIPTION
        Replaces the v0.7.75 server-side KQL `mv-expand s1 .. s7` chain
        used by Get-AzLocalUpdateRunFailures. Each level of
        Azure Resource Graph's `mv-expand` operator silently caps at
        exactly 128 expanded child rows per parent (the same cap that
        caused the v0.7.76 P0 bug in Get-AzLocalFleetHealthFailures), so
        any update-run step that had more than 128 sibling steps at any
        level was at risk of having its deepest error silently dropped.
 
        This walker performs the same "find the deepest meaningful
        errorMessage" computation entirely in PowerShell on the raw
        `properties.progress.steps` array as returned by ARG, with no
        truncation. The output schema is intentionally identical to the
        columns the previous KQL projection emitted (Depth, Msg, Name,
        FirstDescription).
 
    .PARAMETER Steps
        The `steps` array (or an object with a `.steps` array) at the
        current tree level. Pass `progress.steps` from the ARG response
        on the first call. The walker recurses into `step.steps`
        automatically for as many levels as the data contains. `$null`
        and empty arrays are tolerated (return Depth=0).
 
    .PARAMETER Depth
        Internal recursion accumulator. The caller normally omits this
        and the walker starts at depth 1, matching the depth numbers the
        legacy KQL produced (DeepestStepDepth 1..8 or 0 if no error).
 
    .PARAMETER MaxDepth
        Defensive recursion ceiling. The previous KQL stopped at depth
        8 (s1..s7 + s7.steps[0]); we default to 16 here so unusually
        deep error chains still surface. Bumped above 8 to remove the
        previous artificial ceiling now that we are no longer paying
        the ARG join cost.
 
    .OUTPUTS
        Hashtable with keys:
          Depth [int] - 0 if no meaningful errorMessage
                                       was found, otherwise the depth
                                       (1-based) of the deepest match
          Msg [string] - the deepest errorMessage text
          Name [string] - that step's `name`
          FirstDescription [string] - the very first non-empty
                                       top-level `description`, used as
                                       a fallback when no errorMessage
                                       was found anywhere in the tree
 
    .NOTES
        Author: Neil Bird, Microsoft.
        Added: v0.7.76 (P0 ARG mv-expand 128-cap fix)
        Module: AzLocal.UpdateManagement (private helper)
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $false, Position = 0)]
        [AllowNull()]
        $Steps,

        [Parameter(Mandatory = $false)]
        [int]$Depth = 1,

        [Parameter(Mandatory = $false)]
        [int]$MaxDepth = 16
    )

    $result = @{
        Depth            = 0
        Msg              = ''
        Name             = ''
        FirstDescription = ''
    }

    if ($null -eq $Steps)        { return $result }
    if ($Depth -gt $MaxDepth)    { return $result }

    # Normalise to an enumerable. ARG returns JsonArrays which behave like
    # PowerShell arrays once converted, but defensively wrap single-object
    # returns and avoid string enumeration.
    $enumerable = @()
    if ($Steps -is [System.Collections.IEnumerable] -and -not ($Steps -is [string])) {
        $enumerable = @($Steps)
    } else {
        $enumerable = @($Steps)
    }
    if ($enumerable.Count -eq 0) { return $result }

    foreach ($step in $enumerable) {
        if ($null -eq $step) { continue }

        # First non-empty top-level description wins as fallback. We
        # only capture this at depth 1 so deep recursion does not
        # overwrite it.
        if ($Depth -eq 1 -and -not $result.FirstDescription) {
            $desc = $null
            try { $desc = $step.description } catch { $desc = $null }
            if ($desc) { $result.FirstDescription = [string]$desc }
        }

        # Capture this step's errorMessage if meaningful (>10 chars,
        # matching the legacy KQL `strlen(eNMsg) > 10` threshold).
        $msg = $null
        try { $msg = $step.errorMessage } catch { $msg = $null }
        if ($null -ne $msg) {
            $msgStr = [string]$msg
            if ($msgStr.Length -gt 10 -and $Depth -ge $result.Depth) {
                # `-ge` (not `-gt`) so a tie at the same depth still
                # updates, mirroring the legacy `arg_max(mvDepth, *)`
                # last-wins semantics.
                $result.Depth = $Depth
                $result.Msg   = $msgStr
                $stepName = $null
                try { $stepName = $step.name } catch { $stepName = $null }
                $result.Name = if ($stepName) { [string]$stepName } else { '' }
            }
        }

        # Recurse into nested steps. Tolerate the property being absent
        # or null.
        $childSteps = $null
        try { $childSteps = $step.steps } catch { $childSteps = $null }
        if ($null -ne $childSteps) {
            $child = Resolve-AzLocalUpdateRunDeepestError -Steps $childSteps -Depth ($Depth + 1) -MaxDepth $MaxDepth
            if ($child.Depth -gt $result.Depth) {
                $result.Depth = $child.Depth
                $result.Msg   = $child.Msg
                $result.Name  = $child.Name
            }
            if ($Depth -eq 1 -and -not $result.FirstDescription -and $child.FirstDescription) {
                $result.FirstDescription = $child.FirstDescription
            }
        }
    }

    return $result
}