ralphblaster-agent 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ const axios = require('axios');
2
+ const config = require('./config');
3
+ const logger = require('./logger');
4
+ const packageJson = require('../package.json');
5
+
6
+ // Agent version from package.json
7
+ const AGENT_VERSION = packageJson.version;
8
+
9
+ // Timeout constants
10
+ const LONG_POLLING_TIMEOUT_MS = 65000; // 65s for long polling (server max 60s + buffer)
11
+ const REGULAR_API_TIMEOUT_MS = 15000; // 15s for regular API calls
12
+
13
+ class ApiClient {
14
+ constructor() {
15
+ this.client = axios.create({
16
+ baseURL: config.apiUrl,
17
+ headers: {
18
+ 'Content-Type': 'application/json'
19
+ },
20
+ timeout: REGULAR_API_TIMEOUT_MS
21
+ });
22
+
23
+ // Add Authorization header via interceptor to prevent token exposure in logs
24
+ this.client.interceptors.request.use((requestConfig) => {
25
+ requestConfig.headers.Authorization = `Bearer ${config.apiToken}`;
26
+ requestConfig.headers['X-Agent-Version'] = AGENT_VERSION;
27
+ return requestConfig;
28
+ });
29
+
30
+ // Sanitize errors to prevent token leakage in stack traces
31
+ this.client.interceptors.response.use(
32
+ response => response,
33
+ error => {
34
+ // Remove auth header from error config before it gets logged
35
+ if (error.config && error.config.headers) {
36
+ error.config.headers.Authorization = 'Bearer [REDACTED]';
37
+ }
38
+ // Also redact from response config if present
39
+ if (error.response && error.response.config && error.response.config.headers) {
40
+ error.response.config.headers.Authorization = 'Bearer [REDACTED]';
41
+ }
42
+ return Promise.reject(error);
43
+ }
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Poll for next available job (with long polling)
49
+ * @returns {Promise<Object|null>} Job object or null if no jobs available
50
+ */
51
+ async getNextJob() {
52
+ try {
53
+ logger.debug('Long polling for next job (timeout: 30s)...');
54
+ const response = await this.client.get('/api/v1/ralph/jobs/next', {
55
+ params: { timeout: 30 }, // Server waits up to 30s for job
56
+ timeout: LONG_POLLING_TIMEOUT_MS // Client waits up to 65s
57
+ });
58
+
59
+ if (response.status === 204) {
60
+ // No jobs available
61
+ return null;
62
+ }
63
+
64
+ if (response.data && response.data.success) {
65
+ const job = response.data.job;
66
+
67
+ // Validate job object
68
+ const validationError = this.validateJob(job);
69
+ if (validationError) {
70
+ logger.error(`Invalid job received from API: ${validationError}`);
71
+ return null;
72
+ }
73
+
74
+ logger.info(`Claimed job #${job.id} - ${job.task_title}`);
75
+
76
+ // Log full job details for debugging (especially useful in multi-agent scenarios)
77
+ logger.debug('Job details:', {
78
+ id: job.id,
79
+ job_type: job.job_type,
80
+ task_title: job.task_title,
81
+ created_at: job.created_at,
82
+ user_id: job.user_id,
83
+ project_id: job.project?.id,
84
+ project_name: job.project?.name,
85
+ has_prompt: !!job.prompt,
86
+ prompt_length: job.prompt?.length || 0
87
+ });
88
+
89
+ return job;
90
+ }
91
+
92
+ logger.warn('Unexpected response from API', response.data);
93
+ return null;
94
+ } catch (error) {
95
+ if (error.response?.status === 204) {
96
+ // No jobs available
97
+ return null;
98
+ }
99
+
100
+ if (error.response?.status === 403) {
101
+ logger.error('API token lacks ralph_agent permission');
102
+ throw new Error('Invalid API token permissions');
103
+ }
104
+
105
+ if (error.code === 'ECONNREFUSED') {
106
+ logger.error(`Cannot connect to API at ${config.apiUrl}`);
107
+ return null;
108
+ }
109
+
110
+ logger.error('Error fetching next job', error.message);
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Update job status to running
117
+ * @param {number} jobId - Job ID
118
+ */
119
+ async markJobRunning(jobId) {
120
+ try {
121
+ await this.client.patch(`/api/v1/ralph/jobs/${jobId}`, {
122
+ status: 'running'
123
+ });
124
+ logger.info(`Job #${jobId} marked as running`);
125
+ } catch (error) {
126
+ logger.error(`Error marking job #${jobId} as running`, error.message);
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Validate and truncate output to prevent excessive data transmission
133
+ * @param {string} output - Output string to validate
134
+ * @param {number} maxSize - Maximum size in bytes (default 10MB)
135
+ * @returns {string} Validated/truncated output
136
+ */
137
+ validateOutput(output, maxSize = 10 * 1024 * 1024) {
138
+ if (typeof output !== 'string') {
139
+ return '';
140
+ }
141
+
142
+ if (output.length > maxSize) {
143
+ logger.warn(`Output truncated from ${output.length} to ${maxSize} bytes`);
144
+ return output.substring(0, maxSize) + '\n\n[OUTPUT TRUNCATED - EXCEEDED MAX SIZE]';
145
+ }
146
+
147
+ return output;
148
+ }
149
+
150
+ /**
151
+ * Update job status to completed
152
+ * @param {number} jobId - Job ID
153
+ * @param {Object} result - Job result containing output, summary, etc.
154
+ */
155
+ async markJobCompleted(jobId, result) {
156
+ try {
157
+ const payload = {
158
+ status: 'completed',
159
+ output: this.validateOutput(result.output || ''),
160
+ execution_time_ms: result.executionTimeMs
161
+ };
162
+
163
+ // Add job-type specific fields with validation
164
+ if (result.prdContent) {
165
+ payload.prd_content = this.validateOutput(result.prdContent);
166
+ }
167
+ if (result.summary) {
168
+ payload.summary = this.validateOutput(result.summary, 10000); // 10KB max for summary
169
+ }
170
+ if (result.branchName) {
171
+ // Validate branch name format
172
+ if (!/^[a-zA-Z0-9/_-]{1,200}$/.test(result.branchName)) {
173
+ logger.warn('Invalid branch name format, omitting from payload');
174
+ } else {
175
+ payload.branch_name = result.branchName;
176
+ }
177
+ }
178
+
179
+ // Add git activity metadata
180
+ if (result.gitActivity) {
181
+ payload.git_activity = {
182
+ commit_count: result.gitActivity.commitCount || 0,
183
+ last_commit: result.gitActivity.lastCommit || null,
184
+ changes: result.gitActivity.changes || null,
185
+ pushed_to_remote: result.gitActivity.pushedToRemote || false,
186
+ has_uncommitted_changes: result.gitActivity.hasUncommittedChanges || false
187
+ };
188
+ }
189
+
190
+ await this.client.patch(`/api/v1/ralph/jobs/${jobId}`, payload);
191
+ logger.info(`Job #${jobId} marked as completed`);
192
+ } catch (error) {
193
+ logger.error(`Error marking job #${jobId} as completed`, error.message);
194
+ throw error;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Update job status to failed
200
+ * @param {number} jobId - Job ID
201
+ * @param {Error|string} error - Error object or error message
202
+ * @param {string} partialOutput - Partial output if any
203
+ */
204
+ async markJobFailed(jobId, error, partialOutput = null) {
205
+ try {
206
+ // Support both Error objects and string messages for backward compatibility
207
+ const errorMessage = typeof error === 'string' ? error : error.message || String(error);
208
+ const payload = {
209
+ status: 'failed',
210
+ error: errorMessage,
211
+ output: partialOutput || error.partialOutput || null
212
+ };
213
+
214
+ // Add error categorization if available (from enriched Error objects)
215
+ if (typeof error === 'object' && error !== null) {
216
+ if (error.category) {
217
+ payload.error_category = error.category;
218
+ }
219
+ if (error.technicalDetails) {
220
+ payload.error_details = error.technicalDetails;
221
+ }
222
+ }
223
+
224
+ await this.client.patch(`/api/v1/ralph/jobs/${jobId}`, payload);
225
+ logger.info(`Job #${jobId} marked as failed with category: ${payload.error_category || 'unknown'}`);
226
+ } catch (error) {
227
+ logger.error(`Error marking job #${jobId} as failed`, error.message);
228
+ // Don't throw - we want to continue even if this fails
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Send heartbeat to keep job alive (updates claimed_at)
234
+ * @param {number} jobId - Job ID
235
+ */
236
+ async sendHeartbeat(jobId) {
237
+ try {
238
+ await this.client.patch(`/api/v1/ralph/jobs/${jobId}`, {
239
+ status: 'running',
240
+ heartbeat: true // Distinguish from initial markJobRunning call
241
+ });
242
+ logger.debug(`Heartbeat sent for job #${jobId}`);
243
+ } catch (error) {
244
+ logger.warn(`Error sending heartbeat for job #${jobId}`, error.message);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Send progress update for job (streaming Claude output)
250
+ * @param {number} jobId - Job ID
251
+ * @param {string} chunk - Output chunk
252
+ */
253
+ async sendProgress(jobId, chunk) {
254
+ try {
255
+ await this.client.patch(`/api/v1/ralph/jobs/${jobId}/progress`, {
256
+ chunk: chunk
257
+ });
258
+ logger.debug(`Progress sent for job #${jobId}`);
259
+ } catch (error) {
260
+ logger.warn(`Error sending progress for job #${jobId}`, error.message);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Update job metadata (best-effort, doesn't fail job if unsuccessful)
266
+ * @param {number} jobId - Job ID
267
+ * @param {Object} metadata - Metadata object to merge
268
+ */
269
+ async updateJobMetadata(jobId, metadata) {
270
+ try {
271
+ await this.client.patch(`/api/v1/ralph/jobs/${jobId}/metadata`, {
272
+ metadata: metadata
273
+ });
274
+ logger.debug(`Metadata updated for job #${jobId}`, metadata);
275
+ } catch (error) {
276
+ logger.warn(`Error updating metadata for job #${jobId}`, error.message);
277
+ // Don't throw - metadata updates are best-effort
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Validate job object from API
283
+ * @param {Object} job - Job object to validate
284
+ * @returns {string|null} Error message if invalid, null if valid
285
+ */
286
+ validateJob(job) {
287
+ // Basic structure validation
288
+ if (!job || typeof job !== 'object') {
289
+ return 'Job is null or not an object';
290
+ }
291
+
292
+ // Required fields
293
+ if (typeof job.id !== 'number' || job.id <= 0) {
294
+ return 'Job ID is missing or invalid';
295
+ }
296
+
297
+ if (typeof job.job_type !== 'string' || !job.job_type.trim()) {
298
+ return 'Job type is missing or invalid';
299
+ }
300
+
301
+ // Validate job_type is one of the known types
302
+ const validJobTypes = ['prd_generation', 'code_execution'];
303
+ if (!validJobTypes.includes(job.job_type)) {
304
+ return `Unknown job type: ${job.job_type}`;
305
+ }
306
+
307
+ if (typeof job.task_title !== 'string' || !job.task_title.trim()) {
308
+ return 'Task title is missing or invalid';
309
+ }
310
+
311
+ // Validate prompt if present (can be null/empty for legacy clients)
312
+ if (job.prompt !== null && job.prompt !== undefined && typeof job.prompt !== 'string') {
313
+ return 'Prompt must be a string or null';
314
+ }
315
+
316
+ // For code_execution jobs, validate project
317
+ if (job.job_type === 'code_execution') {
318
+ if (!job.project || typeof job.project !== 'object') {
319
+ return 'Project object is required for code_execution jobs';
320
+ }
321
+
322
+ if (typeof job.project.system_path !== 'string' || !job.project.system_path.trim()) {
323
+ return 'Project system_path is missing or invalid';
324
+ }
325
+ }
326
+
327
+ // For prd_generation jobs, validate project if present
328
+ if (job.job_type === 'prd_generation' && job.project) {
329
+ if (typeof job.project !== 'object') {
330
+ return 'Project must be an object if provided';
331
+ }
332
+
333
+ if (job.project.system_path !== null &&
334
+ job.project.system_path !== undefined &&
335
+ typeof job.project.system_path !== 'string') {
336
+ return 'Project system_path must be a string if provided';
337
+ }
338
+ }
339
+
340
+ return null; // Valid
341
+ }
342
+ }
343
+
344
+ module.exports = ApiClient;
package/src/config.js ADDED
@@ -0,0 +1,21 @@
1
+ require('dotenv').config();
2
+
3
+ const config = {
4
+ // API configuration
5
+ apiUrl: process.env.RALPH_API_URL || 'https://app.ralphblaster.com',
6
+ apiToken: process.env.RALPH_API_TOKEN,
7
+
8
+ // Execution configuration
9
+ maxRetries: parseInt(process.env.RALPH_MAX_RETRIES || '3', 10),
10
+
11
+ // Logging
12
+ logLevel: process.env.RALPH_LOG_LEVEL || 'info'
13
+ };
14
+
15
+ // Validate required configuration
16
+ if (!config.apiToken) {
17
+ console.error('Error: RALPH_API_TOKEN environment variable is required');
18
+ process.exit(1);
19
+ }
20
+
21
+ module.exports = config;