import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { CronExpressionParser } from 'cron-parser'; import { type CronJob, CronJobSchema, CronStoreSchema } from './types.ts'; export type OnJobCallback = (job: CronJob) => Promise; export class CronService { private _filePath: string; private _jobs: Map = new Map(); private _timers: Map> = new Map(); private _onJob: OnJobCallback; private _lastMtime = 0; constructor(workspacePath: string, onJob: OnJobCallback) { this._filePath = join(workspacePath, 'cron', 'jobs.json'); this._onJob = onJob; this._load(); } // --------------------------------------------------------------------------- // Persistence // --------------------------------------------------------------------------- private _load(): void { if (!existsSync(this._filePath)) return; try { const raw = readFileSync(this._filePath, 'utf8'); const store = CronStoreSchema.parse(JSON.parse(raw)); this._jobs = new Map(store.jobs.map((j) => [j.id, j])); this._lastMtime = statSync(this._filePath).mtimeMs; } catch { // start fresh on corrupt file } } private _save(): void { const store = CronStoreSchema.parse({ jobs: [...this._jobs.values()] }); const dir = this._filePath.replace(/\/[^/]+$/, ''); require('node:fs').mkdirSync(dir, { recursive: true }); writeFileSync(this._filePath, JSON.stringify(store, null, 2), 'utf8'); this._lastMtime = statSync(this._filePath).mtimeMs; } private _reloadIfChanged(): void { if (!existsSync(this._filePath)) return; const mtime = statSync(this._filePath).mtimeMs; if (mtime !== this._lastMtime) this._load(); } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- listJobs(): CronJob[] { this._reloadIfChanged(); return [...this._jobs.values()]; } addJob(job: Omit): CronJob { const now = Date.now(); const full = CronJobSchema.parse({ ...job, state: {}, createdAtMs: now, updatedAtMs: now }); this._jobs.set(full.id, full); this._save(); this._arm(full); return full; } removeJob(id: string): boolean { if (!this._jobs.has(id)) return false; this._clearTimer(id); this._jobs.delete(id); this._save(); return true; } enableJob(id: string, enabled: boolean): boolean { const job = this._jobs.get(id); if (!job) return false; this._jobs.set(id, { ...job, enabled, updatedAtMs: Date.now() }); this._save(); if (enabled) this._arm(this._jobs.get(id)!); else this._clearTimer(id); return true; } async runJob(id: string): Promise { const job = this._jobs.get(id); if (!job) return `Error: job ${id} not found`; await this._execute(job); return `Job ${id} executed.`; } status(): string { const jobs = this.listJobs(); if (jobs.length === 0) return 'No cron jobs configured.'; return jobs .map((j) => { const next = j.state.nextRunAtMs ? new Date(j.state.nextRunAtMs).toISOString() : 'N/A'; return `[${j.enabled ? 'ON' : 'OFF'}] ${j.id} "${j.name}" next=${next}`; }) .join('\n'); } /** Arm all loaded jobs. Call once after construction. */ start(): void { for (const job of this._jobs.values()) { if (job.enabled) this._arm(job); } } stop(): void { for (const id of this._timers.keys()) this._clearTimer(id); } // --------------------------------------------------------------------------- // Scheduling internals // --------------------------------------------------------------------------- private _arm(job: CronJob): void { this._clearTimer(job.id); if (!job.enabled) return; const delayMs = this._nextDelayMs(job); if (delayMs === null) return; const nextRunAtMs = Date.now() + delayMs; const updated: CronJob = { ...job, state: { ...job.state, nextRunAtMs }, updatedAtMs: Date.now() }; this._jobs.set(job.id, updated); this._save(); const timer = setTimeout(() => void this._tick(job.id), delayMs); this._timers.set(job.id, timer); } private async _tick(id: string): Promise { this._timers.delete(id); const job = this._jobs.get(id); if (!job || !job.enabled) return; await this._execute(job); // Re-arm unless it was deleted or is a one-shot const current = this._jobs.get(id); if (current && !current.deleteAfterRun) this._arm(current); } private async _execute(job: CronJob): Promise { try { await this._onJob(job); const updated: CronJob = { ...job, state: { ...job.state, lastRunAtMs: Date.now(), lastStatus: 'ok', lastError: null }, updatedAtMs: Date.now(), }; this._jobs.set(job.id, updated); if (job.deleteAfterRun) { this._jobs.delete(job.id); } this._save(); } catch (err) { const updated: CronJob = { ...job, state: { ...job.state, lastRunAtMs: Date.now(), lastStatus: 'error', lastError: String(err) }, updatedAtMs: Date.now(), }; this._jobs.set(job.id, updated); this._save(); } } private _nextDelayMs(job: CronJob): number | null { const { schedule } = job; const now = Date.now(); if (schedule.kind === 'at') { const delay = schedule.atMs - now; return delay > 0 ? delay : null; } if (schedule.kind === 'every') { const lastRun = job.state.lastRunAtMs ?? 0; const elapsed = now - lastRun; const delay = Math.max(0, schedule.everyMs - elapsed); return delay; } if (schedule.kind === 'cron') { try { const interval = CronExpressionParser.parse(schedule.expr, { tz: schedule.tz }); const next = interval.next(); return next.getTime() - now; } catch { console.error(`[cron] Failed to parse expression for job ${job.id}: ${schedule.expr}`); return null; } } return null; } private _clearTimer(id: string): void { const timer = this._timers.get(id); if (timer !== undefined) { clearTimeout(timer); this._timers.delete(id); } } }