legion-settings 1.3.26 → 1.4.0

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: a4d1e8b1f3f0f96ee8db9d11604d914fc253e430827e37f7e30cc244151dc75b
4
- data.tar.gz: 1ed13e9f16b97fc33acc72e484671794210ac4750aef5502fc86e685ec2f9324
3
+ metadata.gz: baf40211abee6a48e5863077ce8e31fa416afbad9f003933e7edcfca4b8a72ba
4
+ data.tar.gz: f8faf5fb5eadead569c9f222a70c58e5ac11bc923dc281bd587f3f2704125cd0
5
5
  SHA512:
6
- metadata.gz: 47ab18a67eef8c35d86e52e9deec5716916754e5a84e0faec1ba3bbed4f24e9f4bb5c7749268eee23c96056ce85ef11c47787ac2df06fa5b918365730ddf6926
7
- data.tar.gz: c76877c9e9a19f44c33a816a0580c265f721fe6dd0ef486eb06ec377ac6aedd5ae60fe52b4aaf4d8feb59bb18527f6127da40a0611d27718de3be63af5581b97
6
+ metadata.gz: 7dbe223a77152d41812ec94bd98457db0570dae8bdea879f58b4ec052fca03eed46d1364617d4affb73d4cd7bc0364e6f6074c5a0fe5d8c433776e900d354417
7
+ data.tar.gz: b362c5d3a541c5afce645b47656ef1a427b65d58500bd5100e14717451dacaa8024823a188fd97f2475cb2ab1e176912c30ed7ba0496f0f512dd79a593228401
data/.gitignore CHANGED
@@ -9,7 +9,8 @@
9
9
  /tmp/
10
10
  /legion/.idea/
11
11
  /.idea/
12
+ *.gem
12
13
  *.key
13
14
  # rspec failure tracking
14
15
  .rspec_status
15
- legionio.key
16
+ legionio.key
@@ -0,0 +1,29 @@
1
+ # Standard LegionIO pre-commit configuration
2
+ # Install: pre-commit install
3
+ # Manual: pre-commit run --all-files
4
+ repos:
5
+ - repo: https://github.com/pre-commit/pre-commit-hooks
6
+ rev: v5.0.0
7
+ hooks:
8
+ - id: trailing-whitespace
9
+ - id: end-of-file-fixer
10
+ - id: check-yaml
11
+ - id: check-json
12
+ exclude: Gemfile\.lock
13
+ - id: check-merge-conflict
14
+
15
+ - repo: local
16
+ hooks:
17
+ - id: rubocop
18
+ name: RuboCop (autofix)
19
+ entry: scripts/pre-commit-rubocop.sh
20
+ language: script
21
+ types: [ruby]
22
+ pass_filenames: true
23
+
24
+ - id: ruby-syntax
25
+ name: Ruby syntax check
26
+ entry: ruby -c
27
+ language: system
28
+ types: [ruby]
29
+ pass_filenames: true
data/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Legion::Settings Changelog
2
2
 
