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
-
.count_occurrences(expr, from:, to:, tz: nil) ⇒ Object
Count cron occurrences in the half-open window (from, to].
-
.matches?(cron_spec, time) ⇒ Boolean
Returns true iff ‘time` matches `cron_spec`.
-
.next_occurrence(expr, after:, tz: nil) ⇒ Object
Returns the next Time at or after ‘after + 1 minute` that matches.
-
.parse(expr) ⇒ Object
Parse a 5-field cron string into { field => SortedSet of ints }.
- .parse_field(spec, range, field_name) ⇒ Object
- .parse_item(item, range, field_name) ⇒ Object
-
.to_tz_time(epoch, tz) ⇒ Object
Build a Time at ‘epoch` expressed in `tz`.
-
.utc_tz?(tz) ⇒ Boolean
True if ‘tz` should be treated as UTC (nil, empty, or “UTC”).
-
.with_tz(tz) ⇒ Object
Run a block with ENV set.
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`).
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
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”).
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 |