Add local backup and restore workflow

This commit is contained in:
Meta Vens 2026-04-24 16:04:43 +01:00
commit 6dcd37fe31
6 changed files with 1330 additions and 5 deletions

View file

@ -384,6 +384,40 @@ If you still have an old mixed local JSON vector store from earlier builds, migr
npm run rag:migrate-vector-layout
```
Manual local state backups now have a real path instead of vibes:
```bash
npm run state:backup
```
Optional label:
```bash
npm run state:backup -- -Label before-big-reindex
```
That backup contains:
- a `pg_dump` custom-format dump for Postgres jobs, audit events, and interaction memory
- Qdrant snapshots for every collection currently on the local node
- copies of `.voidbot/rag/*` so archive-backed rebuild provenance does not evaporate
Backups are written under `.voidbot/backups/<timestamp>[-label]/` with a `manifest.json`.
Restore the newest backup:
```bash
npm run state:restore
```
Restore a specific backup directory:
```bash
npm run state:restore -- -BackupPath E:\Projects\VoidBot\.voidbot\backups\<timestamp>
```
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.
## Commands
Current slash commands:
@ -428,9 +462,10 @@ Useful local state and docs:
Reasonable next steps from here:
1. Add health checks, backups, and operational guidance around Postgres and Qdrant.
2. Expand worker processing into richer provider run records and moderation hooks.
3. Replace the remaining scaffolded provider paths with funded, production-grade implementations.
4. Add real constrained sandbox execution instead of policy-only dry runs.
2. Automate recurring and off-box retention for Postgres, Qdrant, and archive backups instead of relying on manual local runs.
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.
## Related Docs

View file

@ -42,7 +42,7 @@ Discord Gateway
## Upgrade path
1. Add health checks, backup/restore guidance, and operational runbooks for Postgres and Qdrant.
1. Automate recurring and off-box retention for the new Postgres/Qdrant/manual-archive 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

@ -1,6 +1,6 @@
services:
qdrant:
image: qdrant/qdrant:latest
image: qdrant/qdrant:1.17.1
container_name: voidbot-qdrant
restart: unless-stopped
ports:

View file

@ -13,6 +13,8 @@
"dev:mcp": "tsx apps/worker/src/mcp-server.ts",
"dev:worker": "tsx apps/worker/src/index.ts",
"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",
"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

@ -0,0 +1,679 @@
param(
[string] $OutputRoot,
[string] $Label,
[switch] $NoRestart
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $repoRoot ".env"
$startScript = Join-Path $PSScriptRoot "start-voidbot-stack.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 Parse-DatabaseDsn {
param(
[Parameter(Mandatory = $true)]
[string] $Dsn
)
$uri = [Uri]$Dsn
$userInfo = if ([string]::IsNullOrWhiteSpace($uri.UserInfo)) { @() } else { $uri.UserInfo.Split(":", 2) }
return @{
host = $uri.Host
port = if ($uri.Port -gt 0) { $uri.Port } else { 5432 }
database = $uri.AbsolutePath.TrimStart("/")
username = if ($userInfo.Length -ge 1) { [Uri]::UnescapeDataString($userInfo[0]) } else { "voidbot" }
password = if ($userInfo.Length -ge 2) { [Uri]::UnescapeDataString($userInfo[1]) } else { "voidbot" }
}
}
function Invoke-CurlJson {
param(
[Parameter(Mandatory = $true)]
[string[]] $Arguments
)
$response = & curl.exe @Arguments
if ($LASTEXITCODE -ne 0) {
throw "curl.exe failed: $($Arguments -join ' ')"
}
if ([string]::IsNullOrWhiteSpace($response)) {
return $null
}
return $response | ConvertFrom-Json
}
function Test-TcpEndpoint {
param(
[Parameter(Mandatory = $true)]
[string] $HostName,
[Parameter(Mandatory = $true)]
[int] $Port
)
$client = [System.Net.Sockets.TcpClient]::new()
try {
$connectTask = $client.ConnectAsync($HostName, $Port)
if (-not $connectTask.Wait(1500)) {
return $false
}
return $client.Connected
} catch {
return $false
} finally {
$client.Dispose()
}
}
function Test-IsLocalHostName {
param(
[Parameter(Mandatory = $true)]
[string] $HostName
)
return $HostName -in @("127.0.0.1", "localhost", "::1")
}
function Test-IsLocalUrl {
param(
[Parameter(Mandatory = $true)]
[string] $Url
)
$uri = [Uri]$Url
return $uri.Host -in @("127.0.0.1", "localhost", "::1")
}
function Wait-Until {
param(
[Parameter(Mandatory = $true)]
[scriptblock] $Condition,
[Parameter(Mandatory = $true)]
[string] $FailureMessage,
[int] $Attempts = 30,
[int] $SleepSeconds = 2
)
for ($attempt = 0; $attempt -lt $Attempts; $attempt += 1) {
if (& $Condition) {
return
}
Start-Sleep -Seconds $SleepSeconds
}
throw $FailureMessage
}
function Get-DockerContainerHealth {
param(
[Parameter(Mandatory = $true)]
[string] $ContainerName
)
try {
$status = & docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}" $ContainerName 2>$null
if ($LASTEXITCODE -ne 0) {
return $null
}
return $status.Trim()
} catch {
return $null
}
}
function Ensure-DockerOnPath {
$dockerCliDir = "C:\Program Files\Docker\Docker\resources\bin"
if (Test-Path $dockerCliDir) {
$env:Path = "$dockerCliDir;$env:Path"
}
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw "Docker CLI is not available on PATH."
}
& docker version *> $null
if ($LASTEXITCODE -ne 0) {
throw "Docker is installed but the daemon is not ready."
}
}
function Ensure-Postgres {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[Parameter(Mandatory = $true)]
[string] $DatabaseDsn
)
$database = Parse-DatabaseDsn -Dsn $DatabaseDsn
if (Test-TcpEndpoint -HostName $database.host -Port $database.port) {
return @{
dsn = $DatabaseDsn
host = $database.host
port = $database.port
database = $database.database
username = $database.username
password = $database.password
containerName = "voidbot-postgres"
startedByScript = $false
healthy = $true
}
}
if (-not (Test-IsLocalHostName -HostName $database.host)) {
throw "Postgres is not reachable at $($database.host):$($database.port), and the host is not local enough for Docker auto-start."
}
Ensure-DockerOnPath
$composeFile = Join-Path $RepoRoot "infra\postgres\docker-compose.yml"
$env:VOIDBOT_POSTGRES_PORT = [string]$database.port
$env:VOIDBOT_POSTGRES_DB = $database.database
$env:VOIDBOT_POSTGRES_USER = $database.username
$env:VOIDBOT_POSTGRES_PASSWORD = $database.password
& docker compose -f $composeFile up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose failed to start Postgres."
}
Wait-Until -Condition {
$health = Get-DockerContainerHealth -ContainerName "voidbot-postgres"
if ([string]::IsNullOrWhiteSpace($health) -or $health -eq "none") {
return Test-TcpEndpoint -HostName $database.host -Port $database.port
}
return $health -eq "healthy"
} -FailureMessage "Postgres did not become reachable at $($database.host):$($database.port) in time."
return @{
dsn = $DatabaseDsn
host = $database.host
port = $database.port
database = $database.database
username = $database.username
password = $database.password
containerName = "voidbot-postgres"
startedByScript = $true
healthy = $true
}
}
function Ensure-Qdrant {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[Parameter(Mandatory = $true)]
[string] $QdrantUrl
)
$healthUrl = "$($QdrantUrl.TrimEnd('/'))/collections"
try {
[void](Invoke-CurlJson -Arguments @("-fsS", $healthUrl))
return @{
url = $QdrantUrl
startedByScript = $false
healthy = $true
}
} catch {
}
if (-not (Test-IsLocalUrl -Url $QdrantUrl)) {
throw "Qdrant is not reachable at $QdrantUrl, and the URL is not local enough for Docker auto-start."
}
Ensure-DockerOnPath
$composeFile = Join-Path $RepoRoot "infra\qdrant\docker-compose.yml"
& docker compose -f $composeFile up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose failed to start Qdrant."
}
Wait-Until -Condition {
try {
[void](Invoke-CurlJson -Arguments @("-fsS", $healthUrl))
return $true
} catch {
return $false
}
} -FailureMessage "Qdrant did not become reachable at $QdrantUrl in time."
return @{
url = $QdrantUrl
startedByScript = $true
healthy = $true
}
}
function Get-RuntimeProcesses {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot
)
$patterns = @(
"apps\bot\src\index.ts",
"apps/bot/src/index.ts",
"apps\bot\dist\index.js",
"apps/bot/dist/index.js",
"apps\worker\src\index.ts",
"apps/worker/src/index.ts",
"apps\worker\dist\index.js",
"apps/worker/dist/index.js"
)
return Get-CimInstance Win32_Process |
Where-Object {
$_.Name -eq "node.exe" -and
$_.CommandLine -and
$_.CommandLine -like "*$RepoRoot*"
} |
Where-Object {
$commandLine = $_.CommandLine
$matchesRuntime = $false
foreach ($pattern in $patterns) {
if ($commandLine -like "*$pattern*") {
$matchesRuntime = $true
break
}
}
return $matchesRuntime
}
}
function Stop-RuntimeProcesses {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot
)
$processes = @(Get-RuntimeProcesses -RepoRoot $RepoRoot)
foreach ($process in $processes) {
try {
Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop
} catch {
}
}
return $processes
}
function Get-PostgresVersion {
param(
[Parameter(Mandatory = $true)]
[string] $ContainerName
)
$version = & docker exec $ContainerName postgres --version
if ($LASTEXITCODE -ne 0) {
throw "Failed to determine PostgreSQL version from $ContainerName."
}
return $version.Trim()
}
function Get-QdrantInfo {
param(
[Parameter(Mandatory = $true)]
[string] $QdrantUrl
)
return Invoke-CurlJson -Arguments @("-fsS", "$($QdrantUrl.TrimEnd('/'))/")
}
function Get-QdrantCollections {
param(
[Parameter(Mandatory = $true)]
[string] $QdrantUrl
)
$response = Invoke-CurlJson -Arguments @("-fsS", "$($QdrantUrl.TrimEnd('/'))/collections")
return @($response.result.collections | ForEach-Object { $_.name })
}
function Backup-Postgres {
param(
[Parameter(Mandatory = $true)]
[hashtable] $Postgres,
[Parameter(Mandatory = $true)]
[string] $OutputDir
)
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$containerPath = "/tmp/voidbot-backup.dump"
$outputPath = Join-Path $OutputDir "voidbot.dump"
& docker exec "-e" "PGPASSWORD=$($Postgres.password)" $Postgres.containerName `
pg_dump `
"--username=$($Postgres.username)" `
"--dbname=$($Postgres.database)" `
"--format=custom" `
"--no-owner" `
"--no-privileges" `
"--file=$containerPath"
if ($LASTEXITCODE -ne 0) {
throw "pg_dump failed inside $($Postgres.containerName)."
}
try {
& docker cp "$($Postgres.containerName):$containerPath" $outputPath
if ($LASTEXITCODE -ne 0) {
throw "docker cp failed while exporting the PostgreSQL dump."
}
} finally {
& docker exec $Postgres.containerName rm -f $containerPath *> $null
}
return @{
skipped = $false
containerName = $Postgres.containerName
version = Get-PostgresVersion -ContainerName $Postgres.containerName
dumpFile = "postgres/voidbot.dump"
database = $Postgres.database
host = $Postgres.host
port = $Postgres.port
}
}
function Backup-QdrantCollections {
param(
[Parameter(Mandatory = $true)]
[string] $QdrantUrl,
[Parameter(Mandatory = $true)]
[string] $OutputDir
)
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$rootInfo = Get-QdrantInfo -QdrantUrl $QdrantUrl
$collections = Get-QdrantCollections -QdrantUrl $QdrantUrl
$backedUpCollections = @()
foreach ($collectionName in $collections) {
Write-Host "Creating Qdrant snapshot for $collectionName..."
$escapedCollection = [Uri]::EscapeDataString($collectionName)
$createResponse = Invoke-CurlJson -Arguments @(
"-fsS",
"-X",
"POST",
"$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection/snapshots"
)
$snapshotName = [string]$createResponse.result.name
Wait-Until -Condition {
try {
$listResponse = Invoke-CurlJson -Arguments @(
"-fsS",
"$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection/snapshots"
)
$names = @($listResponse.result | ForEach-Object { $_.name })
return $names -contains $snapshotName
} catch {
return $false
}
} -FailureMessage "Qdrant snapshot $snapshotName for collection $collectionName did not become downloadable in time."
$downloadPath = Join-Path $OutputDir $snapshotName
try {
Write-Host "Downloading Qdrant snapshot $snapshotName..."
& curl.exe -fsS "$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection/snapshots/$snapshotName" --output $downloadPath
if ($LASTEXITCODE -ne 0) {
throw "Failed to download Qdrant snapshot $snapshotName for collection $collectionName."
}
} finally {
& curl.exe -fsS -X DELETE "$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection/snapshots/$snapshotName" *> $null
}
$backedUpCollections += @{
name = $collectionName
snapshotFile = "qdrant/$snapshotName"
}
}
return @{
skipped = $false
url = $QdrantUrl
version = [string]$rootInfo.version
title = [string]$rootInfo.title
collections = $backedUpCollections
}
}
function Backup-RagArchives {
param(
[Parameter(Mandatory = $true)]
[string] $StorageRoot,
[Parameter(Mandatory = $true)]
[string] $OutputDir
)
$ragRoot = Join-Path $StorageRoot "rag"
if (-not (Test-Path $ragRoot)) {
return @{
skipped = $true
reason = "RAG archive directory does not exist."
files = @()
}
}
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$items = @(Get-ChildItem -Path $ragRoot -Force)
if ($items.Count -gt 0) {
Copy-Item -Path (Join-Path $ragRoot "*") -Destination $OutputDir -Recurse -Force
}
$files = Get-ChildItem -Path $OutputDir -File -Recurse |
ForEach-Object {
@{
relativePath = $_.FullName.Substring($OutputDir.Length + 1)
size = $_.Length
}
}
return @{
skipped = $false
sourceRoot = $ragRoot
files = $files
}
}
function Sanitize-Label {
param(
[Parameter(Mandatory = $true)]
[string] $Value
)
$sanitized = ($Value -replace "[^A-Za-z0-9._-]", "-").Trim("-")
if ([string]::IsNullOrWhiteSpace($sanitized)) {
return $null
}
return $sanitized
}
$config = Read-DotEnv -Path $envFile
$storageRoot = Resolve-ConfigPath -RepoRoot $repoRoot -Value $config["STORAGE_ROOT"] -Fallback ".voidbot"
$backupRoot = if ([string]::IsNullOrWhiteSpace($OutputRoot)) {
Join-Path $storageRoot "backups"
} else {
[System.IO.Path]::GetFullPath($OutputRoot)
}
$stateStorageBackend = if ($config.ContainsKey("STATE_STORAGE_BACKEND")) { $config["STATE_STORAGE_BACKEND"] } else { "postgres" }
$databaseDsn = if ($config.ContainsKey("DATABASE_DSN")) { $config["DATABASE_DSN"] } else { "postgres://voidbot:voidbot@localhost:5432/voidbot" }
$vectorStoreKind = if ($config.ContainsKey("VECTOR_STORE_KIND")) { $config["VECTOR_STORE_KIND"] } else { "local_json" }
$qdrantUrl = if ($config.ContainsKey("QDRANT_URL")) { $config["QDRANT_URL"] } else { "http://127.0.0.1:6333" }
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$labelSuffix = $null
if (-not [string]::IsNullOrWhiteSpace($Label)) {
$sanitizedLabel = Sanitize-Label -Value $Label
if ($sanitizedLabel) {
$labelSuffix = "-$sanitizedLabel"
}
}
$backupDir = Join-Path $backupRoot "$timestamp$labelSuffix"
$postgresDir = Join-Path $backupDir "postgres"
$qdrantDir = Join-Path $backupDir "qdrant"
$archiveDir = Join-Path $backupDir "archives"
$manifestPath = Join-Path $backupDir "manifest.json"
$stoppedRuntime = @()
$shouldRestart = $false
$pendingError = $null
try {
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
$stoppedRuntime = @(Stop-RuntimeProcesses -RepoRoot $repoRoot)
$shouldRestart = $stoppedRuntime.Count -gt 0 -and -not $NoRestart
$stoppedPids = @($stoppedRuntime | ForEach-Object { $_.ProcessId })
$stoppedDisplay = if ($stoppedPids.Count -gt 0) { $stoppedPids -join ", " } else { "none" }
Write-Host "Stopped runtime processes: $stoppedDisplay"
$manifest = @{
backupFormat = "voidbot_state_backup_v1"
createdAt = (Get-Date).ToString("o")
repoRoot = $repoRoot
storageRoot = $storageRoot
runtimeStopped = @($stoppedRuntime | ForEach-Object { $_.ProcessId })
postgres = @{
skipped = $true
reason = "STATE_STORAGE_BACKEND is not postgres."
}
qdrant = @{
skipped = $true
reason = "VECTOR_STORE_KIND is not qdrant."
}
archives = $null
}
if ($stateStorageBackend -eq "postgres") {
Write-Host "Backing up Postgres state..."
Ensure-DockerOnPath
$postgres = Ensure-Postgres -RepoRoot $repoRoot -DatabaseDsn $databaseDsn
$manifest.postgres = Backup-Postgres -Postgres $postgres -OutputDir $postgresDir
}
if ($vectorStoreKind -eq "qdrant") {
Write-Host "Backing up Qdrant collections..."
Ensure-DockerOnPath
[void](Ensure-Qdrant -RepoRoot $repoRoot -QdrantUrl $qdrantUrl)
$manifest.qdrant = Backup-QdrantCollections -QdrantUrl $qdrantUrl -OutputDir $qdrantDir
}
Write-Host "Copying RAG archives..."
$manifest.archives = Backup-RagArchives -StorageRoot $storageRoot -OutputDir $archiveDir
$manifest | ConvertTo-Json -Depth 8 | Set-Content -Path $manifestPath -Encoding utf8
Write-Host "VoidBot state backup complete."
Write-Host "Backup: $backupDir"
} catch {
$pendingError = $_
} finally {
if ($shouldRestart) {
try {
Write-Host "Restarting VoidBot stack..."
& powershell -ExecutionPolicy Bypass -File $startScript
if ($LASTEXITCODE -ne 0) {
throw "Failed to restart the VoidBot stack after backup."
}
} catch {
if ($null -eq $pendingError) {
$pendingError = $_
} else {
Write-Warning "Backup hit an error, and the automatic stack restart also failed: $($_.Exception.Message)"
}
}
}
}
if ($null -ne $pendingError) {
if ($pendingError.Exception) {
throw $pendingError.Exception
}
throw $pendingError
}

View file

@ -0,0 +1,609 @@
param(
[string] $BackupPath,
[switch] $NoRestart
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $repoRoot ".env"
$startScript = Join-Path $PSScriptRoot "start-voidbot-stack.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 Parse-DatabaseDsn {
param(
[Parameter(Mandatory = $true)]
[string] $Dsn
)
$uri = [Uri]$Dsn
$userInfo = if ([string]::IsNullOrWhiteSpace($uri.UserInfo)) { @() } else { $uri.UserInfo.Split(":", 2) }
return @{
host = $uri.Host
port = if ($uri.Port -gt 0) { $uri.Port } else { 5432 }
database = $uri.AbsolutePath.TrimStart("/")
username = if ($userInfo.Length -ge 1) { [Uri]::UnescapeDataString($userInfo[0]) } else { "voidbot" }
password = if ($userInfo.Length -ge 2) { [Uri]::UnescapeDataString($userInfo[1]) } else { "voidbot" }
}
}
function Invoke-CurlJson {
param(
[Parameter(Mandatory = $true)]
[string[]] $Arguments
)
$response = & curl.exe @Arguments
if ($LASTEXITCODE -ne 0) {
throw "curl.exe failed: $($Arguments -join ' ')"
}
if ([string]::IsNullOrWhiteSpace($response)) {
return $null
}
return $response | ConvertFrom-Json
}
function Test-TcpEndpoint {
param(
[Parameter(Mandatory = $true)]
[string] $HostName,
[Parameter(Mandatory = $true)]
[int] $Port
)
$client = [System.Net.Sockets.TcpClient]::new()
try {
$connectTask = $client.ConnectAsync($HostName, $Port)
if (-not $connectTask.Wait(1500)) {
return $false
}
return $client.Connected
} catch {
return $false
} finally {
$client.Dispose()
}
}
function Test-IsLocalHostName {
param(
[Parameter(Mandatory = $true)]
[string] $HostName
)
return $HostName -in @("127.0.0.1", "localhost", "::1")
}
function Test-IsLocalUrl {
param(
[Parameter(Mandatory = $true)]
[string] $Url
)
$uri = [Uri]$Url
return $uri.Host -in @("127.0.0.1", "localhost", "::1")
}
function Wait-Until {
param(
[Parameter(Mandatory = $true)]
[scriptblock] $Condition,
[Parameter(Mandatory = $true)]
[string] $FailureMessage,
[int] $Attempts = 30,
[int] $SleepSeconds = 2
)
for ($attempt = 0; $attempt -lt $Attempts; $attempt += 1) {
if (& $Condition) {
return
}
Start-Sleep -Seconds $SleepSeconds
}
throw $FailureMessage
}
function Get-DockerContainerHealth {
param(
[Parameter(Mandatory = $true)]
[string] $ContainerName
)
try {
$status = & docker inspect --format "{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}" $ContainerName 2>$null
if ($LASTEXITCODE -ne 0) {
return $null
}
return $status.Trim()
} catch {
return $null
}
}
function Ensure-DockerOnPath {
$dockerCliDir = "C:\Program Files\Docker\Docker\resources\bin"
if (Test-Path $dockerCliDir) {
$env:Path = "$dockerCliDir;$env:Path"
}
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw "Docker CLI is not available on PATH."
}
& docker version *> $null
if ($LASTEXITCODE -ne 0) {
throw "Docker is installed but the daemon is not ready."
}
}
function Ensure-Postgres {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[Parameter(Mandatory = $true)]
[string] $DatabaseDsn
)
$database = Parse-DatabaseDsn -Dsn $DatabaseDsn
if (Test-TcpEndpoint -HostName $database.host -Port $database.port) {
return @{
dsn = $DatabaseDsn
host = $database.host
port = $database.port
database = $database.database
username = $database.username
password = $database.password
containerName = "voidbot-postgres"
startedByScript = $false
healthy = $true
}
}
if (-not (Test-IsLocalHostName -HostName $database.host)) {
throw "Postgres is not reachable at $($database.host):$($database.port), and the host is not local enough for Docker auto-start."
}
Ensure-DockerOnPath
$composeFile = Join-Path $RepoRoot "infra\postgres\docker-compose.yml"
$env:VOIDBOT_POSTGRES_PORT = [string]$database.port
$env:VOIDBOT_POSTGRES_DB = $database.database
$env:VOIDBOT_POSTGRES_USER = $database.username
$env:VOIDBOT_POSTGRES_PASSWORD = $database.password
& docker compose -f $composeFile up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose failed to start Postgres."
}
Wait-Until -Condition {
$health = Get-DockerContainerHealth -ContainerName "voidbot-postgres"
if ([string]::IsNullOrWhiteSpace($health) -or $health -eq "none") {
return Test-TcpEndpoint -HostName $database.host -Port $database.port
}
return $health -eq "healthy"
} -FailureMessage "Postgres did not become reachable at $($database.host):$($database.port) in time."
return @{
dsn = $DatabaseDsn
host = $database.host
port = $database.port
database = $database.database
username = $database.username
password = $database.password
containerName = "voidbot-postgres"
startedByScript = $true
healthy = $true
}
}
function Ensure-Qdrant {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot,
[Parameter(Mandatory = $true)]
[string] $QdrantUrl
)
$healthUrl = "$($QdrantUrl.TrimEnd('/'))/collections"
try {
[void](Invoke-CurlJson -Arguments @("-fsS", $healthUrl))
return @{
url = $QdrantUrl
startedByScript = $false
healthy = $true
}
} catch {
}
if (-not (Test-IsLocalUrl -Url $QdrantUrl)) {
throw "Qdrant is not reachable at $QdrantUrl, and the URL is not local enough for Docker auto-start."
}
Ensure-DockerOnPath
$composeFile = Join-Path $RepoRoot "infra\qdrant\docker-compose.yml"
& docker compose -f $composeFile up -d
if ($LASTEXITCODE -ne 0) {
throw "Docker Compose failed to start Qdrant."
}
Wait-Until -Condition {
try {
[void](Invoke-CurlJson -Arguments @("-fsS", $healthUrl))
return $true
} catch {
return $false
}
} -FailureMessage "Qdrant did not become reachable at $QdrantUrl in time."
return @{
url = $QdrantUrl
startedByScript = $true
healthy = $true
}
}
function Get-RuntimeProcesses {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot
)
$patterns = @(
"apps\bot\src\index.ts",
"apps/bot/src/index.ts",
"apps\bot\dist\index.js",
"apps/bot/dist/index.js",
"apps\worker\src\index.ts",
"apps/worker/src/index.ts",
"apps\worker\dist\index.js",
"apps/worker/dist/index.js"
)
return Get-CimInstance Win32_Process |
Where-Object {
$_.Name -eq "node.exe" -and
$_.CommandLine -and
$_.CommandLine -like "*$RepoRoot*"
} |
Where-Object {
$commandLine = $_.CommandLine
$matchesRuntime = $false
foreach ($pattern in $patterns) {
if ($commandLine -like "*$pattern*") {
$matchesRuntime = $true
break
}
}
return $matchesRuntime
}
}
function Stop-RuntimeProcesses {
param(
[Parameter(Mandatory = $true)]
[string] $RepoRoot
)
$processes = @(Get-RuntimeProcesses -RepoRoot $RepoRoot)
foreach ($process in $processes) {
try {
Stop-Process -Id $process.ProcessId -Force -ErrorAction Stop
} catch {
}
}
return $processes
}
function Get-LatestBackupDirectory {
param(
[Parameter(Mandatory = $true)]
[string] $BackupRoot
)
if (-not (Test-Path $BackupRoot)) {
throw "Backup root does not exist: $BackupRoot"
}
$latest = Get-ChildItem -Path $BackupRoot -Directory |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (-not $latest) {
throw "No backup directories exist under $BackupRoot"
}
return $latest.FullName
}
function Restore-PostgresBackup {
param(
[Parameter(Mandatory = $true)]
[hashtable] $Postgres,
[Parameter(Mandatory = $true)]
[string] $DumpFile
)
if (-not (Test-Path $DumpFile)) {
throw "PostgreSQL dump file does not exist: $DumpFile"
}
$containerPath = "/tmp/voidbot-restore.dump"
& docker cp $DumpFile "$($Postgres.containerName):$containerPath"
if ($LASTEXITCODE -ne 0) {
throw "docker cp failed while uploading the PostgreSQL dump."
}
try {
& docker exec "-e" "PGPASSWORD=$($Postgres.password)" $Postgres.containerName `
pg_restore `
"--clean" `
"--if-exists" `
"--exit-on-error" `
"--single-transaction" `
"--no-owner" `
"--no-privileges" `
"--username=$($Postgres.username)" `
"--dbname=$($Postgres.database)" `
$containerPath
if ($LASTEXITCODE -ne 0) {
throw "pg_restore failed inside $($Postgres.containerName)."
}
} finally {
& docker exec $Postgres.containerName rm -f $containerPath *> $null
}
}
function Get-QdrantCollections {
param(
[Parameter(Mandatory = $true)]
[string] $QdrantUrl
)
$response = Invoke-CurlJson -Arguments @("-fsS", "$($QdrantUrl.TrimEnd('/'))/collections")
return @($response.result.collections | ForEach-Object { $_.name })
}
function Restore-QdrantSnapshots {
param(
[Parameter(Mandatory = $true)]
[string] $QdrantUrl,
[Parameter(Mandatory = $true)]
[object[]] $Collections
)
$existingCollections = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
foreach ($name in Get-QdrantCollections -QdrantUrl $QdrantUrl) {
[void]$existingCollections.Add($name)
}
foreach ($collection in $Collections) {
$collectionName = [string]$collection.name
$snapshotFile = [string]$collection.snapshotFile
Write-Host "Restoring Qdrant collection $collectionName from $(Split-Path -Leaf $snapshotFile)..."
if (-not (Test-Path $snapshotFile)) {
throw "Qdrant snapshot file does not exist: $snapshotFile"
}
$escapedCollection = [Uri]::EscapeDataString($collectionName)
if ($existingCollections.Contains($collectionName)) {
[void](Invoke-CurlJson -Arguments @(
"-fsS",
"-X",
"DELETE",
"$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection"
))
}
& curl.exe -fsS -X POST "$($QdrantUrl.TrimEnd('/'))/collections/$escapedCollection/snapshots/upload?priority=snapshot" `
-F "snapshot=@$snapshotFile" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Failed to upload Qdrant snapshot for collection $collectionName."
}
Wait-Until -Condition {
try {
$collectionsNow = Get-QdrantCollections -QdrantUrl $QdrantUrl
return $collectionsNow -contains $collectionName
} catch {
return $false
}
} -FailureMessage "Collection $collectionName did not reappear after Qdrant snapshot restore."
}
}
function Restore-RagArchives {
param(
[Parameter(Mandatory = $true)]
[string] $SourceDir,
[Parameter(Mandatory = $true)]
[string] $StorageRoot
)
if (-not (Test-Path $SourceDir)) {
return
}
$targetDir = Join-Path $StorageRoot "rag"
New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
Copy-Item -Path (Join-Path $SourceDir "*") -Destination $targetDir -Recurse -Force
}
function Test-BackupSectionSkipped {
param(
[Parameter(Mandatory = $true)]
[psobject] $Section
)
$property = $Section.PSObject.Properties["skipped"]
if ($null -eq $property) {
return $false
}
return [bool]$property.Value
}
$config = Read-DotEnv -Path $envFile
$storageRoot = Resolve-ConfigPath -RepoRoot $repoRoot -Value $config["STORAGE_ROOT"] -Fallback ".voidbot"
$backupRoot = Join-Path $storageRoot "backups"
$backupDirectory = if ([string]::IsNullOrWhiteSpace($BackupPath)) {
Get-LatestBackupDirectory -BackupRoot $backupRoot
} else {
[System.IO.Path]::GetFullPath($BackupPath)
}
$manifestPath = Join-Path $backupDirectory "manifest.json"
if (-not (Test-Path $manifestPath)) {
throw "Backup manifest not found: $manifestPath"
}
$manifest = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json
$stateStorageBackend = if ($config.ContainsKey("STATE_STORAGE_BACKEND")) { $config["STATE_STORAGE_BACKEND"] } else { "postgres" }
$databaseDsn = if ($config.ContainsKey("DATABASE_DSN")) { $config["DATABASE_DSN"] } else { "postgres://voidbot:voidbot@localhost:5432/voidbot" }
$vectorStoreKind = if ($config.ContainsKey("VECTOR_STORE_KIND")) { $config["VECTOR_STORE_KIND"] } else { "local_json" }
$qdrantUrl = if ($config.ContainsKey("QDRANT_URL")) { $config["QDRANT_URL"] } else { "http://127.0.0.1:6333" }
$stoppedRuntime = @()
$shouldRestart = $false
$pendingError = $null
try {
$stoppedRuntime = @(Stop-RuntimeProcesses -RepoRoot $repoRoot)
$shouldRestart = $stoppedRuntime.Count -gt 0 -and -not $NoRestart
$stoppedPids = @($stoppedRuntime | ForEach-Object { $_.ProcessId })
$stoppedDisplay = if ($stoppedPids.Count -gt 0) { $stoppedPids -join ", " } else { "none" }
Write-Host "Stopped runtime processes: $stoppedDisplay"
if ($stateStorageBackend -eq "postgres" -and -not (Test-BackupSectionSkipped -Section $manifest.postgres)) {
Write-Host "Restoring Postgres state..."
Ensure-DockerOnPath
$postgres = Ensure-Postgres -RepoRoot $repoRoot -DatabaseDsn $databaseDsn
Restore-PostgresBackup -Postgres $postgres -DumpFile (Join-Path $backupDirectory ([string]$manifest.postgres.dumpFile))
}
if ($vectorStoreKind -eq "qdrant" -and -not (Test-BackupSectionSkipped -Section $manifest.qdrant)) {
Write-Host "Restoring Qdrant collections..."
Ensure-DockerOnPath
[void](Ensure-Qdrant -RepoRoot $repoRoot -QdrantUrl $qdrantUrl)
$resolvedCollections = @($manifest.qdrant.collections | ForEach-Object {
@{
name = [string]$_.name
snapshotFile = Join-Path $backupDirectory ([string]$_.snapshotFile)
}
})
Restore-QdrantSnapshots -QdrantUrl $qdrantUrl -Collections $resolvedCollections
}
Write-Host "Restoring RAG archives..."
Restore-RagArchives -SourceDir (Join-Path $backupDirectory "archives") -StorageRoot $storageRoot
Write-Host "VoidBot state restore complete."
Write-Host "Restored from: $backupDirectory"
} catch {
$pendingError = $_
} finally {
if ($shouldRestart) {
try {
Write-Host "Restarting VoidBot stack..."
& powershell -ExecutionPolicy Bypass -File $startScript
if ($LASTEXITCODE -ne 0) {
throw "Failed to restart the VoidBot stack after restore."
}
} catch {
if ($null -eq $pendingError) {
$pendingError = $_
} else {
Write-Warning "Restore hit an error, and the automatic stack restart also failed: $($_.Exception.Message)"
}
}
}
}
if ($null -ne $pendingError) {
if ($pendingError.Exception) {
throw $pendingError.Exception
}
throw $pendingError
}