legionio 1.6.21 → 1.6.24

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: 4d50e1efc9398c0e2d380c7a921e2ccc8b6e6db3a142d4f4a700ff536555a2e4
4
- data.tar.gz: e44d47e0c4d04362d9a35c6ad6953d0b4a42a5f0f2225dd0420e7f2d1c544ad4
3
+ metadata.gz: 02c65a88cb7cdf1a90b60cc3fe150983d9f3aa7b465344ab63a2e9128327b943
4
+ data.tar.gz: ff0aa83e8b72dc3452b0d11f08006ddd3cee4d831f99213da2bb65f153f17963
5
5
  SHA512:
6
- metadata.gz: f4cc63f869d21abc423f7836a25b91f62a815d01d1972004b85728be0b969e332a5f95c9172ab6177f3759e837b6e40bbb30afba1ecd3a817cd5088fff4c675b
7
- data.tar.gz: 719f9a8e3d316937be26ed610b7ba86c653317b476b271d821591fecf5ff8157c02788b03df1468c7fe3af19d76ec1a55cf48312d911a502e30873c51d378bb0
6
+ metadata.gz: 7b6b41d1f383380ef4c7d6a01b6ca4bc6eb595afc7a3674544d25afb6d88a6fae37f1fefd94542a1bc801e40d18c966b3de8832fea5b6c08c410125a7e08f45f
7
+ data.tar.gz: 6dc2552ec0839432ab21c3664973c78b9ae9c392749758fb8422a35637c5cf9c7c3bf3219594d7b5c82e474e0e6cbe2e286f059de4429aabee660bbe94d058bb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.6.24] - 2026-03-28
6
+
7
+ ### Added
8
+ - `Legion::API.register_library_routes(gem_name, routes_module)` class method: library gems self-register their Sinatra route modules at boot via `router.register_library` + Sinatra `register`. Implemented in `lib/legion/api/library_routes.rb`.
9
+ - `Legion::API::SyncDispatch.dispatch(exchange_name, routing_key, payload, envelope, timeout:)`: synchronous AMQP dispatch using a temporary exclusive reply_to queue with configurable timeout (default 30s). Implemented in `lib/legion/api/sync_dispatch.rb`.
10
+ - Remote dispatch in `LexDispatch`: when a registered extension route's runner class is not loaded in the current process, the request is forwarded via AMQP — async (202) by default or sync (blocks on reply queue) when `X-Legion-Sync: true` header is present. Returns 403 when `definition[:remote_invocable] == false`.
11
+ - `Routes::Llm` and `Routes::Apollo` registration now guarded: skipped in `api.rb` when `Legion::LLM::Routes` / `Legion::Apollo::Routes` are already defined (i.e. self-registered by the library gem).
12
+
13
+ ### Changed
14
+ - `api.rb`: requires `api/library_routes` and `api/sync_dispatch`; LLM and Apollo route registration conditional on gem self-registration not already having run.
15
+
16
+ ## [1.6.23] - 2026-03-28
17
+
18
+ ### Added
19
+ - `Legion::Extensions::Definitions` mixin: class-level `definition` DSL for method contracts (`desc`, `inputs`, `outputs`, `remote_invocable`, `mcp_exposed`, `idempotent`, `risk_tier`, `tags`, `requires`). Auto-extended onto every runner module at boot by the builder.
20
+ - `Legion::Extensions::Actors::Dsl` mixin: `define_dsl_accessor` generates class-level getter/setter DSL with inheritance and instance delegation. Wired into all actor base classes (`Every`, `Poll`, `Subscription`, `Once`, `Base`).
21
+ - `Absorbers::Base#absorb`: canonical entry point replacing `handle`. `alias handle absorb` preserves backward compatibility.
22
+
23
+ ### Changed
24
+ - `Builders::Runners#build_runner_list`: auto-extends `Legion::Extensions::Definitions` onto every discovered runner module unless it already responds to `:definition`.
25
+ - `Hooks::Base`: extended with `Definitions` mixin; `mount` DSL removed (paths fully derived from naming).
26
+ - `Absorbers::Base`: extended with `Definitions` mixin.
27
+ - `AbsorberDispatch`: calls `absorber.absorb` instead of `absorber.handle`.
28
+ - `Helpers::Lex`: all `function_*` helpers and `expose_as_mcp_tool`/`mcp_tool_prefix` marked `@deprecated` — use `definition` DSL instead.
29
+
30
+ ## [1.6.22] - 2026-03-27
31
+
32
+ ### Added
33
+ - `POST /api/llm/inference` daemon endpoint: accepts a full messages array plus optional tool schemas, runs a single LLM completion pass, and returns `{ content, tool_calls, stop_reason, model, input_tokens, output_tokens }` — the client owns the tool execution loop
34
+ - `Legion::CLI::Chat::DaemonChat` adapter: drop-in replacement for the `RubyLLM::Chat` object that routes all inference through the daemon, executes tool calls locally, and loops until the LLM produces a final text response
35
+ - `spec/legion/api/llm_inference_spec.rb`: 12 examples covering the new `/api/llm/inference` endpoint
36
+ - `spec/legion/cli/chat/daemon_chat_spec.rb`: 25 examples covering `DaemonChat` initialization, tool registration, tool execution loop, streaming, and error handling
37
+
38
+ ### Changed
39
+ - `legion chat setup_connection`: replaced `Connection.ensure_llm` (local LLM boot) with a daemon availability check via `Legion::LLM::DaemonClient.available?` — **hard fails with a descriptive error if the daemon is not running**
40
+ - `legion chat create_chat`: now returns a `DaemonChat` instance instead of a direct `RubyLLM::Chat` object; all LLM calls route through the daemon
41
+
5
42
  ## [1.6.21] - 2026-03-27
