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 +4 -4
- data/CHANGELOG.md +25 -0
- data/lib/legion/api/lex_dispatch.rb +234 -0
- data/lib/legion/api/library_routes.rb +18 -0
- data/lib/legion/api/router.rb +98 -0
- data/lib/legion/api/sync_dispatch.rb +102 -0
- data/lib/legion/api.rb +42 -6
- data/lib/legion/cli/admin/purge_topology.rb +149 -0
- data/lib/legion/cli/generate_command.rb +2 -2
- data/lib/legion/extensions/absorbers/base.rb +9 -2
- data/lib/legion/extensions/actors/absorber_dispatch.rb +1 -1
- data/lib/legion/extensions/actors/base.rb +23 -5
- data/lib/legion/extensions/actors/dsl.rb +29 -0
- data/lib/legion/extensions/actors/every.rb +7 -9
- data/lib/legion/extensions/actors/once.rb +4 -0
- data/lib/legion/extensions/actors/poll.rb +8 -13
- data/lib/legion/extensions/actors/subscription.rb +9 -18
- data/lib/legion/extensions/builders/hooks.rb +19 -2
- data/lib/legion/extensions/builders/routes.rb +20 -5
- data/lib/legion/extensions/builders/runners.rb +2 -0
- data/lib/legion/extensions/core.rb +50 -0
- data/lib/legion/extensions/definitions.rb +48 -0
- data/lib/legion/extensions/helpers/lex.rb +12 -0
- data/lib/legion/extensions/helpers/segments.rb +1 -1
- data/lib/legion/extensions/hooks/base.rb +5 -6
- data/lib/legion/extensions/transport.rb +1 -1
- data/lib/legion/version.rb +1 -1
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02c65a88cb7cdf1a90b60cc3fe150983d9f3aa7b465344ab63a2e9128327b943
|
|
4
|
+
data.tar.gz: ff0aa83e8b72dc3452b0d11f08006ddd3cee4d831f99213da2bb65f153f17963
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|