legion-settings 1.3.27 → 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: 6a096c46e4b074a4e77c104f21b1dc89b953ad15370e514ccd7face8cf8a7b8f
4
- data.tar.gz: 06475bdc9bf41c6219e2c61fef17006346bb57b833a9a6c992a02f1d1178047a
3
+ metadata.gz: baf40211abee6a48e5863077ce8e31fa416afbad9f003933e7edcfca4b8a72ba
4
+ data.tar.gz: f8faf5fb5eadead569c9f222a70c58e5ac11bc923dc281bd587f3f2704125cd0
5
5
  SHA512:
6
- metadata.gz: 30cc986020bd6c2f4c783193f65fb07b935e08ddadd1a00608a90001a8312da19e88612e99449bc283ae4d716d119f22517c37809670f50e854a8658502c645f
7
- data.tar.gz: 7d41547be02a86b3afc065e8865dbe16c02ebde7b592bd7f9a83585b22d7f07cfc1fe078eb171b754f2398d9fce9942410e8b9b999e744a73ee6abf378ada1bb
6
+ metadata.gz: 7dbe223a77152d41812ec94bd98457db0570dae8bdea879f58b4ec052fca03eed46d1364617d4affb73d4cd7bc0364e6f6074c5a0fe5d8c433776e900d354417
7
+ data.tar.gz: b362c5d3a541c5afce645b47656ef1a427b65d58500bd5100e14717451dacaa8024823a188fd97f2475cb2ab1e176912c30ed7ba0496f0f512dd79a593228401
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
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
+
3
20
  ## [1.3.27] - 2026-04-27
4
21
 
5
22
  ### Added
@@ -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
@@ -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
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'extensions/store'
4
+ require_relative 'extensions/filter'
5
+ require_relative 'extensions/normalizer'
6
+
7
+ module Legion
8
+ module Settings
9
+ # Thread-safe runtime registry for extensions, runners, and tools.
10
+ #
11
+ # Used by the LegionIO boot pipeline to register discovered extensions,
12
+ # their runner modules, and individual tools. Consumers (legion-mcp,
13
+ # legion-llm, legion-rbac, API) read from this registry at runtime.
14
+ #
15
+ # Each store is a Concurrent::Map-backed Store instance. Read operations
16
+ # return frozen duplicates so callers cannot mutate registry internals.
17
+ module Extensions
18
+ @extension_store = Store.new
19
+ @runner_store = Store.new
20
+ @tool_store = Store.new
21
+
22
+ class << self
23
+ # ----------------------------------------------------------------
24
+ # Registration (called during LegionIO boot pipeline)
25
+ # ----------------------------------------------------------------
26
+
27
+ def register_extension(name, metadata = {})
28
+ normalized = Normalizer.normalize_extension(name, metadata)
29
+ @extension_store.register(name, normalized)
30
+ end
31
+
32
+ def register_runner(name, metadata = {})
33
+ normalized = Normalizer.normalize_runner(name, metadata)
34
+ @runner_store.register(name, normalized)
35
+ end
36
+
37
+ def register_tool(name, metadata = {})
38
+ normalized = Normalizer.normalize_tool(name, metadata)
39
+ @tool_store.register(name, normalized)
40
+ end
41
+
42
+ def transition(name, state, **extra)
43
+ @extension_store.update(name, state: state, transitioned_at: Time.now, **extra)
44
+ end
45
+
46
+ # ----------------------------------------------------------------
47
+ # Query (called by legion-mcp, legion-llm, legion-rbac, API)
48
+ # ----------------------------------------------------------------
49
+
50
+ def extensions
51
+ @extension_store.all
52
+ end
53
+
54
+ def runners
55
+ @runner_store.all
56
+ end
57
+
58
+ def tools
59
+ @tool_store.all
60
+ end
61
+
62
+ def find_extension(name)
63
+ @extension_store.find(name)
64
+ end
65
+
66
+ def find_runner(name)
67
+ @runner_store.find(name)
68
+ end
69
+
70
+ def find_tool(name)
71
+ @tool_store.find(name)
72
+ end
73
+
74
+ def filter_tools(**criteria)
75
+ entries = @tool_store.all.map(&:dup)
76
+ result = Filter.apply_tool_filters(entries, criteria, extension_store: @extension_store)
77
+ result.each(&:freeze)
78
+ result.freeze
79
+ end
80
+
81
+ def filter_extensions(**criteria)
82
+ entries = @extension_store.all.map(&:dup)
83
+ result = Filter.apply_extension_filters(entries, criteria)
84
+ result.each(&:freeze)
85
+ result.freeze
86
+ end
87
+
88
+ # ----------------------------------------------------------------
89
+ # Lifecycle
90
+ # ----------------------------------------------------------------
91
+
92
+ def unregister_extension(name)
93
+ removed = @extension_store.delete(name)
94
+ return nil unless removed
95
+
96
+ key = name.to_s
97
+ @runner_store.delete_where { |v| v[:extension].to_s == key }
98
+ @tool_store.delete_where { |v| v[:extension].to_s == key }
99
+ removed
100
+ end
101
+
102
+ def unregister_tool(name)
103
+ @tool_store.delete(name)
104
+ end
105
+
106
+ def reset!
107
+ @extension_store.clear
108
+ @runner_store.clear
109
+ @tool_store.clear
110
+ end
111
+
112
+ # ----------------------------------------------------------------
113
+ # Counts
114
+ # ----------------------------------------------------------------
115
+
116
+ def extension_count
117
+ @extension_store.size
118
+ end
119
+
120
+ def runner_count
121
+ @runner_store.size
122
+ end
123
+
124
+ def tool_count
125
+ @tool_store.size
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -1,41 +1,119 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent/hash'
4
+
3
5
  module Legion
