activematrix 0.0.7 → 0.0.8
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 +4 -4
- data/README.md +96 -28
- data/app/models/active_matrix/agent.rb +36 -1
- data/app/models/active_matrix/agent_store.rb +29 -0
- data/app/models/active_matrix/application_record.rb +8 -0
- data/app/models/active_matrix/chat_session.rb +29 -0
- data/app/models/active_matrix/knowledge_base.rb +26 -0
- data/exe/activematrix +7 -0
- data/lib/active_matrix/agent_manager.rb +160 -121
- data/lib/active_matrix/agent_registry.rb +25 -21
- data/lib/active_matrix/api.rb +8 -2
- data/lib/active_matrix/async_query.rb +58 -0
- data/lib/active_matrix/bot/base.rb +3 -3
- data/lib/active_matrix/bot/builtin_commands.rb +188 -0
- data/lib/active_matrix/bot/command_parser.rb +175 -0
- data/lib/active_matrix/cli.rb +273 -0
- data/lib/active_matrix/client.rb +21 -6
- data/lib/active_matrix/client_pool.rb +38 -27
- data/lib/active_matrix/daemon/probe_server.rb +118 -0
- data/lib/active_matrix/daemon/signal_handler.rb +156 -0
- data/lib/active_matrix/daemon/worker.rb +109 -0
- data/lib/active_matrix/daemon.rb +236 -0
- data/lib/active_matrix/engine.rb +5 -1
- data/lib/active_matrix/errors.rb +1 -1
- data/lib/active_matrix/event_router.rb +61 -49
- data/lib/active_matrix/events.rb +1 -0
- data/lib/active_matrix/instrumentation.rb +148 -0
- data/lib/active_matrix/memory/agent_memory.rb +7 -21
- data/lib/active_matrix/memory/conversation_memory.rb +4 -20
- data/lib/active_matrix/memory/global_memory.rb +15 -30
- data/lib/active_matrix/message_dispatcher.rb +197 -0
- data/lib/active_matrix/metrics.rb +424 -0
- data/lib/active_matrix/presence_manager.rb +181 -0
- data/lib/active_matrix/telemetry.rb +134 -0
- data/lib/active_matrix/version.rb +1 -1
- data/lib/active_matrix.rb +12 -2
- data/lib/generators/active_matrix/install/install_generator.rb +3 -15
- data/lib/generators/active_matrix/install/templates/README +5 -2
- metadata +141 -45
- data/lib/active_matrix/protocols/cs/message_relationships.rb +0 -318
- data/lib/generators/active_matrix/install/templates/create_agent_memories.rb +0 -17
- data/lib/generators/active_matrix/install/templates/create_conversation_contexts.rb +0 -21
- data/lib/generators/active_matrix/install/templates/create_global_memories.rb +0 -20
- data/lib/generators/active_matrix/install/templates/create_matrix_agents.rb +0 -26
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveMatrix
|
|
4
|
+
module Bot
|
|
5
|
+
# Built-in commands that can be included in bot classes
|
|
6
|
+
#
|
|
7
|
+
# @example Include in a bot
|
|
8
|
+
# class MyBot < ActiveMatrix::Bot::Base
|
|
9
|
+
# include ActiveMatrix::Bot::BuiltinCommands
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
module BuiltinCommands
|
|
13
|
+
def self.included(base)
|
|
14
|
+
base.extend(ClassMethods)
|
|
15
|
+
base.register_builtin_commands
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module ClassMethods
|
|
19
|
+
def register_builtin_commands
|
|
20
|
+
# Ping command - connectivity test
|
|
21
|
+
command(
|
|
22
|
+
:ping,
|
|
23
|
+
desc: 'Test bot connectivity and response time'
|
|
24
|
+
) do
|
|
25
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
26
|
+
|
|
27
|
+
response_parts = [
|
|
28
|
+
'**Pong!**',
|
|
29
|
+
'',
|
|
30
|
+
'Bot is online and responding',
|
|
31
|
+
"Server time: #{Time.current.strftime('%Y-%m-%d %H:%M:%S %Z')}"
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
end_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
response_time_ms = ((end_time - start_time) * 1000).round(2)
|
|
36
|
+
response_parts.insert(2, "Response time: #{response_time_ms}ms")
|
|
37
|
+
|
|
38
|
+
room.send_notice(response_parts.join("\n"))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Version command - show bot version
|
|
42
|
+
command(
|
|
43
|
+
:version,
|
|
44
|
+
desc: 'Show bot version information'
|
|
45
|
+
) do
|
|
46
|
+
response_parts = [
|
|
47
|
+
'**Version Information**',
|
|
48
|
+
'',
|
|
49
|
+
"ActiveMatrix: #{ActiveMatrix::VERSION}",
|
|
50
|
+
"Ruby: #{RUBY_VERSION}",
|
|
51
|
+
"Platform: #{RUBY_PLATFORM}"
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Add application version if available
|
|
55
|
+
response_parts << "Application: #{Rails.application.version}" if defined?(Rails) && Rails.application.respond_to?(:version)
|
|
56
|
+
|
|
57
|
+
room.send_notice(response_parts.join("\n"))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Status command - show bot status
|
|
61
|
+
command(
|
|
62
|
+
:status,
|
|
63
|
+
desc: 'Show bot status and health information'
|
|
64
|
+
) do
|
|
65
|
+
response_parts = [
|
|
66
|
+
'**Bot Status**',
|
|
67
|
+
'',
|
|
68
|
+
'State: Online',
|
|
69
|
+
"Uptime: #{format_uptime}",
|
|
70
|
+
"User ID: #{client.mxid}",
|
|
71
|
+
"Homeserver: #{client.api.homeserver}"
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
# Add room count if available
|
|
75
|
+
if client.respond_to?(:rooms)
|
|
76
|
+
room_count = client.rooms.size
|
|
77
|
+
response_parts << "Joined rooms: #{room_count}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Add metrics if available
|
|
81
|
+
if defined?(ActiveMatrix::Metrics)
|
|
82
|
+
metrics = ActiveMatrix::Metrics.instance.get_health_summary
|
|
83
|
+
if metrics[:total_operations].positive?
|
|
84
|
+
response_parts += [
|
|
85
|
+
'',
|
|
86
|
+
'**Metrics**',
|
|
87
|
+
"Total operations: #{metrics[:total_operations]}",
|
|
88
|
+
"Success rate: #{metrics[:overall_success_rate]}%"
|
|
89
|
+
]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
room.send_notice(response_parts.join("\n"))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Time command - show current time
|
|
97
|
+
command(
|
|
98
|
+
:time,
|
|
99
|
+
desc: 'Show current time in specified timezone',
|
|
100
|
+
notes: 'Usage: !time [TIMEZONE]. Examples: !time UTC, !time America/New_York'
|
|
101
|
+
) do |timezone = nil|
|
|
102
|
+
time = if timezone && defined?(ActiveSupport::TimeZone)
|
|
103
|
+
tz = ActiveSupport::TimeZone[timezone]
|
|
104
|
+
if tz
|
|
105
|
+
tz.now
|
|
106
|
+
else
|
|
107
|
+
room.send_notice("Unknown timezone: #{timezone}. Using server time.")
|
|
108
|
+
Time.current
|
|
109
|
+
end
|
|
110
|
+
else
|
|
111
|
+
Time.current
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
formatted = time.strftime('%Y-%m-%d %H:%M:%S %Z')
|
|
115
|
+
unix_timestamp = time.to_i
|
|
116
|
+
|
|
117
|
+
response_parts = [
|
|
118
|
+
'**Current Time**',
|
|
119
|
+
'',
|
|
120
|
+
formatted,
|
|
121
|
+
"Unix timestamp: #{unix_timestamp}"
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
room.send_notice(response_parts.join("\n"))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Echo command - echo back message
|
|
128
|
+
command(
|
|
129
|
+
:echo,
|
|
130
|
+
desc: 'Echo back the provided message'
|
|
131
|
+
) do |message = nil|
|
|
132
|
+
if message.nil? || message.strip.empty?
|
|
133
|
+
room.send_notice('Nothing to echo. Usage: !echo <message>')
|
|
134
|
+
else
|
|
135
|
+
room.send_text(message)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Rooms command - list joined rooms (admin only)
|
|
140
|
+
command(
|
|
141
|
+
:rooms,
|
|
142
|
+
desc: 'List joined rooms',
|
|
143
|
+
only: :admin
|
|
144
|
+
) do
|
|
145
|
+
rooms_list = client.rooms.map do |r|
|
|
146
|
+
name = r.display_name || r.id
|
|
147
|
+
"- #{name}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if rooms_list.empty?
|
|
151
|
+
room.send_notice('Not joined to any rooms.')
|
|
152
|
+
else
|
|
153
|
+
response = [
|
|
154
|
+
"**Joined Rooms** (#{rooms_list.size})",
|
|
155
|
+
'',
|
|
156
|
+
*rooms_list.first(20)
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
response << "... and #{rooms_list.size - 20} more" if rooms_list.size > 20
|
|
160
|
+
|
|
161
|
+
room.send_notice(response.join("\n"))
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def format_uptime
|
|
170
|
+
return 'Unknown' unless defined?(@start_time)
|
|
171
|
+
|
|
172
|
+
seconds = (Time.current - @start_time).to_i
|
|
173
|
+
days = seconds / 86_400
|
|
174
|
+
hours = (seconds % 86_400) / 3600
|
|
175
|
+
minutes = (seconds % 3600) / 60
|
|
176
|
+
secs = seconds % 60
|
|
177
|
+
|
|
178
|
+
parts = []
|
|
179
|
+
parts << "#{days}d" if days.positive?
|
|
180
|
+
parts << "#{hours}h" if hours.positive?
|
|
181
|
+
parts << "#{minutes}m" if minutes.positive?
|
|
182
|
+
parts << "#{secs}s" if secs.positive? || parts.empty?
|
|
183
|
+
|
|
184
|
+
parts.join(' ')
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveMatrix
|
|
4
|
+
module Bot
|
|
5
|
+
# Parses command arguments with support for flags and quoted strings
|
|
6
|
+
#
|
|
7
|
+
# @example Basic parsing
|
|
8
|
+
# parser = CommandParser.new('search "hello world" --verbose')
|
|
9
|
+
# parser.args # => ["search", "hello world"]
|
|
10
|
+
# parser.flags # => { "verbose" => true }
|
|
11
|
+
#
|
|
12
|
+
# @example With key-value flags
|
|
13
|
+
# parser = CommandParser.new('greet --name=Alice --formal')
|
|
14
|
+
# parser.positional_args # => []
|
|
15
|
+
# parser.flags # => { "name" => "Alice", "formal" => true }
|
|
16
|
+
#
|
|
17
|
+
class CommandParser
|
|
18
|
+
attr_reader :raw_input, :command_name, :args, :raw_args
|
|
19
|
+
|
|
20
|
+
# Command prefixes recognized by the parser
|
|
21
|
+
COMMAND_PREFIXES = ['/', '!'].freeze
|
|
22
|
+
|
|
23
|
+
# @param input [String] Raw command input
|
|
24
|
+
# @param prefixes [Array<String>] Command prefixes to recognize
|
|
25
|
+
def initialize(input, prefixes: COMMAND_PREFIXES)
|
|
26
|
+
@raw_input = input.to_s.strip
|
|
27
|
+
@prefixes = prefixes
|
|
28
|
+
@command_name = nil
|
|
29
|
+
@args = []
|
|
30
|
+
@raw_args = ''
|
|
31
|
+
@parsed_flags = nil
|
|
32
|
+
parse!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if input is a valid command (has prefix and command name)
|
|
36
|
+
#
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def command?
|
|
39
|
+
!@command_name.nil? && @prefixes.any? { |prefix| @raw_input.start_with?(prefix) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Get the prefix used in the command
|
|
43
|
+
#
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
def prefix
|
|
46
|
+
@prefixes.find { |p| @raw_input.start_with?(p) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Parse flags and positional arguments
|
|
50
|
+
#
|
|
51
|
+
# @return [Hash] Hash with :flags and :args keys
|
|
52
|
+
def parse_flags
|
|
53
|
+
@parse_flags ||= begin
|
|
54
|
+
flags = {}
|
|
55
|
+
positional = []
|
|
56
|
+
|
|
57
|
+
@args.each do |arg|
|
|
58
|
+
case arg
|
|
59
|
+
when /\A--([^=]+)=(.+)\z/
|
|
60
|
+
# --key=value format
|
|
61
|
+
flags[Regexp.last_match(1)] = Regexp.last_match(2)
|
|
62
|
+
when /\A--(.+)\z/
|
|
63
|
+
# --flag format (boolean)
|
|
64
|
+
flags[Regexp.last_match(1)] = true
|
|
65
|
+
when /\A-([a-zA-Z]+)\z/
|
|
66
|
+
# Short flags like -v, -abc (multiple)
|
|
67
|
+
Regexp.last_match(1).chars.each { |c| flags[c] = true }
|
|
68
|
+
else
|
|
69
|
+
positional << arg
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
{ flags: flags, args: positional }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get only positional arguments (no flags)
|
|
78
|
+
#
|
|
79
|
+
# @return [Array<String>]
|
|
80
|
+
def positional_args
|
|
81
|
+
parse_flags[:args]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get only flag arguments
|
|
85
|
+
#
|
|
86
|
+
# @return [Hash<String, Object>]
|
|
87
|
+
def flags
|
|
88
|
+
parse_flags[:flags]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if a specific flag is set
|
|
92
|
+
#
|
|
93
|
+
# @param name [String] Flag name (without dashes)
|
|
94
|
+
# @return [Boolean]
|
|
95
|
+
def flag?(name)
|
|
96
|
+
flags.key?(name.to_s)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Get a flag value
|
|
100
|
+
#
|
|
101
|
+
# @param name [String] Flag name
|
|
102
|
+
# @param default [Object] Default value if flag not set
|
|
103
|
+
# @return [Object]
|
|
104
|
+
def flag(name, default = nil)
|
|
105
|
+
flags.fetch(name.to_s, default)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Formatted command string
|
|
109
|
+
#
|
|
110
|
+
# @return [String]
|
|
111
|
+
def formatted_command
|
|
112
|
+
return '' unless command?
|
|
113
|
+
|
|
114
|
+
[@command_name, *@args].join(' ')
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def parse!
|
|
120
|
+
# Check if input starts with a valid prefix
|
|
121
|
+
found_prefix = @prefixes.find { |p| @raw_input.start_with?(p) }
|
|
122
|
+
return unless found_prefix
|
|
123
|
+
|
|
124
|
+
# Remove prefix
|
|
125
|
+
content = @raw_input[found_prefix.length..].strip
|
|
126
|
+
return if content.empty?
|
|
127
|
+
|
|
128
|
+
# Parse respecting quoted strings
|
|
129
|
+
parts = parse_with_quotes(content)
|
|
130
|
+
return if parts.empty?
|
|
131
|
+
|
|
132
|
+
@command_name = parts.first.downcase
|
|
133
|
+
@args = parts[1..] || []
|
|
134
|
+
@raw_args = @args.join(' ')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def parse_with_quotes(input)
|
|
138
|
+
parts = []
|
|
139
|
+
current = +''
|
|
140
|
+
in_quotes = false
|
|
141
|
+
quote_char = nil
|
|
142
|
+
|
|
143
|
+
input.each_char do |char|
|
|
144
|
+
case char
|
|
145
|
+
when '"', "'"
|
|
146
|
+
if in_quotes && char == quote_char
|
|
147
|
+
# End of quoted section
|
|
148
|
+
in_quotes = false
|
|
149
|
+
quote_char = nil
|
|
150
|
+
elsif !in_quotes
|
|
151
|
+
# Start of quoted section
|
|
152
|
+
in_quotes = true
|
|
153
|
+
quote_char = char
|
|
154
|
+
else
|
|
155
|
+
# Different quote inside quotes, treat as literal
|
|
156
|
+
current << char
|
|
157
|
+
end
|
|
158
|
+
when ' ', "\t"
|
|
159
|
+
if in_quotes
|
|
160
|
+
current << char
|
|
161
|
+
elsif current.length.positive?
|
|
162
|
+
parts << current
|
|
163
|
+
current = +''
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
current << char
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
parts << current if current.length.positive?
|
|
171
|
+
parts
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Rails/Exit
|
|
4
|
+
require 'thor'
|
|
5
|
+
require 'active_matrix'
|
|
6
|
+
require 'active_matrix/daemon'
|
|
7
|
+
|
|
8
|
+
module ActiveMatrix
|
|
9
|
+
# Command-line interface for ActiveMatrix daemon
|
|
10
|
+
#
|
|
11
|
+
# @example Start the daemon
|
|
12
|
+
# bundle exec activematrix start
|
|
13
|
+
#
|
|
14
|
+
# @example Start with options
|
|
15
|
+
# bundle exec activematrix start --workers 3 --probe-port 3042
|
|
16
|
+
#
|
|
17
|
+
class CLI < Thor
|
|
18
|
+
def self.exit_on_failure?
|
|
19
|
+
true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
desc 'start', 'Start the ActiveMatrix daemon'
|
|
23
|
+
option :workers, type: :numeric, default: 1, desc: 'Number of worker processes'
|
|
24
|
+
option :probe_port, type: :numeric, default: 3042, desc: 'Health check probe port'
|
|
25
|
+
option :probe_host, type: :string, default: '127.0.0.1', desc: 'Health check bind address'
|
|
26
|
+
option :agents, type: :string, desc: 'Comma-separated list of agent names to start'
|
|
27
|
+
option :daemon, type: :boolean, default: false, desc: 'Run as background daemon'
|
|
28
|
+
option :pidfile, type: :string, desc: 'PID file path (implies --daemon)'
|
|
29
|
+
option :logfile, type: :string, desc: 'Log file path'
|
|
30
|
+
option :require, type: :string, aliases: '-r', desc: 'File to require before starting'
|
|
31
|
+
option :environment, type: :string, aliases: '-e', desc: 'Rails environment'
|
|
32
|
+
option :otel, type: :boolean, default: false, desc: 'Enable OpenTelemetry tracing'
|
|
33
|
+
option :otel_exporter, type: :string, default: 'otlp', desc: 'OTel exporter (otlp, console)'
|
|
34
|
+
def start
|
|
35
|
+
boot_rails
|
|
36
|
+
configure_from_options
|
|
37
|
+
|
|
38
|
+
daemonize if options[:daemon] || options[:pidfile]
|
|
39
|
+
|
|
40
|
+
run_daemon
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
desc 'stop', 'Stop the ActiveMatrix daemon'
|
|
44
|
+
option :pidfile, type: :string, default: 'tmp/pids/activematrix.pid', desc: 'PID file path'
|
|
45
|
+
option :timeout, type: :numeric, default: 30, desc: 'Shutdown timeout in seconds'
|
|
46
|
+
def stop
|
|
47
|
+
pidfile = options[:pidfile]
|
|
48
|
+
|
|
49
|
+
unless File.exist?(pidfile)
|
|
50
|
+
say "PID file not found: #{pidfile}", :red
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
pid = File.read(pidfile).to_i
|
|
55
|
+
say "Stopping ActiveMatrix daemon (PID: #{pid})..."
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
Process.kill('TERM', pid)
|
|
59
|
+
wait_for_shutdown(pid, options[:timeout])
|
|
60
|
+
say 'Daemon stopped successfully', :green
|
|
61
|
+
rescue Errno::ESRCH
|
|
62
|
+
say 'Process not running, cleaning up PID file', :yellow
|
|
63
|
+
File.delete(pidfile)
|
|
64
|
+
rescue Errno::EPERM
|
|
65
|
+
say "Permission denied to stop process #{pid}", :red
|
|
66
|
+
exit 1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
desc 'status', 'Show daemon status'
|
|
71
|
+
option :pidfile, type: :string, default: 'tmp/pids/activematrix.pid', desc: 'PID file path'
|
|
72
|
+
option :probe_port, type: :numeric, default: 3042, desc: 'Health check probe port'
|
|
73
|
+
option :probe_host, type: :string, default: '127.0.0.1', desc: 'Health check host'
|
|
74
|
+
def status
|
|
75
|
+
# Try HTTP probe first
|
|
76
|
+
if (probe_status = fetch_probe_status)
|
|
77
|
+
display_status(probe_status)
|
|
78
|
+
elsif (pid = read_pid)
|
|
79
|
+
say "Daemon running (PID: #{pid}), but health probe not responding", :yellow
|
|
80
|
+
else
|
|
81
|
+
say 'Daemon not running', :red
|
|
82
|
+
exit 1
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
desc 'reload', 'Reload agent configuration'
|
|
87
|
+
option :pidfile, type: :string, default: 'tmp/pids/activematrix.pid', desc: 'PID file path'
|
|
88
|
+
def reload
|
|
89
|
+
pid = read_pid
|
|
90
|
+
unless pid
|
|
91
|
+
say 'Daemon not running', :red
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
begin
|
|
96
|
+
Process.kill('HUP', pid)
|
|
97
|
+
say 'Reload signal sent', :green
|
|
98
|
+
rescue Errno::ESRCH
|
|
99
|
+
say 'Process not running', :red
|
|
100
|
+
exit 1
|
|
101
|
+
rescue Errno::EPERM
|
|
102
|
+
say 'Permission denied', :red
|
|
103
|
+
exit 1
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
desc 'version', 'Show ActiveMatrix version'
|
|
108
|
+
def version
|
|
109
|
+
say "ActiveMatrix #{ActiveMatrix::VERSION}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
map %w[-v --version] => :version
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def boot_rails
|
|
117
|
+
ENV['RAILS_ENV'] ||= options[:environment] || 'development'
|
|
118
|
+
|
|
119
|
+
if options[:require]
|
|
120
|
+
require File.expand_path(options[:require])
|
|
121
|
+
elsif File.exist?('config/environment.rb')
|
|
122
|
+
require File.expand_path('config/environment.rb')
|
|
123
|
+
else
|
|
124
|
+
say 'No Rails application found. Use --require to specify a file.', :red
|
|
125
|
+
exit 1
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def configure_from_options
|
|
130
|
+
ActiveMatrix.configure do |config|
|
|
131
|
+
config.daemon_workers = options[:workers] if options[:workers]
|
|
132
|
+
config.probe_port = options[:probe_port] if options[:probe_port]
|
|
133
|
+
config.probe_host = options[:probe_host] if options[:probe_host]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
configure_telemetry if options[:otel]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def configure_telemetry
|
|
140
|
+
require 'active_matrix/telemetry'
|
|
141
|
+
|
|
142
|
+
exporter = options[:otel_exporter]&.to_sym
|
|
143
|
+
ActiveMatrix::Telemetry.configure!(exporter: exporter)
|
|
144
|
+
rescue LoadError => e
|
|
145
|
+
say "OpenTelemetry not available: #{e.message}", :yellow
|
|
146
|
+
say 'Install opentelemetry-sdk and opentelemetry-exporter-otlp gems', :yellow
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def daemonize
|
|
150
|
+
pidfile = options[:pidfile] || 'tmp/pids/activematrix.pid'
|
|
151
|
+
logfile = options[:logfile] || 'log/activematrix.log'
|
|
152
|
+
|
|
153
|
+
# Ensure directories exist
|
|
154
|
+
FileUtils.mkdir_p(File.dirname(pidfile))
|
|
155
|
+
FileUtils.mkdir_p(File.dirname(logfile))
|
|
156
|
+
|
|
157
|
+
# Check if already running
|
|
158
|
+
if File.exist?(pidfile)
|
|
159
|
+
pid = File.read(pidfile).to_i
|
|
160
|
+
begin
|
|
161
|
+
Process.kill(0, pid)
|
|
162
|
+
say "Daemon already running (PID: #{pid})", :red
|
|
163
|
+
exit 1
|
|
164
|
+
rescue Errno::ESRCH
|
|
165
|
+
File.delete(pidfile)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
Process.daemon(true, true)
|
|
170
|
+
|
|
171
|
+
# Redirect output
|
|
172
|
+
$stdout.reopen(logfile, 'a')
|
|
173
|
+
$stderr.reopen($stdout)
|
|
174
|
+
$stdout.sync = true
|
|
175
|
+
|
|
176
|
+
# Write PID file
|
|
177
|
+
File.write(pidfile, Process.pid.to_s)
|
|
178
|
+
|
|
179
|
+
at_exit { FileUtils.rm_f(pidfile) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def run_daemon
|
|
183
|
+
daemon = ActiveMatrix::Daemon.new(
|
|
184
|
+
workers: options[:workers],
|
|
185
|
+
probe_port: options[:probe_port],
|
|
186
|
+
probe_host: options[:probe_host],
|
|
187
|
+
agent_names: parse_agent_names
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
daemon.run
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def parse_agent_names
|
|
194
|
+
return nil unless options[:agents]
|
|
195
|
+
|
|
196
|
+
options[:agents].split(',').map(&:strip)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def read_pid
|
|
200
|
+
pidfile = options[:pidfile]
|
|
201
|
+
return nil unless File.exist?(pidfile)
|
|
202
|
+
|
|
203
|
+
pid = File.read(pidfile).to_i
|
|
204
|
+
begin
|
|
205
|
+
Process.kill(0, pid)
|
|
206
|
+
pid
|
|
207
|
+
rescue Errno::ESRCH
|
|
208
|
+
nil
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def wait_for_shutdown(pid, timeout)
|
|
213
|
+
deadline = Time.zone.now + timeout
|
|
214
|
+
|
|
215
|
+
while Time.zone.now < deadline
|
|
216
|
+
begin
|
|
217
|
+
Process.kill(0, pid)
|
|
218
|
+
sleep 0.5
|
|
219
|
+
rescue Errno::ESRCH
|
|
220
|
+
return true
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
say 'Timeout waiting for graceful shutdown, sending SIGKILL', :yellow
|
|
225
|
+
Process.kill('KILL', pid)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def fetch_probe_status
|
|
229
|
+
require 'net/http'
|
|
230
|
+
require 'json'
|
|
231
|
+
|
|
232
|
+
uri = URI("http://#{options[:probe_host]}:#{options[:probe_port]}/status")
|
|
233
|
+
response = Net::HTTP.get_response(uri)
|
|
234
|
+
|
|
235
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
236
|
+
|
|
237
|
+
JSON.parse(response.body, symbolize_names: true)
|
|
238
|
+
rescue StandardError
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def display_status(status)
|
|
243
|
+
say 'ActiveMatrix Daemon Status', :green
|
|
244
|
+
say '=' * 40
|
|
245
|
+
say "Status: #{status[:status]}"
|
|
246
|
+
say "Uptime: #{format_duration(status[:uptime])}"
|
|
247
|
+
say "Workers: #{status[:workers]}"
|
|
248
|
+
say ''
|
|
249
|
+
say 'Agents:'
|
|
250
|
+
say " Total: #{status.dig(:agents, :total) || 0}"
|
|
251
|
+
say " Online: #{status.dig(:agents, :online) || 0}"
|
|
252
|
+
say " Connecting: #{status.dig(:agents, :connecting) || 0}"
|
|
253
|
+
say " Error: #{status.dig(:agents, :error) || 0}"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def format_duration(seconds)
|
|
257
|
+
return 'N/A' unless seconds
|
|
258
|
+
|
|
259
|
+
hours = seconds / 3600
|
|
260
|
+
minutes = (seconds % 3600) / 60
|
|
261
|
+
secs = seconds % 60
|
|
262
|
+
|
|
263
|
+
if hours.positive?
|
|
264
|
+
"#{hours}h #{minutes}m #{secs}s"
|
|
265
|
+
elsif minutes.positive?
|
|
266
|
+
"#{minutes}m #{secs}s"
|
|
267
|
+
else
|
|
268
|
+
"#{secs}s"
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
# rubocop:enable Rails/Exit
|
data/lib/active_matrix/client.rb
CHANGED
|
@@ -18,7 +18,7 @@ module ActiveMatrix
|
|
|
18
18
|
# @!attribute sync_filter [rw] The global sync filter
|
|
19
19
|
# @return [Hash,String] A filter definition, either as defined by the
|
|
20
20
|
# Matrix spec, or as an identifier returned by a filter creation request
|
|
21
|
-
attr_reader :api
|
|
21
|
+
attr_reader :api, :sync_thread
|
|
22
22
|
attr_accessor :cache, :sync_filter, :next_batch
|
|
23
23
|
|
|
24
24
|
events :error, :event, :account_data, :presence_event, :invite_event, :leave_event, :ephemeral_event, :state_event
|
|
@@ -499,11 +499,7 @@ module ActiveMatrix
|
|
|
499
499
|
def stop_listener_thread
|
|
500
500
|
return unless @sync_thread
|
|
501
501
|
|
|
502
|
-
|
|
503
|
-
@should_listen[:run] = false
|
|
504
|
-
else
|
|
505
|
-
@should_listen = false
|
|
506
|
-
end
|
|
502
|
+
stop_listener
|
|
507
503
|
|
|
508
504
|
if @sync_thread.alive?
|
|
509
505
|
ret = @sync_thread.join(0.1)
|
|
@@ -512,11 +508,30 @@ module ActiveMatrix
|
|
|
512
508
|
@sync_thread = nil
|
|
513
509
|
end
|
|
514
510
|
|
|
511
|
+
# Signal the listener to stop (works for both thread and async modes)
|
|
512
|
+
def stop_listener
|
|
513
|
+
if @should_listen.is_a? Hash
|
|
514
|
+
@should_listen[:run] = false
|
|
515
|
+
else
|
|
516
|
+
@should_listen = false
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Start listening without spawning a thread (for use with async)
|
|
521
|
+
def start_listener
|
|
522
|
+
@should_listen = true
|
|
523
|
+
end
|
|
524
|
+
|
|
515
525
|
# Check if there's a thread listening for events
|
|
516
526
|
def listening?
|
|
517
527
|
@sync_thread&.alive? == true
|
|
518
528
|
end
|
|
519
529
|
|
|
530
|
+
# Check if listening is enabled (may or may not have active thread)
|
|
531
|
+
def listening_enabled?
|
|
532
|
+
@should_listen == true
|
|
533
|
+
end
|
|
534
|
+
|
|
520
535
|
# Run a message sync round, triggering events as necessary
|
|
521
536
|
#
|
|
522
537
|
# @param skip_store_batch [Boolean] Should this sync skip storing the returned next_batch token,
|