SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
4.1 KB · 115 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/daemon/cron.ts4 * Description: Dependency-free cron parser (5 fields: min hour dom month dow) with next-run computation (v2 design §7).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910export interface CronSpec {11  minutes: Set<number>;12  hours: Set<number>;13  daysOfMonth: Set<number>;14  months: Set<number>;15  daysOfWeek: Set<number>;16}1718function parseField(field: string, min: number, max: number): Set<number> | null {19  const out = new Set<number>();20  for (const part of field.split(",")) {21    const step = /^(\*|\d+(?:-\d+)?)\/(\d+)$/.exec(part);22    if (step !== null) {23      const stepBy = Number.parseInt(step[2] as string, 10);24      if (Number.isNaN(stepBy) || stepBy <= 0) return null;25      let lo = min;26      let hi = max;27      const range = step[1] as string;28      if (range !== "*") {29        const dash = range.split("-");30        lo = Number.parseInt(dash[0] as string, 10);31        hi = dash.length > 1 ? Number.parseInt(dash[1] as string, 10) : max;32      }33      for (let v = lo; v <= hi; v += stepBy) out.add(v);34      continue;35    }36    if (part === "*") {37      for (let v = min; v <= max; v += 1) out.add(v);38      continue;39    }40    const dash = /^(\d+)-(\d+)$/.exec(part);41    if (dash !== null) {42      const lo = Number.parseInt(dash[1] as string, 10);43      const hi = Number.parseInt(dash[2] as string, 10);44      if (lo < min || hi > max || lo > hi) return null;45      for (let v = lo; v <= hi; v += 1) out.add(v);46      continue;47    }48    const value = Number.parseInt(part, 10);49    if (Number.isNaN(value) || value < min || value > max) return null;50    out.add(value);51  }52  return out.size > 0 ? out : null;53}5455/** Parse a 5-field cron expression. Returns null when invalid. */56export function parseCron(expr: string): CronSpec | null {57  const fields = expr.trim().split(/\s+/);58  if (fields.length !== 5) return null;59  const minutes = parseField(fields[0] as string, 0, 59);60  const hours = parseField(fields[1] as string, 0, 23);61  const daysOfMonth = parseField(fields[2] as string, 1, 31);62  const months = parseField(fields[3] as string, 1, 12);63  const daysOfWeek = parseField(fields[4] as string, 0, 7);64  if (minutes === null || hours === null || daysOfMonth === null || months === null || daysOfWeek === null) {65    return null;66  }67  // Cron convention: 7 == 0 == Sunday.68  if (daysOfWeek.has(7)) daysOfWeek.add(0);69  return { minutes, hours, daysOfMonth, months, daysOfWeek };70}7172function matches(spec: CronSpec, date: Date): boolean {73  return (74    spec.minutes.has(date.getMinutes()) &&75    spec.hours.has(date.getHours()) &&76    spec.daysOfMonth.has(date.getDate()) &&77    spec.months.has(date.getMonth() + 1) &&78    spec.daysOfWeek.has(date.getDay())79  );80}8182const MAX_SCAN_MINUTES = 366 * 24 * 60;8384/** Next fire time strictly after `from`. Null when the spec can never fire within a year. */85export function nextCronRun(spec: CronSpec, from: Date): Date | null {86  const cursor = new Date(from.getTime());87  cursor.setSeconds(0, 0);88  for (let i = 0; i < MAX_SCAN_MINUTES; i += 1) {89    cursor.setTime(cursor.getTime() + 60_000);90    if (matches(spec, cursor)) return new Date(cursor.getTime());91  }92  return null;93}9495/** Parse "07:00-23:00"-style active hours. Null = always active. */96export function parseActiveHours(value: string | undefined): { start: number; end: number } | null {97  if (value === undefined) return null;98  const match = /^(\d{1,2}):(\d{2})-(\d{1,2}):(\d{2})$/.exec(value.trim());99  if (match === null) return null;100  const start = Number.parseInt(match[1] as string, 10) * 60 + Number.parseInt(match[2] as string, 10);101  const end = Number.parseInt(match[3] as string, 10) * 60 + Number.parseInt(match[4] as string, 10);102  return { start, end };103}104105/** True when `date` falls inside the active-hours window (wrapping windows supported). */106export function withinActiveHours(107  hours: { start: number; end: number } | null,108  date: Date,109): boolean {110  if (hours === null) return true;111  const minute = date.getHours() * 60 + date.getMinutes();112  if (hours.start <= hours.end) return minute >= hours.start && minute < hours.end;113  return minute >= hours.start || minute < hours.end;114}115