4
6
  module Settings
5
7
  module Helper
8
+ # Namespace boundary words — segment extraction stops at these.
9
+ # Matches LegionIO's Extensions::Helpers::Base::NAMESPACE_BOUNDARIES.
10
+ NAMESPACE_BOUNDARIES = %w[Actor Actors Runners Helpers Transport Data].freeze
11
+
12
+ # Returns the gem-level settings hash for this extension.
13
+ # Sub-modules (ConceptualBlending inside Agentic::Language) get
14
+ # the SAME hash as the root — they access their section via key:
15
+ # settings[:conceptual_blending]
16
+ #
17
+ # Path resolution uses segments derived from the class namespace:
18
+ # Legion::Extensions::Github → Settings[:extensions][:github]
19
+ # Legion::Extensions::Agentic::Learning → Settings[:extensions][:agentic][:learning]
20
+ # Legion::Extensions::MicrosoftTeams → Settings[:extensions][:microsoft_teams]
21
+ # Legion::Extensions::Llm::Openai → Settings[:extensions][:llm][:openai]
6
22
  def settings
7
- ext_key = derive_settings_key
8
- if Legion::Settings[:extensions]&.key?(ext_key)
9
- Legion::Settings[:extensions][ext_key]
10
- else
11
- {}
12
- end
23
+ segments = derive_settings_segments
24
+ dig_or_create(Legion::Settings[:extensions], segments)
13
25
  end
14
26
 
15
27
  private
16
28
 
17
- def derive_settings_key
18
- if respond_to?(:lex_filename)
19
- fname = lex_filename
20
- (fname.is_a?(Array) ? fname.first : fname).to_sym
21
- else
22
- derive_settings_key_from_class
23
- end
29
+ # Derives the gem-level segments from the class namespace.
30
+ # Stops at NAMESPACE_BOUNDARIES so sub-modules (Runners, Actors, etc.)
31
+ # resolve to their parent extension, not deeper.
32
+ #
33
+ # Legion::Extensions::Agentic::Learning::ConceptualBlending::Runners::Blend
34
+ # → ['agentic', 'learning'] (stops at ConceptualBlending because next is Runners)
35
+ #
36
+ # Legion::Extensions::Agentic::Learning::ConceptualBlending
37
+ # → ['agentic', 'learning'] (stops at ConceptualBlending — it's a sub-module, not a segment)
38
+ #
39
+ # Wait — ConceptualBlending IS a namespace part, not a boundary word.
40
+ # The gem is lex-agentic-learning → segments are ['agentic', 'learning'].
41
+ # ConceptualBlending is INSIDE the gem, not part of the gem name.
42
+ # So we need to know the gem's segment count to stop there.
43
+ #
44
+ # Strategy: if the caller responds to :segments (LegionIO's Base mixin),
45
+ # use those directly. Otherwise derive from namespace, stopping at
46
+ # boundary words or after 2 levels (covers most lex-X-Y patterns).
47
+ def derive_settings_segments
48
+ # Prefer explicit segments from LegionIO's Helpers::Base
49
+ return segments.map { |s| s.to_s.to_sym } if respond_to?(:segments)
50
+
51
+ derive_segments_from_class
24
52
  end
