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
29 changes: 28 additions & 1 deletion src/gateway/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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');
});
});

Expand Down
68 changes: 67 additions & 1 deletion src/gateway/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string> {
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<string> {
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<SyncResult> {
// Check if R2 is configured
if (!env.R2_ACCESS_KEY_ID || !env.R2_SECRET_ACCESS_KEY || !env.CF_ACCOUNT_ID) {
Expand All @@ -54,10 +118,12 @@ export async function syncToR2(sandbox: Sandbox, env: MoltbotEnv): Promise<SyncR
if (checkLegacy.exitCode === 0) {
configDir = '/root/.clawdbot';
} else {
// Gather diagnostics to explain why config is missing
const diagnostics = await diagnoseConfigMissing(sandbox);
return {
success: false,
error: 'Sync aborted: no config file found',
details: 'Neither openclaw.json nor clawdbot.json found in config directory.',
details: diagnostics,
};
}
}
Expand Down
16 changes: 15 additions & 1 deletion src/routes/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,18 @@ adminApi.get('/storage', async (c) => {
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) {
Expand All @@ -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,
Expand Down