import { randomUUID } from 'node:crypto'; import * as fs from 'fs'; import * as path from 'path'; import type { CronJob, CronJobCreateInput, CronJobDelivery, CronJobLastRun, CronJobUpdateInput, CronSchedule, } from '@src/lib/cron-types'; import { getUserDataDir } from './paths'; interface StoredCronJob extends CronJob { agentId?: string | null; } interface CronStore { jobs: StoredCronJob[]; } const CRON_STORE_PATH = path.join(getUserDataDir(), 'cron', 'jobs.json'); const MAX_CRON_LOOKAHEAD_MINUTES = 366 * 24 * 60; function readStore(): CronStore { try { if (fs.existsSync(CRON_STORE_PATH)) { const parsed = JSON.parse(fs.readFileSync(CRON_STORE_PATH, 'utf-8')) as Partial; return { jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [], }; } } catch { // Fall back to an empty store on malformed JSON. } return { jobs: [] }; } function writeStore(store: CronStore): void { fs.mkdirSync(path.dirname(CRON_STORE_PATH), { recursive: true }); fs.writeFileSync(CRON_STORE_PATH, JSON.stringify(store, null, 2), 'utf-8'); } function normalizeString(value: unknown): string { return String(value ?? '').trim(); } function normalizeIsoDate(value: unknown, fallback?: string): string { const raw = normalizeString(value); const ms = Date.parse(raw); if (Number.isFinite(ms)) { return new Date(ms).toISOString(); } if (fallback) { return fallback; } return new Date().toISOString(); } function normalizeDelivery(value: unknown): CronJobDelivery | undefined { if (!value || typeof value !== 'object') { return { mode: 'none' }; } const input = value as Partial; const mode = input.mode === 'announce' ? 'announce' : 'none'; if (mode === 'announce') { const channel = normalizeString(input.channel); const to = normalizeString(input.to); const accountId = normalizeString(input.accountId); return { mode, channel: channel || undefined, to: to || undefined, accountId: accountId || undefined, }; } return { mode: 'none' }; } function normalizeLastRun(value: unknown): CronJobLastRun | undefined { if (!value || typeof value !== 'object') return undefined; const input = value as Partial; const time = normalizeString(input.time); if (!time) return undefined; return { time: normalizeIsoDate(time), success: input.success !== false, error: normalizeString(input.error) || undefined, duration: typeof input.duration === 'number' && Number.isFinite(input.duration) ? input.duration : undefined, }; } function normalizeSchedule(value: unknown, fallback?: CronJob['schedule']): CronJob['schedule'] | null { if (typeof value === 'string') { const trimmed = value.trim(); return trimmed || fallback || null; } if (!value || typeof value !== 'object') { return fallback || null; } const input = value as Partial; if (input.kind === 'at') { const at = normalizeString(input.at); return at ? { kind: 'at', at: normalizeIsoDate(at) } : fallback || null; } if (input.kind === 'every') { const everyMs = typeof input.everyMs === 'number' && Number.isFinite(input.everyMs) ? input.everyMs : 0; if (everyMs <= 0) return fallback || null; return { kind: 'every', everyMs, anchorMs: typeof input.anchorMs === 'number' && Number.isFinite(input.anchorMs) ? input.anchorMs : undefined, }; } if (input.kind === 'cron') { const expr = normalizeString(input.expr); return expr ? { kind: 'cron', expr, tz: normalizeString(input.tz) || undefined, } : fallback || null; } return fallback || null; } function parseCronNumberToken(token: string, min: number, max: number, isDayOfWeek = false): number | null { if (!/^\d+$/.test(token)) return null; const parsed = Number(token); if (!Number.isFinite(parsed)) return null; if (isDayOfWeek && parsed === 7) return 0; if (parsed < min || parsed > max) return null; return parsed; } function matchesCronField(expression: string, value: number, min: number, max: number, isDayOfWeek = false): boolean { const trimmed = expression.trim(); if (!trimmed) return false; if (trimmed === '*') return true; return trimmed.split(',').some((segment) => { const part = segment.trim(); if (!part) return false; if (part === '*') return true; const [rangeExpression, stepExpression] = part.split('/'); const step = stepExpression ? Number(stepExpression) : 1; if (!Number.isFinite(step) || step <= 0) return false; if (rangeExpression === '*') { return (value - min) % step === 0; } if (rangeExpression.includes('-')) { const [startRaw, endRaw] = rangeExpression.split('-'); const start = parseCronNumberToken(startRaw.trim(), min, max, isDayOfWeek); const end = parseCronNumberToken(endRaw.trim(), min, max, isDayOfWeek); if (start == null || end == null || start > end) return false; if (value < start || value > end) return false; return (value - start) % step === 0; } const literal = parseCronNumberToken(rangeExpression.trim(), min, max, isDayOfWeek); if (literal == null) return false; return value === literal; }); } function estimateNextCronRun(expr: string, now = new Date()): string | undefined { const parts = expr.trim().split(/\s+/); if (parts.length !== 5) return undefined; const [minuteExpr, hourExpr, dayOfMonthExpr, monthExpr, dayOfWeekExpr] = parts; const candidate = new Date(now); candidate.setSeconds(0, 0); candidate.setMinutes(candidate.getMinutes() + 1); for (let index = 0; index < MAX_CRON_LOOKAHEAD_MINUTES; index += 1) { const minute = candidate.getMinutes(); const hour = candidate.getHours(); const dayOfMonth = candidate.getDate(); const month = candidate.getMonth() + 1; const dayOfWeek = candidate.getDay(); if ( matchesCronField(minuteExpr, minute, 0, 59) && matchesCronField(hourExpr, hour, 0, 23) && matchesCronField(dayOfMonthExpr, dayOfMonth, 1, 31) && matchesCronField(monthExpr, month, 1, 12) && matchesCronField(dayOfWeekExpr, dayOfWeek, 0, 7, true) ) { return candidate.toISOString(); } candidate.setMinutes(candidate.getMinutes() + 1); } return undefined; } function estimateNextRun(schedule: CronJob['schedule'], enabled: boolean): string | undefined { if (!enabled) return undefined; if (typeof schedule === 'string') { return estimateNextCronRun(schedule); } if (schedule.kind === 'at') { const atMs = Date.parse(schedule.at); if (!Number.isFinite(atMs) || atMs <= Date.now()) return undefined; return new Date(atMs).toISOString(); } if (schedule.kind === 'every') { if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0) return undefined; const nowMs = Date.now(); const anchorMs = typeof schedule.anchorMs === 'number' && Number.isFinite(schedule.anchorMs) ? schedule.anchorMs : nowMs; const delta = Math.max(nowMs - anchorMs, 0); const steps = Math.floor(delta / schedule.everyMs) + 1; return new Date(anchorMs + steps * schedule.everyMs).toISOString(); } return estimateNextCronRun(schedule.expr); } function normalizeStoredJob(input: Partial | null | undefined): StoredCronJob | null { if (!input || typeof input !== 'object') return null; const id = normalizeString(input.id); const name = normalizeString(input.name); const message = normalizeString(input.message); const schedule = normalizeSchedule(input.schedule); if (!id || !name || !message || !schedule) { return null; } const createdAt = normalizeIsoDate(input.createdAt); const updatedAt = normalizeIsoDate(input.updatedAt, createdAt); const enabled = input.enabled !== false; return { id, name, message, schedule, delivery: normalizeDelivery(input.delivery), enabled, createdAt, updatedAt, lastRun: normalizeLastRun(input.lastRun), nextRun: estimateNextRun(schedule, enabled), agentId: normalizeString(input.agentId) || undefined, }; } function listNormalizedJobs(): StoredCronJob[] { return readStore().jobs .map((job) => normalizeStoredJob(job)) .filter((job): job is StoredCronJob => Boolean(job)) .sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt)); } function writeJobs(jobs: StoredCronJob[]): StoredCronJob[] { writeStore({ jobs }); return jobs; } function ensureCreateInput(input: CronJobCreateInput): void { if (!normalizeString(input.name)) { throw new Error('name is required'); } if (!normalizeString(input.message)) { throw new Error('message is required'); } if (!normalizeString(input.schedule)) { throw new Error('schedule is required'); } } export function listCronJobs(): CronJob[] { return listNormalizedJobs(); } export function createCronJob(input: CronJobCreateInput & { agentId?: string | null }): CronJob { ensureCreateInput(input); const jobs = listNormalizedJobs(); const now = new Date().toISOString(); const schedule = normalizeSchedule(input.schedule); if (!schedule) { throw new Error('schedule is required'); } const nextJob = normalizeStoredJob({ id: `cron-${randomUUID()}`, name: input.name, message: input.message, schedule, delivery: input.delivery, enabled: input.enabled !== false, createdAt: now, updatedAt: now, agentId: input.agentId, }); if (!nextJob) { throw new Error('Failed to create cron job'); } writeJobs([...jobs, nextJob]); return nextJob; } export function updateCronJob(jobId: string, input: CronJobUpdateInput & { agentId?: string | null }): CronJob { const normalizedJobId = normalizeString(jobId); if (!normalizedJobId) { throw new Error('id is required'); } const jobs = listNormalizedJobs(); const index = jobs.findIndex((job) => job.id === normalizedJobId); if (index === -1) { throw new Error(`Cron job "${normalizedJobId}" not found`); } const currentJob = jobs[index]; const schedule = normalizeSchedule(input.schedule, currentJob.schedule); const nextJob = normalizeStoredJob({ ...currentJob, name: normalizeString(input.name) || currentJob.name, message: normalizeString(input.message) || currentJob.message, schedule, delivery: typeof input.delivery === 'undefined' ? currentJob.delivery : input.delivery, enabled: typeof input.enabled === 'boolean' ? input.enabled : currentJob.enabled, updatedAt: new Date().toISOString(), agentId: typeof input.agentId === 'undefined' ? currentJob.agentId : input.agentId, }); if (!nextJob) { throw new Error(`Cron job "${normalizedJobId}" could not be normalized`); } jobs[index] = nextJob; writeJobs(jobs); return nextJob; } export function deleteCronJob(jobId: string): { id: string } { const normalizedJobId = normalizeString(jobId); if (!normalizedJobId) { throw new Error('id is required'); } const jobs = listNormalizedJobs(); const nextJobs = jobs.filter((job) => job.id !== normalizedJobId); if (nextJobs.length === jobs.length) { throw new Error(`Cron job "${normalizedJobId}" not found`); } writeJobs(nextJobs); return { id: normalizedJobId }; } export function toggleCronJob(jobId: string, enabled: boolean): CronJob { return updateCronJob(jobId, { enabled }); } export function triggerCronJob(jobId: string): CronJob { const normalizedJobId = normalizeString(jobId); if (!normalizedJobId) { throw new Error('id is required'); } const jobs = listNormalizedJobs(); const index = jobs.findIndex((job) => job.id === normalizedJobId); if (index === -1) { throw new Error(`Cron job "${normalizedJobId}" not found`); } const currentJob = jobs[index]; const nextJob = normalizeStoredJob({ ...currentJob, updatedAt: new Date().toISOString(), lastRun: { time: new Date().toISOString(), success: true, }, }); if (!nextJob) { throw new Error(`Cron job "${normalizedJobId}" could not be normalized`); } jobs[index] = nextJob; writeJobs(jobs); return nextJob; }