legionio 1.6.22 → 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: 68888d98222b99e6e88670e8df81245d312dbed7c5f04d59a0cb79e592d65e47
4
- data.tar.gz: be305f5229f73ad9c28e76a87c5353afac7edfbe8764284db03e34d727af1ef8
3
+ metadata.gz: 02c65a88cb7cdf1a90b60cc3fe150983d9f3aa7b465344ab63a2e9128327b943
4
+ data.tar.gz: ff0aa83e8b72dc3452b0d11f08006ddd3cee4d831f99213da2bb65f153f17963
5
5
  SHA512:
6
- metadata.gz: 180fc2adda6a37c44accfddd5c25099bab8568462ec58c99f51ccf58203d62d65813831ff569cfcd729524566be12b2c037c6df184efcf1514d9a9bd75964698
7
- data.tar.gz: d5f1b84b9c562df8766b20923ebec48215b78b28e600744e9b210578a311138df4ffef221f8bb42520272fc71bff8b3041420765d7d876c8a634146927e55b15
6
+ metadata.gz: 7b6b41d1f383380ef4c7d6a01b6ca4bc6eb595afc7a3674544d25afb6d88a6fae37f1fefd94542a1bc801e40d18c966b3de8832fea5b6c08c410125a7e08f45f
7
+ data.tar.gz: 6dc2552ec0839432ab21c3664973c78b9ae9c392749758fb8422a35637c5cf9c7c3bf3219594d7b5c82e474e0e6cbe2e286f059de4429aabee660bbe94d058bb
data/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
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
+
5
30
  ## [1.6.22] - 2026-03-27
6
31
 
7
32
  ### 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
@@ -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
data/lib/legion/api.rb CHANGED
@@ -47,6 +47,10 @@ require_relative 'api/costs'
47
47
  require_relative 'api/traces'
48
48
  require_relative 'api/stats'
49
49
  require_relative 'api/codegen'
50
+ require_relative 'api/router'
51
+ require_relative 'api/library_routes'
52
+ require_relative 'api/sync_dispatch'
53
+ require_relative 'api/lex_dispatch'
50
54
  require_relative 'api/graphql' if defined?(GraphQL)
51
55
 
52
56
  module Legion
@@ -72,6 +76,21 @@ module Legion
72
76
  Legion::API::OpenAPI.to_json
73
77
  end
74
78
 
79
+ # Root discovery — lists all tiers
80
+ get '/api/discovery' do
81
+ content_type :json
82
+ Legion::JSON.dump({
83
+ infrastructure: [
84
+ { path: '/api/health', method: 'GET' },
85
+ { path: '/api/ready', method: 'GET' },
86
+ { path: '/api/openapi.json', method: 'GET' },
87
+ { path: '/api/discovery', method: 'GET' }
88
+ ],
89
+ libraries: Legion::API.router.library_names,
90
+ extensions: Legion::API.router.extension_names
91
+ })
92
+ end
93
+
75
94
  # Health and readiness
76
95
  get '/api/health' do
77
96
  json_response({ status: 'ok', version: Legion::VERSION })
@@ -87,8 +106,14 @@ module Legion
87
106
  content_type :json
88
107
  Legion::Logging.warn "API #{request.request_method} #{request.path_info} returned 404: no route matches"
89
108
  Legion::JSON.dump({
90
- error: { code: 'not_found', message: "no route matches #{request.request_method} #{request.path_info}" },
91
- meta: { timestamp: Time.now.utc.iso8601, node: Legion::Settings[:client][:name] }
109
+ task_id: nil,
110
+ conversation_id: nil,
111
+ status: 'failed',
112
+ error: {
113
+ code: 404,
114
+ message: "no route matches #{request.request_method} #{request.path_info}"
115
+ },
116
+ meta: { timestamp: Time.now.utc.iso8601, node: Legion::Settings[:client][:name] }
92
117
  })
93
118
  end
94
119
 
@@ -97,12 +122,16 @@ module Legion
97
122
  err = env['sinatra.error']
98
123
  Legion::Logging.log_exception(err, payload_summary: "API #{request.request_method} #{request.path_info} returned 500", component_type: :api)
99
124
  Legion::JSON.dump({
100
- error: { code: 'internal_error', message: err.message },
101
- meta: { timestamp: Time.now.utc.iso8601, node: Legion::Settings[:client][:name] }
125
+ task_id: nil,
126
+ conversation_id: nil,
127
+ status: 'failed',
128
+ error: { code: 500, message: err.message },
129
+ meta: { timestamp: Time.now.utc.iso8601, node: Legion::Settings[:client][:name] }
102
130
  })
103
131
  end
104
132
 
105
133
  # Mount route modules
134
+ register Routes::LexDispatch
106
135
  register Routes::Tasks
107
136
  register Routes::Extensions
108
137
  register Routes::Nodes
@@ -126,14 +155,14 @@ module Legion
126
155
  register Routes::Capacity
127
156
  register Routes::Audit
128
157
  register Routes::Metrics
129
- register Routes::Llm
158
+ register Routes::Llm unless defined?(Legion::LLM::Routes)
130
159
  register Routes::ExtensionCatalog
131
160
  register Routes::OrgChart
132
161
  register Routes::Governance
133
162
  register Routes::Acp
134
163
  register Routes::Prompts
135
164
  register Routes::Marketplace
136
- register Routes::Apollo
165
+ register Routes::Apollo unless defined?(Legion::Apollo::Routes)
137
166
  register Routes::Costs
138
167
  register Routes::Traces
139
168
  register Routes::Stats
@@ -143,6 +172,13 @@ module Legion
143
172
  use Legion::API::Middleware::RequestLogger
144
173
  use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
145
174
 
175
+ # Tier-aware router (three-tier namespace)
176
+ class << self
177
+ def router
178
+ @router ||= Legion::API::Router.new
179
+ end
180
+ end
181
+
146
182
  # Hook registry (preserved from original implementation)
147
183
  class << self
148
184
  def hook_registry