activematrix 0.0.5 → 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/jobs/active_matrix/application_job.rb +11 -0
- data/app/models/active_matrix/agent/jobs/memory_reaper.rb +87 -0
- data/app/models/active_matrix/agent.rb +166 -0
- data/app/models/active_matrix/agent_store.rb +80 -0
- data/app/models/active_matrix/application_record.rb +15 -0
- data/app/models/active_matrix/chat_session.rb +105 -0
- data/app/models/active_matrix/knowledge_base.rb +100 -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 +18 -0
- 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/railtie.rb +8 -0
- data/lib/active_matrix/telemetry.rb +134 -0
- data/lib/active_matrix/version.rb +1 -1
- data/lib/active_matrix.rb +18 -11
- data/lib/generators/active_matrix/install/install_generator.rb +3 -22
- data/lib/generators/active_matrix/install/templates/README +5 -2
- metadata +191 -31
- data/lib/generators/active_matrix/install/templates/agent_memory.rb +0 -47
- data/lib/generators/active_matrix/install/templates/conversation_context.rb +0 -72
- 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
- data/lib/generators/active_matrix/install/templates/global_memory.rb +0 -70
- data/lib/generators/active_matrix/install/templates/matrix_agent.rb +0 -127
|
@@ -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,
|
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'singleton'
|
|
4
|
-
require '
|
|
4
|
+
require 'async'
|
|
5
|
+
require 'async/semaphore'
|
|
6
|
+
require 'async/condition'
|
|
5
7
|
|
|
6
8
|
module ActiveMatrix
|
|
7
|
-
# Manages
|
|
9
|
+
# Manages Matrix client connections per homeserver with rate limiting.
|
|
10
|
+
#
|
|
11
|
+
# NOTE: Despite the name, this is not a traditional connection pool.
|
|
12
|
+
# Each agent gets a dedicated long-lived client. The "pool" provides:
|
|
13
|
+
# - Semaphore-based rate limiting on client creation per homeserver
|
|
14
|
+
# - Tracking of active clients for health monitoring
|
|
15
|
+
#
|
|
16
|
+
# Clients are NOT returned to the pool after use - they remain active
|
|
17
|
+
# for the agent's lifetime. The semaphore prevents too many agents
|
|
18
|
+
# from connecting to a single homeserver simultaneously.
|
|
19
|
+
#
|
|
8
20
|
class ClientPool
|
|
9
21
|
include Singleton
|
|
10
22
|
include ActiveMatrix::Logging
|
|
11
23
|
|
|
12
24
|
def initialize
|
|
13
|
-
@pools =
|
|
25
|
+
@pools = {}
|
|
14
26
|
@config = ActiveMatrix.config
|
|
15
27
|
@mutex = Mutex.new
|
|
16
28
|
end
|
|
17
29
|
|
|
18
30
|
# Get or create a client for a homeserver
|
|
19
31
|
def get_client(homeserver, **)
|
|
20
|
-
@mutex.synchronize
|
|
21
|
-
|
|
22
|
-
pool.checkout(**)
|
|
23
|
-
end
|
|
32
|
+
pool = @mutex.synchronize { get_or_create_pool(homeserver) }
|
|
33
|
+
pool.checkout(**)
|
|
24
34
|
end
|
|
25
35
|
|
|
26
36
|
# Return a client to the pool
|
|
@@ -78,32 +88,37 @@ module ActiveMatrix
|
|
|
78
88
|
@available = []
|
|
79
89
|
@in_use = {}
|
|
80
90
|
@mutex = Mutex.new
|
|
81
|
-
@
|
|
91
|
+
@semaphore = Async::Semaphore.new(max_size)
|
|
82
92
|
end
|
|
83
93
|
|
|
84
94
|
def checkout(**)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
client = find_available_client
|
|
95
|
+
# Acquire semaphore temporarily to rate-limit client creation
|
|
96
|
+
@semaphore.acquire
|
|
88
97
|
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
client = @mutex.synchronize do
|
|
99
|
+
# Try to find an available client
|
|
100
|
+
existing = find_available_client
|
|
91
101
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
if existing
|
|
103
|
+
@available.delete(existing)
|
|
104
|
+
existing
|
|
105
|
+
else
|
|
106
|
+
create_client(**)
|
|
96
107
|
end
|
|
108
|
+
end
|
|
97
109
|
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
# Track as in use
|
|
111
|
+
@mutex.synchronize do
|
|
100
112
|
@in_use[client.object_id] = {
|
|
101
113
|
client: client,
|
|
102
114
|
checked_out_at: Time.current
|
|
103
115
|
}
|
|
104
|
-
|
|
105
|
-
client
|
|
106
116
|
end
|
|
117
|
+
|
|
118
|
+
client
|
|
119
|
+
ensure
|
|
120
|
+
# Release immediately - semaphore only rate-limits creation, not usage
|
|
121
|
+
@semaphore.release
|
|
107
122
|
end
|
|
108
123
|
|
|
109
124
|
def checkin(client)
|
|
@@ -115,12 +130,8 @@ module ActiveMatrix
|
|
|
115
130
|
if client_valid?(client)
|
|
116
131
|
@available << client
|
|
117
132
|
else
|
|
118
|
-
# Client is no longer valid, don't return to pool
|
|
119
133
|
logger.debug "Discarding invalid client for #{@homeserver}"
|
|
120
134
|
end
|
|
121
|
-
|
|
122
|
-
# Signal waiting threads
|
|
123
|
-
@condition.signal
|
|
124
135
|
end
|
|
125
136
|
end
|
|
126
137
|
|
|
@@ -140,7 +151,7 @@ module ActiveMatrix
|
|
|
140
151
|
@mutex.synchronize do
|
|
141
152
|
# Stop all clients
|
|
142
153
|
(@available + @in_use.values.map { |e| e[:client] }).each do |client|
|
|
143
|
-
client.
|
|
154
|
+
client.stop_listener if client.listening?
|
|
144
155
|
client.logout if client.logged_in?
|
|
145
156
|
rescue StandardError => e
|
|
146
157
|
logger.error "Error cleaning up client: #{e.message}"
|
|
@@ -158,7 +169,7 @@ module ActiveMatrix
|
|
|
158
169
|
@available.select! { |client| client_valid?(client) }
|
|
159
170
|
|
|
160
171
|
# Return first available
|
|
161
|
-
@available.
|
|
172
|
+
@available.shift
|
|
162
173
|
end
|
|
163
174
|
|
|
164
175
|
def create_client(**)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'async'
|
|
4
|
+
require 'async/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module ActiveMatrix
|
|
8
|
+
class Daemon
|
|
9
|
+
# Lightweight HTTP health probe server using Async::HTTP
|
|
10
|
+
#
|
|
11
|
+
# Endpoints:
|
|
12
|
+
# - GET /health - Returns 200 if healthy, 503 if shutting down
|
|
13
|
+
# - GET /status - Returns detailed JSON status
|
|
14
|
+
# - GET /metrics - Prometheus-compatible metrics
|
|
15
|
+
#
|
|
16
|
+
class ProbeServer
|
|
17
|
+
attr_reader :host, :port, :daemon
|
|
18
|
+
|
|
19
|
+
def initialize(host:, port:, daemon:)
|
|
20
|
+
@host = host
|
|
21
|
+
@port = port
|
|
22
|
+
@daemon = daemon
|
|
23
|
+
@thread = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def start
|
|
27
|
+
@thread = Thread.new do
|
|
28
|
+
Sync do
|
|
29
|
+
endpoint = Async::HTTP::Endpoint.parse("http://#{host}:#{port}")
|
|
30
|
+
|
|
31
|
+
@server = Async::HTTP::Server.for(endpoint) do |request|
|
|
32
|
+
handle_request(request)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
logger.info "Probe server listening on #{host}:#{port}"
|
|
36
|
+
@server.run
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
logger.error "Probe server error: #{e.message}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stop
|
|
44
|
+
@thread&.kill
|
|
45
|
+
@thread&.join(1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def handle_request(request)
|
|
51
|
+
path = request.path
|
|
52
|
+
|
|
53
|
+
case path
|
|
54
|
+
when '/health'
|
|
55
|
+
health_response
|
|
56
|
+
when '/status'
|
|
57
|
+
status_response
|
|
58
|
+
when '/metrics'
|
|
59
|
+
metrics_response
|
|
60
|
+
else
|
|
61
|
+
not_found_response
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def health_response
|
|
66
|
+
status = daemon.status
|
|
67
|
+
code = status[:status] == 'ok' ? 200 : 503
|
|
68
|
+
|
|
69
|
+
::Protocol::HTTP::Response[code, { 'content-type' => 'text/plain' }, [status[:status]]]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def status_response
|
|
73
|
+
body = JSON.pretty_generate(daemon.status)
|
|
74
|
+
|
|
75
|
+
::Protocol::HTTP::Response[200, { 'content-type' => 'application/json' }, [body]]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def metrics_response
|
|
79
|
+
status = daemon.status
|
|
80
|
+
|
|
81
|
+
lines = [
|
|
82
|
+
'# HELP activematrix_up Is the daemon running',
|
|
83
|
+
'# TYPE activematrix_up gauge',
|
|
84
|
+
"activematrix_up #{status[:status] == 'ok' ? 1 : 0}",
|
|
85
|
+
'',
|
|
86
|
+
'# HELP activematrix_uptime_seconds Daemon uptime in seconds',
|
|
87
|
+
'# TYPE activematrix_uptime_seconds counter',
|
|
88
|
+
"activematrix_uptime_seconds #{status[:uptime]}",
|
|
89
|
+
'',
|
|
90
|
+
'# HELP activematrix_workers Number of worker processes',
|
|
91
|
+
'# TYPE activematrix_workers gauge',
|
|
92
|
+
"activematrix_workers #{status[:workers]}",
|
|
93
|
+
'',
|
|
94
|
+
'# HELP activematrix_agents_total Total number of agents',
|
|
95
|
+
'# TYPE activematrix_agents_total gauge',
|
|
96
|
+
"activematrix_agents_total #{status.dig(:agents, :total) || 0}",
|
|
97
|
+
'',
|
|
98
|
+
'# HELP activematrix_agents Agent count by state',
|
|
99
|
+
'# TYPE activematrix_agents gauge',
|
|
100
|
+
"activematrix_agents{state=\"online\"} #{status.dig(:agents, :online) || 0}",
|
|
101
|
+
"activematrix_agents{state=\"connecting\"} #{status.dig(:agents, :connecting) || 0}",
|
|
102
|
+
"activematrix_agents{state=\"error\"} #{status.dig(:agents, :error) || 0}",
|
|
103
|
+
"activematrix_agents{state=\"offline\"} #{status.dig(:agents, :offline) || 0}"
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
::Protocol::HTTP::Response[200, { 'content-type' => 'text/plain; version=0.0.4' }, [lines.join("\n")]]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def not_found_response
|
|
110
|
+
::Protocol::HTTP::Response[404, { 'content-type' => 'text/plain' }, ['Not Found']]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def logger
|
|
114
|
+
ActiveMatrix.logger
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|