Public/Get-StmScheduledTaskRun.ps1

function Get-StmScheduledTaskRun {
    <#
    .SYNOPSIS
        Retrieves run history for scheduled tasks on a local or remote computer.
 
    .DESCRIPTION
        The Get-StmScheduledTaskRun function retrieves information about the execution history of scheduled tasks
        from the Windows Task Scheduler. It queries the Task Scheduler event log to provide details about task
        runs, including start and end times, status, and results. You can filter by task name and target a
        specific computer. Optionally, credentials can be supplied for remote queries.
 
    .PARAMETER TaskName
        The name of the scheduled task to retrieve run history for. If not specified, retrieves run history for
        all tasks.
 
    .PARAMETER TaskPath
        The path of the scheduled task(s) to retrieve run history for. Matches the TaskPath parameter of
        Get-ScheduledTask. If not specified, all task paths are considered.
 
    .PARAMETER ComputerName
        The name of the computer to query. If not specified, the local computer is used.
 
    .PARAMETER Credential
        The credentials to use when connecting to the remote computer. If not specified, the current user's
        credentials are used.
 
    .PARAMETER MaxRuns
        The maximum number of task runs to return per task. If not specified, all available runs are returned.
 
    .EXAMPLE
        Get-StmScheduledTaskRun -TaskName "MyTask"
 
        Retrieves the run history for the scheduled task named "MyTask" on the local computer.
 
    .EXAMPLE
        Get-StmScheduledTaskRun -ComputerName "Server01"
 
        Retrieves the run history for all scheduled tasks on the remote computer "Server01".
 
    .EXAMPLE
        $credentials = Get-Credential
        Get-StmScheduledTaskRun -TaskName "BackupTask" -ComputerName "Server02" -Credential $credentials
 
        Retrieves the run history for the "BackupTask" scheduled task on "Server02" using the specified
        credentials.
 
    .EXAMPLE
        Get-StmScheduledTaskRun -TaskName "MyTask" -MaxRuns 5
 
        Retrieves the 5 most recent runs for the scheduled task named "MyTask" on the local computer.
 
    .INPUTS
        None. You cannot pipe objects to Get-StmScheduledTaskRun.
 
    .OUTPUTS
        PSCustomObject
        Returns objects containing details about each scheduled task run, including task name, start time, end
        time, status, and result.
 
    .NOTES
        This function requires access to the Microsoft-Windows-TaskScheduler/Operational event log on the target
        computer. Remote queries require appropriate permissions and network connectivity.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $TaskName,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $TaskPath,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]
        $ComputerName = 'localhost',

        [Parameter(Mandatory = $false)]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]
        [System.Management.Automation.Credential()]
        $Credential = [System.Management.Automation.PSCredential]::Empty,

        [Parameter(Mandatory = $false)]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $MaxRuns
    )

    begin {
        Write-Verbose 'Starting Get-StmScheduledTaskRun'
        $scheduledTaskParameters = @{
            ErrorAction = 'Stop'
        }
        if ($PSBoundParameters.ContainsKey('TaskName')) {
            Write-Verbose "Using provided task name '$TaskName'"
            $scheduledTaskParameters['TaskName'] = $TaskName
        }
        else {
            Write-Verbose 'No task name provided (all task names)'
        }

        if ($PSBoundParameters.ContainsKey('TaskPath')) {
            Write-Verbose "Using provided task path '$TaskPath'"
            $scheduledTaskParameters['TaskPath'] = $TaskPath
        }
        else {
            Write-Verbose 'No task path provided (all task paths)'
        }

        $cimSessionParameters = @{
            ErrorAction = 'Stop'
        }
        $getWinEventCommonParameters = @{
            LogName     = 'Microsoft-Windows-TaskScheduler/Operational'
            ErrorAction = 'Stop'
        }
        $cimSessionParameters['ComputerName'] = $ComputerName
        if ($PSBoundParameters.ContainsKey('ComputerName')) {
            Write-Verbose "Using provided computer name '$ComputerName'"
            $getWinEventCommonParameters['ComputerName'] = $ComputerName
        }
        else {
            Write-Verbose 'Using local computer'
        }
        if ($PSBoundParameters.ContainsKey('Credential')) {
            Write-Verbose 'Using provided credential'
            $cimSessionParameters['Credential'] = $Credential
            $getWinEventCommonParameters['Credential'] = $Credential
        }
        else {
            Write-Verbose 'Using current user credentials'
        }
        $cimSession = New-StmCimSession @cimSessionParameters
        $scheduledTaskParameters['CimSession'] = $cimSession
    }

    process {
        try {
            $scheduledTasks = Get-ScheduledTask @scheduledTaskParameters
            Write-Verbose "Retrieved $($scheduledTasks.Count) task(s)"
        }
        catch {
            $errorRecordParameters = @{
                Exception         = $_.Exception
                ErrorId           = 'ScheduledTaskRetrievalFailed'
                ErrorCategory     = [System.Management.Automation.ErrorCategory]::NotSpecified
                TargetObject      = $TaskName
                Message           = "Failed to retrieve scheduled tasks. $($_.Exception.Message)"
                RecommendedAction = (
                    'Verify the task name is correct and that you have permission to access the scheduled tasks.'
                )
            }
            $errorRecord = New-StmError @errorRecordParameters
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        }

        if ($null -eq $scheduledTasks -or $scheduledTasks.Count -eq 0) {
            Write-Verbose 'No scheduled tasks found.'
            return # Exit early if no tasks are found
        }

        # Use a stack to process tasks in LIFO order
        $scheduledTasksToProcess = New-Object -TypeName 'System.Collections.Stack'
        $scheduledTasks | ForEach-Object {
            $scheduledTasksToProcess.Push($_)
        }

        # Iterate through the stack to process each task
        Write-Verbose "Processing $($scheduledTasksToProcess.Count) scheduled task(s) for last run information"
        while ($scheduledTasksToProcess.Count -gt 0) {
            $currentTask = $scheduledTasksToProcess.Pop()
            Write-Verbose "Processing task '$($currentTask.TaskName)'"
            try {
                Write-Verbose "Getting scheduled task information for task '$($currentTask.TaskName)'"
                $currentTaskInfo = $currentTask | Get-ScheduledTaskInfo -ErrorAction 'Continue'
                if ($null -eq $currentTaskInfo) {
                    Write-Verbose "No scheduled task information found for task '$($currentTask.TaskName)'"
                }
                else {
                    Write-Verbose (
                        "Scheduled task information for task '$($currentTask.TaskName)': " +
                        "$($currentTaskInfo | Out-String)"
                    )
                }

                Write-Verbose (
                    "Retrieving all events for task '$($currentTask.TaskName)' from the Task Scheduler " +
                    "Operational log"
                )
                Write-Verbose "Log Name: $($getWinEventCommonParameters['LogName'])"
                $allEventsXPathParameters = @{
                    NamedDataFilter = @{
                        TaskName = $currentTask.URI
                    }
                }
                $allEventsXPathFilter = Get-WinEventXPathFilter @allEventsXPathParameters
                Write-Verbose (
                    "XPath filter for all events of task '$($currentTask.TaskName)': $allEventsXPathFilter"
                )
                $taskEventsParameters = @{
                    FilterXPath = $allEventsXPathFilter
                }
                $taskEvents = Get-WinEvent @taskEventsParameters @getWinEventCommonParameters
                Write-Verbose "Retrieved $($taskEvents.Count) event(s) for task '$($currentTask.TaskName)'"

                if ($null -eq $taskEvents -or $taskEvents.Count -eq 0) {
                    Write-Verbose "No events found for task '$($currentTask.TaskName)'"
                    continue # Skip to the next task if no events are found
                }

                $uniqueActivityIds = $taskEvents | Select-Object -ExpandProperty 'ActivityId' -Unique
                Write-Verbose (
                    "Found $($uniqueActivityIds.Count) unique activity ID(s) for task '$($currentTask.TaskName)'"
                )

                # Limit the number of activity IDs if MaxRuns is specified
                if ($PSBoundParameters.ContainsKey('MaxRuns')) {
                    Write-Verbose "Limiting to $MaxRuns most recent runs for task '$($currentTask.TaskName)'"
                    $uniqueActivityIds = $uniqueActivityIds | Select-Object -First $MaxRuns
                    Write-Verbose (
                        "Limited to $($uniqueActivityIds.Count) activity ID(s) for task '$($currentTask.TaskName)'"
                    )
                }

                foreach ($activityId in $uniqueActivityIds) {
                    Write-Verbose "Processing activity ID '$activityId' for task '$($currentTask.TaskName)'"
                    $runDetails = [ordered]@{
                        TaskName             = $currentTask.TaskName
                        ActivityId           = $activityId
                        ResultCode           = $null
                        StartTime            = $null
                        EndTime              = $null
                        Duration             = $null
                        DurationSeconds      = $null
                        LaunchRequestIgnored = $null
                        Events               = $null
                        EventCount           = $null
                        EventXml             = $null
                    }
                    $activityEvents = $taskEvents | Where-Object { $_.ActivityId -eq $activityId }
                    Write-Verbose (
                        "Found $($activityEvents.Count) event(s) for activity ID '$activityId' of task " +
                        "'$($currentTask.TaskName)'"
                    )
                    if ($activityEvents.Count -eq 0) {
                        Write-Verbose (
                            "No events found for activity ID '$activityId' of task '$($currentTask.TaskName)'"
                        )
                        continue # Skip to the next activity ID if no events are found
                    }

                    # Find the start and end events for the activity ID
                    # The Windows Event Log returns events from newest to oldest,
                    # so the first event is the most recent
                    # Sort the events by RecordId to be safe
                    Write-Verbose (
                        "Sorting events by RecordId for activity ID '$activityId' of task " +
                        "'$($currentTask.TaskName)'"
                    )
                    $sortedEvents = $activityEvents | Sort-Object -Property 'RecordId' -Descending
                    Write-Verbose (
                        "Finding the start event for activity ID '$activityId' of task '$($currentTask.TaskName)'"
                    )
                    $startEvent = $sortedEvents | Select-Object -Last 1
                    Write-Verbose (
                        "Start event for activity ID '$activityId' of task '$($currentTask.TaskName)': " +
                        "$($startEvent | Out-String)"
                    )
                    Write-Verbose (
                        "Finding the end event for activity ID '$activityId' of task '$($currentTask.TaskName)'"
                    )
                    $endEvent = $sortedEvents | Select-Object -First 1
                    Write-Verbose (
                        "End event for activity ID '$activityId' of task '$($currentTask.TaskName)': " +
                        "$($endEvent | Out-String)"
                    )

                    # Some events may not have an ActivityId, so we need to handle that case
                    # For example, event ID 129 (Created Task Process) does not have an ActivityId
                    # We assume no unrelated events exist between the start and end events (🤞)
                    Write-Verbose (
                        "Finding events between the start and end events of task '$($currentTask.TaskName)' " +
                        "that do not have an ActivityId"
                    )
                    $eventsWithoutActivityId = $taskEvents | Where-Object {
                        $null -eq $_.ActivityId -and
                        $_.RecordId -gt $startEvent.RecordId -and
                        $_.RecordId -lt $endEvent.RecordId
                    }
                    Write-Verbose (
                        "Found $($eventsWithoutActivityId.Count) event(s) without ActivityId for activity ID " +
                        "'$activityId' of task '$($currentTask.TaskName)'"
                    )
                    if ($eventsWithoutActivityId.Count -gt 0) {
                        Write-Verbose (
                            "Adding events without ActivityId to run details for activity ID '$activityId' of " +
                            "task '$($currentTask.TaskName)'"
                        )
                        $sortedEvents += $eventsWithoutActivityId
                        $sortedEvents = $sortedEvents | Sort-Object -Property 'TimeCreated' -Descending
                    }

                    # Add all of the events to the run details
                    Write-Verbose (
                        "Adding events to run details for activity ID '$activityId' of task " +
                        "'$($currentTask.TaskName)'"
                    )
                    $runDetails['Events'] = $sortedEvents
                    $runDetails['EventCount'] = $sortedEvents.Count
                    $runDetails['EventXml'] = $sortedEvents | ForEach-Object {
                        # Convert each event to XML for more detailed information
                        Write-Verbose (
                            "Converting event with RecordId '$($_.RecordId)' to XML for activity ID " +
                            "'$activityId' of task '$($currentTask.TaskName)'"
                        )
                        $xml = $_.ToXml()
                        if ($null -eq $xml) {
                            Write-Verbose "Event with RecordId '$($_.RecordId)' has no XML representation"
                            $null
                        }
                        else {
                            Write-Verbose "Event with RecordId '$($_.RecordId)' converted to XML"
                            [xml]$xml
                        }
                    }
                    Write-Verbose (
                        "Added $($sortedEvents.Count) event(s) to run details for activity ID " +
                        "'$activityId' of task '$($currentTask.TaskName)'"
                    )

                    # Add the result code
                    $resultCode = $runDetails['EventXml'].Event.EventData.Data | Where-Object {
                        $_.Name -eq 'ResultCode'
                    } | Select-Object -ExpandProperty '#text' -Unique
                    $noResultCodeFound = (
                        $null -eq $resultCode -or
                        $resultCode.Count -eq 0 -or
                        [string]::IsNullOrEmpty($resultCode[0])
                    )
                    if ($noResultCodeFound) {
                        Write-Verbose (
                            "No ResultCode found for activity ID '$activityId' of task " +
                            "'$($currentTask.TaskName)'"
                        )
                    }
                    elseif ($resultCode.Count -gt 1) {
                        Write-Verbose (
                            "Multiple ResultCode(s) found for activity ID '$activityId' of task " +
                            "'$($currentTask.TaskName)'"
                        )
                        $selectObjectParameters = @{
                            InputObject    = $resultCode
                            ExpandProperty = 'ResultCode'
                            Unique         = $true
                        }
                        $runDetails['ResultCode'] = Select-Object @selectObjectParameters
                        Write-Verbose "Using multiple ResultCode(s): $($runDetails['ResultCode'] | Out-String)"
                    }
                    else {
                        Write-Verbose (
                            "Single ResultCode found for activity ID '$activityId' of task " +
                            "'$($currentTask.TaskName)'"
                        )
                        $runDetails['ResultCode'] = $resultCode
                        Write-Verbose (
                            "Using ResultCode '$($runDetails['ResultCode'])' for activity ID '$activityId' of " +
                            "task '$($currentTask.TaskName)'"
                        )
                    }

                    # Add the start and end times
                    $startTime = $startEvent.TimeCreated
                    $endTime = $endEvent.TimeCreated
                    Write-Verbose (
                        "Start time for activity ID '$activityId' of task '$($currentTask.TaskName)': $startTime"
                    )
                    Write-Verbose (
                        "End time for activity ID '$activityId' of task '$($currentTask.TaskName)': $endTime"
                    )
                    $runDetails['StartTime'] = $startTime
                    $runDetails['EndTime'] = $endTime

                    # Add the duration
                    $runDetails['Duration'] = $endTime - $startTime
                    $runDetails['DurationSeconds'] = [math]::Round($runDetails['Duration'].TotalSeconds, 2)
                    Write-Verbose (
                        "Duration for activity ID '$activityId' of task '$($currentTask.TaskName)': " +
                        "$($runDetails['Duration']) ($($runDetails['DurationSeconds']) seconds)"
                    )

                    # Check if the task was launched or ignored
                    $launchRequestIgnoredEvent = $sortedEvents | Where-Object {
                        $_.TaskDisplayName -eq 'Launch request ignored, instance already running'
                    }
                    if ($null -ne $launchRequestIgnoredEvent) {
                        Write-Verbose (
                            "Launch request ignored event found for activity ID '$activityId' of task " +
                            "'$($currentTask.TaskName)'"
                        )
                        $runDetails['LaunchRequestIgnored'] = $true
                    }
                    else {
                        Write-Verbose (
                            "No launch request ignored event found for activity ID '$activityId' of task " +
                            "'$($currentTask.TaskName)'"
                        )
                        $runDetails['LaunchRequestIgnored'] = $false
                    }

                    # Return the run details
                    Write-Verbose (
                        "Returning run details for activity ID '$activityId' of task '$($currentTask.TaskName)'"
                    )
                    [PSCustomObject]$runDetails
                }
            }
            catch {
                $errorRecordParameters = @{
                    Exception         = $_.Exception
                    ErrorId           = 'ScheduledTaskRunRetrievalFailed'
                    ErrorCategory     = [System.Management.Automation.ErrorCategory]::NotSpecified
                    TargetObject      = $currentTask.TaskName
                    Message           = (
                        "Failed to retrieve scheduled task run information for task " +
                        "'$($currentTask.TaskName)'. $($_.Exception.Message)"
                    )
                    RecommendedAction = (
                        'Verify the task name is correct and that you have permission to access the ' +
                        'scheduled tasks.'
                    )
                }
                $errorRecord = New-StmError @errorRecordParameters
                Write-Error -ErrorRecord $errorRecord
            }
        }
    }

    end {
        Write-Verbose 'Completed Get-StmScheduledTaskRun'
        if ($null -ne $cimSession) {
            Write-Verbose 'Closing CIM session'
            $cimSession | Remove-CimSession -ErrorAction 'SilentlyContinue'
        }
        else {
            Write-Verbose 'No CIM session to close'
        }
    }
}