Duplicati Watchdog for Windows PowerShell 7 (for run-script-before)

Hi All,

I wanted to give you a watchdog script for Win11 I’m using for Duplicati.
The description is in the code header.

Have fun!

EDIT: Code change for PowerShell 7 only

#requires -Version 7.0

<#
.SYNOPSIS
    Duplicati 2.x watchdog for Windows using PowerShell 7+.

.DESCRIPTION
    This script is designed for use as Duplicati's "run-script-before"
    command.

    The run-script-before process does not perform the monitoring itself.
    It starts an independent hidden PowerShell 7 child process and exits
    immediately with exit code 0.

    The independent watchdog child:

      1. Reads DUPLICATI__backup_name.
      2. Resolves the exact Duplicati BackupID.
      3. Waits for the corresponding backup task to become active.
      4. Obtains the exact TaskID from:
             ServerUtil status --json
      5. Verifies that exact TaskID through:
             GET /api/v1/tasks
      6. Monitors only that exact TaskID.
      7. Exits immediately when that exact task finishes.
      8. If the task exceeds TimeoutMinutes:
           - sends STOP to the exact TaskID
           - waits StopWaitSeconds
           - verifies the exact same BackupID/TaskID
           - sends ABORT if the same task is still active
           - waits AbortWaitSeconds
           - verifies the exact same BackupID/TaskID again
           - terminates Duplicati.Server.exe only if the same task
             is still active
           - waits ServerRestartWaitSeconds
           - starts Duplicati.Server.exe again if necessary

    SAFETY PRINCIPLE

    No STOP, ABORT or server termination is performed merely because
    some Duplicati task exists.

    Immediately before every destructive operation the watchdog verifies:

        BackupID == originally resolved BackupID
        TaskID   == originally resolved TaskID

    The REST API must additionally report that exact TaskID as Running.

    If the BackupID or TaskID changes, or the REST API no longer reports
    the exact task as Running, destructive action is cancelled.

    MULTIPLE WATCHDOGS

    Every watchdog is associated with one exact BackupID and TaskID.
    This allows several backup jobs and watchdog processes to coexist.

    NORMAL COMPLETION

    The watchdog exits as soon as its exact task is no longer Running.
    It does not wait for the complete timeout period.

    BACKUP IDENTIFICATION

    The backup name is supplied by Duplicati through:

        DUPLICATI__backup_name

    The exact name is resolved through:

        GET /api/v1/backups

    TASK IDENTIFICATION

    ServerUtil status --json is the authoritative source for:

        BackupID -> TaskID

    The REST API /api/v1/tasks is then used to verify the exact TaskID
    and its current state.

    AUTHENTICATION

    The Duplicati server password is read from Windows Credential Manager
    using the target:

        duplicati-serverutil-password

    ServerUtil is called using:

        --password=$duplicati-serverutil-password
        --secret-provider=wincred://

    The password is never stored in this script.

    POWERSHELL VERSION

    PowerShell 7.x is required.

    Windows PowerShell 5.1 may remain installed on the system. It is not
    used by this script.

    The run-script-before command must explicitly invoke PowerShell 7.

    Example:

        "C:\Program Files\PowerShell\7\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -File "C:\Path\To\DuplicatiWatchdog.ps1"

    The exact script path is installation-specific and must be adjusted
    outside this script.

    CHILD PROCESS

    The parent process determines the PowerShell 7 executable from
    $PSHOME and starts an independent hidden child.

    The child receives:

        -WatchdogChild

    The child inherits Duplicati's environment, including:

        DUPLICATI__backup_name

    The parent does not wait for the child.

    TEST MODE

    Test mode is non-destructive.

        .\DuplicatiWatchdog.ps1 -Test -Debug -BackupName "backup-name"

    Test mode verifies:

        - Windows Credential Manager access
        - Duplicati API authentication
        - exact backup-name lookup
        - BackupID resolution
        - ServerUtil active-task lookup
        - exact TaskID resolution
        - REST TaskID verification

    Test mode does NOT:

        - STOP a task
        - ABORT a task
        - terminate Duplicati.Server.exe
        - restart Duplicati.Server.exe

    IMPORTANT

    A successful test proves only the checks performed by the test.
    It does not prove that STOP, ABORT or server restart will succeed.

    DEBUG MODE

    Use:

        -Debug

    to display diagnostic information.

    No permanent log file is created.

    DEFAULT CONFIGURATION

        TimeoutMinutes           = 10
        PollIntervalSeconds      = 5
        StopWaitSeconds          = 10
        AbortWaitSeconds         = 10
        ServerRestartWaitSeconds = 15
        InitialDiscoverySeconds  = 60

    SERVER RESTART

    The watchdog only starts Duplicati.Server.exe after termination if
    the server process has not already returned.

    SECURITY

    The Duplicati password is stored outside this script in Windows
    Credential Manager.

    The script does not write the password to a log file.

    INSTALLATION

    Save this script as:

        DuplicatiWatchdog.ps1

    Place it in any directory accessible to the account running Duplicati.

    Configure Duplicati's run-script-before command to explicitly use
    PowerShell 7.

    MANUAL TEST

    Example:

        .\DuplicatiWatchdog.ps1 -Test -Debug -BackupName "backup-name"

    LICENSE / PUBLICATION

    This script is provided as automation code for Duplicati installations.
    Use and modify it at your own risk. No warranty is provided.
#>

