Runspaces/Runspaces.ps1

#==============================================================================
# Jobs Phase 6 IT3.1 - Job-based IPC for runspace tasks
# A pack that carries ts_JobToken / ts_JobPipe runs through a JOB WRAPPER:
# the runspace pulls the token from Explorer's Jobs pipe (it is the same
# process, so the pipe server accepts it), publishes $global:SRxJobChild,
# routes ProcessRules' rule callbacks to its Job, runs the ruleset script
# unchanged with the ORIGINAL pack, reports every error as a 'problem' and
# ends with 'complete' (ok / failed). Explorer learns everything through
# the Job - the legacy callbacks (OnStatus / OnBegin / OnEnd / OnError /
# OnStart / OnStop) are no longer registered by Explorer.
# A pack without a token runs exactly as before (legacy path).
# OnEnd / OnError callbacks now also receive the pack object (3rd arg) so
# the host can map a finished / timed-out runspace to its Job.
#==============================================================================
class Runspaces {
    $Caller                                                             = $null
    # ---- Jobs Phase 6 IT3.1
    static [string]  $JobModuleName                                     = 'SRxCore'   # module that defines SRxBasePipeChildAPI
    static [boolean] $FailOnErrors                                      = $true       # non-terminating errors of a task = failure
    static [int]     $MaxReportedErrors                                 = 5
    hidden $_jobScriptBlock                                             = $null
    #--------------------------------------
    # Private properties
    #--------------------------------------
    hidden $_host                                                       = $Host
    hidden [System.Windows.Forms.Timer]$_timer                             = $null 

    hidden [System.Management.Automation.ScriptBlock]$_closeCallback    = $null
    hidden [System.Management.Automation.ScriptBlock]$_beginCallback    = $null
    hidden [System.Management.Automation.ScriptBlock]$_endCallback      = $null
    hidden [System.Management.Automation.ScriptBlock]$_errorCallback    = $null
    hidden [System.Management.Automation.ScriptBlock]$_startCallback    = $null
    hidden [System.Management.Automation.ScriptBlock]$_statusCallback   = $null
    hidden [System.Management.Automation.ScriptBlock]$_stopCallback     = $null

    hidden [string]$_messageText                                        = "Saving {0} filters..."

    hidden $_progressId                                                 = $null
    hidden $_running                                                    = $false
    hidden [int]$_startedCount                                          = 0
    hidden [int]$_totalCount                                            = 0
    hidden [int]$_completedCount                                        = 0
    hidden [string]$_commandFile                                        = ""
    hidden [int]$_ticks                                                 = 0

    hidden $_runspacepool                                               = $null
    hidden $_runspaces                                                  = $null
    hidden [System.Collections.ArrayList]$_allObjects                   = $null
    hidden [boolean]$_timedOutTasks                                     = $false

    hidden [string]$_scriptFile                                         = $null
    hidden $_scriptBlock                                                = $null

    hidden [System.Collections.ArrayList] $_filterNodes                 = $null

    hidden $_userModules                                                = $null
    hidden $_userSnapins                                                = $null
    hidden $_userVariables                                              = $null
    hidden $_userFunctions                                              = $null

    #--------------------------------------
    # Public properties
    #--------------------------------------

    [PSObject]$Parameter                                                = $null

    [boolean]$ImportVariables                                           = $false
    [boolean]$ImportModules                                             = $false
    [boolean]$ImportFunctions                                           = $false


    [int]$Throttle                                                      = 20
    [int]$SleepTimer                                                    = 200
    [int]$RunspaceTimeout                                               = 0
    [boolean]$NoCloseOnTimeout                                          = $false
    [int]$MaxQueue                                                      = $null

    [boolean]$AppendLog                                                 = $false
    [string]$LogFile                                                    = $null

    [boolean]$Quiet                                                     = $false

