legionio 1.5.10 → 1.5.12

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a667cd060abef4731373441c150267a5cfff9851061823edf4ba2f11af957958
4
- data.tar.gz: 667fa1b3e3779215514cbade8b8062a1e5a219fdbea51bbbae4e4e5a69c43c12
3
+ metadata.gz: 2b5d012d4df4a245648fe489a4c998c34fa209c119b74301d209f29cdeb44977
4
+ data.tar.gz: 2f818144217a965f4353201773843cbfef1e151406b27996f6d493f144fa7de9
5
5
  SHA512:
6
- metadata.gz: 5bae139c1dc0209987800907ce76b4c198ffff24de925e7fdfd2d2d0603bfbb6c095c4e863cc32510cac62fbf820ed850d8a05023d659a70c9bfd234c754f603
7
- data.tar.gz: eea81ebd4a661ecd921aaf48c08259757ff434baea7f4685859bd2df6cac912e7ac89b09e4c64c3edc315549c485d3b46501609cd3cf2cd7d0c60a12491700c1
6
+ metadata.gz: e436068fa3c9159a904fd9a57434916ad49a1e332b6220c2b89c290725094da4173aadea83c0656cccb564a05b0033521ad5ee37b6e0dc054eb83fa7facf2114
7
+ data.tar.gz: d83cacb7a7afddfb35892ef4d1183c32cce83c64bfa53fed51d599b8eb04a99589af9fc1202a778a7389b257ea2c9fdd9a71010f2ebfb2c8b2b193b54fbe31fe
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.5.12] - 2026-03-25
4
+
5
+ ### Added
6
+ - `GET /api/stats` endpoint — comprehensive daemon runtime stats: extensions (loaded/actor counts), gaia (status/channels/phases), transport (session/channels), cache/cache_local (pool stats), llm (provider health/routing), data/data_local (pool/tuning via legion-data stats), api (puma threads/routes)
7
+
8
+ ### Changed
9
+ - Bumped gemspec dependency: legion-data >= 1.6.0 (required for `Legion::Data.stats`)
10
+
11
+ ## [1.5.11] - 2026-03-25
12
+
13
+ ### Added
14
+ - `legionio debug` command — full diagnostic dump (16 sections: versions, doctor, config, gems, extensions, RBAC, LLM, GAIA, transport, events, Apollo, remote/local Redis, PostgreSQL, RabbitMQ, API health) output as markdown or JSON, suitable for piping to an LLM session
15
+ - `legionio update --cleanup` flag — removes old gem versions after update via `Gem::Uninstaller` (default: no cleanup)
16
+
17
+ ### Fixed
18
+ - `update_command.rb` `snapshot_versions` now uses `find_all_by_name` + max version instead of `find_by_name`, which returned the already-activated (potentially stale) gem version
19
+ - `service.rb` `setup_api` guard prevents duplicate Puma start when `@api_thread` is already alive
20
+
21
+ ### Changed
22
+ - Bumped gemspec dependencies: legion-data >= 1.5.3, legion-gaia >= 0.9.24, legion-llm >= 0.5.8, legion-tty >= 0.4.35
23
+
3
24
  ## [1.5.10] - 2026-03-25
4
25
 
5
26
  ### Changed
data/CLAUDE.md CHANGED
@@ -633,6 +633,7 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
633
633
  | `lib/legion/telemetry.rb` | Opt-in OpenTelemetry tracing: `with_span` wrapper, `sanitize_attributes`, `record_exception` |
634
634
  | `lib/legion/metrics.rb` | Opt-in Prometheus metrics: event-driven counters, pull-based gauges, `prometheus-client` guarded |
635
635
  | `lib/legion/api/metrics.rb` | `GET /metrics` Prometheus text-format endpoint with gauge refresh |
636
+ | `lib/legion/api/stats.rb` | `GET /api/stats` comprehensive daemon runtime stats (extensions, gaia, transport, cache, llm, data, api) |
636
637
  | `lib/legion/chat/notification_queue.rb` | Thread-safe priority queue for background notifications (critical/info/debug) |
637
638
  | `lib/legion/chat/notification_bridge.rb` | Event-driven bridge: matches Legion events to chat notifications via fnmatch patterns |
638
639
  | `lib/legion/api/middleware/auth.rb` | Auth: JWT Bearer auth middleware (real token validation, skip paths for health/ready) |
data/legionio.gemspec CHANGED
@@ -54,12 +54,14 @@ Gem::Specification.new do |spec|
54
54
 
55
55
  spec.add_dependency 'legion-cache', '>= 1.3.16'
56
56
  spec.add_dependency 'legion-crypt', '>= 1.4.12'
57
- spec.add_dependency 'legion-data', '>= 1.5.0'
57
+ spec.add_dependency 'legion-data', '>= 1.6.0'
58
58
  spec.add_dependency 'legion-json', '>= 1.2.1'
59
59
  spec.add_dependency 'legion-logging', '>= 1.3.2'
60
60
  spec.add_dependency 'legion-settings', '>= 1.3.19'
61
61
  spec.add_dependency 'legion-transport', '>= 1.4.0'
62
62
 
