ralphblaster-agent 0.1.1
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 +294 -0
- package/bin/agent-dashboard.sh +168 -0
- package/bin/monitor-agent.sh +264 -0
- package/bin/ralphblaster.js +247 -0
- package/package.json +64 -0
- package/postinstall-colored.js +66 -0
- package/src/api-client.js +764 -0
- package/src/claude-plugin/.claude-plugin/plugin.json +9 -0
- package/src/claude-plugin/README.md +42 -0
- package/src/claude-plugin/skills/ralph/SKILL.md +259 -0
- package/src/commands/add-project.js +257 -0
- package/src/commands/init.js +79 -0
- package/src/config-file-manager.js +84 -0
- package/src/config.js +66 -0
- package/src/error-window.js +86 -0
- package/src/executor/claude-runner.js +716 -0
- package/src/executor/error-handler.js +65 -0
- package/src/executor/git-helper.js +196 -0
- package/src/executor/index.js +296 -0
- package/src/executor/job-handlers/clarifying-questions.js +213 -0
- package/src/executor/job-handlers/code-execution.js +145 -0
- package/src/executor/job-handlers/prd-generation.js +259 -0
- package/src/executor/path-helper.js +74 -0
- package/src/executor/prompt-validator.js +51 -0
- package/src/executor.js +4 -0
- package/src/index.js +342 -0
- package/src/logger.js +193 -0
- package/src/logging/README.md +93 -0
- package/src/logging/config.js +179 -0
- package/src/logging/destinations/README.md +290 -0
- package/src/logging/destinations/api-destination-unbatched.js +118 -0
- package/src/logging/destinations/api-destination.js +40 -0
- package/src/logging/destinations/base-destination.js +85 -0
- package/src/logging/destinations/batched-destination.js +198 -0
- package/src/logging/destinations/console-destination.js +172 -0
- package/src/logging/destinations/file-destination.js +208 -0
- package/src/logging/destinations/index.js +29 -0
- package/src/logging/destinations/progress-batch-destination-unbatched.js +92 -0
- package/src/logging/destinations/progress-batch-destination.js +41 -0
- package/src/logging/formatter.js +288 -0
- package/src/logging/log-manager.js +426 -0
- package/src/progress-throttle.js +101 -0
- package/src/system-monitor.js +64 -0
- package/src/utils/format.js +16 -0
- package/src/utils/log-file-helper.js +265 -0
- package/src/utils/progress-parser.js +250 -0
- package/src/worktree-manager.js +255 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
const logger = require('../logger');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validate prompt to prevent injection attacks
|
|
5
|
+
* @param {string} prompt - Prompt to validate
|
|
6
|
+
* @throws {Error} If prompt contains dangerous content
|
|
7
|
+
* @returns {boolean} True if validation passes
|
|
8
|
+
*/
|
|
9
|
+
function validatePrompt(prompt) {
|
|
10
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
11
|
+
throw new Error('Prompt must be a non-empty string');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Check prompt length (prevent DoS via massive prompts)
|
|
15
|
+
const MAX_PROMPT_LENGTH = 500000; // 500KB
|
|
16
|
+
if (prompt.length > MAX_PROMPT_LENGTH) {
|
|
17
|
+
throw new Error(`Prompt exceeds maximum length of ${MAX_PROMPT_LENGTH} characters`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Check for dangerous patterns that could lead to malicious operations
|
|
21
|
+
const dangerousPatterns = [
|
|
22
|
+
{ pattern: /rm\s+-rf\s+\//i, description: 'dangerous deletion command' },
|
|
23
|
+
{ pattern: /rm\s+-rf\s+~/i, description: 'dangerous home directory deletion' },
|
|
24
|
+
{ pattern: /\/etc\/passwd/i, description: 'system file access' },
|
|
25
|
+
{ pattern: /\/etc\/shadow/i, description: 'password file access' },
|
|
26
|
+
{ pattern: /curl.*\|\s*sh/i, description: 'remote code execution pattern' },
|
|
27
|
+
{ pattern: /wget.*\|\s*sh/i, description: 'remote code execution pattern' },
|
|
28
|
+
{ pattern: /eval\s*\(/i, description: 'code evaluation' },
|
|
29
|
+
{ pattern: /exec\s*\(/i, description: 'code execution' },
|
|
30
|
+
{ pattern: /\$\(.*rm.*-rf/i, description: 'command injection with deletion' },
|
|
31
|
+
{ pattern: /`.*rm.*-rf/i, description: 'command injection with deletion' },
|
|
32
|
+
{ pattern: /base64.*decode.*eval/i, description: 'obfuscated code execution' },
|
|
33
|
+
{ pattern: /\.ssh\/id_rsa/i, description: 'SSH key access' },
|
|
34
|
+
{ pattern: /\.aws\/credentials/i, description: 'AWS credentials access' }
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
for (const { pattern, description } of dangerousPatterns) {
|
|
38
|
+
if (pattern.test(prompt)) {
|
|
39
|
+
logger.error(`Prompt validation failed: contains ${description}`);
|
|
40
|
+
throw new Error(`Prompt contains potentially dangerous content: ${description}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Log sanitized version for security audit
|
|
45
|
+
const sanitizedPreview = prompt.substring(0, 200).replace(/\n/g, ' ');
|
|
46
|
+
logger.debug(`Prompt validated (${prompt.length} chars): ${sanitizedPreview}...`);
|
|
47
|
+
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = { validatePrompt };
|
package/src/executor.js
ADDED
package/src/index.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
const ApiClient = require('./api-client');
|
|
2
|
+
const Executor = require('./executor');
|
|
3
|
+
const ProgressThrottle = require('./progress-throttle');
|
|
4
|
+
const ErrorWindow = require('./error-window');
|
|
5
|
+
const config = require('./config');
|
|
6
|
+
const loggingConfig = require('./logging/config');
|
|
7
|
+
const logger = require('./logger');
|
|
8
|
+
const { formatDuration } = require('./utils/format');
|
|
9
|
+
|
|
10
|
+
// Timeout constants
|
|
11
|
+
const TIMEOUTS = {
|
|
12
|
+
SHUTDOWN_DELAY_MS: 500, // Delay before shutdown
|
|
13
|
+
ERROR_RETRY_DELAY_MS: 5000, // 5 seconds retry delay after errors
|
|
14
|
+
HEARTBEAT_INTERVAL_MS: 20000, // 20 seconds for heartbeat
|
|
15
|
+
MIN_REQUEST_INTERVAL_MS: 1000, // Minimum 1 second between API requests
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
class RalphAgent {
|
|
19
|
+
constructor() {
|
|
20
|
+
// Agent ID for multi-agent support (from centralized logging config)
|
|
21
|
+
this.agentId = loggingConfig.agentId;
|
|
22
|
+
|
|
23
|
+
this.apiClient = new ApiClient(this.agentId);
|
|
24
|
+
this.executor = new Executor(this.apiClient);
|
|
25
|
+
this.isRunning = false;
|
|
26
|
+
this.currentJob = null;
|
|
27
|
+
this.heartbeatInterval = null;
|
|
28
|
+
this.jobStartTime = null; // Track when job started for elapsed time
|
|
29
|
+
|
|
30
|
+
// Rate limiting state
|
|
31
|
+
this.lastRequestTime = 0;
|
|
32
|
+
this.minRequestInterval = TIMEOUTS.MIN_REQUEST_INTERVAL_MS;
|
|
33
|
+
|
|
34
|
+
// Time-window based error tracking for circuit breaker
|
|
35
|
+
this.errorWindow = new ErrorWindow();
|
|
36
|
+
|
|
37
|
+
// Adaptive progress throttling
|
|
38
|
+
this.progressThrottle = new ProgressThrottle();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Start the agent
|
|
43
|
+
*/
|
|
44
|
+
async start() {
|
|
45
|
+
this.isRunning = true;
|
|
46
|
+
|
|
47
|
+
// Set agent ID in logger context
|
|
48
|
+
logger.setAgentId(this.agentId);
|
|
49
|
+
|
|
50
|
+
logger.info('Ralph Agent starting...');
|
|
51
|
+
logger.info(`Agent ID: ${this.agentId}`);
|
|
52
|
+
logger.info(`API URL: ${config.apiUrl}`);
|
|
53
|
+
|
|
54
|
+
// Setup graceful shutdown
|
|
55
|
+
this.setupShutdownHandlers();
|
|
56
|
+
|
|
57
|
+
// Start polling loop
|
|
58
|
+
await this.pollLoop();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Stop the agent
|
|
63
|
+
*/
|
|
64
|
+
async stop() {
|
|
65
|
+
logger.info('Ralph Agent stopping...');
|
|
66
|
+
this.isRunning = false;
|
|
67
|
+
|
|
68
|
+
// Stop heartbeat first to prevent updates during shutdown
|
|
69
|
+
this.stopHeartbeat();
|
|
70
|
+
|
|
71
|
+
// Kill any running Claude process
|
|
72
|
+
if (this.executor.currentProcess) {
|
|
73
|
+
logger.warn('Terminating running Claude process');
|
|
74
|
+
await this.executor.killCurrentProcess();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// If currently executing a job, mark it as failed
|
|
78
|
+
if (this.currentJob) {
|
|
79
|
+
logger.warn(`Marking job #${this.currentJob.id} as failed due to shutdown`);
|
|
80
|
+
try {
|
|
81
|
+
await this.apiClient.markJobFailed(
|
|
82
|
+
this.currentJob.id,
|
|
83
|
+
'Agent shutdown during execution'
|
|
84
|
+
);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
logger.error('Failed to mark job as failed during shutdown: ' + error.message);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
logger.info('Ralph Agent stopped');
|
|
91
|
+
// Give async operations time to complete before exiting
|
|
92
|
+
setTimeout(() => process.exit(0), TIMEOUTS.SHUTDOWN_DELAY_MS);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Main polling loop with rate limiting and exponential backoff
|
|
97
|
+
*/
|
|
98
|
+
async pollLoop() {
|
|
99
|
+
while (this.isRunning) {
|
|
100
|
+
try {
|
|
101
|
+
// Enforce minimum interval between requests
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const timeSinceLastRequest = now - this.lastRequestTime;
|
|
104
|
+
if (timeSinceLastRequest < this.minRequestInterval) {
|
|
105
|
+
await this.sleep(this.minRequestInterval - timeSinceLastRequest);
|
|
106
|
+
}
|
|
107
|
+
this.lastRequestTime = Date.now();
|
|
108
|
+
|
|
109
|
+
// Check for next job (long polling - server waits up to 10s)
|
|
110
|
+
const job = await this.apiClient.getNextJob();
|
|
111
|
+
|
|
112
|
+
// Note: Successful API call (error window handles its own cleanup)
|
|
113
|
+
|
|
114
|
+
if (job) {
|
|
115
|
+
await this.processJob(job);
|
|
116
|
+
// After processing, immediately poll for next job
|
|
117
|
+
} else {
|
|
118
|
+
// No jobs available after long poll timeout
|
|
119
|
+
// Small delay before reconnecting to prevent hammering
|
|
120
|
+
await this.sleep(1000); // 1s minimum between polls
|
|
121
|
+
}
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// Record error in time window for circuit breaker
|
|
124
|
+
this.errorWindow.addError();
|
|
125
|
+
const errorCount = this.errorWindow.getErrorCount();
|
|
126
|
+
|
|
127
|
+
logger.error(`Error in poll loop (${errorCount} errors in last 60s)`, error.message);
|
|
128
|
+
|
|
129
|
+
// Exponential backoff based on recent error count: 5s, 10s, 20s, 40s, max 60s
|
|
130
|
+
const backoffTime = Math.min(
|
|
131
|
+
TIMEOUTS.ERROR_RETRY_DELAY_MS * Math.pow(2, Math.min(errorCount - 1, 4)),
|
|
132
|
+
60000
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
logger.info(`Backing off for ${backoffTime}ms before retry`);
|
|
136
|
+
await this.sleep(backoffTime);
|
|
137
|
+
|
|
138
|
+
// Circuit breaker: Stop if error rate exceeds 50% over last 60 seconds
|
|
139
|
+
if (this.errorWindow.shouldShutdown()) {
|
|
140
|
+
logger.error('Error rate >50% in last 60 seconds, shutting down for safety');
|
|
141
|
+
logger.error(`Total errors: ${errorCount} (threshold based on 5s request interval)`);
|
|
142
|
+
await this.stop();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Process a job
|
|
150
|
+
* @param {Object} job - Job object from API
|
|
151
|
+
*/
|
|
152
|
+
async processJob(job) {
|
|
153
|
+
this.currentJob = job;
|
|
154
|
+
this.jobStartTime = Date.now(); // Track start time for elapsed time calculation
|
|
155
|
+
|
|
156
|
+
logger.info('═══════════════════════════════════════════════════════════');
|
|
157
|
+
logger.info(`Processing Job #${job.id}`, {
|
|
158
|
+
jobType: job.job_type,
|
|
159
|
+
taskTitle: job.task_title,
|
|
160
|
+
projectName: job.project?.name,
|
|
161
|
+
hasPrompt: !!job.prompt,
|
|
162
|
+
promptLength: job.prompt?.length || 0
|
|
163
|
+
});
|
|
164
|
+
logger.info('═══════════════════════════════════════════════════════════');
|
|
165
|
+
|
|
166
|
+
// Set logger context so internal logs are sent to UI (Phase 3: with global context)
|
|
167
|
+
logger.setJobContext(job.id, this.apiClient, {
|
|
168
|
+
jobType: job.job_type,
|
|
169
|
+
taskTitle: job.task_title,
|
|
170
|
+
projectId: job.project?.id,
|
|
171
|
+
projectName: job.project?.name
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
// Mark job as running
|
|
176
|
+
logger.debug('Marking job as running...');
|
|
177
|
+
await this.apiClient.markJobRunning(job.id);
|
|
178
|
+
logger.debug('Job marked as running in API');
|
|
179
|
+
|
|
180
|
+
// Send initial status event
|
|
181
|
+
logger.debug('Sending initial status event to UI...');
|
|
182
|
+
await this.apiClient.sendStatusEvent(job.id, 'job_claimed', `Starting: ${job.task_title}`);
|
|
183
|
+
|
|
184
|
+
// Start heartbeat to keep job alive
|
|
185
|
+
logger.debug('Starting heartbeat timer...');
|
|
186
|
+
this.startHeartbeat(job.id);
|
|
187
|
+
|
|
188
|
+
// Reset progress throttle for new job
|
|
189
|
+
this.progressThrottle = new ProgressThrottle();
|
|
190
|
+
|
|
191
|
+
// Execute the job with progress callback
|
|
192
|
+
logger.info('Beginning job execution...');
|
|
193
|
+
const result = await this.executor.execute(job, async (chunk) => {
|
|
194
|
+
// Adaptive throttling: adjusts based on update rate
|
|
195
|
+
// High rate (>20/sec): 500ms throttle, Medium (5-20/sec): 200ms, Low (<5/sec): 100ms
|
|
196
|
+
if (this.progressThrottle.shouldThrottle()) {
|
|
197
|
+
return; // Skip this update
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Record this update for rate tracking
|
|
201
|
+
this.progressThrottle.recordUpdate();
|
|
202
|
+
|
|
203
|
+
// Send progress update to server (best-effort, don't fail job on error)
|
|
204
|
+
try {
|
|
205
|
+
await this.apiClient.sendProgress(job.id, chunk);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
logger.debug(`Progress update failed for job #${job.id}: ${error.message}`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
logger.info('Job execution completed, processing results...');
|
|
212
|
+
|
|
213
|
+
// Mark job as completed
|
|
214
|
+
logger.info('Marking job as completed in API...');
|
|
215
|
+
await this.apiClient.markJobCompleted(job.id, result);
|
|
216
|
+
|
|
217
|
+
const executionTime = Date.now() - this.jobStartTime;
|
|
218
|
+
logger.info('═══════════════════════════════════════════════════════════');
|
|
219
|
+
logger.info(`✓ Job #${job.id} completed successfully`, {
|
|
220
|
+
executionTimeMs: executionTime,
|
|
221
|
+
executionTime: formatDuration(executionTime)
|
|
222
|
+
});
|
|
223
|
+
logger.info('═══════════════════════════════════════════════════════════');
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const executionTime = Date.now() - this.jobStartTime;
|
|
226
|
+
logger.error('═══════════════════════════════════════════════════════════');
|
|
227
|
+
logger.error(`✗ Job #${job.id} failed after ${formatDuration(executionTime)}`, {
|
|
228
|
+
error: error.message,
|
|
229
|
+
category: error.category,
|
|
230
|
+
hasPartialOutput: !!error.partialOutput
|
|
231
|
+
});
|
|
232
|
+
logger.error('═══════════════════════════════════════════════════════════');
|
|
233
|
+
|
|
234
|
+
// Mark job as failed (pass full error object to include categorization)
|
|
235
|
+
logger.info('Marking job as failed in API...');
|
|
236
|
+
await this.apiClient.markJobFailed(
|
|
237
|
+
job.id,
|
|
238
|
+
error, // Pass full error object instead of just message
|
|
239
|
+
error.partialOutput || null
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
logger.error(`Job #${job.id} marked as failed in API`);
|
|
243
|
+
} finally {
|
|
244
|
+
// Stop heartbeat immediately to prevent race conditions
|
|
245
|
+
this.stopHeartbeat();
|
|
246
|
+
logger.info('Heartbeat stopped');
|
|
247
|
+
|
|
248
|
+
// Clear logger context (flush remaining batched logs)
|
|
249
|
+
logger.info('Flushing remaining logs to API...');
|
|
250
|
+
await logger.clearJobContext();
|
|
251
|
+
logger.info('Logger context cleared');
|
|
252
|
+
|
|
253
|
+
// Clear current job reference and reset time tracking
|
|
254
|
+
this.currentJob = null;
|
|
255
|
+
this.jobStartTime = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Start heartbeat for job
|
|
262
|
+
* @param {number} jobId - Job ID
|
|
263
|
+
*/
|
|
264
|
+
startHeartbeat(jobId) {
|
|
265
|
+
// Send heartbeat every 20 seconds to maintain online status (backend offline threshold: 35s)
|
|
266
|
+
this.heartbeatInterval = setInterval(async () => {
|
|
267
|
+
try {
|
|
268
|
+
// Calculate elapsed time
|
|
269
|
+
const elapsed = Date.now() - this.jobStartTime;
|
|
270
|
+
const minutes = Math.floor(elapsed / 60000);
|
|
271
|
+
const seconds = Math.floor((elapsed % 60000) / 1000);
|
|
272
|
+
|
|
273
|
+
// Phase 1.2: Send combined heartbeat + status event in single call (reduces API calls by 50%)
|
|
274
|
+
await this.apiClient.sendHeartbeat(jobId, {
|
|
275
|
+
event_type: 'heartbeat',
|
|
276
|
+
message: `Still working... (${minutes}m ${seconds}s elapsed)`,
|
|
277
|
+
metadata: { elapsed_ms: elapsed }
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// REMOVED: Separate sendStatusEvent() call
|
|
281
|
+
// await this.apiClient.sendStatusEvent(jobId, 'heartbeat', ...);
|
|
282
|
+
} catch (err) {
|
|
283
|
+
logger.warn('Heartbeat failed: ' + err.message);
|
|
284
|
+
}
|
|
285
|
+
}, TIMEOUTS.HEARTBEAT_INTERVAL_MS);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Stop heartbeat
|
|
290
|
+
*/
|
|
291
|
+
stopHeartbeat() {
|
|
292
|
+
if (this.heartbeatInterval) {
|
|
293
|
+
clearInterval(this.heartbeatInterval);
|
|
294
|
+
this.heartbeatInterval = null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Setup graceful shutdown handlers
|
|
300
|
+
*/
|
|
301
|
+
setupShutdownHandlers() {
|
|
302
|
+
process.on('SIGINT', () => {
|
|
303
|
+
logger.info('Received SIGINT signal');
|
|
304
|
+
this.stop();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
process.on('SIGTERM', () => {
|
|
308
|
+
logger.info('Received SIGTERM signal');
|
|
309
|
+
this.stop();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
process.on('uncaughtException', (error) => {
|
|
313
|
+
logger.error('╔══════════════════════════════════════════════════════════');
|
|
314
|
+
logger.error('║ UNCAUGHT EXCEPTION - Agent will shutdown');
|
|
315
|
+
logger.error('║ Message: ' + (error?.message || error));
|
|
316
|
+
logger.error('║ Stack: ' + (error?.stack || 'no stack'));
|
|
317
|
+
logger.error('╚══════════════════════════════════════════════════════════');
|
|
318
|
+
console.error('UNCAUGHT EXCEPTION:', error);
|
|
319
|
+
this.stop();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
323
|
+
logger.error('╔══════════════════════════════════════════════════════════');
|
|
324
|
+
logger.error('║ UNHANDLED REJECTION - Agent will shutdown');
|
|
325
|
+
logger.error('║ Reason: ' + (reason?.message || reason));
|
|
326
|
+
logger.error('║ Stack: ' + (reason?.stack || 'no stack'));
|
|
327
|
+
logger.error('╚══════════════════════════════════════════════════════════');
|
|
328
|
+
console.error('UNHANDLED REJECTION:', reason);
|
|
329
|
+
this.stop();
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Sleep helper
|
|
335
|
+
* @param {number} ms - Milliseconds to sleep
|
|
336
|
+
*/
|
|
337
|
+
sleep(ms) {
|
|
338
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = RalphAgent;
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const loggingConfig = require('./logging/config');
|
|
2
|
+
const LogManager = require('./logging/log-manager');
|
|
3
|
+
const { ConsoleDestination } = require('./logging/destinations');
|
|
4
|
+
|
|
5
|
+
// Create console destination with centralized config
|
|
6
|
+
const consoleDestination = new ConsoleDestination({
|
|
7
|
+
colors: loggingConfig.consoleColors,
|
|
8
|
+
format: loggingConfig.consoleFormat,
|
|
9
|
+
minLevel: loggingConfig.logLevel
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Initialize LogManager with console destination
|
|
13
|
+
// API destination will be added dynamically when job context is set
|
|
14
|
+
const logManager = new LogManager([consoleDestination], {
|
|
15
|
+
agentId: loggingConfig.agentId
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Internal log function that delegates to LogManager
|
|
20
|
+
* @param {string} level - Log level ('error', 'warn', 'info', 'debug')
|
|
21
|
+
* @param {string} message - Log message to output
|
|
22
|
+
* @param {Object|null} data - Optional structured metadata to include
|
|
23
|
+
* @private
|
|
24
|
+
*/
|
|
25
|
+
function log(level, message, data = null) {
|
|
26
|
+
// Delegate to LogManager (non-blocking)
|
|
27
|
+
logManager[level](message, data || {}).catch(() => {
|
|
28
|
+
// Silently handle errors to prevent cascading failures
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// For error level, trigger immediate flush
|
|
32
|
+
if (level === 'error') {
|
|
33
|
+
logManager.flush().catch(() => {}); // Best-effort immediate flush
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
/**
|
|
39
|
+
* Log an error message (level 0 - always visible)
|
|
40
|
+
* Used for critical errors requiring immediate attention.
|
|
41
|
+
* Sent to API if job context is set, and flushed immediately (doesn't wait for batch).
|
|
42
|
+
* @param {string} msg - Error message
|
|
43
|
+
* @param {Object} [data] - Optional structured metadata
|
|
44
|
+
* @example
|
|
45
|
+
* logger.error('Database connection failed', { error: err.message })
|
|
46
|
+
*/
|
|
47
|
+
error: (msg, data) => log('error', msg, data),
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Log a warning message (level 1)
|
|
51
|
+
* Used for concerning conditions that should be reviewed but aren't critical.
|
|
52
|
+
* Warning logs are NOT sent to the API (local only).
|
|
53
|
+
* @param {string} msg - Warning message
|
|
54
|
+
* @param {Object} [data] - Optional structured metadata
|
|
55
|
+
* @example
|
|
56
|
+
* logger.warn('Disk space low', { available: '5%' })
|
|
57
|
+
*/
|
|
58
|
+
warn: (msg, data) => log('warn', msg, data),
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Log an info message (level 2 - default)
|
|
62
|
+
* Used for general operational information.
|
|
63
|
+
* Sent to API if job context is set.
|
|
64
|
+
* @param {string} msg - Info message
|
|
65
|
+
* @param {Object} [data] - Optional structured metadata
|
|
66
|
+
* @example
|
|
67
|
+
* logger.info('Server started', { port: 3000 })
|
|
68
|
+
*/
|
|
69
|
+
info: (msg, data) => log('info', msg, data),
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Log a debug message (level 3 - most verbose)
|
|
73
|
+
* Used for detailed debugging information.
|
|
74
|
+
* Debug logs are NOT sent to the API (local only).
|
|
75
|
+
* @param {string} msg - Debug message
|
|
76
|
+
* @param {Object} [data] - Optional structured metadata
|
|
77
|
+
* @example
|
|
78
|
+
* logger.debug('Request received', { headers, body })
|
|
79
|
+
*/
|
|
80
|
+
debug: (msg, data) => log('debug', msg, data),
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Set agent ID for multi-agent support
|
|
84
|
+
* @param {string} id - Agent ID (e.g., 'agent-1', 'agent-2')
|
|
85
|
+
*/
|
|
86
|
+
setAgentId: (id) => {
|
|
87
|
+
logManager.setAgentId(id);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Set job context for API logging
|
|
92
|
+
* When set, info and error logs will be sent to the API's "Instance Setup Logs"
|
|
93
|
+
* Uses batching to reduce API overhead (flushes every 2s or when 10 logs buffered)
|
|
94
|
+
* @param {number} jobId - Job ID
|
|
95
|
+
* @param {Object} apiClient - API client instance
|
|
96
|
+
* @param {Object} context - Optional global context to add to all logs
|
|
97
|
+
*/
|
|
98
|
+
setJobContext: (jobId, apiClient, context = {}) => {
|
|
99
|
+
// Set job context in LogManager
|
|
100
|
+
logManager.setJobContext(jobId, context);
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Clear job context (called when job completes)
|
|
105
|
+
* Ensures all buffered logs are flushed before shutdown
|
|
106
|
+
*/
|
|
107
|
+
clearJobContext: async () => {
|
|
108
|
+
// Clear job context in LogManager
|
|
109
|
+
await logManager.clearJobContext();
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
// ===== Phase 3: Enhanced Structured Logging =====
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Set global context that will be included in all subsequent logs
|
|
116
|
+
* @param {string|Object} keyOrContext - Key name or object with multiple keys
|
|
117
|
+
* @param {*} value - Value (only if first param is a string)
|
|
118
|
+
* @example
|
|
119
|
+
* logger.setContext('component', 'worktree')
|
|
120
|
+
* logger.setContext({ component: 'worktree', operation: 'create' })
|
|
121
|
+
*/
|
|
122
|
+
setContext: (keyOrContext, value) => {
|
|
123
|
+
logManager.setContext(keyOrContext, value);
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create a child logger with additional context
|
|
128
|
+
* Context is additive - child inherits parent context and adds its own
|
|
129
|
+
* @param {Object} context - Additional context for this child logger
|
|
130
|
+
* @returns {Object} Child logger with all parent methods
|
|
131
|
+
* @example
|
|
132
|
+
* const worktreeLogger = logger.child({ component: 'worktree' })
|
|
133
|
+
* worktreeLogger.info('Creating worktree') // Includes component: 'worktree'
|
|
134
|
+
*/
|
|
135
|
+
child: (context) => {
|
|
136
|
+
return logManager.child(context);
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Log a semantic event with structured metadata
|
|
141
|
+
* @param {string} eventType - Event type in format 'category.action' (e.g., 'worktree.created')
|
|
142
|
+
* @param {Object} data - Additional event data
|
|
143
|
+
* @example
|
|
144
|
+
* logger.event('worktree.created', { path: '/path/to/worktree', duration: 3200 })
|
|
145
|
+
*/
|
|
146
|
+
event: (eventType, data = {}) => {
|
|
147
|
+
logManager.event(eventType, data).catch(() => {
|
|
148
|
+
// Silently handle errors to prevent cascading failures
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Start a performance timer for an operation
|
|
154
|
+
* @param {string} operation - Operation name (e.g., 'worktree.create')
|
|
155
|
+
* @param {Object} initialContext - Initial context for the operation
|
|
156
|
+
* @returns {Object} Timer object with done() method
|
|
157
|
+
* @example
|
|
158
|
+
* const timer = logger.startTimer('worktree.create')
|
|
159
|
+
* // ... do work ...
|
|
160
|
+
* timer.done({ path: '/path/to/worktree' }) // Logs duration automatically
|
|
161
|
+
*/
|
|
162
|
+
startTimer: (operation, initialContext = {}) => {
|
|
163
|
+
return logManager.startTimer(operation, initialContext);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Measure and log an async operation automatically
|
|
168
|
+
* @param {string} operation - Operation name (e.g., 'prd.conversion')
|
|
169
|
+
* @param {Function} fn - Async function to measure
|
|
170
|
+
* @param {Object} context - Additional context
|
|
171
|
+
* @returns {Promise<*>} Result of the async function
|
|
172
|
+
* @example
|
|
173
|
+
* const result = await logger.measure('prd.conversion', async () => {
|
|
174
|
+
* return await convertPRD(job)
|
|
175
|
+
* })
|
|
176
|
+
* // Logs: prd.conversion.started, then prd.conversion.complete with duration
|
|
177
|
+
*/
|
|
178
|
+
measure: async (operation, fn, context = {}) => {
|
|
179
|
+
return await logManager.measure(operation, fn, context);
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// ===== Direct Access to LogManager =====
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get the underlying LogManager instance
|
|
186
|
+
* Useful for advanced use cases like adding/removing destinations dynamically
|
|
187
|
+
* @returns {LogManager} The LogManager instance
|
|
188
|
+
* @example
|
|
189
|
+
* const manager = logger.getManager()
|
|
190
|
+
* manager.addDestination(new FileDestination({ path: '/var/log/app.log' }))
|
|
191
|
+
*/
|
|
192
|
+
getManager: () => logManager
|
|
193
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Logging Configuration
|
|
2
|
+
|
|
3
|
+
This directory contains centralized logging configuration for the RalphBlaster Agent.
|
|
4
|
+
|
|
5
|
+
## Files
|
|
6
|
+
|
|
7
|
+
### `config.js`
|
|
8
|
+
|
|
9
|
+
Central configuration module that consolidates all logging-related settings:
|
|
10
|
+
|
|
11
|
+
- **Console Output Settings**: `logLevel`, `consoleColors`, `consoleFormat`
|
|
12
|
+
- **Log Batching Settings**: `maxBatchSize`, `flushInterval`, `useBatchEndpoint`
|
|
13
|
+
- **Agent Identification**: `agentId`
|
|
14
|
+
|
|
15
|
+
All settings are read from environment variables with sensible defaults and validation.
|
|
16
|
+
|
|
17
|
+
### `formatter.js`
|
|
18
|
+
|
|
19
|
+
Shared formatting utilities for log output:
|
|
20
|
+
|
|
21
|
+
- Message formatting and redaction
|
|
22
|
+
- Metadata formatting
|
|
23
|
+
- Color codes and styling
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
### Importing Configuration
|
|
28
|
+
|
|
29
|
+
```javascript
|
|
30
|
+
const loggingConfig = require('./logging/config');
|
|
31
|
+
|
|
32
|
+
console.log(loggingConfig.logLevel); // 'info'
|
|
33
|
+
console.log(loggingConfig.consoleColors); // true
|
|
34
|
+
console.log(loggingConfig.maxBatchSize); // 10
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Environment Variables
|
|
38
|
+
|
|
39
|
+
| Variable | Type | Default | Description |
|
|
40
|
+
|----------|------|---------|-------------|
|
|
41
|
+
| `RALPHBLASTER_LOG_LEVEL` | string | `info` | Log level: error, warn, info, debug |
|
|
42
|
+
| `RALPHBLASTER_CONSOLE_COLORS` | boolean | `true` | Enable/disable colored output |
|
|
43
|
+
| `RALPHBLASTER_CONSOLE_FORMAT` | string | `pretty` | Console format: pretty, json |
|
|
44
|
+
| `RALPHBLASTER_AGENT_ID` | string | `agent-default` | Agent identifier for multi-agent mode |
|
|
45
|
+
| `RALPHBLASTER_MAX_BATCH_SIZE` | number | `10` | Max logs to batch before flush |
|
|
46
|
+
| `RALPHBLASTER_FLUSH_INTERVAL` | number | `2000` | Milliseconds between auto-flushes |
|
|
47
|
+
| `RALPHBLASTER_USE_BATCH_ENDPOINT` | boolean | `true` | Use batch API endpoint |
|
|
48
|
+
|
|
49
|
+
### Validation
|
|
50
|
+
|
|
51
|
+
The configuration module validates all values:
|
|
52
|
+
|
|
53
|
+
- **Log Level**: Must be one of `error`, `warn`, `info`, `debug` (falls back to `info`)
|
|
54
|
+
- **Console Format**: Must be one of `pretty`, `json` (falls back to `pretty`)
|
|
55
|
+
- **Numeric Values**: Must be positive integers (falls back to defaults)
|
|
56
|
+
- **Boolean Values**: Treats `false`, `0`, and empty string as false
|
|
57
|
+
|
|
58
|
+
Invalid values trigger a warning and use the default value.
|
|
59
|
+
|
|
60
|
+
### Immutability
|
|
61
|
+
|
|
62
|
+
The configuration object is frozen to prevent accidental modifications:
|
|
63
|
+
|
|
64
|
+
```javascript
|
|
65
|
+
loggingConfig.logLevel = 'debug'; // Fails silently in non-strict mode
|
|
66
|
+
console.log(loggingConfig.logLevel); // Still 'info'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Migration Notes
|
|
70
|
+
|
|
71
|
+
Previously, logging settings were scattered across multiple files:
|
|
72
|
+
|
|
73
|
+
- `src/config.js` - logLevel, consoleColors, consoleFormat
|
|
74
|
+
- `src/setup-log-batcher.js` - maxBatchSize, flushInterval, useBatchEndpoint
|
|
75
|
+
- `src/index.js` - agentId
|
|
76
|
+
|
|
77
|
+
Now all settings are centralized in `src/logging/config.js`.
|
|
78
|
+
|
|
79
|
+
### Backward Compatibility
|
|
80
|
+
|
|
81
|
+
For backward compatibility, `src/config.js` re-exports the console-related settings:
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
const config = require('./config');
|
|
85
|
+
console.log(config.logLevel); // Still works
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
However, new code should import directly from `logging/config`:
|
|
89
|
+
|
|
90
|
+
```javascript
|
|
91
|
+
const loggingConfig = require('./logging/config');
|
|
92
|
+
console.log(loggingConfig.logLevel); // Preferred
|
|
93
|
+
```
|