3
+ ## [1.4.0] - 2026-04-29
4
+
5
+ ### Added
6
+ - `Legion::Settings::Extensions` — thread-safe runtime registry for extensions, runners, and tools
7
+ - `register_extension`, `register_runner`, `register_tool` — registration methods called during LegionIO boot pipeline
8
+ - `transition(name, state)` — lifecycle state transitions (:discovered, :loaded, :running, :stopped)
9
+ - `extensions`, `runners`, `tools` — query methods returning frozen snapshots of all registered entries
10
+ - `find_extension`, `find_runner`, `find_tool` — lookup by name returning frozen copies
11
+ - `filter_tools(**criteria)` — filter by extension, deferred, sticky, mcp_tier, tags, category, state, source
12
+ - `filter_extensions(**criteria)` — filter by state, category, phase
13
+ - `unregister_extension(name)` — cascade-removes extension and its associated runners and tools
14
+ - `unregister_tool(name)` — remove a single tool
15
+ - `reset!` — clear all registries (for test cleanup)
16
+ - `extension_count`, `runner_count`, `tool_count` — convenience count methods
17
+ - All read operations return frozen duplicates to prevent mutation of registry internals
18
+ - Thread-safe reads and writes via `Concurrent::Map` without explicit locking
19
+
20
+ ## [1.3.27] - 2026-04-27
21
+
22
+ ### Added
23
+ - `Settings.reload!` — re-reads all previously loaded config files and re-resolves vault://, env://, and lease:// references; returns a hash of changed keys with old/new values; thread-safe via internal mutex
24
+ - `Settings.watch!` — registers a SIGHUP handler that triggers `reload!` in a background thread; optionally accepts a block for change notification
25
+ - `Settings.on_reload(&block)` — register callbacks invoked after `reload!` detects changes; multiple callbacks supported, called in order, rescue-safe
26
+ - Private `diff_settings` helper for deep comparison of old vs new config hashes
27
+ - Private `fire_reload_callbacks` for executing registered change callbacks
28
+
29
+ ### Fixed
30
+ - `reload!` preserves programmatic module merges and reapplies `.legionio.env` overrides to the reloaded settings loader
31
+ - `watch!` no-ops when SIGHUP is unavailable and coalesces repeated SIGHUP events through a single reload worker
32
+ - Replaced deprecated helper logging method calls with direct `log.debug/info/warn/error` usage
33
+
3
34
  ## [1.3.26] - 2026-04-02
4
35
 
5
36
  ### Changed
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Configuration management module for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Loads settings from JSON files, directories, and environment variables. Provides a unified `Legion::Settings[:key]` accessor used by all other Legion gems.
4
4
 
5
- **Version**: 1.3.26
5
+ **Version**: 1.3.27
6
6
 
7
7
  ## Installation
8
8
 
