legionio 1.7.31 → 1.7.33

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: 962639f3a96de99418ba1cb7feb2ba06b488776df2c2c3a1ec7dfd0d353e6759
4
- data.tar.gz: 87f2e206fab455fac86e36efdc497ff67d4614bb0699be00eb146c764ef43797
3
+ metadata.gz: 839e777ef392bc9f9a005418901f90837a78e10dc16f4425788332f8098a449c
4
+ data.tar.gz: 81c74483a04d1e5ae46789b0648153df04e954b94415168063fb5831a67d83c5
5
5
  SHA512:
6
- metadata.gz: ef4a6788ec894fe2107c834c470fc469d866d36e445618bee17d6149738e2b31ce49c708619f0aaff12d140e799a92562e5383e9515f6d88e1ce15bbaeb9834c
7
- data.tar.gz: 3a8a85d88ba670d683c196de7e60c32f37650e3ce420869a5e7b781f0741dcadce758f6c42b6cdb71b61c32ca68ac23d05d1571b00f2b974423da8779d975b82
6
+ metadata.gz: 3828b5dc1f52bd1e1a1f6f1c63d5fe2fe148f019be8e24fdf58cc1c0c69c4788d99f64a119398d9d8209042e21ba47f48a24a9b8692227b5851113825dc7d3c1
7
+ data.tar.gz: 3e05508d0d71bbed405d18219f79e7e4745ab28be2e16755747822bc1ec0dae2b441b3810b6dd0c1ec42cef58ccfacea065e2b33588f1bc20d225c5e473f4415
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.7.33] - 2026-04-09
4
+
5
+ ### Added
6
+ - Phase 8 prerequisites: `Broker.lease_for(name)` returns raw Lease, `Broker.renewer_for(name)` returns LeaseRenewer
7
+ - `LeaseRenewer` now exposes `attr_reader :provider` for structured credential access
8
+ - Non-renewing registration path: static API key providers (expires_at: nil, renewable: false) stored in `Concurrent::AtomicReference` without background LeaseRenewer thread
9
+ - `Broker.refresh_credential(name)` for manual refresh of static credentials
10
+ - `Broker.providers` and `Broker.leases` include both dynamic and static registrations
11
+ - `register_provider_with_broker` in service.rb — winning auth provider auto-registered with Broker after identity resolution
12
+
13
+ ### Changed (Copilot review #126)
14
+ - Renamed extension catalog routes from `/api/extensions` to `/api/extension_catalog` to eliminate route conflict with LexDispatch's `GET /api/extensions/:lex_name/:component_type/:component_name/:method_name` wildcard
15
+ - Updated `GET /api/extension_catalog/available` (was `/api/extensions/available`)
16
+ - Updated OpenAPI spec paths and `list_extensions` chat tool to match new route prefix
17
+ - Froze individual entry hashes in `Catalog::Available::EXTENSIONS` via `.each(&:freeze).freeze`; `all`, `by_category`, and `find` now return dup copies to prevent caller mutation
18
+ - Added explicit `require 'legion/api/helpers'` and `require 'legion/api/extensions'` to `spec/legion/api/extensions_spec.rb` for deterministic spec loading
19
+ - Added `loader.settings[:data]`, `[:transport]`, and `[:extensions]` initialization to extensions spec `before(:all)` for isolation
20
+
21
+ ## [1.7.32] - 2026-04-09
22
+
23
+ ### Changed
24
+ - Rewrote `/api/extensions` routes to use in-memory state from `Catalog` instead of database queries — no `require_data!` dependency
25
+ - All extension routes now use `:name` (string identifier like `lex-node`) instead of numeric `:id` params
26
+ - Added `GET /api/extensions/available` route backed by `Catalog::Available.all` (static ecosystem list, filterable by `?category=`)
27
+ - Added `Legion::Extensions::Catalog::Available` module with 120+ known LEX gems organized by category
28
+ - Extension helper methods (`find_extension_module`, `find_runner_info`, `runner_summaries`, `halt_not_found`) moved into `Legion::API::Helpers` for reuse across all API tests
29
+
3
30
  ## [1.7.31] - 2026-04-08
4
31
 
5
32
  ### Added
@@ -5,87 +5,157 @@ module Legion
5
5
  module Routes
6
6
  module Extensions
7
7
  def self.registered(app)
8
+ register_available_route(app)
8
9
  register_extension_routes(app)
9
10
  register_runner_routes(app)
10
11
  register_function_routes(app)
12
+ register_invoke_route(app)
13
+ end
14
+
15
+ def self.register_available_route(app)
16
+ app.get '/api/extension_catalog/available' do
17
+ entries = Legion::Extensions::Catalog::Available.all
18
+ entries = entries.select { |e| e[:category] == params[:category] } if params[:category]
19
+ json_response(entries)
20
+ end
11
21
  end
12
22
 
13
23
  def self.register_extension_routes(app)
14
- app.get '/api/extensions' do
15
- require_data!
16
- dataset = Legion::Data::Model::Extension.order(:id)
17
- dataset = dataset.where(active: true) if params[:active] == 'true'
18
- json_collection(dataset)
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
30
+ entries = entries.select { |e| e[:state] == params[:state] } if params[:state]
31
+ json_response(entries)
19
32
  end
20
33
 
21
- app.get '/api/extensions/:id' do
22
- require_data!
23
- ext = find_or_halt(Legion::Data::Model::Extension, params[:id])
24
- json_response(ext.values)
34
+ app.get '/api/extension_catalog/:name' do
35
+ name = params[:name]
36
+ entry = Legion::Extensions::Catalog.entry(name)
37
+ halt_not_found("extension '#{name}' not found") unless entry
38
+
39
+ ext_mod = find_extension_module(name)
40
+ version = ext_mod&.const_defined?(:VERSION) ? ext_mod::VERSION : nil
41
+
42
+ runners = ext_mod ? runner_summaries(ext_mod) : []
43
+
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)
25
52
  end
26
53
  end
27
54
 
28
55
  def self.register_runner_routes(app)
29
- app.get '/api/extensions/:id/runners' do
30
- require_data!
31
- find_or_halt(Legion::Data::Model::Extension, params[:id])
32
- runners = Legion::Data::Model::Runner.where(extension_id: params[:id].to_i).order(:id)
33
- json_collection(runners)
56
+ app.get '/api/extension_catalog/:name/runners' do
57
+ name = params[:name]
58
+ halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name)
59
+
60
+ ext_mod = find_extension_module(name)
61
+ halt_not_found("extension '#{name}' not loaded") unless ext_mod
62
+
63
+ json_response(runner_summaries(ext_mod))
34
64
  end