[CmdletBinding()]
param(
    [Alias('Test')]
    [switch]$TestMode,

    [switch]$WatchdogChild,

    [string]$BackupName
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# ============================================================================
# CONFIGURATION
# ============================================================================

$DuplicatiServerUrl = 'http://127.0.0.1:8200'

$ServerUtilPath = Join-Path `
    ${env:ProgramFiles} `
    'Duplicati 2\Duplicati.CommandLine.ServerUtil.exe'

$DuplicatiServerExe = Join-Path `
    ${env:ProgramFiles} `
    'Duplicati 2\Duplicati.Server.exe'

$CredentialTarget = 'duplicati-serverutil-password'

$TimeoutMinutes = 10

$PollIntervalSeconds = 5

$StopWaitSeconds = 10

$AbortWaitSeconds = 10

$ServerRestartWaitSeconds = 15

$InitialDiscoverySeconds = 60

# ============================================================================
# DEBUG OUTPUT
# ============================================================================

function Write-DebugMessage {
    param(
        [Parameter(Mandatory)]
        [string]$Message
    )

    if ($DebugPreference -ne 'SilentlyContinue') {
        Write-Host "[DEBUG $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')] $Message"
    }
}

# ============================================================================
# WINDOWS CREDENTIAL MANAGER
# ============================================================================

Add-Type @'
using System;
using System.Runtime.InteropServices;

public static class NativeCredentialManager
{
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    public struct CREDENTIAL
    {
        public uint Flags;
        public uint Type;
        public IntPtr TargetName;
        public IntPtr Comment;
        public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
        public uint CredentialBlobSize;
        public IntPtr CredentialBlob;
        public uint Persist;
        public uint AttributeCount;
        public IntPtr Attributes;
        public IntPtr TargetAlias;
        public IntPtr UserName;
    }

    [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    public static extern bool CredRead(
        string target,
        uint type,
        uint flags,
        out IntPtr credential
    );

    [DllImport("advapi32.dll", SetLastError = true)]
    public static extern void CredFree(IntPtr credential);
}
'@

function Get-WindowsCredentialPassword {
    param(
        [Parameter(Mandatory)]
        [string]$Target
    )

    Write-DebugMessage "Reading Windows Credential Manager target '$Target'."

    $credentialPtr = [IntPtr]::Zero

    try {
        $success = [NativeCredentialManager]::CredRead(
            $Target,
            1,
            0,
            [ref]$credentialPtr
        )

        if (-not $success) {
            $errorCode = [Runtime.InteropServices.Marshal]::GetLastWin32Error()

            throw "CredRead failed for '$Target'. Windows error code: $errorCode."
        }

        $credential = [Runtime.InteropServices.Marshal]::PtrToStructure(
            $credentialPtr,
            [type][NativeCredentialManager+CREDENTIAL]
        )

        if ($credential.CredentialBlobSize -eq 0) {
            throw "Credential '$Target' exists but contains an empty password."
        }

        $password = [Runtime.InteropServices.Marshal]::PtrToStringUni(
            $credential.CredentialBlob,
            [int]($credential.CredentialBlobSize / 2)
        )

        if ([string]::IsNullOrWhiteSpace($password)) {
            throw "Credential '$Target' contains an empty password."
        }

        Write-DebugMessage 'Credential Manager lookup succeeded.'

        return $password
    }
    finally {
        if ($credentialPtr -ne [IntPtr]::Zero) {
            [NativeCredentialManager]::CredFree($credentialPtr)
        }
    }
}

# ============================================================================
# DUPLICATI REST API
# ============================================================================

function Invoke-DuplicatiApi {
    param(
        [Parameter(Mandatory)]
        [ValidateSet('GET', 'POST', 'PUT', 'DELETE')]
        [string]$Method,

        [Parameter(Mandatory)]
        [string]$Path,

        [string]$AccessToken,

        [object]$Body
    )

    $uri = "$DuplicatiServerUrl$Path"

    $headers = @{
        Accept = 'application/json'
    }

    if (-not [string]::IsNullOrWhiteSpace($AccessToken)) {
        $headers['Authorization'] = "Bearer $AccessToken"
    }

    Write-DebugMessage "API $Method $uri"

    $params = @{
        Uri         = $uri
        Method      = $Method
        Headers     = $headers
        ErrorAction = 'Stop'
    }

    if ($null -ne $Body) {
        $params['ContentType'] = 'application/json'
        $params['Body'] = ($Body | ConvertTo-Json -Compress)
    }

    try {
        return Invoke-RestMethod @params
    }
    catch {
        Write-DebugMessage "API request failed: $($_.Exception.Message)"
        throw
    }
}

# ============================================================================
# DUPLICATI AUTHENTICATION
# ============================================================================

function Get-DuplicatiAccessToken {
    param(
        [Parameter(Mandatory)]
        [string]$Password
    )

    Write-DebugMessage 'Requesting Duplicati REST access token.'

    $body = @{
        password = $Password
    }

    $response = Invoke-DuplicatiApi `
        -Method POST `
        -Path '/api/v1/auth/login' `
        -Body $body

    $token = $null

    foreach ($propertyName in @(
        'AccessToken',
        'accessToken',
        'Token',
        'token'
    )) {
        if ($null -ne $response.PSObject.Properties[$propertyName]) {
            $candidate = $response.$propertyName

            if (-not [string]::IsNullOrWhiteSpace([string]$candidate)) {
                $token = [string]$candidate
                break
            }
        }
    }

    if ([string]::IsNullOrWhiteSpace($token)) {
        throw 'Duplicati authentication succeeded but no access token was returned.'
    }

    Write-DebugMessage 'Duplicati REST access token obtained.'

    return $token
}

# ============================================================================
# EXACT BACKUP LOOKUP
# ============================================================================

function Get-ExactBackup {
    param(
        [Parameter(Mandatory)]
        [string]$AccessToken,

        [Parameter(Mandatory)]
        [string]$Name
    )

    Write-DebugMessage "Looking up exact backup name '$Name'."

    $response = Invoke-DuplicatiApi `
        -Method GET `
        -Path '/api/v1/backups' `
        -AccessToken $AccessToken

    $items = @($response)

    Write-DebugMessage "Backup response top-level item count: $($items.Count)"

    $matches = @()

    foreach ($item in $items) {
        if ($null -eq $item) {
            continue
        }

        $id = $null
        $candidateName = $null

        if ($null -ne $item.PSObject.Properties['ID']) {
            $id = [string]$item.ID
        }
        elseif ($null -ne $item.PSObject.Properties['Id']) {
            $id = [string]$item.Id
        }

        if ($null -ne $item.PSObject.Properties['Name']) {
            $candidateName = [string]$item.Name
        }

        Write-DebugMessage "Backup candidate: ID='$id' Name='$candidateName'"

        if ($candidateName -ceq $Name) {
            $matches += $item
        }
    }

    if ($matches.Count -eq 0) {
        throw "No backup with the exact name '$Name' was found."
    }

    if ($matches.Count -gt 1) {
        throw "More than one backup with the exact name '$Name' was returned."
    }

    $backup = $matches[0]

    $backupId = $null

    if ($null -ne $backup.PSObject.Properties['ID']) {
        $backupId = [string]$backup.ID
    }
    elseif ($null -ne $backup.PSObject.Properties['Id']) {
        $backupId = [string]$backup.Id
    }

    if ([string]::IsNullOrWhiteSpace($backupId)) {
        throw "Backup '$Name' was found but has no usable BackupID."
    }

    return [pscustomobject]@{
        Name = $Name
        ID   = $backupId
    }
}

# ============================================================================
# SERVERUTIL STATUS
# ============================================================================

function Get-ServerUtilStatus {

    if (-not (Test-Path -LiteralPath $ServerUtilPath)) {
        throw "ServerUtil was not found: $ServerUtilPath"
    }

    Write-DebugMessage 'Calling ServerUtil status --json.'

    $arguments = @(
        'status'
        '--json'
        '--password=$duplicati-serverutil-password'
        '--secret-provider=wincred://'
    )

    $output = & $ServerUtilPath @arguments 2>&1

    $exitCode = $LASTEXITCODE

    Write-DebugMessage "ServerUtil exit code: $exitCode"

    $raw = ($output | Out-String).Trim()

    Write-DebugMessage "ServerUtil raw response:`n$raw"

    if ($exitCode -ne 0) {
        throw "ServerUtil status failed with exit code $exitCode."
    }

    if ([string]::IsNullOrWhiteSpace($raw)) {
        throw 'ServerUtil returned an empty response.'
    }

    try {
        return ($raw | ConvertFrom-Json)
    }
    catch {
        throw "Could not parse ServerUtil JSON: $($_.Exception.Message)"
    }
}

# ============================================================================
# EXACT ACTIVE TASK
# ============================================================================

function Find-ExactActiveTask {
    param(
        [Parameter(Mandatory)]
        [string]$BackupId
    )

    $status = Get-ServerUtilStatus

    if ($null -eq $status.ActiveTask) {
        Write-DebugMessage 'ServerUtil reports no active task.'
        return $null
    }

    $activeTaskId = [string]$status.ActiveTask.Task
    $activeBackupId = [string]$status.ActiveTask.BackupId

    Write-DebugMessage "ServerUtil ActiveTask: Task='$activeTaskId' BackupId='$activeBackupId'."

    if ($activeBackupId -ne $BackupId) {
        Write-DebugMessage "Active task belongs to BackupID '$activeBackupId', not '$BackupId'."
        return $null
    }

    if ([string]::IsNullOrWhiteSpace($activeTaskId)) {
        throw "ServerUtil returned BackupID '$BackupId' but no TaskID."
    }

    return [pscustomobject]@{
        BackupId = $BackupId
        TaskId   = $activeTaskId
    }
}

# ============================================================================
# EXACT REST TASK
# ============================================================================

function Get-ExactTaskState {
    param(
        [Parameter(Mandatory)]
        [string]$AccessToken,

        [Parameter(Mandatory)]
        [string]$TaskId
    )

    $response = Invoke-DuplicatiApi `
        -Method GET `
        -Path '/api/v1/tasks' `
        -AccessToken $AccessToken

    $records = @($response)

    foreach ($record in $records) {

        if ($null -eq $record) {
            continue
        }

        if ($null -eq $record.PSObject.Properties['ID']) {
            continue
        }

        if ([string]$record.ID -eq $TaskId) {
            return $record
        }
    }

    return $null
}

function Test-ExactTaskActive {
    param(
        [Parameter(Mandatory)]
        [string]$AccessToken,

        [Parameter(Mandatory)]
        [string]$TaskId
    )

    $task = Get-ExactTaskState `
        -AccessToken $AccessToken `
        -TaskId $TaskId

    if ($null -eq $task) {
        Write-DebugMessage "REST API has no record for TaskID '$TaskId'."
        return $null
    }

    Write-DebugMessage "REST task state:`n$($task | ConvertTo-Json -Depth 10)"

    if ([string]$task.Status -eq 'Running') {
        return $task
    }

    return $null
}

# ============================================================================
# FINAL SAFETY CHECK
# ============================================================================

function Confirm-ExactTaskStillActive {
    param(
        [Parameter(Mandatory)]
        [string]$BackupId,

        [Parameter(Mandatory)]
        [string]$TaskId
    )

    Write-DebugMessage "Safety verification for BackupID '$BackupId', TaskID '$TaskId'."

    $status = Get-ServerUtilStatus

    if ($null -eq $status.ActiveTask) {
        Write-DebugMessage 'Safety verification failed: no active task.'
        return $false
    }

    $currentTaskId = [string]$status.ActiveTask.Task
    $currentBackupId = [string]$status.ActiveTask.BackupId

    Write-DebugMessage "Safety check: BackupID='$currentBackupId', TaskID='$currentTaskId'."

    if ($currentBackupId -ne $BackupId) {
        Write-DebugMessage 'Safety verification failed: BackupID changed.'
        return $false
    }

    if ($currentTaskId -ne $TaskId) {
        Write-DebugMessage 'Safety verification failed: TaskID changed.'
        return $false
    }

    $taskState = Test-ExactTaskActive `
        -AccessToken $script:AccessToken `
        -TaskId $TaskId

    if ($null -eq $taskState) {
        Write-DebugMessage 'Safety verification failed: REST task is no longer Running.'
        return $false
    }

    return $true
}

# ============================================================================
# STOP
# ============================================================================

function Stop-ExactTask {
    param(
        [Parameter(Mandatory)]
        [string]$AccessToken,

        [Parameter(Mandatory)]
        [string]$TaskId
    )

    Write-DebugMessage "Sending STOP request for TaskID $TaskId."

    try {
        Invoke-DuplicatiApi `
            -Method POST `
            -Path "/api/v1/task/$TaskId/stop" `
            -AccessToken $AccessToken | Out-Null

        Write-DebugMessage "STOP request accepted for TaskID $TaskId."
    }
    catch {
        Write-DebugMessage "STOP request failed for TaskID ${TaskId}: $($_.Exception.Message)"
        throw
    }
}

# ============================================================================
# ABORT
# ============================================================================

function Abort-ExactTask {
    param(
        [Parameter(Mandatory)]
        [string]$AccessToken,

        [Parameter(Mandatory)]
        [string]$TaskId
    )

    Write-DebugMessage "Sending ABORT request for TaskID $TaskId."

    try {
        Invoke-DuplicatiApi `
            -Method POST `
            -Path "/api/v1/task/$TaskId/abort" `
            -AccessToken $AccessToken | Out-Null

        Write-DebugMessage "ABORT request accepted for TaskID $TaskId."
    }
    catch {
        Write-DebugMessage "ABORT request failed for TaskID ${TaskId}: $($_.Exception.Message)"
        throw
    }
}

# ============================================================================
# DUPLICATI SERVER PROCESS
# ============================================================================

function Get-DuplicatiServerProcess {

    $processes = @(
        Get-Process `
            -Name 'Duplicati.Server' `
            -ErrorAction SilentlyContinue
    )

    if ($processes.Count -eq 0) {
        return $null
    }

    return $processes[0]
}

function Stop-DuplicatiServer {

    $process = Get-DuplicatiServerProcess

    if ($null -eq $process) {
        Write-DebugMessage 'Duplicati.Server.exe is not currently running.'
        return
    }

    Write-DebugMessage "Terminating Duplicati.Server.exe PID $($process.Id)."

    Stop-Process `
        -Id $process.Id `
        -Force `
        -ErrorAction Stop

    Write-DebugMessage 'Duplicati.Server.exe terminated.'
}

# ============================================================================
# SERVER START
# ============================================================================

function Start-DuplicatiServer {

    if (-not (Test-Path -LiteralPath $DuplicatiServerExe)) {
        throw "Duplicati.Server.exe was not found: $DuplicatiServerExe"
    }

    $existing = Get-DuplicatiServerProcess

    if ($null -ne $existing) {
        Write-DebugMessage "Duplicati.Server.exe already running with PID $($existing.Id)."
        return
    }

    Write-DebugMessage 'Starting Duplicati.Server.exe.'

    $psi = [System.Diagnostics.ProcessStartInfo]::new()

    $psi.FileName = $DuplicatiServerExe
    $psi.UseShellExecute = $false
    $psi.CreateNoWindow = $true
    $psi.WorkingDirectory = Split-Path -Parent $DuplicatiServerExe

    $process = [System.Diagnostics.Process]::new()

    $process.StartInfo = $psi

    if (-not $process.Start()) {
        throw 'Failed to start Duplicati.Server.exe.'
    }

    Write-DebugMessage "Duplicati.Server.exe started with PID $($process.Id)."

    $process.Dispose()
}

function Wait-ForDuplicatiServer {
    param(
        [Parameter(Mandatory)]
        [int]$TimeoutSeconds
    )

    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)

    while ((Get-Date) -lt $deadline) {

        try {
            $status = Get-ServerUtilStatus

            if ([string]$status.ServerState -eq 'Running') {
                Write-DebugMessage 'Duplicati server reports Running.'
                return $true
            }
        }
        catch {
            Write-DebugMessage "Server not ready yet: $($_.Exception.Message)"
        }

        Start-Sleep -Seconds 1
    }

    return $false
}

# ============================================================================
# START INDEPENDENT WATCHDOG CHILD
# ============================================================================

function Start-WatchdogChild {

    Write-DebugMessage 'Starting independent PowerShell 7 watchdog child.'

    $pwshPath = Join-Path $PSHOME 'pwsh.exe'

    if (-not (Test-Path -LiteralPath $pwshPath)) {
        throw "PowerShell 7 executable was not found: $pwshPath"
    }

    if ([string]::IsNullOrWhiteSpace($PSCommandPath)) {
        throw 'PSCommandPath is empty; cannot start watchdog child.'
    }

    $psi = [System.Diagnostics.ProcessStartInfo]::new()

    $psi.FileName = $pwshPath
    $psi.UseShellExecute = $false
    $psi.CreateNoWindow = $true
    $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden

    $psi.WorkingDirectory = Split-Path `
        -Parent `
        $PSCommandPath

    [void]$psi.ArgumentList.Add('-NoProfile')
    [void]$psi.ArgumentList.Add('-NonInteractive')
    [void]$psi.ArgumentList.Add('-ExecutionPolicy')
    [void]$psi.ArgumentList.Add('Bypass')
    [void]$psi.ArgumentList.Add('-File')
    [void]$psi.ArgumentList.Add($PSCommandPath)
    [void]$psi.ArgumentList.Add('-WatchdogChild')

    $process = [System.Diagnostics.Process]::new()

    $process.StartInfo = $psi

    if (-not $process.Start()) {
        throw 'PowerShell 7 watchdog child could not be started.'
    }

    Write-DebugMessage "Watchdog child started with PID $($process.Id)."

    $process.Dispose()
}

# ============================================================================
# NON-DESTRUCTIVE TEST MODE
# ============================================================================

function Invoke-TestMode {
    param(
        [Parameter(Mandatory)]
        [string]$Name
    )

    Write-Host ''
    Write-Host '================================================'
    Write-Host 'Duplicati Watchdog - NON-DESTRUCTIVE TEST'
    Write-Host '================================================'
    Write-Host ''

    Write-Host '[1/5] Reading password from Windows Credential Manager...'

    $password = Get-WindowsCredentialPassword `
        -Target $CredentialTarget

    Write-Host '      Credential Manager OK'

    Write-Host ''
    Write-Host '[2/5] Authenticating against Duplicati API...'

    $script:AccessToken = Get-DuplicatiAccessToken `
        -Password $password

    Write-Host '      API authentication OK'

    Write-Host ''
    Write-Host '[3/5] Resolving exact backup name...'

    $backup = Get-ExactBackup `
        -AccessToken $script:AccessToken `
        -Name $Name

    Write-Host ''
    Write-Host '      EXACT BACKUP FOUND'
    Write-Host ''
    Write-Host "      BackupName = $($backup.Name)"
    Write-Host "      BackupID   = $($backup.ID)"

    Write-Host ''
    Write-Host '[4/5] Resolving exact active task through ServerUtil...'

    $activeTask = Find-ExactActiveTask `
        -BackupId $backup.ID

    if ($null -eq $activeTask) {

        Write-Host '      No active task found for this backup.'
        Write-Host ''
        Write-Host 'TEST PASSED - API and backup resolution work; no active task existed to verify.'
        return
    }

    Write-Host ''
    Write-Host '      EXACT ACTIVE TASK FOUND'
    Write-Host ''
    Write-Host "      BackupName = $($backup.Name)"
    Write-Host "      BackupID   = $($activeTask.BackupId)"
    Write-Host "      TaskID     = $($activeTask.TaskId)"

    Write-Host ''
    Write-Host '[5/5] Verifying exact TaskID through REST API...'

    $taskState = Get-ExactTaskState `
        -AccessToken $script:AccessToken `
        -TaskId $activeTask.TaskId

    if ($null -eq $taskState) {
        throw "TaskID $($activeTask.TaskId) was not returned by /api/v1/tasks."
    }

    Write-Host ''
    Write-Host '      REST task state:'
    Write-Host ($taskState | ConvertTo-Json -Depth 10)

    if ([string]$taskState.ID -ne [string]$activeTask.TaskId) {
        throw 'REST API TaskID does not match ServerUtil TaskID.'
    }

    if ([string]$taskState.Status -ne 'Running') {
        throw "Exact TaskID $($activeTask.TaskId) is not Running."
    }

    Write-Host ''
    Write-Host 'TEST PASSED - exact BackupID -> TaskID resolution and REST verification work.'
    Write-Host ''
    Write-Host 'IMPORTANT: This test is NON-DESTRUCTIVE.'
    Write-Host 'STOP, ABORT and server restart were NOT executed.'
}

# ============================================================================
# WATCHDOG
# ============================================================================

function Invoke-Watchdog {
    param(
        [Parameter(Mandatory)]
        [string]$Name
    )

    Write-DebugMessage "Watchdog child started for backup '$Name'."

    $password = Get-WindowsCredentialPassword `
        -Target $CredentialTarget

    $script:AccessToken = Get-DuplicatiAccessToken `
        -Password $password

    $backup = Get-ExactBackup `
        -AccessToken $script:AccessToken `
        -Name $Name

    $backupId = [string]$backup.ID

    Write-DebugMessage "Resolved backup '$Name' to BackupID '$backupId'."

    # ------------------------------------------------------------------------
    # Wait for this backup to become the active task.
    # ------------------------------------------------------------------------

    $discoveryDeadline = (Get-Date).AddSeconds($InitialDiscoverySeconds)

    $exactTask = $null

    while ((Get-Date) -lt $discoveryDeadline) {

        $exactTask = Find-ExactActiveTask `
            -BackupId $backupId

        if ($null -ne $exactTask) {
            break
        }

        Start-Sleep -Seconds $PollIntervalSeconds
    }

    if ($null -eq $exactTask) {

        Write-DebugMessage `
            "No active task for BackupID '$backupId' was discovered within $InitialDiscoverySeconds seconds."

        return
    }

    $taskId = [string]$exactTask.TaskId

    Write-DebugMessage `
        "Monitoring exact BackupID '$backupId', TaskID '$taskId'."

    # ------------------------------------------------------------------------
    # Initial REST verification.
    # ------------------------------------------------------------------------

    $taskState = Test-ExactTaskActive `
        -AccessToken $script:AccessToken `
        -TaskId $taskId

    if ($null -eq $taskState) {

        Write-DebugMessage `
            "TaskID '$taskId' was no longer Running during initial verification."

        return
    }

    # ------------------------------------------------------------------------
    # Determine task start time.
    # ------------------------------------------------------------------------

    $taskStartTime = $null

    if ($null -ne $taskState.TaskStarted) {

        try {
            $taskStartTime = [DateTimeOffset]::Parse(
                [string]$taskState.TaskStarted
            )
        }
        catch {
            Write-DebugMessage `
                'Could not parse TaskStarted; using current time.'
        }
    }

    if ($null -eq $taskStartTime) {
        $taskStartTime = [DateTimeOffset]::Now
    }

    Write-DebugMessage "Task start time: $taskStartTime"

    $timeoutAt = $taskStartTime.AddMinutes($TimeoutMinutes)

    Write-DebugMessage "Timeout threshold: $timeoutAt"

    # ------------------------------------------------------------------------
    # Monitor exact TaskID.
    # ------------------------------------------------------------------------

    while ($true) {

        $currentTask = Test-ExactTaskActive `
            -AccessToken $script:AccessToken `
            -TaskId $taskId

        if ($null -eq $currentTask) {

            Write-DebugMessage `
                "Exact TaskID '$taskId' is no longer Running. Watchdog exits normally."

            return
        }

        if ((Get-Date) -lt $timeoutAt.LocalDateTime) {

            Start-Sleep `
                -Seconds $PollIntervalSeconds

            continue
        }

        Write-DebugMessage `
            "TIMEOUT reached for exact TaskID '$taskId'."

        break
    }

    # ------------------------------------------------------------------------
    # STOP
    # ------------------------------------------------------------------------

    Write-DebugMessage 'Performing final safety check before STOP.'

    if (-not (Confirm-ExactTaskStillActive `
        -BackupId $backupId `
        -TaskId $taskId)) {

        Write-DebugMessage `
            'STOP cancelled because the exact task is no longer safely identifiable.'

        return
    }

    Stop-ExactTask `
        -AccessToken $script:AccessToken `
        -TaskId $taskId

    Start-Sleep `
        -Seconds $StopWaitSeconds

    # ------------------------------------------------------------------------
    # Verify after STOP.
    # ------------------------------------------------------------------------

    Write-DebugMessage 'Checking exact task after STOP.'

    $stillExact = Confirm-ExactTaskStillActive `
        -BackupId $backupId `
        -TaskId $taskId

    if (-not $stillExact) {

        Write-DebugMessage `
            "TaskID '$taskId' stopped successfully. Watchdog exits."

        return
    }

    # ------------------------------------------------------------------------
    # ABORT
    # ------------------------------------------------------------------------

    Write-DebugMessage `
        "TaskID '$taskId' is still active after STOP."

    if (-not (Confirm-ExactTaskStillActive `
        -BackupId $backupId `
        -TaskId $taskId)) {

        Write-DebugMessage `
            'ABORT cancelled because the exact task is no longer safely identifiable.'

        return
    }

    Abort-ExactTask `
        -AccessToken $script:AccessToken `
        -TaskId $taskId

    Start-Sleep `
        -Seconds $AbortWaitSeconds

    # ------------------------------------------------------------------------
    # Verify after ABORT.
    # ------------------------------------------------------------------------

    Write-DebugMessage 'Checking exact task after ABORT.'

    $stillExact = Confirm-ExactTaskStillActive `
        -BackupId $backupId `
        -TaskId $taskId

    if (-not $stillExact) {

        Write-DebugMessage `
            "TaskID '$taskId' terminated after ABORT. Watchdog exits."

        return
    }

    # ------------------------------------------------------------------------
    # LAST RESORT: TERMINATE SERVER
    # ------------------------------------------------------------------------

    Write-DebugMessage `
        "TaskID '$taskId' is still active after ABORT."

    if (-not (Confirm-ExactTaskStillActive `
        -BackupId $backupId `
        -TaskId $taskId)) {

        Write-DebugMessage `
            'Server termination cancelled because the exact task is no longer safely identifiable.'

        return
    }

    Write-DebugMessage `
        "Exact TaskID '$taskId' is still active. Terminating Duplicati.Server.exe."

    Stop-DuplicatiServer

    Write-DebugMessage `
        "Waiting $ServerRestartWaitSeconds seconds before checking/restarting Duplicati."

    Start-Sleep `
        -Seconds $ServerRestartWaitSeconds

    # ------------------------------------------------------------------------
    # Restart only if the server did not return by itself.
    # ------------------------------------------------------------------------

    $serverProcess = Get-DuplicatiServerProcess

    if ($null -eq $serverProcess) {

        Write-DebugMessage `
            'Duplicati.Server.exe has not returned. Starting it.'

        Start-DuplicatiServer

        if (Wait-ForDuplicatiServer -TimeoutSeconds 30) {

            Write-DebugMessage `
                'Duplicati server restart confirmed.'
        }
        else {

            Write-DebugMessage `
                'WARNING: Duplicati server did not report Running within 30 seconds.'
        }
    }
    else {

        Write-DebugMessage `
            "Duplicati.Server.exe already returned with PID $($serverProcess.Id). No second start performed."
    }

    Write-DebugMessage 'Watchdog escalation completed.'
}

# ============================================================================
# ENTRY POINT
# ============================================================================

try {

    # ------------------------------------------------------------------------
    # TEST MODE
    #
    # Test mode must work without DUPLICATI__backup_name.
    # ------------------------------------------------------------------------

    if ($TestMode) {

        if ([string]::IsNullOrWhiteSpace($BackupName)) {
            throw 'Test mode requires -BackupName "exact backup name".'
        }

        Invoke-TestMode `
            -Name $BackupName

        exit 0
    }

    # ------------------------------------------------------------------------
    # WATCHDOG CHILD
    # ------------------------------------------------------------------------

    if ($WatchdogChild) {

        $environmentBackupName = $env:DUPLICATI__backup_name

        if ([string]::IsNullOrWhiteSpace($environmentBackupName)) {

            Write-DebugMessage `
                'Watchdog child has no DUPLICATI__backup_name. Exiting safely.'

            exit 0
        }

        Invoke-Watchdog `
            -Name $environmentBackupName

        exit 0
    }

    # ------------------------------------------------------------------------
    # NORMAL run-script-before PARENT
    #
    # The parent starts the independent child and exits immediately.
    # It does not monitor the child and does not wait for it.
    # ------------------------------------------------------------------------

    $environmentBackupName = $env:DUPLICATI__backup_name

    if ([string]::IsNullOrWhiteSpace($environmentBackupName)) {

        Write-DebugMessage `
            'DUPLICATI__backup_name is not set. Nothing to monitor.'

        exit 0
    }

    Write-DebugMessage `
        "run-script-before invoked for backup '$environmentBackupName'."

    Start-WatchdogChild

    Write-DebugMessage `
        'Independent watchdog child started. Parent exits immediately with code 0.'

    exit 0
}
catch {

    Write-Error $_.Exception.Message

    # ------------------------------------------------------------------------
    # The parent must never abort a Duplicati backup because the watchdog
    # itself failed to start.
    #
    # Test mode and the child return non-zero so errors are visible when
    # manually testing/debugging.
    # ------------------------------------------------------------------------

    if ($TestMode -or $WatchdogChild) {
        exit 1
    }

    exit 0
}

The code has been optimized:

#Requires -Version 7.0

<#
.SYNOPSIS
    Duplicati 2.x watchdog for Windows using PowerShell 7+.

.DESCRIPTION
    This script is designed for use as Duplicati's "run-script-before"
    command.

    The run-script-before process does not perform the monitoring itself.
    It starts an independent hidden PowerShell 7 child process and exits
    immediately with exit code 0.

    The independent watchdog child:

      1. Reads DUPLICATI__backup_name.
      2. Resolves the exact Duplicati BackupID.
      3. Waits for the corresponding backup task to become active.
      4. Obtains the exact TaskID from:
             ServerUtil status --json
      5. Verifies that exact TaskID through:
             GET /api/v1/tasks
      6. Monitors only that exact TaskID.
      7. Exits immediately when that exact task finishes.
      8. If the task exceeds TimeoutMinutes:
           - sends STOP to the exact TaskID
           - waits StopWaitSeconds
           - verifies the exact same BackupID/TaskID
           - sends ABORT if the same task is still active
           - waits AbortWaitSeconds
           - verifies the exact same BackupID/TaskID again
           - terminates Duplicati.GUI.TrayIcon.exe only if the same task
             is still active
           - waits ServerRestartWaitSeconds
           - starts Duplicati.GUI.TrayIcon.exe again if it is not already
             running
           - waits until the Duplicati server is reachable on port 8200
           - retries startup at most ServerStartAttempts times

      CRASH HANDLING

      The watchdog also records the exact Duplicati.GUI.TrayIcon.exe PID that
      was present when monitoring began. If that process disappears while
      the watchdog is monitoring the backup, Duplicati is treated as having
      crashed. The watchdog then skips the STOP/ABORT escalation that can no
      longer be performed and goes directly to the server restart stage.

      If the task disappears after STOP or ABORT, the watchdog distinguishes
      between these cases:

        - TrayIcon still exists: the task was removed/stopped while Duplicati
          itself remained alive; the watchdog exits.
        - TrayIcon disappeared: Duplicati itself was lost; the watchdog
          proceeds to restart Duplicati.

    SAFETY PRINCIPLE

    No STOP, ABORT or server termination is performed merely because
    some Duplicati task exists.

    Immediately before every destructive operation the watchdog verifies:

        BackupID == originally resolved BackupID
        TaskID   == originally resolved TaskID

    The REST API must additionally report that exact TaskID as Running.

    If the BackupID or TaskID changes, or the REST API no longer reports
    the exact task as Running, destructive action is cancelled.

    MULTIPLE WATCHDOGS

    Every watchdog is associated with one exact BackupID and TaskID.
    This allows several backup jobs and watchdog processes to coexist.

    NORMAL COMPLETION

    The watchdog exits as soon as its exact task is no longer active while
    the Duplicati TrayIcon host process is still alive.

    A task disappearing is NOT treated as normal completion if the monitored
    TrayIcon process has also disappeared. In that case the watchdog treats
    the process loss as a Duplicati crash and enters the restart stage.

    BACKUP IDENTIFICATION

    The backup name is supplied by Duplicati through:

        DUPLICATI__backup_name

    The exact name is resolved through:

        GET /api/v1/backups

    TASK IDENTIFICATION

    ServerUtil status --json is the authoritative source for:

        BackupID -> TaskID

    The REST API /api/v1/tasks is then used to verify the exact TaskID
    and its current state.

    AUTHENTICATION

    The Duplicati server password is read from Windows Credential Manager
    using the target:

        duplicati-serverutil-password

    ServerUtil is called using:

        --password=$duplicati-serverutil-password
        --secret-provider=wincred://

    The password is never stored in this script.

    POWERSHELL VERSION

    PowerShell 7.x is required.

    Windows PowerShell 5.1 may remain installed on the system. It is not
    used by this script.

    The run-script-before command must explicitly invoke PowerShell 7.

    Example:

        "C:\Program Files\PowerShell\7\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -File "C:\Path\To\DuplicatiWatchdog.ps1"

    The exact script path is installation-specific and must be adjusted
    outside this script.

    CHILD PROCESS

    The parent process determines the PowerShell 7 executable from
    $PSHOME and starts an independent hidden child.

    The child receives:

        -WatchdogChild

    The child inherits Duplicati's environment, including:

        DUPLICATI__backup_name

    The parent does not wait for the child.

    TEST MODE

    Test mode is non-destructive.

        .\DuplicatiWatchdog.ps1 -Test -Debug -BackupName "backup-name"

    Test mode verifies:

        - Windows Credential Manager access
        - Duplicati API authentication
        - exact backup-name lookup
        - BackupID resolution
        - ServerUtil active-task lookup
        - exact TaskID resolution
        - REST TaskID verification

    Test mode does NOT:

        - STOP a task
        - ABORT a task
        - terminate Duplicati.GUI.TrayIcon.exe
        - restart Duplicati.GUI.TrayIcon.exe

    IMPORTANT

    A successful test proves only the checks performed by the test.
    It does not prove that STOP, ABORT or server restart will succeed.

    DEBUG MODE

    DEBUG IS OFF BY DEFAULT.

    For a normal production run, leave:

        $EnableDebugLog = $false

    For a real backup test started by Duplicati, temporarily change it to:

        $EnableDebugLog = $true

    Then start the backup normally through Duplicati. No change to the
    Duplicati run-script-before command is required.

    With $EnableDebugLog = $true, diagnostic messages are written to:

        DuplicatiWatchdog.log

    in the same directory as this script.

    After the test, set:

        $EnableDebugLog = $false

    again.

    The built-in PowerShell -Debug switch can additionally be used for
    manual diagnostic or test-mode invocations, for example:

        .\DuplicatiWatchdog.ps1 -Test -Debug -BackupName "backup-name"

    The script deliberately does not define its own -Debug parameter because
    -Debug is already a PowerShell common parameter.

    The debug log records parent and child startup so that a very short
    parent process cannot disappear before its execution can be diagnosed.

    The log can contain:

        - script startup
        - parent startup
        - DUPLICATI__backup_name
        - child startup
        - child PID
        - child initialization
        - backup identification
        - task discovery
        - task monitoring
        - timeout
        - STOP
        - ABORT
        - server termination
        - server restart
        - normal completion
        - errors
        - script exit

    DEFAULT CONFIGURATION

        TimeoutMinutes           = 10
        PollIntervalSeconds      = 5
        StopWaitSeconds          = 10
        AbortWaitSeconds         = 10
        ServerRestartWaitSeconds = 15
        ServerStartAttempts      = 3
        ServerStartupWaitSeconds = 60
        InitialDiscoverySeconds  = 60
        EnableDebugLog            = false

    REAL BACKUP ESCALATION TEST

    To test the escalation sequence with a real Duplicati backup:

      1. Temporarily set EnableDebugLog to $true.
      2. Temporarily reduce TimeoutMinutes, for example to 2.
      3. Start the selected backup normally through Duplicati.
      4. Inspect DuplicatiWatchdog.log in the script directory.
      5. Verify which escalation stage was reached.
      6. After the test, restore TimeoutMinutes to 10.
      7. Set EnableDebugLog back to $false.

    A shortened timeout can deliberately trigger the timeout logic, but it
    does not guarantee that every later escalation stage will be reached.
    STOP may terminate the task before ABORT is needed. ABORT and server
    termination therefore require the exact task to remain active after the
    preceding stage.

    SERVER RESTART

    This watchdog is intentionally designed only for the Windows Duplicati
    GUI/TrayIcon configuration in which Duplicati.GUI.TrayIcon.exe hosts
    the Duplicati server. It does not handle standalone Duplicati.Server.exe
    installations. Users of a standalone server must provide their own
    process-handling logic if required.

    The watchdog records the exact TrayIcon PID present when monitoring
    begins. During STOP/ABORT escalation, loss of that PID means the
    Duplicati host process has disappeared and the watchdog immediately
    switches to the restart stage.

    After the exact task has survived STOP and ABORT, only a
    Duplicati.GUI.TrayIcon.exe process whose executable path exactly matches
    the configured Duplicati.GUI.TrayIcon.exe path is eligible for termination.

    The watchdog never intentionally starts a second TrayIcon instance while
    an exact TrayIcon process is already running. This is important because
    Duplicati's GUI/TrayIcon code enforces single-instance behavior.

    After the old process is confirmed terminated, the watchdog waits
    ServerRestartWaitSeconds. It then starts Duplicati.GUI.TrayIcon.exe and
    waits up to ServerStartupWaitSeconds for TCP port 8200 to become
    reachable. Startup is attempted at most ServerStartAttempts times.

    Port readiness is checked at the normal PollIntervalSeconds interval;
    the watchdog does not assume that starting the process means that the
    embedded Duplicati server is already ready. After both STOP and ABORT,
    the watchdog also checks whether 127.0.0.1:8200 is still reachable. If
    the TrayIcon process is alive but the server port is no longer reachable,
    the watchdog treats Duplicati as unhealthy and enters restart handling.

    SECURITY

    The Duplicati password is stored outside this script in Windows
    Credential Manager.

    The password is never written to the debug log.

    USER PROFILE / USERNAME

    No Windows username is hard-coded in this script.

    The script uses its own $PSScriptRoot and the current PowerShell
    environment to locate its files.

    RUN-SCRIPT-BEFORE

    The script is intended to be configured as Duplicati's run-script-before
    command. The parent exits immediately after starting the independent
    watchdog child, so Duplicati is not kept waiting for the watchdog timeout.

    The watchdog child continues independently in the background.

    MULTIPLE WATCHDOGS / TASK SAFETY

    Each watchdog resolves the backup name supplied by its own Duplicati
    invocation, obtains the corresponding BackupID and then the exact TaskID.

    Destructive actions are repeatedly checked against that original
    BackupID/TaskID pair. A different task is not treated as the original
    task.

    SERVER PROCESS SAFETY

    This watchdog targets only Duplicati.GUI.TrayIcon.exe because that is the
    process that hosts the Duplicati server in the supported configuration.

    Server termination is only attempted after the exact task has again been
    verified as active. The candidate process must also have the exact
    configured Duplicati.GUI.TrayIcon.exe executable path.

    If exact process termination cannot be confirmed, the watchdog does not
    start another TrayIcon process merely on assumption.

    If the monitored TrayIcon PID disappears before server termination is
    needed, that is treated as a Duplicati process crash and the watchdog
    proceeds to restart handling.

    INSTALLATION

    Save this script as:

        DuplicatiWatchdog.ps1

    Place it in any directory accessible to the account running Duplicati.

    Configure Duplicati's run-script-before command to explicitly use
    PowerShell 7.

    MANUAL TEST

    Example:

        .\DuplicatiWatchdog.ps1 -Test -Debug -BackupName "backup-name"

    This manual test is non-destructive and does not execute STOP, ABORT,
    server termination or server restart.

    PASSWORD VERIFICATION

    The ServerUtil credential can be verified independently using the
    configured Windows Credential Manager entry and ServerUtil's list-backups
    command. The password itself must never be placed in this script.

    TROUBLESHOOTING

    Use $EnableDebugLog = $true for a real Duplicati-triggered test, or use
    -Test -Debug for a manual test.

    Inspect DuplicatiWatchdog.log for:

        - parent and child PIDs
        - backup name
        - BackupID
        - TaskID
        - task status
        - timeout detection
        - STOP result
        - ABORT result
        - exact-task rechecks
        - server process handling
        - restart handling
        - errors and exit codes

    A successful test does not prove that destructive escalation or server
    restart will succeed; those stages require the corresponding conditions.

    CUSTOMIZATION

    The timeout and polling values are defined near the beginning of the
    script and can be adjusted if required.

    The default values are intentionally conservative and can be changed
    for a particular Duplicati installation.

    DESIGN PRINCIPLES

    The watchdog is deliberately self-contained.

    It uses no Task Scheduler and requires no run-script-after command.

    Each watchdog monitors only the task belonging to the backup that
    started it.

    Destructive operations require repeated identity verification.

    Normal task completion always takes precedence over timeout escalation.

    LICENSE / PUBLICATION

    This script is provided as automation code for the supported Duplicati
    GUI/TrayIcon configuration described above.
    Use and modify it at your own risk. No warranty is provided.
#>

[CmdletBinding()]
param(
    [Alias('Test')]
    [switch]$TestMode,

    [switch]$WatchdogChild,

    [string]$BackupName
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------

$DuplicatiServerUrl = 'http://127.0.0.1:8200'

$ServerUtilPath = Join-Path ${env:ProgramFiles} 'Duplicati 2\Duplicati.CommandLine.ServerUtil.exe'
$DuplicatiTrayIconExe = Join-Path ${env:ProgramFiles} 'Duplicati 2\Duplicati.GUI.TrayIcon.exe'

$CredentialTarget = 'duplicati-serverutil-password'

$TimeoutMinutes = 10
$PollIntervalSeconds = 5
$StopWaitSeconds = 10
$AbortWaitSeconds = 10
$ServerRestartWaitSeconds = 15
$ServerStartAttempts = 3
$ServerStartupWaitSeconds = 60
$InitialDiscoverySeconds = 60

# Set to $true temporarily when a real Duplicati-triggered run should create
# the diagnostic log. Leave $false for normal production operation.
$EnableDebugLog = $false

# Log is deliberately stored beside this script.
$LogPath = Join-Path $PSScriptRoot 'DuplicatiWatchdog.log'
$LogDirectory = $PSScriptRoot

$DebugOutput = $EnableDebugLog -or $PSBoundParameters.ContainsKey('Debug')

# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------

function Write-Log {
    param(
        [Parameter(Mandatory)]
        [string]$Message,

        [ValidateSet('INFO', 'DEBUG', 'WARN', 'ERROR')]
        [string]$Level = 'INFO'
    )

    try {
        $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'

        if ($WatchdogChild) {
            $mode = 'CHILD'
        }
        elseif ($TestMode) {
            $mode = 'TEST'
        }
        else {
            $mode = 'PARENT'
        }

        $line = "$timestamp [$Level] [$mode] [PID=$PID] $Message"

        if ($DebugOutput) {
            Add-Content `
                -LiteralPath $LogPath `
                -Value $line `
                -Encoding UTF8 `
                -ErrorAction SilentlyContinue
        }

        if ($DebugOutput -or $TestMode) {
            Write-Host $line
        }
    }
    catch {
        if ($DebugOutput -or $TestMode) {
            Write-Host "LOGGING ERROR: $($_.Exception.Message)"
        }
    }
}

# ---------------------------------------------------------------------------
# Windows Credential Manager
# ---------------------------------------------------------------------------

Add-Type @'
using System;
using System.Runtime.InteropServices;

public static class WindowsCredentialReader
{
    [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
    public struct CREDENTIAL
    {
        public int Flags;
        public int Type;
        public IntPtr TargetName;
        public IntPtr Comment;
        public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
        public int CredentialBlobSize;
        public IntPtr CredentialBlob;
        public int Persist;
        public int AttributeCount;
        public IntPtr Attributes;
        public IntPtr TargetAlias;
        public IntPtr UserName;
    }

    [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
    public static extern bool CredRead(
        string target,
        int type,
        int reservedFlag,
        out IntPtr credentialPtr);

    [DllImport("advapi32.dll", SetLastError = true)]
    public static extern bool CredFree(IntPtr credential);

    public static string ReadGenericCredential(string target)
    {
        IntPtr credentialPtr;

        if (!CredRead(target, 1, 0, out credentialPtr))
        {
            int error = Marshal.GetLastWin32Error();
            throw new System.ComponentModel.Win32Exception(error);
        }

        try
        {
            CREDENTIAL credential =
                Marshal.PtrToStructure<CREDENTIAL>(credentialPtr);

            if (credential.CredentialBlob == IntPtr.Zero ||
                credential.CredentialBlobSize <= 0)
            {
                throw new InvalidOperationException(
                    "Credential exists but contains no secret.");
            }

            byte[] data = new byte[credential.CredentialBlobSize];

            Marshal.Copy(
                credential.CredentialBlob,
                data,
                0,
                credential.CredentialBlobSize);

            return System.Text.Encoding.Unicode.GetString(data).TrimEnd('\0');
        }
        finally
        {
            CredFree(credentialPtr);
        }
    }
}
'@

# ---------------------------------------------------------------------------
# Read Duplicati ServerUtil password
# ---------------------------------------------------------------------------

function Get-DuplicatiServerPassword {
    Write-Log "Reading Duplicati ServerUtil password from Windows Credential Manager."

    $password = [WindowsCredentialReader]::ReadGenericCredential($CredentialTarget)

    if ([string]::IsNullOrWhiteSpace($password)) {
        throw "Windows Credential Manager returned an empty Duplicati ServerUtil password."
    }

    Write-Log "Duplicati ServerUtil credential was successfully read."

    return $password
}

# ---------------------------------------------------------------------------
# Duplicati API login
# ---------------------------------------------------------------------------

function Get-DuplicatiAuthCookie {
    param(
        [Parameter(Mandatory)]
        [string]$Password
    )

    Write-Log "Authenticating against Duplicati server $DuplicatiServerUrl."

    $loginUri = "$DuplicatiServerUrl/api/v1/auth/login"

    $body = @{
        Password = $Password
    } | ConvertTo-Json -Compress

    $session = New-Object Microsoft.PowerShell.Commands.WebRequestSession

    $response = Invoke-WebRequest `
        -Uri $loginUri `
        -Method Post `
        -ContentType 'application/json' `
        -Body $body `
        -WebSession $session `
        -UseBasicParsing

    if ($response.StatusCode -lt 200 -or $response.StatusCode -ge 300) {
        throw "Duplicati API login failed with HTTP status $($response.StatusCode)."
    }

    $loginResult = $response.Content | ConvertFrom-Json
    $accessToken = $null

    if ($null -ne $loginResult.AccessToken) {
        $accessToken = [string]$loginResult.AccessToken
    }
    elseif ($null -ne $loginResult.access_token) {
        $accessToken = [string]$loginResult.access_token
    }
    elseif ($null -ne $loginResult.Token) {
        $accessToken = [string]$loginResult.Token
    }
    elseif ($null -ne $loginResult.token) {
        $accessToken = [string]$loginResult.token
    }

    if ([string]::IsNullOrWhiteSpace($accessToken)) {
        throw "Duplicati API login succeeded, but the response contained no access token."
    }

    $session.Headers['Authorization'] = "Bearer $accessToken"

    Write-Log "Duplicati API authentication succeeded and Bearer access token was installed in the WebRequestSession."

    return $session
}

# ---------------------------------------------------------------------------
# ServerUtil backup enumeration
# ---------------------------------------------------------------------------

function Get-ServerBackups {
    param(
        [Parameter(Mandatory)]
        [string]$Password
    )

    if (-not (Test-Path -LiteralPath $ServerUtilPath)) {
        throw "ServerUtil executable was not found: $ServerUtilPath"
    }

    $arguments = @(
        'list-backups'
        '--json'
        '--password=$duplicati-serverutil-password'
        '--secret-provider=wincred://'
    )

    Write-Log "Querying Duplicati ServerUtil for configured backups." 'DEBUG'

    $output = & $ServerUtilPath @arguments 2>&1
    $exitCode = $LASTEXITCODE

    if ($exitCode -ne 0) {
        throw "ServerUtil list-backups failed with exit code $exitCode. Output: $($output -join ' ')"
    }

    $jsonText = $output -join "`n"

    try {
        $result = $jsonText | ConvertFrom-Json
    }
    catch {
        throw "ServerUtil list-backups returned invalid JSON: $jsonText"
    }

    if ($null -eq $result.Backups) {
        throw "ServerUtil list-backups JSON did not contain a Backups property."
    }

    return @($result.Backups)
}

# ---------------------------------------------------------------------------
# Exact backup lookup
# ---------------------------------------------------------------------------

function Get-BackupByName {
    param(
        [Parameter(Mandatory)]
        [string]$Password,

        [Parameter(Mandatory)]
        [string]$Name
    )

    Write-Log "Looking up exact backup name '$Name' via ServerUtil."

    $backups = @(Get-ServerBackups -Password $Password)

    $matches = @(
        $backups | Where-Object {
            [string]$_.Name -ceq $Name
        }
    )

    if ($matches.Count -eq 0) {
        $availableNames = ($backups | ForEach-Object { [string]$_.Name }) -join ', '
        throw "No backup with exact name '$Name' was found. Available backups: $availableNames"
    }

    if ($matches.Count -gt 1) {
        throw "More than one backup has the exact name '$Name'."
    }

    $backup = $matches[0]

    if ($null -eq $backup.Id) {
        throw "ServerUtil returned backup '$Name' without an Id."
    }

    Write-Log "Backup resolved: Name='$($backup.Name)', BackupID='$($backup.Id)'."

    return [pscustomobject]@{
        ID   = [string]$backup.Id
        Name = [string]$backup.Name
    }
}

# ---------------------------------------------------------------------------
# ServerUtil status
# ---------------------------------------------------------------------------

function Get-ServerStatus {
    param(
        [Parameter(Mandatory)]
        [string]$Password
    )

    if (-not (Test-Path -LiteralPath $ServerUtilPath)) {
        throw "ServerUtil executable was not found: $ServerUtilPath"
    }

    $arguments = @(
        'status'
        '--json'
        '--password=$duplicati-serverutil-password'
        '--secret-provider=wincred://'
    )

    Write-Log "Querying Duplicati ServerUtil status." 'DEBUG'

    $output = & $ServerUtilPath @arguments 2>&1

    $exitCode = $LASTEXITCODE

    if ($exitCode -ne 0) {
        throw "ServerUtil status failed with exit code $exitCode. Output: $($output -join ' ')"
    }

    $jsonText = $output -join "`n"

    try {
        $status = $jsonText | ConvertFrom-Json
    }
    catch {
        throw "ServerUtil returned invalid JSON: $jsonText"
    }

    if (-not $status.Success) {
        throw "ServerUtil status returned Success=false."
    }

    return $status
}

# ---------------------------------------------------------------------------
# Find exact active task for BackupID
# ---------------------------------------------------------------------------

function Find-ExactActiveTask {
    param(
        [Parameter(Mandatory)]
        [string]$BackupId,

        [Parameter(Mandatory)]
        [string]$Password
    )

    $status = Get-ServerStatus -Password $Password

    if ($null -eq $status.ActiveTask) {
        return $null
    }

    $activeTaskProperties = @($status.ActiveTask.PSObject.Properties.Name)

    if ('BackupId' -notin $activeTaskProperties -or 'Task' -notin $activeTaskProperties) {
        Write-Log "ServerUtil reported an ActiveTask object without BackupId and/or Task. No exact active backup task can be confirmed; treating it as no matching active task." 'DEBUG'
        return $null
    }

    $activeBackupId = [string]$status.ActiveTask.BackupId
    $taskId = [int]$status.ActiveTask.Task

    if ([string]::IsNullOrWhiteSpace($activeBackupId)) {
        Write-Log "ServerUtil reported an ActiveTask without a usable BackupId. No exact active backup task can be confirmed; treating it as no matching active task." 'DEBUG'
        return $null
    }

    if ($activeBackupId -ne $BackupId) {
        Write-Log "Active task TaskID=$taskId belongs to BackupID=$activeBackupId, not BackupID=$BackupId." 'DEBUG'
        return $null
    }

    Write-Log "Exact active task found: BackupID=$BackupId -> TaskID=$taskId."

    return [pscustomobject]@{
        TaskId   = $taskId
        BackupId = $activeBackupId
    }
}

# ---------------------------------------------------------------------------
# Get exact REST task
# ---------------------------------------------------------------------------

function Get-ExactTask {
    param(
        [Parameter(Mandatory)]
        $Session,

        [Parameter(Mandatory)]
        [int]$TaskId
    )

    $uri = "$DuplicatiServerUrl/api/v1/tasks"

    $tasks = Invoke-RestMethod `
        -Uri $uri `
        -Method Get `
        -WebSession $Session

    $task = @(
        $tasks | Where-Object {
            [int]$_.ID -eq $TaskId
        }
    )

    if ($task.Count -eq 0) {
        return $null
    }

    return $task[0]
}

# ---------------------------------------------------------------------------
# Exact safety verification
# ---------------------------------------------------------------------------

function Confirm-ExactTask {
    param(
        [Parameter(Mandatory)]
        [string]$BackupId,

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

        [Parameter(Mandatory)]
        [string]$Password
    )

    $status = Get-ServerStatus -Password $Password

    if ($null -eq $status.ActiveTask) {
        Write-Log "Safety verification failed: no active task exists." 'WARN'
        return $false
    }

    $activeTaskProperties = @($status.ActiveTask.PSObject.Properties.Name)

    if ('BackupId' -notin $activeTaskProperties -or 'Task' -notin $activeTaskProperties) {
        Write-Log "Safety verification failed: ServerUtil ActiveTask has no usable BackupId and/or Task property." 'WARN'
        return $false
    }

    $actualTaskId = [int]$status.ActiveTask.Task
    $actualBackupId = [string]$status.ActiveTask.BackupId

    if ([string]::IsNullOrWhiteSpace($actualBackupId)) {
        Write-Log "Safety verification failed: ServerUtil ActiveTask has no usable BackupId." 'WARN'
        return $false
    }

    if ($actualTaskId -ne $TaskId) {
        Write-Log "Safety verification failed: expected TaskID=$TaskId, actual TaskID=$actualTaskId." 'WARN'
        return $false
    }

    if ($actualBackupId -ne $BackupId) {
        Write-Log "Safety verification failed: expected BackupID=$BackupId, actual BackupID=$actualBackupId." 'WARN'
        return $false
    }

    Write-Log "Safety verification succeeded: BackupID=$BackupId -> TaskID=$TaskId."

    return $true
}

# ---------------------------------------------------------------------------
# STOP exact task
# ---------------------------------------------------------------------------

function Stop-ExactTask {
    param(
        [Parameter(Mandatory)]
        $Session,

        [Parameter(Mandatory)]
        [int]$TaskId
    )

    $uri = "$DuplicatiServerUrl/api/v1/task/$TaskId/stop"

    Write-Log "Sending STOP request for exact TaskID=$TaskId."

    try {
        Invoke-RestMethod `
            -Uri $uri `
            -Method Post `
            -WebSession $Session | Out-Null

        Write-Log "STOP request sent for TaskID=$TaskId."

        return $true
    }
    catch {
        Write-Log "STOP request failed for TaskID=${TaskId}: $($_.Exception.Message)" 'ERROR'

        return $false
    }
}

# ---------------------------------------------------------------------------
# ABORT exact task
# ---------------------------------------------------------------------------

function Abort-ExactTask {
    param(
        [Parameter(Mandatory)]
        $Session,

        [Parameter(Mandatory)]
        [int]$TaskId
    )

    $uri = "$DuplicatiServerUrl/api/v1/task/$TaskId/abort"

    Write-Log "Sending ABORT request for exact TaskID=$TaskId."

    try {
        Invoke-RestMethod `
            -Uri $uri `
            -Method Post `
            -WebSession $Session | Out-Null

        Write-Log "ABORT request sent for TaskID=$TaskId."

        return $true
    }
    catch {
        Write-Log "ABORT request failed for TaskID=${TaskId}: $($_.Exception.Message)" 'ERROR'

        return $false
    }
}

# ---------------------------------------------------------------------------
# Duplicati GUI / TrayIcon process
# ---------------------------------------------------------------------------

function Get-DuplicatiTrayIconProcesses {
    $processes = @()

    foreach ($process in @(Get-Process -Name 'Duplicati.GUI.TrayIcon' -ErrorAction SilentlyContinue)) {
        try {
            $path = $process.MainModule.FileName

            if ($path -ieq $DuplicatiTrayIconExe) {
                $processes += $process
            }
        }
        catch {
            Write-Log "Could not verify executable path for Duplicati.GUI.TrayIcon PID=$($process.Id): $($_.Exception.Message)" 'WARN'
        }
    }

    return @($processes)
}

function Get-DuplicatiTrayIconProcessById {
    param(
        [Parameter(Mandatory)]
        [int]$ProcessId
    )

    foreach ($process in @(Get-DuplicatiTrayIconProcesses)) {
        if ($process.Id -eq $ProcessId) {
            return $process
        }
    }

    return $null
}

function Test-DuplicatiTrayIconProcessAlive {
    param(
        [Parameter(Mandatory)]
        [int]$ProcessId
    )

    return $null -ne (Get-DuplicatiTrayIconProcessById -ProcessId $ProcessId)
}

function Get-InitialDuplicatiTrayIconProcessId {
    $processes = @(Get-DuplicatiTrayIconProcesses)

    if ($processes.Count -eq 0) {
        Write-Log "No exact Duplicati.GUI.TrayIcon.exe process was found when watchdog monitoring began." 'WARN'
        return $null
    }

    if ($processes.Count -gt 1) {
        Write-Log "Multiple exact Duplicati.GUI.TrayIcon.exe processes were found. Monitoring the first PID=$($processes[0].Id) as the original host process." 'WARN'
    }

    $processId = [int]$processes[0].Id
    Write-Log "Original Duplicati.GUI.TrayIcon.exe host PID=$processId recorded for crash detection."
    return $processId
}

function Stop-DuplicatiTrayIconProcess {
    $processes = @(Get-DuplicatiTrayIconProcesses)

    if ($processes.Count -eq 0) {
        Write-Log "No matching Duplicati.GUI.TrayIcon.exe process is currently running." 'WARN'
        return $false
    }

    $allStopped = $true

    foreach ($process in $processes) {
        Write-Log "Terminating exact Duplicati.GUI.TrayIcon.exe PID=$($process.Id)." 'WARN'

        try {
            Stop-Process -Id $process.Id -Force -ErrorAction Stop
        }
        catch {
            $allStopped = $false
            Write-Log "Failed to terminate Duplicati.GUI.TrayIcon.exe PID=$($process.Id): $($_.Exception.Message)" 'ERROR'
            continue
        }

        try {
            Wait-Process -Id $process.Id -Timeout $StopWaitSeconds -ErrorAction SilentlyContinue
        }
        catch {
            # The process has normally already exited; this is intentionally ignored.
        }

        $stillRunning = @(Get-DuplicatiTrayIconProcesses | Where-Object { $_.Id -eq $process.Id })

        if ($stillRunning.Count -gt 0) {
            $allStopped = $false
            Write-Log "Duplicati.GUI.TrayIcon.exe PID=$($process.Id) could not be confirmed as terminated." 'ERROR'
        }
        else {
            Write-Log "Duplicati.GUI.TrayIcon.exe PID=$($process.Id) terminated and termination was confirmed."
        }
    }

    return $allStopped
}

function Test-DuplicatiServerPort {
    $client = [System.Net.Sockets.TcpClient]::new()

    try {
        $async = $client.BeginConnect('127.0.0.1', 8200, $null, $null)

        if (-not $async.AsyncWaitHandle.WaitOne(1000)) {
            return $false
        }

        $client.EndConnect($async)
        return $true
    }
    catch {
        return $false
    }
    finally {
        $client.Dispose()
    }
}

function Wait-ForDuplicatiServer {
    param(
        [Parameter(Mandatory)]
        [int]$TimeoutSeconds
    )

    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)

    while ((Get-Date) -lt $deadline) {
        if (Test-DuplicatiServerPort) {
            Write-Log "Duplicati server is reachable on 127.0.0.1:8200."
            return $true
        }

        Write-Log "Waiting for Duplicati server on 127.0.0.1:8200." 'DEBUG'

        $remaining = [int][Math]::Ceiling(($deadline - (Get-Date)).TotalSeconds)

        if ($remaining -le 0) {
            break
        }

        Start-Sleep -Seconds ([Math]::Min($PollIntervalSeconds, $remaining))
    }

    if (Test-DuplicatiServerPort) {
        Write-Log "Duplicati server is reachable on 127.0.0.1:8200."
        return $true
    }

    Write-Log "Duplicati server did not become reachable on 127.0.0.1:8200 within $TimeoutSeconds second(s)." 'WARN'
    return $false
}

function Start-DuplicatiTrayIcon {
    if (-not (Test-Path -LiteralPath $DuplicatiTrayIconExe)) {
        throw "Duplicati.GUI.TrayIcon.exe was not found: $DuplicatiTrayIconExe"
    }

    $existing = @(Get-DuplicatiTrayIconProcesses)

    if ($existing.Count -gt 0) {
        Write-Log "Duplicati.GUI.TrayIcon.exe is already running. A second instance will NOT be started."

        if (Test-DuplicatiServerPort) {
            Write-Log "Existing Duplicati.GUI.TrayIcon.exe has a reachable server on 127.0.0.1:8200."
            return $true
        }

        Write-Log "Existing Duplicati.GUI.TrayIcon.exe is running, but port 8200 is not reachable." 'WARN'
        return $false
    }

    Write-Log "Starting Duplicati.GUI.TrayIcon.exe."

    $workingDirectory = Split-Path -Parent $DuplicatiTrayIconExe

    try {
        $process = Start-Process `
            -FilePath $DuplicatiTrayIconExe `
            -WorkingDirectory $workingDirectory `
            -PassThru `
            -ErrorAction Stop

        Write-Log "Duplicati.GUI.TrayIcon.exe start command succeeded with PID=$($process.Id)."
    }
    catch {
        Write-Log "Starting Duplicati.GUI.TrayIcon.exe failed: $($_.Exception.Message)" 'ERROR'
        return $false
    }

    if (Wait-ForDuplicatiServer -TimeoutSeconds $ServerStartupWaitSeconds) {
        Write-Log "Duplicati server startup completed successfully."
        return $true
    }

    Write-Log "Duplicati.GUI.TrayIcon.exe did not make port 8200 reachable within $ServerStartupWaitSeconds second(s)." 'ERROR'

    $failedProcess = @(Get-DuplicatiTrayIconProcesses | Where-Object { $_.Id -eq $process.Id })

    if ($failedProcess.Count -gt 0) {
        Write-Log "Stopping failed TrayIcon startup attempt PID=$($process.Id) before retry." 'WARN'

        try {
            Stop-Process -Id $process.Id -Force -ErrorAction Stop
        }
        catch {
            Write-Log "Could not stop failed TrayIcon startup attempt PID=$($process.Id): $($_.Exception.Message)" 'ERROR'
            return $false
        }

        Start-Sleep -Seconds 2
    }

    return $false
}

function Invoke-DuplicatiRestart {
    Write-Log "Beginning Duplicati.GUI.TrayIcon.exe restart handling." 'WARN'

    $existing = @(Get-DuplicatiTrayIconProcesses)

    if ($existing.Count -gt 0) {
        Write-Log "A Duplicati.GUI.TrayIcon.exe process already exists during restart handling."

        if (Test-DuplicatiServerPort) {
            Write-Log "Existing Duplicati server is already reachable on 127.0.0.1:8200. No restart is required."
            return $true
        }

        Write-Log "Existing TrayIcon process is not serving port 8200. It will be terminated before restart." 'WARN'

        if (-not (Stop-DuplicatiTrayIconProcess)) {
            Write-Log "Restart aborted because the existing Duplicati.GUI.TrayIcon.exe process could not be confirmed as terminated." 'ERROR'
            return $false
        }
    }

    Write-Log "Waiting $ServerRestartWaitSeconds second(s) before starting Duplicati.GUI.TrayIcon.exe."
    Start-Sleep -Seconds $ServerRestartWaitSeconds

    for ($attempt = 1; $attempt -le $ServerStartAttempts; $attempt++) {
        Write-Log "Duplicati.GUI.TrayIcon.exe startup attempt $attempt of $ServerStartAttempts."

        if (Start-DuplicatiTrayIcon) {
            Write-Log "Duplicati.GUI.TrayIcon.exe restart succeeded on attempt $attempt."
            return $true
        }

        if ($attempt -lt $ServerStartAttempts) {
            Write-Log "Startup attempt $attempt failed. Retrying." 'WARN'
        }
    }

    Write-Log "Duplicati.GUI.TrayIcon.exe could not be restarted successfully after $ServerStartAttempts attempt(s)." 'ERROR'
    return $false
}

# ---------------------------------------------------------------------------
# Start independent watchdog child
# ---------------------------------------------------------------------------

function Start-WatchdogChild {
    param(
        [Parameter(Mandatory)]
        [string]$ChildBackupName
    )

    Write-Log "Parent is preparing to start watchdog child for backup '$ChildBackupName'."

    $pwshPath = Join-Path $PSHOME 'pwsh.exe'

    if (-not (Test-Path -LiteralPath $pwshPath)) {
        throw "PowerShell 7 executable was not found: $pwshPath"
    }

    $psi = [System.Diagnostics.ProcessStartInfo]::new()

    $psi.FileName = $pwshPath
    $psi.UseShellExecute = $false
    $psi.CreateNoWindow = $true
    $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden

    [void]$psi.ArgumentList.Add('-NoProfile')
    [void]$psi.ArgumentList.Add('-NonInteractive')
    [void]$psi.ArgumentList.Add('-ExecutionPolicy')
    [void]$psi.ArgumentList.Add('Bypass')
    [void]$psi.ArgumentList.Add('-File')
    [void]$psi.ArgumentList.Add($PSCommandPath)
    [void]$psi.ArgumentList.Add('-WatchdogChild')

    $psi.Environment['DUPLICATI__backup_name'] = $ChildBackupName

    $process = [System.Diagnostics.Process]::new()
    $process.StartInfo = $psi

    if (-not $process.Start()) {
        throw "PowerShell child process could not be started."
    }

    Write-Log "WATCHDOG CHILD STARTED successfully. ChildPID=$($process.Id), BackupName='$ChildBackupName'."

    return $process.Id
}

# ---------------------------------------------------------------------------
# Test
# ---------------------------------------------------------------------------

function Invoke-Test {
    Write-Log "TEST MODE started."

    if (-not (Test-Path -LiteralPath $ServerUtilPath)) {
        throw "ServerUtil executable was not found: $ServerUtilPath"
    }

    Write-Log "ServerUtil found: $ServerUtilPath"

    if (-not (Test-Path -LiteralPath $DuplicatiTrayIconExe)) {
        throw "Duplicati.GUI.TrayIcon.exe was not found: $DuplicatiTrayIconExe"
    }

    Write-Log "Duplicati.GUI.TrayIcon.exe found: $DuplicatiTrayIconExe"

    $password = Get-DuplicatiServerPassword

    $session = Get-DuplicatiAuthCookie -Password $password

    $uri = "$DuplicatiServerUrl/api/v1/backups"

    $backups = @(Get-ServerBackups -Password $password)

    Write-Log "Available backups: $($backups.Count)"

    foreach ($backup in $backups) {
        Write-Log "Backup: ID=$($backup.Id), Name='$($backup.Name)'." 'DEBUG'
    }

    if ([string]::IsNullOrWhiteSpace($BackupName)) {
        Write-Log "No -BackupName supplied. Backup enumeration test completed."
        Write-Log "TEST PASSED - Credential Manager, API login and ServerUtil backup enumeration work."

        return
    }

    $backup = Get-BackupByName `
        -Password $password `
        -Name $BackupName

    $backupId = [string]$backup.ID

    Write-Log "Testing exact task resolution for BackupName='$BackupName', BackupID=$backupId."

    $task = Find-ExactActiveTask `
        -BackupId $backupId `
        -Password $password

    if ($null -eq $task) {
        Write-Log "No active task currently belongs to BackupID=$backupId."
        Write-Log "TEST PASSED - exact backup lookup works. No matching active task exists right now."

        return
    }

    $restTask = Get-ExactTask `
        -Session $session `
        -TaskId $task.TaskId

    if ($null -eq $restTask) {
        throw "ServerUtil reported TaskID=$($task.TaskId), but REST /api/v1/tasks did not return that task."
    }

    Write-Log "Exact task resolved: BackupName='$BackupName', BackupID=$backupId, TaskID=$($task.TaskId), REST Status='$($restTask.Status)'."

    Write-Log "TEST PASSED - exact BackupID -> TaskID resolution works."
    Write-Log "TEST NOTE - this does NOT execute STOP, ABORT, process termination or server restart."
}

# ---------------------------------------------------------------------------
# Watchdog
# ---------------------------------------------------------------------------

function Invoke-Watchdog {
    param(
        [Parameter(Mandatory)]
        [string]$CurrentBackupName
    )

    Write-Log "WATCHDOG CHILD INITIALIZED for BackupName='$CurrentBackupName'."

    $password = Get-DuplicatiServerPassword

    $session = Get-DuplicatiAuthCookie `
        -Password $password

    $backup = Get-BackupByName `
        -Password $password `
        -Name $CurrentBackupName

    $backupId = [string]$backup.ID

    Write-Log "Watchdog target established: BackupName='$CurrentBackupName', BackupID=$backupId."

    $hostProcessId = Get-InitialDuplicatiTrayIconProcessId

    $deadline = (Get-Date).AddSeconds($InitialDiscoverySeconds)

    $targetTask = $null

    while ((Get-Date) -lt $deadline) {
        if ($null -ne $hostProcessId -and -not (Test-DuplicatiTrayIconProcessAlive -ProcessId $hostProcessId)) {
            Write-Log "Original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId disappeared during task discovery. Duplicati is treated as crashed." 'ERROR'

            if (Invoke-DuplicatiRestart) {
                Write-Log "Duplicati restart after process loss completed. Watchdog exits."
            }
            else {
                Write-Log "Duplicati restart after process loss failed." 'ERROR'
            }

            return
        }

        $targetTask = Find-ExactActiveTask `
            -BackupId $backupId `
            -Password $password

        if ($null -ne $targetTask) {
            break
        }

        Write-Log "Waiting for exact active task for BackupID=$backupId." 'DEBUG'

        Start-Sleep -Seconds $PollIntervalSeconds
    }

    if ($null -eq $targetTask) {
        if ($null -ne $hostProcessId -and -not (Test-DuplicatiTrayIconProcessAlive -ProcessId $hostProcessId)) {
            Write-Log "No task was discovered because original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId disappeared. Entering restart handling." 'ERROR'

            if (Invoke-DuplicatiRestart) {
                Write-Log "Duplicati restart after process loss completed. Watchdog exits."
            }
            else {
                Write-Log "Duplicati restart after process loss failed." 'ERROR'
            }

            return
        }

        Write-Log "No active task for BackupID=$backupId was discovered within ${InitialDiscoverySeconds}s. Watchdog exits."
        return
    }

    $taskId = [int]$targetTask.TaskId

    Write-Log "Monitoring exact TaskID=$taskId for BackupID=$backupId."

    $watchStart = Get-Date
    $timeout = [TimeSpan]::FromMinutes($TimeoutMinutes)

    while ($true) {
        if ($null -ne $hostProcessId -and -not (Test-DuplicatiTrayIconProcessAlive -ProcessId $hostProcessId)) {
            Write-Log "Original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId disappeared while TaskID=$taskId was being monitored. Duplicati is treated as crashed." 'ERROR'

            if (Invoke-DuplicatiRestart) {
                Write-Log "Duplicati restart after process loss completed. Watchdog exits."
            }
            else {
                Write-Log "Duplicati restart after process loss failed." 'ERROR'
            }

            return
        }

        $elapsed = (Get-Date) - $watchStart

        $currentTask = Get-ExactTask `
            -Session $session `
            -TaskId $taskId

        if ($null -eq $currentTask) {
            if ($null -ne $hostProcessId -and (Test-DuplicatiTrayIconProcessAlive -ProcessId $hostProcessId)) {
                Write-Log "TaskID=$taskId no longer exists while original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId is still alive. Watchdog exits normally."
                return
            }

            Write-Log "TaskID=$taskId disappeared together with the original Duplicati.GUI.TrayIcon.exe process. Entering restart handling." 'ERROR'

            if (Invoke-DuplicatiRestart) {
                Write-Log "Duplicati restart after task/process loss completed. Watchdog exits."
            }
            else {
                Write-Log "Duplicati restart after task/process loss failed." 'ERROR'
            }

            return
        }

        $status = [string]$currentTask.Status

        Write-Log "TaskID=$taskId status='$status', elapsed=$([math]::Round($elapsed.TotalSeconds,1))s." 'DEBUG'

        if ($status -notin @(
                'Running',
                'Starting',
                'Processing',
                'Stopping',
                'Aborting'
            )) {

            if ($null -ne $hostProcessId -and (Test-DuplicatiTrayIconProcessAlive -ProcessId $hostProcessId)) {
                Write-Log "TaskID=$taskId is no longer actively running. Final status='$status'. Duplicati host PID=$hostProcessId is still alive. Watchdog exits normally."
                return
            }

            Write-Log "TaskID=$taskId is no longer actively running, but original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId is gone. Entering restart handling." 'ERROR'

            if (Invoke-DuplicatiRestart) {
                Write-Log "Duplicati restart after task/process loss completed. Watchdog exits."
            }
            else {
                Write-Log "Duplicati restart after task/process loss failed." 'ERROR'
            }

            return
        }

        if ($elapsed -ge $timeout) {
            break
        }

        Start-Sleep -Seconds $PollIntervalSeconds
    }

    Write-Log "TIMEOUT reached for exact TaskID=$taskId, BackupID=$backupId after $TimeoutMinutes minute(s)." 'WARN'

    if ($null -ne $hostProcessId -and -not (Test-DuplicatiTrayIconProcessAlive -ProcessId $hostProcessId)) {
        Write-Log "Original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId disappeared at timeout. Skipping STOP/ABORT and entering restart handling." 'ERROR'

        if (Invoke-DuplicatiRestart) {
            Write-Log "Duplicati restart after process loss completed. Watchdog exits."
        }
        else {
            Write-Log "Duplicati restart after process loss failed." 'ERROR'
        }

        return
    }

    if (-not (Confirm-ExactTask `
        -BackupId $backupId `
        -TaskId $taskId `
        -Password $password)) {

        Write-Log "STOP was NOT sent because exact task safety verification failed." 'ERROR'
        return
    }

    [void](Stop-ExactTask `
        -Session $session `
        -TaskId $taskId)

    Write-Log "Waiting $StopWaitSeconds second(s) after STOP."

    Start-Sleep -Seconds $StopWaitSeconds

    $hostStillAliveAfterStop = $null -ne $hostProcessId -and (Test-DuplicatiTrayIconProcessAlive -ProcessId $hostProcessId)
    $serverReachableAfterStop = Test-DuplicatiServerPort

    if ($serverReachableAfterStop) {
        Write-Log "Server health check after STOP: 127.0.0.1:8200 reachable."
    }
    else {
        Write-Log "Server health check after STOP: 127.0.0.1:8200 NOT reachable." 'ERROR'
    }

    $afterStop = Find-ExactActiveTask `
        -BackupId $backupId `
        -Password $password

    if ($null -eq $afterStop -or [int]$afterStop.TaskId -ne $taskId) {
        if ($hostStillAliveAfterStop -and $serverReachableAfterStop) {
            Write-Log "Exact TaskID=$taskId is no longer active after STOP, while original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId is still alive and port 8200 is reachable. STOP stage ended the target task; watchdog exits."
            return
        }

        if ($hostStillAliveAfterStop -and -not $serverReachableAfterStop) {
            Write-Log "Exact TaskID=$taskId is no longer active after STOP, but original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId is still alive while port 8200 is NOT reachable. Entering restart handling." 'ERROR'
        }
        else {
            Write-Log "Exact TaskID=$taskId is no longer active after STOP, but original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId disappeared. Entering restart handling." 'ERROR'
        }

        if (Invoke-DuplicatiRestart) {
            Write-Log "Duplicati restart after STOP/process loss completed. Watchdog exits."
        }
        else {
            Write-Log "Duplicati restart after STOP/process loss failed." 'ERROR'
        }

        return
    }

    if (-not $hostStillAliveAfterStop -or -not $serverReachableAfterStop) {
        if (-not $hostStillAliveAfterStop) {
            Write-Log "Exact TaskID=$taskId is still reported after STOP, but original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId disappeared. Entering restart handling." 'ERROR'
        }
        else {
            Write-Log "Exact TaskID=$taskId is still active after STOP, original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId is alive, but port 8200 is NOT reachable. Entering restart handling." 'ERROR'
        }

        if (Invoke-DuplicatiRestart) {
            Write-Log "Duplicati restart after STOP/process loss completed. Watchdog exits."
        }
        else {
            Write-Log "Duplicati restart after STOP/process loss failed." 'ERROR'
        }

        return
    }

    Write-Log "Exact TaskID=$taskId is still active after STOP." 'WARN'

    if (-not (Confirm-ExactTask `
        -BackupId $backupId `
        -TaskId $taskId `
        -Password $password)) {

        Write-Log "ABORT was NOT sent because exact task safety verification failed." 'ERROR'
        return
    }

    [void](Abort-ExactTask `
        -Session $session `
        -TaskId $taskId)

    Write-Log "Waiting $AbortWaitSeconds second(s) after ABORT."

    Start-Sleep -Seconds $AbortWaitSeconds

    $hostStillAliveAfterAbort = $null -ne $hostProcessId -and (Test-DuplicatiTrayIconProcessAlive -ProcessId $hostProcessId)
    $serverReachableAfterAbort = Test-DuplicatiServerPort

    if ($serverReachableAfterAbort) {
        Write-Log "Server health check after ABORT: 127.0.0.1:8200 reachable."
    }
    else {
        Write-Log "Server health check after ABORT: 127.0.0.1:8200 NOT reachable." 'ERROR'
    }

    $afterAbort = Find-ExactActiveTask `
        -BackupId $backupId `
        -Password $password

    if ($null -eq $afterAbort -or [int]$afterAbort.TaskId -ne $taskId) {
        if ($hostStillAliveAfterAbort -and $serverReachableAfterAbort) {
            Write-Log "Exact TaskID=$taskId is no longer active after ABORT, while original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId is still alive and port 8200 is reachable. ABORT stage ended the target task; watchdog exits."
            return
        }

        if ($hostStillAliveAfterAbort -and -not $serverReachableAfterAbort) {
            Write-Log "Exact TaskID=$taskId is no longer active after ABORT, but original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId is still alive while port 8200 is NOT reachable. Entering restart handling." 'ERROR'
        }
        else {
            Write-Log "Exact TaskID=$taskId is no longer active after ABORT, but original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId disappeared. Entering restart handling." 'ERROR'
        }

        if (Invoke-DuplicatiRestart) {
            Write-Log "Duplicati restart after ABORT/process loss completed. Watchdog exits."
        }
        else {
            Write-Log "Duplicati restart after ABORT/process loss failed." 'ERROR'
        }

        return
    }

    if (-not $hostStillAliveAfterAbort -or -not $serverReachableAfterAbort) {
        if (-not $hostStillAliveAfterAbort) {
            Write-Log "Exact TaskID=$taskId is still reported after ABORT, but original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId disappeared. Entering restart handling." 'ERROR'
        }
        else {
            Write-Log "Exact TaskID=$taskId is still active after ABORT, original Duplicati.GUI.TrayIcon.exe PID=$hostProcessId is alive, but port 8200 is NOT reachable. Entering restart handling." 'ERROR'
        }

        if (Invoke-DuplicatiRestart) {
            Write-Log "Duplicati restart after ABORT/process loss completed. Watchdog exits."
        }
        else {
            Write-Log "Duplicati restart after ABORT/process loss failed." 'ERROR'
        }

        return
    }

    Write-Log "Exact TaskID=$taskId is STILL active after ABORT." 'ERROR'

    if (-not (Confirm-ExactTask `
        -BackupId $backupId `
        -TaskId $taskId `
        -Password $password)) {

        Write-Log "Duplicati.GUI.TrayIcon.exe termination was NOT performed because exact task safety verification failed." 'ERROR'
        return
    }

    $serverStopped = Stop-DuplicatiTrayIconProcess

    if (-not $serverStopped) {
        Write-Log "TrayIcon restart was NOT attempted because the exact Duplicati.GUI.TrayIcon.exe process could not be confirmed as terminated." 'ERROR'
        return
    }

    if (Invoke-DuplicatiRestart) {
        Write-Log "Watchdog escalation sequence completed for BackupID=$backupId, TaskID=$taskId."
    }
    else {
        Write-Log "Watchdog escalation reached restart stage, but Duplicati could not be restarted successfully." 'ERROR'
    }
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

$exitCode = 0

try {
    Write-Log "============================================================"
    Write-Log "DuplicatiWatchdog.ps1 STARTED."
    Write-Log "ScriptPath='$PSCommandPath'."
    Write-Log "ScriptRoot='$PSScriptRoot'."
    Write-Log "LogPath='$LogPath'."
    Write-Log "PowerShell='$($PSVersionTable.PSVersion)'."
    Write-Log "ProcessID=$PID."
    Write-Log "============================================================"

    # Test mode is handled before requiring DUPLICATI__backup_name.
    if ($TestMode) {
        Invoke-Test

        exit 0
    }

    # -----------------------------------------------------------------------
    # Child mode
    # -----------------------------------------------------------------------

    if ($WatchdogChild) {
        Write-Log "CHILD INVOCATION detected."

        $envBackupName = [Environment]::GetEnvironmentVariable(
            'DUPLICATI__backup_name'
        )

        if ([string]::IsNullOrWhiteSpace($envBackupName)) {
            throw "DUPLICATI__backup_name is not set in watchdog child process."
        }

        Write-Log "Child received DUPLICATI__backup_name='$envBackupName'."

        Invoke-Watchdog `
            -CurrentBackupName $envBackupName

        Write-Log "WATCHDOG CHILD FINISHED normally."

        exit 0
    }

    # -----------------------------------------------------------------------
    # Parent mode
    # -----------------------------------------------------------------------

    Write-Log "PARENT INVOCATION detected."

    $envBackupName = [Environment]::GetEnvironmentVariable(
        'DUPLICATI__backup_name'
    )

    if ([string]::IsNullOrWhiteSpace($envBackupName)) {
        Write-Log "DUPLICATI__backup_name is not set in parent process." 'ERROR'
        Write-Log "Parent exits with code 0 so the Duplicati backup is not aborted."

        exit 0
    }

    Write-Log "Parent received DUPLICATI__backup_name='$envBackupName'."

    $childPid = Start-WatchdogChild `
        -ChildBackupName $envBackupName

    Write-Log "Parent successfully launched watchdog child PID=$childPid."
    Write-Log "PARENT EXITING immediately with code 0."

    exit 0
}
catch {
    $message = $_.Exception.Message

    Write-Log "FATAL ERROR: $message" 'ERROR'

    if ($TestMode -or $WatchdogChild) {
        $exitCode = 1
    }
    else {
        # The parent must never make run-script-before fail merely because
        # the watchdog could not be started.
        $exitCode = 0
    }
}
finally {
    Write-Log "DuplicatiWatchdog.ps1 END. ExitCode=$exitCode."
}

exit $exitCode