legionio 1.6.10 → 1.6.11

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: 52f5c8d830d3bd42ee91a5058a5fb3a81de3b40dde9de4fc15f0aa5d71cf5f5c
4
- data.tar.gz: 25039551a8e8f44aff39165f3b672abfeb6fb8a6c211ae86b13731c085061322
3
+ metadata.gz: 33862e1884c9ba2e72a1dc6920fb32626bce8a54a3a866065ae8413f3b351639
4
+ data.tar.gz: 7f78f235733a55b9561d28a454f8d3d5287814b5e4ae627ee83de821ef41a4ac
5
5
  SHA512:
6
- metadata.gz: 043ad66b0d3e94a98277cf7f3363fda0749ec743a7a22e11ae0702f3b1591cfd0dbe0291d243eaa57502b14afb3701648114d46887507c2c1b52bc6d01a64f2e
7
- data.tar.gz: e4d85ead5e60258fca76e9a7c42614067af3d09c7ee2677efd47ed2bff1ac0bc146cf30bedc8e11965ee421843984a53549c3392743aface150dc99def1f0c6d
6
+ metadata.gz: 04ae595b050e2790044d84c382b2f42fa2dc34516245695f75728764d0365a203636c1cbd9dffbc7f0c18ec95586f06922310f2933fa6a38e4583a0d2307c5ff
7
+ data.tar.gz: 6ea1841ad50f8fb26c9243ea01194576aee7813721b5a1677c0452e8aa71e99ea5788430d003ac5aa1ea910a25310b565c62b17819c77d1e24cc811260d01eea
data/CHANGELOG.md CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.6.11] - 2026-03-26
6
+
5
7
  ### Added
8
+ - `Legion::Dispatch` module with pluggable strategy interface and `Local` implementation using `Concurrent::FixedThreadPool`
9
+ - Local dispatch wiring in `extensions.rb`: `dispatch_local_actors` registers non-remote extensions in thread pool
10
+ - `Ingress.local_runner?` short-circuit: runners for `remote_invocable? false` extensions skip AMQP round-trip
11
+ - `setup_dispatch` in `Service` boot sequence with graceful shutdown
12
+ - `legion broker stats` and `legion broker cleanup` CLI commands for RabbitMQ management
6
13
  - End-to-end integration test for TBI Phase 5 self-generating functions loop (9 examples)
7
14
  - Test dependencies: lex-codegen, lex-eval added to Gemfile for integration testing