63
- spec.add_dependency 'legion-tty', '>= 0.4.34'
63
+ spec.add_dependency 'legion-gaia', '>= 0.9.24'
64
+ spec.add_dependency 'legion-llm', '>= 0.5.8'
65
+ spec.add_dependency 'legion-tty', '>= 0.4.35'
64
66
  spec.add_dependency 'lex-node'
65
67
  end
@@ -155,6 +155,7 @@ module Legion
155
155
  .merge(gaia_paths)
156
156
  .merge(apollo_paths)
157
157
  .merge(openapi_paths)
158
+ .merge(stats_paths)
158
159
  end
159
160
  private_class_method :paths
160
161
 
@@ -1661,6 +1662,42 @@ module Legion
1661
1662
  }
1662
1663
  end
1663
1664
  private_class_method :openapi_paths
1665
+
1666
+ def self.stats_paths
1667
+ {
1668
+ '/api/stats' => {
1669
+ get: {
1670
+ tags: ['Stats'],
1671
+ summary: 'Comprehensive daemon runtime stats',
1672
+ description: 'Returns runtime statistics for all subsystems: extensions, gaia, transport, cache, llm, data, and api. ' \
1673
+ 'Each section collects independently — one subsystem failure does not affect others.',
1674
+ operationId: 'getStats',
1675
+ responses: {
1676
+ '200' => ok_response('Stats', wrap_data('StatsObject').merge(
1677
+ properties: {
1678
+ data: {
1679
+ type: 'object',
1680
+ properties: {
1681
+ extensions: { type: 'object' },
1682
+ gaia: { type: 'object' },
1683
+ transport: { type: 'object' },
1684
+ cache: { type: 'object' },
1685
+ cache_local: { type: 'object' },
1686
+ llm: { type: 'object' },
1687
+ data: { type: 'object' },
1688
+ data_local: { type: 'object' },
1689
+ api: { type: 'object' }
1690
+ }
1691
+ },
1692
+ meta: { '$ref' => '#/components/schemas/Meta' }
1693
+ }
1694
+ ))
1695
+ }
1696
+ }
1697
+ }
1698
+ }
1699
+ end
1700
+ private_class_method :stats_paths
1664
1701
  end
1665
1702
  end
1666
1703
  end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module Stats
