legionio 1.9.0 → 1.9.2
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 +17 -0
- data/lib/legion/api/extensions.rb +49 -15
- data/lib/legion/api/knowledge.rb +9 -1
- data/lib/legion/api/stats.rb +6 -4
- data/lib/legion/extensions/actors/subscription.rb +11 -0
- data/lib/legion/extensions/handle.rb +106 -0
- data/lib/legion/extensions/handle_registry.rb +71 -0
- data/lib/legion/extensions.rb +158 -16
- data/lib/legion/ingress.rb +18 -4
- data/lib/legion/tools/discovery.rb +12 -1
- data/lib/legion/tools/registry.rb +18 -1
- data/lib/legion/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1dee00872a482d84ca7cffed624add4a568595383b31d93ffe1983d5bfb89d91
|
|
4
|
+
data.tar.gz: 0e8eaba327808c586a3cdf6d9e8da177831ac0ce9f5fb1919de115f48b63346f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b7124a1ed4fa62aaa5da4e55391961973435b324cc17142245c52af7b32d43d6f7c161341f12d27aca47310dc9e6289a3d1a4d781561b001baa724c03ddc8ba
|
|
7
|
+
data.tar.gz: 952d0045d985d1f14ac76e15e015fa8bd279ed92ae123cd43e7f5139ed7efbee9a3cba93856ce2f6c8603e74f9549495e415fcd8459a27d3b35cb169eebec617
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.9.2] - 2026-04-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- `POST /api/knowledge/status` no longer silently defaults to the daemon's cwd. Uses `knowledge.default_corpus_path` setting or `LEGION_CORPUS_PATH` env var; returns 400 when unresolvable. Prevents `Errno::EPERM` crashes on macOS when the daemon is launched from `~` and `Find.find` walks into TCC-protected subdirs like `~/Library/Accounts`.
|
|
9
|
+
|
|
10
|
+
## [1.9.1] - 2026-04-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Extension runtime handles now expose authoritative active/latest versions, reload state, pending-reload status, hot-reload eligibility, and owned runtime resources through `/api/extension_catalog`.
|
|
14
|
+
- Extension dispatch quiescing now blocks API, ingress, and subscription runner dispatch while an extension is stopping or actively reloading.
|
|
15
|
+
- `Legion::Tools::Registry.unregister_extension` removes callable tools owned by an extension during unload/reload cleanup.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- Runtime handle `loaded?` no longer reports `stopped` or `failed` extensions as loaded.
|
|
19
|
+
- Extension registration publication now happens after extension autobuild and runtime side effects complete, avoiding durable registration of failed loads.
|
|
20
|
+
- Extension runtime handles now transition to loaded only after `require` and extension side effects succeed, and multi-segment extension modules keep their hyphenated lex identity.
|
|
21
|
+
|
|
5
22
|
## [1.9.0] - 2026-04-24
|
|
6
23
|
|
|
7
24
|
### Added
|
|
@@ -22,33 +22,21 @@ module Legion
|
|
|
22
22
|
|
|
23
23
|
def self.register_extension_routes(app)
|
|
24
24
|
app.get '/api/extension_catalog' do
|
|
25
|
-
entries =
|
|
26
|
-
{ name: name, state: entry[:state].to_s,
|
|
27
|
-
registered_at: entry[:registered_at]&.iso8601,
|
|
28
|
-
started_at: entry[:started_at]&.iso8601 }
|
|
29
|
-
end
|
|
25
|
+
entries = Routes::Extensions.extension_entries
|
|
30
26
|
entries = entries.select { |e| e[:state] == params[:state] } if params[:state]
|
|
31
27
|
json_response(entries)
|
|
32
28
|
end
|
|
33
29
|
|
|
34
30
|
app.get '/api/extension_catalog/:name' do
|
|
35
31
|
name = params[:name]
|
|
36
|
-
entry =
|
|
32
|
+
entry = Routes::Extensions.extension_entry(name)
|
|
37
33
|
halt_not_found("extension '#{name}' not found") unless entry
|
|
38
34
|
|
|
39
35
|
ext_mod = find_extension_module(name)
|
|
40
|
-
version = ext_mod&.const_defined?(:VERSION) ? ext_mod::VERSION : nil
|
|
41
36
|
|
|
42
37
|
runners = ext_mod ? runner_summaries(ext_mod) : []
|
|
43
38
|
|
|
44
|
-
json_response(
|
|
45
|
-
name: name,
|
|
46
|
-
state: entry[:state].to_s,
|
|
47
|
-
version: version,
|
|
48
|
-
registered_at: entry[:registered_at]&.iso8601,
|
|
49
|
-
started_at: entry[:started_at]&.iso8601,
|
|
50
|
-
runners: runners
|
|
51
|
-
}.compact)
|
|
39
|
+
json_response(entry.merge(runners: runners).compact)
|
|
52
40
|
end
|
|
53
41
|
end
|
|
54
42
|
|
|
@@ -154,6 +142,52 @@ module Legion
|
|
|
154
142
|
end
|
|
155
143
|
|
|
156
144
|
class << self
|
|
145
|
+
def extension_entries
|
|
146
|
+
handles = if Legion::Extensions.respond_to?(:extension_handles)
|
|
147
|
+
Legion::Extensions.extension_handles
|
|
148
|
+
else
|
|
149
|
+
[]
|
|
150
|
+
end
|
|
151
|
+
return handles.map { |handle| serialize_handle(handle) } unless handles.empty?
|
|
152
|
+
|
|
153
|
+
Legion::Extensions::Catalog.all.filter_map do |name, entry|
|
|
154
|
+
serialize_catalog_entry(name, entry)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def extension_entry(name)
|
|
159
|
+
handle = Legion::Extensions.extension_handle(name) if Legion::Extensions.respond_to?(:extension_handle)
|
|
160
|
+
return serialize_handle(handle) if handle
|
|
161
|
+
|
|
162
|
+
serialize_catalog_entry(name, Legion::Extensions::Catalog.entry(name))
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def serialize_handle(handle)
|
|
166
|
+
{
|
|
167
|
+
name: handle.lex_name,
|
|
168
|
+
state: handle.state.to_s,
|
|
169
|
+
active_version: handle.active_version&.to_s,
|
|
170
|
+
latest_installed_version: handle.latest_installed_version&.to_s,
|
|
171
|
+
reload_state: handle.reload_state.to_s,
|
|
172
|
+
pending_reload: handle.pending_reload?,
|
|
173
|
+
hot_reloadable: handle.hot_reloadable,
|
|
174
|
+
loaded_at: handle.loaded_at&.iso8601,
|
|
175
|
+
last_error: handle.last_error,
|
|
176
|
+
routes: handle.routes,
|
|
177
|
+
tools: handle.tools,
|
|
178
|
+
absorbers: handle.absorbers,
|
|
179
|
+
owned_runners: handle.runners
|
|
180
|
+
}.compact
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def serialize_catalog_entry(name, entry)
|
|
184
|
+
return nil unless entry
|
|
185
|
+
|
|
186
|
+
{ name: name, state: entry[:state].to_s,
|
|
187
|
+
registered_at: entry[:registered_at]&.iso8601,
|
|
188
|
+
started_at: entry[:started_at]&.iso8601 }
|
|
189
|
+
end
|
|
190
|
+
|
|
157
191
|
private :register_available_route, :register_extension_routes,
|
|
158
192
|
:register_runner_routes, :register_function_routes, :register_invoke_route
|
|
159
193
|
end
|
data/lib/legion/api/knowledge.rb
CHANGED
|
@@ -67,7 +67,15 @@ module Legion
|
|
|
67
67
|
app.post '/api/knowledge/status' do
|
|
68
68
|
require_knowledge_ingest!
|
|
69
69
|
body = parse_request_body
|
|
70
|
-
path = body[:path] ||
|
|
70
|
+
path = body[:path] ||
|
|
71
|
+
Legion::Settings.dig(:knowledge, :default_corpus_path) ||
|
|
72
|
+
ENV.fetch('LEGION_CORPUS_PATH', nil)
|
|
73
|
+
|
|
74
|
+
if path.nil? || path.to_s.empty?
|
|
75
|
+
halt 400, json_error('missing_param',
|
|
76
|
+
'path is required (no knowledge.default_corpus_path configured)')
|
|
77
|
+
end
|
|
78
|
+
|
|
71
79
|
result = Legion::Extensions::Knowledge::Runners::Ingest.scan_corpus(path: path)
|
|
72
80
|
json_response(result)
|
|
73
81
|
end
|
data/lib/legion/api/stats.rb
CHANGED
|
@@ -20,21 +20,23 @@ module Legion
|
|
|
20
20
|
end
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
loaded: :@loaded_extensions,
|
|
23
|
+
EXTENSION_TASK_IVARS = {
|
|
25
24
|
discovered: :@extensions,
|
|
26
25
|
subscription: :@subscription_tasks,
|
|
27
26
|
every: :@timer_tasks,
|
|
28
27
|
poll: :@poll_tasks,
|
|
29
28
|
once: :@once_tasks,
|
|
30
29
|
loop: :@loop_tasks,
|
|
31
|
-
|
|
30
|
+
actors: :@running_instances
|
|
32
31
|
}.freeze
|
|
33
32
|
|
|
34
33
|
class << self
|
|
35
34
|
def collect_extensions
|
|
36
35
|
ext = Legion::Extensions
|
|
37
|
-
|
|
36
|
+
{
|
|
37
|
+
loaded: ext.extension_handle_registry.loaded.count,
|
|
38
|
+
running: ext.extension_handle_registry.running.count
|
|
39
|
+
}.merge(EXTENSION_TASK_IVARS.transform_values { |ivar| ext.instance_variable_get(ivar)&.count || 0 })
|
|
38
40
|
rescue StandardError => e
|
|
39
41
|
{ error: e.message }
|
|
40
42
|
end
|
|
@@ -214,6 +214,11 @@ module Legion
|
|
|
214
214
|
end
|
|
215
215
|
|
|
216
216
|
def dispatch_runner(message, runner_cls, function, check_subtask, generate_task)
|
|
217
|
+
unless extension_dispatch_allowed?
|
|
218
|
+
log.warn "[Subscription] rejecting #{lex_name}/#{function}: extension is not accepting new work" if defined?(log)
|
|
219
|
+
return { success: false, status: 'task.blocked', error: { code: 'extension_quiescing' } }
|
|
220
|
+
end
|
|
221
|
+
|
|
217
222
|
run_block = lambda {
|
|
218
223
|
ctx = message.merge(runner_class: runner_cls.to_s, function: function.to_s)
|
|
219
224
|
Legion::Context.with_task_context(ctx) do
|
|
@@ -232,6 +237,12 @@ module Legion
|
|
|
232
237
|
end
|
|
233
238
|
end
|
|
234
239
|
|
|
240
|
+
def extension_dispatch_allowed?
|
|
241
|
+
return true unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:dispatch_allowed?)
|
|
242
|
+
|
|
243
|
+
Legion::Extensions.dispatch_allowed?(lex_name)
|
|
244
|
+
end
|
|
245
|
+
|
|
235
246
|
def reject_or_retry(delivery_info, metadata, payload)
|
|
236
247
|
headers = metadata&.headers || {}
|
|
237
248
|
retry_count = RetryPolicy.extract_retry_count(headers)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
class Handle
|
|
6
|
+
STATES = %i[registered loaded starting running stopping stopped failed].freeze
|
|
7
|
+
LOADED_STATES = %i[loaded starting running stopping].freeze
|
|
8
|
+
DISPATCHABLE_STATES = %i[loaded starting running].freeze
|
|
9
|
+
RELOAD_STATES = %i[idle pending updating rolling_back failed].freeze
|
|
10
|
+
DISPATCH_BLOCKING_RELOAD_STATES = %i[updating rolling_back].freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :lex_name, :gem_name, :active_version, :state, :reload_state, :hot_reloadable,
|
|
13
|
+
:latest_installed_version, :spec, :gem_dir, :loaded_features, :actors, :routes, :tools, :absorbers,
|
|
14
|
+
:runners, :loaded_at, :last_error
|
|
15
|
+
|
|
16
|
+
def initialize(**attrs)
|
|
17
|
+
lex_name = attrs.fetch(:lex_name)
|
|
18
|
+
spec = attrs[:spec]
|
|
19
|
+
@lex_name = lex_name.to_s
|
|
20
|
+
@gem_name = (attrs[:gem_name] || lex_name).to_s
|
|
21
|
+
@spec = spec
|
|
22
|
+
@active_version = normalize_version(attrs[:active_version] || spec&.version)
|
|
23
|
+
@latest_installed_version = normalize_version(attrs[:latest_installed_version] || attrs[:installed_version] || @active_version)
|
|
24
|
+
@state = normalize_state(attrs.fetch(:state, :registered))
|
|
25
|
+
@reload_state = normalize_reload_state(attrs.fetch(:reload_state, :idle))
|
|
26
|
+
@hot_reloadable = attrs[:hot_reloadable] == true
|
|
27
|
+
@gem_dir = attrs[:gem_dir] || spec&.gem_dir
|
|
28
|
+
@loaded_features = Array(attrs.fetch(:loaded_features, [])).dup.freeze
|
|
29
|
+
@actors = Array(attrs.fetch(:actors, [])).dup.freeze
|
|
30
|
+
@routes = Array(attrs.fetch(:routes, [])).dup.freeze
|
|
31
|
+
@tools = Array(attrs.fetch(:tools, [])).dup.freeze
|
|
32
|
+
@absorbers = Array(attrs.fetch(:absorbers, [])).dup.freeze
|
|
33
|
+
@runners = Array(attrs.fetch(:runners, [])).dup.freeze
|
|
34
|
+
@loaded_at = attrs.fetch(:loaded_at, Time.now)
|
|
35
|
+
@last_error = attrs[:last_error]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def loaded?
|
|
39
|
+
LOADED_STATES.include?(state)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def running?
|
|
43
|
+
state == :running
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def pending_reload?
|
|
47
|
+
return false if active_version.nil? || latest_installed_version.nil?
|
|
48
|
+
|
|
49
|
+
latest_installed_version > active_version
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dispatchable?
|
|
53
|
+
DISPATCHABLE_STATES.include?(state) && !DISPATCH_BLOCKING_RELOAD_STATES.include?(reload_state)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def with(**attrs)
|
|
57
|
+
self.class.new(**to_h, **attrs)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
{
|
|
62
|
+
lex_name: lex_name,
|
|
63
|
+
gem_name: gem_name,
|
|
64
|
+
active_version: active_version,
|
|
65
|
+
latest_installed_version: latest_installed_version,
|
|
66
|
+
state: state,
|
|
67
|
+
reload_state: reload_state,
|
|
68
|
+
hot_reloadable: hot_reloadable,
|
|
69
|
+
spec: spec,
|
|
70
|
+
gem_dir: gem_dir,
|
|
71
|
+
loaded_features: loaded_features,
|
|
72
|
+
actors: actors,
|
|
73
|
+
routes: routes,
|
|
74
|
+
tools: tools,
|
|
75
|
+
absorbers: absorbers,
|
|
76
|
+
runners: runners,
|
|
77
|
+
loaded_at: loaded_at,
|
|
78
|
+
last_error: last_error
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def normalize_version(value)
|
|
85
|
+
return nil if value.nil?
|
|
86
|
+
return value if value.is_a?(Gem::Version)
|
|
87
|
+
|
|
88
|
+
Gem::Version.new(value.to_s)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def normalize_state(value)
|
|
92
|
+
normalized = value.to_sym
|
|
93
|
+
return normalized if STATES.include?(normalized)
|
|
94
|
+
|
|
95
|
+
raise ArgumentError, "unknown extension state: #{value.inspect}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def normalize_reload_state(value)
|
|
99
|
+
normalized = value.to_sym
|
|
100
|
+
return normalized if RELOAD_STATES.include?(normalized)
|
|
101
|
+
|
|
102
|
+
raise ArgumentError, "unknown extension reload state: #{value.inspect}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/handle'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
class HandleRegistry
|
|
8
|
+
def initialize
|
|
9
|
+
@handles = {}
|
|
10
|
+
@mutex = Mutex.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register(lex_name, **attrs)
|
|
14
|
+
key = normalize_name(lex_name)
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
current = @handles[key]
|
|
17
|
+
@handles[key] = current ? current.with(**attrs) : Handle.new(lex_name: key, **attrs)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def transition(lex_name, state)
|
|
22
|
+
update(lex_name, state: state)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def update(lex_name, **attrs)
|
|
26
|
+
key = normalize_name(lex_name)
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
current = @handles[key] || Handle.new(lex_name: key)
|
|
29
|
+
@handles[key] = current.with(**attrs)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fetch(lex_name)
|
|
34
|
+
@mutex.synchronize { @handles[normalize_name(lex_name)] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def all
|
|
38
|
+
@mutex.synchronize { @handles.values.dup }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def running
|
|
42
|
+
all.select(&:running?)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def loaded
|
|
46
|
+
all.select(&:loaded?)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def dispatch_allowed?(lex_name)
|
|
50
|
+
handle = fetch(lex_name)
|
|
51
|
+
return true unless handle
|
|
52
|
+
|
|
53
|
+
handle.dispatchable?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def delete(lex_name)
|
|
57
|
+
@mutex.synchronize { @handles.delete(normalize_name(lex_name)) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def reset!
|
|
61
|
+
@mutex.synchronize { @handles.clear }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def normalize_name(lex_name)
|
|
67
|
+
lex_name.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/legion/extensions.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'legion/extensions/core'
|
|
4
4
|
require 'legion/extensions/catalog'
|
|
5
|
+
require 'legion/extensions/handle_registry'
|
|
5
6
|
require 'legion/extensions/permissions'
|
|
6
7
|
require 'legion/runner'
|
|
7
8
|
|
|
@@ -22,6 +23,7 @@ module Legion
|
|
|
22
23
|
@actors = []
|
|
23
24
|
@running_instances = Concurrent::Array.new
|
|
24
25
|
@loaded_extensions = []
|
|
26
|
+
reset_runtime_handles!
|
|
25
27
|
@pending_registrations = Concurrent::Array.new
|
|
26
28
|
|
|
27
29
|
find_extensions
|
|
@@ -33,7 +35,7 @@ module Legion
|
|
|
33
35
|
hook_phase_actors(phase_num)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
|
|
38
|
+
transition_loaded_extensions(:running)
|
|
37
39
|
Catalog.flush_persisted_transitions
|
|
38
40
|
|
|
39
41
|
load_yaml_agents
|
|
@@ -47,7 +49,7 @@ module Legion
|
|
|
47
49
|
deadline = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15
|
|
48
50
|
shutdown_start = Time.now
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
transition_loaded_extensions(:stopping)
|
|
51
53
|
|
|
52
54
|
if @subscription_pool
|
|
53
55
|
@subscription_pool.shutdown
|
|
@@ -100,10 +102,7 @@ module Legion
|
|
|
100
102
|
|
|
101
103
|
Legion::Dispatch.shutdown if defined?(Legion::Dispatch) && Legion::Dispatch.instance_variable_get(:@dispatcher)
|
|
102
104
|
|
|
103
|
-
|
|
104
|
-
Catalog.transition(name, :stopped)
|
|
105
|
-
unregister_capabilities(name)
|
|
106
|
-
end
|
|
105
|
+
transition_loaded_extensions(:stopped) { |name| unregister_capabilities(name) }
|
|
107
106
|
Legion::Logging.info "Successfully shut down all actors (#{(Time.now - shutdown_start).round(1)}s)"
|
|
108
107
|
end
|
|
109
108
|
|
|
@@ -146,6 +145,8 @@ module Legion
|
|
|
146
145
|
end
|
|
147
146
|
|
|
148
147
|
Catalog.register(gem_name)
|
|
148
|
+
register_extension_handle(gem_name, state: :registered,
|
|
149
|
+
latest_installed_version: latest_installed_version(gem_name))
|
|
149
150
|
entry
|
|
150
151
|
end
|
|
151
152
|
|
|
@@ -211,9 +212,11 @@ module Legion
|
|
|
211
212
|
results.each_with_index do |result, idx|
|
|
212
213
|
if result
|
|
213
214
|
Catalog.transition(result[:gem_name], :loaded)
|
|
215
|
+
transition_extension_handle(result[:gem_name], :loaded)
|
|
214
216
|
register_in_registry(gem_name: result[:gem_name], version: result[:version])
|
|
215
217
|
@loaded_extensions.push(result[:gem_name])
|
|
216
218
|
else
|
|
219
|
+
transition_extension_handle(eligible[idx][:gem_name], :failed)
|
|
217
220
|
Legion::Logging.warn("#{eligible[idx][:gem_name]} failed to load")
|
|
218
221
|
end
|
|
219
222
|
end
|
|
@@ -275,14 +278,6 @@ module Legion
|
|
|
275
278
|
has_logger = extension.respond_to?(:log)
|
|
276
279
|
extension.autobuild
|
|
277
280
|
|
|
278
|
-
require 'legion/transport/messages/lex_register'
|
|
279
|
-
registration = Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners)
|
|
280
|
-
if @pending_registrations
|
|
281
|
-
@pending_registrations << registration
|
|
282
|
-
else
|
|
283
|
-
registration.publish
|
|
284
|
-
end
|
|
285
|
-
|
|
286
281
|
register_capabilities(entry[:gem_name], extension.runners) if extension.respond_to?(:runners)
|
|
287
282
|
write_lex_cli_manifest(entry, extension)
|
|
288
283
|
register_absorber_capabilities(entry[:gem_name], extension.absorbers) if extension.respond_to?(:absorbers)
|
|
@@ -301,6 +296,14 @@ module Legion
|
|
|
301
296
|
extension.log.info "Loaded v#{extension::VERSION}"
|
|
302
297
|
Legion::Events.emit('extension.loaded', name: ext_name, version: entry[:gem_name])
|
|
303
298
|
|
|
299
|
+
require 'legion/transport/messages/lex_register'
|
|
300
|
+
registration = Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners)
|
|
301
|
+
if @pending_registrations
|
|
302
|
+
@pending_registrations << registration
|
|
303
|
+
else
|
|
304
|
+
registration.publish
|
|
305
|
+
end
|
|
306
|
+
|
|
304
307
|
begin
|
|
305
308
|
if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker)
|
|
306
309
|
worker_id = "lex-#{ext_name}"
|
|
@@ -320,6 +323,8 @@ module Legion
|
|
|
320
323
|
Legion::Logging.debug "Extensions#load_extension failed to register digital worker for #{ext_name}: #{e.message}" if defined?(Legion::Logging)
|
|
321
324
|
nil
|
|
322
325
|
end
|
|
326
|
+
register_extension_handle(entry[:gem_name], spec: entry[:spec], state: :loaded, loaded_at: Time.now,
|
|
327
|
+
latest_installed_version: latest_installed_version(entry[:gem_name]))
|
|
323
328
|
true
|
|
324
329
|
rescue StandardError => e
|
|
325
330
|
Legion::Logging.log_exception(e, lex: entry[:gem_name], component_type: :boot)
|
|
@@ -599,9 +604,12 @@ module Legion
|
|
|
599
604
|
public
|
|
600
605
|
|
|
601
606
|
def loaded_extension_modules
|
|
607
|
+
handles = extension_handles
|
|
608
|
+
active_names = handles.select(&:dispatchable?).map(&:lex_name)
|
|
602
609
|
constants(false).filter_map do |const_name|
|
|
603
610
|
mod = const_get(const_name, false)
|
|
604
611
|
next nil unless mod.is_a?(Module) && mod.respond_to?(:runner_modules)
|
|
612
|
+
next nil if handles.any? && !active_names.include?(module_lex_name(mod))
|
|
605
613
|
|
|
606
614
|
mod
|
|
607
615
|
rescue StandardError => e
|
|
@@ -611,7 +619,11 @@ module Legion
|
|
|
611
619
|
end
|
|
612
620
|
|
|
613
621
|
# Legacy capability registration - now handled by Tools::Discovery
|
|
614
|
-
def unregister_capabilities(
|
|
622
|
+
def unregister_capabilities(gem_name)
|
|
623
|
+
return unless defined?(Legion::Tools::Registry) && Legion::Tools::Registry.respond_to?(:unregister_extension)
|
|
624
|
+
|
|
625
|
+
Legion::Tools::Registry.unregister_extension(gem_name)
|
|
626
|
+
end
|
|
615
627
|
|
|
616
628
|
def register_absorber_capabilities(_gem_name, _absorbers); end
|
|
617
629
|
|
|
@@ -620,7 +632,10 @@ module Legion
|
|
|
620
632
|
def gem_load(entry)
|
|
621
633
|
gem_name = entry[:gem_name]
|
|
622
634
|
require_path = entry[:require_path]
|
|
623
|
-
|
|
635
|
+
spec = Gem::Specification.find_by_name(gem_name)
|
|
636
|
+
gem_dir = spec.gem_dir
|
|
637
|
+
entry[:spec] = spec
|
|
638
|
+
entry[:version] = spec.version.to_s
|
|
624
639
|
require "#{gem_dir}/lib/#{require_path}"
|
|
625
640
|
true
|
|
626
641
|
rescue Gem::MissingSpecError => e
|
|
@@ -762,6 +777,82 @@ module Legion
|
|
|
762
777
|
@extensions
|
|
763
778
|
end
|
|
764
779
|
|
|
780
|
+
def loaded_extensions
|
|
781
|
+
extension_handle_registry.loaded.map(&:lex_name)
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def extension_handles
|
|
785
|
+
extension_handle_registry.all
|
|
786
|
+
end
|
|
787
|
+
|
|
788
|
+
def extension_handle(name)
|
|
789
|
+
extension_handle_registry.fetch(name)
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def register_extension_handle(name, **attrs)
|
|
793
|
+
extension_handle_registry.register(name, **attrs)
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
def transition_extension_handle(name, state)
|
|
797
|
+
extension_handle_registry.transition(name, state)
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
def update_extension_handle(name, **attrs)
|
|
801
|
+
extension_handle_registry.update(name, **attrs)
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
def reset_runtime_handles!
|
|
805
|
+
extension_handle_registry.reset!
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
def dispatch_allowed?(lex_name)
|
|
809
|
+
extension_handle_registry.dispatch_allowed?(normalize_lex_name(lex_name))
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def dispatch_allowed_for_runner?(runner_class)
|
|
813
|
+
lex_name = lex_name_for_runner_class(runner_class)
|
|
814
|
+
return true unless lex_name
|
|
815
|
+
|
|
816
|
+
dispatch_allowed?(lex_name)
|
|
817
|
+
end
|
|
818
|
+
|
|
819
|
+
def record_extension_resource(lex_name, resource_type, value)
|
|
820
|
+
handle = extension_handle(lex_name) || register_extension_handle(normalize_lex_name(lex_name))
|
|
821
|
+
values = Array(handle.public_send(resource_type))
|
|
822
|
+
return handle if values.include?(value)
|
|
823
|
+
|
|
824
|
+
update_extension_handle(handle.lex_name, resource_type => values + [value])
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
def reload_extension(name)
|
|
828
|
+
gem_name = normalize_lex_name(name)
|
|
829
|
+
update_extension_handle(gem_name, reload_state: :updating)
|
|
830
|
+
unregister_capabilities(gem_name)
|
|
831
|
+
reset_runner_cache
|
|
832
|
+
|
|
833
|
+
entry = @extensions&.find { |candidate| candidate[:gem_name] == gem_name }
|
|
834
|
+
raise "#{gem_name} failed to reload" if entry && !load_extension(entry)
|
|
835
|
+
|
|
836
|
+
update_extension_handle(gem_name, state: :running, reload_state: :idle, last_error: nil,
|
|
837
|
+
latest_installed_version: latest_installed_version(gem_name))
|
|
838
|
+
true
|
|
839
|
+
rescue StandardError => e
|
|
840
|
+
update_extension_handle(gem_name, reload_state: :failed, last_error: e.message)
|
|
841
|
+
raise
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
def extension_handle_registry
|
|
845
|
+
@extension_handle_registry ||= HandleRegistry.new
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def transition_loaded_extensions(state)
|
|
849
|
+
@loaded_extensions&.each do |name|
|
|
850
|
+
Catalog.transition(name, state)
|
|
851
|
+
transition_extension_handle(name, state)
|
|
852
|
+
yield name if block_given?
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
|
|
765
856
|
def load_yaml_agents
|
|
766
857
|
@load_yaml_agents ||= begin
|
|
767
858
|
require 'legion/settings/agent_loader'
|
|
@@ -777,6 +868,57 @@ module Legion
|
|
|
777
868
|
|
|
778
869
|
private
|
|
779
870
|
|
|
871
|
+
def latest_installed_version(gem_name)
|
|
872
|
+
Gem::Specification.find_all_by_name(gem_name).map(&:version).max
|
|
873
|
+
rescue StandardError
|
|
874
|
+
nil
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
def reset_runner_cache
|
|
878
|
+
return unless defined?(Legion::Ingress) && Legion::Ingress.respond_to?(:reset_runner_cache!)
|
|
879
|
+
|
|
880
|
+
Legion::Ingress.reset_runner_cache!
|
|
881
|
+
end
|
|
882
|
+
|
|
883
|
+
def normalize_lex_name(name)
|
|
884
|
+
str = name.to_s
|
|
885
|
+
str.start_with?('lex-') ? str : "lex-#{str.tr('.', '-').tr('_', '-')}"
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
def module_lex_name(mod)
|
|
889
|
+
parts = mod.name.to_s.split('::')
|
|
890
|
+
idx = parts.index('Extensions')
|
|
891
|
+
return nil unless idx
|
|
892
|
+
|
|
893
|
+
extension_parts = extension_parts_from_const(parts, idx)
|
|
894
|
+
return nil if extension_parts.empty?
|
|
895
|
+
|
|
896
|
+
"lex-#{extension_parts.join('-')}"
|
|
897
|
+
end
|
|
898
|
+
|
|
899
|
+
def lex_name_for_runner_class(runner_class)
|
|
900
|
+
parts = runner_class.to_s.split('::')
|
|
901
|
+
idx = parts.index('Extensions')
|
|
902
|
+
return nil unless idx
|
|
903
|
+
|
|
904
|
+
extension_parts = extension_parts_from_const(parts, idx)
|
|
905
|
+
return nil if extension_parts.empty?
|
|
906
|
+
|
|
907
|
+
"lex-#{extension_parts.join('-')}"
|
|
908
|
+
end
|
|
909
|
+
|
|
910
|
+
def extension_parts_from_const(parts, idx)
|
|
911
|
+
parts[(idx + 1)..].to_a.each_with_object([]) do |part, extension_parts|
|
|
912
|
+
break extension_parts if %w[Actor Actors Runners Helpers Transport Data Hooks Skills].include?(part)
|
|
913
|
+
|
|
914
|
+
extension_parts << camel_to_snake(part).tr('_', '-')
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
|
|
918
|
+
def camel_to_snake(value)
|
|
919
|
+
value.to_s.gsub(/(?<!^)[A-Z]/) { "_#{Regexp.last_match(0)}" }.downcase
|
|
920
|
+
end
|
|
921
|
+
|
|
780
922
|
def default_agents_directory
|
|
781
923
|
custom = Legion::Settings.dig(:agents, :directory)
|
|
782
924
|
return custom if custom && Dir.exist?(custom)
|
data/lib/legion/ingress.rb
CHANGED
|
@@ -64,6 +64,14 @@ module Legion
|
|
|
64
64
|
fn_str = fn.to_s
|
|
65
65
|
raise InvalidFunction, "invalid function format: #{fn_str}" unless fn_str.match?(FUNCTION_PATTERN)
|
|
66
66
|
|
|
67
|
+
unless extension_dispatch_allowed?(rc)
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
status: 'task.blocked',
|
|
71
|
+
error: { code: 'extension_quiescing', message: "extension for #{rc} is not accepting new work" }
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
67
75
|
# RAI invariant #2: registration precedes permission
|
|
68
76
|
if defined?(Legion::DigitalWorker::Registry) && message[:worker_id]
|
|
69
77
|
Legion::DigitalWorker::Registry.validate_execution!(
|
|
@@ -138,8 +146,18 @@ module Legion
|
|
|
138
146
|
false
|
|
139
147
|
end
|
|
140
148
|
|
|
149
|
+
def reset_runner_cache!
|
|
150
|
+
@registered_runner_modules = nil
|
|
151
|
+
end
|
|
152
|
+
|
|
141
153
|
private
|
|
142
154
|
|
|
155
|
+
def extension_dispatch_allowed?(runner_class)
|
|
156
|
+
return true unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:dispatch_allowed_for_runner?)
|
|
157
|
+
|
|
158
|
+
Legion::Extensions.dispatch_allowed_for_runner?(runner_class)
|
|
159
|
+
end
|
|
160
|
+
|
|
143
161
|
def resolve_runner_class(runner_class)
|
|
144
162
|
return runner_class unless runner_class.is_a?(String)
|
|
145
163
|
|
|
@@ -169,10 +187,6 @@ module Legion
|
|
|
169
187
|
@registered_runner_modules = modules
|
|
170
188
|
end
|
|
171
189
|
|
|
172
|
-
def reset_runner_cache!
|
|
173
|
-
@registered_runner_modules = nil
|
|
174
|
-
end
|
|
175
|
-
|
|
176
190
|
def parse_payload(payload)
|
|
177
191
|
case payload
|
|
178
192
|
when Hash
|
|
@@ -123,7 +123,9 @@ module Legion
|
|
|
123
123
|
ext: ext, runner_mod: runner_mod, func_name: func_name,
|
|
124
124
|
meta: meta, defn: defn, deferred: is_deferred
|
|
125
125
|
)
|
|
126
|
-
Legion::Tools::Registry.register(tool_class)
|
|
126
|
+
return unless Legion::Tools::Registry.register(tool_class)
|
|
127
|
+
|
|
128
|
+
record_tool_owner(ext, tool_class)
|
|
127
129
|
end
|
|
128
130
|
|
|
129
131
|
def resolve_mcp_tools_enabled(ext, runner_mod)
|
|
@@ -214,6 +216,15 @@ module Legion
|
|
|
214
216
|
end
|
|
215
217
|
end
|
|
216
218
|
|
|
219
|
+
def record_tool_owner(ext, tool_class)
|
|
220
|
+
return unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:record_extension_resource)
|
|
221
|
+
|
|
222
|
+
ext_name = derive_extension_name(ext)
|
|
223
|
+
Legion::Extensions.record_extension_resource("lex-#{ext_name.tr('_', '-')}", :tools, tool_class.tool_name)
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
handle_exception(e, level: :warn, handled: true, operation: :record_tool_owner)
|
|
226
|
+
end
|
|
227
|
+
|
|
217
228
|
def merge_trigger_words(ext, runner_mod)
|
|
218
229
|
ext_words = ext.respond_to?(:trigger_words) ? Array(ext.trigger_words) : []
|
|
219
230
|
|
|
@@ -56,7 +56,8 @@ module Legion
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def for_extension(ext_name)
|
|
59
|
-
|
|
59
|
+
normalized = normalize_extension(ext_name)
|
|
60
|
+
all_tools.select { |t| t.respond_to?(:extension) && normalize_extension(t.extension) == normalized }
|
|
60
61
|
end
|
|
61
62
|
|
|
62
63
|
def for_runner(runner_name)
|
|
@@ -73,6 +74,22 @@ module Legion
|
|
|
73
74
|
@deferred.clear
|
|
74
75
|
end
|
|
75
76
|
end
|
|
77
|
+
|
|
78
|
+
def unregister_extension(ext_name)
|
|
79
|
+
normalized = normalize_extension(ext_name)
|
|
80
|
+
@mutex.synchronize do
|
|
81
|
+
before = @always.size + @deferred.size
|
|
82
|
+
@always.reject! { |t| t.respond_to?(:extension) && normalize_extension(t.extension) == normalized }
|
|
83
|
+
@deferred.reject! { |t| t.respond_to?(:extension) && normalize_extension(t.extension) == normalized }
|
|
84
|
+
before - (@always.size + @deferred.size)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def normalize_extension(ext_name)
|
|
91
|
+
ext_name.to_s.delete_prefix('lex-').tr('-', '_')
|
|
92
|
+
end
|
|
76
93
|
end
|
|
77
94
|
end
|
|
78
95
|
end
|
data/lib/legion/version.rb
CHANGED
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.9.
|
|
4
|
+
version: 1.9.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -830,6 +830,8 @@ files:
|
|
|
830
830
|
- lib/legion/extensions/data/model.rb
|
|
831
831
|
- lib/legion/extensions/definitions.rb
|
|
832
832
|
- lib/legion/extensions/gem_source.rb
|
|
833
|
+
- lib/legion/extensions/handle.rb
|
|
834
|
+
- lib/legion/extensions/handle_registry.rb
|
|
833
835
|
- lib/legion/extensions/helpers/base.rb
|
|
834
836
|
- lib/legion/extensions/helpers/cache.rb
|
|
835
837
|
- lib/legion/extensions/helpers/core.rb
|