Skip to main content
Glama
portel-dev

NCP - Natural Context Provider

by portel-dev
migration.ts8.53 kB
/** * Migration Utility - Convert from jobs.json (V1) to schedule.json (V2) * Migrates from one-job-per-schedule to timing-groups architecture */ import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs'; import { join } from 'path'; import { getSchedulerDirectory } from '../../utils/ncp-paths.js'; import { ScheduledJob, JobsStorage, ScheduledTask, TimingGroup, SchedulerStorage } from '../../types/scheduler.js'; import { cronToTimingId, cronToTimingName, normalizeCronExpression } from './cron-expression-utils.js'; import { logger } from '../../utils/logger.js'; export interface MigrationResult { success: boolean; jobsCount: number; tasksCount: number; timingsCount: number; backupPath?: string; error?: string; } export class SchedulerMigration { private schedulerDir: string; private oldJobsFile: string; private newScheduleFile: string; constructor() { this.schedulerDir = getSchedulerDirectory(); this.oldJobsFile = join(this.schedulerDir, 'jobs.json'); this.newScheduleFile = join(this.schedulerDir, 'schedule.json'); } /** * Check if migration is needed */ needsMigration(): boolean { // Migration needed if: // 1. Old jobs.json exists // 2. New schedule.json doesn't exist OR is older than jobs.json if (!existsSync(this.oldJobsFile)) { return false; } if (!existsSync(this.newScheduleFile)) { return true; } // If both exist, check which is newer const oldStats = require('fs').statSync(this.oldJobsFile); const newStats = require('fs').statSync(this.newScheduleFile); // If jobs.json is newer, migration may be needed return oldStats.mtime > newStats.mtime; } /** * Perform migration from V1 (jobs) to V2 (tasks + timings) */ migrate(): MigrationResult { try { logger.info('[Migration] Starting migration from jobs.json to schedule.json'); // Load old jobs const oldStorage = this.loadOldJobs(); if (!oldStorage || Object.keys(oldStorage.jobs).length === 0) { logger.info('[Migration] No jobs to migrate'); return { success: true, jobsCount: 0, tasksCount: 0, timingsCount: 0 }; } const jobs = Object.values(oldStorage.jobs); logger.info(`[Migration] Found ${jobs.length} jobs to migrate`); // Group jobs by cron expression const jobsByTiming = this.groupJobsByTiming(jobs); logger.info(`[Migration] Grouped into ${jobsByTiming.size} unique timings`); // Create new storage structure const newStorage: SchedulerStorage = { version: '2.0.0', tasks: {}, timings: {} }; // Convert each timing group for (const [cronExpression, jobsInTiming] of jobsByTiming) { const timingId = cronToTimingId(cronExpression); const timingName = cronToTimingName(cronExpression); // Create timing group const timing: TimingGroup = { id: timingId, name: timingName, cronExpression, timezone: jobsInTiming[0].timezone, // Use timezone from first job taskIds: [], createdAt: new Date().toISOString() }; // Convert each job in this timing to a task for (const job of jobsInTiming) { const task: ScheduledTask = { id: job.id, // Keep same ID for continuity name: job.name, description: job.description, timingId, tool: job.tool, parameters: job.parameters, fireOnce: job.fireOnce, maxExecutions: job.maxExecutions, endDate: job.endDate, createdAt: job.createdAt, status: job.status, errorMessage: job.errorMessage, workingDirectory: job.workingDirectory, executionCount: job.executionCount, lastExecutionId: job.lastExecutionId, lastExecutionAt: job.lastExecutionAt }; newStorage.tasks[task.id] = task; timing.taskIds.push(task.id); } newStorage.timings[timingId] = timing; } // Backup old jobs.json const backupPath = this.backupOldJobs(); // Write new schedule.json this.writeNewSchedule(newStorage); logger.info(`[Migration] Migration successful:`); logger.info(` - Jobs migrated: ${jobs.length}`); logger.info(` - Tasks created: ${Object.keys(newStorage.tasks).length}`); logger.info(` - Timings created: ${Object.keys(newStorage.timings).length}`); logger.info(` - Backup created: ${backupPath}`); return { success: true, jobsCount: jobs.length, tasksCount: Object.keys(newStorage.tasks).length, timingsCount: Object.keys(newStorage.timings).length, backupPath }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`[Migration] Migration failed: ${errorMessage}`); return { success: false, jobsCount: 0, tasksCount: 0, timingsCount: 0, error: errorMessage }; } } /** * Load jobs from old jobs.json */ private loadOldJobs(): JobsStorage | null { if (!existsSync(this.oldJobsFile)) { return null; } try { const content = readFileSync(this.oldJobsFile, 'utf-8'); return JSON.parse(content); } catch (error) { logger.error(`[Migration] Failed to load jobs.json: ${error}`); return null; } } /** * Group jobs by cron expression (normalized) */ private groupJobsByTiming(jobs: ScheduledJob[]): Map<string, ScheduledJob[]> { const groups = new Map<string, ScheduledJob[]>(); for (const job of jobs) { const normalized = normalizeCronExpression(job.cronExpression); if (!groups.has(normalized)) { groups.set(normalized, []); } groups.get(normalized)!.push(job); } return groups; } /** * Backup old jobs.json */ private backupOldJobs(): string { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = join(this.schedulerDir, `jobs.json.backup-${timestamp}`); try { renameSync(this.oldJobsFile, backupPath); logger.info(`[Migration] Backed up jobs.json to ${backupPath}`); return backupPath; } catch (error) { logger.warn(`[Migration] Failed to backup jobs.json: ${error}`); // Don't fail migration if backup fails return ''; } } /** * Write new schedule.json */ private writeNewSchedule(storage: SchedulerStorage): void { try { const content = JSON.stringify(storage, null, 2); writeFileSync(this.newScheduleFile, content, 'utf-8'); logger.info(`[Migration] Wrote schedule.json with ${Object.keys(storage.tasks).length} tasks`); } catch (error) { throw new Error(`Failed to write schedule.json: ${error}`); } } /** * Get migration summary for display */ getMigrationSummary(): string { if (!this.needsMigration()) { return 'No migration needed - already using V2 format'; } const oldStorage = this.loadOldJobs(); if (!oldStorage) { return 'No jobs.json found'; } const jobCount = Object.keys(oldStorage.jobs).length; const jobs = Object.values(oldStorage.jobs); const timingGroups = this.groupJobsByTiming(jobs); return `Migration available:\n` + ` Jobs to migrate: ${jobCount}\n` + ` Will create ${timingGroups.size} timing groups\n` + ` OS scheduler entries will reduce from ${jobCount} to ${timingGroups.size}`; } } /** * Auto-migrate if needed (called on scheduler initialization) */ export async function autoMigrate(): Promise<void> { const migration = new SchedulerMigration(); if (!migration.needsMigration()) { logger.debug('[Migration] No migration needed'); return; } logger.info('[Migration] Auto-migration triggered'); logger.info('[Migration] ' + migration.getMigrationSummary()); const result = migration.migrate(); if (result.success) { logger.info('[Migration] Auto-migration completed successfully'); logger.info(`[Migration] Note: OS scheduler entries need to be updated manually or via sync command`); } else { logger.error(`[Migration] Auto-migration failed: ${result.error}`); logger.error('[Migration] Falling back to old jobs.json format'); } }

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