Add offsite backup sync and scheduler

This commit is contained in:
Meta Vens 2026-04-24 16:38:17 +01:00
commit 69958db930
8 changed files with 630 additions and 4 deletions

View file

@ -50,3 +50,11 @@ INDEXED_CHANNEL_IDS=
EXCLUDED_CHANNEL_IDS=
EXCLUDED_CHANNEL_NAMES=
WORKER_POLL_INTERVAL_MS=5000
OFFSITE_BACKUP_SSH_TARGET=
OFFSITE_BACKUP_REMOTE_WINDOWS_DIR=C:\Meta\voidbot-backups
OFFSITE_BACKUP_REMOTE_SFTP_DIR=/C:/Meta/voidbot-backups
OFFSITE_BACKUP_LOCAL_KEEP_LATEST=7
OFFSITE_BACKUP_REMOTE_KEEP_LATEST=14
OFFSITE_BACKUP_LABEL=offsite-auto
OFFSITE_BACKUP_TASK_NAME=VoidBot Offsite Backup
OFFSITE_BACKUP_TASK_TIME=04:30

View file

@ -418,6 +418,41 @@ npm run state:restore -- -BackupPath E:\Projects\VoidBot\.voidbot\backups\<times
The backup and restore scripts stop the local bot/worker first and restart them afterward if they were already running. Because the local Qdrant node is shared, the backup path snapshots every collection on that node, not just the two VoidBot ones.
For the next rung up from "local recovery only", VoidBot can also push nightly backup archives to an SSH-reachable offsite box:
```bash
npm run state:sync-offsite
```
That flow:
- runs the normal local backup first
- compresses the resulting backup directory into a zip archive
- uploads it over `sftp`
- keeps a bounded number of scheduled local backup directories
- prunes older remote zip archives after upload
- writes status to `.voidbot/status/offsite-backup.json`
- appends logs to `.voidbot/logs/offsite-backup.log`
Install the daily scheduled task:
```bash
npm run state:install-offsite-task
```
Defaults come from `.env` / `.env.example`:
- `OFFSITE_BACKUP_SSH_TARGET`
- `OFFSITE_BACKUP_REMOTE_WINDOWS_DIR`
- `OFFSITE_BACKUP_REMOTE_SFTP_DIR`
- `OFFSITE_BACKUP_LOCAL_KEEP_LATEST`
- `OFFSITE_BACKUP_REMOTE_KEEP_LATEST`
- `OFFSITE_BACKUP_LABEL`
- `OFFSITE_BACKUP_TASK_NAME`
- `OFFSITE_BACKUP_TASK_TIME`
The scheduled task uses `Interactive` logon on Windows so it can see the current user's SSH key material. That means the backup job is intended for a workstation session that stays logged in; it is not pretending to be a hardened headless backup appliance.
## Commands
Current slash commands:
@ -462,7 +497,7 @@ Useful local state and docs:
Reasonable next steps from here:
1. Add health checks, backups, and operational guidance around Postgres and Qdrant.
2. Automate recurring and off-box retention for Postgres, Qdrant, and archive backups instead of relying on manual local runs.
2. Add periodic restore drills and remote retention monitoring so the offsite backup path stays real instead of ceremonial.
3. Expand worker processing into richer provider run records and moderation hooks.
4. Replace the remaining scaffolded provider paths with funded, production-grade implementations.
5. Add real constrained sandbox execution instead of policy-only dry runs.

View file

@ -42,7 +42,7 @@ Discord Gateway
## Upgrade path
1. Automate recurring and off-box retention for the new Postgres/Qdrant/manual-archive backup path.
1. Add restore drills and retention monitoring around the new local-plus-offsite backup path.
2. Add moderation, budgeting, and rate limits before enabling `openai_api` for member traffic.
3. Expand worker-side run records and admin tooling around the interaction memory/event stores.
4. Expand sandbox execution from dry-run policy checks to real constrained runners.

View file

