UNPKG

lsh-framework

Version:

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

387 lines (386 loc) 13.7 kB
/** * Job Management System for LSH Shell * Supports CRUD operations on shell jobs and system processes * * REFACTORED: Now extends BaseJobManager to eliminate duplication */ import { spawn, exec } from 'child_process'; import { promisify } from 'util'; import * as fs from 'fs'; import { BaseJobManager, } from './base-job-manager.js'; import MemoryJobStorage from './job-storage-memory.js'; const execAsync = promisify(exec); export class JobManager extends BaseJobManager { nextJobId = 1; persistenceFile; schedulerInterval; constructor(persistenceFile = '/tmp/lsh-jobs.json') { super(new MemoryJobStorage(), 'JobManager'); this.persistenceFile = persistenceFile; this.loadPersistedJobs(); this.startScheduler(); this.setupCleanupHandlers(); } /** * Start a job (execute it as a process) */ async startJob(jobId) { const baseJob = await this.getJob(jobId); if (!baseJob) { throw new Error(`Job ${jobId} not found`); } const job = baseJob; if (job.status === 'running') { throw new Error(`Job ${jobId} is already running`); } try { // Spawn the process if (job.type === 'shell') { job.process = spawn('sh', ['-c', job.command], { cwd: job.cwd, env: job.env, stdio: ['pipe', 'pipe', 'pipe'], }); } else { const [cmd, ...args] = job.command.split(' '); job.process = spawn(cmd, args.concat(job.args || []), { cwd: job.cwd, env: job.env, stdio: ['pipe', 'pipe', 'pipe'], }); } job.pid = job.process.pid; // Handle output job.process.stdout?.on('data', (data) => { job.stdout = (job.stdout || '') + data.toString(); if (job.logFile) { fs.appendFileSync(job.logFile, data); } this.emit('jobOutput', job.id, 'stdout', data.toString()); }); job.process.stderr?.on('data', (data) => { job.stderr = (job.stderr || '') + data.toString(); if (job.logFile) { fs.appendFileSync(job.logFile, data); } this.emit('jobOutput', job.id, 'stderr', data.toString()); }); // Handle completion job.process.on('exit', (code, signal) => { const status = code === 0 ? 'completed' : (signal === 'SIGKILL' ? 'killed' : 'failed'); this.updateJobStatus(job.id, status, { completedAt: new Date(), exitCode: code || undefined, }); this.emit('jobCompleted', job, code, signal); this.persistJobs(); }); // Set timeout if specified if (job.timeout) { job.timer = setTimeout(() => { this.stopJob(job.id, 'SIGKILL'); }, job.timeout); } // Update status to running const updatedJob = await this.updateJobStatus(job.id, 'running', { startedAt: new Date(), pid: job.pid, }); await this.persistJobs(); return updatedJob; } catch (error) { await this.updateJobStatus(job.id, 'failed', { completedAt: new Date(), stderr: error.message, }); this.emit('jobFailed', job, error); await this.persistJobs(); throw error; } } /** * Stop a running job */ async stopJob(jobId, signal = 'SIGTERM') { const baseJob = await this.getJob(jobId); if (!baseJob) { throw new Error(`Job ${jobId} not found`); } const job = baseJob; if (job.status !== 'running') { throw new Error(`Job ${jobId} is not running`); } if (!job.process || !job.pid) { throw new Error(`Job ${jobId} has no associated process`); } // Clear timeout if exists if (job.timer) { clearTimeout(job.timer); job.timer = undefined; } // Kill the process try { job.process.kill(signal); } catch (error) { this.logger.error(`Failed to kill job ${jobId}`, error); } // Update status const updatedJob = await this.updateJobStatus(jobId, 'stopped', { completedAt: new Date(), }); await this.persistJobs(); return updatedJob; } /** * Create and immediately start a job */ async runJob(spec) { const job = await this.createJob(spec); return await this.startJob(job.id); } /** * Pause a job (stop it but keep for later resumption) */ async pauseJob(jobId) { await this.stopJob(jobId, 'SIGSTOP'); return await this.updateJobStatus(jobId, 'paused'); } /** * Resume a paused job */ async resumeJob(jobId) { const baseJob = await this.getJob(jobId); if (!baseJob) { throw new Error(`Job ${jobId} not found`); } const job = baseJob; if (job.status !== 'paused') { throw new Error(`Job ${jobId} is not paused`); } if (!job.process || !job.pid) { throw new Error(`Job ${jobId} has no associated process`); } // Send SIGCONT to resume try { job.process.kill('SIGCONT'); return await this.updateJobStatus(jobId, 'running'); } catch (error) { throw new Error(`Failed to resume job ${jobId}: ${error}`); } } /** * Kill a job forcefully */ async killJob(jobId, signal = 'SIGKILL') { return await this.stopJob(jobId, signal); } /** * Monitor a job's resource usage */ async monitorJob(jobId) { const baseJob = await this.getJob(jobId); if (!baseJob) { throw new Error(`Job ${jobId} not found`); } const job = baseJob; if (!job.pid) { throw new Error(`Job ${jobId} is not running`); } try { const { stdout } = await execAsync(`ps -p ${job.pid} -o pid,ppid,pcpu,pmem,etime,state`); const lines = stdout.split('\n'); if (lines.length < 2) { return null; // Process not found } const parts = lines[1].trim().split(/\s+/); const monitoring = { pid: parseInt(parts[0]), ppid: parseInt(parts[1]), cpu: parseFloat(parts[2]), memory: parseFloat(parts[3]), elapsed: parts[4], state: parts[5], timestamp: new Date(), }; // Update job with current resource usage job.cpuUsage = monitoring.cpu; job.memoryUsage = monitoring.memory; this.emit('jobMonitoring', job, monitoring); return monitoring; } catch (_error) { return null; // Process likely terminated } } /** * Get system processes */ async getSystemProcesses() { try { const { stdout } = await execAsync('ps -eo pid,ppid,user,pcpu,pmem,lstart,comm,args'); const lines = stdout.split('\n').slice(1); // Skip header return lines .filter(line => line.trim()) .map(line => { const parts = line.trim().split(/\s+/); return { pid: parseInt(parts[0]), ppid: parseInt(parts[1]), user: parts[2], cpu: parseFloat(parts[3]), memory: parseFloat(parts[4]), startTime: new Date(parts.slice(5, 9).join(' ')), name: parts[9], command: parts.slice(10).join(' ') || parts[9], status: 'running' }; }); } catch (error) { this.logger.error('Failed to get system processes', error); return []; } } /** * Get job statistics */ getJobStats() { const jobs = Array.from(this.jobs.values()); const stats = { total: jobs.length, byStatus: {}, byType: {}, running: jobs.filter(j => j.status === 'running').length, completed: jobs.filter(j => j.status === 'completed').length, failed: jobs.filter(j => j.status === 'failed').length, }; jobs.forEach(job => { stats.byStatus[job.status] = (stats.byStatus[job.status] || 0) + 1; stats.byType[job.type] = (stats.byType[job.type] || 0) + 1; }); return stats; } /** * Clean up old jobs */ async cleanupJobs(olderThanHours = 24) { const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000); const jobs = await this.listJobs(); let cleaned = 0; for (const job of jobs) { if (job.status === 'completed' || job.status === 'failed') { if (job.completedAt && job.completedAt < cutoff) { await this.removeJob(job.id, true); cleaned++; } } } this.logger.info(`Cleaned up ${cleaned} old jobs`); return cleaned; } // ================================ // PRIVATE: Persistence & Scheduling // ================================ async loadPersistedJobs() { try { if (fs.existsSync(this.persistenceFile)) { const data = fs.readFileSync(this.persistenceFile, 'utf8'); const persistedJobs = JSON.parse(data); for (const job of persistedJobs) { // Convert date strings back to Date objects job.createdAt = new Date(job.createdAt); if (job.startedAt) job.startedAt = new Date(job.startedAt); if (job.completedAt) job.completedAt = new Date(job.completedAt); // Don't restore running processes - mark them as stopped if (job.status === 'running') { job.status = 'stopped'; } await this.storage.save(job); this.jobs.set(job.id, job); } this.logger.info(`Loaded ${persistedJobs.length} persisted jobs`); } } catch (error) { this.logger.error('Failed to load persisted jobs', error); } } async persistJobs() { try { const jobs = Array.from(this.jobs.values()).map(job => { const { process: _process, timer: _timer, ...serializable } = job; return serializable; }); fs.writeFileSync(this.persistenceFile, JSON.stringify(jobs, null, 2)); } catch (error) { this.logger.error('Failed to persist jobs', error); } } startScheduler() { // Check for scheduled jobs every minute this.schedulerInterval = setInterval(() => { this.checkScheduledJobs(); }, 60000); // Run immediately on startup this.checkScheduledJobs(); } async checkScheduledJobs() { const jobs = await this.listJobs({ status: 'created' }); const now = new Date(); for (const job of jobs) { if (job.schedule?.nextRun && job.schedule.nextRun <= now) { this.logger.info(`Starting scheduled job: ${job.id}`); try { await this.startJob(job.id); // Calculate next run time if (job.schedule.interval) { job.schedule.nextRun = new Date(now.getTime() + job.schedule.interval); await this.updateJob(job.id, { schedule: job.schedule }); } } catch (error) { this.logger.error(`Failed to start scheduled job ${job.id}`, error); } } } } setupCleanupHandlers() { const cleanup = async () => { this.logger.info('JobManager shutting down...'); if (this.schedulerInterval) { clearInterval(this.schedulerInterval); } // Stop all running jobs const jobs = await this.listJobs({ status: 'running' }); for (const job of jobs) { try { await this.stopJob(job.id); } catch (error) { this.logger.error(`Failed to stop job ${job.id}`, error); } } await this.persistJobs(); await this.cleanup(); }; process.on('SIGTERM', cleanup); process.on('SIGINT', cleanup); } /** * Override cleanup to include scheduler */ async cleanup() { if (this.schedulerInterval) { clearInterval(this.schedulerInterval); } await super.cleanup(); } } export default JobManager;