clawdie-ai/scripts/backup.ts

252 lines
10 KiB
TypeScript
Raw Normal View History

/**
* Clawdie backup exports all critical state to a portable tarball.
*
* What's included:
* messages.db SQLite: all chats, messages, tasks, sessions
* memory_db.sql pg_dump: agent memory + embeddings
* skills_db.sql pg_dump: built-in knowledge (skip with --skip-skills)
* env .env config (API keys, db passwords, all settings)
* groups/ registered group configurations
* mount-allowlist.json mount security allowlist
* manifest.json backup metadata
*
* ZFS snapshots: taken automatically via hostd before export (skip with --no-snapshot).
* Sanoid handles ongoing automated retention; this script does on-demand export.
*
* Usage:
* npx tsx scripts/backup.ts
* npx tsx scripts/backup.ts --skip-skills --output /mnt/backup
* npx tsx scripts/backup.ts --no-snapshot
*
* Output: ~/clawdie-backup-DD.mmm.YYYY-HHMM.tar.gz
*
* Requires: pg_dump (install postgresql17-client on host if missing)
* Restore: see docs/sessions/2026-03-16-backup-restore.md
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
AGENT_NAME,
MEMORY_DB_NAME,
MEMORY_DB_URL,
MOUNT_ALLOWLIST_PATH,
SKILLS_DB_NAME,
SKILLS_DB_URL,
STORE_DIR,
GROUPS_DIR,
ZFS_PREFIX,
} from '../src/config.js';
import { formatDisplayDate, formatSnapshotStamp } from '../src/display-date.js';
import { hostd } from '../src/hostd/client.js';
// ── CLI args ──────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const SKIP_SKILLS = args.includes('--skip-skills');
const NO_SNAPSHOT = args.includes('--no-snapshot');
const outputIdx = args.indexOf('--output');
const OUTPUT_DIR = outputIdx !== -1 ? args[outputIdx + 1] : os.homedir();
// ── Timestamp helpers ─────────────────────────────────────────────────────────
const now = new Date();
const STAMP = formatSnapshotStamp(now);
const BACKUP_NAME = `${AGENT_NAME}-backup-${STAMP}`;
// ── Helpers ───────────────────────────────────────────────────────────────────
function ok(msg: string): void { console.log(`${msg}`); }
function warn(msg: string): void { console.log(`${msg}`); }
function fail(msg: string): never { console.error(`${msg}`); process.exit(1); }
function run(cmd: string, args: string[], env?: NodeJS.ProcessEnv): { ok: boolean; output: string } {
const result = spawnSync(cmd, args, {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...env },
});
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
return { ok: result.status === 0 && !result.error, output };
}
function commandExists(cmd: string): boolean {
return run('which', [cmd]).ok;
}
// ── ZFS snapshots (via hostd) ──────────────────────────────────────────────────
async function takeZfsSnapshots(snapshotTag: string): Promise<void> {
const datasets = [
`zroot/${ZFS_PREFIX}/jails/${AGENT_NAME}-db`,
`zroot/${ZFS_PREFIX}/jails/${AGENT_NAME}-git`,
`zroot/${ZFS_PREFIX}/jails/${AGENT_NAME}-cms`,
];
for (const dataset of datasets) {
try {
const res = await hostd('zfs-snapshot', { dataset, name: snapshotTag });
if (res.ok) {
ok(`ZFS snapshot: ${dataset}@${snapshotTag}`);
} else {
warn(`ZFS snapshot failed for ${dataset}: ${res.error ?? res.output}`);
}
} catch {
warn(`hostd unreachable — ZFS snapshot skipped for ${dataset}`);
break;
}
}
}
// ── pg_dump ───────────────────────────────────────────────────────────────────
function pgDump(url: string, dbName: string, destFile: string): boolean {
if (!commandExists('pg_dump')) {
warn(`pg_dump not found — skipping ${dbName} (install postgresql-client on host)`);
return false;
}
const result = run('pg_dump', ['--dbname', url, '--no-password', '--clean', '--if-exists', '-f', destFile]);
if (result.ok) {
const sizeKb = Math.round(fs.statSync(destFile).size / 1024);
ok(`pg_dump ${dbName}${path.basename(destFile)} (${sizeKb} KB)`);
return true;
} else {
warn(`pg_dump ${dbName} failed: ${result.output.slice(0, 200)}`);
return false;
}
}
// ── Main ──────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
console.log('');
console.log(`Clawdie backup — ${formatDisplayDate(now)}`);
console.log(`Output: ${OUTPUT_DIR}/${BACKUP_NAME}.tar.gz`);
console.log('');
// ── Staging dir ─────────────────────────────────────────────────────────────
const tmpBase = path.join(process.cwd(), 'tmp', 'backup');
fs.mkdirSync(tmpBase, { recursive: true });
const stagingBase = fs.mkdtempSync(path.join(tmpBase, 'clawdie-backup-'));
const stagingDir = path.join(stagingBase, BACKUP_NAME);
fs.mkdirSync(stagingDir, { recursive: true });
const manifest: Record<string, unknown> = {
agent: AGENT_NAME,
created_at: now.toISOString(),
created_display: formatDisplayDate(now),
stamp: STAMP,
items: [] as string[],
};
const items = manifest.items as string[];
// ── ZFS snapshots ────────────────────────────────────────────────────────────
if (!NO_SNAPSHOT) {
console.log('Taking ZFS snapshots...');
// Use snapshot stamp format per AGENTS.md: DD.mmm.YYYY-HHMM
const snapshotTag = `pre-backup-${STAMP}`;
await takeZfsSnapshots(snapshotTag);
console.log('');
}
// ── SQLite ────────────────────────────────────────────────────────────────────
console.log('Exporting SQLite...');
const sqliteSrc = path.join(STORE_DIR, 'messages.db');
if (fs.existsSync(sqliteSrc)) {
fs.copyFileSync(sqliteSrc, path.join(stagingDir, 'messages.db'));
const sizeKb = Math.round(fs.statSync(sqliteSrc).size / 1024);
ok(`messages.db (${sizeKb} KB)`);
items.push('messages.db');
} else {
warn('messages.db not found — skipping');
}
console.log('');
// ── PostgreSQL dumps ──────────────────────────────────────────────────────────
console.log('Exporting PostgreSQL...');
if (pgDump(MEMORY_DB_URL, MEMORY_DB_NAME, path.join(stagingDir, 'memory_db.sql'))) {
items.push('memory_db.sql');
}
if (!SKIP_SKILLS) {
if (pgDump(SKILLS_DB_URL, SKILLS_DB_NAME, path.join(stagingDir, 'skills_db.sql'))) {
items.push('skills_db.sql');
}
} else {
ok('skills_db skipped (--skip-skills)');
}
console.log('');
// ── Config ────────────────────────────────────────────────────────────────────
console.log('Exporting config...');
const envSrc = path.join(process.cwd(), '.env');
if (fs.existsSync(envSrc)) {
fs.copyFileSync(envSrc, path.join(stagingDir, 'env'));
ok('.env');
items.push('env');
} else {
warn('.env not found');
}
const groupsSrc = GROUPS_DIR;
if (fs.existsSync(groupsSrc)) {
const dest = path.join(stagingDir, 'groups');
fs.mkdirSync(dest);
for (const f of fs.readdirSync(groupsSrc)) {
fs.copyFileSync(path.join(groupsSrc, f), path.join(dest, f));
}
ok('groups/');
items.push('groups/');
}
if (fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
fs.copyFileSync(MOUNT_ALLOWLIST_PATH, path.join(stagingDir, 'mount-allowlist.json'));
ok('mount-allowlist.json');
items.push('mount-allowlist.json');
} else {
ok('mount-allowlist.json not found — skipping');
}
console.log('');
// ── Manifest ──────────────────────────────────────────────────────────────────
fs.writeFileSync(path.join(stagingDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
// ── Tarball ───────────────────────────────────────────────────────────────────
console.log('Creating archive...');
if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const tarPath = path.join(OUTPUT_DIR, `${BACKUP_NAME}.tar.gz`);
const tarResult = run('tar', ['-czf', tarPath, '-C', stagingBase, BACKUP_NAME]);
if (!tarResult.ok) fail(`tar failed: ${tarResult.output}`);
const sizeMb = (fs.statSync(tarPath).size / 1024 / 1024).toFixed(2);
ok(`${BACKUP_NAME}.tar.gz (${sizeMb} MB)`);
// ── Cleanup ───────────────────────────────────────────────────────────────────
fs.rmSync(stagingBase, { recursive: true, force: true });
console.log('');
console.log('─'.repeat(60));
console.log(` Backup complete: ${tarPath}`);
console.log(` Items: ${items.join(', ')}`);
console.log(` Restore: see docs/sessions/2026-03-16-backup-restore.md`);
console.log('─'.repeat(60));
console.log('');
}
main().catch((err) => {
console.error('Backup failed:', err instanceof Error ? err.message : String(err));
process.exit(1);
});