    #--------------------------------------
    # Constructor
    #--------------------------------------
    Runspaces( [string]$ScriptFile) {        
        $this._scriptFile = $ScriptFile
    }
    #--------------------------------------
    # Private methods
    #--------------------------------------
    hidden [void] waitForJobs() {
        if( -not $this._timer) {
            $this._timer = [System.Windows.Forms.Timer]::new()
            $this._timer.Interval = 5000
            $this._timer.Tag = $this
            $this._timer.add_tick( 
            {
                param($source, $e)
                $_this = $source.Tag 

                $_this._ticks++
                $more = $_this.getRunspaceData()# $false )
                if( -not $more ) {   $_this.endJobs() }
                else { Write-Host "^" -NoNewline }
            })    
        }
        #Write-Host "starting DoEvents timer ..."
        $this._timer.start()
        $this._ticks = 0
    }
    hidden [void] endJobs() {
        if($this._timer) { $this._timer.stop() }
        $this._ticks = 0
        $this._running = $false

        #if (-not $this.Quiet) {
        # Write-Progress -Id $this._progressId -Activity "Running Query" -Status "Starting threads" -Completed
        #}
        $this.statusUpdate($true)

        $this._totalCount  = 0
        $this._completedCount = 0
        $this._startedCount = 0
        $this._allObjects.Clear()
        if( $this._stopCallback) { $this._stopCallback.Invoke($this) }
    }
    hidden [void] load() {
        #No max queue specified? Estimate one.
        if( -not $this.MaxQueue ) {
            if($this.RunspaceTimeout -ne 0){ $this.MaxQueue = $this.Throttle }
            else{ $this.MaxQueue = $this.Throttle * 3 }
        }

        $this._progressId = Get-Random
        TR "Throttle: '$($this.Throttle)' SleepTimer '$($this.SleepTimer)' runSpaceTimeout '$($this.RunspaceTimeout)' maxQueue '$($this.MaxQueue)' logFile '$($this.LogFile)'"

        #If they want to import variables or modules, create a clean runspace, get loaded items, use those to exclude items
        if ($this.ImportVariables -or $this.ImportModules -or $this.ImportFunctions) {
            $StandardUserEnv = [powershell]::Create().addscript({

                #Get modules, snapins, functions in this clean runspace
                $Modules = Get-Module | Select-Object -ExpandProperty Name
                # NOTE: -snapins removed from support in core 6 & 7
                #$Snapins = $null
                #if( $PSVersionTable.PSVersion.Major -eq 5 ) {
                    $Snapins = Get-PSSnapin | Select-Object -ExpandProperty Name  
                #}
                $Functions = Get-ChildItem function:\ | Select-Object -ExpandProperty Name

                #Get variables in this clean runspace
                #Called last to get vars like $? into session
                $Variables = Get-Variable | Select-Object -ExpandProperty Name

                #Return a hashtable where we can access each.
                @{
                    Variables   = $Variables
                    Modules     = $Modules
                    Snapins     = $Snapins
                    Functions   = $Functions
                }
            },$true).invoke()[0]
            
            if ($this.ImportVariables) {
                #Exclude common parameters, bound parameters, and automatic variables
                Function _temp {[cmdletbinding(SupportsShouldProcess=$True)] param() }
                $VariablesToExclude = @( (Get-Command _temp | Select-Object -ExpandProperty parameters).Keys + $PSBoundParameters.Keys + $StandardUserEnv.Variables )
                Write-Verbose "Excluding variables $( ($VariablesToExclude | Sort-Object ) -join ", ")"

                # we don't use 'Get-Variable -Exclude', because it uses regexps.
                # One of the veriables that we pass is '$?'.
                # There could be other variables with such problems.
                # Scope 2 required if we move to a real module
                $this._userVariables = @( Get-Variable | Where-Object { -not ($VariablesToExclude -contains $_.Name) } )
                Write-Verbose "Found variables to import: $( ($this._userVariables | Select-Object -expandproperty Name | Sort-Object ) -join ", " | Out-String).`n"
            }
            
            if ($this.ImportModules) {
                $this._userModules = @( Get-Module | Where-Object {$StandardUserEnv.Modules -notcontains $_.Name -and (Test-Path $_.Path -ErrorAction SilentlyContinue)} | Select-Object -ExpandProperty Path )
                #if( $PSVersionTable.PSVersion.Major -eq 5 ) {
                    $this._userSnapins = @( Get-PSSnapin | Select-Object -ExpandProperty Name | Where-Object {$StandardUserEnv.Snapins -notcontains $_ } )
                #}
            }
            
            if($this.ImportFunctions) {
                $this._userFunctions = @( Get-ChildItem function:\ | Where-Object { $StandardUserEnv.Functions -notcontains $_.Name } )
            }
            
        }

    }
    hidden [void] init() {
        $scriptText = $(Get-Content $this._scriptFile | out-string)
        $this._scriptBlock = [scriptblock]::Create( $scriptText )
        $this._jobScriptBlock = [scriptblock]::Create( [Runspaces]::JobWrapperText($scriptText) )      # Jobs Phase 6 IT3.1

        #TR "`$ScriptBlock: $($this._scriptBlock | Out-String)"
        TR "Creating runspace pool and session states"

        #If specified, add variables and modules/snapins to session state
        $sessionstate = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
                    
        if($this.ImportVariables -and $this._userVariables.count -gt 0) {
            foreach($Variable in $this._userVariables) {
                $sessionstate.Variables.Add((New-Object -TypeName System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList $Variable.Name, $Variable.Value, $null) )
            }
        }
        
        if ($this.ImportModules) {
            if($this._userModules.count -gt 0) {
                foreach($ModulePath in $this._userModules) {
                    TR " > ModulePath = $ModulePath"
                    $sessionstate.ImportPSModule($ModulePath)
                }
            }
            #if( $PSVersionTable.PSVersion.Major -eq 5 ) {
                if($this._userSnapins.count -gt 0) {
                    foreach($PSSnapin in $this._userSnapins) {
                        [void]$sessionstate.ImportPSSnapIn($PSSnapin, [ref]$null)
                    }
                }
            #}
        }
            
        if($this.ImportFunctions -and $this._userFunctions.count -gt 0) {
            foreach ($FunctionDef in $this._userFunctions) {
                $sessionstate.Commands.Add((New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $FunctionDef.Name,$FunctionDef.ScriptBlock))
            }
        }
            

        #Create runspace pool
        $this._runspacepool = [runspacefactory]::CreateRunspacePool(1, $this.Throttle, $sessionstate, $this._host)
        $this._runspacepool.Open()

        TR "Creating empty collection to hold runspace jobs"
        $this._runspaces = [System.Collections.ArrayList]::new() #New-Object System.Collections.ArrayList
        <#
        #If inputObject is bound get a total count and set bound to true
        $bound = $PSBoundParameters.keys -contains "InputObject"
        if(-not $bound) {
            [System.Collections.ArrayList]$allObjects = @()
        }
        #>

        $this._allObjects = [System.Collections.ArrayList]::new()

        #Set up log file if specified
        if( $this.LogFile -and (-not (Test-Path $this.LogFile) -or $this.AppendLog -eq $false)){
            New-Item -ItemType file -Path $this.LogFile -Force | Out-Null
            ("" | Select-Object -Property Date, Action, Runtime, Status, Details | ConvertTo-Csv -NoTypeInformation -Delimiter ";")[0] | Out-File $this.LogFile
        }

        #write initial log entry
        $log = "" | Select-Object -Property Date, Action, Runtime, Status, Details
            $log.Date = Get-Date
            $log.Action = "Batch processing started"
            $log.Runtime = $null
            $log.Status = "Started"
            $log.Details = $null
            if($this.LogFile) {
                ($log | convertto-csv -Delimiter ";" -NoTypeInformation)[1] | Out-File $this.LogFile -Append
            }
        $this._timedOutTasks = $false

        #counts for progress
        $this._totalCount = 0
        $this._completedCount = 0
        $this._startedCount = 0

        $this.statusUpdate($true)
    }
    hidden [void] addRunspaceJobs($pack) {

        [void]$this._allObjects.add( $pack )
        #counts for progress
        $this._totalCount += $this._allObjects.count

        foreach($object in $this._allObjects) {
            #region add scripts to runspace pool
                #Create the powershell instance, set verbose if needed, supply the scriptblock and parameters
                $powershell = [powershell]::Create()
                <#
                if ($VerbosePreference -eq 'Continue') {
                    [void]$powerShell.AddScript({$VerbosePreference = 'Continue'})
                }
                #>

                #begin Jobs Phase 6 IT3.1 - a Job task runs through the job wrapper
                $isJobTask = $false
                try { $isJobTask = -not [string]::IsNullOrEmpty([string]$object.ts_JobToken) } catch { $isJobTask = $false }
                if ($isJobTask -and $null -ne $this._jobScriptBlock) {
                    [void]$powershell.AddScript($this._jobScriptBlock).AddArgument($object)
                }
                else {
                    [void]$powershell.AddScript($this._scriptBlock).AddArgument($object)     # legacy (no Job)
                }
                #end Jobs Phase 6 IT3.1

                if ($this.Parameter) {
                    [void]$powershell.AddArgument($this.Parameter)
                }
                <#
                # $Using support from Boe Prox
                if ($UsingVariableData) {
                    Foreach($UsingVariable in $UsingVariableData) {
                        Write-Verbose "Adding $($UsingVariable.Name) with value: $($UsingVariable.Value)"
                        [void]$PowerShell.AddArgument($UsingVariable.Value)
                    }
                }
                #>

                #Add the runspace into the powershell instance
                $powershell.RunspacePool = $this._runspacepool

                #Create a temporary collection for each runspace
                $temp = "" | Select-Object PowerShell, StartTime, object, Runspace
                $temp.PowerShell = $powershell
                $temp.StartTime = Get-Date
                $temp.object = $object

                #Save the handle output when calling BeginInvoke() that will be used later to end the runspace
                $temp.Runspace = $powershell.BeginInvoke()
                $this._startedCount++
                $this._commandFile += "$($temp.object.ts_RuleSet) "
                if( $this._beginCallback ) { $this._beginCallback.Invoke($this, $temp.object.ts_RuleSet) }

                #Add the temp tracking info to $runspaces collection
                TR ( "Adding {0} to collection at {1}" -f $temp.object, $temp.starttime.tostring() )
                $this._runspaces.Add($temp) | Out-Null

                #loop through existing runspaces one time
                $this.getRunspaceData()# $false )

                #If we have more running than max queue (used to control timeout accuracy)
                #Script scope resolves odd PowerShell 2 issue
                $firstRun = $true
                while ($this._runspaces.count -ge $this.MaxQueue) {
                    #give verbose output
                    if($firstRun) {
                        TR "$($this._runspaces.count) items running - exceeded $($this.MaxQueue) limit."
                    }
                    $firstRun = $false

                    #run get-runspace data and sleep for a short while
                    $this.getRunspaceData()# $false )
                    Start-Sleep -Milliseconds $this.SleepTimer
                }
            #endregion add scripts to runspace pool
        }
        $this._allObjects.Clear()
    }
    hidden [void] beginJobs($pack) {

        if( -not $this._running) {
            $this._running = $true
            if( $this._startCallback) { $this._startCallback.Invoke($this) }
        }

        #Use Try/Finally to catch Ctrl+C and clean up.
        try {
            $this.addRunspaceJobs($pack)
            TR ( "Finish processing the remaining runspace jobs: {0}" -f ( @($this._runspaces | Where-Object {$Null -ne $_.Runspace}).Count) )
            $this.waitForJobs()
        }
        finally {
            <#
            #Close the runspace pool, unless we specified no close on timeout and something timed out
            if ( ($this._timedOutTasks -eq $false) -or ( ($this._timedOutTasks -eq $true) -and ($this.NoCloseOnTimeout -eq $false) ) ) {
                Write-Verbose "Closing the runspace pool"
                $this._runspacepool.close()
            }
            #collect garbage
            [gc]::Collect()
            #>

        }

        #$this._running = $false
        #if( $this._stopCallback) { $this._stopCallback.Invoke($this) }

    }
    hidden [void] statusUpdate( [boolean]$completed) {
        if (-not $this.Quiet) {
            if( -not $completed) {
                Write-Progress -Id $this._progressId -Activity "Running Query" -Status "Starting threads"`
                    -CurrentOperation "$($this._startedCount) threads defined - $($this._totalCount) input objects - $($this._completedCount) input objects processed"`
                    -PercentComplete $( Try { $($this._completedCount) / $($this._totalCount) * 100 } Catch {0} )
            }
            else {
                Write-Progress -Id $this._progressId -Activity "Running Query" -Status "Starting threads" -Completed
            }
        }
        if( $this._statusCallback) { 

            #[string]$ruleName = "Saving $($this._startedCount) filters..." #"Executing rule: $($args[0])"
            #write-host ("Creating shortcut for {0} with path {1} in folder {2}..." -f $_.Key, $_.Value[0], $_.Value[1])
            [string]$messageText = $this._messageText -f $this._commandFile #$($this._startedCount)
            if($completed) { $messageText = "Ready"   }

            #[string]$counter = "$($this._completedCount)" #"$($args[1])"
            [int]$cnt = $($($this._completedCount)*10 + $this._ticks)
            [string]$counter  = "$cnt" #"$($args[1])"

            #[string]$maxcount = "$($this._totalCount)" #"$($args[2])"\
            [int]$maxcnt = $($($this._totalCount)*10)
            [string]$maxcount =  "$maxcnt" #"$($args[2])"\

            if( $this._completedCount -gt $this._totalCount ) { 
                [int]$maxcnt = $($($this._completedCount)*10)
                #$maxcount = "$($this._completedCount)"
                $maxcount = "$maxcnt" 

            }
            if($cnt -ge $maxcnt) {
                $counter  = "$maxcnt"
            }
            
            [string]$message = "$messageText,$counter,$maxcount"
            # [LEGACY-IPC] Jobs Phase 6 IT3.1: Explorer no longer registers OnStatus - Job
            # tasks report to their Job; kept for any other caller of this class
            if($this._statusCallback) { $this._statusCallback.Invoke($this, $message) }
        }
    }
    hidden [boolean] getRunspaceData() { # [boolean]$wait = $false) {
        #[cmdletbinding()]
        #param( [switch]$Wait )
        #loop through runspaces
        #if $wait is specified, keep looping until all complete
        $more = $false
# Do {
            #set more to false for tracking completion
            $more = $false

            #Progress bar if we have inputobject count (bound parameter)
            $this.statusUpdate($false)

            #run through each runspace.
            $this._commandFile = ""
            foreach($runspace in $this._runspaces) {

                #get the duration - inaccurate
                $currentdate = Get-Date
                $runtime = $currentdate - $runspace.startTime
                $runMin = [math]::Round( $runtime.totalminutes ,2 )

                #set up log object
                $log = "" | Select-Object Date, Action, Runtime, Status, Details
                $log.Action = "Removing:'$($runspace.object)'"
                $log.Date = $currentdate
                $log.Runtime = "$runMin minutes"

                #If runspace completed, end invoke, dispose, recycle, counter++
                If ($runspace.Runspace.isCompleted) {

                    $this._completedCount++

                    #check if there were errors
                    if($runspace.powershell.Streams.Error.Count -gt 0) {
                        #set the logging info and move the file to completed
                        $log.status = "CompletedWithErrors"
                        Write-Verbose ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1]
                        foreach($ErrorRecord in $runspace.powershell.Streams.Error) {
                            Write-Error -ErrorRecord $ErrorRecord
                        }
                    }
                    else {
                        #add logging details and cleanup
                        $log.status = "Completed"
                        Write-Verbose ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1]
                    }

                    #everything is logged, clean up the runspace
                    $runspace.powershell.EndInvoke($runspace.Runspace)
                    $runspace.powershell.dispose()
                    $runspace.Runspace = $null
                    $runspace.powershell = $null
                    if( $this._endCallback ) { $this._endCallback.Invoke($this, $runspace.object.ts_RuleSet, $runspace.object) }     # IT3.1: + pack

                }
                #If runtime exceeds max, dispose the runspace
                elseIf ( $this.RunspaceTimeout -ne 0 -and $runtime.totalseconds -gt $this.RunspaceTimeout) {
                    $this._completedCount++
                    $this._timedOutTasks = $true

                    #add logging details and cleanup
                    $log.status = "TimedOut"
                    Write-Verbose ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1]
                    Write-Error "Runspace timed out at $($runtime.totalseconds) seconds for the object:`n$($runspace.object | out-string)"

