#!/usr/bin/env bash
# ============================================================================
# backup-vincent.sh — Comprehensive backup for the Vincent AI brain system
#
# Backs up:
# • events.db (SQLite with WAL checkpoint + atomic copy)
# • events/ (JSON event logs, date-based subdirectories)
# • config files (config.py, brain.py, embeddings.py, storage.py)
# • migrations/ (database migration files)
#
# Usage: backup-vincent.sh [--test]
# --test Dry-run mode: prints what would happen without making changes
#
# Output: ~/backups/brain/YYYY-MM-DD/
# Log: ~/backups/brain/backup.log
# Retention: 90 days
# ============================================================================
set -euo pipefail
# ── Paths ────────────────────────────────────────────────────────────────────
BRAIN_DIR="$HOME/.hermes/brain"
DB_PATH="$BRAIN_DIR/index/events.db"
EVENTS_DIR="$BRAIN_DIR/events"
CONFIG_FILES=("config.py" "brain.py" "embeddings.py" "storage.py")
MIGRATIONS_DIR="$BRAIN_DIR/migrations"
BACKUP_BASE="$HOME/backups/brain"
BACKUP_DATE="$(date +%Y-%m-%d)"
BACKUP_DIR="$BACKUP_BASE/$BACKUP_DATE"
LOG_FILE="$BACKUP_BASE/backup.log"
RETENTION_DAYS=90
# ── Flags ────────────────────────────────────────────────────────────────────
DRY_RUN=false
if [[ "${1:-}" == "--test" ]]; then
DRY_RUN=true
fi
# ── Helpers ──────────────────────────────────────────────────────────────────
log() {
local ts
ts="$(date '+%Y-%m-%d %H:%M:%S')"
local msg="[$ts] $*"
echo "$msg" | tee -a "$LOG_FILE"
}
die() {
log "ERROR: $*"
exit 1
}
# ── Pre-flight checks ───────────────────────────────────────────────────────
# Ensure log directory exists (needed even for dry-run so log file can be written)
mkdir -p "$BACKUP_BASE"
log "═══════════════════════════════════════════════════════"
log "Backup started (dry-run=$DRY_RUN)"
log "═══════════════════════════════════════════════════════"
# Verify source paths exist
[[ -f "$DB_PATH" ]] || die "Database not found: $DB_PATH"
log "✓ Database found: $DB_PATH ($(du -h "$DB_PATH" | cut -f1))"
if [[ -d "$EVENTS_DIR" ]]; then
event_count=$(find "$EVENTS_DIR" -type f -name '*.json' 2>/dev/null | wc -l)
log "✓ Events directory found: $EVENTS_DIR ($event_count JSON files)"
else
log "⚠ Events directory not found: $EVENTS_DIR (skipping)"
fi
if [[ -d "$MIGRATIONS_DIR" ]]; then
mig_count=$(find "$MIGRATIONS_DIR" -type f | wc -l)
log "✓ Migrations directory found: $MIGRATIONS_DIR ($mig_count files)"
else
log "⚠ Migrations directory not found: $MIGRATIONS_DIR (skipping)"
fi
for f in "${CONFIG_FILES[@]}"; do
src="$BRAIN_DIR/$f"
if [[ -f "$src" ]]; then
log "✓ Config file found: $src"
else
log "⚠ Config file not found: $src (skipping)"
fi
done
# ── Step 1: Create backup directory ─────────────────────────────────────────
if [[ "$DRY_RUN" == true ]]; then
log "[DRY-RUN] Would create backup directory: $BACKUP_DIR"
else
mkdir -p "$BACKUP_DIR"
log "✓ Created backup directory: $BACKUP_DIR"
fi
# ── Step 2: SQLite WAL checkpoint ───────────────────────────────────────────
if [[ "$DRY_RUN" == true ]]; then
log "[DRY-RUN] Would checkpoint WAL for: $DB_PATH"
else
log "→ Checkpointing SQLite WAL (TRUNCATE)..."
result=$(sqlite3 "$DB_PATH" "PRAGMA wal_checkpoint(TRUNCATE);" 2>&1)
log " WAL checkpoint result: $result"
log "→ Running VACUUM for compactness..."
sqlite3 "$DB_PATH" "VACUUM;" 2>&1
log "✓ Database checkpointed and vacuumed"
fi
# ── Step 3: Atomic copy of events.db ────────────────────────────────────────
if [[ "$DRY_RUN" == true ]]; then
log "[DRY-RUN] Would atomically copy events.db → $BACKUP_DIR/events.db"
else
tmp_db=$(mktemp "$BACKUP_DIR/events.db.XXXXXX")
log "→ Copying events.db to temp file..."
cp -p "$DB_PATH" "$tmp_db"
# Atomic rename
mv "$tmp_db" "$BACKUP_DIR/events.db"
db_size=$(du -h "$BACKUP_DIR/events.db" | cut -f1)
log "✓ events.db copied atomically ($db_size)"
fi
# ── Step 4: Copy events/ directory ──────────────────────────────────────────
if [[ -d "$EVENTS_DIR" ]]; then
if [[ "$DRY_RUN" == true ]]; then
log "[DRY-RUN] Would copy events/ → $BACKUP_DIR/events/"
else
log "→ Copying events directory..."
rsync -a --info=progress2 "$EVENTS_DIR/" "$BACKUP_DIR/events/" 2>/dev/null || \
cp -r "$EVENTS_DIR" "$BACKUP_DIR/events"
event_files=$(find "$BACKUP_DIR/events" -type f -name '*.json' | wc -l)
log "✓ Events copied ($event_files JSON files)"
fi
fi
# ── Step 5: Copy config files ───────────────────────────────────────────────
for f in "${CONFIG_FILES[@]}"; do
src="$BRAIN_DIR/$f"
if [[ -f "$src" ]]; then
if [[ "$DRY_RUN" == true ]]; then
log "[DRY-RUN] Would copy $f → $BACKUP_DIR/$f"
else
cp -p "$src" "$BACKUP_DIR/$f"
log "✓ Copied: $f"
fi
fi
done
# ── Step 6: Copy migrations/ directory ──────────────────────────────────────
if [[ -d "$MIGRATIONS_DIR" ]]; then
if [[ "$DRY_RUN" == true ]]; then
log "[DRY-RUN] Would copy migrations/ → $BACKUP_DIR/migrations/"
else
log "→ Copying migrations directory..."
cp -r "$MIGRATIONS_DIR" "$BACKUP_DIR/migrations"
mig_files=$(find "$BACKUP_DIR/migrations" -type f | wc -l)
log "✓ Migrations copied ($mig_files files)"
fi
fi
# ── Step 7: Create manifest.txt with checksums ──────────────────────────────
if [[ "$DRY_RUN" == true ]]; then
log "[DRY-RUN] Would generate manifest.txt with SHA-256 checksums"
else
manifest="$BACKUP_DIR/manifest.txt"
log "→ Generating manifest..."
{
echo "# Backup manifest — $BACKUP_DATE"
echo "# Generated: $(date -Iseconds)"
echo "# Source brain directory: $BRAIN_DIR"
echo ""
echo "# ── Checksums (SHA-256) ──"
# Find all files in backup dir, sort for deterministic order
find "$BACKUP_DIR" -type f ! -name 'manifest.txt' -print0 | \
sort -z | \
xargs -0 sha256sum 2>/dev/null || true
echo ""
echo "# ── Metadata ──"
echo "backup_date=$BACKUP_DATE"
echo "source_db=$DB_PATH"
echo "source_db_size=$(du -h "$DB_PATH" | cut -f1)"
echo "backup_dir=$BACKUP_DIR"
echo "total_backup_size=$(du -sh "$BACKUP_DIR" | cut -f1)"
echo "retention_days=$RETENTION_DAYS"
} > "$manifest"
log "✓ Manifest written: $manifest"
fi
# ── Step 8: Cleanup old backups (>90 days) ──────────────────────────────────
if [[ "$DRY_RUN" == true ]]; then
old_dirs=$(find "$BACKUP_BASE" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" 2>/dev/null | wc -l)
log "[DRY-RUN] Would remove $old_dirs backup(s) older than $RETENTION_DAYS days"
else
old_dirs=$(find "$BACKUP_BASE" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION_DAYS" 2>/dev/null)
count=0
while IFS= read -r dir; do
[[ -z "$dir" ]] && continue
rm -rf "$dir"
log "→ Removed old backup: $dir"
count=$((count + 1))
done <<< "$old_dirs"
if [[ $count -gt 0 ]]; then
log "✓ Cleaned up $count backup(s) older than $RETENTION_DAYS days"
else
log "✓ No old backups to clean up"
fi
fi
# ── Done ─────────────────────────────────────────────────────────────────────
if [[ "$DRY_RUN" == true ]]; then
log "═══════════════════════════════════════════════════════"
log "Backup dry-run completed successfully"
log "═══════════════════════════════════════════════════════"
else
total_size=$(du -sh "$BACKUP_DIR" 2>/dev/null | cut -f1)
log "═══════════════════════════════════════════════════════"
log "Backup completed successfully"
log " Directory: $BACKUP_DIR"
log " Size: ${total_size:-unknown}"
log "═══════════════════════════════════════════════════════"
fi