From f4be6d1da3322739cfceecbff04a69aa53df4ed7 Mon Sep 17 00:00:00 2001 From: Isaac Rowntree Date: Mon, 9 Feb 2026 10:20:58 +1100 Subject: [PATCH] fix: add gateway check and diagnostics to sync endpoint The manual sync endpoint was returning 500 errors when triggered before the gateway had started (or when config files were missing). This adds: - Gateway-running check before attempting sync (returns 409 if not ready) - Diagnostic info when config files are missing: checks gateway status, gateway logs, local config directory contents, R2 backup contents, and R2 mount health - Correct HTTP status codes (400 for client errors, not 500) Co-Authored-By: Claude Opus 4.6 --- src/gateway/sync.test.ts | 29 ++++++++++++++++- src/gateway/sync.ts | 68 +++++++++++++++++++++++++++++++++++++++- src/routes/api.ts | 16 +++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) 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,