6
43
 
7
44
  ### Added
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module Routes
8
+ module LexDispatch
9
+ def self.registered(app)
10
+ register_discovery(app)
11
+ register_dispatch(app)
12
+ end
13
+
14
+ # Discovery endpoints (GET)
15
+ def self.register_discovery(app)
16
+ # GET /api/extensions/index — list all extensions
17
+ app.get '/api/extensions/index' do
18
+ content_type :json
19
+ names = Legion::API.router.extension_names
20
+ Legion::JSON.dump({ extensions: names })
21
+ end
22
+
23
+ # GET /api/extensions/:lex_name/:component_type/:component_name/:method_name — full contract
24
+ app.get '/api/extensions/:lex_name/:component_type/:component_name/:method_name' do
25
+ content_type :json
26
+ entry = Legion::API.router.find_extension_route(
27
+ params[:lex_name], params[:component_type],
28
+ params[:component_name], params[:method_name]
29
+ )
30
+ unless entry
31
+ halt 404, Legion::JSON.dump({
32
+ task_id: nil,
33
+ conversation_id: nil,
34
+ status: 'failed',
35
+ error: { code: 404, message: 'route not found' }
36
+ })
37
+ end
38
+
39
+ amqp_pfx = entry[:amqp_prefix].to_s.then { |p| p.empty? ? "lex.#{params[:lex_name]}" : p }
40
+ response = {
41
+ extension: params[:lex_name],
42
+ component_type: params[:component_type],
43
+ component: params[:component_name],
44
+ method: params[:method_name],
45
+ definition: entry[:definition],
46
+ amqp: {
47
+ exchange: amqp_pfx,
48
+ routing_key: "#{amqp_pfx}.#{params[:component_type]}.#{params[:component_name]}.#{params[:method_name]}"
49
+ }
50
+ }
51
+ if params[:component_type] == 'hooks'
52
+ response[:hook_endpoint] =
53
+ "/api/extensions/#{params[:lex_name]}/hooks/#{params[:component_name]}/#{params[:method_name]}"
54
+ end
55
+ Legion::JSON.dump(response)
56
+ end
57
+ end
58
+
59
+ # Dispatch endpoint (POST)
60
+ def self.register_dispatch(app)
61
+ dispatcher = method(:dispatch_request)
62
+ app.post '/api/extensions/:lex_name/:component_type/:component_name/:method_name' do
63
+ dispatcher.call(self, request, params)
64
+ end
65
+ end
66
+
67
+ def self.dispatch_request(context, request, params) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
68
+ content_type = 'application/json'
69
+ context.content_type content_type
70
+
71
+ entry = Legion::API.router.find_extension_route(
72
+ params[:lex_name], params[:component_type],
73
+ params[:component_name], params[:method_name]
74
+ )
75
+
76
+ unless entry
77
+ route_key = "#{params[:lex_name]}/#{params[:component_type]}/#{params[:component_name]}/#{params[:method_name]}"
78
+ context.halt 404, Legion::JSON.dump({
79
+ task_id: nil,
80
+ conversation_id: nil,
81
+ status: 'failed',
82
+ error: { code: 404, message: "no route registered for '#{route_key}'" }
83
+ })
84
+ end
85
+
86
+ envelope = build_envelope(request)
87
+
88
+ payload = begin
89
+ body = request.body.read
90
+ body.nil? || body.empty? ? {} : Legion::JSON.load(body)
91
+ rescue StandardError => e
92
+ Legion::Logging.warn "[LexDispatch] invalid JSON body: #{e.message}" if defined?(Legion::Logging)
93
+ context.halt 400, Legion::JSON.dump({
94
+ task_id: nil,
95
+ conversation_id: nil,
96
+ status: 'failed',
97
+ error: { code: 400, message: 'request body is not valid JSON' }
98
+ })
99
+ end
100
+
101
+ # Remote dispatch: when the runner class is not loaded locally, forward via AMQP
102
+ unless extension_loaded_locally?(entry)
103
+ if definition_blocks_remote?(entry)
104
+ context.halt 403, Legion::JSON.dump({
105
+ task_id: nil,
106
+ conversation_id: nil,
107
+ status: 'failed',
108
+ error: { code: 403, message: 'Method not remotely invocable' }
109
+ })
110
+ end
111
+
112
+ exchange_name = entry[:amqp_prefix].to_s.then { |p| p.empty? ? "lex.#{entry[:lex_name]}" : p }
113
+ routing_key = "#{exchange_name}.#{entry[:component_type]}.#{entry[:component_name]}.#{entry[:method_name]}"
114
+
115
+ if request.env['HTTP_X_LEGION_SYNC'] == 'true'
116
+ result = Legion::API::SyncDispatch.dispatch(exchange_name, routing_key, payload, envelope)
117
+ return Legion::JSON.dump(result)
118
+ else
119
+ unless defined?(Legion::Transport) &&
120
+ Legion::Transport.respond_to?(:connected?) &&
121
+ Legion::Transport.connected?
122
+ context.halt 503, Legion::JSON.dump({
123
+ task_id: nil,
124
+ conversation_id: nil,
125
+ status: 'failed',
126
+ error: { code: 503, message: 'Transport not available' }
127
+ })
128
+ end
129
+
130
+ dispatch_async_amqp(exchange_name, routing_key, payload, envelope)
131
+ context.status 202
132
+ return Legion::JSON.dump(envelope.merge(status: 'queued'))
133
+ end
134
+ end
135
+
136
+ result = Legion::Ingress.run(
137
+ payload: payload.merge(envelope.slice(:task_id, :conversation_id, :parent_id, :master_id, :chain_id)),
138
+ runner_class: entry[:runner_class],
139
+ function: entry[:method_name].to_sym,
140
+ source: 'lex_dispatch',
141
+ generate_task: true
142
+ )
143
+
144
+ response_body = envelope.merge(
145
+ status: result[:status],
146
+ result: result[:result]
147
+ ).compact
148
+
149
+ Legion::JSON.dump(response_body)
150
+ rescue StandardError => e
151
+ route_key = "#{params[:lex_name]}/#{params[:component_type]}/#{params[:component_name]}/#{params[:method_name]}"
152
+ Legion::Logging.log_exception(e, payload_summary: "LexDispatch POST #{route_key}", component_type: :api)
153
+ context.status 500
154
+ Legion::JSON.dump({
155
+ task_id: nil,
156
+ conversation_id: nil,
157
+ status: 'failed',
158
+ error: { code: 500, message: e.message }
159
+ })
160
+ end
161
+
162
+ def self.parse_header_integer(value)
163
+ return nil if value.nil?
164
+
165
+ Integer(value)
166
+ rescue ArgumentError, TypeError
167
+ nil
168
+ end
169
+
170
+ def self.build_envelope(request)
171
+ task_id = parse_header_integer(request.env['HTTP_X_LEGION_TASK_ID'])
172
+ conversation_id = request.env['HTTP_X_LEGION_CONVERSATION_ID'] || ::SecureRandom.uuid
173
+ parent_id = parse_header_integer(request.env['HTTP_X_LEGION_PARENT_ID'])
174
+ master_id = parse_header_integer(request.env['HTTP_X_LEGION_MASTER_ID'])
175
+ chain_id = parse_header_integer(request.env['HTTP_X_LEGION_CHAIN_ID'])
176
+ debug = request.env['HTTP_X_LEGION_DEBUG'] == 'true'
177
+
178
+ {
179
+ task_id: task_id,
180
+ conversation_id: conversation_id,
181
+ parent_id: parent_id,
182
+ master_id: master_id || task_id,
183
+ chain_id: chain_id,
184
+ debug: debug
185
+ }.compact
186
+ end
187
+
188
+ # Returns true when the runner class referenced by the route entry is
189
+ # available in the current process (i.e. the extension is loaded locally).
190
+ def self.extension_loaded_locally?(entry)
191
+ runner_class = entry[:runner_class]
192
+ return false if runner_class.nil? || runner_class.to_s.empty?
193
+
194
+ # Try constant lookup — safe because runner_class is from the route registry,
195
+ # not from user input.
196
+ parts = runner_class.to_s.split('::').reject(&:empty?)
197
+ parts.reduce(Object) { |mod, name| mod.const_get(name, false) }
198
+ true
199
+ rescue NameError, TypeError
200
+ false
201
+ end
202
+
203
+ # Returns true when the definition-level flag explicitly disables remote dispatch.
204
+ # Extension-level gate (entry[:lex_name] module) takes precedence over definition flag.
205
+ def self.definition_blocks_remote?(entry)
206
+ defn = entry[:definition]
207
+ return false if defn.nil?
208
+
209
+ defn[:remote_invocable] == false
210
+ end
211
+
212
+ # Publish an async AMQP message for remote dispatch (fire-and-forget).
213
+ def self.dispatch_async_amqp(exchange_name, routing_key, payload, envelope)
214
+ return unless defined?(Legion::Transport) &&
215
+ Legion::Transport.respond_to?(:connected?) &&
216
+ Legion::Transport.connected?
217
+
218
+ channel = Legion::Transport.channel
219
+ exchange = channel.exchange(exchange_name, type: :topic, durable: true, passive: true)
220
+ message = Legion::JSON.dump(payload.merge(envelope))
221
+ exchange.publish(message, routing_key: routing_key, content_type: 'application/json', persistent: true)
222
+ rescue StandardError => e
223
+ Legion::Logging.warn "[LexDispatch] async AMQP publish failed: #{e.message}" if defined?(Legion::Logging)
224
+ raise
225
+ end
226
+
227
+ class << self
228
+ private :register_discovery, :register_dispatch, :dispatch_request, :parse_header_integer,
229
+ :build_envelope, :extension_loaded_locally?, :definition_blocks_remote?, :dispatch_async_amqp
230
+ end
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ # Register a library gem's route module with the tier-aware router and mount it
6
+ # on this Sinatra app.
7
+ #
8
+ # Call from the library gem's boot/start method:
9
+ # Legion::API.register_library_routes('llm', Legion::LLM::Routes) if defined?(Legion::API)
10
+ #
11
+ # @param gem_name [String] short name for the library (e.g. 'llm', 'apollo')
12
+ # @param routes_module [Module] a Sinatra::Extension module to register
13
+ def self.register_library_routes(gem_name, routes_module)
14
+ router.register_library(gem_name, routes_module)
15
+ register routes_module
16
+ end
17
+ end
18
+ end
@@ -43,6 +43,8 @@ module Legion
43
43
  end