25
53
 
26
- def derive_settings_key_from_class
54
+ def derive_segments_from_class
27
55
  name = respond_to?(:ancestors) ? ancestors.first.to_s : self.class.to_s
28
56
  parts = name.split('::')
29
57
  ext_idx = parts.index('Extensions')
30
- target = if ext_idx && parts[ext_idx + 1]
31
- parts[ext_idx + 1]
32
- else
33
- parts.last
34
- end
35
- target.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
36
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
37
- .downcase
38
- .to_sym
58
+ return [camelize_to_snake(parts.last).to_sym] unless ext_idx
59
+
60
+ segment_parts = []
61
+ ((ext_idx + 1)...parts.length).each do |i|
62
+ break if NAMESPACE_BOUNDARIES.include?(parts[i])
63
+
64
+ segment_parts << camelize_to_snake(parts[i]).to_sym
65
+ end
66
+
67
+ # The gem-level segments are the parts between Extensions:: and
68
+ # the first sub-module that isn't part of the gem name.
69
+ # For lex-agentic-learning, gem segments = [:agentic, :learning].
70
+ # ConceptualBlending is a sub-module INSIDE the gem.
71
+ # We use the registered extension entry to find the correct depth,
72
+ # falling back to all segments if no registry entry exists.
73
+ resolve_gem_segments(segment_parts)
74
+ end
75
+
76
+ def resolve_gem_segments(all_segments)
77
+ return all_segments if all_segments.length <= 1
78
+
79
+ # Check if Settings::Extensions has this extension registered
80
+ # with known segments — use those as the authoritative gem boundary.
81
+ if defined?(Legion::Settings::Extensions)
82
+ # Try progressively shorter segment paths to find the registered gem
83
+ all_segments.length.downto(1) do |len|
84
+ candidate = all_segments[0, len]
85
+ gem_name = "lex-#{candidate.join('-')}"
86
+ entry = Legion::Settings::Extensions.find_extension(gem_name)
87
+ return candidate if entry
88
+ end
89
+ end
90
+
91
+ all_segments
92
+ end
93
+
94
+ # Digs into a nested hash using segments as keys, creating
95
+ # Concurrent::Hash at each level if missing.
96
+ def dig_or_create(root, segments)
97
+ return Concurrent::Hash.new unless root.is_a?(Hash)
98
+
99
+ segments.reduce(root) do |current, key|
100
+ if current.is_a?(Hash) && current.key?(key)
101
+ current[key]
102
+ elsif current.is_a?(Hash)
103
+ empty = Concurrent::Hash.new
104
+ current[key] = empty
105
+ empty
106
+ else
107
+ return Concurrent::Hash.new
108
+ end
109
+ end
110
+ end
111
+
112
+ def camelize_to_snake(str)
113
+ str.to_s
114
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
115
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
116
+ .downcase
39
117
  end
40
118
  end
41
119
  end
@@ -4,9 +4,11 @@ require 'resolv'
4
4
  require 'socket'
5
5
  require 'digest'
6
6
  require 'tmpdir'
7
+ require 'concurrent/hash'
7
8
  require 'legion/logging'
8
9
  require 'legion/settings/os'
9
10
  require_relative 'dns_bootstrap'
11
+ require_relative 'deep_merge'
10
12
 
11
13
  module Legion
12
14
  module Settings
@@ -47,7 +49,7 @@ module Legion
47
49
  def dns_defaults
48
50
  resolv_config = read_resolv_config
49
51
  {
50
- fqdn: detect_fqdn,
52
+ fqdn: nil, # lazy — resolved on first access via resolve_fqdn!
51
53
  default_domain: resolv_config[:search_domains]&.first,
52
54
  search_domains: resolv_config[:search_domains] || [],
53
55
  nameservers: resolv_config[:nameservers] || [],
@@ -55,6 +57,15 @@ module Legion
55
57
  }
