src/private/Invoke-OrchestrationSequence.ps1

#Requires -Version 7.0

<#
.SYNOPSIS
    Internal function to execute an orchestration sequence.
 
.DESCRIPTION
    Executes a sequence of scripts as part of an orchestration/playbook run.
    This is used internally by Resume-AitherOrchestration and other orchestration commands.
 
    Resume semantics live here. Previously SkipFailed and RetryFailed were accepted
    and never read -- SkipFailed only decided whether to `break`, and RetryFailed did
    nothing at all -- so a resume re-ran every completed step. That is unusable for a
    multi-hour migration, where re-running step 1 means re-transferring images.
 
.PARAMETER Scripts
    Array of script specifications to execute.
 
.PARAMETER StartFrom
    Index to start execution from (for resume).
 
.PARAMETER CompletedIndices
    Indices that already succeeded in a previous run. They are SKIPPED, not re-run.
    This is what makes resume a resume. Passing an empty array runs everything.
 
.PARAMETER FailedIndices
    Indices that failed in a previous run. Combined with -SkipFailed (skip them) or
    -RetryFailed (run them again). With neither, they run normally.
 
.PARAMETER SkipFailed
    Skip previously failed scripts, and continue past a failure in this run instead
    of stopping at it.
 
.PARAMETER RetryFailed
    Retry previously failed scripts.
 
.PARAMETER DryRun
    Forward -DryRun to each script so the script's OWN dry-run path is exercised.
    The engine deliberately does NOT short-circuit here: returning early without
    calling anything means dry-run parity can never be verified, which is the
    condition this parameter exists to test.
 
.PARAMETER TimeoutSec
    Per-step timeout. 0 (default) means no timeout, preserving existing behaviour.
    A hung step otherwise blocks forever -- which is exactly what a wedged image
    transfer does.
 
.PARAMETER OnFailure
    Script to invoke when a step fails, receiving the error context. Lets a playbook
    drive its own rollback instead of only logging that it should have.
 
.PARAMETER ExecutionId
    Execution ID for tracking.
 
.NOTES
    Internal use only. Use Invoke-AitherPlaybook for public API.
#>

function Invoke-OrchestrationSequence {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [array]$Scripts,

        [int]$StartFrom = 0,

        [int[]]$CompletedIndices = @(),

        [int[]]$FailedIndices = @(),

        [switch]$SkipFailed,

        [switch]$RetryFailed,

        [switch]$DryRun,

        [int]$TimeoutSec = 0,

        [string]$OnFailure,

        [string]$ExecutionId,

        [hashtable]$Context = @{}
    )

    $results = @()
    $success = $true
    $completedSet = [System.Collections.Generic.HashSet[int]]::new([int[]]$CompletedIndices)
    $failedSet = [System.Collections.Generic.HashSet[int]]::new([int[]]$FailedIndices)

    for ($i = $StartFrom; $i -lt $Scripts.Count; $i++) {
        $script = $Scripts[$i]
        $scriptName = if ($script -is [string]) { $script } else { $script.Script }

        # Already succeeded in a previous run -- the whole point of resuming.
        if ($completedSet.Contains($i)) {
            Write-Verbose "Skipping already-completed script $($i + 1)/$($Scripts.Count): $scriptName"
            $results += [PSCustomObject]@{
                Index   = $i
                Script  = $scriptName
                Success = $true
                Skipped = $true
                Output  = $null
                Error   = $null
            }
            continue
        }

        # Previously failed and the caller asked to skip those.
        if ($failedSet.Contains($i) -and $SkipFailed -and -not $RetryFailed) {
            Write-Verbose "Skipping previously-failed script $($i + 1)/$($Scripts.Count): $scriptName"
            $results += [PSCustomObject]@{
                Index   = $i
                Script  = $scriptName
                Success = $true
                Skipped = $true
                Output  = $null
                Error   = 'skipped (previously failed, -SkipFailed)'
            }
            continue
        }

        try {
            Write-Verbose "Executing script $($i + 1)/$($Scripts.Count): $scriptName"

            $invokeParams = @{ Script = $scriptName; ErrorAction = 'Stop' }
            if ($DryRun) { $invokeParams.DryRun = $true }

            if ($TimeoutSec -gt 0) {
                # Start-Job runs in a FRESH runspace that has none of AitherZero
                # loaded, so the module must be imported inside it -- same pattern as
                # the parallel path. Without this the job does not fail fast: an
                # unresolved command sends PowerShell into a full PSModulePath
                # autoload scan, measured at ~120s on this box against a 2s timeout.
                # Every "timeout" would then be a command-not-found wearing a
                # timeout's clothes, and the step would never have run at all.
                $jobModuleRoot = Get-AitherModuleRoot
                $job = Start-Job -ScriptBlock {
                    param($p, $ModuleRoot)
                    $modulePath = Join-Path $ModuleRoot 'AitherZero' 'AitherZero.psd1'
                    Import-Module $modulePath -Force
                    Invoke-AitherScript @p
                } -ArgumentList $invokeParams, $jobModuleRoot

                if (Wait-Job -Job $job -Timeout $TimeoutSec) {
                    $scriptResult = Receive-Job -Job $job -ErrorAction Stop
                    Remove-Job -Job $job -Force
                }
                else {
                    Stop-Job -Job $job
                    Remove-Job -Job $job -Force
                    throw "Step timed out after ${TimeoutSec}s: $scriptName"
                }
            }
            else {
                $scriptResult = Invoke-AitherScript @invokeParams
            }

            $results += [PSCustomObject]@{
                Index   = $i
                Script  = $scriptName
                Success = $true
                Skipped = $false
                Output  = $scriptResult
                Error   = $null
            }
        }
        catch {
            $success = $false
            $errMsg = $_.Exception.Message
            $results += [PSCustomObject]@{
                Index   = $i
                Script  = $scriptName
                Success = $false
                Skipped = $false
                Output  = $null
                Error   = $errMsg
            }

            # A handler, not a log line: this is how a playbook drives rollback.
            if ($OnFailure) {
                try {
                    Write-Verbose "Invoking OnFailure handler: $OnFailure"
                    Invoke-AitherScript -Script $OnFailure -ErrorAction Stop
                }
                catch {
                    # The handler failing must not mask the original failure, but it
                    # must not vanish either -- a rollback that did not run is the
                    # single most important thing to surface here.
                    Write-Warning "OnFailure handler '$OnFailure' failed: $($_.Exception.Message)"
                }
            }

            if (-not $SkipFailed) {
                Write-Warning "Script '$scriptName' failed: $errMsg"
                break
            }
        }
    }

    return [PSCustomObject]@{
        Success     = $success
        ExecutionId = $ExecutionId
        Results     = $results
        Completed   = ($results | Where-Object { $_.Success }).Count
        Failed      = ($results | Where-Object { -not $_.Success }).Count
        Skipped     = ($results | Where-Object { $_.Skipped }).Count
        Total       = $Scripts.Count
    }
}