Public/Update-HDTRunStateStep.ps1

function Update-HDTRunStateStep {
    <#
        .SYNOPSIS
            Records the outcome of one step in the run state document.
 
        .DESCRIPTION
            Every step is idempotent or checkpointed. On resume the
            engine skips completed steps by index and re-runs the interrupted one
            only if the step declares resumable: true."
 
            stepIndex is the 1-based index of the NEXT step to run, so this
            advances it past a step that Completed or was Skipped and LEAVES IT
            ALONE for one that is Running, Failed or still Pending. A failed run
            therefore resumes AT the failure rather than after it - otherwise the
            technician who fixes the cause never gets the step retried, and the
            deployment continues on top of work that never happened.
 
            The document is mutated in memory and returned; nothing is written
            and no clock is read. Save-HDTRunState stamps updatedUtc and
            checkpoints it.
 
            Timestamps are stored as formatted strings, never as [datetime]
            objects, because a raw date serialises as "\/Date(...)\/" under
            Windows PowerShell 5.1.
 
        .PARAMETER State
            A New-HDTRunState or Import-HDTRunState result.
 
        .PARAMETER Index
            The 1-based step index to update.
 
        .PARAMETER Status
            Pending, Running, Completed, Failed or Skipped.
 
        .PARAMETER Attempt
            How many times the step has been started. 03-04's retry policy
            increments it.
 
        .PARAMETER Leg
            Which leg touched the step. Recorded so a resume can say "step 3
            completed on leg 1" rather than "on some earlier leg" - the one thing
            a technician reading a multi-leg log actually wants to know.
 
        .PARAMETER ExitCode
            The step's exit code. Zero is a result, not an absence, so it is
            recorded when supplied.
 
        .PARAMETER Message
            A one-line explanation, typically the failure.
 
        .PARAMETER StartedUtc
            When the step started. Converted to UTC and formatted.
 
        .PARAMETER EndedUtc
            When the step ended. Converted to UTC and formatted.
 
        .PARAMETER DurationMs
            How long the step took.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject - the same state object,
            so a caller can pipe straight into Save-HDTRunState.
 
        .EXAMPLE
            Update-HDTRunStateStep -State $state -Index 3 -Status Failed `
                -ExitCode 2 -Message 'DISM returned 0x80070002' | Out-Null
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Mutates an in-memory document; Save-HDTRunState is what writes and it declares ShouldProcess.')]
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNull()]
        [object] $State,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateRange(1, [int]::MaxValue)]
        [int] $Index,

        [Parameter(Mandatory = $true, Position = 2)]
        [ValidateSet('Pending', 'Running', 'Completed', 'Failed', 'Skipped')]
        [string] $Status,

        [Parameter()]
        [int] $Attempt,

        [Parameter()]
        [int] $Leg,

        [Parameter()]
        [int] $ExitCode,

        [Parameter()]
        [AllowEmptyString()]
        [string] $Message,

        [Parameter()]
        [datetime] $StartedUtc,

        [Parameter()]
        [datetime] $EndedUtc,

        [Parameter()]
        [long] $DurationMs
    )

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

    $invariant = [System.Globalization.CultureInfo]::InvariantCulture

    $target = @(@($State.step) | Where-Object { [int] $_.index -eq $Index })

    if ($target.Count -ne 1) {
        $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord `
                    -Message ("step index {0} is not in this sequence, which has {1} step(s). A step result can only be recorded against a step the sequence declares." -f $Index, @($State.step).Count)))
    }

    $step = $target[0]
    $step.status = $Status

    if ($PSBoundParameters.ContainsKey('Attempt')) {
        $step.attempt = $Attempt
    }

    if ($PSBoundParameters.ContainsKey('Leg')) {
        $step.leg = $Leg
    }

    if ($PSBoundParameters.ContainsKey('ExitCode')) {
        $step.exitCode = $ExitCode
    }

    if ($PSBoundParameters.ContainsKey('Message')) {
        $step.message = $Message
    }

    if ($PSBoundParameters.ContainsKey('StartedUtc')) {
        $step.startedUtc = $StartedUtc.ToUniversalTime().ToString('o', $invariant)
    }

    if ($PSBoundParameters.ContainsKey('EndedUtc')) {
        $step.endedUtc = $EndedUtc.ToUniversalTime().ToString('o', $invariant)
    }

    if ($PSBoundParameters.ContainsKey('DurationMs')) {
        $step.durationMs = $DurationMs
    }

    # Completed and Skipped are the only outcomes that move on. Failed stays put
    # so a resume retries the step that failed.
    if (@('Completed', 'Skipped') -contains $Status) {
        $State.stepIndex = $Index + 1
    }

    return $State
}