lsh-framework
Version:
A powerful, extensible shell with advanced job management, database persistence, and modern CLI features
294 lines (293 loc) • 9.53 kB
JavaScript
/**
* 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;