35
65
 
36
- app.get '/api/extensions/:id/runners/:runner_id' do
37
- require_data!
38
- find_or_halt(Legion::Data::Model::Extension, params[:id])
39
- runner = find_or_halt(Legion::Data::Model::Runner, params[:runner_id])
40
- json_response(runner.values)
66
+ app.get '/api/extension_catalog/:name/runners/:runner_name' do
67
+ name = params[:name]
68
+ halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name)
69
+
70
+ ext_mod = find_extension_module(name)
71
+ halt_not_found("extension '#{name}' not loaded") unless ext_mod
72
+
73
+ info = find_runner_info(ext_mod, params[:runner_name])
74
+ halt_not_found("runner '#{params[:runner_name]}' not found") unless info
75
+
76
+ runner_mod = info[:runner_module]
77
+ functions = runner_mod.instance_methods(false).map(&:to_s)
78
+
79
+ json_response({
80
+ name: info[:runner_name],
81
+ runner_class: info[:runner_class],
82
+ functions: functions
83
+ })
41
84
  end
42
85
  end
43
86
 
44
- def self.register_function_routes(app) # rubocop:disable Metrics/AbcSize
45
- app.get '/api/extensions/:id/runners/:runner_id/functions' do
46
- require_data!
47
- find_or_halt(Legion::Data::Model::Extension, params[:id])
48
- find_or_halt(Legion::Data::Model::Runner, params[:runner_id])
49
- functions = Legion::Data::Model::Function.where(runner_id: params[:runner_id].to_i).order(:id)
50
- json_collection(functions)
87
+ def self.register_function_routes(app)
88
+ app.get '/api/extension_catalog/:name/runners/:runner_name/functions' do
89
+ name = params[:name]
90
+ halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name)
91
+
92
+ ext_mod = find_extension_module(name)
93
+ halt_not_found("extension '#{name}' not loaded") unless ext_mod
94
+
95
+ info = find_runner_info(ext_mod, params[:runner_name])
96
+ halt_not_found("runner '#{params[:runner_name]}' not found") unless info
97
+
98
+ functions = info[:runner_module].instance_methods(false).map do |m|
99
+ args = info.dig(:class_methods, m, :args)
100
+ { name: m.to_s, args: args }
101
+ end
102
+ json_response(functions)
51
103
  end
52
104
 
53
- app.get '/api/extensions/:id/runners/:runner_id/functions/:function_id' do
54
- require_data!
55
- find_or_halt(Legion::Data::Model::Extension, params[:id])
56
- find_or_halt(Legion::Data::Model::Runner, params[:runner_id])
57
- func = find_or_halt(Legion::Data::Model::Function, params[:function_id])
58
- json_response(func.values)
105
+ app.get '/api/extension_catalog/:name/runners/:runner_name/functions/:function_name' do
106
+ name = params[:name]
107
+ halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name)
108
+
109
+ ext_mod = find_extension_module(name)
110
+ halt_not_found("extension '#{name}' not loaded") unless ext_mod
111
+
112
+ info = find_runner_info(ext_mod, params[:runner_name])
113
+ halt_not_found("runner '#{params[:runner_name]}' not found") unless info
114
+
115
+ func_sym = params[:function_name].to_sym
116
+ halt_not_found("function '#{params[:function_name]}' not found") unless info[:runner_module].method_defined?(func_sym, false)
117
+
118
+ args = info.dig(:class_methods, func_sym, :args)
119
+ json_response({ name: params[:function_name], runner: params[:runner_name], args: args })
59
120
  end
121
+ end
122
+
123
+ def self.register_invoke_route(app)
124
+ app.post '/api/extension_catalog/:name/runners/:runner_name/functions/:function_name/invoke' do
125
+ name = params[:name]
126
+ halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name)
127
+
128
+ ext_mod = find_extension_module(name)
129
+ halt_not_found("extension '#{name}' not loaded") unless ext_mod
130
+
131
+ info = find_runner_info(ext_mod, params[:runner_name])
132
+ halt_not_found("runner '#{params[:runner_name]}' not found") unless info
133
+
134
+ func_sym = params[:function_name].to_sym
135
+ halt_not_found("function '#{params[:function_name]}' not found") unless info[:runner_module].method_defined?(func_sym, false)
60
136
 
61
- app.post '/api/extensions/:id/runners/:runner_id/functions/:function_id/invoke' do
62
- require_data!
63
- path = "/api/extensions/#{params[:id]}/runners/#{params[:runner_id]}/functions/#{params[:function_id]}/invoke"
64
- Legion::Logging.debug "API: POST #{path} params=#{params.keys}"
65
- find_or_halt(Legion::Data::Model::Extension, params[:id])
66
- runner = find_or_halt(Legion::Data::Model::Runner, params[:runner_id])
67
- func = find_or_halt(Legion::Data::Model::Function, params[:function_id])
68
137
  body = parse_request_body
69
138
 
70
139
  result = Legion::Ingress.run(
71
- payload: body, runner_class: runner.values[:namespace],
72
- function: func.values[:name].to_sym, source: 'api',
140
+ payload: body,
141
+ runner_class: info[:runner_class],
142
+ function: func_sym,
143
+ source: 'api',
73
144
  check_subtask: body.fetch(:check_subtask, true),
74
145
  generate_task: body.fetch(:generate_task, true)
75
146
  )
76
- Legion::Logging.info "API: invoked function #{func.values[:name]} via runner #{runner.values[:namespace]}, task #{result[:task_id]}"
77
147
  json_response(result, status_code: 201)
78
148
  rescue NameError => e
79
- Legion::Logging.warn "API POST /api/extensions invoke returned 422: #{e.message}"
80
149
  json_error('invalid_runner', e.message, status_code: 422)
81
150
  rescue StandardError => e
82
- Legion::Logging.error "API POST /api/extensions invoke: #{e.class} #{e.message}"
151
+ Legion::Logging.error "API POST /api/extension_catalog invoke: #{e.class} - #{e.message}" if defined?(Legion::Logging)
83
152
  json_error('execution_error', e.message, status_code: 500)
