Module: KairosMcp::Daemon::Chronos::Cron

Defined in:
lib/kairos_mcp/daemon/chronos.rb

Overview

Cron module — 5-field cron parser and evaluator.

Fields (standard cron):

minute (0-59), hour (0-23), day-of-month (1-31),
month (1-12), day-of-week (0-6, 0=Sunday)

Supported syntax per field:

*            — any value
N            — exact value
N-M          — inclusive range
N,M,O        — list of values
*/N          — every N (starting from field minimum)
N-M/S        — every S in N..M

Standard dom/dow OR semantics:

Both restricted  → fire if EITHER matches.
One wildcard     → fire iff the other matches.
Both wildcard    → fire if all other fields match.

Constant Summary collapse

FIELDS =
%i[minute hour mday month wday].freeze
RANGES =
{
  minute: 0..59,
  hour:   0..23,
  mday:   1..31,
  month:  1..12,
  wday:   0..6
}.freeze

Class Method Summary collapse

Class Method Details

.count_occurrences(expr, from:, to:, tz: nil) ⇒ Object

Count cron occurrences in the half-open window (from, to].

Uses a per-minute iterator — trivially fast for realistic windows (48h = 2880 iterations). If ‘from >= to`, returns 0.



568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
# File 'lib/kairos_mcp/daemon/chronos.rb', line 568

def count_occurrences(expr, from:, to:, tz: nil)
  cron = parse(expr)
  return 0 if from >= to

  # Start at the first minute strictly after `from`.
  start_epoch = ((from.to_i / 60) + 1) * 60
  end_epoch   = (to.to_i / 60) * 60
  return 0 if start_epoch > end_epoch

  count = 0
  with_tz(tz) do
    t = to_tz_time(start_epoch, tz)
    while t.to_i <= end_epoch
      count += 1 if matches?(cron, t)
      t += 60
    end
  end
  count
end

.matches?(cron_spec, time) ⇒ Boolean

Returns true iff ‘time` matches `cron_spec`. `time` should be in the target timezone already (see `with_tz`).

Returns:

  • (Boolean)


543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
# File 'lib/kairos_mcp/daemon/chronos.rb', line 543

def matches?(cron_spec, time)
  return false unless cron_spec[:minute].include?(time.min)
  return false unless cron_spec[:hour].include?(time.hour)
  return false unless cron_spec[:month].include?(time.month)

  dom_wild = cron_spec[:mday] == RANGES[:mday].to_a
  dow_wild = cron_spec[:wday] == RANGES[:wday].to_a
  dom_ok   = cron_spec[:mday].include?(time.mday)
  dow_ok   = cron_spec[:wday].include?(time.wday)

  if dom_wild && dow_wild
    true
  elsif dom_wild
    dow_ok
  elsif dow_wild
    dom_ok
  else
    dom_ok || dow_ok
  end
end

.next_occurrence(expr, after:, tz: nil) ⇒ Object

Returns the next Time at or after ‘after + 1 minute` that matches. Returns nil if none within ~2 years (safety ceiling against bad cron expressions that will never match).



591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
# File 'lib/kairos_mcp/daemon/chronos.rb', line 591

def next_occurrence(expr, after:, tz: nil)
  cron = parse(expr)
  start_epoch = ((after.to_i / 60) + 1) * 60
  max_iter    = 366 * 24 * 60 * 2 # ~2 years of minutes

  with_tz(tz) do
    t = to_tz_time(start_epoch, tz)
    max_iter.times do
      return t if matches?(cron, t)

      t += 60
    end
  end
  nil
end

.parse(expr) ⇒ Object

Parse a 5-field cron string into { field => SortedSet of ints }. Raises ArgumentError on malformed input.



482
483
484
485
486
487
488
489
490
491
# File 'lib/kairos_mcp/daemon/chronos.rb', line 482

def parse(expr)
  parts = expr.to_s.strip.split(/\s+/)
  unless parts.size == 5
    raise ArgumentError, "cron must have 5 fields, got #{parts.size}: #{expr.inspect}"
  end

  FIELDS.each_with_index.each_with_object({}) do |(field, i), acc|
    acc[field] = parse_field(parts[i], RANGES[field], field)
  end
end

.parse_field(spec, range, field_name) ⇒ Object

Raises:

  • (ArgumentError)


493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
# File 'lib/kairos_mcp/daemon/chronos.rb', line 493

def parse_field(spec, range, field_name)
  spec = spec.to_s.strip
  raise ArgumentError, "empty field #{field_name}" if spec.empty?

  return range.to_a if spec == '*'

  values = []
  spec.split(',').each do |item|
    values.concat(parse_item(item, range, field_name))
  end
  result = values.sort.uniq
  result.each do |v|
    unless range.cover?(v)
      raise ArgumentError,
            "cron value #{v} out of range for #{field_name} (#{range})"
    end
  end
  result
end

.parse_item(item, range, field_name) ⇒ Object



513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
# File 'lib/kairos_mcp/daemon/chronos.rb', line 513

def parse_item(item, range, field_name)
  case item
  when /\A\*\/(\d+)\z/
    step = Integer(Regexp.last_match(1))
    raise ArgumentError, "step must be positive in #{field_name}" if step <= 0

    range.step(step).to_a
  when /\A(\d+)-(\d+)\/(\d+)\z/
    a = Integer(Regexp.last_match(1))
    b = Integer(Regexp.last_match(2))
    step = Integer(Regexp.last_match(3))
    raise ArgumentError, "step must be positive in #{field_name}" if step <= 0
    raise ArgumentError, "range #{a}-#{b} inverted in #{field_name}" if a > b

    (a..b).step(step).to_a
  when /\A(\d+)-(\d+)\z/
    a = Integer(Regexp.last_match(1))
    b = Integer(Regexp.last_match(2))
    raise ArgumentError, "range #{a}-#{b} inverted in #{field_name}" if a > b

    (a..b).to_a
  when /\A(\d+)\z/
    [Integer(Regexp.last_match(1))]
  else
    raise ArgumentError, "malformed cron item #{item.inspect} in #{field_name}"
  end
end

.to_tz_time(epoch, tz) ⇒ Object

Build a Time at ‘epoch` expressed in `tz`. For UTC we force `.utc` so the system TZ (via `getlocal`) cannot leak in.



615
616
617
618
# File 'lib/kairos_mcp/daemon/chronos.rb', line 615

def to_tz_time(epoch, tz)
  t = Time.at(epoch)
  utc_tz?(tz) ? t.utc : t.getlocal
end

.utc_tz?(tz) ⇒ Boolean

True if ‘tz` should be treated as UTC (nil, empty, or “UTC”).

Returns:

  • (Boolean)


608
609
610
611
# File 'lib/kairos_mcp/daemon/chronos.rb', line 608

def utc_tz?(tz)
  tz = tz.to_s.strip if tz.is_a?(String)
  tz.nil? || tz == '' || tz == 'UTC'
end

.with_tz(tz) ⇒ Object

Run a block with ENV set. Safe in single-threaded code; the daemon event loop never runs concurrently, and tests use explicit TZ or UTC. If ‘tz` is nil/empty/UTC, no swap occurs —the caller must still use `to_tz_time` so that the UTC branch actually produces a UTC Time rather than a system-local one.



625
626
627
628
629
630
631
632
633
634
635
636
637
# File 'lib/kairos_mcp/daemon/chronos.rb', line 625

def with_tz(tz)
  if utc_tz?(tz)
    yield
  else
    old = ENV['TZ']
    begin
      ENV['TZ'] = tz.to_s
      yield
    ensure
      ENV['TZ'] = old
    end
  end
end