diff --git a/src/gateway/sync.test.ts b/src/gateway/sync.test.ts index f062ffed7..33bd8dadd 100644 --- a/src/gateway/sync.test.ts +++ b/src/gateway/sync.test.ts @@ -39,7 +39,7 @@ describe('syncToR2', () => { }); describe('sanity checks', () => { - it('returns error when source has no config file', async () => { + it('returns error with diagnostics when config file missing and gateway not running', async () => { const { sandbox, startProcessMock } = createMockSandbox(); startProcessMock .mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n')) @@ -52,6 +52,33 @@ describe('syncToR2', () => { expect(result.success).toBe(false); expect(result.error).toBe('Sync aborted: no config file found'); + expect(result.details).toContain('Gateway process is not running'); + }); + + it('returns error with diagnostics when config file missing but gateway is running', async () => { + const { sandbox, startProcessMock, listProcessesMock } = createMockSandbox(); + listProcessesMock.mockResolvedValue([ + { command: 'start-openclaw.sh', status: 'running', id: 'proc_123' }, + ]); + startProcessMock + .mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n')) + .mockResolvedValueOnce(createMockProcess('', { exitCode: 1 })) // No openclaw.json + .mockResolvedValueOnce(createMockProcess('', { exitCode: 1 })) // No clawdbot.json + // Diagnostic commands: + .mockResolvedValueOnce(createMockProcess('total 0')) // ls /root/.openclaw/ + .mockResolvedValueOnce(createMockProcess('No such file or directory')) // ls R2 backups + .mockResolvedValueOnce(createMockProcess('no .last-sync file')); // cat .last-sync + + const env = createMockEnvWithR2(); + + const result = await syncToR2(sandbox, env); + + expect(result.success).toBe(false); + expect(result.error).toBe('Sync aborted: no config file found'); + expect(result.details).toContain('Gateway running'); + expect(result.details).toContain('proc_123'); + expect(result.details).toContain('Local /root/.openclaw/'); + expect(result.details).toContain('R2 backups'); }); }); diff --git a/src/gateway/sync.ts b/src/gateway/sync.ts index 63808c471..119f19185 100644 --- a/src/gateway/sync.ts +++ b/src/gateway/sync.ts @@ -2,6 +2,7 @@ import type { Sandbox } from '@cloudflare/sandbox'; import type { MoltbotEnv } from '../types'; import { R2_MOUNT_PATH } from '../config'; import { mountR2Storage } from './r2'; +import { findExistingMoltbotProcess } from './process'; import { waitForProcess } from './utils'; export interface SyncResult { @@ -29,6 +30,69 @@ export interface SyncResult { * @param env - Worker environment bindings * @returns SyncResult with success status and optional error details */ +async function runDiagnosticCmd(sandbox: Sandbox, cmd: string): Promise { + const proc = await sandbox.startProcess(cmd); + await waitForProcess(proc, 5000); + const logs = await proc.getLogs(); + return (logs.stdout || '').trim(); +} + +async function diagnoseConfigMissing(sandbox: Sandbox): Promise { + const parts: string[] = []; + + // Check if the gateway process is running + const gateway = await findExistingMoltbotProcess(sandbox); + if (!gateway) { + parts.push('Gateway process is not running — config has not been created yet.'); + return parts.join(' | '); + } + + parts.push(`Gateway running (${gateway.id}, status: ${gateway.status})`); + + // Get gateway logs for clues (did onboard or patching fail?) + try { + const logs = await gateway.getLogs(); + const stderr = logs.stderr?.trim(); + if (stderr) { + parts.push(`Gateway stderr: ${stderr.slice(0, 300)}`); + } + } catch { + // getLogs may not be available on all process types + } + + // Check local config directory contents + try { + const localLs = await runDiagnosticCmd(sandbox, 'ls -la /root/.openclaw/ 2>&1'); + parts.push(`Local /root/.openclaw/: ${localLs.slice(0, 300)}`); + } catch { + parts.push('Local /root/.openclaw/: failed to list'); + } + + // Check R2 backup directory contents + try { + const r2Ls = await runDiagnosticCmd( + sandbox, + `ls -la ${R2_MOUNT_PATH}/openclaw/ 2>&1; echo "---"; ls -la ${R2_MOUNT_PATH}/clawdbot/ 2>&1`, + ); + parts.push(`R2 backups: ${r2Ls.slice(0, 300)}`); + } catch { + parts.push('R2 backups: failed to list'); + } + + // Check R2 mount health (can we actually read from it?) + try { + const mountTest = await runDiagnosticCmd( + sandbox, + `cat ${R2_MOUNT_PATH}/.last-sync 2>&1 || echo "no .last-sync file"`, + ); + parts.push(`R2 .last-sync: ${mountTest.slice(0, 100)}`); + } catch { + parts.push('R2 mount: unresponsive'); + } + + return parts.join(' | '); +} + export async function syncToR2(sandbox: Sandbox, env: MoltbotEnv): Promise { // Check if R2 is configured if (!env.R2_ACCESS_KEY_ID || !env.R2_SECRET_ACCESS_KEY || !env.CF_ACCOUNT_ID) { @@ -54,10 +118,12 @@ export async function syncToR2(sandbox: Sandbox, env: MoltbotEnv): Promise { adminApi.post('/storage/sync', async (c) => { const sandbox = c.get('sandbox'); + // Check if the gateway is running before attempting sync + const gatewayProcess = await findExistingMoltbotProcess(sandbox); + if (!gatewayProcess) { + return c.json( + { + success: false, + error: 'Gateway not running yet, nothing to sync', + }, + 409, + ); + } + const result = await syncToR2(sandbox, c.env); if (result.success) { @@ -257,7 +269,9 @@ adminApi.post('/storage/sync', async (c) => { lastSync: result.lastSync, }); } else { - const status = result.error?.includes('not configured') ? 400 : 500; + const isClientError = + result.error?.includes('not configured') || result.error?.includes('Sync aborted'); + const status = isClientError ? 400 : 500; return c.json( { success: false,