@ -15,6 +15,8 @@
"stack:start": "powershell -ExecutionPolicy Bypass -File scripts/start-voidbot-stack.ps1",
"state:backup": "powershell -ExecutionPolicy Bypass -File scripts/backup-voidbot-state.ps1",
"state:restore": "powershell -ExecutionPolicy Bypass -File scripts/restore-voidbot-state.ps1",
"state:sync-offsite": "powershell -ExecutionPolicy Bypass -File scripts/sync-voidbot-backup-offsite.ps1",
"state:install-offsite-task": "powershell -ExecutionPolicy Bypass -File scripts/install-offsite-backup-task.ps1",
"postgres:up": "docker compose -f infra/postgres/docker-compose.yml up -d",
"codex:mcp:install": "tsx scripts/install-codex-mcp.ts",
"codex:mcp:remove": "tsx scripts/remove-codex-mcp.ts",

View file

@ -85,7 +85,7 @@ function Invoke-CurlJson {
[string[]] $Arguments
)
$response = & curl.exe @Arguments
$response = & curl.exe --connect-timeout 5 --max-time 30 @Arguments
if ($LASTEXITCODE -ne 0) {
throw "curl.exe failed: $($Arguments -join ' ')"

View file

@ -0,0 +1,86 @@
param(
[string] $TaskName,
[string] $TaskTime,
[switch] $RunNow
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $repoRoot ".env"
$syncScript = Join-Path $PSScriptRoot "sync-voidbot-backup-offsite.ps1"
function Read-DotEnv {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
if (-not (Test-Path $Path)) {
throw "Missing .env at $Path"
}
$values = @{}
foreach ($line in Get-Content -Path $Path) {
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
if ($line.TrimStart().StartsWith("#")) {
continue
}
$separatorIndex = $line.IndexOf("=")
if ($separatorIndex -lt 1) {
continue
}
$key = $line.Substring(0, $separatorIndex).Trim()
$value = $line.Substring($separatorIndex + 1)
$values[$key] = $value
}
return $values
}
$config = Read-DotEnv -Path $envFile
$taskNameValue = if (-not [string]::IsNullOrWhiteSpace($TaskName)) {
$TaskName
} elseif ($config.ContainsKey("OFFSITE_BACKUP_TASK_NAME") -and -not [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_TASK_NAME"])) {
$config["OFFSITE_BACKUP_TASK_NAME"]
} else {
"VoidBot Offsite Backup"
}
$taskTimeValue = if (-not [string]::IsNullOrWhiteSpace($TaskTime)) {
$TaskTime
} elseif ($config.ContainsKey("OFFSITE_BACKUP_TASK_TIME") -and -not [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_TASK_TIME"])) {
$config["OFFSITE_BACKUP_TASK_TIME"]
} else {
"04:30"
}
if (-not ($config.ContainsKey("OFFSITE_BACKUP_SSH_TARGET")) -or [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_SSH_TARGET"])) {
throw "OFFSITE_BACKUP_SSH_TARGET must be configured in .env before installing the scheduled task."
}
$runAt = [DateTime]::ParseExact($taskTimeValue, "HH:mm", [System.Globalization.CultureInfo]::InvariantCulture)
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$syncScript`""
$trigger = New-ScheduledTaskTrigger -Daily -At $runAt
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\$env:USERNAME" -LogonType Interactive -RunLevel Limited
$description = "Runs VoidBot offsite backup sync to the Qwen box. Interactive logon required so the SSH key material is available."
Register-ScheduledTask -TaskName $taskNameValue -Action $action -Trigger $trigger -Settings $settings -Principal $principal -Description $description -Force | Out-Null
Write-Host "Installed scheduled task: $taskNameValue"
Write-Host "Schedule: daily at $taskTimeValue"
Write-Host "Task runs as: $($env:USERDOMAIN)\$($env:USERNAME)"
Write-Host "Logon mode: Interactive"
if ($RunNow) {
Start-ScheduledTask -TaskName $taskNameValue
Write-Host "Started task immediately."
}

View file

@ -84,7 +84,7 @@ function Invoke-CurlJson {
[string[]] $Arguments
)
$response = & curl.exe @Arguments
$response = & curl.exe --connect-timeout 5 --max-time 30 @Arguments
if ($LASTEXITCODE -ne 0) {
throw "curl.exe failed: $($Arguments -join ' ')"

View file

@ -0,0 +1,495 @@
param(
[string] $SshTarget,
[string] $RemoteWindowsDir,
[string] $RemoteSftpDir,
[int] $LocalKeepLatest,
[int] $RemoteKeepLatest,
[string] $BackupLabel,
[switch] $NoRestart
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $repoRoot ".env"
$backupScript = Join-Path $PSScriptRoot "backup-voidbot-state.ps1"
function Read-DotEnv {
param(
[Parameter(Mandatory = $true)]
[string] $Path
)
if (-not (Test-Path $Path)) {
throw "Missing .env at $Path"
}
$values = @{}
foreach ($line in Get-Content -Path $Path) {
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
if ($line.TrimStart().StartsWith("#")) {
continue
}
$separatorIndex = $line.IndexOf("=")
if ($separatorIndex -lt 1) {
continue
}
$key = $line.Substring(0, $separatorIndex).Trim()
$value = $line.Substring($separatorIndex + 1)
$values[$key] = $value
}
return $values
}
function Resolve-ConfigPath {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[Parameter(Mandatory = $false)]
[AllowEmptyString()]
[string] $Value,
[Parameter(Mandatory = $true)]
[string] $Fallback
)
$raw = if ([string]::IsNullOrWhiteSpace($Value)) { $Fallback } else { $Value }
return [System.IO.Path]::GetFullPath((Join-Path $RepoRoot $raw))
}
function Write-StatusFile {
param(
[Parameter(Mandatory = $true)]
[string] $Path,
[Parameter(Mandatory = $true)]
[hashtable] $Status
)
$directory = Split-Path -Parent $Path
New-Item -ItemType Directory -Force -Path $directory | Out-Null
$Status | ConvertTo-Json -Depth 8 | Set-Content -Path $Path -Encoding utf8
}
function Start-LogCapture {
param(
[Parameter(Mandatory = $true)]
[string] $LogPath
)
$directory = Split-Path -Parent $LogPath
New-Item -ItemType Directory -Force -Path $directory | Out-Null
try {
Start-Transcript -Path $LogPath -Append | Out-Null
return $true
} catch {
Write-Warning "Failed to start transcript logging at ${LogPath}: $($_.Exception.Message)"
return $false
}
}
function Stop-LogCapture {
param(
[Parameter(Mandatory = $true)]
[bool] $Started
)
if (-not $Started) {
return
}
try {
Stop-Transcript | Out-Null
} catch {
}
}
function Convert-WindowsPathToSftpPath {
param(
[Parameter(Mandatory = $true)]
[string] $WindowsPath
)
if ($WindowsPath -notmatch "^[A-Za-z]:\\") {
throw "Remote Windows path must start with a drive letter: $WindowsPath"
}
$drive = $WindowsPath.Substring(0, 1).ToUpperInvariant()
$remainder = $WindowsPath.Substring(2).Replace("\", "/").TrimStart("/")
return "/$($drive):/$remainder"
}
function Get-LatestBackupDirectory {
param(
[Parameter(Mandatory = $true)]
[string] $BackupRoot,
[Parameter(Mandatory = $true)]
[string] $Label
)
$pattern = "*-$Label"
$latest = Get-ChildItem -Path $BackupRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like $pattern } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (-not $latest) {
throw "No backup directories matching $pattern were found under $BackupRoot."
}
return $latest
}
function Invoke-RemotePowerShell {
param(
[Parameter(Mandatory = $true)]
[string] $Target,
[Parameter(Mandatory = $true)]
[string] $Script
)
$bytes = [System.Text.Encoding]::Unicode.GetBytes($Script)
$encoded = [Convert]::ToBase64String($bytes)
$output = & ssh $Target powershell -NoProfile -NonInteractive -EncodedCommand $encoded
if ($LASTEXITCODE -ne 0) {
throw "Remote PowerShell command failed on $Target."
}
return ($output -join "`n").Trim()
}
function Ensure-RemoteDirectory {
param(
[Parameter(Mandatory = $true)]
[string] $Target,
[Parameter(Mandatory = $true)]
[string] $RemoteWindowsDir
)
$escapedDir = $RemoteWindowsDir.Replace("'", "''")
$script = @'
$ProgressPreference = 'SilentlyContinue'
New-Item -ItemType Directory -Force -Path '__REMOTE_DIR__' | Out-Null
'@.Replace("__REMOTE_DIR__", $escapedDir)
[void](Invoke-RemotePowerShell -Target $Target -Script $script)
}
function Get-RemoteFileInfo {
param(
[Parameter(Mandatory = $true)]
[string] $Target,
[Parameter(Mandatory = $true)]
[string] $RemoteWindowsPath
)
$escapedPath = $RemoteWindowsPath.Replace("'", "''")
$script = @'
$ProgressPreference = 'SilentlyContinue'
$item = Get-Item -LiteralPath '__REMOTE_PATH__'
[pscustomobject]@{
name = $item.Name
size = $item.Length
lastWriteTimeUtc = $item.LastWriteTimeUtc.ToString('o')
} | ConvertTo-Json -Compress
'@.Replace("__REMOTE_PATH__", $escapedPath)
$output = Invoke-RemotePowerShell -Target $Target -Script $script
return $output | ConvertFrom-Json
}
function Upload-WithSftp {
param(
[Parameter(Mandatory = $true)]
[string] $Target,
[Parameter(Mandatory = $true)]
[string] $LocalFile,
[Parameter(Mandatory = $true)]
[string] $RemoteSftpPath,
[Parameter(Mandatory = $true)]
[string] $BatchFilePath
)
$localForSftp = $LocalFile.Replace("\", "/")
$content = @(
"put `"$localForSftp`" $RemoteSftpPath",
"bye"
)
Set-Content -Path $BatchFilePath -Value $content -Encoding ascii
try {
& sftp -b $BatchFilePath $Target
if ($LASTEXITCODE -ne 0) {
throw "sftp upload failed for $LocalFile."
}
} finally {
Remove-Item $BatchFilePath -Force -ErrorAction SilentlyContinue
}
}
function Prune-RemoteBackups {
param(
[Parameter(Mandatory = $true)]
[string] $Target,
[Parameter(Mandatory = $true)]
[string] $RemoteWindowsDir,
[Parameter(Mandatory = $true)]
[int] $KeepLatest
)
if ($KeepLatest -lt 0) {
throw "Remote keep-latest count cannot be negative."
}
$escapedDir = $RemoteWindowsDir.Replace("'", "''")
$script = @'
$ProgressPreference = 'SilentlyContinue'
$files = Get-ChildItem -Path '__REMOTE_DIR__' -File -Filter '*.zip' | Sort-Object LastWriteTimeUtc -Descending
$toDelete = @($files | Select-Object -Skip __KEEP_LATEST__)
foreach ($file in $toDelete) {
Remove-Item -LiteralPath $file.FullName -Force
Write-Output $file.Name
}
'@.Replace("__REMOTE_DIR__", $escapedDir).Replace("__KEEP_LATEST__", [string]$KeepLatest)
$output = Invoke-RemotePowerShell -Target $Target -Script $script
if ([string]::IsNullOrWhiteSpace($output)) {
return @()
}
return @($output -split "`r?`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
function Prune-LocalBackupDirectories {
param(
[Parameter(Mandatory = $true)]
[string] $BackupRoot,
[Parameter(Mandatory = $true)]
[string] $BackupLabel,
[Parameter(Mandatory = $true)]
[int] $KeepLatest
)
if ($KeepLatest -lt 0) {
throw "Local keep-latest count cannot be negative."
}
$pattern = "*-$BackupLabel"
$toDelete = Get-ChildItem -Path $BackupRoot -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like $pattern } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -Skip $KeepLatest
$deleted = @()
foreach ($item in $toDelete) {
Remove-Item -LiteralPath $item.FullName -Recurse -Force
$deleted += $item.Name
}
return $deleted
}
function Prune-LocalBackupArchives {
param(
[Parameter(Mandatory = $true)]
[string] $BackupRoot,
[Parameter(Mandatory = $true)]
[string] $BackupLabel,
[Parameter(Mandatory = $true)]
[int] $KeepLatest
)
if ($KeepLatest -lt 0) {
throw "Local keep-latest count cannot be negative."
}
$pattern = "*-$BackupLabel.zip"
$toDelete = Get-ChildItem -Path $BackupRoot -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like $pattern } |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -Skip $KeepLatest
$deleted = @()
foreach ($item in $toDelete) {
Remove-Item -LiteralPath $item.FullName -Force
$deleted += $item.Name
}
return $deleted
}
$config = Read-DotEnv -Path $envFile
$storageRoot = Resolve-ConfigPath -RepoRoot $repoRoot -Value $config["STORAGE_ROOT"] -Fallback ".voidbot"
$backupRoot = Join-Path $storageRoot "backups"
$statusPath = Join-Path $storageRoot "status\offsite-backup.json"
$logPath = Join-Path $storageRoot "logs\offsite-backup.log"
$sshTargetValue = if (-not [string]::IsNullOrWhiteSpace($SshTarget)) {
$SshTarget
} elseif ($config.ContainsKey("OFFSITE_BACKUP_SSH_TARGET")) {
$config["OFFSITE_BACKUP_SSH_TARGET"]
} else {
""
}
$remoteWindowsDirValue = if (-not [string]::IsNullOrWhiteSpace($RemoteWindowsDir)) {
$RemoteWindowsDir
} elseif ($config.ContainsKey("OFFSITE_BACKUP_REMOTE_WINDOWS_DIR")) {
$config["OFFSITE_BACKUP_REMOTE_WINDOWS_DIR"]
} else {
"C:\Meta\voidbot-backups"
}
$remoteSftpDirValue = if (-not [string]::IsNullOrWhiteSpace($RemoteSftpDir)) {
$RemoteSftpDir
} elseif ($config.ContainsKey("OFFSITE_BACKUP_REMOTE_SFTP_DIR") -and -not [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_REMOTE_SFTP_DIR"])) {
$config["OFFSITE_BACKUP_REMOTE_SFTP_DIR"]
} else {
Convert-WindowsPathToSftpPath -WindowsPath $remoteWindowsDirValue
}
$localKeepLatestValue = if ($PSBoundParameters.ContainsKey("LocalKeepLatest")) {
$LocalKeepLatest
} elseif ($config.ContainsKey("OFFSITE_BACKUP_LOCAL_KEEP_LATEST")) {
[int]$config["OFFSITE_BACKUP_LOCAL_KEEP_LATEST"]
} else {
7
}
$remoteKeepLatestValue = if ($PSBoundParameters.ContainsKey("RemoteKeepLatest")) {
$RemoteKeepLatest
} elseif ($config.ContainsKey("OFFSITE_BACKUP_REMOTE_KEEP_LATEST")) {
[int]$config["OFFSITE_BACKUP_REMOTE_KEEP_LATEST"]
} else {
14
}
$backupLabelValue = if (-not [string]::IsNullOrWhiteSpace($BackupLabel)) {
$BackupLabel
} elseif ($config.ContainsKey("OFFSITE_BACKUP_LABEL") -and -not [string]::IsNullOrWhiteSpace($config["OFFSITE_BACKUP_LABEL"])) {
$config["OFFSITE_BACKUP_LABEL"]
} else {
"offsite-auto"
}
if ([string]::IsNullOrWhiteSpace($sshTargetValue)) {
throw "OFFSITE_BACKUP_SSH_TARGET is required for offsite backup sync."
}
$status = @{
startedAt = (Get-Date).ToString("o")
repoRoot = $repoRoot
status = "starting"
sshTarget = $sshTargetValue
remoteWindowsDir = $remoteWindowsDirValue
remoteSftpDir = $remoteSftpDirValue
localKeepLatest = $localKeepLatestValue
remoteKeepLatest = $remoteKeepLatestValue
backupLabel = $backupLabelValue
}
$transcriptStarted = Start-LogCapture -LogPath $logPath
$archivePath = $null
try {
Write-StatusFile -Path $statusPath -Status $status
Write-Host "Running local backup first..."
$backupArgs = @(
"-ExecutionPolicy",
"Bypass",
"-File",
$backupScript,
"-Label",
$backupLabelValue
)
if ($NoRestart) {
$backupArgs += "-NoRestart"
}
& powershell @backupArgs
if ($LASTEXITCODE -ne 0) {
throw "Local backup script failed."
}
$backupDir = Get-LatestBackupDirectory -BackupRoot $backupRoot -Label $backupLabelValue
$backupName = $backupDir.Name
$archivePath = Join-Path $backupRoot "$backupName.zip"
$status.status = "compressing"
$status.backupDirectory = $backupDir.FullName
$status.archivePath = $archivePath
Write-StatusFile -Path $statusPath -Status $status
Write-Host "Compressing $backupName for offsite transfer..."
& tar.exe -a -cf $archivePath -C $backupRoot $backupName
if ($LASTEXITCODE -ne 0) {
throw "tar.exe failed while creating $archivePath."
}
$archiveItem = Get-Item -LiteralPath $archivePath
$status.archiveSize = $archiveItem.Length
$status.status = "uploading"
Write-StatusFile -Path $statusPath -Status $status
Write-Host "Ensuring remote directory exists..."
Ensure-RemoteDirectory -Target $sshTargetValue -RemoteWindowsDir $remoteWindowsDirValue
$remoteFileName = $archiveItem.Name
$remoteWindowsPath = Join-Path $remoteWindowsDirValue $remoteFileName
$remoteSftpPath = "$($remoteSftpDirValue.TrimEnd('/'))/$remoteFileName"
$batchPath = Join-Path $storageRoot "status\offsite-upload.sftp"
Write-Host "Uploading $remoteFileName to $sshTargetValue..."
Upload-WithSftp -Target $sshTargetValue -LocalFile $archivePath -RemoteSftpPath $remoteSftpPath -BatchFilePath $batchPath
$remoteInfo = Get-RemoteFileInfo -Target $sshTargetValue -RemoteWindowsPath $remoteWindowsPath
$status.remoteFile = @{
path = $remoteWindowsPath
name = $remoteInfo.name
size = $remoteInfo.size
lastWriteTimeUtc = $remoteInfo.lastWriteTimeUtc
}
Remove-Item -LiteralPath $archivePath -Force
$archivePath = $null
$status.status = "pruning"
Write-StatusFile -Path $statusPath -Status $status
Write-Host "Pruning local scheduled backups..."
$deletedLocalDirs = Prune-LocalBackupDirectories -BackupRoot $backupRoot -BackupLabel $backupLabelValue -KeepLatest $localKeepLatestValue
$deletedLocalArchives = Prune-LocalBackupArchives -BackupRoot $backupRoot -BackupLabel $backupLabelValue -KeepLatest 0
Write-Host "Pruning remote scheduled backups..."
$deletedRemoteArchives = Prune-RemoteBackups -Target $sshTargetValue -RemoteWindowsDir $remoteWindowsDirValue -KeepLatest $remoteKeepLatestValue
$status.deletedLocalBackupDirectories = $deletedLocalDirs
$status.deletedLocalArchives = $deletedLocalArchives
$status.deletedRemoteArchives = $deletedRemoteArchives
$status.status = "completed"
$status.completedAt = (Get-Date).ToString("o")
Write-StatusFile -Path $statusPath -Status $status
Write-Host "Offsite backup sync complete."
Write-Host "Latest uploaded archive: $remoteWindowsPath"
} catch {
$status.status = "failed"
$status.completedAt = (Get-Date).ToString("o")
$status.error = $_.Exception.Message
Write-StatusFile -Path $statusPath -Status $status
throw
} finally {
if ($archivePath -and (Test-Path $archivePath)) {
Remove-Item -LiteralPath $archivePath -Force -ErrorAction SilentlyContinue
}
Stop-LogCapture -Started $transcriptStarted
}