7
+ def self.registered(app)
8
+ app.get '/api/stats' do
9
+ result = {}
10
+ result[:extensions] = Routes::Stats.collect_extensions
11
+ result[:gaia] = Routes::Stats.collect_gaia
12
+ result[:transport] = Routes::Stats.collect_transport
13
+ result[:cache] = Routes::Stats.collect_cache
14
+ result[:cache_local] = Routes::Stats.collect_cache_local
15
+ result[:llm] = Routes::Stats.collect_llm
16
+ result[:data] = Routes::Stats.collect_data
17
+ result[:data_local] = Routes::Stats.collect_data_local
18
+ result[:api] = Routes::Stats.collect_api
19
+ json_response(result)
20
+ end
21
+ end
22
+
23
+ EXTENSION_IVARS = {
24
+ loaded: :@loaded_extensions,
25
+ discovered: :@extensions,
26
+ subscription: :@subscription_tasks,
27
+ every: :@timer_tasks,
28
+ poll: :@poll_tasks,
29
+ once: :@once_tasks,
30
+ loop: :@loop_tasks,
31
+ running: :@running_instances
32
+ }.freeze
33
+
34
+ class << self
35
+ def collect_extensions
36
+ ext = Legion::Extensions
37
+ EXTENSION_IVARS.transform_values { |ivar| ext.instance_variable_get(ivar)&.count || 0 }
38
+ rescue StandardError => e
39
+ { error: e.message }
40
+ end
41
+
42
+ def collect_gaia
43
+ return { started: false } unless defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:started?) && Legion::Gaia.started?
44
+
45
+ Legion::Gaia.status
46
+ rescue StandardError => e
47
+ { error: e.message }
48
+ end
49
+
50
+ def collect_transport
51
+ conn = Legion::Transport::Connection
52
+ connected = begin
53
+ Legion::Settings[:transport][:connected]
54
+ rescue StandardError
55
+ false
56
+ end
57
+ connector = defined?(Legion::Transport::TYPE) ? Legion::Transport::TYPE.to_s : 'unknown'
58
+
59
+ info = { connected: connected, connector: connector }
60
+
61
+ session = conn.session
62
+ if session.respond_to?(:open?) && session.open?
63
+ info[:session_open] = true
64
+ info[:channel_max] = session.channel_max if session.respond_to?(:channel_max)
65
+ # Bunny tracks open channels in @channels hash
66
+ channels = session.instance_variable_get(:@channels)
67
+ info[:channels_open] = channels.is_a?(Hash) ? channels.count : nil
68
+ else
69
+ info[:session_open] = false
70
+ end
71
+
72
+ info[:build_session_open] = conn.build_session_open?
73
+ info[:lite_mode] = conn.lite_mode?
74
+ info
75
+ rescue StandardError => e
76
+ { error: e.message }
77
+ end
78
+
79
+ def collect_cache
80
+ return { connected: false } unless defined?(Legion::Cache)
81
+
82
+ info = { connected: Legion::Cache.connected? }
83
+ info[:using_local] = Legion::Cache.using_local? if Legion::Cache.respond_to?(:using_local?)
84
+ info[:using_memory] = Legion::Cache.instance_variable_get(:@using_memory) == true
85
+ info[:driver] = begin
86
+ Legion::Settings[:cache][:driver]
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
91
+ if Legion::Cache.connected? && Legion::Cache.respond_to?(:size)
92
+ info[:pool_size] = begin
93
+ Legion::Cache.size
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ info[:pool_available] = begin
98
+ Legion::Cache.available
99
+ rescue StandardError
100
+ nil
101
+ end
102
+ end
103
+ info
104
+ rescue StandardError => e
105
+ { error: e.message }
106
+ end
107
+
108
+ def collect_cache_local
109
+ return { connected: false } unless defined?(Legion::Cache::Local)
110
+
111
+ info = { connected: Legion::Cache::Local.connected? }
112
+ if Legion::Cache::Local.connected?
113
+ info[:pool_size] = begin
114
+ Legion::Cache::Local.size
115
+ rescue StandardError
116
+ nil
117
+ end
118
+ info[:pool_available] = begin
119
+ Legion::Cache::Local.available
120
+ rescue StandardError
121
+ nil
122
+ end
123
+ end
124
+ info
125
+ rescue StandardError => e
126
+ { error: e.message }
127
+ end
128
+
129
+ def collect_llm
130
+ return { started: false } unless defined?(Legion::LLM) && Legion::LLM.started?
131
+
132
+ info = { started: true }
133
+ s = Legion::LLM.settings
134
+ info[:default_model] = s[:default_model]
135
+ info[:default_provider] = s[:default_provider]
136
+ info[:pipeline_enabled] = s[:pipeline_enabled] == true
137
+
138
+ if defined?(Legion::LLM::Router) && Legion::LLM::Router.routing_enabled?
139
+ info[:routing_enabled] = true
140
+ tracker = Legion::LLM::Router.health_tracker
141
+ if tracker
142
+ providers = s[:providers] || {}
143
+ info[:provider_health] = providers.each_with_object({}) do |(name, _cfg), h|
144
+ h[name] = { circuit: tracker.circuit_state(name)&.to_s }
145
+ rescue StandardError
146
+ nil
147
+ end
148
+ end
149
+ else
150
+ info[:routing_enabled] = false
151
+ end
152
+
153
+ if defined?(Legion::LLM::ConversationStore)
154
+ store = Legion::LLM::ConversationStore
155
+ info[:conversations] = store.respond_to?(:size) ? store.size : nil
156
+ end
157
+ info
158
+ rescue StandardError => e
159
+ { error: e.message }
160
+ end
161
+
162
+ def collect_data
163
+ return { connected: false } unless defined?(Legion::Data) && Legion::Settings[:data][:connected]
164
+
165
+ if Legion::Data.respond_to?(:stats)
166
+ stats = Legion::Data.stats
167
+ stats[:shared] || stats
168
+ else
169
+ { connected: true, adapter: begin
170
+ Legion::Data::Connection.adapter
171
+ rescue StandardError
172
+ nil
173
+ end }
174
+ end
175
+ rescue StandardError => e
176
+ { error: e.message }
177
+ end
178
+
179
+ def collect_data_local
180
+ return { connected: false } unless defined?(Legion::Data::Local) && Legion::Data::Local.connected?
181
+
182
+ if Legion::Data::Local.respond_to?(:stats)
183
+ Legion::Data::Local.stats
184
+ else
185
+ { connected: true }
186
+ end
187
+ rescue StandardError => e
188
+ { error: e.message }
189
+ end
190
+
191
+ def collect_api
192
+ port = Legion::Settings.dig(:api, :port) || Legion::Settings.dig(:http, :port) || 4567
193
+ info = { port: port }
194
+
195
+ # Puma thread pool stats if available
196
+ puma_server = Puma::Server.current if defined?(Puma::Server) && Puma::Server.respond_to?(:current)
197
+ if puma_server.respond_to?(:pool_capacity)
198
+ info[:puma] = {
199
+ pool_capacity: puma_server.pool_capacity,
200
+ max_threads: puma_server.max_threads,
201
+ running: puma_server.running,
202
+ backlog: puma_server.backlog
203
+ }
204
+ end
205
+
206
+ info[:routes] = begin
207
+ Legion::API.routes.values.flatten.count
208
+ rescue StandardError
209
+ nil
210
+ end
211
+ info
212
+ rescue StandardError => e
213
+ { error: e.message }
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
data/lib/legion/api.rb CHANGED
@@ -45,6 +45,7 @@ require_relative 'api/marketplace'
45
45
  require_relative 'api/apollo'
46
46
  require_relative 'api/costs'
47
47
  require_relative 'api/traces'
48
+ require_relative 'api/stats'
48
49
  require_relative 'api/graphql' if defined?(GraphQL)
49
50
 
50
51
  module Legion
@@ -135,6 +136,7 @@ module Legion
135
136
  register Routes::Apollo
136
137
  register Routes::Costs
137
138
  register Routes::Traces
