Public/Get-HDTDeploymentFailure.ps1

function Get-HDTDeploymentFailure {
    <#
        .SYNOPSIS
            What to tell the technician about a deployment that failed, derived
            from the log the engine already wrote.

        .DESCRIPTION
            A MACHINE THAT FAILED USED TO SAY NOTHING TO THE PERSON STANDING IN
            FRONT OF IT. The reason went into the JSONL, a FATAL line went into
            a console the payload had hidden, and five seconds later wpeutil
            powered the machine off. Everything needed to fix it was on a share,
            in a folder named after a computer that never finished being built,
            and the technician had a black screen.

            MDT SHOWS A SUMMARY DIALOG NAMING THE STEP. This is the derivation
            behind HDT's, and it is the same shape as Get-HDTDeploymentProgress
            deliberately: DESIGN 11.1's one source of truth is the event stream,
            so the failure screen reads the same records the progress screen
            does rather than being handed a second version of the story.

            THE REASON IS NOT SUMMARISED. The most useful sentence a technician
            can be shown is the one the step wrote - "disk 0 carries existing
            data on volume C (NTFS), D (NTFS), and the step did not declare that
            it may be replaced" contains the fix. Shortening it to "disk error"
            would leave them with the log to go and read anyway.

            A RUN THAT DIED BEFORE ANY STEP IS STILL A FAILURE WITH A SENTENCE.
            A share that could not be reached or a sequence that would not parse
            has no step to name, and the technician needs the reason more in that
            case rather than less.

        .PARAMETER Record
            The log records, oldest first, as ConvertTo-HDTLogRecord writes them
            - the same input Get-HDTDeploymentProgress takes.

        .PARAMETER LogPath
            Where the log will still be after this machine is powered off. Shown
            on the screen, because the RAM disk goes with the power.

        .INPUTS
            None. This command does not accept pipeline input.

        .OUTPUTS
            System.Management.Automation.PSCustomObject with IsFailure, RunId,
            SequenceId, StepNumber, StepCount, StepName, StepType, Message,
            Status, LogPath and Field.

        .EXAMPLE
            $failure = Get-HDTDeploymentFailure -Record $record -LogPath $log.LogPath
            if ($failure.IsFailure) { Show-HDTDeploymentFailure -Failure $failure }
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]] $Record,

        [Parameter()]
        [AllowEmptyString()]
        [string] $LogPath = ''
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    # THE SAME READER Get-HDTDeploymentProgress USES, and for the same reason: a
    # record may arrive as a dictionary the engine built or as an object
    # ConvertFrom-Json made, and a hashtable's keys are not its properties.
    $valueOf = {
        param([object] $Row, [string] $Name)

        if ($null -eq $Row) { return $null }

        if ($Row -is [System.Collections.IDictionary]) {
            if (-not $Row.Contains($Name)) { return $null }
            return $Row[$Name]
        }

        if ($null -eq $Row.PSObject.Properties[$Name]) { return $null }

        return $Row.$Name
    }

    $result = [ordered] @{
        IsFailure  = $false
        RunId      = ''
        SequenceId = ''
        StepNumber = 0
        StepCount  = 0
        StepName   = ''
        StepType   = ''
        Message    = ''
        Status     = ''
        LogPath    = $LogPath
    }

    foreach ($row in @($Record)) {

        $runId = [string] (& $valueOf $row 'runId')
        if (-not [string]::IsNullOrWhiteSpace($runId)) { $result['RunId'] = $runId }

        $eventName = [string] (& $valueOf $row 'event')
        if ([string]::IsNullOrWhiteSpace($eventName)) { continue }

        $data = & $valueOf $row 'data'

        switch ($eventName) {

            'run.start' {
                $sequenceId = [string] (& $valueOf $data 'sequenceId')
                if (-not [string]::IsNullOrWhiteSpace($sequenceId)) { $result['SequenceId'] = $sequenceId }

                $stepCount = & $valueOf $data 'stepCount'
                if ($null -ne $stepCount) { $result['StepCount'] = [int] $stepCount }
            }

            'step.fail' {
                # THE LAST FAILURE WINS. A step that was retried fails more than
                # once, and the attempt that ended the run is the one on screen.
                $result['IsFailure'] = $true
                $result['Status'] = 'Failed'

                $index = & $valueOf $data 'index'
                if ($null -eq $index) { $index = & $valueOf $row 'stepIndex' }
                if ($null -ne $index) { $result['StepNumber'] = [int] $index }

                $name = [string] (& $valueOf $data 'name')
                if ([string]::IsNullOrWhiteSpace($name)) { $name = [string] (& $valueOf $row 'stepName') }
                $result['StepName'] = $name

                $type = [string] (& $valueOf $data 'type')
                if ([string]::IsNullOrWhiteSpace($type)) { $type = [string] (& $valueOf $row 'stepType') }
                $result['StepType'] = $type

                $message = [string] (& $valueOf $row 'message')
                if (-not [string]::IsNullOrWhiteSpace($message)) { $result['Message'] = $message.Trim() }
            }

            'run.end' {
                $status = [string] (& $valueOf $data 'status')
                if (-not [string]::IsNullOrWhiteSpace($status)) { $result['Status'] = $status }
            }
        }
    }

    # run.end IS THE ENGINE'S OWN VERDICT, as it is for the progress screen: a
    # run that ended Failed with no step.fail - a teardown that failed after the
    # last step - is still a failure a technician must be shown.
    if ([string] $result['Status'] -eq 'Failed') { $result['IsFailure'] = $true }

    # -- what the window shows ----------------------------------------------

    $stepText = 'before the first step'
    if (-not [string]::IsNullOrWhiteSpace([string] $result['StepName'])) {
        $stepText = [string] $result['StepName']

        if ([int] $result['StepCount'] -gt 0) {
            $stepText = '{0} (step {1} of {2})' -f $stepText, [int] $result['StepNumber'], [int] $result['StepCount']
        }

        if (-not [string]::IsNullOrWhiteSpace([string] $result['StepType'])) {
            $stepText = '{0} - {1}' -f $stepText, [string] $result['StepType']
        }
    }

    $logText = [string] $result['LogPath']
    if ([string]::IsNullOrWhiteSpace($logText)) { $logText = '(no log destination was resolved)' }

    $result['Field'] = @(
        [pscustomobject] @{ Name = 'HDTFailureStepText'; Text = $stepText }
        [pscustomobject] @{ Name = 'HDTFailureMessageText'; Text = [string] $result['Message'] }
        [pscustomobject] @{ Name = 'HDTFailureLogText'; Text = $logText }
        [pscustomobject] @{ Name = 'HDTFailureSequenceText'; Text = [string] $result['SequenceId'] }
        [pscustomobject] @{ Name = 'HDTFailureRunText'; Text = [string] $result['RunId'] }
    )

    return [pscustomobject] $result
}