legionio 1.8.16 → 1.9.1

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: 7565dee1ffc3d39358bae7bcde3e0c3af8368ede4f8948b6f9c1c618b7a96001
4
- data.tar.gz: 033d14bde74d0b753da46cc5cc45e2f600655a1be402c157b64f36284e9aa88c
3
+ metadata.gz: 45c36457fa18952ae68b86d8b1d230a5166bdf119338ce9c9e23791b413f822b
4
+ data.tar.gz: 380124a7e0321717dc18751d5de61bf83b2cd255d50efa5cf07a920c7d115f5a
5
5
  SHA512:
6
- metadata.gz: b70ac4808b1322dffc0b58d169140ce3d2ea7b524658097b16fae4f9a563245e38be2116854f75decdecd58b1eda72cec9448193168110d485a9b00887ac544b
7
- data.tar.gz: 6b929b04626948f04993aac61d41da43231e4c66f6a1b6e60196e69b7cf71acc45bb34cf7fb8b3c5c7bcc60ca60a1bcc5c667fbc1101b11b37aaf247a7817db9
6
+ metadata.gz: 4bbc31826f903cbc0753fb79ed0745b1322d0acc86009a7b88586cd7ca4f520bc5e97d81f7c777bb7ba72be89d7d894565403cc413eda5b6934eadf35161f1b3
7
+ data.tar.gz: b546b6d4bd2f78e5a5d87c4acc253c1d849bd59adbd45af28781f16ab56c1895c389145576bf9ddae0af14b0907e32a4ee560817c34d2b2a83d11ab1a05bb10a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.9.1] - 2026-04-25
6
+
7
+ ### Added
8
+ - 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`.
9
+ - Extension dispatch quiescing now blocks API, ingress, and subscription runner dispatch while an extension is stopping or actively reloading.
10
+ - `Legion::Tools::Registry.unregister_extension` removes callable tools owned by an extension during unload/reload cleanup.
11
+
12
+ ### Fixed
13
+ - Runtime handle `loaded?` no longer reports `stopped` or `failed` extensions as loaded.
14
+ - Extension registration publication now happens after extension autobuild and runtime side effects complete, avoiding durable registration of failed loads.
15
+ - 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.
16
+
17
+ ## [1.9.0] - 2026-04-24
18
+
19
+ ### Added
20
+ - `Legion::Identity::Resolver` — composite identity resolution chain with parallel provider execution, DB persistence, and transport event publishing
21
+ - `Legion::Identity::Trust` — trust level enum (verified, authenticated, configured, cached, unverified)
22
+ - `Legion::Identity::Grant` — frozen value object for credential access auditing
23
+ - `Identity::Process` extended with trust, aliases, providers, profile composite state
24
+ - `Identity::Broker` upgraded to `[provider, qualifier]` tuple-keyed multi-instance storage with `for_context` routing and bounded async audit queue
25
+ - `Resolver.upgrade!` for post-boot identity trust escalation with canonical_name change support
26
+ - Settings client name updated from resolved identity for correct queue naming
27
+
28
+ ### Changed
29
+ - `setup_identity` gate relaxed to run with DB-only nodes (not just transport)
30
+ - `register_credential_providers` gate relaxed for DB-only nodes
31
+ - Reload lifecycle: `Resolver.reset!` preserves providers, re-resolves with existing registrations
32
+ - Middleware `system_principal` uses Resolver identity when available
33
+
34
+ ### Fixed
35
+ - `Request.from_auth_context` canonical normalization now matches DB constraint `^[a-z0-9][a-z0-9_-]*$`
36
+ - `/api/identity/audit` reads from `identity_audit_log` table instead of `AuditRecord`
37
+
38
+ ### Removed
39
+ - Legacy tree-walk identity discovery (`resolve_identity_providers`, `find_identity_providers`, `collect_identity_providers`)
40
+ - `identity_provider?` and `register_identity_provider` from extensions.rb
5
41
 
6
42
  ## [1.8.16] - 2026-04-22
7
43
 
@@ -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 = Legion::Extensions::Catalog.all.map do |name, entry|
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 = Legion::Extensions::Catalog.entry(name)
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
@@ -8,12 +8,23 @@ module Legion
8
8
  app.helpers IdentityAuditHelpers
9
9
 
10
10
  app.get '/api/identity/audit' do
11
- halt 503, json_error('unavailable', 'audit records not available') unless defined?(Legion::Data::Model::AuditRecord)
11
+ require_data!
12
+ halt 503, json_error('unavailable', 'identity audit log not available') unless defined?(Legion::Data::Model::IdentityAuditLog)
12
13
 
13
- dataset = Legion::Data::Model::AuditRecord.where(entity_type: 'identity')
14
+ dataset = Legion::Data::Model::IdentityAuditLog.dataset
14
15
 
15
16
  principal = params[:principal]
16
- dataset = dataset.where(Sequel.lit("metadata->>'principal' = ?", principal)) if principal
17
+ if principal && defined?(Legion::Data::Model::Principal)
18
+ principal_record = Legion::Data::Model::Principal.where(canonical_name: principal).first
19
+ halt 404, json_error('not_found', "principal '#{principal}' not found") unless principal_record
20
+ dataset = dataset.where(principal_id: principal_record.id)
21
+ end
22
+
23
+ provider = params[:provider]
24
+ dataset = dataset.where(provider_name: provider) if provider
25
+
26
+ event_type = params[:event_type]
27
+ dataset = dataset.where(event_type: event_type) if event_type
17
28
 
18
29
  since = params[:since]
19
30
  if since
@@ -23,7 +34,9 @@ module Legion
23
34
 
24
35
  records = dataset.order(Sequel.desc(:created_at)).limit(100).all
25
36
  json_collection(records.map do |r|
26
- { id: r.id, action: r.action, entity_type: r.entity_type, metadata: r.parsed_metadata, created_at: r.created_at }
37
+ { id: r.id, event_type: r.event_type, provider_name: r.provider_name,
38
+ trust_level: r.trust_level, detail: r.detail,
39
+ node_id: r.node_id, session_id: r.session_id, created_at: r.created_at }
27
40
  end)
28
41
  end
29
42
  end
@@ -20,21 +20,23 @@ module Legion
20
20
  end
21
21
  end
22
22
 
23
- EXTENSION_IVARS = {
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
- running: :@running_instances
30
+ actors: :@running_instances
32
31
  }.freeze
33
32
 
34
33
  class << self
35
34
  def collect_extensions
36
35
  ext = Legion::Extensions
37
- EXTENSION_IVARS.transform_values { |ivar| ext.instance_variable_get(ivar)&.count || 0 }
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
data/lib/legion/cli.rb CHANGED
@@ -267,6 +267,9 @@ module Legion
267
267
  desc 'init', 'Initialize a new Legion workspace'
268
268
  subcommand 'init', Legion::CLI::Init
269
269
 
270
+ desc 'detect SUBCOMMAND', 'Scan environment and recommend extensions'
271
+ subcommand 'detect', Legion::CLI::Detect
272
+
270
273
  # --- Interactive & shortcuts ---
271
274
  desc 'knowledge SUBCOMMAND', 'Search and manage the document knowledge base'
272
275
  subcommand 'knowledge', Legion::CLI::Knowledge
@@ -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