56
58
  end
57
59
 
60
+ # Lazily resolve the FQDN on first access instead of blocking at init.
61
+ #
62
+ # @return [String, nil] the fully qualified domain name, or nil
63
+ def resolve_fqdn!
64
+ return @settings[:dns][:fqdn] if @settings.dig(:dns, :fqdn)
65
+
66
+ @settings[:dns][:fqdn] = detect_fqdn
67
+ end
68
+
58
69
  def client_defaults
59
70
  {
60
71
  hostname: system_hostname,
@@ -64,64 +75,25 @@ module Legion
64
75
  }
65
76
  end
66
77
 
67
- def logging_defaults
68
- {
69
- level: 'info',
70
- format: 'text',
71
- log_file: './legionio/logs/legion.log',
72
- log_stdout: true,
73
- trace: true,
74
- async: true,
75
- include_pid: false,
76
- transport: {
77
- enabled: true,
78
- forward_logs: true,
79
- forward_exceptions: true
80
- }
81
- }
82
- end
83
-
84
- def absorbers_defaults
85
- {
86
- enabled: true,
87
- max_depth: 5,
88
- sources: {
89
- meetings: {
90
- enabled: true,
91
- include_chat: true,
92
- include_files: true,
93
- retention_days: 90,
94
- min_duration_min: 5
95
- },
96
- email_inbox: {
97
- enabled: false,
98
- folder: 'inbox',
99
- max_age_days: 30
100
- },
101
- github: {
102
- enabled: true,
103
- events: %w[pull_request issues]
104
- },
105
- files: {
106
- enabled: true,
107
- watch_dirs: [],
108
- extensions: %w[pdf docx txt md pptx rtf]
109
- }
110
- }
111
- }
112
- end
78
+ # No more per-module defaults methods in the Loader.
79
+ # Tier 1 deps (json, logging) are called directly in default_settings.
80
+ # Tier 2 libraries (transport, cache, etc.) self-register via
81
+ # Legion::Settings.register_library when they load.
113
82
 
114
83
  def default_settings