139
+ register Routes::Stats
138
140
  register Routes::GraphQL if defined?(Routes::GraphQL)
139
141
 
140
142
  use Legion::API::Middleware::RequestLogger
@@ -0,0 +1,453 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'net/http'
5
+ require 'json'
6
+ require 'socket'
7
+ require 'thor'
8
+
9
+ module Legion
10
+ module CLI
11
+ class Debug < Thor
12
+ namespace 'debug'
13
+
14
+ def self.exit_on_failure?
15
+ true
16
+ end
17
+
18
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
19
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
20
+ class_option :port, type: :numeric, default: 4567, desc: 'API port'
21
+ class_option :host, type: :string, default: '127.0.0.1', desc: 'API host'
22
+ class_option :config_dir, type: :string, desc: 'Config directory path'
23
+
24
+ desc 'dump', 'Full diagnostic dump (markdown, suitable for piping to LLM)'
25
+ default_task :dump
26
+ def dump
27
+ sections = collect_all_sections
28
+
29
+ output = if options[:json]
30
+ ::JSON.pretty_generate(sections)
31
+ else
32
+ build_markdown(sections)
33
+ end
34
+
35
+ puts output
36
+
37
+ path = write_dump_file(output)
38
+ warn "Saved to #{path}" if path
39
+ end
40
+
41
+ DEBUG_DIR = File.expand_path('~/.legionio/debug')
42
+
43
+ no_commands do # rubocop:disable Metrics/BlockLength
44
+ private
45
+
46
+ def collect_all_sections
47
+ sections = {}
48
+ sections[:versions] = section_versions
49
+ sections[:doctor] = section_doctor
50
+ sections[:config] = section_config
51
+ sections[:gems] = section_gems
52
+ sections[:extensions] = section_extensions
53
+ sections[:rbac] = section_rbac
54
+ sections[:llm] = section_llm
55
+ sections[:gaia] = section_gaia
56
+ sections[:transport] = section_transport
57
+ sections[:events] = section_events
58
+ sections[:apollo] = section_apollo
59
+ sections[:remote_redis] = section_remote_redis
60
+ sections[:local_redis] = section_local_redis
61
+ sections[:postgresql] = section_postgresql
62
+ sections[:rabbitmq] = section_rabbitmq
63
+ sections[:api_health] = section_api_health
64
+ sections
65
+ end
66
+
67
+ def write_dump_file(output)
68
+ FileUtils.mkdir_p(DEBUG_DIR)
69
+ ext = options[:json] ? 'json' : 'md'
70
+ filename = "#{Time.now.utc.strftime('%Y-%m-%d_%H%M%S')}.#{ext}"
71
+ path = File.join(DEBUG_DIR, filename)
72
+ File.write(path, output)
73
+ path
74
+ rescue StandardError => e
75
+ warn "Warning: could not write debug file: #{e.message}"
76
+ nil
77
+ end
78
+
79
+ def api_host
80
+ options[:host] || '127.0.0.1'
81
+ end
82
+
83
+ def api_port_number
84
+ options[:port] || 4567
85
+ end
86
+
87
+ def api_get(path)
88
+ uri = URI("http://#{api_host}:#{api_port_number}#{path}")
89
+ http = Net::HTTP.new(uri.host, uri.port)
90
+ http.open_timeout = 3
91
+ http.read_timeout = 5
92
+ response = http.get(uri.request_uri)
93
+ ::JSON.parse(response.body, symbolize_names: true)
94
+ rescue StandardError => e
95
+ { error: e.message }
96
+ end
97
+
98
+ def load_settings
99
+ Connection.config_dir = options[:config_dir] if options[:config_dir]
100
+ Connection.log_level = 'error'
101
+ Connection.ensure_settings
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ def section_versions
107
+ components = {}
108
+ components[:legionio] = defined?(Legion::VERSION) ? Legion::VERSION : 'unknown'
109
+ components[:ruby] = RUBY_VERSION
110
+ components[:platform] = RUBY_PLATFORM
111
+ components[:yjit] = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?
112
+
113
+ %w[legion-transport legion-cache legion-crypt legion-data
114
+ legion-json legion-logging legion-settings
115
+ legion-llm legion-gaia legion-mcp legion-rbac legion-tty].each do |gem_name|
116
+ spec = Gem::Specification.find_by_name(gem_name)
117
+ components[gem_name.to_sym] = spec.version.to_s
118
+ rescue Gem::MissingSpecError
119
+ components[gem_name.to_sym] = 'not installed'
120
+ end
121
+
122
+ components
123
+ rescue StandardError => e
124
+ { error: e.message }
125
+ end
126
+
127
+ def section_doctor
128
+ load_settings
129
+ require 'legion/cli/doctor_command'
130
+ Doctor::CHECKS.map do |name|
131
+ check = Doctor.const_get(name).new
132
+ result = check.run
133
+ { name: result.name, status: result.status, message: result.message }
134
+ rescue StandardError => e
135
+ { name: name.to_s, status: :error, message: e.message }
136
+ end
137
+ rescue StandardError => e
138
+ { error: e.message }
139
+ end
140
+
141
+ def section_config
142
+ load_settings
143
+ settings_hash = Legion::Settings.loader.to_hash
144
+ redact_deep(settings_hash)
145
+ rescue StandardError => e
146
+ { error: e.message }
147
+ end
148
+
149
+ def section_gems
150
+ gems = {}
151
+ duplicates = []
152
+ Gem::Specification.each do |spec|
153
+ next unless spec.name.start_with?('legion-', 'lex-', 'legionio')
154
+
155
+ gems[spec.name] ||= []
156
+ gems[spec.name] << spec.version.to_s
157
+ end
158
+
159
+ gems.each do |name, versions|
160
+ duplicates << { name: name, versions: versions } if versions.size > 1
161
+ end
162
+
163
+ { total: gems.size, duplicates: duplicates,
164
+ versions: gems.transform_values { |v| v.max_by { |ver| Gem::Version.new(ver) } } }
165
+ rescue StandardError => e
166
+ { error: e.message }
167
+ end
168
+
169
+ def section_extensions
170
+ data = api_get('/api/extensions')
171
+ return data if data[:error]
172
+
173
+ exts = data[:data] || data[:extensions] || data
174
+ { count: exts.is_a?(Array) ? exts.size : nil, extensions: exts }
175
+ end
176
+
177
+ def section_rbac
178
+ api_get('/api/rbac/roles')
179
+ end
180
+
181
+ def section_llm
182
+ load_settings
183
+ require 'legion/llm'
184
+ Legion::Settings.merge_settings(:llm, Legion::LLM::Settings.default)
185
+ settings = Legion::LLM.settings
186
+ providers = settings[:providers] || {}
187
+ {
188
+ started: defined?(Legion::LLM) && Legion::LLM.started?,
189
+ default_provider: settings[:default_provider],
190
+ default_model: settings[:default_model],
191
+ providers: providers.map { |name, cfg| { name: name, enabled: cfg[:enabled] } }
192
+ }
193
+ rescue StandardError => e
194
+ { error: e.message }
195
+ end
196
+
197
+ def section_gaia
198
+ status = api_get('/api/gaia/status')
199
+ channels = api_get('/api/gaia/channels')
200
+ buffer = api_get('/api/gaia/buffer')
201
+ sessions = api_get('/api/gaia/sessions')
202
+ { status: status[:data] || status, channels: channels[:data] || channels,
203
+ buffer: buffer[:data] || buffer, sessions: sessions[:data] || sessions }
204
+ end
205
+
206
+ def section_transport
207
+ api_get('/api/transport/status')
208
+ end
209
+
210
+ def section_events
211
+ api_get('/api/events/recent?count=20')
212
+ end
213
+
214
+ def section_apollo
215
+ api_get('/api/apollo/stats')
216
+ end
217
+
218
+ def section_remote_redis
219
+ load_settings
220
+ cache_cfg = Legion::Settings[:cache]
221
+ return { error: 'no cache config' } unless cache_cfg.is_a?(Hash) && cache_cfg[:servers]
222
+
223
+ server = cache_cfg[:servers].first
224
+ host, port = server.to_s.split(':')
225
+ password = cache_cfg[:password]
226
+
227
+ redis_info(host, port.to_i, password)
228
+ rescue StandardError => e
229
+ { error: e.message }
230
+ end
231
+
232
+ def section_local_redis
233
+ load_settings
234
+ local_cfg = Legion::Settings[:cache_local]
235
+ return { error: 'no cache_local config' } unless local_cfg.is_a?(Hash) && local_cfg[:servers]
236
+
237
+ server = local_cfg[:servers].first
238
+ host, port = server.to_s.split(':')
239
+ password = local_cfg[:password]
240
+
241
+ redis_info(host, port.to_i, password)
242
+ rescue StandardError => e
243
+ { error: e.message }
244
+ end
245
+
246
+ def section_postgresql
247
+ load_settings
248
+ data_cfg = Legion::Settings[:data]
249
+ return { error: 'no data config' } unless data_cfg.is_a?(Hash) && data_cfg[:creds]
250
+
251
+ creds = data_cfg[:creds]
252
+ require 'pg'
253
+ conn = PG.connect(
254
+ host: creds[:host], port: creds[:port] || 5432,
255
+ dbname: creds[:database], user: creds[:user], password: creds[:password],
256
+ connect_timeout: 5
257
+ )
258
+
259
+ db_size = conn.exec_params(
260
+ 'SELECT pg_size_pretty(pg_database_size(current_database())) AS size'
261
+ ).first['size']
262
+ migration = conn.exec_params(
263
+ 'SELECT version FROM schema_info ORDER BY version DESC LIMIT 1'
264
+ ).first
265
+ migration_version = migration ? migration['version'] : 'unknown'
266
+
267
+ tables = conn.exec_params(<<~SQL).to_a
268
+ SELECT tablename AS name,
269
+ pg_size_pretty(pg_total_relation_size(quote_ident(tablename))) AS size,
270
+ (SELECT n_live_tup FROM pg_stat_user_tables WHERE relname = tablename) AS rows
271
+ FROM pg_tables WHERE schemaname = 'public'
272
+ ORDER BY pg_total_relation_size(quote_ident(tablename)) DESC LIMIT 20
273
+ SQL
274
+
275
+ conn.close
276
+ { db_size: db_size, migration_version: migration_version, tables: tables }
277
+ rescue LoadError
278
+ { error: 'pg gem not available' }
279
+ rescue StandardError => e
280
+ { error: e.message }
281
+ end
282
+
283
+ def section_rabbitmq
284
+ load_settings
285
+ transport_cfg = Legion::Settings[:transport] || {}
286
+ host = transport_cfg[:host] || 'localhost'
287
+ mgmt_port = transport_cfg[:management_port] || 15_672
288
+ user = transport_cfg[:user] || 'guest'
289
+ pass = transport_cfg[:password] || 'guest'
290
+ vhost = transport_cfg[:vhost] || '/'
291
+
292
+ uri = URI("http://#{host}:#{mgmt_port}/api/overview")
293
+ http = Net::HTTP.new(uri.host, uri.port)
294
+ http.open_timeout = 3
295
+ http.read_timeout = 5
296
+ req = Net::HTTP::Get.new(uri)
297
+ req.basic_auth(user, pass)
298
+ resp = http.request(req)
299
+ overview = ::JSON.parse(resp.body, symbolize_names: true)
300
+
301
+ encoded_vhost = URI.encode_www_form_component(vhost)
302
+ queues_uri = URI("http://#{host}:#{mgmt_port}/api/queues/#{encoded_vhost}")
303
+ req2 = Net::HTTP::Get.new("#{queues_uri.path}?page=1&page_size=15&sort=messages&sort_reverse=true")
304
+ req2.basic_auth(user, pass)
305
+ resp2 = http.request(req2)
306
+ queues = ::JSON.parse(resp2.body, symbolize_names: true)
307
+
308
+ queue_list = queues.is_a?(Array) ? queues : (queues[:items] || [])
309
+
310
+ {
311
+ cluster_name: overview[:cluster_name],
312
+ rabbitmq_version: overview[:rabbitmq_version],
313
+ erlang_version: overview[:erlang_version],
314
+ message_stats: overview[:message_stats],
315
+ queue_totals: overview[:queue_totals],
316
+ object_totals: overview[:object_totals],
317
+ top_queues: queue_list.first(15).map do |q|
318
+ { name: q[:name], messages: q[:messages], consumers: q[:consumers] }
319
+ end
320
+ }
321
+ rescue StandardError => e
322
+ { error: e.message }
323
+ end
324
+
325
+ def section_api_health
326
+ ready = api_get('/api/ready')
327
+ health = api_get('/api/health')
328
+ capacity = api_get('/api/capacity')
329
+ cost = api_get('/api/cost/summary')
330
+ { ready: ready, health: health, capacity: capacity, cost: cost }
331
+ end
332
+
333
+ def redis_info(host, port, password)
334
+ socket = TCPSocket.new(host, port)
335
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
336
+
337
+ if password && !password.empty?
338
+ socket.write("AUTH #{password}\r\n")
339
+ auth_resp = socket.gets
340
+ return { error: "AUTH failed: #{auth_resp&.strip}" } unless auth_resp&.start_with?('+OK')
341
+ end
342
+
343
+ info = redis_command(socket, 'INFO memory')
344
+ dbsize_raw = redis_command(socket, 'DBSIZE')
345
+
346
+ socket.close
347
+
348
+ memory_lines = info.lines.select { |l| l.include?(':') }.to_h { |l| l.strip.split(':', 2) }
349
+ dbsize = dbsize_raw.to_s.scan(/\d+/).first
350
+
351
+ {
352
+ used_memory_human: memory_lines['used_memory_human'],
353
+ used_memory_peak_human: memory_lines['used_memory_peak_human'],
354
+ maxmemory_human: memory_lines['maxmemory_human'],
355
+ mem_fragmentation_ratio: memory_lines['mem_fragmentation_ratio'],
356
+ dbsize: dbsize
357
+ }
358
+ rescue StandardError => e
359
+ { error: e.message }
360
+ end
361
+
362
+ def redis_command(socket, cmd)
363
+ parts = cmd.split
364
+ socket.write("*#{parts.size}\r\n")
365
+ parts.each { |p| socket.write("$#{p.bytesize}\r\n#{p}\r\n") }
366
+
367
+ first = socket.gets
368
+ return '' unless first
369
+
370
+ case first[0]
371
+ when '+', ':' then first[1..].strip
372
+ when '-' then "ERROR: #{first[1..].strip}"
373
+ when '$'
374
+ len = first[1..].to_i
375
+ return '' if len.negative?
376
+
377
+ data = socket.read(len + 2)
378
+ data&.strip || ''
379
+ when '*'
380
+ count = first[1..].to_i
381
+ return '' if count.negative?
382
+
383
+ count.times.map { redis_read_bulk(socket) }.join("\n")
384
+ else
385
+ first.strip
386
+ end
387
+ end
388
+
389
+ def redis_read_bulk(socket)
390
+ header = socket.gets
391
+ return '' unless header&.start_with?('$')
392
+
393
+ len = header[1..].to_i
394
+ return '' if len.negative?
395
+
396
+ data = socket.read(len + 2)
397
+ data&.strip || ''
398
+ end
399
+
400
+ def redact_deep(obj)
401
+ case obj
402
+ when Hash
403
+ obj.each_with_object({}) do |(k, v), h|
404
+ h[k] = if k.to_s.match?(/password|secret|token|key|credential/i) && v.is_a?(String)
405
+ '[REDACTED]'
406
+ else
407
+ redact_deep(v)
408
+ end
409
+ end
410
+ when Array
411
+ obj.map { |v| redact_deep(v) }
412
+ else
413
+ obj
414
+ end
415
+ end
416
+
417
+ def build_markdown(sections)
418
+ lines = []
419
+ lines << '# LegionIO Diagnostic Dump'
420
+ lines << ''
421
+ lines << "Generated: #{Time.now.utc.iso8601}"
422
+ lines << ''
423
+
424
+ { 'Versions' => :versions,
425
+ 'Doctor Checks' => :doctor,
426
+ 'Configuration (redacted)' => :config,
427
+ 'Installed Gems' => :gems,
428
+ 'Loaded Extensions' => :extensions,
429
+ 'RBAC Roles' => :rbac,
430
+ 'LLM Status' => :llm,
431
+ 'GAIA Status' => :gaia,
432
+ 'Transport Status' => :transport,
433
+ 'Recent Events (last 20)' => :events,
434
+ 'Apollo Stats' => :apollo,
435
+ 'Remote Redis' => :remote_redis,
436
+ 'Local Redis' => :local_redis,
437
+ 'PostgreSQL' => :postgresql,
438
+ 'RabbitMQ' => :rabbitmq,
439
+ 'API Health' => :api_health }.each do |title, key|
440
+ lines << "## #{title}"
441
+ lines << ''
442
+ lines << '```json'
443
+ lines << ::JSON.pretty_generate(sections[key])
444
+ lines << '```'
445
+ lines << ''
446
+ end
447
+
448
+ lines.join("\n")
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
@@ -6,6 +6,7 @@ require 'rbconfig'
6
6
  require 'concurrent'
