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.
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/bin/ralph-agent.js +87 -0
- package/package.json +59 -0
- package/src/api-client.js +344 -0
- package/src/config.js +21 -0
- package/src/executor.js +1014 -0
- package/src/index.js +243 -0
- package/src/logger.js +96 -0
- package/src/ralph/prompt.md +165 -0
- package/src/ralph/ralph.sh +239 -0
- package/src/ralph-instance-manager.js +171 -0
- package/src/worktree-manager.js +170 -0
|
@@ -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;
|