lsh-framework
Version:
A powerful, extensible shell with advanced job management, database persistence, and modern CLI features
555 lines (554 loc) • 20.4 kB
JavaScript
/**
* Job Registry - Comprehensive job execution history and analytics
* Tracks all job runs with detailed pass/failure history, output logs, and performance metrics
*
* REFACTORED: Now extends BaseJobManager for unified interface
* Note: This is a read-only tracker - startJob/stopJob only record events
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { exec } from 'child_process';
import { BaseJobManager } from '../lib/base-job-manager.js';
import MemoryJobStorage from '../lib/job-storage-memory.js';
export class JobRegistry extends BaseJobManager {
config;
records = new Map(); // jobId -> execution records
index = new Map(); // tag -> jobIds
statistics = new Map(); // jobId -> stats
constructor(config) {
super(new MemoryJobStorage(), 'JobRegistry');
this.config = {
registryFile: '/tmp/lsh-job-registry.json',
maxRecordsPerJob: 1000,
maxTotalRecords: 50000,
compressionEnabled: true,
metricsRetentionDays: 90,
outputLogDir: '/tmp/lsh-job-logs',
indexingEnabled: true,
...config
};
this.ensureLogDirectory();
this.loadRegistry();
}
/**
* Record the start of a job execution
*/
recordJobStart(job, executionId) {
const record = {
executionId: executionId || this.generateExecutionId(),
jobId: job.id,
jobName: job.name,
command: job.command,
startTime: new Date(),
status: 'running',
stdout: '',
stderr: '',
outputSize: 0,
environment: { ...(job.env || {}) },
workingDirectory: job.cwd || process.cwd(),
user: job.user || process.env.USER || 'unknown',
hostname: os.hostname(),
tags: [...(job.tags || [])],
priority: job.priority || 5,
scheduled: job.type === 'scheduled',
retryCount: 0,
pid: job.pid,
ppid: job.ppid
};
// Store output logs in separate files for large outputs
if (this.config.outputLogDir) {
record.logFile = path.join(this.config.outputLogDir, `${record.executionId}.log`);
}
this.addRecord(record);
this.emit('executionStarted', record);
return record;
}
/**
* Record job output (stdout/stderr)
*/
recordJobOutput(executionId, type, data) {
const record = this.findRecordByExecutionId(executionId);
if (!record)
return;
if (type === 'stdout') {
record.stdout += data;
}
else {
record.stderr += data;
}
record.outputSize += data.length;
// Write to log file if configured
if (record.logFile) {
const logEntry = `[${new Date().toISOString()}] ${type.toUpperCase()}: ${data}`;
fs.appendFileSync(record.logFile, logEntry);
}
this.emit('outputRecorded', executionId, type, data);
}
/**
* Record job completion
*/
recordJobCompletion(executionId, status, exitCode, signal, error) {
const record = this.findRecordByExecutionId(executionId);
if (!record)
return;
record.endTime = new Date();
record.duration = record.endTime.getTime() - record.startTime.getTime();
record.status = status;
record.exitCode = exitCode;
record.signal = signal;
if (error) {
record.errorType = error.constructor.name;
record.errorMessage = error.message;
record.stackTrace = error.stack;
}
// Record resource usage
this.recordResourceUsage(record);
this.updateJobStatistics(record);
this.saveRegistry();
this.emit('executionCompleted', record);
}
/**
* Get execution history for a job - overrides BaseJobManager
* Returns JobExecutionRecord[] which is compatible with BaseJobExecution[]
*/
async getJobHistory(jobId, limit = 50) {
const records = this.records.get(jobId) || [];
return limit ? records.slice(0, limit) : records;
}
/**
* Get job statistics - overrides BaseJobManager
* Returns JobStatistics which is compatible with BaseJobStatistics
*/
async getJobStatistics(jobId) {
const stats = this.statistics.get(jobId);
if (!stats) {
throw new Error(`No statistics found for job ${jobId}`);
}
return stats;
}
/**
* Get all job statistics
*/
getAllStatistics() {
return Array.from(this.statistics.values());
}
/**
* Search job executions
*/
searchExecutions(criteria) {
let results = [];
// Collect all records
for (const records of this.records.values()) {
results.push(...records);
}
// Apply filters
if (criteria.jobId) {
results = results.filter(r => r.jobId === criteria.jobId);
}
if (criteria.status) {
results = results.filter(r => criteria.status.includes(r.status));
}
if (criteria.startTime) {
if (criteria.startTime.from) {
results = results.filter(r => r.startTime >= criteria.startTime.from);
}
if (criteria.startTime.to) {
results = results.filter(r => r.startTime <= criteria.startTime.to);
}
}
if (criteria.duration) {
if (criteria.duration.min && criteria.duration.max) {
results = results.filter(r => r.duration &&
r.duration >= criteria.duration.min &&
r.duration <= criteria.duration.max);
}
}
if (criteria.tags && criteria.tags.length > 0) {
results = results.filter(r => criteria.tags.some(tag => r.tags.includes(tag)));
}
if (criteria.user) {
results = results.filter(r => r.user === criteria.user);
}
if (criteria.command) {
results = results.filter(r => criteria.command.test(r.command));
}
if (criteria.exitCode) {
results = results.filter(r => r.exitCode !== undefined &&
criteria.exitCode.includes(r.exitCode));
}
// Sort by start time (newest first)
results.sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
// Apply limit
if (criteria.limit) {
results = results.slice(0, criteria.limit);
}
return results;
}
/**
* Generate execution report
*/
async generateReport(options = {}) {
const { jobId, timeRange, format = 'text' } = options;
let records = [];
if (jobId) {
records = await this.getJobHistory(jobId);
}
else {
for (const jobRecords of this.records.values()) {
records.push(...jobRecords);
}
}
if (timeRange) {
records = records.filter(r => r.startTime >= timeRange.from && r.startTime <= timeRange.to);
}
switch (format) {
case 'json':
return JSON.stringify(records, null, 2);
case 'csv':
return this.generateCSVReport(records);
case 'text':
default:
return this.generateTextReport(records);
}
}
/**
* Clean old records - overrides BaseJobManager
*/
async cleanup() {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - this.config.metricsRetentionDays);
let removedCount = 0;
for (const [jobId, records] of this.records) {
const filteredRecords = records.filter(r => r.startTime >= cutoffDate);
const removed = records.length - filteredRecords.length;
if (removed > 0) {
this.records.set(jobId, filteredRecords);
removedCount += removed;
// Clean up log files for removed records
records
.filter(r => r.startTime < cutoffDate)
.forEach(r => {
if (r.logFile && fs.existsSync(r.logFile)) {
fs.unlinkSync(r.logFile);
}
});
}
// Limit records per job
if (filteredRecords.length > this.config.maxRecordsPerJob) {
const limited = filteredRecords.slice(0, this.config.maxRecordsPerJob);
this.records.set(jobId, limited);
removedCount += filteredRecords.length - limited.length;
}
}
this.saveRegistry();
this.logger.info(`Cleaned up ${removedCount} old records`);
// Call base cleanup
await super.cleanup();
}
/**
* Export registry data
*/
export(filePath, format = 'json') {
const allRecords = Array.from(this.records.values()).flat();
let content;
if (format === 'csv') {
content = this.generateCSVReport(allRecords);
}
else {
content = JSON.stringify({
records: allRecords,
statistics: Array.from(this.statistics.values()),
exportedAt: new Date().toISOString()
}, null, 2);
}
fs.writeFileSync(filePath, content);
}
addRecord(record) {
const jobRecords = this.records.get(record.jobId) || [];
jobRecords.unshift(record); // Add to beginning (newest first)
// Limit records per job
if (jobRecords.length > this.config.maxRecordsPerJob) {
const removed = jobRecords.splice(this.config.maxRecordsPerJob);
// Clean up log files for removed records
removed.forEach(r => {
if (r.logFile && fs.existsSync(r.logFile)) {
fs.unlinkSync(r.logFile);
}
});
}
this.records.set(record.jobId, jobRecords);
// Update index
if (this.config.indexingEnabled) {
record.tags.forEach(tag => {
const jobIds = this.index.get(tag) || new Set();
jobIds.add(record.jobId);
this.index.set(tag, jobIds);
});
}
}
findRecordByExecutionId(executionId) {
for (const records of this.records.values()) {
const record = records.find(r => r.executionId === executionId);
if (record)
return record;
}
return undefined;
}
updateJobStatistics(record) {
const stats = this.statistics.get(record.jobId) || this.createInitialStatistics(record);
stats.totalExecutions++;
switch (record.status) {
case 'completed':
stats.successfulExecutions++;
stats.lastSuccess = record.endTime;
break;
case 'failed':
stats.failedExecutions++;
stats.lastFailure = record.endTime;
this.updateFailureAnalysis(stats, record);
break;
case 'killed':
stats.killedExecutions++;
break;
}
stats.successRate = (stats.successfulExecutions / stats.totalExecutions) * 100;
stats.lastExecution = record.endTime;
// Update timing statistics
if (record.duration) {
stats.totalRuntime += record.duration;
stats.averageDuration = stats.totalRuntime / stats.totalExecutions;
if (stats.minDuration === 0 || record.duration < stats.minDuration) {
stats.minDuration = record.duration;
}
if (record.duration > stats.maxDuration) {
stats.maxDuration = record.duration;
}
}
// Update resource statistics
if (record.maxMemory) {
stats.averageMemory = (stats.averageMemory * (stats.totalExecutions - 1) + record.maxMemory) / stats.totalExecutions;
if (record.maxMemory > stats.maxMemoryUsed) {
stats.maxMemoryUsed = record.maxMemory;
}
}
if (record.avgCpu) {
stats.averageCpuUsage = (stats.averageCpuUsage * (stats.totalExecutions - 1) + record.avgCpu) / stats.totalExecutions;
}
// Determine trend
stats.recentTrend = this.calculateTrend(record.jobId);
this.statistics.set(record.jobId, stats);
}
createInitialStatistics(record) {
return {
jobId: record.jobId,
jobName: record.jobName,
totalExecutions: 0,
successfulExecutions: 0,
failedExecutions: 0,
killedExecutions: 0,
successRate: 0,
averageDuration: 0,
minDuration: 0,
maxDuration: 0,
totalRuntime: 0,
averageMemory: 0,
maxMemoryUsed: 0,
averageCpuUsage: 0,
recentTrend: 'stable',
commonFailures: []
};
}
updateFailureAnalysis(stats, record) {
if (!record.errorMessage)
return;
const existing = stats.commonFailures.find(f => f.error === record.errorMessage);
if (existing) {
existing.count++;
}
else {
stats.commonFailures.push({
error: record.errorMessage,
count: 1,
percentage: 0
});
}
// Update percentages
const totalFailures = stats.commonFailures.reduce((sum, f) => sum + f.count, 0);
stats.commonFailures.forEach(f => {
f.percentage = (f.count / totalFailures) * 100;
});
// Sort by frequency
stats.commonFailures.sort((a, b) => b.count - a.count);
// Keep only top 10 failure types
stats.commonFailures = stats.commonFailures.slice(0, 10);
}
calculateTrend(jobId) {
const records = this.records.get(jobId) || [];
if (records.length < 5)
return 'stable';
const recent = records.slice(0, 5);
const successCount = recent.filter(r => r.status === 'completed').length;
const _failureCount = recent.filter(r => r.status === 'failed').length;
const recentSuccessRate = successCount / recent.length;
const overallStats = this.statistics.get(jobId);
if (!overallStats)
return 'stable';
const overallSuccessRate = overallStats.successRate / 100;
if (recentSuccessRate > overallSuccessRate + 0.1) {
return 'improving';
}
else if (recentSuccessRate < overallSuccessRate - 0.1) {
return 'degrading';
}
else {
return 'stable';
}
}
recordResourceUsage(record) {
if (!record.pid)
return;
try {
exec(`ps -p ${record.pid} -o %mem,%cpu`, (error, stdout) => {
if (!error && stdout) {
const lines = stdout.trim().split('\n');
if (lines.length > 1) {
const [mem, cpu] = lines[1].trim().split(/\s+/).map(parseFloat);
record.maxMemory = mem;
record.avgCpu = cpu;
}
}
});
}
catch (_error) {
// Ignore resource monitoring errors
}
}
generateExecutionId() {
return `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
generateTextReport(records) {
let report = `Job Execution Report\n`;
report += `Generated: ${new Date().toISOString()}\n`;
report += `Total Executions: ${records.length}\n\n`;
const byStatus = records.reduce((acc, r) => {
acc[r.status] = (acc[r.status] || 0) + 1;
return acc;
}, {});
report += `Status Summary:\n`;
Object.entries(byStatus).forEach(([status, count]) => {
report += ` ${status}: ${count}\n`;
});
report += `\nRecent Executions:\n`;
records.slice(0, 20).forEach(r => {
report += `${r.startTime.toISOString()} | ${r.jobName} | ${r.status} | ${r.duration || 0}ms\n`;
});
return report;
}
generateCSVReport(records) {
const headers = [
'executionId', 'jobId', 'jobName', 'command', 'startTime', 'endTime',
'duration', 'status', 'exitCode', 'user', 'hostname', 'outputSize'
];
let csv = headers.join(',') + '\n';
records.forEach(r => {
const values = [
r.executionId,
r.jobId,
r.jobName,
`"${r.command}"`,
r.startTime.toISOString(),
r.endTime?.toISOString() || '',
r.duration || '',
r.status,
r.exitCode || '',
r.user,
r.hostname,
r.outputSize
];
csv += values.join(',') + '\n';
});
return csv;
}
ensureLogDirectory() {
if (!fs.existsSync(this.config.outputLogDir)) {
fs.mkdirSync(this.config.outputLogDir, { recursive: true });
}
}
loadRegistry() {
try {
if (fs.existsSync(this.config.registryFile)) {
const data = JSON.parse(fs.readFileSync(this.config.registryFile, 'utf8'));
// Restore records
if (data.records) {
for (const [jobId, records] of Object.entries(data.records)) {
this.records.set(jobId, records.map(r => ({
...r,
startTime: new Date(r.startTime),
endTime: r.endTime ? new Date(r.endTime) : undefined
})));
}
}
// Restore statistics
if (data.statistics) {
for (const [jobId, stats] of Object.entries(data.statistics)) {
const s = stats;
this.statistics.set(jobId, {
...s,
lastExecution: s.lastExecution ? new Date(s.lastExecution) : undefined,
lastSuccess: s.lastSuccess ? new Date(s.lastSuccess) : undefined,
lastFailure: s.lastFailure ? new Date(s.lastFailure) : undefined
});
}
}
}
}
catch (error) {
this.logger.error('Failed to load job registry', error);
}
}
saveRegistry() {
try {
const data = {
records: Object.fromEntries(this.records),
statistics: Object.fromEntries(this.statistics),
savedAt: new Date().toISOString()
};
fs.writeFileSync(this.config.registryFile, JSON.stringify(data, null, 2));
}
catch (error) {
this.logger.error('Failed to save job registry', error);
}
}
/**
* Start job - implements BaseJobManager abstract method
* JobRegistry is read-only, so this records the start event
*/
async startJob(jobId) {
const job = await this.getJob(jobId);
if (!job) {
throw new Error(`Job ${jobId} not found in registry`);
}
// Record execution start
this.recordJobStart(job);
// Update job status
return await this.updateJobStatus(jobId, 'running', {
startedAt: new Date(),
});
}
/**
* Stop job - implements BaseJobManager abstract method
* JobRegistry is read-only, so this records the stop event
*/
async stopJob(jobId, _signal) {
const job = await this.getJob(jobId);
if (!job) {
throw new Error(`Job ${jobId} not found in registry`);
}
// Update job status
return await this.updateJobStatus(jobId, 'stopped', {
completedAt: new Date(),
});
}
}
export default JobRegistry;