diff --git a/api/src/services/__tests__/openclawGatewayClient.test.js b/api/src/services/__tests__/openclawGatewayClient.test.js index e7e3dbc..496ff95 100644 --- a/api/src/services/__tests__/openclawGatewayClient.test.js +++ b/api/src/services/__tests__/openclawGatewayClient.test.js @@ -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); @@ -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), ); }); diff --git a/api/src/services/openclawGatewayClient.js b/api/src/services/openclawGatewayClient.js index 3b92af9..a3d7ba0 100644 --- a/api/src/services/openclawGatewayClient.js +++ b/api/src/services/openclawGatewayClient.js @@ -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 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 { @@ -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 @@ -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; + } + } + } 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.