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 +4 -4
- data/.gitignore +2 -1
- data/.pre-commit-config.yaml +29 -0
- data/CHANGELOG.md +31 -0
- data/README.md +46 -1
- data/legion-settings.gemspec +1 -0
- data/lib/legion/settings/agent_loader.rb +2 -10
- 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 +80 -117
- data/lib/legion/settings/overlay.rb +10 -10
- data/lib/legion/settings/project_env.rb +5 -19
- data/lib/legion/settings/resolver.rb +15 -27
- data/lib/legion/settings/version.rb +1 -1
- data/lib/legion/settings.rb +161 -15
- data/scripts/pre-commit-rubocop.sh +39 -0
- metadata +22 -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/.gitignore
CHANGED
|
@@ -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.
|
|
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:
|
data/legion-settings.gemspec
CHANGED
|
@@ -20,7 +20,7 @@ module Legion
|
|
|
20
20
|
definition = load_file(path)
|
|
21
21
|
next unless definition && valid?(definition)
|
|
22
22
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|