Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
scheduler-v1-backup.ts19.1 kB
/** * Scheduler Service - Main orchestrator for scheduled jobs * Combines job management, cron manipulation, and execution recording */ import { JobManager } from './job-manager.js'; import { ExecutionRecorder } from './execution-recorder.js'; import { CronManager } from './cron-manager.js'; import { LaunchdManager } from './launchd-manager.js'; import { TaskSchedulerManager } from './task-scheduler-manager.js'; import { JobExecutor } from './job-executor.js'; import { NaturalLanguageParser } from './natural-language-parser.js'; import { ToolValidator } from './tool-validator.js'; import { SettingsManager } from './settings-manager.js'; import { ScheduledJob, ExecutionSummary, SchedulerConfig } from '../../types/scheduler.js'; import { logger } from '../../utils/logger.js'; import { v4 as uuidv4 } from 'uuid'; import { execSync } from 'child_process'; import { platform } from 'os'; export interface CreateJobOptions { name: string; schedule: string; // Natural language, cron expression, or RFC 3339 datetime timezone?: string; // IANA timezone (e.g., "America/New_York"), defaults to system timezone tool: string; // Format: "mcp_name:tool_name" parameters: Record<string, any>; description?: string; fireOnce?: boolean; maxExecutions?: number; endDate?: string; // ISO date string skipValidation?: boolean; // Skip parameter validation (not recommended) testRun?: boolean; // Run tool once to test before scheduling } export class Scheduler { private jobManager: JobManager; public executionRecorder: ExecutionRecorder; // Public for access from SchedulerMCP private scheduleManager?: CronManager | LaunchdManager | TaskSchedulerManager; private jobExecutor: JobExecutor; private toolValidator: ToolValidator; private settingsManager: SettingsManager; private cleanupJobId?: string; // ID of the automatic cleanup job constructor(orchestrator?: any) { // NCPOrchestrator - using any to avoid circular dependency this.jobManager = new JobManager(); this.executionRecorder = new ExecutionRecorder(); this.jobExecutor = new JobExecutor(); this.toolValidator = new ToolValidator(orchestrator); this.settingsManager = new SettingsManager(); // Initialize platform-specific scheduler const currentPlatform = platform(); if (currentPlatform === 'darwin') { // macOS - use launchd (doesn't require Full Disk Access) try { this.scheduleManager = new LaunchdManager(); logger.info('[Scheduler] Using launchd for macOS scheduling'); } catch (error) { logger.warn(`[Scheduler] Launchd manager initialization failed: ${error instanceof Error ? error.message : String(error)}`); } } else if (currentPlatform === 'win32') { // Windows - use Task Scheduler try { this.scheduleManager = new TaskSchedulerManager(); logger.info('[Scheduler] Using Task Scheduler for Windows scheduling'); } catch (error) { logger.warn(`[Scheduler] Task Scheduler manager initialization failed: ${error instanceof Error ? error.message : String(error)}`); } } else { // Linux/Unix - use cron try { this.scheduleManager = new CronManager(); logger.info('[Scheduler] Using cron for Linux/Unix scheduling'); } catch (error) { logger.warn(`[Scheduler] Cron manager initialization failed: ${error instanceof Error ? error.message : String(error)}`); } } } /** * Check if scheduler is available on this platform */ isAvailable(): boolean { return this.scheduleManager !== undefined; } /** * Set up automatic cleanup job (internal system job) * Creates a cron job that runs cleanup on configured schedule * * NOTE: Only sets up if jobs actually exist to avoid unnecessary crontab modifications * on every CLI invocation (which triggers macOS admin permission dialogs) */ private setupAutomaticCleanup(): void { if (!this.scheduleManager) { return; // No cron manager available } const config = this.settingsManager.getConfig(); // Skip if auto-cleanup is disabled if (!config.enableAutoCleanup) { logger.debug('[Scheduler] Automatic cleanup is disabled'); return; } // Skip if no jobs exist - don't modify crontab unnecessarily // This prevents permission dialogs on every CLI command const allJobs = this.jobManager.getAllJobs(); if (allJobs.length === 0) { logger.debug('[Scheduler] Skipping cleanup setup - no scheduled jobs exist'); return; } try { // Check if cleanup job already exists in crontab const existingJobs = this.scheduleManager.getJobs(); const cleanupJobId = '__ncp_automatic_cleanup__'; const cleanupExists = existingJobs.some(j => j.id === cleanupJobId); if (cleanupExists) { logger.debug('[Scheduler] Automatic cleanup job already exists'); this.cleanupJobId = cleanupJobId; return; // Already set up, don't modify crontab } const ncpPath = this.getNCPExecutablePath(); const cleanupSchedule = config.cleanupSchedule || '0 0 * * *'; // Create a special internal job ID for cleanup this.cleanupJobId = cleanupJobId; // Create cron job that calls the cleanup-runs CLI command const command = `${ncpPath} cleanup-runs --max-age ${config.maxExecutionAgeDays || 14} --max-count ${config.maxExecutionsPerJob || 100}`; this.scheduleManager.addJob(this.cleanupJobId, cleanupSchedule, command); logger.info(`[Scheduler] Automatic cleanup enabled: ${cleanupSchedule}`); logger.info(`[Scheduler] Cleanup policy: ${config.maxExecutionAgeDays || 14} days, ${config.maxExecutionsPerJob || 100} runs per job`); } catch (error) { logger.error(`[Scheduler] Failed to setup automatic cleanup: ${error instanceof Error ? error.message : String(error)}`); } } /** * Get the path to ncp executable */ private getNCPExecutablePath(): string { try { // Use 'which ncp' to find the ncp executable const ncpPath = execSync('which ncp', { encoding: 'utf-8' }).trim(); return ncpPath; } catch { // Fallback to npx if ncp is not in PATH return 'npx ncp'; } } /** * Create a new scheduled job */ async createJob(options: CreateJobOptions): Promise<ScheduledJob> { if (!this.scheduleManager) { throw new Error('Scheduler not available on this platform'); } logger.info(`[Scheduler] Creating job: ${options.name}`); // Default timezone to system timezone if not provided const timezone = options.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; // Parse schedule (RFC 3339 datetime, cron expression, or natural language) let cronExpression: string; let fireOnce = options.fireOnce || false; // Check if it's RFC 3339 datetime (one-time execution with timezone) if (this.isRFC3339DateTime(options.schedule)) { const scheduledDate = new Date(options.schedule); if (isNaN(scheduledDate.getTime())) { throw new Error(`Invalid RFC 3339 datetime: ${options.schedule}`); } // Convert to cron expression for the scheduled time (in system local time) const minute = scheduledDate.getMinutes(); const hour = scheduledDate.getHours(); const day = scheduledDate.getDate(); const month = scheduledDate.getMonth() + 1; cronExpression = `${minute} ${hour} ${day} ${month} *`; fireOnce = true; // RFC 3339 datetime is always one-time logger.info(`[Scheduler] Converted RFC 3339 datetime to cron: ${cronExpression}`); } // Check if it's already a valid cron expression else if (this.isCronExpression(options.schedule)) { cronExpression = options.schedule; } else { // Parse as natural language const parseResult = NaturalLanguageParser.parseSchedule(options.schedule); if (!parseResult.success) { throw new Error(`Failed to parse schedule: ${parseResult.error}`); } cronExpression = parseResult.cronExpression!; // If parser determined it's a one-time execution, set fireOnce if (parseResult.fireOnce) { fireOnce = true; } } // Validate cron expression (all managers use the same validation logic) const cronValidation = this.scheduleManager instanceof TaskSchedulerManager ? TaskSchedulerManager.validateCronExpression(cronExpression) : CronManager.validateCronExpression(cronExpression); if (!cronValidation.valid) { throw new Error(`Invalid cron expression: ${cronValidation.error}`); } // Validate tool and parameters (unless explicitly skipped) if (!options.skipValidation) { logger.info(`[Scheduler] Validating tool and parameters for ${options.tool}`); const toolValidation = await this.toolValidator.validateTool( options.tool, options.parameters, { testRun: options.testRun, timeout: 30000 // 30 second timeout for test runs } ); if (!toolValidation.valid) { const errorMsg = `Tool validation failed:\n${toolValidation.errors.join('\n')}`; logger.error(`[Scheduler] ${errorMsg}`); throw new Error(errorMsg); } // Log warnings if any if (toolValidation.warnings.length > 0) { logger.warn(`[Scheduler] Validation warnings:\n${toolValidation.warnings.join('\n')}`); } // Log test execution result if performed if (toolValidation.testExecutionResult) { if (toolValidation.testExecutionResult.success) { logger.info(`[Scheduler] Test execution succeeded (${toolValidation.testExecutionResult.duration}ms)`); } else { logger.warn(`[Scheduler] Test execution failed: ${toolValidation.testExecutionResult.error}`); } } } else { logger.warn(`[Scheduler] Skipping tool validation (skipValidation=true)`); } // Create job object const jobId = uuidv4(); const job: ScheduledJob = { id: jobId, name: options.name, description: options.description, cronExpression, timezone, // Store IANA timezone tool: options.tool, parameters: options.parameters, fireOnce, maxExecutions: options.maxExecutions, endDate: options.endDate, createdAt: new Date().toISOString(), status: 'active', executionCount: 0, workingDirectory: process.cwd() // Save current working directory for execution }; // Save job to storage this.jobManager.createJob(job); // Add to crontab const ncpPath = this.getNCPExecutablePath(); const command = `${ncpPath} _job-run ${jobId}`; this.scheduleManager.addJob(jobId, cronExpression, command); // Set up automatic cleanup (only runs once, is idempotent) this.setupAutomaticCleanup(); logger.info(`[Scheduler] Job created successfully: ${job.name} (${jobId})`); logger.info(`[Scheduler] Schedule: ${cronExpression}`); logger.info(`[Scheduler] Command: ${command}`); return job; } /** * Check if string is a valid cron expression */ private isCronExpression(schedule: string): boolean { const parts = schedule.trim().split(/\s+/); return parts.length === 5 && CronManager.validateCronExpression(schedule).valid; } /** * Check if string is an RFC 3339 datetime with timezone * Examples: "2025-12-25T15:00:00-05:00", "2025-12-25T20:00:00Z" */ private isRFC3339DateTime(schedule: string): boolean { // RFC 3339 format includes date, time, and timezone offset // Must have 'T' separator and either 'Z' or timezone offset ('+'/'-') const rfc3339Pattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; return rfc3339Pattern.test(schedule.trim()); } /** * Get a job by ID */ getJob(jobId: string): ScheduledJob | null { return this.jobManager.getJob(jobId); } /** * Get a job by name */ getJobByName(name: string): ScheduledJob | null { return this.jobManager.getJobByName(name); } /** * List all jobs */ listJobs(statusFilter?: ScheduledJob['status']): ScheduledJob[] { if (statusFilter) { return this.jobManager.getJobsByStatus(statusFilter); } return this.jobManager.getAllJobs(); } /** * Update a job */ async updateJob(jobId: string, updates: Partial<CreateJobOptions>): Promise<ScheduledJob> { if (!this.scheduleManager) { throw new Error('Scheduler not available on this platform'); } const job = this.jobManager.getJob(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } // Build update object const jobUpdates: Partial<ScheduledJob> = {}; if (updates.name !== undefined) { jobUpdates.name = updates.name; } if (updates.description !== undefined) { jobUpdates.description = updates.description; } if (updates.tool !== undefined) { jobUpdates.tool = updates.tool; } if (updates.parameters !== undefined) { jobUpdates.parameters = updates.parameters; } if (updates.fireOnce !== undefined) { jobUpdates.fireOnce = updates.fireOnce; } if (updates.maxExecutions !== undefined) { jobUpdates.maxExecutions = updates.maxExecutions; } if (updates.endDate !== undefined) { jobUpdates.endDate = updates.endDate; } // Handle schedule update if (updates.schedule !== undefined) { let cronExpression: string; if (this.isCronExpression(updates.schedule)) { cronExpression = updates.schedule; } else { const parseResult = NaturalLanguageParser.parseSchedule(updates.schedule); if (!parseResult.success) { throw new Error(`Failed to parse schedule: ${parseResult.error}`); } cronExpression = parseResult.cronExpression!; } const validation = CronManager.validateCronExpression(cronExpression); if (!validation.valid) { throw new Error(`Invalid cron expression: ${validation.error}`); } jobUpdates.cronExpression = cronExpression; // Update crontab const ncpPath = this.getNCPExecutablePath(); const command = `${ncpPath} _job-run ${jobId}`; this.scheduleManager.addJob(jobId, cronExpression, command); } // Update job in storage this.jobManager.updateJob(jobId, jobUpdates); const updatedJob = this.jobManager.getJob(jobId)!; logger.info(`[Scheduler] Job updated: ${updatedJob.name} (${jobId})`); return updatedJob; } /** * Pause a job */ pauseJob(jobId: string): void { if (!this.scheduleManager) { throw new Error('Scheduler not available on this platform'); } const job = this.jobManager.getJob(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } // Remove from crontab this.scheduleManager.removeJob(jobId); // Update status this.jobManager.updateJob(jobId, { status: 'paused' }); logger.info(`[Scheduler] Job paused: ${job.name} (${jobId})`); } /** * Resume a paused job */ resumeJob(jobId: string): void { if (!this.scheduleManager) { throw new Error('Scheduler not available on this platform'); } const job = this.jobManager.getJob(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } if (job.status !== 'paused') { throw new Error(`Job is not paused (current status: ${job.status})`); } // Add back to crontab const ncpPath = this.getNCPExecutablePath(); const command = `${ncpPath} _job-run ${jobId}`; this.scheduleManager.addJob(jobId, job.cronExpression, command); // Update status this.jobManager.updateJob(jobId, { status: 'active' }); logger.info(`[Scheduler] Job resumed: ${job.name} (${jobId})`); } /** * Delete a job */ deleteJob(jobId: string): void { if (!this.scheduleManager) { throw new Error('Scheduler not available on this platform'); } const job = this.jobManager.getJob(jobId); if (!job) { throw new Error(`Job ${jobId} not found`); } // Remove from crontab this.scheduleManager.removeJob(jobId); // Delete from storage this.jobManager.deleteJob(jobId); logger.info(`[Scheduler] Job deleted: ${job.name} (${jobId})`); } /** * Get executions for a job */ getExecutions(jobId: string): ExecutionSummary[] { return this.executionRecorder.getExecutionsForJob(jobId); } /** * Query executions */ queryExecutions(filters?: { jobId?: string; status?: string; startDate?: string; endDate?: string; }): ExecutionSummary[] { return this.executionRecorder.queryExecutions(filters); } /** * Get execution statistics */ getExecutionStatistics(jobId?: string) { return this.executionRecorder.getStatistics(jobId); } /** * Get job statistics */ getJobStatistics() { return this.jobManager.getStatistics(); } /** * Clean up old executions */ async cleanupOldExecutions(maxAgeDays: number = 30, maxExecutionsPerJob: number = 100): Promise<void> { await this.jobExecutor.cleanupOldExecutions(maxAgeDays, maxExecutionsPerJob); } /** * Sync jobs with crontab (repair/reconcile) */ syncWithCrontab(): { added: number; removed: number; errors: string[] } { if (!this.scheduleManager) { throw new Error('Scheduler not available on this platform'); } const errors: string[] = []; let added = 0; let removed = 0; // Get all active jobs from storage const activeJobs = this.jobManager.getJobsByStatus('active'); // Get all jobs from crontab const cronJobs = this.scheduleManager.getJobs(); const cronJobIds = new Set(cronJobs.map(j => j.id)); // Add missing jobs to crontab for (const job of activeJobs) { if (!cronJobIds.has(job.id)) { try { const ncpPath = this.getNCPExecutablePath(); const command = `${ncpPath} _job-run ${job.id}`; this.scheduleManager.addJob(job.id, job.cronExpression, command); added++; logger.info(`[Scheduler] Added missing job to crontab: ${job.name}`); } catch (error) { errors.push(`Failed to add ${job.id}: ${error instanceof Error ? error.message : String(error)}`); } } } // Remove orphaned jobs from crontab const activeJobIds = new Set(activeJobs.map(j => j.id)); for (const cronJob of cronJobs) { if (!activeJobIds.has(cronJob.id)) { try { this.scheduleManager.removeJob(cronJob.id); removed++; logger.info(`[Scheduler] Removed orphaned job from crontab: ${cronJob.id}`); } catch (error) { errors.push(`Failed to remove ${cronJob.id}: ${error instanceof Error ? error.message : String(error)}`); } } } logger.info(`[Scheduler] Sync complete: added ${added}, removed ${removed}, errors ${errors.length}`); return { added, removed, errors }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/portel-dev/ncp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server