Assets/Get-OpsJob.ps1

<#
.SYNOPSIS
    Gets the status of Ops Jobs.
.DESCRIPTION
    Reports each job's schedule state plus the script it runs and the arguments
    it runs it with, recovered from the Base64 payload New-OpsJob stored on the
    scheduled task's action.

    ScriptArguments comes back as an ordered hashtable, so it can be splatted
    straight back into New-OpsJob to re-register a job with the same arguments.
.PARAMETER JobName
    Optional specific job name. Supports wildcards.
#>

param (
    [string]$JobName = "*"
)

function Get-OpsJobPayload {
    param([string]$Arguments)

    # The payload is Base64 of UTF-8 JSON and therefore a single unquoted token;
    # matching the alphabet is enough to isolate it from the surrounding switches.
    if ($Arguments -notmatch '-PayloadBase64\s+([A-Za-z0-9+/=]+)') { return $null }

    try {
        $Json = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Matches[1]))
        return $Json | ConvertFrom-Json
    }
    catch {
        Write-Warning "Could not decode the payload of a scheduled task: $($_.Exception.Message)"
        return $null
    }
}

function ConvertTo-OpsArgumentTable {
    param($ScriptArguments)

    if ($null -eq $ScriptArguments) { return $null }

    # ConvertFrom-Json turns a JSON object into a PSCustomObject. A hashtable is
    # what New-OpsJob and JobRunner both expect, so hand back something the
    # caller can splat rather than something they have to convert first.
    if ($ScriptArguments -is [System.Management.Automation.PSCustomObject]) {
        $Table = [ordered]@{}
        foreach ($Property in $ScriptArguments.PSObject.Properties) {
            $Table[$Property.Name] = $Property.Value
        }
        return $Table
    }

    return @($ScriptArguments)
}

$Tasks = @(Get-ScheduledTask -TaskName "OpsJob-$JobName" -ErrorAction SilentlyContinue)

$Results = [System.Collections.Generic.List[object]]::new()

foreach ($Task in $Tasks) {
    $Info = $Task | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue

    $ScriptPath = $null
    $ScriptArguments = $null

    foreach ($Action in @($Task.Actions)) {
        $Arguments = [string]$Action.Arguments
        if ([string]::IsNullOrWhiteSpace($Arguments)) { continue }

        $Payload = Get-OpsJobPayload -Arguments $Arguments
        if ($null -ne $Payload) {
            $ScriptPath = $Payload.ScriptPath
            $ScriptArguments = ConvertTo-OpsArgumentTable -ScriptArguments $Payload.ScriptArguments
            break
        }

        # Jobs registered before the payload change passed -ScriptPath on the
        # command line. Their arguments cannot be recovered the same way, so
        # only the path is reported and ScriptArguments stays $null rather than
        # being guessed at.
        if ($Arguments -match '-ScriptPath\s+(?:"([^"]+)"|([^\s"]+))') {
            $ScriptPath = if ($Matches[1]) { $Matches[1] } else { $Matches[2] }
            break
        }
    }

    $Results.Add([PSCustomObject]@{
        # Anchored: an unanchored -replace would also strip the prefix from the
        # middle of a job name such as "Sync-OpsJob-Nightly".
        JobName         = ($Task.TaskName -replace '^OpsJob-', '')
        State           = $Task.State
        ScriptPath      = $ScriptPath
        ScriptArguments = $ScriptArguments
        LastRunTime     = $Info.LastRunTime
        LastTaskResult  = $Info.LastTaskResult
        NextRunTime     = $Info.NextRunTime
    })
}

return $Results.ToArray()