7
7
  require 'net/http'
8
8
  require 'json'
9
+ require 'rubygems/uninstaller'
9
10
 
10
11
  module Legion
11
12
  module CLI
@@ -22,6 +23,7 @@ module Legion
22
23
  desc 'gems', 'Update Legion gems to latest versions (default)'
23
24
  default_task :gems
24
25
  option :dry_run, type: :boolean, default: false, desc: 'Show what would be updated without installing'
26
+ option :cleanup, type: :boolean, default: false, desc: 'Remove old gem versions after update'
25
27
  def gems
26
28
  out = formatter
27
29
  gem_bin = File.join(RbConfig::CONFIG['bindir'], 'gem')
@@ -44,6 +46,8 @@ module Legion
44
46
  else
45
47
  display_results(out, results, before, after)
46
48
  end
49
+
50
+ cleanup_old_gems(out, target_gems) if options[:cleanup] && !options[:dry_run]
47
51
  end
48
52
 
49
53
  no_commands do
@@ -66,11 +70,12 @@ module Legion
66
70
 
67
71
  def snapshot_versions(gem_names)
68
72
  gem_names.each_with_object({}) do |name, hash|
69
- spec = Gem::Specification.find_by_name(name)
70
- hash[name] = spec.version.to_s
71
- rescue Gem::MissingSpecError => e
72
- Legion::Logging.debug("UpdateCommand#snapshot_versions gem #{name} not found: #{e.message}") if defined?(Legion::Logging)
73
- hash[name] = nil
73
+ specs = Gem::Specification.find_all_by_name(name)
74
+ hash[name] = if specs.empty?
75
+ nil
76
+ else
77
+ specs.map(&:version).max.to_s
78
+ end
74
79
  end