44
44
 
45
45
  def self.register_chat(app) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
46
+ register_inference(app)
47
+
46
48
  app.post '/api/llm/chat' do # rubocop:disable Metrics/BlockLength
47
49
  Legion::Logging.debug "API: POST /api/llm/chat params=#{params.keys}"
48
50
  require_llm!
@@ -163,6 +165,75 @@ module Legion
163
165
  end
164
166
  end
165
167
 
168
+ def self.register_inference(app) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
169
+ app.post '/api/llm/inference' do # rubocop:disable Metrics/BlockLength
170
+ require_llm!
171
+ body = parse_request_body
172
+ validate_required!(body, :messages)
173
+
174
+ messages = body[:messages]
175
+ tools = body[:tools] || []
176
+ model = body[:model]
177
+ provider = body[:provider]
178
+
179
+ unless messages.is_a?(Array)
180
+ halt 400, { 'Content-Type' => 'application/json' },
181
+ Legion::JSON.dump({ error: { code: 'invalid_messages', message: 'messages must be an array' } })
182
+ end
183
+
184
+ session = Legion::LLM.chat(
185
+ model: model,
186
+ provider: provider,
187
+ caller: { source: 'api', path: request.path }
188
+ )
189
+
190
+ unless tools.empty?
191
+ tool_declarations = tools.map do |t|
192
+ ts = t.respond_to?(:transform_keys) ? t.transform_keys(&:to_sym) : t
193
+ tname = ts[:name].to_s
194
+ tdesc = ts[:description].to_s
195
+ tparams = ts[:parameters] || {}
196
+ Class.new do
197
+ define_singleton_method(:tool_name) { tname }
198
+ define_singleton_method(:description) { tdesc }
199
+ define_singleton_method(:parameters) { tparams }
200
+ define_method(:call) { |**_| raise NotImplementedError, "#{tname} executes client-side only" }
201
+ end
202
+ end
203
+ session.with_tools(*tool_declarations)
204
+ end
205
+
206
+ messages.each { |m| session.add_message(m) }
207
+
208
+ last_user = messages.select { |m| (m[:role] || m['role']).to_s == 'user' }.last
209
+ prompt = (last_user || {})[:content] || (last_user || {})['content'] || ''
210
+
211
+ response = session.ask(prompt)
212
+
213
+ tc_list = if response.respond_to?(:tool_calls) && response.tool_calls
214
+ Array(response.tool_calls).map do |tc|
215
+ {
216
+ id: tc.respond_to?(:id) ? tc.id : nil,
217
+ name: tc.respond_to?(:name) ? tc.name : tc.to_s,
218
+ arguments: tc.respond_to?(:arguments) ? tc.arguments : {}
219
+ }
220
+ end
221
+ end
222
+
223
+ json_response({
224
+ content: response.content,
225
+ tool_calls: tc_list,
226
+ stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil,
227
+ model: session.model.to_s,
228
+ input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
229
+ output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil
230
+ }, status_code: 200)
231
+ rescue StandardError => e
232
+ Legion::Logging.error "[api/llm/inference] #{e.class}: #{e.message}" if defined?(Legion::Logging)
233
+ json_response({ error: { code: 'inference_error', message: e.message } }, status_code: 500)
234
+ end
235
+ end
236
+
166
237
  def self.register_providers(app)