84
153
  end
85
154
  end
86
155
 
87
156
  class << self
88
- private :register_extension_routes, :register_runner_routes, :register_function_routes
157
+ private :register_available_route, :register_extension_routes,
158
+ :register_runner_routes, :register_function_routes, :register_invoke_route
89
159
  end
90
160
  end
91
161
  end
@@ -101,6 +101,39 @@ module Legion
101
101
  halt 400, json_error('invalid_json', 'request body is not valid JSON', status_code: 400)
102
102
  end
103
103
 
104
+ def find_extension_module(lex_name)
105
+ short = lex_name.delete_prefix('lex-')
106
+ short_no_sep = short.tr('-', '_').delete('_')
107
+ Legion::Extensions.loaded_extension_modules.find do |mod|
108
+ parts = mod.name&.split('::')
109
+ mod_short = parts&.last&.downcase
110
+ mod_short == short.tr('-', '_') ||
111
+ mod_short == short.delete('-') ||
112
+ mod_short == short_no_sep
113
+ end
114
+ end
115
+
116
+ def find_runner_info(ext_mod, runner_name)
117
+ return nil unless ext_mod.respond_to?(:runners)
118
+
119
+ ext_mod.runners.values.find do |r|
120
+ r[:runner_name].to_s.downcase == runner_name.downcase
121
+ end
122
+ end
123
+
124
+ def runner_summaries(ext_mod)
125
+ return [] unless ext_mod.respond_to?(:runners)
126
+
127
+ ext_mod.runners.values.map do |r|
128
+ functions = r[:runner_module]&.instance_methods(false)&.map(&:to_s) || []
129
+ { name: r[:runner_name], runner_class: r[:runner_class], functions: functions }
130
+ end
131
+ end
132
+
133
+ def halt_not_found(message)
134
+ halt 404, json_error('not_found', message, status_code: 404)
135
+ end
136
+
104
137
  def find_or_halt(model_class, id)
105
138
  record = model_class[id.to_i]
106
139
  halt 404, json_error('not_found', "#{model_class.name.split('::').last} #{id} not found", status_code: 404) if record.nil?
@@ -177,22 +177,23 @@ module Legion
177
177
  }
178
178
  },
179
179
  schemas: {
180
- Meta: META_SCHEMA,
181
- MetaCollection: META_COLLECTION_SCHEMA,
182
- ErrorResponse: ERROR_SCHEMA,
183
- DeletedResponse: deleted_response_schema,
184
- TaskObject: task_object_schema,
185
- TaskInput: task_input_schema,
186
- ExtensionObject: extension_object_schema,
187
- RunnerObject: runner_object_schema,
188
- FunctionObject: function_object_schema,
189
- NodeObject: node_object_schema,
190
- ScheduleObject: schedule_object_schema,
191
- ScheduleInput: schedule_input_schema,
192
- RelationshipObject: stub_object_schema('Relationship'),
193
- ChainObject: stub_object_schema('Chain'),
194
- WorkerObject: worker_object_schema,
195
- WorkerInput: worker_input_schema
180
+ Meta: META_SCHEMA,
181
+ MetaCollection: META_COLLECTION_SCHEMA,
182
+ ErrorResponse: ERROR_SCHEMA,
183
+ DeletedResponse: deleted_response_schema,
184
+ TaskObject: task_object_schema,
185
+ TaskInput: task_input_schema,
186
+ ExtensionObject: extension_object_schema,
187
+ RunnerObject: runner_object_schema,
188
+ FunctionObject: function_object_schema,
189
+ AvailableExtensionObject: available_extension_object_schema,
190
+ NodeObject: node_object_schema,
191
+ ScheduleObject: schedule_object_schema,
192
+ ScheduleInput: schedule_input_schema,
193
+ RelationshipObject: stub_object_schema('Relationship'),
194
+ ChainObject: stub_object_schema('Chain'),
195
+ WorkerObject: worker_object_schema,
196
+ WorkerInput: worker_input_schema
196
197
  }
197
198
  }
198
199
  end
@@ -240,11 +241,12 @@ module Legion
240
241
  {
241
242
  type: 'object',
242
243
  properties: {
243
- id: { type: 'integer' },
244
- name: { type: 'string' },
245
- namespace: { type: 'string' },
246
- active: { type: 'boolean' },
247
- version: { type: 'string', nullable: true }
244
+ name: { type: 'string' },
245
+ state: { type: 'string' },
246
+ version: { type: 'string', nullable: true },
247
+ registered_at: { type: 'string', format: 'date-time', nullable: true },
248
+ started_at: { type: 'string', format: 'date-time', nullable: true },
249
+ runners: { type: 'array', items: { '$ref' => '#/components/schemas/RunnerObject' } }
248
250
  }
249
251
  }
250
252
  end
@@ -254,10 +256,9 @@ module Legion
254
256
  {
255
257
  type: 'object',
256
258
  properties: {
257
- id: { type: 'integer' },
258
- extension_id: { type: 'integer' },
259
259
  name: { type: 'string' },
260
- namespace: { type: 'string' }
260
+ runner_class: { type: 'string' },
261
+ functions: { type: 'array', items: { type: 'string' } }
261
262
  }
262
263
  }
263
264
  end
@@ -267,14 +268,26 @@ module Legion
267
268
  {
268
269
  type: 'object',
269
270
  properties: {
270
- id: { type: 'integer' },
271
- runner_id: { type: 'integer' },
272
- name: { type: 'string' }
271
+ name: { type: 'string' },
272
+ runner: { type: 'string' },
273
+ args: { type: 'object', nullable: true }
273
274
  }
274
275
  }
275
276
  end
276
277
  private_class_method :function_object_schema
277
278
 
279
+ def self.available_extension_object_schema
280
+ {
281
+ type: 'object',
282
+ properties: {
283
+ name: { type: 'string' },
284
+ category: { type: 'string' },
285
+ description: { type: 'string' }
286
+ }
287
+ }
288
+ end
289
+ private_class_method :available_extension_object_schema
290
+
278
291
  def self.node_object_schema
