Skip to content
Merged
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
60 changes: 59 additions & 1 deletion api/src/services/__tests__/openclawGatewayClient.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,64 @@ describe('openclawGatewayClient', () => {
expect(result).toEqual([]);
});

it('should return jobs via WS RPC fallback when tool invoke paths are unavailable', async () => {
jest.useRealTimers();
const deviceAuth = configureDeviceAuth();

// Both /tools/invoke attempts return 404 (non-retryable), forcing WS RPC fallback.
global.fetch.mockResolvedValue({
ok: false,
status: 404,
text: async () =>
JSON.stringify({ ok: false, error: { type: 'not_found', message: 'Tool not available' } }),
});

mockWebSocket.send.mockImplementation((payload) => {
const message = JSON.parse(payload);
if (message.method === 'connect') {
originalSetTimeout(() => {
emitWs(
'message',
JSON.stringify({ type: 'res', id: message.id, ok: true, payload: {} }),
);
}, 0);
return;
}
if (message.method === 'cron.list') {
originalSetTimeout(() => {
emitWs(
'message',
JSON.stringify({
type: 'res',
id: message.id,
ok: true,
payload: { jobs: [{ id: 'ws-1' }, { id: 'ws-2' }] },
}),
);
}, 0);
}
});

const promise = cronList({ wsRpcOptions: { deviceAuth } });

// cronList performs two invoke-tool attempts first; emit WS handshake/events after a tick.
originalSetTimeout(() => {
emitWs('open');
emitWs(
'message',
JSON.stringify({
type: 'event',
event: 'connect.challenge',
payload: { nonce: 'nonce-123' },
}),
);
}, 10);

await expect(promise).resolves.toEqual([{ id: 'ws-1' }, { id: 'ws-2' }]);
expect(getFileContent).not.toHaveBeenCalled();
jest.useFakeTimers({ legacyFakeTimers: false });
}, 10000);

it('should fallback to jobs.json when cron.list fails', async () => {
const error = new Error('Tool failed');
global.fetch.mockRejectedValueOnce(error);
Expand All @@ -933,7 +991,7 @@ describe('openclawGatewayClient', () => {
expect(result).toEqual([{ id: '1' }, { id: '2' }]);
expect(getFileContent).toHaveBeenCalledWith('/cron/jobs.json');
expect(logger.warn).toHaveBeenCalledWith(
'cron.list tool invocation failed, trying jobs.json fallback',
'cron.list tool invocation failed, trying WS RPC fallback',
expect.any(Object),
);
});
Expand Down
68 changes: 51 additions & 17 deletions api/src/services/openclawGatewayClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -611,11 +611,12 @@ async function sessionsHistory({ sessionKey, limit, includeTools } = {}) {

/**
* List cron jobs from the OpenClaw Gateway scheduler.
* Tries the cron.list tool first (via /tools/invoke), then falls back to
* reading the persisted jobs.json from the workspace service.
* Tries /tools/invoke first (cron, then cron.list), then native WS RPC
* (cron.list), and finally falls back to persisted jobs.json via workspace.
* @returns {Promise<Array>} Array of cron job objects
*/
async function cronList() {
async function cronList(options = {}) {
const wsRpcOptions = options?.wsRpcOptions || {};
// Attempt 1: Try modern cron tool contract via /tools/invoke
// Tool schema: tool='cron' with args={ action: 'list', includeDisabled: true }
try {
Expand All @@ -631,13 +632,15 @@ async function cronList() {
}
} catch (error) {
if (error.code === 'SERVICE_NOT_CONFIGURED' || error.code === 'SERVICE_UNAVAILABLE') {
logger.warn('OpenClaw gateway not available for cron list, returning empty array');
return [];
logger.warn('OpenClaw gateway not available for cron tool invoke, trying legacy cron.list', {
code: error.code,
});
} else {
logger.warn('cron tool invocation failed, trying legacy cron.list', {
error: error.message,
code: error.code,
});
}
logger.warn('cron tool invocation failed, trying legacy cron.list', {
error: error.message,
code: error.code,
});
}

// Attempt 2: Backward-compatible legacy tool name
Expand All @@ -654,17 +657,48 @@ async function cronList() {
}
} catch (error) {
if (error.code === 'SERVICE_NOT_CONFIGURED' || error.code === 'SERVICE_UNAVAILABLE') {
logger.warn('OpenClaw gateway not available for cron.list, returning empty array');
return [];
logger.warn('OpenClaw gateway not available for cron.list invoke, trying WS RPC fallback', {
code: error.code,
});
} else {
// Log and fall through to WS RPC fallback
logger.warn('cron.list tool invocation failed, trying WS RPC fallback', {
error: error.message,
code: error.code,
});
}
}

// Attempt 3: Gateway native WS RPC (works even when /tools/invoke doesn't expose cron tools)
try {
const result = await gatewayWsRpc('cron.list', { includeDisabled: true }, wsRpcOptions);
if (result) {
const jobs = extractJobsArray(result);
if (jobs.length > 0) {
logger.info('cron.list returned jobs via WS RPC', {
count: jobs.length,
});
return jobs;
Comment thread
moltar-bot marked this conversation as resolved.
}
}
} catch (error) {
if (error.code === 'SERVICE_NOT_CONFIGURED' || error.code === 'SERVICE_UNAVAILABLE') {
logger.warn('OpenClaw gateway not available for cron.list WS RPC, trying jobs.json fallback', {
code: error.code ?? error.rpcCode,
rpcCode: error.rpcCode,
rpcDetails: error.rpcDetails,
});
} else {
logger.warn('cron.list WS RPC failed, trying jobs.json fallback', {
error: error.message,
code: error.code ?? error.rpcCode,
rpcCode: error.rpcCode,
rpcDetails: error.rpcDetails,
});
}
// Log and fall through to fallback
logger.warn('cron.list tool invocation failed, trying jobs.json fallback', {
error: error.message,
code: error.code,
});
}

// Attempt 3: Read the persisted jobs.json from the workspace service
// Attempt 4: Read the persisted jobs.json from the workspace service
// OpenClaw stores cron jobs at ~/.openclaw/cron/jobs.json on the gateway host.
// In containerized setups this is typically at /home/node/.openclaw/cron/jobs.json
// which may be accessible via the workspace service.
Expand Down
Loading