Public/New-HDTRunState.ps1

function New-HDTRunState {
    <#
        .SYNOPSIS
            Builds the state document for a new deployment run.
 
        .DESCRIPTION
            "The engine maintains a state document (state.json): resolved
            variables, the step index, per-step results, and a run ID".
This builds it, in memory. It has NO -FileSystem
            parameter and reads nothing: only Save-HDTRunState writes.
 
            The document, in this key order:
 
              schemaVersion 1
              runId the deployment run id, also _HDTRunId
              sequenceId the sequence's id from sequence.yaml
              status Running | Succeeded | Failed
              phase WinPE | FullOS
              leg 1-based, incremented on every resume
              seq the last JSONL seq written - how the
                             monotonic counter survives a reboot
              startedUtc formatted string
              updatedUtc formatted string
              stepIndex the 1-based index of the NEXT step to run
              pauseOnError bool
              variable name -> value, ordered and case-insensitive
              step[] index, name, type, group, status, attempt, leg,
                             resumable, startedUtc, endedUtc, durationMs,
                             exitCode, message
              autoLogon armed, userName, domainName, countSet, secretName,
                             runOnceName
 
            EVERY TIMESTAMP IS A FORMATTED STRING. ConvertTo-Json renders a raw
            [datetime] as "\/Date(1786579862481)\/" under Windows PowerShell
            5.1, and 5.1 is the engine that writes this file in WinPE, so a raw
            date would produce a document the full-OS leg could not read back.
 
        .PARAMETER SequenceId
            The sequence's id from sequence.yaml.
 
        .PARAMETER RunId
            The deployment run id.
 
        .PARAMETER Phase
            WinPE or FullOS.
 
        .PARAMETER Clock
            An IClock. Mandatory, and deliberately so: PROJECT constraint 4
            forbids engine logic from reading the wall clock directly, and a
            state document whose timestamps came from a real clock could not be
            asserted on.
 
        .PARAMETER Variable
            The resolved variables. Copied into an ordered, case-insensitive
            dictionary, so a hand-written rules.yaml may spell a name however it
            likes and a state.json diff stays readable.
 
        .PARAMETER Step
            One entry per flattened step, as a dictionary or an object with
            Index, Name, Type, GroupPath (or Group) and Resumable. Both shapes
            are accepted because 03-02's flattener emits objects and a test
            writes dictionaries, and a state document must not care which -
            and GroupPath is read in preference to Group, because GroupPath is
            what the flattener actually emits.
 
        .PARAMETER PauseOnError
            The LTISuspend equivalent: on failure, drop to a PowerShell
            prompt with the state loaded rather than ending the sequence.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject
 
        .EXAMPLE
            $state = New-HDTRunState -SequenceId 'STD-CLIENT' -RunId $runId -Phase WinPE `
                -Clock (New-HDTClock) -Variable $resolution.Variable -Step $flattened
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds an in-memory document object; it changes no state and writes no file.')]
    [CmdletBinding()]
    [OutputType([object])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $SequenceId,

        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string] $RunId,

        [Parameter(Mandatory = $true)]
        [ValidateSet('WinPE', 'FullOS')]
        [string] $Phase,

        [Parameter(Mandatory = $true)]
        [ValidateNotNull()]
        [object] $Clock,

        [Parameter()]
        [AllowNull()]
        [System.Collections.IDictionary] $Variable,

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

        [Parameter()]
        [switch] $PauseOnError
    )

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

    $stamp = $Clock.GetUtcNow().ToUniversalTime().ToString('o', [System.Globalization.CultureInfo]::InvariantCulture)

    $variableMap = [System.Collections.Specialized.OrderedDictionary]::new([System.StringComparer]::OrdinalIgnoreCase)
    if ($null -ne $Variable) {
        foreach ($key in @($Variable.Keys)) {
            $variableMap[[string] $key] = $Variable[$key]
        }
    }

    $record = New-Object -TypeName System.Collections.ArrayList
    $position = 0

    foreach ($item in @($Step)) {
        if ($null -eq $item) {
            continue
        }

        $position++

        # A dictionary and an object both answer the same five questions; the
        # flattener in 03-02 emits objects, a test writes dictionaries.
        $source = @{}
        if ($item -is [System.Collections.IDictionary]) {
            foreach ($key in @($item.Keys)) {
                $source[[string] $key] = $item[$key]
            }
        } else {
            foreach ($property in @($item.PSObject.Properties)) {
                $source[$property.Name] = $property.Value
            }
        }

        $index = $position
        if ($source.ContainsKey('Index')) {
            $index = [int] $source['Index']
        }

        # GroupPath is what Import-HDTSequenceDocument's flattener emits, and it
        # is the only thing that builds a step list in production. Group is what
        # a hand-written test dictionary says. Reading only the latter left the
        # state document's group array - and every report column rendered from
        # it - empty on every real run.
        $group = [string[]] @()
        if ($source.ContainsKey('GroupPath') -and $null -ne $source['GroupPath']) {
            $group = [string[]] @($source['GroupPath'])
        } elseif ($source.ContainsKey('Group') -and $null -ne $source['Group']) {
            $group = [string[]] @($source['Group'])
        }

        $resumable = $false
        if ($source.ContainsKey('Resumable')) {
            $resumable = [bool] $source['Resumable']
        }

        [void] $record.Add([pscustomobject] ([ordered] @{
                    index      = $index
                    name       = [string] $source['Name']
                    type       = [string] $source['Type']
                    group      = $group
                    status     = 'Pending'
                    attempt    = 0

                    # Which leg last touched this step. Null until it runs, and
                    # the only way a resume can say "step 3 completed on leg 1"
                    # rather than "on some earlier leg".
                    leg        = $null
                    resumable  = $resumable
                    startedUtc = $null
                    endedUtc   = $null
                    durationMs = $null
                    exitCode   = $null
                    message    = $null
                }))
    }

    return [pscustomobject] ([ordered] @{
            schemaVersion      = 1
            runId              = $RunId
            sequenceId         = $SequenceId
            status             = 'Running'
            phase              = $Phase
            leg                = 1
            seq                = [long] 0
            startedUtc         = $stamp
            updatedUtc         = $stamp
            stepIndex          = 1
            pauseOnError       = [bool] $PauseOnError
            variable           = $variableMap
            step               = @($record)
            autoLogon          = [pscustomobject] ([ordered] @{
                    armed       = $false
                    userName    = $null
                    domainName  = $null
                    countSet    = 0
                    secretName  = $null
                    runOnceName = $null
                })
        })
}