UNPKG

lsh-framework

Version:

A powerful, extensible shell with advanced job management, database persistence, and modern CLI features

294 lines (293 loc) 9.53 kB
/** * Base Job Manager * Abstract base class for all job management systems to eliminate duplication in: * - Job lifecycle management (create, start, stop, pause, resume, remove) * - Job status tracking and updates * - Event emission and handling * - Statistics and reporting * - Storage abstraction * * Subclasses implement storage-specific operations (memory, database, filesystem) */ import { EventEmitter } from 'events'; import { createLogger } from './logger.js'; /** * Abstract base class for job managers */ export class BaseJobManager extends EventEmitter { logger; storage; jobs = new Map(); constructor(storage, loggerName = 'JobManager') { super(); this.storage = storage; this.logger = createLogger(loggerName); } /** * Generate unique job ID */ generateJobId(prefix = 'job') { return `${prefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Validate job specification */ validateJobSpec(spec) { if (!spec.name) { throw new Error('Job name is required'); } if (!spec.command) { throw new Error('Job command is required'); } } /** * Create a new job */ async createJob(spec) { this.validateJobSpec(spec); const job = { id: spec.id || this.generateJobId(), name: spec.name, command: spec.command, args: spec.args, status: 'created', createdAt: new Date(), env: spec.env, cwd: spec.cwd || process.cwd(), user: spec.user || process.env.USER, schedule: spec.schedule, tags: spec.tags || [], description: spec.description, priority: spec.priority ?? 5, timeout: spec.timeout, maxRetries: spec.maxRetries ?? 3, retryCount: 0, databaseSync: spec.databaseSync !== false, }; await this.storage.save(job); this.jobs.set(job.id, job); this.emit('job:created', job); this.logger.info(`Job created: ${job.id} (${job.name})`); return job; } /** * Get job by ID */ async getJob(jobId) { // Check memory cache first const job = this.jobs.get(jobId); if (job) { return job; } // Check storage const storedJob = await this.storage.get(jobId); if (storedJob) { this.jobs.set(jobId, storedJob); return storedJob; } return null; } /** * List jobs with optional filtering */ async listJobs(filter) { let jobs = await this.storage.list(filter); // Apply additional filters if (filter) { jobs = this.applyFilters(jobs, filter); } return jobs; } /** * Apply filters to job list */ applyFilters(jobs, filter) { return jobs.filter(job => { // Status filter if (filter.status) { const statuses = Array.isArray(filter.status) ? filter.status : [filter.status]; if (!statuses.includes(job.status)) { return false; } } // Tags filter if (filter.tags && filter.tags.length > 0) { const hasTag = filter.tags.some(tag => job.tags?.includes(tag)); if (!hasTag) { return false; } } // User filter if (filter.user && job.user !== filter.user) { return false; } // Name pattern filter if (filter.namePattern) { const pattern = typeof filter.namePattern === 'string' ? new RegExp(filter.namePattern) : filter.namePattern; if (!pattern.test(job.name)) { return false; } } // Date filters if (filter.createdAfter && job.createdAt < filter.createdAfter) { return false; } if (filter.createdBefore && job.createdAt > filter.createdBefore) { return false; } return true; }); } /** * Update job */ async updateJob(jobId, updates) { const job = await this.getJob(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } // Apply updates if (updates.name) job.name = updates.name; if (updates.description) job.description = updates.description; if (updates.priority !== undefined) job.priority = updates.priority; if (updates.tags) job.tags = updates.tags; if (updates.schedule) job.schedule = updates.schedule; if (updates.env) job.env = { ...job.env, ...updates.env }; if (updates.timeout !== undefined) job.timeout = updates.timeout; if (updates.maxRetries !== undefined) job.maxRetries = updates.maxRetries; await this.storage.update(jobId, job); this.jobs.set(jobId, job); this.emit('job:updated', job); this.logger.info(`Job updated: ${jobId}`); return job; } /** * Update job status */ async updateJobStatus(jobId, status, additionalUpdates) { const job = await this.getJob(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } job.status = status; // Apply additional updates if (additionalUpdates) { Object.assign(job, additionalUpdates); } // Update timestamps if (status === 'running' && !job.startedAt) { job.startedAt = new Date(); } if (status === 'completed' || status === 'failed' || status === 'killed') { job.completedAt = new Date(); } await this.storage.update(jobId, job); this.jobs.set(jobId, job); this.emit(`job:${status}`, job); this.logger.info(`Job ${status}: ${jobId}`); return job; } /** * Remove job */ async removeJob(jobId, force = false) { const job = await this.getJob(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } // Check if job is running if (job.status === 'running' && !force) { throw new Error(`Job ${jobId} is running. Use force=true to remove.`); } // Stop job if running if (job.status === 'running') { await this.stopJob(jobId); } await this.storage.delete(jobId); this.jobs.delete(jobId); this.emit('job:removed', job); this.logger.info(`Job removed: ${jobId}`); return true; } /** * Get job execution history */ async getJobHistory(jobId, limit = 50) { return await this.storage.getExecutions(jobId, limit); } /** * Calculate job statistics */ async getJobStatistics(jobId) { const executions = await this.getJobHistory(jobId); const job = await this.getJob(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } const totalExecutions = executions.length; const successfulExecutions = executions.filter(e => e.status === 'completed').length; const failedExecutions = executions.filter(e => e.status === 'failed').length; const completedExecutions = executions.filter(e => e.duration); const averageDuration = completedExecutions.length > 0 ? completedExecutions.reduce((sum, e) => sum + (e.duration || 0), 0) / completedExecutions.length : 0; const lastExecution = executions[0]?.startTime; const lastSuccess = executions.find(e => e.status === 'completed')?.startTime; const lastFailure = executions.find(e => e.status === 'failed')?.startTime; return { jobId: job.id, jobName: job.name, totalExecutions, successfulExecutions, failedExecutions, successRate: totalExecutions > 0 ? (successfulExecutions / totalExecutions) * 100 : 0, averageDuration, lastExecution, lastSuccess, lastFailure, }; } /** * Record job execution */ async recordExecution(job, status, details = {}) { const execution = { executionId: this.generateJobId('exec'), jobId: job.id, jobName: job.name, command: job.command, startTime: details.startTime || new Date(), endTime: details.endTime, duration: details.duration, status, exitCode: details.exitCode, stdout: details.stdout, stderr: details.stderr, errorMessage: details.errorMessage, }; await this.storage.saveExecution(execution); this.emit('job:execution', execution); return execution; } /** * Cleanup - optional override */ async cleanup() { if (this.storage.cleanup) { await this.storage.cleanup(); } this.jobs.clear(); this.removeAllListeners(); } } export default BaseJobManager;