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 +4 -4
- data/CHANGELOG.md +17 -0
- data/legion-settings.gemspec +1 -0
- data/lib/legion/settings/deep_merge.rb +53 -0
- data/lib/legion/settings/extensions/filter.rb +104 -0
- data/lib/legion/settings/extensions/normalizer.rb +235 -0
- data/lib/legion/settings/extensions/store.rb +80 -0
- data/lib/legion/settings/extensions.rb +130 -0
- data/lib/legion/settings/helper.rb +101 -23
- data/lib/legion/settings/loader.rb +59 -82
- data/lib/legion/settings/overlay.rb +10 -10
- data/lib/legion/settings/project_env.rb +2 -8
- data/lib/legion/settings/version.rb +1 -1
- data/lib/legion/settings.rb +24 -11
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baf40211abee6a48e5863077ce8e31fa416afbad9f003933e7edcfca4b8a72ba
|
|
4
|
+
data.tar.gz: f8faf5fb5eadead569c9f222a70c58e5ac11bc923dc281bd587f3f2704125cd0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/legion-settings.gemspec
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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:
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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:
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/legion/settings.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|