279
292
  {
280
293
  type: 'object',
@@ -370,6 +383,17 @@ module Legion
370
383
 
371
384
  # --- route path builders ---
372
385
 
386
+ def self.wrap_array(schema_ref)
387
+ {
388
+ type: 'object',
389
+ properties: {
390
+ data: { type: 'array', items: { '$ref' => "#/components/schemas/#{schema_ref}" } },
391
+ meta: { '$ref' => '#/components/schemas/Meta' }
392
+ }
393
+ }
394
+ end
395
+ private_class_method :wrap_array
396
+
373
397
  def self.wrap_data(schema_ref)
374
398
  {
375
399
  type: 'object',
@@ -520,28 +544,42 @@ module Legion
520
544
 
521
545
  def self.extension_paths
522
546
  {
523
- '/api/extensions' => {
547
+ '/api/extension_catalog' => {
524
548
  get: {
525
549
  tags: ['Extensions'],
526
- summary: 'List extensions',
550
+ summary: 'List loaded extensions',
527
551
  operationId: 'listExtensions',
528
- parameters: PAGINATION_PARAMS + [
529
- { name: 'active', in: 'query', description: 'Filter to active extensions only', required: false,
530
- schema: { type: 'boolean' } }
552
+ parameters: [
553
+ { name: 'state', in: 'query', description: 'Filter by extension state (e.g. running)', required: false,
554
+ schema: { type: 'string' } }
531
555
  ],
532
556
  responses: {
533
- '200' => ok_response('Extension list', wrap_collection('ExtensionObject')),
534
- '401' => UNAUTH_RESPONSE,
535
- '503' => { description: 'legion-data not connected' }
557
+ '200' => ok_response('Extension list', wrap_array('ExtensionObject')),
558
+ '401' => UNAUTH_RESPONSE
536
559
  }
537
560
  }
538
561
  },
539
- '/api/extensions/{id}' => {
562
+ '/api/extension_catalog/available' => {
540
563
  get: {
541
564
  tags: ['Extensions'],
542
- summary: 'Get extension by ID',
565
+ summary: 'List all available extensions in the ecosystem registry',
566
+ operationId: 'listAvailableExtensions',
567
+ parameters: [
568
+ { name: 'category', in: 'query', description: 'Filter by category (core, ai, agentic, identity, service, other)',
569
+ required: false, schema: { type: 'string' } }
570
+ ],
571
+ responses: {
572
+ '200' => ok_response('Available extension list', wrap_array('AvailableExtensionObject')),
573
+ '401' => UNAUTH_RESPONSE
574
+ }
575
+ }
576
+ },
577
+ '/api/extension_catalog/{name}' => {
578
+ get: {
579
+ tags: ['Extensions'],
580
+ summary: 'Get extension by name',
543
581
  operationId: 'getExtension',
544
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
582
+ parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
545
583
  responses: {
546
584
  '200' => ok_response('Extension detail', wrap_data('ExtensionObject')),
547
585
  '401' => UNAUTH_RESPONSE,
@@ -549,27 +587,27 @@ module Legion
549
587
  }
550
588
  }
551
589
  },
552
- '/api/extensions/{id}/runners' => {
590
+ '/api/extension_catalog/{name}/runners' => {
553
591
  get: {
554
592
  tags: ['Extensions'],
555
593
  summary: 'List runners for extension',
556
594
  operationId: 'listExtensionRunners',
557
- parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }] + PAGINATION_PARAMS,
595
+ parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }],
558
596
  responses: {
559
- '200' => ok_response('Runner list', wrap_collection('RunnerObject')),
597
+ '200' => ok_response('Runner list', wrap_array('RunnerObject')),
560
598
  '401' => UNAUTH_RESPONSE,
561
599
  '404' => NOT_FOUND_RESPONSE
562
600
  }
563
601
  }
564
602
  },
565
- '/api/extensions/{id}/runners/{runner_id}' => {
603
+ '/api/extension_catalog/{name}/runners/{runner_name}' => {
566
604
  get: {
567
605
  tags: ['Extensions'],
568
- summary: 'Get runner by ID',
606
+ summary: 'Get runner by name',
569
607
  operationId: 'getExtensionRunner',
570
608
  parameters: [
571
- { name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
572
- { name: 'runner_id', in: 'path', required: true, schema: { type: 'integer' } }
609
+ { name: 'name', in: 'path', required: true, schema: { type: 'string' } },
610
+ { name: 'runner_name', in: 'path', required: true, schema: { type: 'string' } }
573
611
  ],
574
612
  responses: {
575
613
  '200' => ok_response('Runner detail', wrap_data('RunnerObject')),
@@ -578,31 +616,31 @@ module Legion
578
616
  }
579
617
  }
580
618
  },
581
- '/api/extensions/{id}/runners/{runner_id}/functions' => {
619
+ '/api/extension_catalog/{name}/runners/{runner_name}/functions' => {
582
620
  get: {
583
621
  tags: ['Extensions'],
584
622
  summary: 'List functions for runner',
585
623
  operationId: 'listRunnerFunctions',
586
624
  parameters: [
587
- { name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
588
- { name: 'runner_id', in: 'path', required: true, schema: { type: 'integer' } }
589
- ] + PAGINATION_PARAMS,
625
+ { name: 'name', in: 'path', required: true, schema: { type: 'string' } },
626
+ { name: 'runner_name', in: 'path', required: true, schema: { type: 'string' } }
627
+ ],
590
628
  responses: {
591
- '200' => ok_response('Function list', wrap_collection('FunctionObject')),
629
+ '200' => ok_response('Function list', wrap_array('FunctionObject')),
592
630
  '401' => UNAUTH_RESPONSE,
593
631
  '404' => NOT_FOUND_RESPONSE
594
632
  }
595
633
  }
596
634
  },
597
- '/api/extensions/{id}/runners/{runner_id}/functions/{function_id}' => {
635
+ '/api/extension_catalog/{name}/runners/{runner_name}/functions/{function_name}' => {
598
636
  get: {
599
637
  tags: ['Extensions'],
600
- summary: 'Get function by ID',
638
+ summary: 'Get function by name',
601
639
  operationId: 'getRunnerFunction',
602
640
  parameters: [
603
- { name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
604
- { name: 'runner_id', in: 'path', required: true, schema: { type: 'integer' } },
605
- { name: 'function_id', in: 'path', required: true, schema: { type: 'integer' } }
641
+ { name: 'name', in: 'path', required: true, schema: { type: 'string' } },
642
+ { name: 'runner_name', in: 'path', required: true, schema: { type: 'string' } },
643
+ { name: 'function_name', in: 'path', required: true, schema: { type: 'string' } }
606
644
  ],
607
645
  responses: {
608
646
  '200' => ok_response('Function detail', wrap_data('FunctionObject')),
@@ -611,15 +649,15 @@ module Legion
611
649
  }
612
650
  }
613
651
  },
614
- '/api/extensions/{id}/runners/{runner_id}/functions/{function_id}/invoke' => {
652
+ '/api/extension_catalog/{name}/runners/{runner_name}/functions/{function_name}/invoke' => {
615
653
  post: {
616
654
  tags: ['Extensions'],
617
655
  summary: 'Invoke a function directly',
618
656
  operationId: 'invokeFunction',
619
657
  parameters: [
620
- { name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
621
- { name: 'runner_id', in: 'path', required: true, schema: { type: 'integer' } },
622
- { name: 'function_id', in: 'path', required: true, schema: { type: 'integer' } }
658
+ { name: 'name', in: 'path', required: true, schema: { type: 'string' } },
659
+ { name: 'runner_name', in: 'path', required: true, schema: { type: 'string' } },
660
+ { name: 'function_name', in: 'path', required: true, schema: { type: 'string' } }
623
661
  ],
624
662
  requestBody: {
625
663
  required: false,
@@ -18,19 +18,19 @@ module Legion
18
18
  description 'List loaded Legion extensions and their runners/functions. ' \
19
19
  'Use this to discover what capabilities are available, what extensions are active, ' \
20
20
  'and what tasks can be triggered through the framework.'
21
- param :extension_id, type: 'integer',
22
- desc: 'Show runners for a specific extension ID (optional)', required: false
23
- param :active_only, type: 'string',
24
- desc: 'Set to "true" to show only active extensions (default: all)', required: false
21
+ param :extension_name, type: 'string',
22
+ desc: 'Show runners for a specific extension by name (e.g. lex-node)', required: false
23
+ param :state, type: 'string',
24
+ desc: 'Filter by state (e.g. "running"). Default: all', required: false
25
25
 
26
26
  DEFAULT_PORT = 4567
27
27
  DEFAULT_HOST = '127.0.0.1'
28
28
 
29
- def execute(extension_id: nil, active_only: nil)
30
- if extension_id
31
- fetch_extension_detail(extension_id)
29
+ def execute(extension_name: nil, state: nil)
30
+ if extension_name
31
+ fetch_extension_detail(extension_name)
32
32
  else
33
- fetch_extension_list(active_only)
33
+ fetch_extension_list(state)
34
34
  end
35
35
  rescue Errno::ECONNREFUSED
36
36
  'Legion daemon not running (cannot query extensions API).'
@@ -41,9 +41,9 @@ module Legion
41
41
 
42
42
  private
43
43
 
44
- def fetch_extension_list(active_only)
45
- path = '/api/extensions'
46
- path += '?active=true' if active_only == 'true'
44
+ def fetch_extension_list(state)
45
+ path = '/api/extension_catalog'
46
+ path += "?state=#{state}" if state
47
47
  data = api_get(path)
48
48
  return "API error: #{data[:error]}" if data[:error]
49
49
 
@@ -54,37 +54,35 @@ module Legion
54
54
  format_list(extensions)
55
55
  end
56
56
 
57
- def fetch_extension_detail(ext_id)
58
- ext_data = api_get("/api/extensions/#{ext_id}")
59
- runners_data = api_get("/api/extensions/#{ext_id}/runners")
60
-
57
+ def fetch_extension_detail(name)
58
+ ext_data = api_get("/api/extension_catalog/#{name}")
61
59
  return "API error: #{ext_data[:error]}" if ext_data[:error]
62
60
 
61
+ runners_data = api_get("/api/extension_catalog/#{name}/runners")
63
62
  runners = runners_data[:data] || runners_data[:items] || runners_data
64
63
  runners = [runners] if runners.is_a?(Hash)
65
64
  runners = [] unless runners.is_a?(Array)
66
65
 
67
- format_detail(ext_data, runners)
66
+ format_detail(ext_data[:data] || ext_data, runners)
68
67
  end
69
68
 
70
69
  def format_list(extensions)
71
70
  lines = ["Loaded Extensions (#{extensions.size}):\n"]
72
71
  extensions.each do |ext|
73
- status = ext[:active] ? 'active' : 'inactive'
74
- lines << " #{ext[:id]}. #{ext[:name]} (#{status})"
72
+ lines << " #{ext[:name]} (#{ext[:state]})"
75
73
  end
76
74
  lines.join("\n")
77
75
  end
78
76
 
79
77
  def format_detail(ext, runners)
80
- lines = ["Extension: #{ext[:name]} (id: #{ext[:id]})\n"]
81
- lines << " Status: #{ext[:active] ? 'active' : 'inactive'}"
82
- lines << " Namespace: #{ext[:namespace]}" if ext[:namespace]
78
+ lines = ["Extension: #{ext[:name]}\n"]
79
+ lines << " State: #{ext[:state]}"
80
+ lines << " Version: #{ext[:version]}" if ext[:version]
83
81
 
84
82
  if runners.any?
85
83
  lines << "\n Runners (#{runners.size}):"
86
84
  runners.each do |r|
87
- lines << " #{r[:id]}. #{r[:name] || r[:namespace]}"
85
+ lines << " #{r[:name]} (#{r[:runner_class]})"
88
86
  end
89
87
  else
90
88
  lines << "\n No runners registered."
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Catalog
6
+ module Available
7
+ EXTENSIONS = [
8
+ # core
9
+ { name: 'lex-acp', category: 'core', description: 'Agent communication protocol' },
10
+ { name: 'lex-audit', category: 'core', description: 'Audit logging and trail' },
11
+ { name: 'lex-codegen', category: 'core', description: 'Code generation pipeline' },
12
+ { name: 'lex-conditioner', category: 'core', description: 'Task chain conditioning' },
13
+ { name: 'lex-detect', category: 'core', description: 'Environment detection and recommendations' },
14
+ { name: 'lex-exec', category: 'core', description: 'Shell command execution' },
15
+ { name: 'lex-health', category: 'core', description: 'Health monitoring and metrics' },
16
+ { name: 'lex-lex', category: 'core', description: 'Extension management' },
17
+ { name: 'lex-llm-gateway', category: 'core', description: 'LLM gateway and routing' },
18
+ { name: 'lex-llm-ledger', category: 'core', description: 'LLM cost and usage ledger' },
19
+ { name: 'lex-log', category: 'core', description: 'Log shipping and aggregation' },
20
+ { name: 'lex-metering', category: 'core', description: 'Resource metering and accounting' },
21
+ { name: 'lex-node', category: 'core', description: 'Node identity and registration' },
22
+ { name: 'lex-ping', category: 'core', description: 'Connectivity checks' },
23
+ { name: 'lex-react', category: 'core', description: 'Event-driven reaction engine' },
24
+ { name: 'lex-scheduler', category: 'core', description: 'Cron and interval scheduling' },
25
+ { name: 'lex-synapse', category: 'core', description: 'Agent-to-agent relationships' },
26
+ { name: 'lex-tasker', category: 'core', description: 'Task management and lifecycle' },
27
+ { name: 'lex-telemetry', category: 'core', description: 'OpenTelemetry tracing integration' },
28
+ { name: 'lex-transformer', category: 'core', description: 'Task chain transformation' },
29
+ { name: 'lex-webhook', category: 'core', description: 'Inbound webhook receiver' },
30
+ # ai
31
+ { name: 'lex-azure-ai', category: 'ai', description: 'Azure OpenAI provider integration' },
32
+ { name: 'lex-bedrock', category: 'ai', description: 'AWS Bedrock LLM provider integration' },
33
+ { name: 'lex-claude', category: 'ai', description: 'Anthropic Claude provider integration' },
34
+ { name: 'lex-foundry', category: 'ai', description: 'Azure AI Foundry provider integration' },
35
+ { name: 'lex-gemini', category: 'ai', description: 'Google Gemini provider integration' },
36
+ { name: 'lex-ollama', category: 'ai', description: 'Ollama local LLM provider integration' },
37
+ { name: 'lex-openai', category: 'ai', description: 'OpenAI provider integration' },
38
+ { name: 'lex-xai', category: 'ai', description: 'xAI Grok provider integration' },
39
+ # agentic
40
+ { name: 'lex-agentic-affect', category: 'agentic', description: 'Affective state modeling' },
41
+ { name: 'lex-agentic-attention', category: 'agentic', description: 'Attentional focus and salience' },
42
+ { name: 'lex-agentic-defense', category: 'agentic', description: 'Defensive behavior and threat response' },
43
+ { name: 'lex-agentic-executive', category: 'agentic', description: 'Executive function and planning' },
44
+ { name: 'lex-agentic-homeostasis', category: 'agentic', description: 'Internal state regulation' },
45
+ { name: 'lex-agentic-imagination', category: 'agentic', description: 'Generative imagination and hypothesis' },
46
+ { name: 'lex-agentic-inference', category: 'agentic', description: 'Probabilistic inference engine' },
47
+ { name: 'lex-agentic-integration', category: 'agentic', description: 'Cross-domain knowledge integration' },
48
+ { name: 'lex-agentic-language', category: 'agentic', description: 'Natural language understanding' },
49
+ { name: 'lex-agentic-learning', category: 'agentic', description: 'Online learning and adaptation' },
50
+ { name: 'lex-agentic-memory', category: 'agentic', description: 'Long-term memory and recall' },
51
+ { name: 'lex-agentic-self', category: 'agentic', description: 'Self-model and identity' },
52
+ { name: 'lex-agentic-social', category: 'agentic', description: 'Social cognition and theory of mind' },
53
+ { name: 'lex-adapter', category: 'agentic', description: 'Protocol and format adaptation' },
54
+ { name: 'lex-apollo', category: 'agentic', description: 'Shared knowledge store client' },
55
+ { name: 'lex-autofix', category: 'agentic', description: 'Autonomous code fix pipeline' },
56
+ { name: 'lex-coldstart', category: 'agentic', description: 'Bootstrap knowledge ingestion' },
57
+ { name: 'lex-cost-scanner', category: 'agentic', description: 'Cloud cost scanning and analysis' },
58
+ { name: 'lex-dataset', category: 'agentic', description: 'Dataset management and versioning' },
59
+ { name: 'lex-eval', category: 'agentic', description: 'LLM evaluation framework' },
60
+ { name: 'lex-extinction', category: 'agentic', description: 'Worker lifecycle termination' },
61
+ { name: 'lex-factory', category: 'agentic', description: 'Spec-to-code generation pipeline' },
62
+ { name: 'lex-finops', category: 'agentic', description: 'FinOps cost optimization' },
63
+ { name: 'lex-governance', category: 'agentic', description: 'Policy and compliance governance' },
64
+ { name: 'lex-knowledge', category: 'agentic', description: 'Corpus ingestion and knowledge query' },
65
+ { name: 'lex-mesh', category: 'agentic', description: 'Agent mesh and preference exchange' },
66
+ { name: 'lex-mind-growth', category: 'agentic', description: 'Autonomous cognitive expansion' },
67
+ { name: 'lex-onboard', category: 'agentic', description: 'New agent onboarding workflow' },
68
+ { name: 'lex-pilot-infra-monitor', category: 'agentic', description: 'Infrastructure monitoring pilot' },
69
+ { name: 'lex-pilot-knowledge-assist', category: 'agentic', description: 'Knowledge assist pilot worker' },
70
+ { name: 'lex-privatecore', category: 'agentic', description: 'Private execution enclave' },
71
+ { name: 'lex-prompt', category: 'agentic', description: 'Prompt management and versioning' },
72
+ { name: 'lex-swarm', category: 'agentic', description: 'Multi-agent swarm orchestration' },
73
+ { name: 'lex-swarm-github', category: 'agentic', description: 'GitHub code review swarm' },
74
+ { name: 'lex-tick', category: 'agentic', description: 'Gaia tick cycle driver' },
75
+ # identity
76
+ { name: 'lex-identity-approle', category: 'identity', description: 'Vault AppRole identity provider' },
77
+ { name: 'lex-identity-aws', category: 'identity', description: 'AWS IAM identity provider' },
78
+ { name: 'lex-identity-entra', category: 'identity', description: 'Microsoft Entra identity provider' },
79
+ { name: 'lex-identity-github', category: 'identity', description: 'GitHub App identity provider' },
80
+ { name: 'lex-identity-kerberos', category: 'identity', description: 'Kerberos identity provider' },
81
+ { name: 'lex-identity-kubernetes', category: 'identity', description: 'Kubernetes service account identity provider' },
82
+ { name: 'lex-identity-ldap', category: 'identity', description: 'LDAP identity provider' },
83
+ { name: 'lex-identity-system', category: 'identity', description: 'System identity provider' },
84
+ # service integrations
85
+ { name: 'lex-consul', category: 'service', description: 'HashiCorp Consul service mesh integration' },
86
+ { name: 'lex-github', category: 'service', description: 'GitHub API integration' },
87
+ { name: 'lex-http', category: 'service', description: 'Generic HTTP client runner' },
88
+ { name: 'lex-kerberos', category: 'service', description: 'Kerberos authentication integration' },
89
+ { name: 'lex-microsoft_teams', category: 'service', description: 'Microsoft Teams messaging integration' },
90
+ { name: 'lex-nomad', category: 'service', description: 'HashiCorp Nomad job integration' },
91
+ { name: 'lex-redis', category: 'service', description: 'Redis integration' },
92
+ { name: 'lex-s3', category: 'service', description: 'AWS S3 object storage integration' },
93
+ { name: 'lex-tfe', category: 'service', description: 'Terraform Enterprise integration' },
94
+ { name: 'lex-uais', category: 'service', description: 'UHG AI Services integration' },
95
+ { name: 'lex-vault', category: 'service', description: 'HashiCorp Vault secrets integration' },
96
+ # other integrations
97
+ { name: 'lex-aha', category: 'other', description: 'Aha! roadmap integration' },
98
+ { name: 'lex-chef', category: 'other', description: 'Chef infrastructure automation' },
99
+ { name: 'lex-cloudflare', category: 'other', description: 'Cloudflare DNS and CDN integration' },
100
+ { name: 'lex-discord', category: 'other', description: 'Discord messaging integration' },
101
+ { name: 'lex-dns', category: 'other', description: 'DNS query and management' },
102
+ { name: 'lex-docker', category: 'other', description: 'Docker container integration' },
103
+ { name: 'lex-dynatrace', category: 'other', description: 'Dynatrace APM integration' },
104
+ { name: 'lex-elastic_app_search', category: 'other', description: 'Elastic App Search integration' },
105
+ { name: 'lex-elasticsearch', category: 'other', description: 'Elasticsearch integration' },
106
+ { name: 'lex-gitlab', category: 'other', description: 'GitLab integration' },
107
+ { name: 'lex-google-calendar', category: 'other', description: 'Google Calendar integration' },
108
+ { name: 'lex-grafana', category: 'other', description: 'Grafana dashboard integration' },
109
+ { name: 'lex-home-assistant', category: 'other', description: 'Home Assistant smart home integration' },
110
+ { name: 'lex-influxdb', category: 'other', description: 'InfluxDB time series integration' },
111
+ { name: 'lex-infoblox', category: 'other', description: 'Infoblox IPAM/DNS integration' },
112
+ { name: 'lex-jenkins', category: 'other', description: 'Jenkins CI/CD integration' },
113
+ { name: 'lex-jfrog', category: 'other', description: 'JFrog Artifactory integration' },
114
+ { name: 'lex-jira', category: 'other', description: 'Jira issue tracking integration' },
115
+ { name: 'lex-kafka', category: 'other', description: 'Apache Kafka messaging integration' },
116
+ { name: 'lex-kubernetes', category: 'other', description: 'Kubernetes cluster integration' },
117
+ { name: 'lex-lambda', category: 'other', description: 'AWS Lambda function integration' },
118
+ { name: 'lex-memcached', category: 'other', description: 'Memcached cache integration' },
119
+ { name: 'lex-mongodb', category: 'other', description: 'MongoDB integration' },
120
+ { name: 'lex-mqtt', category: 'other', description: 'MQTT IoT messaging integration' },
121
+ { name: 'lex-openweathermap', category: 'other', description: 'OpenWeatherMap weather integration' },
122
+ { name: 'lex-pagerduty', category: 'other', description: 'PagerDuty alerting integration' },
123
+ { name: 'lex-pihole', category: 'other', description: 'Pi-hole DNS filtering integration' },
124
+ { name: 'lex-postgres', category: 'other', description: 'PostgreSQL database integration' },
125
+ { name: 'lex-prometheus', category: 'other', description: 'Prometheus metrics integration' },
126
+ { name: 'lex-pushbullet', category: 'other', description: 'Pushbullet notification integration' },
127
+ { name: 'lex-pushover', category: 'other', description: 'Pushover notification integration' },
128
+ { name: 'lex-sftp', category: 'other', description: 'SFTP file transfer integration' },
129
+ { name: 'lex-slack', category: 'other', description: 'Slack messaging integration' },
130
+ { name: 'lex-sleepiq', category: 'other', description: 'SleepIQ bed sensor integration' },
131
+ { name: 'lex-smtp', category: 'other', description: 'SMTP email integration' },
132
+ { name: 'lex-sonos', category: 'other', description: 'Sonos audio integration' },
133
+ { name: 'lex-sqs', category: 'other', description: 'AWS SQS queue integration' },
134
+ { name: 'lex-ssh', category: 'other', description: 'SSH remote execution integration' },
135
+ { name: 'lex-telegram', category: 'other', description: 'Telegram messaging integration' },
136
+ { name: 'lex-todoist', category: 'other', description: 'Todoist task management integration' },
137
+ { name: 'lex-twilio', category: 'other', description: 'Twilio SMS/voice integration' },
138
+ { name: 'lex-wled', category: 'other', description: 'WLED LED controller integration' }
139
+ ].each(&:freeze).freeze
140
+
141
+ class << self
142
+ def all
143
+ EXTENSIONS.map(&:dup)
144
+ end
145
+
146
+ def by_category(category)
147
+ EXTENSIONS.select { |e| e[:category] == category }.map(&:dup)
148
+ end
149
+
150
+ def find(name)
151
+ entry = EXTENSIONS.find { |e| e[:name] == name }
152
+ entry&.dup
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'catalog/registry'
4
+ require_relative 'catalog/available'
4
5
 
5
6
  module Legion
6
7
  module Extensions
@@ -9,18 +9,25 @@ module Legion
9
9
 
10
10
  class << self
11
11
  def token_for(provider_name)
12
- renewer = renewers[provider_name.to_sym]
13
- return nil unless renewer
14
-
15
- lease = renewer.current_lease
12
+ lease = lease_for(provider_name)
16
13
  lease&.valid? ? lease.token : nil
17
14
  end
18
15
 
19
- def credentials_for(provider_name, service: nil)
20
- renewer = renewers[provider_name.to_sym]
21
- return nil unless renewer
16
+ def lease_for(provider_name)
17
+ name = provider_name.to_sym
18
+ renewer = renewers[name]
19
+ return renewer.current_lease if renewer
22
20
 
23
- lease = renewer.current_lease
21
+ static_ref = static_leases[name]
22
+ static_ref&.get
23
+ end
24
+
25
+ def renewer_for(provider_name)
26
+ renewers[provider_name.to_sym]
27
+ end
28
+
29
+ def credentials_for(provider_name, service: nil)
30
+ lease = lease_for(provider_name)
24
31
  return nil unless lease&.valid?
25
32
 
26
33
  { token: lease.token, provider: provider_name.to_sym, service: service, lease: lease }
@@ -28,12 +35,37 @@ module Legion
28
35
 
29
36
  def register_provider(provider_name, provider:, lease:)
30
37
  name = provider_name.to_sym
38
+
31
39
  renewers[name]&.stop!
32
- renewers[name] = LeaseRenewer.new(
33
- provider_name: name,
34
- provider: provider,
35
- lease: lease
36
- )
40
+ if lease&.expires_at.nil? && !lease&.renewable
41
+ # Static credential — store without a background renewal thread
42
+ renewers.delete(name)
43
+ static_leases[name] = Concurrent::AtomicReference.new(lease)
44
+ providers_map[name] = provider
45
+ else
46
+ # Dynamic credential — create LeaseRenewer
47
+ static_leases.delete(name)
48
+ renewers[name] = LeaseRenewer.new(
49
+ provider_name: name,
50
+ provider: provider,
51
+ lease: lease
52
+ )
53
+ end
54
+ end
55
+
56
+ def refresh_credential(provider_name)
57
+ name = provider_name.to_sym
58
+ ref = static_leases[name]
59
+ return false unless ref
60
+
61
+ provider = providers_map[name]
62
+ return false unless provider.respond_to?(:provide_token)
63
+
64
+ new_lease = provider.provide_token
65
+ return false unless new_lease&.valid?
66
+
67
+ ref.set(new_lease)
68
+ true
37
69
  end
38
70
 
39
71
  def authenticated?
@@ -77,11 +109,13 @@ module Legion
77
109
  end
78
110
 
79
111
  def providers
80
- renewers.keys
112
+ (renewers.keys + static_leases.keys).uniq
81
113
  end
82
114
 
83
115
  def leases
84
- renewers.transform_values { |r| r.current_lease&.to_h }
116
+ dynamic = renewers.transform_values { |r| r.current_lease&.to_h }
117
+ static = static_leases.transform_values { |ref| ref.get&.to_h }
118
+ dynamic.merge(static)
85
119
  end
86
120
 
87
121
  def shutdown
@@ -91,6 +125,8 @@ module Legion
91
125
  nil
92
126
  end
93
127
  renewers.clear
128
+ static_leases.clear
129
+ providers_map.clear
94
130
  end
95
131
 
96
132
  def reset!
@@ -105,6 +141,14 @@ module Legion
105
141
  @renewers ||= Concurrent::Hash.new
106
142
  end
107
143
 
144
+ def static_leases
145
+ @static_leases ||= Concurrent::Hash.new
146
+ end
147
+
148
+ def providers_map
149
+ @providers_map ||= Concurrent::Hash.new
150
+ end
151
+
108
152
  def fetch_groups
109
153
  process_groups = Identity::Process.identity_hash[:groups]
110
154
  return process_groups if process_groups && !process_groups.empty?
@@ -5,7 +5,7 @@ require 'concurrent'
5
5
  module Legion
6
6
  module Identity
7
7
  class LeaseRenewer
8
- attr_reader :provider_name
8
+ attr_reader :provider_name, :provider
9
9
 
10
10
  BACKOFF_SLEEP = 5
11
11
  MIN_SLEEP = 1
@@ -1107,6 +1107,11 @@ module Legion
1107
1107
  identity = future.value
1108
1108
  Legion::Identity::Process.bind!(provider, identity)
1109
1109
  log.info "[Identity] resolved via #{provider.class.name}: #{identity[:canonical_name]}"
1110
+
1111
+ # Phase 8: Register winning auth provider with Broker so extensions can
1112
+ # call Broker.token_for(:provider_name) without managing tokens themselves.
1113
+ register_provider_with_broker(provider)
1114
+
1110
1115
  true
1111
1116
  else
1112
1117
  false
@@ -1119,6 +1124,19 @@ module Legion
1119
1124
  pool&.kill unless pool&.wait_for_termination(2)
1120
1125
  end
1121
1126
 
1127
+ def register_provider_with_broker(provider)
1128
+ return unless provider.respond_to?(:provide_token) && defined?(Legion::Identity::Broker)
1129
+
1130
+ lease = provider.provide_token
1131
+ return unless lease
1132
+
1133
+ provider_name = provider.respond_to?(:provider_name) ? provider.provider_name : provider.class.name.to_sym
1134
+ Legion::Identity::Broker.register_provider(provider_name, provider: provider, lease: lease)
1135
+ log.info "[Identity] registered provider #{provider_name} with Broker"
1136
+ rescue StandardError => e
1137
+ handle_exception(e, level: :warn, operation: 'service.register_provider_with_broker')
1138
+ end
1139
+
1122
1140
  def find_identity_providers
1123
1141
  return [] unless defined?(Legion::Extensions)
1124
1142
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.7.31'
4
+ VERSION = '1.7.33'
5
5
  end
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.7.31
4
+ version: 1.7.33
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -818,6 +818,7 @@ files:
818
818
  - lib/legion/extensions/builders/runners.rb
819
819
  - lib/legion/extensions/capability.rb
820
820
  - lib/legion/extensions/catalog.rb
821
+ - lib/legion/extensions/catalog/available.rb
821
822
  - lib/legion/extensions/catalog/registry.rb
822
823
  - lib/legion/extensions/core.rb
823
824
  - lib/legion/extensions/data.rb