earl-bot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. checksums.yaml +7 -0
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +40 -0
  4. data/CLAUDE.md +260 -0
  5. data/Gemfile +9 -0
  6. data/Gemfile.lock +177 -0
  7. data/LICENSE +21 -0
  8. data/README.md +106 -0
  9. data/Rakefile +11 -0
  10. data/bin/README.md +21 -0
  11. data/bin/ci +49 -0
  12. data/bin/claude-context +155 -0
  13. data/bin/claude-usage +110 -0
  14. data/bin/coverage +221 -0
  15. data/bin/rubocop +10 -0
  16. data/bin/watch-ci +198 -0
  17. data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
  18. data/config/earl-claude-home/.claude/settings.json +34 -0
  19. data/earl-bot.gemspec +42 -0
  20. data/exe/earl +51 -0
  21. data/exe/earl-install +129 -0
  22. data/exe/earl-permission-server +39 -0
  23. data/lib/earl/claude_session/stats.rb +76 -0
  24. data/lib/earl/claude_session.rb +468 -0
  25. data/lib/earl/command_executor/constants.rb +53 -0
  26. data/lib/earl/command_executor/heartbeat_display.rb +54 -0
  27. data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
  28. data/lib/earl/command_executor/session_handler.rb +126 -0
  29. data/lib/earl/command_executor/spawn_handler.rb +99 -0
  30. data/lib/earl/command_executor/stats_formatter.rb +66 -0
  31. data/lib/earl/command_executor/usage_handler.rb +132 -0
  32. data/lib/earl/command_executor.rb +128 -0
  33. data/lib/earl/command_parser.rb +57 -0
  34. data/lib/earl/config.rb +94 -0
  35. data/lib/earl/cron_parser.rb +105 -0
  36. data/lib/earl/formatting.rb +14 -0
  37. data/lib/earl/heartbeat_config.rb +101 -0
  38. data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
  39. data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
  40. data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
  41. data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
  42. data/lib/earl/heartbeat_scheduler.rb +131 -0
  43. data/lib/earl/logging.rb +12 -0
  44. data/lib/earl/mattermost/api_client.rb +85 -0
  45. data/lib/earl/mattermost.rb +261 -0
  46. data/lib/earl/mcp/approval_handler.rb +304 -0
  47. data/lib/earl/mcp/config.rb +62 -0
  48. data/lib/earl/mcp/github_pat_handler.rb +450 -0
  49. data/lib/earl/mcp/handler_base.rb +13 -0
  50. data/lib/earl/mcp/heartbeat_handler.rb +310 -0
  51. data/lib/earl/mcp/memory_handler.rb +89 -0
  52. data/lib/earl/mcp/server.rb +123 -0
  53. data/lib/earl/mcp/tmux_handler.rb +562 -0
  54. data/lib/earl/memory/prompt_builder.rb +40 -0
  55. data/lib/earl/memory/store.rb +125 -0
  56. data/lib/earl/message_queue.rb +56 -0
  57. data/lib/earl/permission_config.rb +22 -0
  58. data/lib/earl/question_handler/question_posting.rb +58 -0
  59. data/lib/earl/question_handler.rb +116 -0
  60. data/lib/earl/runner/idle_management.rb +44 -0
  61. data/lib/earl/runner/lifecycle.rb +73 -0
  62. data/lib/earl/runner/message_handling.rb +121 -0
  63. data/lib/earl/runner/reaction_handling.rb +42 -0
  64. data/lib/earl/runner/response_lifecycle.rb +96 -0
  65. data/lib/earl/runner/service_builder.rb +48 -0
  66. data/lib/earl/runner/startup.rb +73 -0
  67. data/lib/earl/runner/thread_context_builder.rb +43 -0
  68. data/lib/earl/runner.rb +70 -0
  69. data/lib/earl/safari_automation.rb +497 -0
  70. data/lib/earl/session_manager/persistence.rb +46 -0
  71. data/lib/earl/session_manager/session_creation.rb +108 -0
  72. data/lib/earl/session_manager.rb +92 -0
  73. data/lib/earl/session_store.rb +84 -0
  74. data/lib/earl/streaming_response.rb +219 -0
  75. data/lib/earl/tmux/parsing.rb +80 -0
  76. data/lib/earl/tmux/processes.rb +34 -0
  77. data/lib/earl/tmux/sessions.rb +41 -0
  78. data/lib/earl/tmux.rb +122 -0
  79. data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
  80. data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
  81. data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
  82. data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
  83. data/lib/earl/tmux_monitor.rb +249 -0
  84. data/lib/earl/tmux_session_store.rb +133 -0
  85. data/lib/earl/tool_input_formatter.rb +44 -0
  86. data/lib/earl/version.rb +5 -0
  87. data/lib/earl.rb +87 -0
  88. data/lib/tasks/.keep +1 -0
  89. metadata +248 -0
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ # Holds and validates environment-based configuration for Mattermost
5
+ # connectivity, bot credentials, and channel targeting.
6
+ class Config
7
+ # Groups Mattermost server URL and bot authentication credentials.
8
+ MattermostCredentials = Struct.new(:url, :bot_token, :bot_id, keyword_init: true)
9
+
10
+ attr_reader :credentials, :channel_id, :allowed_users, :skip_permissions
11
+
12
+ alias skip_permissions? skip_permissions
13
+
14
+ def initialize
15
+ @credentials = build_credentials
16
+ @channel_id = required_env("EARL_CHANNEL_ID")
17
+ @allowed_users = ENV.fetch("EARL_ALLOWED_USERS", "").split(",").map(&:strip)
18
+ @skip_permissions = ENV.fetch("EARL_SKIP_PERMISSIONS", "false").downcase == "true"
19
+ end
20
+
21
+ def mattermost_url
22
+ credentials.url
23
+ end
24
+
25
+ def bot_token
26
+ credentials.bot_token
27
+ end
28
+
29
+ def bot_id
30
+ credentials.bot_id
31
+ end
32
+
33
+ def websocket_url
34
+ "#{mattermost_url.sub(%r{^https://}, "wss://").sub(%r{^http://}, "ws://")}/api/v4/websocket"
35
+ end
36
+
37
+ def api_url(path)
38
+ "#{mattermost_url}/api/v4#{path}"
39
+ end
40
+
41
+ def channels
42
+ @channels ||= parse_channels
43
+ end
44
+
45
+ # Builds the env hash for the MCP permission server.
46
+ # Returns nil when permissions are globally skipped.
47
+ def permission_env(channel_id:, thread_id: "")
48
+ return nil if skip_permissions?
49
+
50
+ url, token, bot_id = credentials.to_h.values_at(:url, :bot_token, :bot_id)
51
+ {
52
+ "PLATFORM_URL" => url, "PLATFORM_TOKEN" => token,
53
+ "PLATFORM_CHANNEL_ID" => channel_id, "PLATFORM_THREAD_ID" => thread_id,
54
+ "PLATFORM_BOT_ID" => bot_id, "ALLOWED_USERS" => allowed_users.join(",")
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def parse_channels
61
+ default_dir = Dir.pwd
62
+ return { channel_id => default_dir } unless ENV.key?("EARL_CHANNELS")
63
+
64
+ entries = ENV.fetch("EARL_CHANNELS").split(",")
65
+ return { channel_id => default_dir } if entries.empty?
66
+
67
+ entries.each_with_object({}) do |entry, hash|
68
+ channel, path = entry.strip.split(":", 2)
69
+ hash[channel] = path || default_dir
70
+ end
71
+ end
72
+
73
+ def build_credentials
74
+ url = required_env("MATTERMOST_URL")
75
+ validate_url(url)
76
+ MattermostCredentials.new(
77
+ url: url,
78
+ bot_token: required_env("MATTERMOST_BOT_TOKEN"),
79
+ bot_id: required_env("MATTERMOST_BOT_ID")
80
+ )
81
+ end
82
+
83
+ def required_env(key)
84
+ ENV.fetch(key) { raise "Missing required env var: #{key}" }
85
+ end
86
+
87
+ def validate_url(url)
88
+ uri = URI.parse(url)
89
+ raise "MATTERMOST_URL must be an HTTP(S) URL, got: #{url}" unless uri.is_a?(URI::HTTP)
90
+ rescue URI::InvalidURIError
91
+ raise "MATTERMOST_URL is not a valid URL: #{url}"
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ # Minimal 5-field cron expression parser (minute hour dom month dow).
5
+ # Supports: *, specific values, ranges (1-5), steps (*/15), lists (1,3,5).
6
+ class CronParser
7
+ MAX_SCAN_DAYS = 366
8
+
9
+ def initialize(expression)
10
+ @fields = parse_expression(expression)
11
+ end
12
+
13
+ def matches?(time)
14
+ matches_values?(extract_time_values(time))
15
+ end
16
+
17
+ def next_occurrence(from: Time.now)
18
+ # Start scanning from the next whole minute
19
+ candidate = beginning_of_next_minute(from)
20
+ limit = from + (MAX_SCAN_DAYS * 86_400)
21
+
22
+ while candidate <= limit
23
+ return candidate if matches?(candidate)
24
+
25
+ candidate += 60
26
+ end
27
+
28
+ nil
29
+ end
30
+
31
+ private
32
+
33
+ def extract_time_values(time)
34
+ [time.min, time.hour, time.day, time.month, time.wday]
35
+ end
36
+
37
+ def matches_values?(values)
38
+ @fields.zip(values).all? { |allowed, value| allowed.include?(value) }
39
+ end
40
+
41
+ def beginning_of_next_minute(time)
42
+ Time.new(time.year, time.month, time.day, time.hour, time.min, 0) + 60
43
+ end
44
+
45
+ def parse_expression(expression)
46
+ fields = expression.strip.split(/\s+/)
47
+ count = fields.size
48
+ raise ArgumentError, "Invalid cron expression: expected 5 fields, got #{count}" unless count == 5
49
+
50
+ fields.zip(FIELD_RANGES).map { |field, range| parse_field(field, range) }
51
+ end
52
+
53
+ FIELD_RANGES = [0..59, 0..23, 1..31, 1..12, 0..6].freeze
54
+ private_constant :FIELD_RANGES
55
+
56
+ def parse_field(field, range)
57
+ values = field.split(",").flat_map { |token| PartParser.new(token.strip, range).parse }
58
+ values.select { |val| range.include?(val) }.uniq.sort
59
+ end
60
+
61
+ # Parses a single cron field part (e.g. "*/15", "1-5", "3").
62
+ # Encapsulates the part string and valid range as instance state
63
+ # so parsing methods can reference them without parameter passing.
64
+ class PartParser
65
+ def initialize(part, range)
66
+ @part = part
67
+ @range = range
68
+ end
69
+
70
+ def parse
71
+ return @range.to_a if @part == "*"
72
+ return [@part.to_i] if @part.match?(/\A\d+\z/)
73
+ return parse_wildcard_step if @part.start_with?("*/")
74
+ return parse_range_step if @part.include?("/")
75
+ return parse_range if @part.include?("-")
76
+
77
+ raise ArgumentError, "Invalid cron field: #{@part}"
78
+ end
79
+
80
+ private
81
+
82
+ def parse_wildcard_step
83
+ step = @part.delete_prefix("*/").to_i
84
+ validated_step(@range, step)
85
+ end
86
+
87
+ def parse_range
88
+ left, right = @part.split("-", 2)
89
+ (left.to_i..right.to_i).to_a
90
+ end
91
+
92
+ def parse_range_step
93
+ range_str, step_str = @part.split("/", 2)
94
+ left, right = range_str.split("-", 2)
95
+ validated_step(left.to_i..right.to_i, step_str.to_i)
96
+ end
97
+
98
+ def validated_step(range, step)
99
+ raise ArgumentError, "Invalid step: #{@part}" if step.zero?
100
+
101
+ range.step(step).to_a
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ # Shared formatting helpers for numbers and display.
5
+ module Formatting
6
+ private
7
+
8
+ def format_number(num)
9
+ return "0" unless num
10
+
11
+ num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Earl
6
+ # Loads and validates heartbeat definitions from <config_root>/heartbeats.yml.
7
+ class HeartbeatConfig
8
+ include Logging
9
+
10
+ def self.config_path
11
+ @config_path ||= File.join(Earl.config_root, "heartbeats.yml")
12
+ end
13
+
14
+ attr_reader :path
15
+
16
+ # A single heartbeat definition with schedule, prompt, and execution options.
17
+ HeartbeatDefinition = Struct.new(
18
+ :name, :description, :cron, :interval, :run_at, :channel_id, :working_dir,
19
+ :prompt, :permission_mode, :persistent, :timeout, :enabled, :once,
20
+ keyword_init: true
21
+ ) do
22
+ def self.from_config(name, config, working_dir_resolver)
23
+ schedule = config["schedule"] || {}
24
+ new(
25
+ name: name, description: config["description"] || name,
26
+ cron: schedule["cron"], interval: schedule["interval"], run_at: schedule["run_at"],
27
+ **extract_options(config, working_dir_resolver)
28
+ )
29
+ end
30
+
31
+ def self.extract_options(config, working_dir_resolver)
32
+ {
33
+ channel_id: config["channel_id"],
34
+ working_dir: working_dir_resolver.call(config["working_dir"]),
35
+ prompt: config["prompt"],
36
+ permission_mode: (config["permission_mode"] || "interactive").to_sym,
37
+ persistent: config.fetch("persistent", false),
38
+ timeout: config.fetch("timeout", 600),
39
+ enabled: config.fetch("enabled", true),
40
+ once: config.fetch("once", false)
41
+ }
42
+ end
43
+
44
+ def active?
45
+ enabled && channel_id && prompt
46
+ end
47
+
48
+ def auto_permission?
49
+ permission_mode == :auto
50
+ end
51
+
52
+ def base_session_opts
53
+ { working_dir: working_dir, auto_permission: auto_permission?, channel_id: channel_id }
54
+ end
55
+ end
56
+
57
+ def initialize(path: self.class.config_path)
58
+ @path = path
59
+ end
60
+
61
+ def definitions
62
+ load_definitions
63
+ rescue StandardError => error
64
+ log(:warn, "Failed to load heartbeat config from #{@path}: #{error.message}")
65
+ []
66
+ end
67
+
68
+ private
69
+
70
+ def load_definitions
71
+ return [] unless File.exist?(@path)
72
+
73
+ data = YAML.safe_load_file(@path)
74
+ heartbeats = data.is_a?(Hash) ? data["heartbeats"] : nil
75
+ return [] unless heartbeats.is_a?(Hash)
76
+
77
+ heartbeats.filter_map { |name, config| build_definition(name, config) }
78
+ end
79
+
80
+ def build_definition(name, config)
81
+ return nil unless config.is_a?(Hash)
82
+ return nil unless valid_schedule?(config)
83
+
84
+ definition = HeartbeatDefinition.from_config(name, config, method(:resolve_working_dir))
85
+ definition if definition.active?
86
+ end
87
+
88
+ def valid_schedule?(config)
89
+ schedule = config["schedule"]
90
+ return false unless schedule.is_a?(Hash)
91
+
92
+ schedule.key?("cron") || schedule.key?("interval") || schedule.key?("run_at")
93
+ end
94
+
95
+ def resolve_working_dir(path)
96
+ return nil unless path
97
+
98
+ File.expand_path(path)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class HeartbeatScheduler
5
+ # Auto-reload: detects config file changes and updates heartbeat definitions.
6
+ module ConfigReloading
7
+ private
8
+
9
+ def check_for_reload
10
+ mtime = config_file_mtime
11
+ return if mtime == @control.config_mtime
12
+
13
+ @control.config_mtime = mtime
14
+ reload_definitions
15
+ end
16
+
17
+ def reload_definitions
18
+ new_defs = @deps.heartbeat_config.definitions
19
+ now = Time.now
20
+ new_names = new_defs.map(&:name)
21
+
22
+ @mutex.synchronize do
23
+ add_new_definitions(new_defs, now)
24
+ remove_stale_definitions(new_names)
25
+ update_existing_definitions(new_defs)
26
+ end
27
+
28
+ log(:info, "Heartbeat config reloaded: #{new_defs.size} definition(s)")
29
+ end
30
+
31
+ def add_new_definitions(new_defs, now)
32
+ new_defs.each do |definition|
33
+ def_name = definition.name
34
+ next if @states.key?(def_name)
35
+
36
+ @states[def_name] = build_initial_state(definition, now)
37
+ log(:info, "Heartbeat reload: added '#{def_name}'")
38
+ end
39
+ end
40
+
41
+ def remove_stale_definitions(new_names)
42
+ @states.each_key do |name|
43
+ next if new_names.include?(name)
44
+ next if @states[name].running
45
+
46
+ @states.delete(name)
47
+ log(:info, "Heartbeat reload: removed '#{name}'")
48
+ end
49
+ end
50
+
51
+ def update_existing_definitions(new_defs)
52
+ new_defs.each do |definition|
53
+ @states[definition.name]&.update_definition_if_idle(definition)
54
+ end
55
+ end
56
+
57
+ def config_file_mtime
58
+ File.mtime(@control.heartbeat_config_path)
59
+ rescue Errno::ENOENT
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class HeartbeatScheduler
5
+ # Heartbeat execution: creates sessions, streams responses, handles completion.
6
+ module Execution
7
+ private
8
+
9
+ def execute_heartbeat(state)
10
+ definition = state.definition
11
+ def_name = definition.name
12
+ log(:info, "Heartbeat '#{def_name}' starting")
13
+ thread_id = post_heartbeat_header(definition.channel_id, definition.description)
14
+ return unless thread_id
15
+
16
+ run_heartbeat_session(definition, state, thread_id)
17
+ rescue StandardError => error
18
+ handle_heartbeat_error(state, def_name, error)
19
+ ensure
20
+ finalize_heartbeat(state)
21
+ end
22
+
23
+ def handle_heartbeat_error(state, name, error)
24
+ error_msg = error.message
25
+ log(:error, "Heartbeat '#{name}' error: #{error_msg}")
26
+ log(:error, error.backtrace&.first(5)&.join("\n"))
27
+ @mutex.synchronize { state.last_error = error_msg }
28
+ end
29
+
30
+ def post_heartbeat_header(channel_id, description)
31
+ post = @deps.mattermost.create_post(channel_id: channel_id, message: "\u{1FAC0} **#{description}**")
32
+ post&.dig("id")
33
+ end
34
+
35
+ def run_heartbeat_session(definition, state, thread_id)
36
+ session = build_heartbeat_session(definition, state)
37
+ completed = false
38
+ response = build_heartbeat_response(thread_id, definition.channel_id)
39
+ setup_heartbeat_callbacks(session, response) { completed = true }
40
+ session.start
41
+ session.send_message(definition.prompt)
42
+ await_heartbeat_completion(session, definition.timeout) { completed }
43
+ log(:info, "Heartbeat '#{definition.name}' completed (run ##{state.run_count + 1})")
44
+ end
45
+
46
+ def build_heartbeat_response(thread_id, channel_id)
47
+ response = StreamingResponse.new(thread_id: thread_id, mattermost: @deps.mattermost, channel_id: channel_id)
48
+ response.start_typing
49
+ response
50
+ end
51
+
52
+ def build_heartbeat_session(definition, state)
53
+ session_opts = heartbeat_session_opts(definition)
54
+ persistent = definition.persistent
55
+ apply_resume_opts(session_opts, state) if persistent
56
+ session = ClaudeSession.new(**session_opts)
57
+ @mutex.synchronize { state.session_id = session.session_id } if persistent
58
+ session
59
+ end
60
+
61
+ def heartbeat_session_opts(definition)
62
+ auto, channel_id, working_dir = definition.base_session_opts.values_at(:auto_permission, :channel_id,
63
+ :working_dir)
64
+ { working_dir: working_dir, permission_config: auto ? nil : heartbeat_permission_env(channel_id) }
65
+ end
66
+
67
+ def apply_resume_opts(opts, state)
68
+ saved_session_id = state.session_id
69
+ return unless saved_session_id
70
+
71
+ opts[:session_id] = saved_session_id
72
+ opts[:mode] = :resume
73
+ end
74
+
75
+ def heartbeat_permission_env(channel_id)
76
+ @deps.config.permission_env(channel_id: channel_id)
77
+ end
78
+
79
+ def setup_heartbeat_callbacks(session, response)
80
+ session.on_text { |text| response.on_text(text) }
81
+ session.on_complete do |_|
82
+ response.on_complete
83
+ yield
84
+ end
85
+ session.on_tool_use { |tool_use| response.on_tool_use(tool_use) }
86
+ end
87
+
88
+ def await_heartbeat_completion(session, timeout)
89
+ start_time = monotonic_now
90
+ until yield
91
+ if monotonic_now - start_time >= timeout
92
+ log(:warn, "Heartbeat timed out after #{timeout}s")
93
+ session.kill
94
+ return
95
+ end
96
+ sleep 1
97
+ end
98
+ end
99
+
100
+ def monotonic_now
101
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class HeartbeatScheduler
5
+ # Per-heartbeat runtime state.
6
+ HeartbeatState = Struct.new(
7
+ :definition, :next_run_at, :running, :run_thread, :last_run_at,
8
+ :last_completed_at, :last_error, :run_count, :session_id,
9
+ keyword_init: true
10
+ ) do
11
+ def to_status
12
+ {
13
+ name: definition.name, description: definition.description,
14
+ next_run_at: next_run_at, last_run_at: last_run_at,
15
+ last_completed_at: last_completed_at, last_error: last_error,
16
+ run_count: run_count, running: running
17
+ }
18
+ end
19
+
20
+ def update_definition_if_idle(new_definition)
21
+ return if running
22
+
23
+ self.definition = new_definition
24
+ end
25
+
26
+ def dispatch(now, &block)
27
+ self.running = true
28
+ self.last_run_at = now
29
+ self.run_thread = Thread.new(&block)
30
+ end
31
+
32
+ def mark_completed(next_run)
33
+ self.running = false
34
+ self.last_completed_at = Time.now
35
+ self.run_count += 1
36
+ self.run_thread = nil
37
+ self.next_run_at = next_run
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Earl
4
+ class HeartbeatScheduler
5
+ # Heartbeat lifecycle: finalization, next-run computation, and one-shot disabling.
6
+ module Lifecycle
7
+ private
8
+
9
+ def finalize_heartbeat(state)
10
+ definition = state.definition
11
+ is_once = definition.once
12
+ next_run = is_once ? nil : compute_next_run(definition, Time.now)
13
+ @mutex.synchronize { state.mark_completed(next_run) }
14
+ disable_heartbeat(definition.name) if is_once
15
+ end
16
+
17
+ def compute_next_run(definition, from)
18
+ schedule = definition.to_h.values_at(:run_at, :cron, :interval)
19
+ compute_from_schedule(schedule, from)
20
+ end
21
+
22
+ def compute_from_schedule(schedule, from)
23
+ run_at, cron, interval = schedule
24
+ if run_at
25
+ compute_run_at(run_at, from)
26
+ elsif cron
27
+ CronParser.new(cron).next_occurrence(from: from)
28
+ elsif interval
29
+ from + interval
30
+ end
31
+ end
32
+
33
+ def compute_run_at(run_at, from)
34
+ target = Time.at(run_at)
35
+ [target, from].max
36
+ end
37
+
38
+ def disable_heartbeat(name)
39
+ path = @control.heartbeat_config_path
40
+ return unless File.exist?(path)
41
+
42
+ update_yaml_entry(path, name)
43
+ rescue StandardError => error
44
+ log(:warn, "Failed to disable heartbeat '#{name}': #{error.message}")
45
+ end
46
+
47
+ def update_yaml_entry(path, name)
48
+ File.open(path, "r+") do |lockfile|
49
+ lockfile.flock(File::LOCK_EX)
50
+ yaml_data = YAML.safe_load_file(path)
51
+ next unless disable_entry(yaml_data, name)
52
+
53
+ write_yaml_atomically(path, yaml_data)
54
+ log(:info, "One-off heartbeat '#{name}' disabled in YAML")
55
+ end
56
+ end
57
+
58
+ def disable_entry(yaml_data, name)
59
+ return false unless yaml_data.is_a?(Hash)
60
+
61
+ entry = yaml_data.dig("heartbeats", name)
62
+ return false unless entry.is_a?(Hash)
63
+
64
+ entry["enabled"] = false
65
+ true
66
+ end
67
+
68
+ def write_yaml_atomically(path, data)
69
+ tmp_path = "#{path}.tmp.#{Process.pid}"
70
+ File.write(tmp_path, YAML.dump(data))
71
+ File.rename(tmp_path, path)
72
+ end
73
+ end
74
+ end
75
+ end