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
37 changes: 28 additions & 9 deletions src/gateway/sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,38 @@ describe('syncToR2', () => {
});

describe('sanity checks', () => {
it('returns error when source has no config file', async () => {
it('returns error when config directories are empty', async () => {
const { sandbox, startProcessMock } = createMockSandbox();
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 either
.mockResolvedValueOnce(createMockProcess('')) // Empty .openclaw dir
.mockResolvedValueOnce(createMockProcess('')); // Empty .clawdbot dir

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.error).toBe('Sync aborted: no config data found');
});

it('syncs when config dir has content but no openclaw.json (exitCode null)', async () => {
const { sandbox, startProcessMock } = createMockSandbox();
const timestamp = '2026-02-09T12:00:00+00:00';

// ls returns directory content even when exitCode is null (#212)
startProcessMock
.mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n'))
.mockResolvedValueOnce(createMockProcess('agents\n', { exitCode: undefined as any }))
.mockResolvedValueOnce(createMockProcess(''))
.mockResolvedValueOnce(createMockProcess(timestamp));

const env = createMockEnvWithR2();

const result = await syncToR2(sandbox, env);

expect(result.success).toBe(true);
expect(result.lastSync).toBe(timestamp);
});
});

Expand All @@ -60,10 +79,10 @@ describe('syncToR2', () => {
const { sandbox, startProcessMock } = createMockSandbox();
const timestamp = '2026-01-27T12:00:00+00:00';

// Calls: mount check, check openclaw.json, rsync, cat timestamp
// Calls: mount check, ls config dir, rsync, cat timestamp
startProcessMock
.mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n'))
.mockResolvedValueOnce(createMockProcess('ok'))
.mockResolvedValueOnce(createMockProcess('openclaw.json\n'))
.mockResolvedValueOnce(createMockProcess(''))
.mockResolvedValueOnce(createMockProcess(timestamp));

Expand All @@ -78,10 +97,10 @@ describe('syncToR2', () => {
it('returns error when rsync fails (no timestamp created)', async () => {
const { sandbox, startProcessMock } = createMockSandbox();

// Calls: mount check, check openclaw.json, rsync (fails), cat timestamp (empty)
// Calls: mount check, ls config dir, rsync (fails), cat timestamp (empty)
startProcessMock
.mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n'))
.mockResolvedValueOnce(createMockProcess('ok'))
.mockResolvedValueOnce(createMockProcess('openclaw.json\n'))
.mockResolvedValueOnce(createMockProcess('', { exitCode: 1 }))
.mockResolvedValueOnce(createMockProcess(''));

Expand All @@ -99,7 +118,7 @@ describe('syncToR2', () => {

startProcessMock
.mockResolvedValueOnce(createMockProcess('s3fs on /data/moltbot type fuse.s3fs\n'))
.mockResolvedValueOnce(createMockProcess('ok'))
.mockResolvedValueOnce(createMockProcess('openclaw.json\n'))
.mockResolvedValueOnce(createMockProcess(''))
.mockResolvedValueOnce(createMockProcess(timestamp));

Expand Down
27 changes: 18 additions & 9 deletions src/gateway/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,32 @@ export async function syncToR2(sandbox: Sandbox, env: MoltbotEnv): Promise<SyncR
return { success: false, error: 'Failed to mount R2 storage' };
}

// Determine which config directory exists
// Check new path first, fall back to legacy
// Use exit code (0 = exists) rather than stdout parsing to avoid log-flush races
// Determine which config directory has content worth backing up.
// Uses stdout-based detection (ls output) instead of exitCode, because the
// sandbox API sometimes returns null for exitCode even on success (#212).
let configDir = '/root/.openclaw';
try {
const checkNew = await sandbox.startProcess('test -f /root/.openclaw/openclaw.json');
const checkNew = await sandbox.startProcess('ls -A /root/.openclaw/ 2>/dev/null | head -1');
await waitForProcess(checkNew, 5000);
if (checkNew.exitCode !== 0) {
const checkLegacy = await sandbox.startProcess('test -f /root/.clawdbot/clawdbot.json');
const checkNewLogs = await checkNew.getLogs();
const hasNewContent = (checkNewLogs.stdout || '').trim().length > 0;

if (!hasNewContent) {
const checkLegacy = await sandbox.startProcess(
'ls -A /root/.clawdbot/ 2>/dev/null | head -1',
);
await waitForProcess(checkLegacy, 5000);
if (checkLegacy.exitCode === 0) {
const checkLegacyLogs = await checkLegacy.getLogs();
const hasLegacyContent = (checkLegacyLogs.stdout || '').trim().length > 0;

if (hasLegacyContent) {
configDir = '/root/.clawdbot';
} else {
return {
success: false,
error: 'Sync aborted: no config file found',
details: 'Neither openclaw.json nor clawdbot.json found in config directory.',
error: 'Sync aborted: no config data found',
details:
'Neither /root/.openclaw/ nor /root/.clawdbot/ contain data to back up.',
};
}
}
Expand Down