Public/Start-EnvkAppBatch.ps1


#Requires -Version 5.1
<#
.SYNOPSIS
    STA RunspacePool parallel app launcher for PowerShell 7+.
 
.DESCRIPTION
    Start-EnvkAppBatch launches a batch of apps concurrently using an STA RunspacePool.
    STA (Single-Threaded Apartment) mode is required so that each worker thread can
    call UIAutomationClient (Invoke-WindowMaximize) without an InvalidOperationException.
 
    Each worker calls Invoke-AppWorker (Private), which runs the full per-app launch
    sequence (path validation, already-running check, Start-Process, Invoke-WindowMaximize)
    and collects all log messages in a buffered List[PSCustomObject]. Write-EnvkLog uses
    Add-Content which is not thread-safe; buffering avoids silent log entry drops under
    concurrent writes (PowerShell/PowerShell#14416).
 
    The caller (Invoke-EnvkStartup, Plan 03) is responsible for:
      - Calling Start-EnvkAppBatch once per priority group
      - Flushing $result.LogLines via Write-EnvkLog after each batch completes
      - Never calling Start-EnvkAppBatch on PS 5.1 (guarded internally; orchestrator checks
        PS version before selecting the parallel path)
 
    Execution flow:
      1. Guard: throw terminating error if PS Major < 7.
      2. Build InitialSessionState loading the Envoke module manifest.
      3. Create RunspacePool(1, ThrottleLimit, iss, $Host).
      4. Set pool.ApartmentState = STA BEFORE pool.Open().
      5. For each app: [PowerShell]::Create(), assign to pool, AddScript + AddArgument x4,
         BeginInvoke() -> collect [PSCustomObject]@{PS; Handle; AppName} in $inflight list.
      6. EndInvoke each handle; check HadErrors/Streams.Error; Dispose each PS instance.
      7. pool.Close()/Dispose() in finally.
      8. Return results array (unary comma to prevent single-element unboxing).
 
.PARAMETER Apps
    Array of resolved app PSCustomObjects (from Resolve-AppConfig) with properties:
    Name, Path, ProcessName, Arguments, SkipMaximize, Priority.
 
.PARAMETER ThrottleLimit
    Maximum number of concurrent runspaces (from config parallelLimit, default 5).
 
.PARAMETER WindowTimeoutMs
    Window detection timeout in milliseconds (from config windowTimeoutSeconds * 1000).
 
.PARAMETER EnableDebug
    When set, passed through to Invoke-AppWorker/Invoke-WindowMaximize workers for
    detailed UIAutomation logging. Workers receive this as a plain [bool] parameter.
    Note: $script:Config['EnableDebug'] is intentionally set to $false in worker
    runspaces to suppress direct Write-EnvkLog calls from IWM/Get-MainWindow; Add-Content
    is not thread-safe and concurrent writes drop entries silently. All log output is
    buffered in result.LogLines and flushed sequentially by the parent.
 
.OUTPUTS
    [PSCustomObject[]]
    Array of per-app result objects, one per input app, with:
      AppName [string]
      Status [string] -- 'Launched' | 'AlreadyRunning' | 'Failed' | 'Skipped'
      ElapsedMs [int]
      MaximizeResult [PSCustomObject] or $null
      ErrorMessage [string] or $null
      LogLines [System.Collections.Generic.List[PSCustomObject]] -- Level/Message entries
 
.NOTES
    Author: Aaron AlAnsari
    Created: 2026-02-25
 
    STA REQUIREMENT: UIAutomationClient requires STA mode. The pool.ApartmentState must
    be set to STA before pool.Open(). Setting it after Open() throws
    InvalidRunspacePoolStateException. ForEach-Object -Parallel and Start-ThreadJob both
    create MTA runspaces and cannot be used for UIAutomation work.
 
    MODULE LOADING: Parallel runspaces do not inherit the parent session's loaded modules.
    InitialSessionState.ImportPSModule() with the .psd1 manifest path loads the full
    module (including all Private functions, including Invoke-AppWorker) into each runspace.
 
    LOG BUFFERING: Write-EnvkLog uses Add-Content which is not thread-safe under concurrent
    writes (silently drops entries). Workers collect log lines in List[PSCustomObject]
    returned in the result object. The caller flushes them via Write-EnvkLog sequentially.
 
    WORKER FUNCTION: The per-app logic lives in Invoke-AppWorker (Private). The worker
    scriptblock calls Invoke-AppWorker after setting $script:GracePeriodMs. This separation
    allows the per-app logic to be tested with InModuleScope mocks independently of the
    RunspacePool infrastructure.
#>


# PSUseOutputTypeCorrectly: unary comma operator forces an Object[] wrapper around the
# result array to prevent single-element unboxing in the PowerShell pipeline.
# PSUseShouldProcessForStateChangingFunctions: Start-EnvkAppBatch delegates all state changes
# to Start-Process (which natively supports -WhatIf) and Invoke-WindowMaximize.
# Adding ShouldProcess here would leave $launchedPid null in -WhatIf mode.
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseOutputTypeCorrectly', '')]
[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '')]
param ()