167
238
  app.get '/api/llm/providers' do
168
239
  require_llm!
@@ -190,7 +261,7 @@ module Legion
190
261
  end
191
262
 
192
263
  class << self
193
- private :register_chat, :register_providers
264
+ private :register_chat, :register_inference, :register_providers
194
265
  end
195
266
  end
196
267
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ class Router
6
+ def initialize
7
+ @infrastructure_routes = []
8
+ @library_routes = {}
9
+ @extension_routes = {}
10
+ end
11
+
12
+ # --- Infrastructure tier ---
13
+
14
+ def register_infrastructure(path, method: :get, summary: nil)
15
+ @infrastructure_routes << { path: path, method: method, summary: summary }
16
+ end
17
+
18
+ def infrastructure_routes
19
+ @infrastructure_routes.dup
20
+ end
21
+
22
+ # --- Library gem tier ---
23
+
24
+ def register_library(gem_name, routes_module)
25
+ @library_routes[gem_name.to_s] = routes_module
26
+ end
27
+
28
+ def library_routes
29
+ @library_routes.dup
30
+ end
31
+
32
+ def library_names
33
+ @library_routes.keys
34
+ end
35
+
36
+ # --- Extension tier ---
37
+
38
+ def register_extension_route(**opts)
39
+ lex_name = opts[:lex_name]
40
+ component_type = opts[:component_type]
41
+ component_name = opts[:component_name]
42
+ method_name = opts[:method_name]
43
+ key = "#{lex_name}/#{component_type}/#{component_name}/#{method_name}"
44
+ @extension_routes[key] = {
45
+ lex_name: lex_name.to_s,
46
+ amqp_prefix: opts[:amqp_prefix].to_s,
47
+ component_type: component_type.to_s,
48
+ component_name: component_name.to_s,
49
+ method_name: method_name.to_s,
50
+ runner_class: opts[:runner_class],
51
+ definition: opts[:definition]
52
+ }
53
+ end
54
+
55
+ def find_extension_route(lex_name, component_type, component_name, method_name)
56
+ key = "#{lex_name}/#{component_type}/#{component_name}/#{method_name}"
57
+ @extension_routes[key]
58
+ end
59
+
60
+ def extension_routes
61
+ @extension_routes.dup
62
+ end
63
+
64
+ def extension_names
65
+ @extension_routes.values.map { |r| r[:lex_name] }.uniq
66
+ end
67
+
68
+ def components_for(lex_name)
69
+ @extension_routes.values
70
+ .select { |r| r[:lex_name] == lex_name.to_s }
71
+ .group_by { |r| r[:component_type] }
72
+ end
73
+
74
+ def methods_for(lex_name, component_type, component_name)
75
+ @extension_routes.values.select do |r|
76
+ r[:lex_name] == lex_name.to_s &&
77
+ r[:component_type] == component_type.to_s &&
78
+ r[:component_name] == component_name.to_s
79
+ end
80
+ end
81
+
82
+ def discovery_extension(lex_name)
83
+ comps = components_for(lex_name)
84
+ return nil if comps.empty?
85
+
86
+ comps.transform_values do |routes|
87
+ routes.map { |r| { name: r[:component_name], method: r[:method_name], definition: r[:definition] } }
88
+ end
89
+ end
90
+
91
+ def clear!
92
+ @infrastructure_routes.clear
93
+ @library_routes.clear
94
+ @extension_routes.clear
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module SyncDispatch
8
+ # Dispatch a message synchronously via AMQP using a temporary reply_to queue.
9
+ # Blocks until a response arrives or the timeout expires.
10
+ #
11
+ # @param exchange_name [String] target exchange (e.g. "lex.github")
12
+ # @param routing_key [String] routing key (e.g. "lex.github.runners.pull_request.create")
13
+ # @param payload [Hash] message payload
14
+ # @param envelope [Hash] task envelope (task_id, conversation_id, etc.)
15
+ # @param timeout [Integer] seconds to wait (default 30)
16
+ # @return [Hash]
17
+ def self.dispatch(exchange_name, routing_key, payload, envelope, timeout: 30)
18
+ unless defined?(Legion::Transport) &&
19
+ Legion::Transport.respond_to?(:connected?) &&
20
+ Legion::Transport.connected?
21
+ return envelope.merge(
22
+ status: 'failed',
23
+ error: { code: 503, message: 'Transport not available for sync dispatch' }
24
+ )
25
+ end
26
+
27
+ perform_dispatch(exchange_name, routing_key, payload, envelope, timeout)
28
+ rescue StandardError => e
29
+ Legion::Logging.error "[SyncDispatch] #{e.class}: #{e.message}" if defined?(Legion::Logging)
30
+ envelope.merge(
31
+ status: 'failed',
32
+ error: { code: 500, message: e.message }
33
+ )
34
+ end
35
+
36
+ # @api private
37
+ def self.perform_dispatch(exchange_name, routing_key, payload, envelope, timeout)
38
+ response = nil
39
+ mutex = Mutex.new
40
+ condition = ConditionVariable.new
41
+ reply_queue_name = "sync.reply.#{::SecureRandom.uuid}"
42
+
43
+ begin
44
+ channel = Legion::Transport.channel
45
+ reply_queue = channel.queue(reply_queue_name, exclusive: true, auto_delete: true)
46
+ subscribe_reply(reply_queue, mutex, condition) { |r| response = r }
47
+ publish_sync(channel, exchange_name, routing_key, payload, envelope, reply_queue_name)
48
+ wait_for_response(mutex, condition, timeout) { response }
49
+ response || envelope.merge(
50
+ status: 'timeout',
51
+ error: { code: 504, message: "Sync dispatch timed out after #{timeout}s" }
52
+ )
53
+ ensure
54
+ reply_queue&.delete rescue nil # rubocop:disable Style/RescueModifier
55
+ end
56
+ end
57
+
58
+ # @api private
59
+ def self.subscribe_reply(reply_queue, mutex, condition)
60
+ reply_queue.subscribe(block: false) do |_delivery_info, _metadata, body|
61
+ parsed = begin
62
+ Legion::JSON.load(body)
63
+ rescue StandardError
64
+ { raw: body }
65
+ end
66
+ mutex.synchronize do
67
+ yield parsed
68
+ condition.signal
69
+ end
70
+ end
71
+ end
72
+
73
+ # @api private
74
+ def self.wait_for_response(mutex, condition, timeout)
75
+ mutex.synchronize do
76
+ deadline = Time.now + timeout
77
+ loop do
78
+ remaining = deadline - Time.now
79
+ break if yield || remaining <= 0
80
+
81
+ condition.wait(mutex, remaining)
82
+ end
83
+ end
84
+ end
85
+
86
+ # @api private
87
+ def self.publish_sync(channel, exchange_name, routing_key, payload, envelope, reply_queue_name) # rubocop:disable Metrics/ParameterLists
88
+ exchange = channel.exchange(exchange_name, type: :topic, durable: true, passive: true)
89
+ message = Legion::JSON.dump(payload.merge(envelope))
90
+ exchange.publish(
91
+ message,
92
+ routing_key: routing_key,
93
+ reply_to: reply_queue_name,
94
+ content_type: 'application/json',
95
+ persistent: false
96
+ )
97
+ end
98
+
99
+ private_class_method :perform_dispatch, :subscribe_reply, :wait_for_response, :publish_sync
100
+ end
101
+ end
102
+ end