115
84
  {
85
+ # --- Tier 1: gemspec dependencies (always installed with legion-settings) ---
86
+ # legion-logging: always available, has Settings.default
87
+ logging: Legion::Logging::Settings.default,
88
+ # legion-json: always available, no Settings module yet — stub
89
+ # until legion-json adds Legion::JSON::Settings.default
90
+ json: Concurrent::Hash.new,
91
+
92
+ # --- Structural: owned by legion-settings itself ---
116
93
  client: client_defaults,
117
- cluster: { public_keys: {} },
118
- crypt: {
119
- cluster_secret: nil,
120
- cluster_secret_timeout: 5,
121
- vault: { connected: false }
122
- },
123
- cache: { enabled: true, connected: false, driver: 'dalli' },
124
- extensions: {
94
+ cluster: Concurrent::Hash.new,
95
+ dns: dns_defaults,
96
+ extensions: Concurrent::Hash[
125
97
  core: %w[
126
98
  lex-node lex-tasker lex-scheduler lex-health lex-ping
127
99
  lex-telemetry lex-metering lex-log lex-audit
@@ -140,20 +112,25 @@ module Legion
140
112
  reserved_words: %w[transport cache crypt data settings json logging llm rbac legion],
141
113
  agentic: { allowed: nil, blocked: [] },
142
114
  parallel_pool_size: 24
143
- },
115
+ ],
144
116
  reload: false,
145
117
  reloading: false,
146
118
  auto_install_missing_lex: true,
147
119
  default_extension_settings: {},
148
- logging: logging_defaults,
149
- absorbers: absorbers_defaults,
150
- transport: { connected: false },
151
- data: { connected: false },
152
120
  role: { profile: nil, extensions: [] },
153
121
  region: { current: nil, primary: nil, failover: nil, peers: [],
154
122
  default_affinity: 'any', data_residency: {} },
155
123
  process: { role: 'full' },
156
- dns: dns_defaults
124
+
125
+ # --- Tier 2: stubs for libraries that self-register via register_library ---
126
+ # These ensure Settings[:key] returns a hash (not nil) before
127
+ # the owning library loads. The library replaces these with its
128
+ # full defaults when it calls Legion::Settings.register_library.
129
+ absorbers: Concurrent::Hash.new,
130
+ cache: Concurrent::Hash.new,
131
+ crypt: Concurrent::Hash.new,
132
+ data: Concurrent::Hash.new,
133
+ transport: Concurrent::Hash.new
157
134
  }
158
135
  end
159
136
 
@@ -165,12 +142,26 @@ module Legion
165
142
  @settings
166
143
  end
167
144
 
145
+ # Direct key lookup — does NOT trigger indifferent_access! rebuild.
146
+ # This is the hot path called by every Settings[:key] access.
147
+ # Supports both symbol and string keys without converting the whole tree.
168
148
  def [](key)
169
- to_hash[key]
149
+ result = @settings[key]
150
+ return result unless result.nil? && key.is_a?(String)
151
+
152
+ @settings[key.to_sym]
170
153
  end
171
154
 
155
+ # Direct nested lookup — does NOT trigger indifferent_access! rebuild.
172
156
  def dig(*keys)
173
- to_hash.dig(*keys)
157
+ keys.reduce(self) do |current, key|
158
+ return nil unless current.respond_to?(:[])
159
+
160
+ value = current.is_a?(Loader) ? current[key] : (current[key] || current[key.to_s])
161
+ return nil if value.nil? && !current.is_a?(Loader)
162
+
163
+ value
164
+ end
174
165
  end
175
166
 
176
167
  def []=(key, value)
@@ -376,29 +367,15 @@ module Legion
376
367
 
377
368
  def read_config_file(file)
378
369
  contents = File.read(file).dup
379
- if contents.respond_to?(:force_encoding)
380
- encoding = ::Encoding::ASCII_8BIT
381
- contents = contents.force_encoding(encoding)
382
- bom = (+"\xEF\xBB\xBF").force_encoding(encoding)
383
- contents.sub!(bom, '')
384
- else
385
- contents.sub!(/^\357\273\277/, '')
386
- end
370
+ encoding = ::Encoding::ASCII_8BIT
371
+ contents = contents.force_encoding(encoding)
372
+ bom = (+"\xEF\xBB\xBF").force_encoding(encoding)
373
+ contents.sub!(bom, '')
387
374
  contents.strip
388
375
  end
389
376
 
390
377
  def deep_merge(hash_one, hash_two)
391
- merged = hash_one.dup
392
- hash_two.each do |key, value|
393
- merged[key] = if hash_one[key].is_a?(Hash) && value.is_a?(Hash)
394
- deep_merge(hash_one[key], value)
395
- elsif hash_one[key].is_a?(Array) && value.is_a?(Array)
396
- hash_one[key].concat(value).uniq
397
- else
398
- value
399
- end
400
- end
401
- merged
378
+ DeepMerge.deep_merge(hash_one, hash_two)
402
379
  end
403
380
 
404
381
  def create_loaded_tempfile!
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/settings/deep_merge'
4
+
3
5
  module Legion
4
6
  module Settings
5
7
  # Thread-local request-scoped settings overlay.
@@ -38,6 +40,13 @@ module Legion
38
40
  Thread.current[THREAD_KEY]
39
41
  end
40
42
 
43
+ # Returns true when a thread-local overlay is active.
44
+ #
45
+ # @return [Boolean]
46
+ def active?
47
+ !Thread.current[THREAD_KEY].nil?
48
+ end
49
+
41
50
  # Clear the thread-local overlay for the current thread.
42
51
  def clear_overlay!
43
52
  Thread.current[THREAD_KEY] = nil
@@ -61,16 +70,7 @@ module Legion
61
70
  private
62
71
 
63
72
  def deep_merge(base, overrides)
64
- result = base.dup
65
- overrides.each do |key, value|
66
- existing = result[key]
67
- result[key] = if existing.is_a?(Hash) && value.is_a?(Hash)
68
- deep_merge(existing, value)
69
- else
70
- value
71
- end
72
- end
73
- result
73
+ DeepMerge.deep_merge(base, overrides)
74
74
  end
75
75
  end
76
76
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'legion/logging'
4
+ require 'legion/settings/deep_merge'
4
5
 
5
6
  module Legion
6
7
  module Settings
@@ -106,14 +107,7 @@ module Legion
106
107
  end
107
108
 
108
109
  def deep_merge_into!(base, overrides)
109
- overrides.each do |key, value|
110
- if base[key].is_a?(Hash) && value.is_a?(Hash)
111
- deep_merge_into!(base[key], value)
112
- else
113
- base[key] = value
114
- end
115
- end
116
- base
110
+ DeepMerge.deep_merge!(base, overrides)
117
111
  end
118
112
  end
119
113
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module Settings
5
- VERSION = '1.3.27'
5
+ VERSION = '1.4.0'
6
6
  end
7
7
  end
@@ -10,6 +10,8 @@ require 'legion/settings/validation_error'
10
10
  require 'legion/settings/helper'
11
11
  require 'legion/settings/overlay'
12
12
  require 'legion/settings/project_env'
13
+ require 'legion/settings/extensions'
14
+ require 'legion/settings/deep_merge'
13
15
 
14
16
  module Legion
15
17
  module Settings
@@ -56,6 +58,10 @@ module Legion
56
58
  def [](key)
57
59
  logger.info('Legion::Settings was not loaded, auto-loading now') if @loader.nil?
58
60
  load if @loader.nil?
61
+
62
+ # Fast path: skip overlay resolution when no overlay is active
63
+ return @loader[key] unless Overlay.active?
64
+
59
65
  overlay_val = Overlay.overlay_for(key)
60
66
  base_val = @loader[key]
61
67
  if overlay_val.is_a?(Hash) && base_val.is_a?(Hash)
@@ -97,7 +103,22 @@ module Legion
97
103
  thing[key.to_sym] = hash
98
104
  @loader.load_module_settings(thing)
99
105
  schema.register(key.to_sym, hash)
100
- validate_module_on_merge(key.to_sym)
106
+ # Validation deferred to validate! — don't validate on every merge during boot
107
+ end
108
+
109
+ # Clean hook for legion-* core libraries to register their defaults.
110
+ # Called at the bottom of the library's settings.rb file.
111
+ # Library defaults fill in gaps; user JSON config wins.
112
+ # Idempotent — calling twice with the same key is safe.
113
+ #
114
+ # Usage in legion-transport/lib/legion/transport/settings.rb:
115
+ # Legion::Settings.register_library(:transport, Legion::Transport::Settings.default)
116
+ def register_library(key, defaults)
117
+ sym = key.to_sym
118
+ return if @registered_libraries&.include?(sym)
119
+
120
+ merge_settings(sym, defaults)
121
+ (@registered_libraries ||= []) << sym
101
122
  end
102
123
 
103
124
  def define_schema(key, overrides)
@@ -292,6 +313,7 @@ module Legion
292
313
  @loaded = nil
293
314
  @schema = nil
294
315
  @cross_validations = nil
316
+ @registered_libraries = nil
295
317
  @reload_callbacks = nil
296
318
  @reload_mutex = nil
297
319
  @reload_flag = nil
@@ -311,16 +333,7 @@ module Legion
311
333
  end
312
334
 
313
335
  def deep_merge_for_overlay(base, overlay)
314
- result = base.dup
315
- overlay.each do |key, value|
316
- existing = result[key]
317
- result[key] = if existing.is_a?(Hash) && value.is_a?(Hash)
318
- deep_merge_for_overlay(existing, value)
319
- else
320
- value
321
- end
322
- end
323
- result
336
+ DeepMerge.deep_merge(base, overlay)
324
337
  end
325
338
 
326
339
  def ensure_loader
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-settings
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.27
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: concurrent-ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.2'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: legion-json
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -65,7 +79,12 @@ files:
65
79
  - legion-settings.gemspec
66
80
  - lib/legion/settings.rb
67
81
  - lib/legion/settings/agent_loader.rb
82
+ - lib/legion/settings/deep_merge.rb
68
83
  - lib/legion/settings/dns_bootstrap.rb
84
+ - lib/legion/settings/extensions.rb
85
+ - lib/legion/settings/extensions/filter.rb
86
+ - lib/legion/settings/extensions/normalizer.rb
87
+ - lib/legion/settings/extensions/store.rb
69
88
  - lib/legion/settings/helper.rb
70
89
  - lib/legion/settings/loader.rb
71
90
  - lib/legion/settings/os.rb