function Start-EnvkAppBatch {
    [CmdletBinding()]
    [OutputType([PSCustomObject[]])]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject[]]$Apps,

        [Parameter(Mandatory)]
        [int]$ThrottleLimit,

        [Parameter(Mandatory)]
        [int]$WindowTimeoutMs,

        [Parameter()]
        [switch]$EnableDebug
    )

    # ------------------------------------------------------------------
    # Guard: PS 7+ required for RunspacePool STA support.
    # ------------------------------------------------------------------
    if ($PSVersionTable.PSVersion.Major -lt 7) {
        $err = [System.Management.Automation.ErrorRecord]::new(
            [System.InvalidOperationException]::new(
                "Start-EnvkAppBatch requires PowerShell 7 or later. Current version: $($PSVersionTable.PSVersion). " +
                "Use Start-EnvkAppSequence for PowerShell 5.1 sequential launching."
            ),
            'PSVersionRequired',
            [System.Management.Automation.ErrorCategory]::InvalidOperation,
            $null
        )
        $PSCmdlet.ThrowTerminatingError($err)
    }

    # ------------------------------------------------------------------
    # Build InitialSessionState loading the Envoke module.
    # (Get-Module Envoke).ModuleBase resolves the module directory;
    # the manifest .psd1 is at $ModuleBase\Envoke.psd1.
    # ------------------------------------------------------------------
    $moduleBase = (Get-Module Envoke).ModuleBase
    $manifestPath = Join-Path $moduleBase 'Envoke.psd1'

    $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
    $iss.ImportPSModule($manifestPath)

    # ------------------------------------------------------------------
    # Create RunspacePool. ApartmentState MUST be set before Open().
    # ------------------------------------------------------------------
    $pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(
        1, $ThrottleLimit, $iss, $Host
    )
    $pool.ApartmentState = [System.Threading.ApartmentState]::STA
    $pool.Open()

    # Worker scriptblock: runs per-app inside each STA runspace.
    # Sets GracePeriodMs then delegates to Invoke-AppWorker (Private).
    # Log messages are buffered in the result object (not written to disk here).
    #
    # PRIVATE FUNCTION ACCESS: Invoke-AppWorker is a private module function and is
    # not exported from the module. The scriptblock runs in global scope (outside the
    # module scope), so a bare call to "Invoke-AppWorker" would fail with
    # "not recognized". Using "& (Get-Module Envoke) { ... }" executes the
    # body inside the module's own scope where private functions are accessible.
    $workerScript = {
        param(
            [PSCustomObject]$App,
            [int]$WorkerWindowTimeoutMs,
            [bool]$WorkerEnableDebug
        )

        # Delegate to Invoke-AppWorker via module scope so the private function is accessible.
        # GracePeriodMs is set inside the module-scope block because Invoke-AppWorker reads
        # $script:GracePeriodMs from the module's own script scope, not the runspace global scope.
        & (Get-Module Envoke) {
            param($App, $WindowTimeoutMs, $EnableDebug)
            $script:GracePeriodMs = 2000
            # Suppress direct Write-EnvkLog DEBUG writes in worker runspaces.
            # Worker runspaces load the module fresh; $script:Config is unset by default.
            # EnableDebug is passed to Invoke-AppWorker as a plain bool so it can forward
            # it to Invoke-WindowMaximize for detailed UIAutomation logging, but direct
            # Write-EnvkLog calls (from IWM/Get-MainWindow) must NOT write to the log file
            # here -- Add-Content is not thread-safe and concurrent writes silently drop
            # entries. All log output is buffered in result.LogLines and flushed
            # sequentially by the parent after each batch completes.
            if ($null -eq $script:Config) { $script:Config = @{} }
            $script:Config['EnableDebug'] = $false
            Invoke-AppWorker -App $App -WindowTimeoutMs $WindowTimeoutMs -EnableDebug $EnableDebug
        } $App $WorkerWindowTimeoutMs $WorkerEnableDebug
    }

    # ------------------------------------------------------------------
    # Dispatch all apps asynchronously and collect results.
    # ------------------------------------------------------------------
    $results  = [System.Collections.Generic.List[PSCustomObject]]::new()
    $inflight = [System.Collections.Generic.List[PSCustomObject]]::new()

    try {
        # Fire all apps asynchronously.
        foreach ($app in $Apps) {
            $ps = [System.Management.Automation.PowerShell]::Create()
            $ps.RunspacePool = $pool
            [void]$ps.AddScript($workerScript)
            [void]$ps.AddArgument($app)
            [void]$ps.AddArgument($WindowTimeoutMs)
            [void]$ps.AddArgument([bool]$EnableDebug)
            $inflight.Add([PSCustomObject]@{
                PS      = $ps
                Handle  = $ps.BeginInvoke()
                AppName = $app.Name
            })
        }

        # Collect results -- EndInvoke blocks until each worker completes.
        foreach ($item in $inflight) {
            $output = $item.PS.EndInvoke($item.Handle)

            if ($item.PS.HadErrors) {
                # Worker-level error (exception escaped the worker).
                $workerError = $item.PS.Streams.Error | Select-Object -First 1
                $errMsg = if ($null -ne $workerError) { $workerError.ToString() } else { 'Unknown worker error' }
                $errorLogLines = [System.Collections.Generic.List[PSCustomObject]]::new()
                $errorLogLines.Add([PSCustomObject]@{
                    Level   = 'ERROR'
                    Message = "Start-EnvkAppBatch: worker error for '$($item.AppName)' -- $errMsg"
                })
                $results.Add([PSCustomObject]@{
                    AppName        = $item.AppName
                    Status         = 'Failed'
                    ElapsedMs      = 0
                    MaximizeResult = $null
                    ErrorMessage   = $errMsg
                    LogLines       = $errorLogLines
                })
            }
            elseif ($null -ne $output -and $output.Count -gt 0) {
                $results.Add($output[0])
            }
            else {
                # Worker returned nothing (unexpected).
                $emptyLogLines = [System.Collections.Generic.List[PSCustomObject]]::new()
                $emptyLogLines.Add([PSCustomObject]@{
                    Level   = 'ERROR'
                    Message = "Start-EnvkAppBatch: worker returned no output for '$($item.AppName)'"
                })
                $results.Add([PSCustomObject]@{
                    AppName        = $item.AppName
                    Status         = 'Failed'
                    ElapsedMs      = 0
                    MaximizeResult = $null
                    ErrorMessage   = 'Worker returned no output'
                    LogLines       = $emptyLogLines
                })
            }

            $item.PS.Dispose()
        }
    }
    finally {
        $pool.Close()
        $pool.Dispose()
    }

    return , $results.ToArray()
}