75
80
  end
76
81
 
@@ -174,6 +179,39 @@ module Legion
174
179
  suggest_detect(out)
175
180
  end
176
181
 
182
+ def cleanup_old_gems(out, gem_names)
183
+ Gem::Specification.reset
184
+ cleaned = 0
185
+
186
+ gem_names.each do |name|
187
+ specs = Gem::Specification.find_all_by_name(name).sort_by(&:version)
188
+ next if specs.size <= 1
189
+
190
+ latest = specs.pop
191
+ specs.each do |old_spec|
192
+ Gem::Uninstaller.new(
193
+ old_spec.name,
194
+ version: old_spec.version,
195
+ ignore: true,
196
+ executables: false,
197
+ force: true,
198
+ abort_on_dependent: false
199
+ ).uninstall
200
+ out.success(" Cleaned #{old_spec.name}-#{old_spec.version} (keeping #{latest.version})")
201
+ cleaned += 1
202
+ rescue StandardError => e
203
+ out.error(" Failed to clean #{old_spec.name}-#{old_spec.version}: #{e.message}")
204
+ end
205
+ end
206
+
207
+ out.spacer
208
+ if cleaned.positive?
209
+ out.success("Cleaned #{cleaned} old gem version(s)")
210
+ else
211
+ puts 'No old gem versions to clean'
212
+ end
213
+ end
214
+
177
215
  def suggest_detect(out)