                    #Depending on how it hangs, we could still get stuck here as dispose calls a synchronous method on the powershell instance
                    if (!$this.NoCloseOnTimeout) { $runspace.powershell.dispose() }
                    $runspace.Runspace = $null
                    $runspace.powershell = $null
                    if( $this._errorCallback ) { $this._errorCallback.Invoke($this, $runspace.object.ts_RuleSet, $runspace.object) }     # IT3.1: + pack

                }

                #If runspace isn't null set more to true
                elseIf ($null -ne $runspace.Runspace ) {
                    $log = $null
                    $more = $true
                    if( $null -ne $runspace.object.ts_RuleSet) { $this._commandFile += "$($runspace.object.ts_RuleSet) "}
                }

                #log the results if a log file was indicated
                if($this.LogFile -and $log) {
                    ($log | ConvertTo-Csv -Delimiter ";" -NoTypeInformation)[1] | out-file $this.LogFile -append
                }
            }

            #Clean out unused runspace jobs
            $temphash = $this._runspaces.clone()
            $temphash | Where-Object { $Null -eq $_.runspace } | ForEach-Object {
                $this._runspaces.remove($_)
            }

            #sleep for a bit if we will loop again
# if($wait){ Start-Sleep -milliseconds $this.SleepTimer }

        #Loop again only if -wait parameter and there are more runspaces to process
