Public/Invoke-HDTTaskSequence.ps1

function Invoke-HDTTaskSequence {
    <#
        .SYNOPSIS
            Runs a flattened task sequence: ordering, conditions, retry,
            reboot-and-resume, and the finally teardown that makes autologon
            safe.
 
        .DESCRIPTION
            The execution loop. It takes an imported sequence and an
            execution context and returns:
 
              Status Succeeded | Failed | RebootPending
              State the state document as it stands
              Result one row per step the loop reached, in order
              FailedStep the flattened step that ended the run, or $null
 
            THE BRANCH ORDER PER STEP IS THE DESIGN, and each branch is a test:
 
              1. already Completed or Skipped on a previous leg -> step.skip
              2. left Running by an interrupted leg -> resumable:
                 true re-runs it, anything else FAILS THE RUN
              2a. the administrator disabled the step -> step.skip
              3. runIn does not match this leg's phase -> step.skip
              4. a group condition is false, outermost first -> step.skip,
                 naming THE GROUP
              5. the step's own condition is false -> step.skip
              6. the step type says it does not apply -> step.skip
              7. run it
 
            Case 2 is the one worth spelling out. A step recorded Running was
            started and never finished - the machine rebooted, or the power went.
            Re-running it silently would repeat half-applied work; skipping it
            silently would build on work that never happened. So HDT refuses,
            names the step, and says what `resumable: true` would have done.
 
            CHECKPOINTS BRACKET EVERY STEP. The state is saved when a step is
            marked Running and again when its outcome is known, which is what
            makes case 2 detectable at all. The live variable dictionary is
            copied into the state on every save, so a variable a step set in
            WinPE is there for a condition in the full OS.
 
            THE REBOOT CEREMONY IS ORDERED, and the order is an argument rather
            than a preference:
 
              1. mark the step Completed, advancing stepIndex past it - or
                 Pending, leaving it, when the step asked to be re-entered
              2. SAVE
              3. take (or generate) the deployment password
              4. Set-HDTAutoLogon for 1 + the Restart steps still ahead
              5. SAVE again, so autoLogon.armed is durable
              6. status heartbeat
              7. IPowerService.Restart
 
            STEP 1'S SECOND HALF IS 07-02's. A step that owns a LIST - the
            InstallApplications step, which gets a 3010 halfway through and needs
            the reboot to come back to it - returns
            New-HDTStepResult -Reenter. Recording it Completed would advance
            stepIndex past it and silently skip every application after the one
            that asked for the restart, so the run would report success having
            installed half the software. Pending leaves stepIndex where it is:
            the next leg runs the step again, and the step picks up from the
            progress it checkpointed into a variable. Everything else about the
            ceremony is identical, which is why Reenter changes one assignment
            rather than adding a branch to the ceremony.
 
            If arming succeeded and the save then failed, the machine would
            reboot, autologon, and resume at the OLD index - re-running the
            Restart step, which reboots again: an infinite loop that needs a
            technician and a boot menu. If the save succeeds and arming then
            fails, the machine reboots and stops at the logon screen: stuck, but
            safe and diagnosable. Between a loop and a stop, choose the stop.
 
            REMAININGLEG IS A BOUND, NOT A PREDICTION. It is one for this reboot
            plus one for every Restart step still ahead, but a CommandLine step
            returning 3010 can add a leg nobody counted. Every arm re-sets the
            count, so the bound is refreshed on each reboot and Windows'
            AutoLogonCount backstop stays the third line of defence rather than
            the first.
 
            TEARDOWN RUNS FROM finally, NOT FROM A STEP. MDT's
            cleanup is a task sequence step, so a failure before it leaves
            autologon armed. Here every terminal outcome - success, failure, a
            thrown exception, even a failed checkpoint - runs the autologon
            checklist. The one outcome that does NOT tear down is RebootPending:
            the machine has to stay armed to come back.
 
            The finally runs: clear the step, stamp the run status, checkpoint,
            TEAR DOWN, log run.end, write the heartbeat, copy the logs back. The
            teardown comes before run.end so the copied-back log carries its
            record, and so run.end is genuinely the last line of the run.
 
            PAUSEONERROR DOES NOT PROMPT. The LTISuspend equivalent is
            read from the state document; when it is set and a step fails
            terminally, the loop logs at Error that the run is paused, writes the
            heartbeat and RETURNS with the state loaded. Dropping to a live
            prompt belongs to the caller (Start-HDTDeployment, phase 05): an
            engine that blocked on input could not be unit tested and would hang
            CI.
 
            TIMEOUTS ARE NOT PRE-EMPTIVE. `timeoutMinutes` is passed to the step
            - only CommandLine can enforce it, through IProcessService - and
            measured by the loop afterwards. HDT does not preempt a synchronous
            step: one that hangs in-process hangs the sequence, exactly as MDT's
            does. Running steps in a child runspace is a post-v1 idea, and
            ForEach-Object -Parallel is not available to an engine that must run
            under Windows PowerShell 5.1.
 
        .PARAMETER Sequence
            An Import-HDTSequenceDocument result. Its Step list is already
            flattened into execution order.
 
        .PARAMETER Context
            A New-HDTExecutionContext context. Everything the loop touches -
            filesystem, clock, registry, LSA, power - comes from its service
            catalog, so the whole engine runs under Pester against fakes.
 
        .PARAMETER State
            An existing run state, which is what a resume is. Without one a fresh
            document is built from the sequence.
 
        .PARAMETER StatePath
            Where to checkpoint. Defaults to state.json in the log directory,
            which is where the log directory listing puts it. A caller
            that follows the X:\HDT\state.json convention - Start-HDTResume.ps1
            does - passes it explicitly.
 
        .PARAMETER MirrorStatePath
            A second location for the same document, conventionally the target
            volume once one is formatted. The mirror is what makes the WinPE to
            full-OS transition survivable.
 
        .PARAMETER StepType
            A pre-built Get-HDTStepType registry. Without one the loop discovers
            once per run and hands the same registry to every dispatch.
 
        .PARAMETER StatusPath
            Where to write the status.json heartbeat. Defaults to
            status.json in the log directory.
 
        .PARAMETER LogDestination
            The share's log root. When given, the log directory is copied back at
            the end of the run - on failure too, because a deployment that dies
            is exactly when the logs matter.
 
        .PARAMETER AutoLogonUserName
            The account the reboot ceremony arms autologon for. Defaults to
            Administrator, MDT's model.
 
        .PARAMETER AutoLogonDomainName
            That account's domain. Empty for a workgroup machine, which is what a
            machine mid-deployment normally is.
 
        .PARAMETER ResumeCommand
            What RunOnce launches at logon. Passed through to Set-HDTAutoLogon,
            which defaults it to Start-HDTResume.ps1.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with Status, State,
            Result and FailedStep.
 
        .EXAMPLE
            $run = Invoke-HDTTaskSequence -Sequence $sequence -Context $context `
                -StatePath 'X:\HDT\state.json' -MirrorStatePath 'W:\HDT\state.json'
 
            if ($run.Status -eq 'RebootPending') { return }
 
        .EXAMPLE
            Invoke-HDTTaskSequence -Sequence $sequence -Context $context -State $state
 
            The second leg, resumed from the state the first one checkpointed.
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNull()]
        [object] $Sequence,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNull()]
        [object] $Context,

        [Parameter()]
        [AllowNull()]
        [object] $State,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $StatePath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $MirrorStatePath,

        [Parameter()]
        [AllowNull()]
        [object[]] $StepType,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $StatusPath,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $LogDestination,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $AutoLogonUserName = 'Administrator',

        [Parameter()]
        [AllowEmptyString()]
        [string] $AutoLogonDomainName = '',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string] $ResumeCommand
    )

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

    $log = $Context.Log
    $fileSystem = $Context.Service.FileSystem
    $clock = $Context.Service.Clock

    $logRoot = ([string] $log.LogPath).TrimEnd('\', '/')

    $statePathValue = '{0}\state.json' -f $logRoot
    if ($PSBoundParameters.ContainsKey('StatePath')) {
        $statePathValue = $StatePath
    }

    $statusPathValue = '{0}\status.json' -f $logRoot
    if ($PSBoundParameters.ContainsKey('StatusPath')) {
        $statusPathValue = $StatusPath
    }

    $stepList = [object[]] @($Sequence.Step)

    if (-not $PSCmdlet.ShouldProcess([string] $Sequence.Id, ('Run {0} task sequence step(s)' -f $stepList.Count))) {
        return
    }

    $state = $State
    if ($null -eq $state) {
        $state = New-HDTRunState -SequenceId ([string] $Sequence.Id) -RunId ([string] $Context.RunId) `
            -Phase ([string] $Context.Phase) -Clock $clock -Variable $Context.Variable -Step $stepList
    }

    $Context.State = $state

    $saveArgument = @{ Path = $statePathValue; FileSystem = $fileSystem; Clock = $clock }
    if ($PSBoundParameters.ContainsKey('MirrorStatePath')) {
        $saveArgument['MirrorPath'] = $MirrorStatePath
    }

    $outcome = New-Object -TypeName System.Collections.ArrayList
    $failedStep = $null
    $runStatus = 'Succeeded'

    # The live dictionary is the truth while the run is executing and the state
    # document is the truth across a reboot, so every checkpoint copies one into
    # the other. Without this a variable set in WinPE is gone by the full OS.
    #
    # The log's seq goes with them, because DESIGN 4.4.2 requires the monotonic
    # counter to survive a reboot: the next leg seeds its log context from this
    # number, and a leg that restarted at 1 would make the ordering of a
    # multi-leg deployment exactly as ambiguous as the counter exists to prevent.
    $saveState = {
        foreach ($name in @($Context.Variable.Keys)) {
            $state.variable[[string] $name] = $Context.Variable[$name]
        }

        $state.seq = [long] $log.Seq

        Save-HDTRunState -State $state @saveArgument
    }

    # Captured, because $PSBoundParameters is scoped to the function's own param
    # block and is EMPTY inside a scriptblock invoked with &. The relocation
    # below reads these from the loop body, where that trap does not apply - but
    # a later refactor that moves the block into a scriptblock would silently
    # start overruling a caller who named a path, so they are read once here.
    $mirrorStateWasGiven = $PSBoundParameters.ContainsKey('MirrorStatePath')
    $statusPathWasGiven = $PSBoundParameters.ContainsKey('StatusPath')

    $reportUnresolved = {
        param([object] $Unresolved, [string] $Where)

        if (@($Unresolved).Count -eq 0) {
            return
        }

        Write-HDTLog -Context $log -Severity Warning `
            -Message ("{0} names {1} variable token(s) nothing has supplied: {2}. The token is left literal and the comparison is false." -f
                $Where, @($Unresolved).Count, (@($Unresolved) -join ', ')) `
            -Data ([ordered] @{ unresolved = [string[]] @($Unresolved) })
    }

    $skipStep = {
        param([int] $Index, [object] $Step, [string] $Reason)

        Update-HDTRunStateStep -State $state -Index $Index -Status Skipped -Message $Reason -Leg ([int] $state.leg) | Out-Null
        & $saveState

        Write-HDTLog -Context $log -Event 'step.skip' -Message $Reason `
            -Data ([ordered] @{ index = $Index; name = [string] $Step.Name; type = [string] $Step.Type; reason = $Reason })

        [void] $outcome.Add([pscustomobject] ([ordered] @{
                    Index        = $Index
                    Name         = [string] $Step.Name
                    Type         = [string] $Step.Type
                    Status       = 'Skipped'
                    ExitCode     = 0
                    Message      = $Reason
                    Attempt      = 0
                    DurationMs   = [long] 0
                    TimedOut     = $false
                    FailureClass = $null
                    Reason       = $Reason
                }))
    }

    Write-HDTLog -Context $log -Event 'run.start' `
        -Message ("Run {0} starting at step {1} of {2} in the {3} phase (leg {4})" -f
            $Context.RunId, $state.stepIndex, $stepList.Count, $Context.Phase, $state.leg) `
        -Data ([ordered] @{
            sequenceId = [string] $Sequence.Id
            stepIndex  = [int] $state.stepIndex
            stepCount  = $stepList.Count
            leg        = [int] $state.leg
        })

    # HOW MANY STEPS THIS RUN HAS, set once and carried by every heartbeat from
    # here on. The console tailing Logs\_active\ shows "step 7 of 12", and it
    # cannot count them itself - it is reading a share, not running a sequence.
    $log.StepCount = $stepList.Count

    Write-HDTStatus -Context $log -Path $statusPathValue -Status 'Running'

    if ([string] $Context.Phase -ne [string] $state.phase) {
        Write-HDTLog -Context $log -Event 'phase.change' `
            -Message ("The deployment moved from the {0} phase to the {1} phase" -f $state.phase, $Context.Phase) `
            -Data ([ordered] @{ from = [string] $state.phase; to = [string] $Context.Phase })

        $state.phase = [string] $Context.Phase
    }

    try {
        $registry = $StepType
        if ($null -eq $registry) {
            $registry = @(Get-HDTStepType)
        }

        for ($index = [int] $state.stepIndex; $index -le $stepList.Count; $index++) {

            $step = $stepList[$index - 1]
            $stepName = [string] $step.Name
            $stepTypeName = [string] $step.Type

            # READ OFF THE CONTEXT, NOT OFF $logRoot. Once DESIGN 4.4.1's
            # relocation has fired, the log root is on the target volume and a
            # step log built from the captured value would write half its lines
            # to a RAM disk that is about to disappear.
            $currentLogRoot = ([string] $log.LogPath).TrimEnd('\', '/')

            $stepLogPath = '{0}\Steps\{1}' -f $currentLogRoot, (Get-HDTStepLogName -Index $index -Name $stepName)
            if (-not [string]::IsNullOrWhiteSpace([string] $step.Log)) {
                # DESIGN 4.4.4: a step may declare its own log file, in addition
                # to the master.
                $stepLogPath = '{0}\{1}' -f $currentLogRoot, [string] $step.Log
            }

            $Context.Attempt = 1
            $Context.SetStep($index, $stepName, $stepTypeName, $stepLogPath)

            $found = @(@($state.step) | Where-Object { [int] $_.index -eq $index })
            $recorded = $null
            if ($found.Count -eq 1) {
                $recorded = $found[0]
            }

            # 1. Already done on a previous leg.
            if ($null -ne $recorded -and @('Completed', 'Skipped') -contains [string] $recorded.status) {
                $where = 'an earlier leg'
                if ($null -ne $recorded.leg) {
                    $where = 'leg {0}' -f $recorded.leg
                }

                $reason = "step {0} '{1}' was already {2} on {3}" -f $index, $stepName, ([string] $recorded.status).ToLowerInvariant(), $where

                Write-HDTLog -Context $log -Event 'step.skip' -Message $reason `
                    -Data ([ordered] @{ index = $index; name = $stepName; type = $stepTypeName; reason = $reason })

                [void] $outcome.Add([pscustomobject] ([ordered] @{
                            Index        = $index
                            Name         = $stepName
                            Type         = $stepTypeName
                            Status       = 'Skipped'
                            ExitCode     = 0
                            Message      = $reason
                            Attempt      = [int] $recorded.attempt
                            DurationMs   = [long] 0
                            TimedOut     = $false
                            FailureClass = $null
                            Reason       = $reason
                        }))

                continue
            }

            # 2. Interrupted on a previous leg.
            if ($null -ne $recorded -and [string] $recorded.status -eq 'Running') {
                if ([bool] $step.Resumable) {
                    Write-HDTLog -Context $log -Severity Warning `
                        -Message ("step {0} '{1}' was interrupted on an earlier leg and declares resumable: true, so it is being run again" -f $index, $stepName) `
                        -Data ([ordered] @{ index = $index; name = $stepName; resumable = $true })
                } else {
                    $reason = "step {0} '{1}' was interrupted and does not declare resumable: true, so HDT will not run it again. Half-applied work is not silently repeated." -f $index, $stepName

                    Update-HDTRunStateStep -State $state -Index $index -Status Failed -Message $reason -Leg ([int] $state.leg) | Out-Null
                    & $saveState

                    Write-HDTLog -Context $log -Severity Error -Event 'step.fail' -Message $reason `
                        -Data ([ordered] @{ index = $index; name = $stepName; resumable = $false })

                    [void] $outcome.Add([pscustomobject] ([ordered] @{
                                Index        = $index
                                Name         = $stepName
                                Type         = $stepTypeName
                                Status       = 'Failed'
                                ExitCode     = 0
                                Message      = $reason
                                Attempt      = [int] $recorded.attempt
                                DurationMs   = [long] 0
                                TimedOut     = $false
                                FailureClass = 'Configuration'
                                Reason       = $reason
                            }))

                    $failedStep = $step
                    $runStatus = 'Failed'
                    break
                }
            }

            # 2a. SWITCHED OFF BY THE ADMINISTRATOR, which outranks every reason
            # below it. A disabled step is not evaluated for phase or
            # condition at all: those answer "would this apply here", and
            # somebody has already said it should not run anywhere. Reporting
            # a phase mismatch for a step that is switched off would send a
            # technician looking for the wrong thing.
            if ([bool] $step.Disabled) {
                & $skipStep $index $step ("step {0} '{1}' is disabled" -f $index, $stepName)

                continue
            }

            # 3. The phase filter.
            if (-not (Test-HDTStepRunInPhase -RunIn ([string] $step.RunIn) -Phase ([string] $Context.Phase))) {
                & $skipStep $index $step ("step {0} '{1}' declares runIn {2} and this leg is running in the {3} phase" -f
                    $index, $stepName, $step.RunIn, $Context.Phase)

                continue
            }

            # 4. The group conditions, outermost first. The reason names the
            # GROUP, so a technician reading six skip records knows they are
            # one decision rather than six.
            $skippedByGroup = $false
            foreach ($ancestor in @($step.GroupCondition)) {
                $unresolved = New-Object -TypeName System.Collections.ArrayList

                $met = Test-HDTStepCondition -Condition ([string] $ancestor.Condition) `
                    -Variable $Context.Variable -Unresolved $unresolved

                & $reportUnresolved $unresolved ("the condition of the group '{0}'" -f $ancestor.Group)

                if (-not $met) {
                    & $skipStep $index $step ("the group '{0}' is skipped: its condition {1} is false" -f
                        $ancestor.Group, $ancestor.Condition)

                    $skippedByGroup = $true
                    break
                }
            }
            if ($skippedByGroup) {
                continue
            }

            # 5. The step's own condition.
            $unresolved = New-Object -TypeName System.Collections.ArrayList

            $met = Test-HDTStepCondition -Condition ([string] $step.Condition) `
                -Variable $Context.Variable -Unresolved $unresolved

            & $reportUnresolved $unresolved ("the condition of step {0} '{1}'" -f $index, $stepName)

            if (-not $met) {
                & $skipStep $index $step ("step {0} '{1}' is skipped: its condition {2} is false" -f
                    $index, $stepName, $step.Condition)

                continue
            }

            # 6. Applicability.
            if (-not (Test-HDTStepApplicable -Step $step -Context $Context -StepType $registry)) {
                & $skipStep $index $step ("step {0} '{1}' is skipped: the {2} step type reported that it does not apply to this machine" -f
                    $index, $stepName, $stepTypeName)

                continue
            }

            # 7. Run it. The Running checkpoint is what makes case 2 detectable.
            Update-HDTRunStateStep -State $state -Index $index -Status Running -Attempt 1 `
                -Leg ([int] $state.leg) -StartedUtc ($clock.GetUtcNow()) | Out-Null
            & $saveState

            $attempt = Invoke-HDTStepAttempt -Step $step -Context $Context -StepType $registry

            # A REBOOT NOBODY CAN COME BACK FROM IS A FAILED STEP, AND IT IS
            # DECIDED HERE - before the step is recorded, so it travels through
            # the same recording, the same log line and the same Failed branch
            # as any other failure rather than needing a path of its own.
            #
            # ONE PASSWORD, AND THE ADMINISTRATOR SET IT (DESIGN 4.5.2: "The
            # administrator sets the password; HDT does not invent one"). The
            # unattend arms the FIRST logon with %HDTAdminPassword%, so that is
            # what the deployed machine's Administrator account carries; arming a
            # later leg with anything else means Winlogon trying a password the
            # account does not have, and the resume stops at a logon screen with
            # nothing to explain it.
            #
            # An earlier draft minted a random secret per deployment and kept it
            # in the state document. It was abandoned for the reason the design
            # gives: a deployment that fails halfway leaves a machine nobody can
            # log into, at exactly the moment somebody needs to get into it.
            if ([string] $attempt.Status -eq 'RebootRequested' -and
                [string]::IsNullOrWhiteSpace([string] $(
                    if ($Context.Variable.Contains('HDTAdminPassword')) { $Context.Variable['HDTAdminPassword'] } else { '' }))) {

                # MUTATED, NOT REPLACED. Invoke-HDTStepAttempt adds Attempt and
                # DurationMs to what New-HDTStepResult returned, and the recorder
                # below reads both - a fresh result object carries neither and
                # takes the whole run down through the engine's own catch.
                $attempt.Status = 'Failed'
                $attempt.Message = "this step asks for a restart, but nothing supplies HDTAdminPassword - so autologon cannot be armed and the sequence would not come back. Set it in the fallback rule of rules.yaml (MDT's [Default] section), in Control\machines\<UUID>.yaml for this machine, or on the wizard's administrator password page."
                $attempt.Data = [ordered] @{ errorId = 'HDTConfigurationError' }
            }

            $recordedStatus = 'Failed'
            if (@('Completed', 'RebootRequested') -contains [string] $attempt.Status) {
                $recordedStatus = 'Completed'
            }

            # A STEP THAT OWNS A LIST ASKS TO BE COME BACK TO. Recording a
            # RebootRequested step Completed advances stepIndex past it, which is
            # right for a Restart step and wrong for an InstallApplications step
            # that got a 3010 halfway down its list - the applications after it
            # would be silently skipped and the run would report success having
            # installed half the software. Pending leaves stepIndex where it is,
            # so the next leg runs the step again and it picks up from the
            # progress it checkpointed into a variable.
            if ([string] $attempt.Status -eq 'RebootRequested' -and [bool] $attempt.Reenter) {
                $recordedStatus = 'Pending'
            }

            Update-HDTRunStateStep -State $state -Index $index -Status $recordedStatus `
                -Attempt ([int] $attempt.Attempt) -Leg ([int] $state.leg) `
                -ExitCode ([int] $attempt.ExitCode) -Message ([string] $attempt.Message) `
                -EndedUtc ($clock.GetUtcNow()) -DurationMs ([long] $attempt.DurationMs) | Out-Null
            & $saveState

            [void] $outcome.Add([pscustomobject] ([ordered] @{
                        Index        = $index
                        Name         = $stepName
                        Type         = $stepTypeName
                        Status       = [string] $attempt.Status
                        ExitCode     = [int] $attempt.ExitCode
                        Message      = [string] $attempt.Message
                        Attempt      = [int] $attempt.Attempt
                        DurationMs   = [long] $attempt.DurationMs
                        TimedOut     = [bool] $attempt.TimedOut
                        FailureClass = $attempt.FailureClass
                        Reason       = $null
                    }))

            if ([string] $attempt.Status -eq 'Completed') {
                Write-HDTLog -Context $log -Event 'step.complete' `
                    -Message ("step {0} '{1}' completed" -f $index, $stepName) `
                    -DurationMs ([long] $attempt.DurationMs) `
                    -Data ([ordered] @{ index = $index; attempt = [int] $attempt.Attempt; exitCode = [int] $attempt.ExitCode })

                # DESIGN 4.4.1's RELOCATION, and DESIGN 4.3's state mirror, at
                # the one point that sees every step finish. It runs AFTER the
                # step's own completion record, so the RAM-disk copy carries a
                # whole account of the step that caused the move.
                #
                # A DEPLOYMENT THAT DIES IN WinPE LOSES ITS LOG AT THE REBOOT,
                # and dying in WinPE is exactly when the log is wanted: X: is a
                # RAM disk. The moment a step formats a volume and publishes
                # HDTOSVolume there is somewhere for the log to live that
                # survives the power going off, so it goes there.
                #
                # THE STEP DOES NOT DO THIS ITSELF. A step does not own the log
                # context, and one that reached into it would be the wrong shape.
                #
                # Four conditions, and this is all of them: the WinPE phase, a
                # non-empty HDTOSVolume, a log still on the RAM disk, and a step
                # that reported Completed - which is the branch this is in.
                if ([string] $Context.Phase -eq 'WinPE' -and
                    ([string] $log.LogPath) -eq (Get-HDTLogPath -Phase WinPE) -and
                    $Context.Variable.Contains('HDTOSVolume') -and
                    -not [string]::IsNullOrWhiteSpace([string] $Context.Variable['HDTOSVolume'])) {

                    # Set-HDTLogPath never throws: a target volume that cannot be
                    # written leaves the context on X: and returns the old path,
                    # and then nothing below fires either.
                    $relocated = Set-HDTLogPath -Context $log `
                        -TargetVolume ([string] $Context.Variable['HDTOSVolume']) `
                        -Variable $Context.Variable

                    if ($relocated -ne (Get-HDTLogPath -Phase WinPE)) {
                        # THE STATE MIRROR RIDES ALONG, because it is the same
                        # trigger and the same information. DESIGN 4.3 says the
                        # state document is mirrored to the target disk's \HDT\
                        # as soon as a formatted volume exists - and
                        # -MirrorStatePath was until now a literal path the
                        # caller had to know in advance, which a boot-time
                        # payload cannot, for exactly the reason it cannot know a
                        # drive letter (SPIKES S9.1). A caller who DID name one
                        # is not overruled.
                        #
                        # Built from the path actually reached, so the mirror and
                        # the log agree about the volume without normalising a
                        # letter twice.
                        $volumeRoot = [System.IO.Path]::GetPathRoot($relocated)

                        if (-not $mirrorStateWasGiven) {
                            $saveArgument['MirrorPath'] = [System.IO.Path]::Combine($volumeRoot, 'HDT\state.json')
                        }

                        # DESIGN 4.4.6's heartbeat lives IN the log directory,
                        # and the copy-back ships that directory. One left behind
                        # on the RAM disk would put a stale 'Running' in the copy
                        # a technician reads, while the live one died with the
                        # reboot.
                        if (-not $statusPathWasGiven) {
                            $statusPathValue = '{0}\status.json' -f $relocated.TrimEnd('\', '/')
                        }

                        # AND SO DOES THE STATE DOCUMENT, FOR THE SAME REASON -
                        # which is not obvious, and cost a full lab run to find.
                        #
                        # By default the state document lives IN the log
                        # directory. Set-HDTLogPath mirrors the whole tree, so a
                        # copy of state.json arrives on the target volume; if the
                        # writes keep going to the RAM disk that copy is FROZEN at
                        # the moment of the move, and Copy-HDTLog then ships the
                        # frozen one to the share. The first real -Task e2e run
                        # after 05-03 read it back reporting three steps Pending
                        # on a deployment that had succeeded, booted, and come up
                        # with the right computer name.
                        #
                        # A STALE STATE DOCUMENT IS WORSE THAN AN ABSENT ONE,
                        # because it is believed. Nothing on the RAM disk survives
                        # the reboot, so leaving the live copy there buys nothing:
                        # the abandoned file stays where it is, exactly as the
                        # abandoned log does, and every write from here lands
                        # beside the log it belongs to.
                        #
                        # A CALLER WHO PUT IT SOMEWHERE ELSE IS NOT OVERRULED, and
                        # nor is one who put it outside the log directory: only a
                        # path that was under the OLD log root is rebased onto the
                        # new one.
                        $oldRoot = $logRoot.TrimEnd('\', '/')

                        if ($statePathValue.StartsWith(($oldRoot + [System.IO.Path]::DirectorySeparatorChar),
                                [System.StringComparison]::OrdinalIgnoreCase)) {

                            $statePathValue = '{0}{1}' -f $relocated.TrimEnd('\', '/'),
                            $statePathValue.Substring($oldRoot.Length)

                            $saveArgument['Path'] = $statePathValue
                        }
                    }
                }

                continue
            }

            if ([string] $attempt.Status -eq 'RebootRequested') {
                # The ceremony. Its order is argued in the description, and it is
                # asserted from the cross-service journal rather than assumed.
                $delaySecond = 0
                if ($null -ne $attempt.Data) {
                    if ($attempt.Data -is [System.Collections.IDictionary]) {
                        if ($attempt.Data.Contains('DelaySecond')) {
                            $delaySecond = [int] $attempt.Data['DelaySecond']
                        }
                    } elseif ($null -ne $attempt.Data.PSObject.Properties['DelaySecond']) {
                        $delaySecond = [int] $attempt.Data.DelaySecond
                    }
                }

                # THE ENGINE GOES ONTO THE DISK BEFORE THE LOGON IS ARMED.
                #
                # DESIGN 4.5.1 launches the resume from <os volume>\HDT\, and for
                # five milestones nothing put it there: the payload and the
                # module were staged into the BOOT IMAGE at X:\HDT\, and X: is a
                # RAM disk that does not survive this restart. The machine
                # rebooted, autologged on and ran nothing, silently skipping
                # every step in a FullOS group while the run reported success.
                #
                # ONLY FROM WinPE, AND ONLY ONCE A VOLUME EXISTS. A full-OS leg
                # is already running from the staged copy, and a sequence that
                # restarts before it has partitioned anything has nowhere to put
                # one - inventing a drive letter is what SPIKES S9.1 forbids.
                #
                # A BOOT IMAGE THAT CANNOT SUPPLY ONE DOES NOT FAIL THE
                # DEPLOYMENT. Windows is already on the disk; refusing here would
                # destroy a machine over a stale image. It warns, reboots, and
                # stops after this leg - which is what it did before this existed,
                # except that now the log says why.
                if ([string] $Context.Phase -eq 'WinPE' -and
                    $Context.Variable.Contains('HDTOSVolume') -and
                    -not [string]::IsNullOrWhiteSpace([string] $Context.Variable['HDTOSVolume'])) {

                    try {
                        $agent = Copy-HDTResumeAgent -TargetVolume ([string] $Context.Variable['HDTOSVolume']) `
                            -FileSystem ($Context.Service.GetRequired('FileSystem', 'Restart')) -Confirm:$false

                        Write-HDTLog -Context $log -Component 'Restart' `
                            -Message ("the resume agent was staged to '{0}' ({1} file(s))" -f
                                $agent.Path, $agent.FileCount) `
                            -Data ([ordered] @{ path = [string] $agent.Path; fileCount = [int] $agent.FileCount })
                    } catch {
                        Write-HDTLog -Context $log -Severity Warning -Component 'Restart' `
                            -Message ("the resume agent could not be staged, so this deployment will stop after the restart and any step in a full-OS group will not run: {0}" -f
                                $_.Exception.Message)
                    }
                }

                # THE PASSWORD IS THE ADMINISTRATOR'S, AND IT WAS CHECKED BEFORE
                # THIS STEP WAS EVER RECORDED - see the guard beside
                # Invoke-HDTStepAttempt above. By here it is known to be set.
                $password = [string] $Context.Variable['HDTAdminPassword']

                $remainingLeg = 1
                for ($ahead = $index + 1; $ahead -le $stepList.Count; $ahead++) {
                    if ([string] $stepList[$ahead - 1].Type -eq 'Restart') {
                        $remainingLeg++
                    }
                }

                $armArgument = @{
                    Registry     = $Context.Service.GetRequired('Registry', 'Restart')
                    Lsa          = $Context.Service.GetRequired('Lsa', 'Restart')
                    UserName     = $AutoLogonUserName
                    Password     = $password
                    RemainingLeg = $remainingLeg
                    DomainName   = $AutoLogonDomainName
                    State        = $state
                    LogContext   = $log
                }
                if ($PSBoundParameters.ContainsKey('ResumeCommand')) {
                    $armArgument['ResumeCommand'] = $ResumeCommand
                }

                Set-HDTAutoLogon @armArgument

                # The second save: autoLogon.armed has to be durable too.
                & $saveState

                Write-HDTStatus -Context $log -Path $statusPathValue -Status 'RebootPending'

                $power = $Context.Service.GetRequired('Power', 'Restart')
                $power.Restart($delaySecond)

                $runStatus = 'RebootPending'
                break
            }

            # Failed.
            Write-HDTLog -Context $log -Severity Error -Event 'step.fail' `
                -Message ("step {0} '{1}' failed: {2}" -f $index, $stepName, $attempt.Message) `
                -DurationMs ([long] $attempt.DurationMs) `
                -Data ([ordered] @{
                    index        = $index
                    attempt      = [int] $attempt.Attempt
                    exitCode     = [int] $attempt.ExitCode
                    failureClass = $attempt.FailureClass
                    timedOut     = [bool] $attempt.TimedOut
                })

            if ([bool] $step.ContinueOnError) {
                Write-HDTLog -Context $log -Severity Warning `
                    -Message ("step {0} '{1}' failed and declares continueOnError: true, so the run continues" -f $index, $stepName) `
                    -Data ([ordered] @{ index = $index; name = $stepName; exitCode = [int] $attempt.ExitCode })

                continue
            }

            $failedStep = $step
            $runStatus = 'Failed'

            if ([bool] $state.pauseOnError) {
                # DESIGN 4.3's LTISuspend, minus the prompt. See the description.
                Write-HDTLog -Context $log -Severity Error `
                    -Message ("The run is paused at step {0} '{1}' because pauseOnError is set. The state is loaded and saved at '{2}'; the caller decides whether to open a prompt." -f
                        $index, $stepName, $statePathValue) `
                    -Data ([ordered] @{ index = $index; name = $stepName; statePath = $statePathValue })

                Write-HDTStatus -Context $log -Path $statusPathValue -Status 'Failed'
            }

            break
        }
    } catch {
        # Anything the loop itself could not handle. A step's own exception was
        # already turned into a Failed result by Invoke-HDTStepAttempt, so
        # reaching here means the engine failed rather than the deployment.
        $runStatus = 'Failed'

        Write-HDTLog -Context $log -Severity Error -Event 'step.fail' `
            -Message ("The task sequence stopped: {0}" -f $_.Exception.Message) `
            -Data ([ordered] @{ sequenceId = [string] $Sequence.Id })
    } finally {
        $log.ClearStep()

        $state.status = 'Running'
        if (@('Succeeded', 'Failed') -contains $runStatus) {
            $state.status = $runStatus
        }

        # The checkpoint may fail - a share that went away, a disk that filled -
        # and the teardown below must not be what pays for it.
        try {
            & $saveState
        } catch {
            Write-HDTLog -Context $log -Severity Error `
                -Message ("The run state could not be checkpointed at '{0}': {1}. Autologon teardown continues regardless." -f
                    $statePathValue, $_.Exception.Message)
        }

        # DESIGN 4.5.2: teardown is a failsafe, not a step. The one outcome that
        # keeps the machine armed is a pending reboot - it has to come back.
        #
        # It runs BEFORE run.end and before copy-back so the log that reaches the
        # share carries the teardown record, and so run.end is genuinely the last
        # line of the run.
        if ($runStatus -ne 'RebootPending') {
            $registryService = $Context.Service.Registry
            $lsaService = $Context.Service.Lsa

            if ($null -eq $registryService -or $null -eq $lsaService) {
                Write-HDTLog -Context $log -Severity Warning `
                    -Message 'Autologon teardown was skipped: this run was started without a registry service or an LSA service, and the teardown checklist cannot run without both.'
            } else {
                $teardown = Clear-HDTAutoLogon -Registry $registryService -Lsa $lsaService `
                    -FileSystem $fileSystem -State $state -StatePath $statePathValue -Clock $clock -LogContext $log

                if (@($teardown.Failed).Count -gt 0) {
                    # Reported, never promoted: the run's own status is what the
                    # caller acts on, and a teardown failure must not hide it.
                    Write-HDTLog -Context $log -Severity Warning `
                        -Message ("Autologon teardown left {0} item(s) unfinished: {1}." -f
                            @($teardown.Failed).Count, (@($teardown.Failed | ForEach-Object { $_.Item }) -join ', ')) `
                        -Data ([ordered] @{ failed = [string[]] @($teardown.Failed | ForEach-Object { $_.Item }) })
                }
            }
        }

        $completedCount = @($outcome | Where-Object { $_.Status -eq 'Completed' }).Count
        $failedCount = @($outcome | Where-Object { $_.Status -eq 'Failed' }).Count
        $skippedCount = @($outcome | Where-Object { $_.Status -eq 'Skipped' }).Count

        Write-HDTLog -Context $log -Event 'run.end' `
            -Message ("Run {0} ended {1}: {2} completed, {3} failed, {4} skipped" -f
                $Context.RunId, $runStatus, $completedCount, $failedCount, $skippedCount) `
            -Data ([ordered] @{
                status    = $runStatus
                completed = $completedCount
                failed    = $failedCount
                skipped   = $skippedCount
                leg       = [int] $state.leg
            })

        Write-HDTStatus -Context $log -Path $statusPathValue -Status $runStatus

        # THE LAST THING THE SCREEN IS TOLD, and the only update that carries a
        # verdict: run.end has just been written, so this is where Running
        # becomes Succeeded or Failed on a technician's screen instead of
        # staying on whichever step was last starting.
        #
        # IN THE finally, SO IT HAPPENS ON THE FAILING RUNS TOO - which are the
        # runs somebody is standing in front of.
        Update-HDTProgressDisplay -Context $Context

        if ($PSBoundParameters.ContainsKey('LogDestination')) {
            # DESIGN 4.4.1: copy-back happens on failure too. Copy-HDTLog is
            # documented never to throw and this catches anyway - nothing in a
            # finally block may be allowed to replace the run's own outcome with
            # its own failure.
            $copyArgument = @{ Context = $log; Destination = $LogDestination }
            if ($Context.Variable.Contains('HDTComputerName') -and
                -not [string]::IsNullOrWhiteSpace([string] $Context.Variable['HDTComputerName'])) {

                $copyArgument['ComputerName'] = [string] $Context.Variable['HDTComputerName']
            }

            try {
                Copy-HDTLog @copyArgument | Out-Null
            } catch {
                Write-HDTLog -Context $log -Severity Warning -Component 'Logging' `
                    -Message ("The deployment logs could not be copied to '{0}': {1}" -f $LogDestination, $_.Exception.Message)
            }
        }

        if ($runStatus -eq 'RebootPending') {
            # One last checkpoint, after the final log record, so the state
            # carries the seq the log stream actually reached. The next leg seeds
            # its counter from this number, and without it the first record after
            # the reboot would reuse the number run.end just consumed.
            try {
                & $saveState
            } catch {
                $null = $_
            }
        }
    }

    return [pscustomobject] ([ordered] @{
            Status     = $runStatus
            State      = $state
            Result     = [object[]] $outcome.ToArray()
            FailedStep = $failedStep
        })
}