178
216
  require 'legion/extensions/detect'
179
217
  missing = Legion::Extensions::Detect.missing
data/lib/legion/cli.rb CHANGED
@@ -62,6 +62,7 @@ module Legion
62
62
  autoload :Apollo, 'legion/cli/apollo_command'
63
63
  autoload :TraceCommand, 'legion/cli/trace_command'
64
64
  autoload :Features, 'legion/cli/features_command'
65
+ autoload :Debug, 'legion/cli/debug_command'
65
66
 
66
67
  class Main < Thor
67
68
  def self.exit_on_failure?
@@ -342,6 +343,9 @@ module Legion
342
343
  desc 'features SUBCOMMAND', 'Install feature bundles (interactive selector)'
343
344
  subcommand 'features', Legion::CLI::Features
344
345
 
346
+ desc 'debug', 'Diagnostic dump for troubleshooting (pipe to LLM for analysis)'
347
+ subcommand 'debug', Legion::CLI::Debug
348
+
345
349
  desc 'tree', 'Print a tree of all available commands'
346
350
  def tree
347
351
  legion_print_command_tree(self.class, 'legion', '')
@@ -236,6 +236,11 @@ module Legion
236
236
  end
237
237
 
238
238
  def setup_api
239
+ if @api_thread&.alive?
240
+ Legion::Logging.warn 'API already running, skipping duplicate setup_api call'
241
+ return
242
+ end
243
+
239
244
  require 'legion/api'