# } while ($more -and $wait)

        return $more
    #End of runspace function
    }
    hidden [void] removeFilter( $filterNode) {
        $selection = ($($this._filterNodes) | Where-Object { -not (($($_.xPath) -eq $($filterNode.xPath)) -and (($_.Scope) -eq $($filterNode.Scope) -and (($_.optype) -eq $($filterNode.optype)))) })
        $this._filterNodes = [System.Collections.ArrayList]::new()
        if( $selection ) { $this._filterNodes += $selection }
    }
    hidden [void] addFilter( $filterNode) {
        $this.removeFilter( $filterNode)
        $this._filterNodes += $filterNode
    }    
    hidden [void] updateFilter($pack) {
        $this._filterNodes = $pack.filterNodes#.Clone()
<#
        Write-Host "<<<<<<<<<<<<<<<<<<< updateFilter >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"
        Write-Host ">>>>>BEFORE: filterNodes:"
        Write-Host "-------------------------"
        Write-Host ($this._filterNodes | FT | Out-String)
#>

        [System.Collections.Stack]$nodesStack = $pack.nodesStack.Clone()
        while( $($nodesStack.Count) -gt 1 ) { $nodesStack.Pop() }

        $termNode = $nodesStack.Peek()
        [string]$operation  = $($termNode.Operation)
        [int]$optype        = $($termNode.optype)
<#
        Write-Host ">>>>> termNode---------------"
        Write-Host ($termNode | FT | Out-String)
        Write-Host "-----------------------------"
#>

        if ($operation -eq "add") {                
            #if( $null -ne $term) {
                $details = @{
                    xPath =     $termNode.xPath
                    Scope =     $termNode.Scope
                    optype =    $optype
                    Filter =    $termNode.Attributes.Clone()
                }
                $object = New-Object PSObject -Property $details 
                $this.addFilter($object) 
            #}
        }
        elseif($operation -eq "remove") {
            $this.removeFilter($termNode)
        }
        else {

        }
<#
        Write-Host ">>>>>AFTER: filterNodes:"
        Write-Host "------------------------"
        Write-Host ($this._filterNodes | FT | Out-String)
        Write-Host ">>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>"
#>
        
    }

    #--------------------------------------
    # Jobs Phase 6 IT3.1 - job wrapper
    # Text of the scriptblock a Job task runs on a pool runspace. The
    # ruleset script is embedded unchanged as $__inner and called with the
    # ORIGINAL pack the Job hands over (Command of the pulled envelope).
    # SRxBasePipeChildAPI is created inside the module scope of
    # [Runspaces]::JobModuleName (PowerShell classes of a module are not
    # visible to scripts), falling back to the global type (tests).
    #--------------------------------------
    static [string] JobWrapperText([string]$scriptText) {
        $head = @'
param($__pack)
$__ok = $true
$__c = $null
$__errBase = $Error.Count
try {
    # IT3.1 r3: never run against Explorer's OWN $global:SRxEnv. ImportVariables hands the
    # pool a reference to it; its script methods belong to Explorer's runspace, so every
    # call from this thread is marshalled into Explorer's busy UI runspace and breaks both
    # sides ("'Test-Path' / 'New-Object' is not recognized"). The ruleset script builds a
    # runspace-local one (ExecuteRulesetJob.ps1 -> Initialize-SRxEnv), as a child process does.
    Remove-Variable -Name SRxEnv -Scope Global -Force -ErrorAction Ignore
    # IT3.1 r4: that Initialize-SRxEnv must not touch the console (title / buffer size):
    # $Host of a pool runspace is Explorer's own console. -NoHostUI by default in THIS runspace.
    if ($null -eq $global:PSDefaultParameterValues) { $global:PSDefaultParameterValues = @{} }
    $global:PSDefaultParameterValues['Initialize-SRxEnv:NoHostUI'] = $true
    # the SRxCore that exports the CURRENT Job functions (not any other loaded copy)
    $__mod = $null
    $__cmd = Get-Command Connect-SRxJobsChild -ErrorAction Ignore
    if ($null -ne $__cmd) { $__mod = $__cmd.Module }
    if ($null -eq $__mod) { $__mod = Get-Module -Name '__MODULE__' -ErrorAction Ignore | Sort-Object Version -Descending | Select-Object -First 1 }
    if ($null -ne $__mod) { $__c = & $__mod { [SRxBasePipeChildAPI]::new() } }
    else { $__c = New-Object -TypeName 'SRxBasePipeChildAPI' }
    # older class copies have no NoSync: then no sync worker is started anyway in a runspace
    if ($null -ne $__c.PSObject.Properties['NoSync']) { $__c.NoSync = $true }
    if (-not $__c.Pull([string]$__pack.ts_JobToken, [string]$__pack.ts_JobPipe)) { throw ('Job token not pulled: ' + $__c.LastError) }
    $global:SRxJobChild = $__c
    $global:__onRuleCallback = $__c.RuleCallback()
    $global:__onRuleCompletedCallback = $__c.RuleCompletedCallback()
    [void]$__c.Hello()
    # legacy pipe-Shell / pipe-Explorer client calls of the script -> this Job (same as Job windows)
    if (Get-Command Install-SRxJobChannels -ErrorAction Ignore) {
        try { Install-SRxJobChannels } catch { [void]$__c.Post('log', ('Install-SRxJobChannels: ' + $_.Exception.Message)) }
    }
    $__task = $__c.Command
    if ($null -eq $__task) { $__task = $__pack }
    # IT3.1 r5/r6: the task's own SRxEnv is built HERE (the ruleset script's
    # Initialize-SRxEnv then finds it); its changes are diffed and sent to
    # Explorer through the Job ('env' before 'complete'). The SRxEnv object is
    # passed explicitly (a class method may see another runspace's globals).
    $__syncEnv = ($null -ne $__c.PSObject.Methods['CaptureEnvBaseline']) -and [bool]$__c.SyncEnv
    if ($__syncEnv -and $null -eq $global:SRxEnv) {
        if (Get-Command Initialize-SRxEnv -ErrorAction Ignore) {
            $__sid = $null
            try { $__sid = $__task.ts_SourceID } catch { $__sid = $null }
            try { Initialize-SRxEnv -SourceID $__sid } # -NoHostUI via PSDefaultParameterValues (r4)
            catch { [void]$__c.Post('problem', ('Initialize-SRxEnv failed: ' + $_.Exception.Message)) }
        }
        else { [void]$__c.Post('problem', 'Initialize-SRxEnv is not available in the runspace - SRxEnv changes cannot be reported') }
    }
    if ($__syncEnv) {
        if ($null -eq $global:SRxEnv) { [void]$__c.Post('problem', 'No SRxEnv in the runspace after Initialize-SRxEnv') }
        else { $__c.CaptureEnvBaseline($global:SRxEnv) }
    }
    $__errBase = $Error.Count # errors of the ruleset script only
    $__inner = {
'@

        $tail = @'
    }
    $null = & $__inner $__task
}
catch {
    $__ok = $false
    if ($null -ne $__c -and $__c.Ready) { [void]$__c.Post('problem', ('Task failed: ' + $_.Exception.Message)) }
    else { throw }
}
finally {
    if ($null -ne $__c -and $__c.Ready) {
        $__new = $Error.Count - $__errBase
        if ($__new -gt 0) {
            $__n = [Math]::Min($__new, __MAXERR__)
            for ($__i = $__n - 1; $__i -ge 0; $__i--) { [void]$__c.Post('problem', ('Error: ' + [string]$Error[$__i])) }
            if ($__new -gt $__n) { [void]$__c.Post('problem', ('... and {0} more error(s)' -f ($__new - $__n))) }
            if (__FAILONERR__) { $__ok = $false }
        }
        # IT3.1 r6: 'env' from THIS runspace's SRxEnv, before 'complete' (also on failure)
        if ($null -ne $__c.PSObject.Methods['PublishEnvChanges'] -and [bool]$__c.SyncEnv) {
            try { [void]$__c.PublishEnvChanges($global:SRxEnv) } catch { [void]$__c.Post('problem', ('SRxEnv report failed: ' + $_.Exception.Message)) }
        }
        [void]$__c.Complete($__ok)
    }
    $global:SRxJobChild = $null
    $global:__onRuleCallback = $null
    $global:__onRuleCompletedCallback = $null
}
'@

        $h = $head.Replace('__MODULE__', [Runspaces]::JobModuleName)
        $t = $tail.Replace('__MAXERR__', [string][Runspaces]::MaxReportedErrors)
        if ([Runspaces]::FailOnErrors) { $t = $t.Replace('__FAILONERR__', '$true') } else { $t = $t.Replace('__FAILONERR__', '$false') }
        return ($h + "`n" + $scriptText + "`n" + $t)
    }

    #--------------------------------------
    # Public methods
    #--------------------------------------
    [void] MessageText($formattedString) {
        $this._messageText = $formattedString
    }
    [void] OnStart( [System.Management.Automation.ScriptBlock]$onStartCallback ) {
        $this._startCallback = $onStartCallback
    }
    [void] OnStatus( [System.Management.Automation.ScriptBlock]$onStatusCallback ) {
        $this._statusCallback = $onStatusCallback
    }
    [void] OnBegin( [System.Management.Automation.ScriptBlock]$onBeginCallback ) {
        $this._beginCallback = $onBeginCallback
    }
    [void] OnEnd( [System.Management.Automation.ScriptBlock]$onEndCallback ) {
        $this._endCallback = $onEndCallback
    }
    [void] OnError( [System.Management.Automation.ScriptBlock]$onErrorCallback ) {
        $this._errorCallback = $onErrorCallback
    }
    [void] OnStop( [System.Management.Automation.ScriptBlock]$onStopCallback ) {
        $this._stopCallback = $onStopCallback
    }       
    [void] OnClose( [System.Management.Automation.ScriptBlock]$onCloseCallback ) {
        $this._closeCallback = $onCloseCallback
    }       
    [void] Open (){
        $this.load()
        $this.init()
    }
    [boolean]Running() { return $this._running }
    [System.Collections.ArrayList] AddPack($pack) {        
        #$this.updateFilter($pack)
        $this.beginJobs($pack) 
        return $null #$this._filterNodes
    }
    [void] Clear() {
        $this.endJobs()
        #if( $this._stopCallback) { $this._stopCallback.Invoke($this) }
    }
    [void] Close (){
        $this.endJobs()
        #Close the runspace pool, unless we specified no close on timeout and something timed out
        if ( ($this._timedOutTasks -eq $false) -or ( ($this._timedOutTasks -eq $true) -and ($this.NoCloseOnTimeout -eq $false) ) ) {
            Write-Verbose "Closing the runspace pool"
            $this._runspacepool.close()
        }
        #collect garbage
        [gc]::Collect()
        
        #
        if( $this._closeCallback) { $this._closeCallback.Invoke($this) }
    }

}