diff --git a/examples/opencode/src/backup.ts b/examples/opencode/src/backup.ts new file mode 100644 index 00000000..832b5350 --- /dev/null +++ b/examples/opencode/src/backup.ts @@ -0,0 +1,178 @@ +import { collectFile, type Sandbox } from '@cloudflare/sandbox'; + +const OPENCODE_STORAGE_DIR = '~/.local/share/opencode/storage'; +const BACKUP_KEY_PREFIX = 'opencode-backup'; + +function uint8ArrayToBase64(bytes: Uint8Array): string { + const CHUNK_SIZE = 0x8000; + const chunks: string[] = []; + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + const chunk = bytes.subarray(i, i + CHUNK_SIZE); + chunks.push(String.fromCharCode.apply(null, chunk as unknown as number[])); + } + return btoa(chunks.join('')); +} + +export async function restoreOpencodeBackup( + sandbox: Sandbox, + bucket: R2Bucket +): Promise { + try { + console.log('[RESTORE] Fetching backup from R2...'); + const key = BACKUP_KEY_PREFIX; + const object = await bucket.get(key); + + if (!object) { + console.log('[RESTORE] Backup object not found in R2'); + return false; + } + + console.log('[RESTORE] Backup found, downloading...'); + const data = await object.arrayBuffer(); + const bytes = new Uint8Array(data); + console.log('[RESTORE] Backup size:', bytes.length, 'bytes'); + + const base64Data = uint8ArrayToBase64(bytes); + + const chunkSize = 100000; + if (base64Data.length > chunkSize) { + console.log('[RESTORE] Large backup, writing to file...'); + await sandbox.writeFile('/tmp/opencode-backup.b64', base64Data, { + encoding: 'utf-8' + }); + console.log('[RESTORE] Decoding base64...'); + const decodeResult = await sandbox.exec( + 'base64 -d /tmp/opencode-backup.b64 > /tmp/opencode-backup.tar.gz' + ); + if (decodeResult.exitCode !== 0) { + console.error('[RESTORE] Base64 decode failed:', decodeResult.stderr); + await sandbox + .exec('rm -f /tmp/opencode-backup.b64 /tmp/opencode-backup.tar.gz') + .catch(() => {}); + return false; + } + await sandbox.exec('rm -f /tmp/opencode-backup.b64'); + } else { + console.log('[RESTORE] Writing base64 data to file...'); + await sandbox.writeFile('/tmp/opencode-backup.b64', base64Data, { + encoding: 'utf-8' + }); + console.log('[RESTORE] Decoding base64...'); + const decodeResult = await sandbox.exec( + 'base64 -d /tmp/opencode-backup.b64 > /tmp/opencode-backup.tar.gz' + ); + if (decodeResult.exitCode !== 0) { + console.error('[RESTORE] Base64 decode failed:', decodeResult.stderr); + await sandbox + .exec('rm -f /tmp/opencode-backup.b64 /tmp/opencode-backup.tar.gz') + .catch(() => {}); + return false; + } + await sandbox.exec('rm -f /tmp/opencode-backup.b64'); + } + + console.log('[RESTORE] Creating directory structure...'); + await sandbox.exec('mkdir -p ~/.local/share/opencode'); + + console.log('[RESTORE] Extracting archive...'); + const extractResult = await sandbox.exec( + 'tar -xzf /tmp/opencode-backup.tar.gz -C ~/.local/share/opencode' + ); + + if (extractResult.exitCode !== 0) { + console.error( + '[RESTORE] Archive extraction failed:', + extractResult.stderr + ); + await sandbox.exec('rm -f /tmp/opencode-backup.tar.gz').catch(() => {}); + return false; + } + + console.log('[RESTORE] Cleanup...'); + await sandbox.exec('rm -f /tmp/opencode-backup.tar.gz'); + + console.log('[RESTORE] Restore completed successfully'); + return true; + } catch (error) { + console.error('[RESTORE] Restore failed with error:', error); + await sandbox + .exec('rm -f /tmp/opencode-backup.b64 /tmp/opencode-backup.tar.gz') + .catch(() => {}); + return false; + } +} + +export async function backupOpencodeStorage( + sandbox: Sandbox, + bucket: R2Bucket +): Promise { + try { + console.log('[BACKUP] Starting backup of OpenCode storage...'); + + // Check if storage directory exists + const dirCheck = await sandbox.exec( + 'if [ -d ~/.local/share/opencode/storage ]; then echo exists; else echo missing; fi' + ); + + if (dirCheck.stdout.trim() !== 'exists') { + console.log('[BACKUP] Storage directory does not exist, skipping backup'); + return; + } + + // Check if storage has content + const contentCheck = await sandbox.exec( + 'if [ -n "$(ls -A ~/.local/share/opencode/storage 2>/dev/null)" ]; then echo has_content; else echo empty; fi' + ); + + if (contentCheck.stdout.trim() !== 'has_content') { + console.log('[BACKUP] Storage directory is empty, skipping backup'); + return; + } + + console.log('[BACKUP] Creating archive...'); + const archiveResult = await sandbox.exec( + `tar -czf /tmp/opencode-backup.tar.gz -C ~/.local/share/opencode storage` + ); + + if (archiveResult.exitCode !== 0) { + console.error( + '[BACKUP] Failed to create archive, exit code:', + archiveResult.exitCode, + 'stderr:', + archiveResult.stderr + ); + return; + } + + const checkResult = await sandbox.exec( + 'test -f /tmp/opencode-backup.tar.gz && echo exists || echo missing' + ); + + if (checkResult.stdout.trim() !== 'exists') { + console.error('[BACKUP] Archive file was not created'); + return; + } + + console.log('[BACKUP] Reading archive file...'); + const fileStream = await sandbox.readFileStream( + '/tmp/opencode-backup.tar.gz' + ); + const { content } = await collectFile(fileStream); + + if (!(content instanceof Uint8Array)) { + console.error('[BACKUP] Failed to read archive file content'); + return; + } + + console.log('[BACKUP] Uploading to R2, size:', content.length, 'bytes'); + const key = BACKUP_KEY_PREFIX; + await bucket.put(key, content); + + console.log('[BACKUP] Backup completed successfully'); + + await sandbox.exec('rm -f /tmp/opencode-backup.tar.gz'); + } catch (error) { + console.error('[BACKUP] Backup failed with error:', error); + await sandbox.exec('rm -f /tmp/opencode-backup.tar.gz').catch(() => {}); + } +} diff --git a/examples/opencode/src/index.ts b/examples/opencode/src/index.ts index 452c5aad..c39e572e 100644 --- a/examples/opencode/src/index.ts +++ b/examples/opencode/src/index.ts @@ -13,6 +13,8 @@ import { } from '@cloudflare/sandbox/opencode'; import type { Config, OpencodeClient } from '@opencode-ai/sdk'; +import { backupOpencodeStorage, restoreOpencodeBackup } from './backup'; + export { Sandbox } from '@cloudflare/sandbox'; const getConfig = (env: Env): Config => ({ @@ -25,14 +27,149 @@ const getConfig = (env: Env): Config => ({ } }); +async function ensureOpencodeStorageRestored( + sandbox: ReturnType, + bucket: R2Bucket | undefined +): Promise { + if (!bucket) { + console.log('[RESTORE] No SESSIONS_BUCKET configured, skipping restore'); + return; + } + + console.log('[RESTORE] Starting restore check...'); + + // Check if storage directory exists and has content FIRST + // We restore based on storage state, not server state + const dirCheck = await sandbox.exec( + 'if [ -d ~/.local/share/opencode/storage ]; then echo exists; else echo missing; fi' + ); + const dirExists = dirCheck.stdout.trim() === 'exists'; + console.log('[RESTORE] Storage directory exists:', dirExists); + + // Check if storage directory has content + let hasContent = false; + if (dirExists) { + const contentCheck = await sandbox.exec( + 'if [ -n "$(ls -A ~/.local/share/opencode/storage 2>/dev/null)" ]; then echo has_content; else echo empty; fi' + ); + hasContent = contentCheck.stdout.trim() === 'has_content'; + console.log('[RESTORE] Storage directory has content:', hasContent); + } + + // If storage exists with content, no restore needed + if (dirExists && hasContent) { + console.log( + '[RESTORE] Storage directory exists with content, no restore needed' + ); + return; + } + + // Check if OpenCode server is running - if so, warn but still attempt restore + const processes = await sandbox.listProcesses(); + + const hasOpencodeProcess = processes.some( + (proc) => + (proc.status === 'starting' || proc.status === 'running') && + proc.command.includes('opencode serve') + ); + + if (!dirExists || !hasContent) { + // If server is running but storage is missing, we need to stop it before restoring + // Otherwise the server won't see the restored data + if (hasOpencodeProcess) { + console.log( + '[RESTORE] Server is running but storage is missing/empty. Stopping server to restore...' + ); + const opencodeProc = processes.find( + (proc) => + (proc.status === 'starting' || proc.status === 'running') && + proc.command.includes('opencode serve') + ); + if (opencodeProc) { + await sandbox.killProcess(opencodeProc.id, 'SIGTERM'); + console.log('[RESTORE] Server stopped'); + } + } + console.log( + '[RESTORE] Storage missing or empty, attempting restore from backup...' + ); + + // Remove existing empty directory if it exists (OpenCode might have created it) + if (dirExists && !hasContent) { + console.log('[RESTORE] Removing empty storage directory...'); + await sandbox.exec('rm -rf ~/.local/share/opencode/storage'); + } + + // Check if backup exists in R2 + const backupCheck = await bucket.get('opencode-backup'); + if (!backupCheck) { + console.log('[RESTORE] No backup found in R2, skipping restore'); + return; + } + + console.log('[RESTORE] Backup found, restoring...'); + const restored = await restoreOpencodeBackup(sandbox, bucket); + + if (restored) { + // Verify restore succeeded + const verifyDirCheck = await sandbox.exec( + 'if [ -d ~/.local/share/opencode/storage ]; then echo exists; else echo missing; fi' + ); + const verifyContentCheck = await sandbox.exec( + 'if [ -n "$(ls -A ~/.local/share/opencode/storage 2>/dev/null)" ]; then echo has_content; else echo empty; fi' + ); + + if ( + verifyDirCheck.stdout.trim() === 'exists' && + verifyContentCheck.stdout.trim() === 'has_content' + ) { + console.log('[RESTORE] Storage restored successfully'); + } else { + console.error( + '[RESTORE] Restore completed but verification failed - dir:', + verifyDirCheck.stdout.trim(), + 'content:', + verifyContentCheck.stdout.trim() + ); + } + } else { + console.error( + '[RESTORE] Restore function returned false (check restore logs for details)' + ); + } + } else { + console.log( + '[RESTORE] Storage directory exists with content, no restore needed' + ); + } +} + export default { - async fetch(request: Request, env: Env): Promise { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext + ): Promise { const url = new URL(request.url); const sandbox = getSandbox(env.Sandbox, 'opencode'); + // Restore OpenCode backup if needed (before server starts) + await ensureOpencodeStorageRestored(sandbox, env.SESSIONS_BUCKET); + // Programmatic SDK test endpoint if (request.method === 'POST' && url.pathname === '/api/test') { - return handleSdkTest(sandbox, env); + const response = await handleSdkTest(sandbox, env); + + // Backup OpenCode storage after operations (in background, non-blocking) + if (env.SESSIONS_BUCKET) { + ctx.waitUntil( + backupOpencodeStorage(sandbox, env.SESSIONS_BUCKET).catch((err) => + console.error('Failed to backup OpenCode storage:', err) + ) + ); + } + + return response; } // Everything else: Web UI proxy @@ -40,7 +177,23 @@ export default { directory: '/home/user/agents', config: getConfig(env) }); - return proxyToOpencode(request, sandbox, server); + + const response = await proxyToOpencode(request, sandbox, server); + + // Backup OpenCode storage after write operations (in background, non-blocking) + // Only backup on POST/PUT/PATCH/DELETE to avoid excessive backups + const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes( + request.method + ); + if (env.SESSIONS_BUCKET && isWriteOperation) { + ctx.waitUntil( + backupOpencodeStorage(sandbox, env.SESSIONS_BUCKET).catch((err) => + console.error('Failed to backup OpenCode storage:', err) + ) + ); + } + + return response; } }; diff --git a/examples/opencode/worker-configuration.d.ts b/examples/opencode/worker-configuration.d.ts index 2fa058a2..97244f4a 100644 --- a/examples/opencode/worker-configuration.d.ts +++ b/examples/opencode/worker-configuration.d.ts @@ -9,6 +9,7 @@ declare namespace Cloudflare { interface Env { ANTHROPIC_API_KEY: string; Sandbox: DurableObjectNamespace; + SESSIONS_BUCKET: R2Bucket; } } interface Env extends Cloudflare.Env {} diff --git a/examples/opencode/wrangler.jsonc b/examples/opencode/wrangler.jsonc index eed7812b..907cb42a 100644 --- a/examples/opencode/wrangler.jsonc +++ b/examples/opencode/wrangler.jsonc @@ -61,5 +61,11 @@ "new_sqlite_classes": ["Sandbox"], "tag": "v1" } + ], + "r2_buckets": [ + { + "binding": "SESSIONS_BUCKET", + "bucket_name": "opencode-sessions" + } ] }