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.
- checksums.yaml +7 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +260 -0
- data/Gemfile +9 -0
- data/Gemfile.lock +177 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/Rakefile +11 -0
- data/bin/README.md +21 -0
- data/bin/ci +49 -0
- data/bin/claude-context +155 -0
- data/bin/claude-usage +110 -0
- data/bin/coverage +221 -0
- data/bin/rubocop +10 -0
- data/bin/watch-ci +198 -0
- data/config/earl-claude-home/.claude/CLAUDE.md +10 -0
- data/config/earl-claude-home/.claude/settings.json +34 -0
- data/earl-bot.gemspec +42 -0
- data/exe/earl +51 -0
- data/exe/earl-install +129 -0
- data/exe/earl-permission-server +39 -0
- data/lib/earl/claude_session/stats.rb +76 -0
- data/lib/earl/claude_session.rb +468 -0
- data/lib/earl/command_executor/constants.rb +53 -0
- data/lib/earl/command_executor/heartbeat_display.rb +54 -0
- data/lib/earl/command_executor/lifecycle_handler.rb +61 -0
- data/lib/earl/command_executor/session_handler.rb +126 -0
- data/lib/earl/command_executor/spawn_handler.rb +99 -0
- data/lib/earl/command_executor/stats_formatter.rb +66 -0
- data/lib/earl/command_executor/usage_handler.rb +132 -0
- data/lib/earl/command_executor.rb +128 -0
- data/lib/earl/command_parser.rb +57 -0
- data/lib/earl/config.rb +94 -0
- data/lib/earl/cron_parser.rb +105 -0
- data/lib/earl/formatting.rb +14 -0
- data/lib/earl/heartbeat_config.rb +101 -0
- data/lib/earl/heartbeat_scheduler/config_reloading.rb +64 -0
- data/lib/earl/heartbeat_scheduler/execution.rb +105 -0
- data/lib/earl/heartbeat_scheduler/heartbeat_state.rb +41 -0
- data/lib/earl/heartbeat_scheduler/lifecycle.rb +75 -0
- data/lib/earl/heartbeat_scheduler.rb +131 -0
- data/lib/earl/logging.rb +12 -0
- data/lib/earl/mattermost/api_client.rb +85 -0
- data/lib/earl/mattermost.rb +261 -0
- data/lib/earl/mcp/approval_handler.rb +304 -0
- data/lib/earl/mcp/config.rb +62 -0
- data/lib/earl/mcp/github_pat_handler.rb +450 -0
- data/lib/earl/mcp/handler_base.rb +13 -0
- data/lib/earl/mcp/heartbeat_handler.rb +310 -0
- data/lib/earl/mcp/memory_handler.rb +89 -0
- data/lib/earl/mcp/server.rb +123 -0
- data/lib/earl/mcp/tmux_handler.rb +562 -0
- data/lib/earl/memory/prompt_builder.rb +40 -0
- data/lib/earl/memory/store.rb +125 -0
- data/lib/earl/message_queue.rb +56 -0
- data/lib/earl/permission_config.rb +22 -0
- data/lib/earl/question_handler/question_posting.rb +58 -0
- data/lib/earl/question_handler.rb +116 -0
- data/lib/earl/runner/idle_management.rb +44 -0
- data/lib/earl/runner/lifecycle.rb +73 -0
- data/lib/earl/runner/message_handling.rb +121 -0
- data/lib/earl/runner/reaction_handling.rb +42 -0
- data/lib/earl/runner/response_lifecycle.rb +96 -0
- data/lib/earl/runner/service_builder.rb +48 -0
- data/lib/earl/runner/startup.rb +73 -0
- data/lib/earl/runner/thread_context_builder.rb +43 -0
- data/lib/earl/runner.rb +70 -0
- data/lib/earl/safari_automation.rb +497 -0
- data/lib/earl/session_manager/persistence.rb +46 -0
- data/lib/earl/session_manager/session_creation.rb +108 -0
- data/lib/earl/session_manager.rb +92 -0
- data/lib/earl/session_store.rb +84 -0
- data/lib/earl/streaming_response.rb +219 -0
- data/lib/earl/tmux/parsing.rb +80 -0
- data/lib/earl/tmux/processes.rb +34 -0
- data/lib/earl/tmux/sessions.rb +41 -0
- data/lib/earl/tmux.rb +122 -0
- data/lib/earl/tmux_monitor/alert_dispatcher.rb +53 -0
- data/lib/earl/tmux_monitor/output_analyzer.rb +35 -0
- data/lib/earl/tmux_monitor/permission_forwarder.rb +80 -0
- data/lib/earl/tmux_monitor/question_forwarder.rb +124 -0
- data/lib/earl/tmux_monitor.rb +249 -0
- data/lib/earl/tmux_session_store.rb +133 -0
- data/lib/earl/tool_input_formatter.rb +44 -0
- data/lib/earl/version.rb +5 -0
- data/lib/earl.rb +87 -0
- data/lib/tasks/.keep +1 -0
- metadata +248 -0
data/lib/earl/config.rb
ADDED
|
@@ -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
|