240
245
  api_settings = Legion::Settings[:api] || {}
241
246
  port = api_settings[:port] || 4567
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.5.10'
4
+ VERSION = '1.5.12'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.10
4
+ version: 1.5.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -253,14 +253,14 @@ dependencies:
253
253
  requirements:
254
254
  - - ">="
255
255
  - !ruby/object:Gem::Version
256
- version: 1.5.0
256
+ version: 1.6.0
257
257
  type: :runtime
258
258
  prerelease: false
259
259
  version_requirements: !ruby/object:Gem::Requirement
260
260
  requirements:
261
261
  - - ">="
262
262
  - !ruby/object:Gem::Version
263
- version: 1.5.0
263
+ version: 1.6.0
264
264
  - !ruby/object:Gem::Dependency
265
265
  name: legion-json
266
266
  requirement: !ruby/object:Gem::Requirement
@@ -317,20 +317,48 @@ dependencies:
317
317
  - - ">="
318
318
  - !ruby/object:Gem::Version
319
319
  version: 1.4.0
320
+ - !ruby/object:Gem::Dependency
321
+ name: legion-gaia
322
+ requirement: !ruby/object:Gem::Requirement
323
+ requirements:
324
+ - - ">="
325
+ - !ruby/object:Gem::Version
326
+ version: 0.9.24
327
+ type: :runtime
328
+ prerelease: false
329
+ version_requirements: !ruby/object:Gem::Requirement
330
+ requirements:
331
+ - - ">="
332
+ - !ruby/object:Gem::Version
333
+ version: 0.9.24
334
+ - !ruby/object:Gem::Dependency
335
+ name: legion-llm
336
+ requirement: !ruby/object:Gem::Requirement
337
+ requirements:
338
+ - - ">="
339
+ - !ruby/object:Gem::Version
340
+ version: 0.5.8
341
+ type: :runtime
342
+ prerelease: false
343
+ version_requirements: !ruby/object:Gem::Requirement
344
+ requirements:
345
+ - - ">="
346
+ - !ruby/object:Gem::Version
347
+ version: 0.5.8
320
348
  - !ruby/object:Gem::Dependency
321
349
  name: legion-tty
322
350
  requirement: !ruby/object:Gem::Requirement
323
351
  requirements:
324
352
  - - ">="
325
353
  - !ruby/object:Gem::Version
326
- version: 0.4.34
354
+ version: 0.4.35
327
355
  type: :runtime
328
356
  prerelease: false
329
357
  version_requirements: !ruby/object:Gem::Requirement
330
358
  requirements:
331
359
  - - ">="
332
360
  - !ruby/object:Gem::Version
333
- version: 0.4.34
361
+ version: 0.4.35
334
362
  - !ruby/object:Gem::Dependency
335
363
  name: lex-node
336
364
  requirement: !ruby/object:Gem::Requirement
@@ -451,6 +479,7 @@ files:
451
479
  - lib/legion/api/relationships.rb
452
480
  - lib/legion/api/schedules.rb
453
481
  - lib/legion/api/settings.rb
482
+ - lib/legion/api/stats.rb
454
483
  - lib/legion/api/tasks.rb
455
484
  - lib/legion/api/tenants.rb
456
485
  - lib/legion/api/token.rb
@@ -555,6 +584,7 @@ files:
555
584
  - lib/legion/cli/dashboard/renderer.rb
556
585
  - lib/legion/cli/dashboard_command.rb
557
586
  - lib/legion/cli/dataset_command.rb
587
+ - lib/legion/cli/debug_command.rb
558
588
  - lib/legion/cli/detect_command.rb
559
589
  - lib/legion/cli/do_command.rb
560
590
  - lib/legion/cli/docs_command.rb