8
15
  - Specs for `legion codegen` CLI subcommand (8 subcommands, 22 examples)
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'erb'
5
+ require 'json'
6
+
7
+ module Legion
8
+ module CLI
9
+ class Broker < Thor
10
+ namespace 'broker'
11
+
12
+ def self.exit_on_failure?
13
+ true
14
+ end
15
+
16
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
17
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
18
+ class_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host'
19
+ class_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port'
20
+ class_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management username'
21
+ class_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password'
22
+ class_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost'
23
+
24
+ desc 'stats', 'Show RabbitMQ broker statistics (queues, exchanges, consumers, DLX)'
25
+ def stats
26
+ out = formatter
27
+ data = fetch_stats
28
+
29
+ if options[:json]
30
+ out.json(data)
31
+ else
32
+ out.header('RabbitMQ Broker Stats')
33
+ out.spacer
34
+ out.detail({
35
+ queues: data[:queues],
36
+ exchanges: data[:exchanges],
37
+ consumers: data[:consumers],
38
+ dlx: data[:dlx]
39
+ })
40
+ end
41
+ rescue Legion::CLI::Error => e
42
+ formatter.error(e.message)
43
+ exit(1)
44
+ end
45
+
46
+ desc 'cleanup', 'Find (and optionally delete) orphaned queues with 0 consumers and 0 messages'
47
+ option :execute, type: :boolean, default: false, desc: 'Actually delete orphaned queues (default: dry-run)'
48
+ def cleanup
49
+ out = formatter
50
+ orphans = find_orphans
51
+
52
+ if orphans.empty?
53
+ out.success('No orphaned queues found')
54
+ return
55
+ end
56
+
57
+ if options[:json]
58
+ out.json({ orphaned_queues: orphans, deleted: options[:execute] })
59
+ delete_orphans(orphans) if options[:execute]
60
+ return
61
+ end
62
+
63
+ out.header("Orphaned Queues (#{orphans.size})")
64
+ orphans.each { |q| out.warn(q) }
65
+ out.spacer
66
+
67
+ if options[:execute]
68
+ delete_orphans(orphans)
69
+ out.success("Deleted #{orphans.size} orphaned queue(s)")
70
+ else
71
+ out.warn('Dry-run mode — pass --execute to delete')
72
+ end
73
+ rescue Legion::CLI::Error => e
74
+ formatter.error(e.message)
75
+ exit(1)
76
+ end
77
+
78
+ no_commands do # rubocop:disable Metrics/BlockLength
79
+ def formatter
80
+ @formatter ||= Output::Formatter.new(
81
+ json: options[:json],
82
+ color: !options[:no_color]
83
+ )
84
+ end
85
+
86
+ private
87
+
88
+ def vhost_encoded
89
+ ERB::Util.url_encode(options[:vhost])
90
+ end
91
+
92
+ def management_api(path)
93
+ uri = URI("http://#{options[:host]}:#{options[:port]}/api#{path}")
94
+ req = Net::HTTP::Get.new(uri)
95
+ req.basic_auth(options[:user], options[:password])
96
+
97
+ response = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http|
98
+ http.request(req)
99
+ end
100
+
101
+ raise Legion::CLI::Error, "Management API error #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
102
+
103
+ ::JSON.parse(response.body, symbolize_names: true)
104
+ rescue Errno::ECONNREFUSED
105
+ raise Legion::CLI::Error, "Cannot connect to RabbitMQ management API at #{options[:host]}:#{options[:port]}"
106
+ rescue Net::OpenTimeout, Net::ReadTimeout
107
+ raise Legion::CLI::Error, "Timed out connecting to RabbitMQ management API at #{options[:host]}:#{options[:port]}"
108
+ end
109
+
110
+ def management_delete(path)
111
+ uri = URI("http://#{options[:host]}:#{options[:port]}/api#{path}")
112
+ req = Net::HTTP::Delete.new(uri)
113
+ req.basic_auth(options[:user], options[:password])
114
+
115
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http|
116
+ http.request(req)
117
+ end
118
+ rescue Errno::ECONNREFUSED
119
+ raise Legion::CLI::Error, "Cannot connect to RabbitMQ management API at #{options[:host]}:#{options[:port]}"
120
+ end
121
+
122
+ def fetch_stats
123
+ queues = management_api("/queues/#{vhost_encoded}")
124
+ exchanges = management_api("/exchanges/#{vhost_encoded}")
125
+
126
+ total_consumers = queues.sum { |q| q[:consumers].to_i }
127
+ dlx_count = queues.count { |q| q.dig(:arguments, :'x-dead-letter-exchange') }
128
+
129
+ {
130
+ queues: queues.size,
131
+ exchanges: exchanges.size,
132
+ consumers: total_consumers,
133
+ dlx: dlx_count
134
+ }
135
+ end
136
+
137
+ def find_orphans
138
+ queues = management_api("/queues/#{vhost_encoded}")
139
+ queues
140
+ .select { |q| q[:consumers].to_i.zero? && q[:messages].to_i.zero? }
141
+ .map { |q| q[:name].to_s }
142
+ end
143
+
144
+ def delete_orphans(orphans)
145
+ orphans.each do |name|
146
+ management_delete("/queues/#{vhost_encoded}/#{ERB::Util.url_encode(name)}")
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
data/lib/legion/cli.rb CHANGED
@@ -66,6 +66,7 @@ module Legion
66
66
  autoload :Debug, 'legion/cli/debug_command'
67
67
  autoload :CodegenCommand, 'legion/cli/codegen_command'
68
68
  autoload :Bootstrap, 'legion/cli/bootstrap_command'
69
+ autoload :Broker, 'legion/cli/broker_command'
69
70
 
70
71
  module Groups
71
72
  autoload :Ai, 'legion/cli/groups/ai_group'
@@ -275,6 +276,9 @@ module Legion
275
276
  desc 'dev SUBCOMMAND', 'Generators, docs, marketplace, and shell completion'
276
277
  subcommand 'dev', Legion::CLI::Groups::Dev
277
278
 
279
+ desc 'broker SUBCOMMAND', 'RabbitMQ broker management (stats, cleanup)'
280
+ subcommand 'broker', Legion::CLI::Broker
281
+
278
282
  desc 'tree', 'Print a tree of all available commands'
279
283
  def tree