@@ -44,6 +44,51 @@ If a caller wants the canonical Legion search directories, use `Legion::Settings
44
44
 
45
45
  Each Legion module registers its own defaults via `merge_settings` during startup, and the nearest `.legionio.env` file is merged on top of base settings. Request overlays applied through `with_overlay` take highest precedence.
46
46
 
47
+ ### Hot Reload
48
+
49
+ `Legion::Settings.reload!` re-reads the config files that were previously loaded, reapplies module defaults and the nearest `.legionio.env`, re-resolves secret references, and returns a hash describing the changed keys.
50
+
51
+ ```ruby
52
+ changes = Legion::Settings.reload!
53
+
54
+ changes
55
+ # {
56
+ # "llm.default_model" => { old: "old-model", new: "new-model" }
57
+ # }
58
+ ```
59
+
60
+ Callbacks run only when changes are detected:
61
+
62
+ ```ruby
63
+ Legion::Settings.on_reload do |changes|
64
+ Legion::Settings.logger.info("Settings changed: #{changes.keys.join(', ')}")
65
+ end
66
+ ```
67
+
68
+ `watch!` installs a SIGHUP handler when the platform supports it. Repeated signals are coalesced through one background reload worker, so rapid SIGHUP bursts do not create unbounded reload threads.
69
+
70
+ ```ruby
71
+ Legion::Settings.watch! do |changes|
72
+ Legion::Settings.logger.info("Reloaded #{changes.size} setting(s)")
73
+ end
74
+
75
+ # Later, from a shell:
76
+ # kill -HUP <daemon_pid>
77
+ ```
78
+
79
+ On platforms without `HUP`, `watch!` logs and returns without raising. Direct `reload!` remains available for API endpoints, tests, or environments that use a different process-control mechanism.
80
+
81
+ ### Project Environment Overrides
82
+
83
+ When present, the nearest `.legionio.env` file is loaded after base settings and module defaults. Dot notation maps to nested settings:
84
+
85
+ ```dotenv
86
+ llm.default_model=claude-sonnet
87
+ cache.driver=redis
88
+ ```
89
+
90
+ Hot reload picks up changes to this file as part of the same `reload!` flow.
91
+
47
92
  ### Secret Resolution
48
93
 
49
94
  Settings values can reference external secret sources using URI syntax. Three schemes are supported:
@@ -26,6 +26,7 @@ Gem::Specification.new do |spec|
26
26
  'rubygems_mfa_required' => 'true'
27
27
  }
28
28
 
29
+ spec.add_dependency 'concurrent-ruby', '>= 1.2'
29
30
  spec.add_dependency 'legion-json', '>= 1.2.0'
30
31
  spec.add_dependency 'legion-logging', '>= 1.5.0'
31
32
  end
@@ -20,7 +20,7 @@ module Legion
20
20
  definition = load_file(path)
21
21
  next unless definition && valid?(definition)
22
22
 
23
- log_debug("Agent loaded: #{definition[:name]} (#{path})")
23
+ log.debug("Agent loaded: #{definition[:name]} (#{path})")
24
24
  definition.merge(_source_path: path, _source_mtime: File.mtime(path))
25
25
  end
26
26
  end
@@ -32,7 +32,7 @@ module Legion
32
32
  when '.json' then ::JSON.parse(content, symbolize_names: true)
33
33
  end
34
34
  rescue StandardError => e
35
- log_warn("Failed to parse agent file #{path}: #{e.message}")
35
+ log.warn("Failed to parse agent file #{path}: #{e.message}")
36
36
  nil
37
37
  end
38
38
 
@@ -51,14 +51,6 @@ module Legion
51
51
  raw_logging = Legion::Settings.loader&.settings&.dig(:logging) if Legion::Settings.respond_to?(:loader)
52
52
  raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default
53
53
  end
54
-
55
- def log_debug(message)
56
- log.debug(message)
57
- end
58
-
59
- def log_warn(message)
60
- log.warn(message)
61
- end
62
54
  end
63
55
  end
64
56
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/hash'
4
+
5
+ module Legion
6
+ module Settings
7
+ # Shared deep-merge logic used by Loader, Overlay, ProjectEnv, and
8
+ # the top-level Settings module. Consolidates four previously
9
+ # duplicated implementations into one place.
10
+ module DeepMerge
11
+ module_function
12
+
13
+ # Non-destructive deep merge. Returns a new hash with +override+
14
+ # values merged on top of +base+. Preserves Concurrent::Hash type
15
+ # when the base hash is one.
16
+ #
17
+ # @param base [Hash] the base hash
18
+ # @param override [Hash] values to merge on top
19
+ # @return [Hash]
20
+ def deep_merge(base, override)
21
+ merged = base.is_a?(Concurrent::Hash) ? Concurrent::Hash[base] : base.dup
22
+ override.each do |key, value|
23
+ existing = base[key]
24
+ merged[key] = if existing.is_a?(Hash) && value.is_a?(Hash)
25
+ deep_merge(existing, value)
26
+ elsif existing.is_a?(Array) && value.is_a?(Array)
27
+ (existing + value).uniq
28
+ else
29
+ value
30
+ end
31
+ end
32
+ merged
33
+ end
34
+
35
+ # In-place deep merge. Mutates +base+ with values from +override+.
36
+ # Nested hashes are merged recursively; scalars and arrays are replaced.
37
+ #
38
+ # @param base [Hash] the hash to mutate
39
+ # @param override [Hash] values to merge in
40
+ # @return [Hash] the mutated base hash
41
+ def deep_merge!(base, override)
42
+ override.each do |key, value|
43
+ if base[key].is_a?(Hash) && value.is_a?(Hash)
44
+ deep_merge!(base[key], value)
45
+ else
46
+ base[key] = value
47
+ end
48
+ end
49
+ base
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Settings
5
+ module Extensions
6
+ # Filter helpers for querying tools and extensions by criteria.
7
+ module Filter
8
+ module_function
9
+
10
+ # Filter tool entries by criteria.
11
+ #
12
+ # Supported criteria:
13
+ # - extension: [String, Symbol] filter by extension name
14
+ # - deferred: [Boolean] filter by deferred flag
15
+ # - sticky: [Boolean] filter by sticky flag
16
+ # - mcp_tier: [Integer] filter by MCP tier
17
+ # - tags: [Array<String>] match any tag
18
+ # - category: [String, Symbol] filter by mcp_category
19
+ # - state: [Symbol] filter tools whose extension is in this state
20
+ # - source: [Symbol] filter by source (:discovery, :manual, :static)
21
+ TOOL_EXACT_FILTERS = {
22
+ deferred: :deferred, sticky: :sticky, mcp_tier: :mcp_tier, source: :source
23
+ }.freeze
24
+
25
+ TOOL_NORMALIZED_FILTERS = {
26
+ extension: :extension, category: :mcp_category
27
+ }.freeze
28
+
29
+ def apply_tool_filters(entries, criteria, extension_store: nil)
30
+ result = entries.dup
31
+ apply_exact_tool_filters!(result, criteria)
32
+ apply_normalized_tool_filters!(result, criteria)
33
+ filter_by_tags!(result, criteria[:tags]) if criteria.key?(:tags)
34
+ filter_by_extension_state!(result, criteria[:state], extension_store) if criteria.key?(:state) && extension_store
35
+ result
36
+ end
37
+
38
+ def apply_exact_tool_filters!(result, criteria)
39
+ TOOL_EXACT_FILTERS.each do |criteria_key, entry_key|
40
+ next unless criteria.key?(criteria_key)
41
+
42
+ value = criteria[criteria_key]
43
+ result.select! { |t| t[entry_key] == value }
44
+ end
45
+ end
46
+
47
+ def apply_normalized_tool_filters!(result, criteria)
48
+ TOOL_NORMALIZED_FILTERS.each do |criteria_key, entry_key|
49
+ next unless criteria.key?(criteria_key)
50
+
51
+ value = normalize(criteria[criteria_key])
52
+ result.select! { |t| normalize(t[entry_key]) == value }
53
+ end
54
+ end
55
+
56
+ # Filter extension entries by criteria.
57
+ #
58
+ # Supported criteria:
59
+ # - state: [Symbol] filter by lifecycle state
60
+ # - data_required, cache_required, llm_required, etc.: [Boolean] filter by requirement flags
61
+ # - category: [String, Symbol] filter by category
62
+ # - phase: [Integer] filter by phase
63
+ EXTENSION_BOOLEAN_FILTERS = %i[
64
+ data_required cache_required transport_required crypt_required
65
+ vault_required llm_required skills_required remote_invocable
66
+ mcp_tools mcp_tools_deferred sticky_tools hot_reloadable
67
+ ].freeze
68
+
69
+ def apply_extension_filters(entries, criteria)
70
+ result = entries.dup
71
+ result.select! { |e| e[:state] == criteria[:state] } if criteria.key?(:state)
72
+ result.select! { |e| normalize(e[:category]) == normalize(criteria[:category]) } if criteria.key?(:category)
73
+ result.select! { |e| e[:phase] == criteria[:phase] } if criteria.key?(:phase)
74
+ apply_extension_boolean_filters!(result, criteria)
75
+ result
76
+ end
77
+
78
+ def apply_extension_boolean_filters!(result, criteria)
79
+ EXTENSION_BOOLEAN_FILTERS.each do |key|
80
+ next unless criteria.key?(key)
81
+
82
+ result.select! { |e| e[key] == criteria[key] }
83
+ end
84
+ end
85
+
86
+ def filter_by_tags!(result, tags)
87
+ tags = Array(tags).map(&:to_s)
88
+ result.select! { |t| Array(t[:tags]).map(&:to_s).intersect?(tags) }
89
+ end
90
+
91
+ def filter_by_extension_state!(result, state, extension_store)
92
+ result.select! do |t|
93
+ ext = extension_store.find(t[:extension])
94
+ ext && ext[:state] == state
95
+ end
96
+ end
97
+
98
+ def normalize(value)
99
+ value.to_s
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Settings
5
+ module Extensions
6
+ # Normalizes tool, runner, and extension entries into complete, known schemas.
7
+ #
8
+ # This is the single source of truth for what fields exist on each entry type.
9
+ # Every field that ANY consumer reads is defined here. Consumers can access
10
+ # any field without defensive nil-checks — absent values are explicitly nil,
11
+ # empty arrays, or empty hashes.
12
+ #
13
+ # If a new consumer needs a field, add it here — don't rely on passthrough.
14
+ module Normalizer
15
+ module_function
16
+
17
+ # -------------------------------------------------------------------
18
+ # Tool: generated from an exposed runner function or hand-authored
19
+ # -------------------------------------------------------------------
20
+ #
21
+ # Consumers:
22
+ # legion-llm: ToolDefinition wire format, executor tool loop, dispatcher
23
+ # legion-mcp: ToolAdapter, server registration, deferred registry
24
+ # legion-rbac: access control by extension/function
25
+ # LegionIO API: tool listing, diagnostics
26
+ def normalize_tool(name, metadata)
27
+ {
28
+ # Identity
29
+ name: name.to_s,
30
+ description: resolve_string(metadata, :description),
31
+ input_schema: resolve_schema(metadata),
32
+
33
+ # Execution
34
+ tool_class: metadata[:tool_class],
35
+ dispatch_type: resolve_dispatch_type(metadata),
36
+
37
+ # Back-references to owning extension/runner/function
38
+ extension: resolve_string(metadata, :extension) || resolve_string(metadata, :ext_name),
39
+ runner: resolve_string(metadata, :runner) || resolve_string(metadata, :runner_snake),
40
+ function: resolve_string(metadata, :function),
41
+
42
+ # Classification
43
+ deferred: metadata[:deferred] == true,
44
+ sticky: metadata.fetch(:sticky, true) == true,
45
+ mcp_tier: metadata[:mcp_tier],
46
+ mcp_category: resolve_string(metadata, :mcp_category),
47
+ trigger_words: Array(metadata[:trigger_words]).map(&:to_s),
48
+ tags: Array(metadata[:tags]).map(&:to_s),
49
+ source: metadata.fetch(:source, :unknown).to_sym,
50
+
51
+ # Confidence / override tracking (written by Tools::Confidence)
52
+ confidence: metadata[:confidence],
53
+ hit_count: metadata[:hit_count],
54
+ miss_count: metadata[:miss_count]
55
+ }
56
+ end
57
+
58
+ # -------------------------------------------------------------------
59
+ # Runner: a module on an extension that exposes callable functions
60
+ # -------------------------------------------------------------------
61
+ #
62
+ # Consumers:
63
+ # LegionIO Tools::Discovery: function synthesis, schema building
64
+ # legion-mcp runner_catalog: runner listing
65
+ # legion-mcp FunctionDiscovery: tool building from runner methods
66
+ def normalize_runner(name, metadata)
67
+ {
68
+ # Identity
69
+ name: name.to_s,
70
+ extension: resolve_string(metadata, :extension),
71
+ runner_module: resolve_string(metadata, :runner_module),
72
+
73
+ # Functions exposed by this runner
74
+ function: resolve_string(metadata, :function),
75
+ functions: metadata[:functions] || metadata[:class_methods] || {},
76
+ exposed: metadata.fetch(:exposed, true) == true,
77
+ definition: metadata[:definition],
78
+
79
+ # MCP/tool behavior inherited by functions on this runner
80
+ mcp_tools: metadata[:mcp_tools],
81
+ mcp_deferred: metadata[:mcp_deferred],
82
+ trigger_words: Array(metadata[:trigger_words]).map(&:to_s)
83
+ }
84
+ end
85
+
86
+ # -------------------------------------------------------------------
87
+ # Extension: a loaded LEX gem with runners, actors, and tools
88
+ # -------------------------------------------------------------------
89
+ #
90
+ # Consumers:
91
+ # LegionIO boot pipeline: phased loading, lifecycle management
92
+ # LegionIO HandleRegistry: state tracking, hot reload
93
+ # LegionIO Registry::Governance: approval, risk tier, naming
94
+ # LegionIO Registry::SecurityScanner: checksum, static analysis
95
+ # LegionIO Catalog::Available: static listing
96
+ # LegionIO API: extension listing, diagnostics
97
+ # legion-mcp: extension_info resource
98
+ # legion-llm: extension filter in tool queries
99
+ def normalize_extension(name, metadata) # rubocop:disable Metrics/AbcSize
100
+ segments = resolve_segments(name, metadata)
101
+ {
102
+ # Identity (derived from gem name via Helpers::Segments conventions)
103
+ name: name.to_s,
104
+ gem_name: resolve_string(metadata, :gem_name) || name.to_s,
105
+ description: resolve_string(metadata, :description),
106
+ version: resolve_string(metadata, :version),
107
+ const_path: resolve_string(metadata, :const_path),
108
+ segments: segments,
109
+ lex_name: resolve_string(metadata, :lex_name) || segments.join('_'),
110
+ lex_slug: resolve_string(metadata, :lex_slug) || segments.join('.'),
111
+ amqp_prefix: resolve_string(metadata, :amqp_prefix),
112
+ settings_path: resolve_string(metadata, :settings_path),
113
+ table_prefix: resolve_string(metadata, :table_prefix),
114
+
115
+ # Lifecycle state
116
+ state: metadata.fetch(:state, :discovered).to_sym,
117
+ loaded_at: metadata[:loaded_at],
118
+ last_error: metadata[:last_error],
119
+
120
+ # Boot classification
121
+ category: metadata[:category],
122
+ tier: metadata[:tier],
123
+ phase: metadata[:phase],
124
+
125
+ # Requirement flags — queryable WITHOUT loading the extension module.
126
+ # LegionIO boot checks these to skip extensions whose deps aren't ready.
127
+ # Defaults match Core module defaults so unset flags behave identically.
128
+ data_required: metadata.fetch(:data_required, false) == true,
129
+ cache_required: metadata.fetch(:cache_required, false) == true,
130
+ transport_required: metadata.fetch(:transport_required, true) == true,
131
+ crypt_required: metadata.fetch(:crypt_required, false) == true,
132
+ vault_required: metadata.fetch(:vault_required, false) == true,
133
+ llm_required: metadata.fetch(:llm_required, false) == true,
134
+ skills_required: metadata.fetch(:skills_required, false) == true,
135
+ remote_invocable: metadata.fetch(:remote_invocable, true) == true,
136
+
137
+ # Extension contents
138
+ runners: Array(metadata[:runners]),
139
+ actors: Array(metadata[:actors]),
140
+ tools: Array(metadata[:tools]),
141
+ absorbers: Array(metadata[:absorbers]),
142
+ routes: Array(metadata[:routes]),
143
+
144
+ # Gem metadata
145
+ spec: metadata[:spec],
146
+ gem_dir: resolve_string(metadata, :gem_dir),
147
+ active_version: resolve_string(metadata, :active_version),
148
+ latest_installed_version: resolve_string(metadata, :latest_installed_version),
149
+ loaded_features: Array(metadata[:loaded_features]),
150
+
151
+ # Reload support
152
+ reload_state: metadata.fetch(:reload_state, :idle),
153
+ hot_reloadable: metadata[:hot_reloadable] == true,
154
+
155
+ # Governance / security
156
+ author: resolve_string(metadata, :author),
157
+ risk_tier: resolve_string(metadata, :risk_tier),
158
+ airb_status: resolve_string(metadata, :airb_status),
159
+ permissions: Array(metadata[:permissions]),
160
+ checksum: resolve_string(metadata, :checksum),
161
+
162
+ # Tool behavior defaults
163
+ mcp_tools: metadata.fetch(:mcp_tools, true) == true,
164
+ mcp_tools_deferred: metadata.fetch(:mcp_tools_deferred, true) == true,
165
+ sticky_tools: metadata.fetch(:sticky_tools, true) == true,
166
+
167
+ # Extension settings — the complete declared configuration with
168
+ # effective runtime values (defaults merged with user overrides).
169
+ # Enables introspection: "what can I configure?" and "what's the
170
+ # current value?" without loading the extension module.
171
+ # Populated by LegionIO from default_settings merged with
172
+ # Legion::Settings[:extensions][:lex_name] at registration time.
173
+ settings_schema: metadata[:settings_schema] || {},
174
+ settings: metadata[:settings] || {}
175
+ }
176
+ end
177
+
178
+ # -------------------------------------------------------------------
179
+ # Schema resolution
180
+ # -------------------------------------------------------------------
181
+
182
+ def resolve_schema(metadata)
183
+ schema = metadata[:input_schema] || metadata[:parameters] || metadata[:params_schema]
184
+ schema.is_a?(Hash) ? schema : {}
185
+ end
186
+
187
+ # -------------------------------------------------------------------
188
+ # Dispatch type detection
189
+ # -------------------------------------------------------------------
190
+
191
+ def resolve_dispatch_type(metadata)
192
+ return metadata[:dispatch_type].to_sym if metadata[:dispatch_type]
193
+
194
+ tool_class = metadata[:tool_class]
195
+ return :runner if tool_class.nil? && metadata[:extension] && metadata[:function]
196
+ return :none unless tool_class
197
+
198
+ if tool_class.respond_to?(:new) && tool_class.method_defined?(:execute)
199
+ :instance
200
+ elsif tool_class.respond_to?(:call)
201
+ :class_call
202
+ else
203
+ :none
204
+ end
205
+ end
206
+
207
+ def resolve_string(metadata, key)
208
+ value = metadata[key]
209
+ value&.to_s
210
+ end
211
+
212
+ # Derive segments from the published gem name. No magic, no lookup tables.
213
+ #
214
+ # Rules (matching Ruby gem → module conventions):
215
+ # dash '-' = module boundary: lex-agentic-learning → ['agentic', 'learning'] → Agentic::Learning
216
+ # underscore '_' = CamelCase inside: lex-microsoft_teams → ['microsoft_teams'] → MicrosoftTeams
217
+ #
218
+ # Examples:
219
+ # lex-github → ['github'] → Legion::Extensions::Github
220
+ # lex-agentic-learning → ['agentic', 'learning'] → Legion::Extensions::Agentic::Learning
221
+ # lex-llm-openai → ['llm', 'openai'] → Legion::Extensions::Llm::Openai
222
+ # lex-llm-azure-foundry → ['llm', 'azure', 'foundry'] → Legion::Extensions::Llm::Azure::Foundry
223
+ # lex-llm-azure_foundry → ['llm', 'azure_foundry'] → Legion::Extensions::Llm::AzureFoundry
224
+ # lex-microsoft_teams → ['microsoft_teams'] → Legion::Extensions::MicrosoftTeams
225
+ def resolve_segments(name, metadata)
226
+ return Array(metadata[:segments]) if metadata[:segments]&.any?
227
+
228
+ gem = (metadata[:gem_name] || name).to_s
229
+ base = gem.start_with?('lex-') ? gem.sub(/\Alex-/, '') : gem
230
+ base.split('-')
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent/map'
4
+
5
+ module Legion
6
+ module Settings
7
+ module Extensions
8
+ # Thread-safe registry store backed by Concurrent::Map.
9
+ #
10
+ # Each store holds one type of entry (extensions, runners, or tools).
11
+ # Entries are plain hashes keyed by normalized string name.
12
+ # Read operations return frozen duplicates so callers cannot mutate internals.
13
+ class Store
14
+ def initialize
15
+ @map = Concurrent::Map.new
16
+ end
17
+
18
+ def register(name, metadata = {})
19
+ key = normalize_key(name)
20
+ entry = metadata.merge(name: key, registered_at: Time.now)
21
+ @map[key] = entry
22
+ entry.freeze
23
+ end
24
+
25
+ def find(name)
26
+ @map[normalize_key(name)]&.dup&.freeze
27
+ end
28
+
29
+ def all
30
+ snapshot = @map.values.map(&:dup)
31
+ snapshot.each(&:freeze)
32
+ snapshot.freeze
33
+ end
34
+
35
+ def filter(**_criteria, &block)
36
+ result = @map.values.map(&:dup)
37
+ result.select!(&block) if block
38
+ result.each(&:freeze)
39
+ result.freeze
40
+ end
41
+
42
+ def delete(name)
43
+ @map.delete(normalize_key(name))
44
+ end
45
+
46
+ def delete_where(&block)
47
+ @map.each_pair { |k, v| @map.delete(k) if block.call(v) }
48
+ end
49
+
50
+ def update(name, **extra)
51
+ key = normalize_key(name)
52
+ old_entry = @map[key]
53
+ return nil unless old_entry
54
+
55
+ updated = old_entry.dup.merge(extra.merge(updated_at: Time.now))
56
+ @map[key] = updated
57
+ updated.freeze
58
+ end
59
+
60
+ def size
61
+ @map.size
62
+ end
63
+
64
+ def any?
65
+ @map.size.positive?
66
+ end
67
+
68
+ def clear
69
+ @map.clear
70
+ end
71
+
72
+ private
73
+
74
+ def normalize_key(name)
75
+ name.to_s
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end