Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions examples/opencode/src/backup.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>,
bucket: R2Bucket
): Promise<boolean> {
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<unknown>,
bucket: R2Bucket
): Promise<void> {
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(() => {});
}
}
159 changes: 156 additions & 3 deletions examples/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand All @@ -25,22 +27,173 @@ const getConfig = (env: Env): Config => ({
}
});

async function ensureOpencodeStorageRestored(
sandbox: ReturnType<typeof getSandbox>,
bucket: R2Bucket | undefined
): Promise<void> {
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<Response> {
async fetch(
request: Request,
env: Env,
ctx: ExecutionContext
): Promise<Response> {
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
const server = await createOpencodeServer(sandbox, {
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;
}
};

Expand Down
1 change: 1 addition & 0 deletions examples/opencode/worker-configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ declare namespace Cloudflare {
interface Env {
ANTHROPIC_API_KEY: string;
Sandbox: DurableObjectNamespace<import("./src/index").Sandbox>;
SESSIONS_BUCKET: R2Bucket;
}
}
interface Env extends Cloudflare.Env {}
Expand Down
6 changes: 6 additions & 0 deletions examples/opencode/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,11 @@
"new_sqlite_classes": ["Sandbox"],
"tag": "v1"
}
],
"r2_buckets": [
{
"binding": "SESSIONS_BUCKET",
"bucket_name": "opencode-sessions"
}
]
}