280
284
  legion_print_command_tree(self.class, ::File.basename($PROGRAM_NAME), '')
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ module Legion
6
+ module Dispatch
7
+ class Local
8
+ def initialize(pool_size: nil)
9
+ max = pool_size || Legion::Settings.dig(:dispatch, :local_pool_size) || 8
10
+ @pool = Concurrent::FixedThreadPool.new(max)
11
+ end
12
+
13
+ def start; end
14
+
15
+ def submit(&block)
16
+ @pool.post do
17
+ block.call
18
+ rescue StandardError => e
19
+ Legion::Logging.error "[Dispatch::Local] #{e.message}" if defined?(Legion::Logging)
20
+ Legion::Logging.debug e.backtrace&.first(5) if defined?(Legion::Logging)
21
+ end
22
+ end
23
+
24
+ def stop
25
+ return unless @pool.running?
26
+
27
+ @pool.shutdown
28
+ @pool.wait_for_termination(15)
29
+ end
30
+
31
+ def capacity
32
+ {
33
+ pool_size: @pool.max_length,
34
+ queue_length: @pool.queue_length,
35
+ running: @pool.running?
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'dispatch/local'
4
+
5
+ module Legion
6
+ module Dispatch
7
+ class << self
8
+ def dispatcher
9
+ @dispatcher ||= Local.new
10
+ end
11
+
12
+ def submit(&)
13
+ dispatcher.submit(&)
14
+ end
15
+
16
+ def shutdown
17
+ @dispatcher&.stop
18
+ end
19
+
20
+ def reset!
21
+ @dispatcher&.stop
22
+ @dispatcher = nil
23
+ end
24
+ end
25
+ end
26
+ end
@@ -89,6 +89,8 @@ module Legion
89
89
 
90
90
  @running_instances&.clear
91
91
 
92
+ Legion::Dispatch.shutdown if defined?(Legion::Dispatch) && Legion::Dispatch.instance_variable_get(:@dispatcher)
93
+
92
94
  @loaded_extensions.each do |name|
93
95
  Catalog.transition(name, :stopped)
94
96
  unregister_capabilities(name)
@@ -283,6 +285,7 @@ module Legion
283
285
  end
284
286
 
285
287
  hook_subscription_actors_pooled(sub_actors) unless sub_actors.empty?
288
+ dispatch_local_actors(@local_tasks) unless @local_tasks.empty?
286
289
 
287
290
  @pending_actors.clear
288
291
  Legion::Logging.info(
@@ -290,7 +293,8 @@ module Legion
290
293
  "every:#{@timer_tasks.count}," \
291
294
  "poll:#{@poll_tasks.count}," \
292
295
  "once:#{@once_tasks.count}," \
293
- "loop:#{@loop_tasks.count}"
296
+ "loop:#{@loop_tasks.count}," \
297
+ "local:#{@local_tasks.count}"
294
298
  )
295
299
  @loaded_extensions&.each { |name| Catalog.transition(name, :running) }
296
300
  end
@@ -466,6 +470,32 @@ module Legion
466
470
  true
467
471
  end
468
472
 
473
+ def dispatch_local_actors(actors)
474
+ require 'legion/dispatch'
475
+
476
+ actors.each do |actor_hash|
477
+ ext_name = actor_hash[:extension_name]
478
+
479
+ runner_mod = actor_hash[:runner_class]
480
+ unless runner_mod
481
+ actor_str = actor_hash[:actor_class].to_s
482
+ runner_str = actor_str.sub('::Actor::', '::Runners::')
483
+ runner_mod = begin
484
+ Kernel.const_get(runner_str)
485
+ rescue NameError
486
+ Legion::Logging.warn "[LocalDispatch] runner not found for #{ext_name}: #{runner_str}" if defined?(Legion::Logging)
487
+ next
488
+ end
489
+ end
490
+
491
+ actor_hash[:runner_module] = runner_mod
492
+ actor_hash[:running_class] = actor_hash[:actor_class]
493
+ @running_instances&.push(actor_hash[:actor_class])
494
+
495
+ Legion::Logging.info "[LocalDispatch] registered: #{ext_name}/#{actor_hash[:actor_name]}" if defined?(Legion::Logging)
496
+ end
497
+ end
498
+
469
499
  public
470
500
 
471
501
  def unregister_capabilities(gem_name)
@@ -39,7 +39,7 @@ module Legion
39
39
 
40
40
  # Normalize and execute via Legion::Runner.run.
41
41
  # Returns the runner result hash.
42
- def run(payload:, runner_class: nil, function: nil, source: 'unknown', principal: nil, **opts) # rubocop:disable Metrics/ParameterLists,Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
42
+ def run(payload:, runner_class: nil, function: nil, source: 'unknown', principal: nil, **opts) # rubocop:disable Metrics/ParameterLists,Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
43
43
  Legion::Logging.info "[Ingress] run: source=#{source} runner_class=#{runner_class} function=#{function}" if defined?(Legion::Logging)
44
44
  check_subtask = opts.fetch(:check_subtask, true)
45
45
  generate_task = opts.fetch(:generate_task, true)
@@ -79,6 +79,12 @@ module Legion
79
79
 
80
80
  Legion::Events.emit('ingress.received', runner_class: rc.to_s, function: fn, source: source)
81
81
 
82
+ if local_runner?(rc)
83
+ Legion::Logging.debug "[Ingress] local short-circuit: #{rc}.#{fn}" if defined?(Legion::Logging)
84
+ klass = rc.is_a?(String) ? Kernel.const_get(rc) : rc
85
+ return klass.send(fn.to_sym, **message)
86
+ end
87
+
82
88
  runner_block = lambda {
83
89
  Legion::Runner.run(
84
90
  runner_class: rc,
@@ -114,6 +120,15 @@ module Legion
114
120
  { success: false, status: 'task.blocked', error: { code: 'insufficient_consent', message: e.message } }
115
121
  end
116
122
 
123
+ def local_runner?(runner_class)
124
+ return false unless defined?(Legion::Extensions) && Legion::Extensions.local_tasks.is_a?(Array)
125
+
126
+ klass = runner_class.is_a?(String) ? Kernel.const_get(runner_class) : runner_class
127
+ Legion::Extensions.local_tasks.any? { |t| t[:runner_module] == klass }
128
+ rescue NameError
129
+ false
130
+ end
131
+
117
132
  private
118
133
 
119
134
  def parse_payload(payload)
@@ -50,6 +50,8 @@ module Legion
50
50
  register_logging_hooks
51
51
  end
52
52
 
53
+ setup_dispatch
54
+
53
55
  if cache
54
56
  begin
55
57
  require 'legion/cache'
@@ -339,6 +341,12 @@ module Legion
339
341
  Legion::Logging.warn "Legion::Apollo failed to load: #{e.message}"
340
342
  end
341
343
 
344
+ def setup_dispatch
345
+ require 'legion/dispatch'
346
+ Legion::Dispatch.dispatcher.start
347
+ Legion::Logging.info "[Service] Dispatch started (strategy: #{Legion::Dispatch.dispatcher.class.name})"
348
+ end
349
+
342
350
  def setup_transport
343
351
  Legion::Logging.info 'Setting up Legion::Transport'
344
352
  require 'legion/transport'
@@ -505,6 +513,8 @@ module Legion
505
513
  @cluster_leader = nil
506
514
  end
507
515
 
516
+ shutdown_component('Dispatch') { Legion::Dispatch.shutdown } if defined?(Legion::Dispatch)
517
+
508
518
  ext_timeout = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15
509
519
  shutdown_component('Extensions', timeout: ext_timeout) { Legion::Extensions.shutdown }
510
520
  Legion::Readiness.mark_not_ready(:extensions)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.10'
4
+ VERSION = '1.6.11'
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.6.10
4
+ version: 1.6.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -514,6 +514,7 @@ files:
514
514
  - lib/legion/cli/audit_command.rb
515
515
  - lib/legion/cli/auth_command.rb
516
516
  - lib/legion/cli/bootstrap_command.rb
517
+ - lib/legion/cli/broker_command.rb
517
518
  - lib/legion/cli/chain.rb
518
519
  - lib/legion/cli/chain_command.rb
519
520
  - lib/legion/cli/chat/agent_delegator.rb
@@ -734,6 +735,8 @@ files:
734
735
  - lib/legion/digital_worker/registry.rb
735
736
  - lib/legion/digital_worker/risk_tier.rb
736
737
  - lib/legion/digital_worker/value_metrics.rb
738
+ - lib/legion/dispatch.rb
739
+ - lib/legion/dispatch/local.rb
737
740
  - lib/legion/docs/site_generator.rb
738
741
  - lib/legion/events.rb
739
742
  - lib/legion/extensions.rb