legionio 1.7.8 → 1.7.12

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: 6741515579e2c24f64b301136e023fb6ddd73b27f41779caace7df02cd15f667
4
- data.tar.gz: c90f66af48bd6705b58c6c31344dd0a9486b8519cc48b98f3b1965d2571527db
3
+ metadata.gz: 60c67de284b066264f07c3ad829b8d193b66d85ae900af468dd7a13165695da8
4
+ data.tar.gz: 8fd7ab97436343722619c6c77e0aaff9b6f664609db1b81a0daff97c6692f419
5
5
  SHA512:
6
- metadata.gz: 5409680125318327c866c9f59dde8ce2b7b5a33cea5082bf2d654c763184c25e4658a24ef43d4b292f12546b314692da1b5d71ca6e25ca55e6095ea376d66d5f
7
- data.tar.gz: 68a9358035224f600dfc673272d2cc82ecdfc7236a7225a2974721ad61b081571dbb190c77d25c8442bc3ff13c7cea44274b8331fd2942213a714167c56017c4
6
+ metadata.gz: 2fde83c468f409a35fc9e02d1dc39d969a0229d32c8b592162789683f21afa31df90763792a1c4c74cb74f13ef21f1288e125d78b2b68ac2f9d724a08eee1e77
7
+ data.tar.gz: d10be584f4e30e1f164545c5c6e72f2e3cfa627e19675021edaf18ee6224d3f66d92ce50fcaf44b40caf0e47515e61d137e921f3903e5cab34254245fcd84bfa
data/.rubocop.yml CHANGED
@@ -5,6 +5,8 @@ AllCops:
5
5
 
6
6
  Layout/LineLength:
7
7
  Max: 160
8
+ Exclude:
9
+ - 'Gemfile'
8
10
 
9
11
  Layout/SpaceAroundEqualsInParameterDefault:
10
12
  EnforcedStyle: space
@@ -65,7 +67,7 @@ Metrics/BlockLength:
65
67
  - 'lib/legion/cli/mode_command.rb'
66
68
 
67
69
  Metrics/AbcSize:
68
- Max: 60
70
+ Max: 62
69
71
  Exclude:
70
72
  - 'lib/legion/cli/chat_command.rb'
71
73
  - 'lib/legion/api/llm.rb'
data/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.7.12] - 2026-04-03
6
+
7
+ ### Fixed
8
+ - Fixes #110: normal daemon boot now prefers library-owned LLM and Apollo API routes, `/api/tenants` uses canonical JSON parsing with correct status codes, SSE listeners drain worker threads on disconnect, paginated collections avoid unconditional `COUNT(*)` unless explicitly requested, and service startup skips duplicate settings loads once configuration is already bootstrapped
9
+
10
+ ## [1.7.11] - 2026-04-02
11
+
12
+ ### Fixed
13
+ - Fixes #113: webhook deliveries now retry non-2xx responses and transport exceptions up to `max_retries`, record per-attempt delivery rows, dead-letter terminal failures, and cache active webhook pattern matching to reduce per-event dispatch overhead
14
+
15
+ ## [1.7.10] - 2026-04-02
16
+
17
+ ### Changed
18
+ - Bumped minimum dependency floors for Legion core gems, including `legion-logging >= 1.5.0`, `legion-settings >= 1.3.25`, and updated transport, data, cache, crypt, Apollo, and MCP minimums
19
+ - Stabilized the `LegionIO` spec suite by fixing the OAuth callback, catalog, and service shutdown regression specs
20
+ - CLI startup now honors settings-driven log levels, normalizes `start --help` into the standard Thor help flow, and routes chat/error logging through the newer helper-backed logger path
21
+ - `Legion::Service`, telemetry, and webhook runtime paths now use structured helper logging more consistently, respect configured logging when no CLI override is passed, and avoid brittle settings reads during boot
22
+ - Extension runtime wiring now deep-dups merged settings, lazily registers the local `extension_catalog` migration, publishes catalog transitions directly to transport, and surfaces auto-binding failures more clearly
23
+ - Secret, region, and task-outcome helpers now use canonical Vault connectivity checks, cache metadata misses more safely, and create meta-learning domains on demand before recording learning episodes
24
+
5
25
  ## [1.7.8] - 2026-04-01
6
26
 
7
27
  ### Added
data/legionio.gemspec CHANGED
@@ -34,7 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.bindir = 'exe'
35
35
  spec.executables = %w[legion legionio]
36
36
 
37
- spec.add_dependency 'legion-mcp', '>= 0.5.1'
37
+ spec.add_dependency 'legion-mcp', '>= 0.7.1'
38
38
 
39
39
  spec.add_dependency 'kramdown', '>= 2.0'
40
40
 
@@ -52,15 +52,15 @@ Gem::Specification.new do |spec|
52
52
  spec.add_dependency 'thor', '>= 1.3'
53
53
  spec.add_dependency 'tty-spinner', '~> 0.9'
54
54
 
55
- spec.add_dependency 'legion-cache', '>= 1.3.16'
56
- spec.add_dependency 'legion-crypt', '>= 1.4.17'
57
- spec.add_dependency 'legion-data', '>= 1.6.7'
55
+ spec.add_dependency 'legion-cache', '>= 1.3.21'
56
+ spec.add_dependency 'legion-crypt', '>= 1.5.0'
57
+ spec.add_dependency 'legion-data', '>= 1.6.19'
58
58
  spec.add_dependency 'legion-json', '>= 1.2.1'
59
- spec.add_dependency 'legion-logging', '>= 1.4.0'
60
- spec.add_dependency 'legion-settings', '>= 1.3.19'
61
- spec.add_dependency 'legion-transport', '>= 1.4.4'
59
+ spec.add_dependency 'legion-logging', '>= 1.5.0'
60
+ spec.add_dependency 'legion-settings', '>= 1.3.25'
61
+ spec.add_dependency 'legion-transport', '>= 1.4.13'
62
62
 
63
- spec.add_dependency 'legion-apollo', '>= 0.3.1'
63
+ spec.add_dependency 'legion-apollo', '>= 0.4.0'
64
64
  spec.add_dependency 'legion-gaia', '>= 0.9.26'
65
65
  spec.add_dependency 'legion-llm', '>= 0.5.8'
66
66
  spec.add_dependency 'legion-tty', '>= 0.4.35'
@@ -4,7 +4,7 @@ module Legion
4
4
  class API < Sinatra::Base
5
5
  module Routes
6
6
  module Audit
7
- def self.registered(app) # rubocop:disable Metrics/AbcSize
7
+ def self.registered(app)
8
8
  app.get '/api/audit' do
9
9
  require_data!
10
10
  dataset = Legion::Data::Model::AuditLog.order(Sequel.desc(:id))
@@ -5,6 +5,7 @@ module Legion
5
5
  module Routes
6
6
  module Events
7
7
  BUFFER_SIZE = 100
8
+ SSE_STOP = Object.new.freeze
8
9
 
9
10
  class << self
10
11
  def event_buffer
@@ -38,6 +39,42 @@ module Legion
38
39
  @listener_installed = true
39
40
  end
40
41
 
42
+ def write_sse_event(out, event)
43
+ payload = event.transform_keys(&:to_s)
44
+ out << "event: #{payload['event']}\ndata: #{Legion::JSON.dump(payload)}\n\n"
45
+ end
46
+
47
+ def stop_queue_stream(queue:, worker:, listener:)
48
+ Legion::Events.off('*', listener) if defined?(Legion::Events)
49
+ return unless worker&.alive?
50
+
51
+ queue.push(SSE_STOP)
52
+ worker.join(0.1)
53
+ rescue ThreadError, IOError, Errno::EPIPE => e
54
+ Legion::Logging.debug("Events SSE cleanup failed: #{e.message}") if defined?(Legion::Logging)
55
+ end
56
+
57
+ def stream_queue(out:, queue:, listener:)
58
+ worker = Thread.new do
59
+ loop do
60
+ event = queue.pop
61
+ break if event.equal?(SSE_STOP)
62
+
63
+ write_sse_event(out, event)
64
+ rescue IOError, Errno::EPIPE => e
65
+ Legion::Logging.debug("Events SSE stream broken for #{event[:event]}: #{e.message}") if defined?(Legion::Logging)
66
+ break
67
+ end
68
+ ensure
69
+ Legion::Events.off('*', listener) if defined?(Legion::Events)
70
+ end
71
+
72
+ cleanup = proc { stop_queue_stream(queue: queue, worker: worker, listener: listener) }
73
+ out.callback(&cleanup)
74
+ out.errback(&cleanup)
75
+ worker
76
+ end
77
+
41
78
  def registered(app)
42
79
  install_listener if defined?(Legion::Events)
43
80
 
@@ -53,21 +90,7 @@ module Legion
53
90
  end
54
91
 
55
92
  stream do |out|
56
- Thread.new do
57
- loop do
58
- event = queue.pop
59
- data = Legion::JSON.dump(event.transform_keys(&:to_s))
60
- out << "event: #{event[:event]}\ndata: #{data}\n\n"
61
- rescue IOError, Errno::EPIPE => e
62
- Legion::Logging.debug "Events SSE stream broken for #{event[:event]}: #{e.message}" if defined?(Legion::Logging)
63
- break
64
- end
65
- ensure
66
- Legion::Events.off('*', listener)
67
- end
68
-
69
- out.callback { Legion::Events.off('*', listener) }
70
- out.errback { Legion::Events.off('*', listener) }
93
+ stream_queue(out: out, queue: queue, listener: listener)
71
94
  end
72
95
  end
73
96
 
@@ -16,17 +16,20 @@ module Legion
16
16
  content_type :json
17
17
  status status_code
18
18
 
19
- total = dataset.respond_to?(:count) ? dataset.count : dataset.length
20
19
  paginated = paginate(dataset)
21
- items = paginated.respond_to?(:all) ? paginated.all : paginated
20
+ items = paginated.respond_to?(:all) ? paginated.all : Array(paginated)
21
+ total = collection_total(dataset, items)
22
+ meta = response_meta.merge(
23
+ count: items.length,
24
+ limit: page_limit,
25
+ offset: page_offset
26
+ )
27
+ meta[:total] = total unless total.nil?
28
+ meta[:has_more] = collection_has_more?(items, total)
22
29
 
23
30
  Legion::JSON.dump({
24
31
  data: items.map { |r| r.respond_to?(:values) ? r.values : r },
25
- meta: response_meta.merge(
26
- total: total,
27
- limit: page_limit,
28
- offset: page_offset
29
- )
32
+ meta: meta
30
33
  })
31
34
  end
32
35
 
@@ -198,6 +201,25 @@ module Legion
198
201
  dataset
199
202
  end
200
203
  end
204
+
205
+ def include_total_count?
206
+ params[:include_total].to_s == 'true'
207
+ end
208
+
209
+ def collection_total(dataset, items)
210
+ return dataset.count if include_total_count? && dataset.respond_to?(:count)
211
+ return dataset.length if dataset.respond_to?(:length) && !dataset.respond_to?(:limit)
212
+
213
+ return page_offset + items.length if items.length < page_limit
214
+
215
+ nil
216
+ end
217
+
218
+ def collection_has_more?(items, total)
219
+ return (page_offset + items.length) < total if total
220
+
221
+ items.length == page_limit
222
+ end
201
223
  end
202
224
  end
203
225
  end
@@ -11,6 +11,9 @@ module Legion
11
11
  # @param gem_name [String] short name for the library (e.g. 'llm', 'apollo')
12
12
  # @param routes_module [Module] a Sinatra::Extension module to register
13
13
  def self.register_library_routes(gem_name, routes_module)
14
+ existing = router.library_routes[gem_name.to_s]
15
+ return routes_module if existing == routes_module
16
+
14
17
  router.register_library(gem_name, routes_module)
15
18
  register routes_module
16
19
  end
@@ -13,14 +13,13 @@ module Legion
13
13
  end
14
14
 
15
15
  app.post '/api/tenants' do
16
- params = parsed_body
16
+ body = parse_request_body
17
17
  result = Legion::Tenants.create(
18
- tenant_id: params['tenant_id'],
19
- name: params['name'],
20
- max_workers: params['max_workers'] || 10
18
+ tenant_id: body[:tenant_id],
19
+ name: body[:name],
20
+ max_workers: body[:max_workers] || 10
21
21
  )
22
- status result[:error] ? 409 : 201
23
- json_response(data: result)
22
+ json_response(result, status_code: result[:error] ? 409 : 201)
24
23
  end
25
24
 
26
25
  app.get '/api/tenants/:tenant_id' do
@@ -167,20 +167,7 @@ module Legion
167
167
  end
168
168
 
169
169
  stream do |out|
170
- Thread.new do
171
- loop do
172
- event = queue.pop
173
- data = Legion::JSON.dump({ **event.transform_keys(&:to_s) })
174
- out << "event: #{event[:event]}\ndata: #{data}\n\n"
175
- rescue IOError, Errno::EPIPE
176
- break
177
- end
178
- ensure
179
- Legion::Events.off('*', listener)
180
- end
181
-
182
- out.callback { Legion::Events.off('*', listener) }
183
- out.errback { Legion::Events.off('*', listener) }
170
+ Routes::Events.stream_queue(out: out, queue: queue, listener: listener)
184
171
  end
185
172
  else
186
173
  count = (params[:count] || 25).to_i
data/lib/legion/api.rb CHANGED
@@ -149,6 +149,25 @@ module Legion
149
149
  def router
150
150
  @router ||= Legion::API::Router.new
151
151
  end
152
+
153
+ def mount_library_routes(gem_name, fallback_module, preferred_constant_path)
154
+ preferred = constant_from_path(preferred_constant_path)
155
+ if preferred.is_a?(Module)
156
+ register_library_routes(gem_name, preferred)
157
+ elsif router.library_names.include?(gem_name)
158
+ register_library_routes(gem_name, router.library_routes.fetch(gem_name))
159
+ else
160
+ register fallback_module
161
+ end
162
+ end
163
+
164
+ private
165
+
166
+ def constant_from_path(path)
167
+ path.to_s.split('::').reject(&:empty?).reduce(Object) { |scope, name| scope.const_get(name) }
168
+ rescue NameError
169
+ nil
170
+ end
152
171
  end
153
172
 
154
173
  # Mount route modules
@@ -175,14 +194,14 @@ module Legion
175
194
  register Routes::Capacity
176
195
  register Routes::Audit
177
196
  register Routes::Metrics
178
- register Routes::Llm unless router.library_names.include?('llm')
197
+ mount_library_routes('llm', Routes::Llm, 'Legion::LLM::Routes')
179
198
  register Routes::ExtensionCatalog
180
199
  register Routes::OrgChart
181
200
  register Routes::Governance
182
201
  register Routes::Acp
183
202
  register Routes::Prompts
184
203
  register Routes::Marketplace
185
- register Routes::Apollo unless router.library_names.include?('apollo')
204
+ mount_library_routes('apollo', Routes::Apollo, 'Legion::Apollo::Routes')
186
205
  register Routes::Costs
187
206
  register Routes::Traces
188
207
  register Routes::Stats
@@ -8,8 +8,14 @@ module Legion
8
8
  module CLI
9
9
  class Chat
10
10
  module ChatLogger
11
- LOG_DIR = File.expand_path('~/.legion')
11
+ LOG_DIR = File.expand_path('~/.legion')
12
12
  LOG_FILE = File.join(LOG_DIR, 'legion-chat.log')
13
+ LEVELS = {
14
+ 'debug' => ::Logger::DEBUG,
15
+ 'info' => ::Logger::INFO,
16
+ 'warn' => ::Logger::WARN,
17
+ 'error' => ::Logger::ERROR
18
+ }.freeze
13
19
 
14
20
  class << self
15
21
  attr_reader :logger
@@ -22,20 +28,21 @@ module Legion
22
28
  @logger
23
29
  end
24
30
 
25
- def debug(msg) = logger&.debug(msg)
26
- def info(msg) = logger&.info(msg)
27
- def warn(msg) = logger&.warn(msg)
28
- def error(msg) = logger&.error(msg)
31
+ def debug(msg) = logger&.debug(msg)
32
+
33
+ def info(msg) = logger&.info(msg)
34
+
35
+ def warn(msg) = logger&.warn(msg)
36
+
37
+ def error(msg) = logger&.error(msg)
29
38
 
30
39
  private
31
40
 
32
- def parse_level(level)
33
- case level.to_s
34
- when 'debug' then ::Logger::DEBUG
35
- when 'warn' then ::Logger::WARN
36
- when 'error' then ::Logger::ERROR
37
- else ::Logger::INFO
38
- end
41
+ def parse_level(level = 'info')
42
+ normalized_level = level.to_s.strip.downcase
43
+ return ::Logger::INFO if normalized_level.empty?
44
+
45
+ LEVELS.fetch(normalized_level, ::Logger::INFO)
39
46
  end
40
47
 
41
48
  def format_entry(severity, datetime, _progname, msg)
@@ -101,7 +101,7 @@ module Legion
101
101
  end
102
102
 
103
103
  desc 'validate', 'Validate current configuration'
104
- def validate # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
104
+ def validate # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
105
105
  out = formatter
106
106
  Connection.config_dir = options[:config_dir] if options[:config_dir]
107
107
 
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+
3
5
  module Legion
4
6
  module CLI
5
7
  module ErrorHandler
8
+ extend Legion::Logging::Helper
9
+
6
10
  PATTERNS = [
7
11
  {
8
12
  match: /connection refused.*5672|ECONNREFUSED.*5672|bunny.*not connected/i,
@@ -71,11 +75,13 @@ module Legion
71
75
  def wrap(error)
72
76
  pattern = PATTERNS.find { |p| error.message.match?(p[:match]) }
73
77
  unless pattern
74
- Legion::Logging.error("[CLI] unhandled error: #{error.class} #{error.message}") if logging_available?
78
+ handle_exception(error, level: :error, handled: true, operation: :wrap_cli_error, matched: false) if logging_available?
79
+ log.error("[CLI] unhandled error: #{error.class} - #{error.message}") if logging_available?
75
80
  return error
76
81
  end
77
82
 
78
- Legion::Logging.warn("[CLI] matched error pattern :#{pattern[:code]} — #{error.message}") if logging_available?
83
+ handle_exception(error, level: :warn, handled: true, operation: :wrap_cli_error, code: pattern[:code]) if logging_available?
84
+ log.warn("[CLI] matched error pattern :#{pattern[:code]} - #{error.message}") if logging_available?
79
85
  Error.actionable(
80
86
  code: pattern[:code],
81
87
  message: "#{pattern[:message]}: #{error.message}",
@@ -10,7 +10,7 @@ module Legion
10
10
  ENV['LEGION_LOCAL'] = 'true'
11
11
  end
12
12
 
13
- log_level = options[:log_level] || 'info'
13
+ log_level = options[:log_level]
14
14
 
15
15
  # Load settings early, before any legion-* gem requires can trigger auto-load.
16
16
  # This ensures DNS bootstrap and config file loading happen exactly once.
@@ -26,7 +26,8 @@ module Legion
26
26
  clear_log_file unless options[:daemonize]
27
27
 
28
28
  api = options.fetch(:api, true)
29
- service_opts = { log_level: log_level, api: api }
29
+ service_opts = { api: api }
30
+ service_opts[:log_level] = log_level if log_level
30
31
  service_opts[:http_port] = options[:http_port] if options[:http_port]
31
32
  service_opts[:role] = :lite if options[:lite]
32
33
  Legion.instance_variable_set(:@service, Legion::Service.new(**service_opts))
data/lib/legion/cli.rb CHANGED
@@ -90,7 +90,7 @@ module Legion
90
90
  end
91
91
 
92
92
  def self.start(given_args = ARGV, config = {})
93
- super
93
+ super(normalize_help_args(given_args), config)
94
94
  rescue Legion::CLI::Error => e
95
95
  Legion::Logging.error("CLI::Main.start CLI error: #{e.message}") if defined?(Legion::Logging)
96
96
  formatter = Output::Formatter.new(json: given_args.include?('--json'), color: !given_args.include?('--no-color'))
@@ -106,6 +106,17 @@ module Legion
106
106
  exit(1)
107
107
  end
108
108
 
109
+ def self.normalize_help_args(given_args)
110
+ args = Array(given_args).dup
111
+ return args unless args.length == 2
112
+ return args unless %w[--help -h].include?(args.last)
113
+
114
+ command = args.first
115
+ return args if command.start_with?('-') || command == 'help'
116
+
117
+ ['help', command]
118
+ end
119
+
109
120
  LEGION_GEMS = %w[
110
121
  legion-transport legion-cache legion-crypt legion-data
111
122
  legion-json legion-logging legion-settings
@@ -163,7 +174,7 @@ module Legion
163
174
  option :pidfile, type: :string, aliases: ['-p'], desc: 'PID file path'
164
175
  option :logfile, type: :string, aliases: ['-l'], desc: 'Log file path'
165
176
  option :time_limit, type: :numeric, aliases: ['-t'], desc: 'Run for N seconds then exit'
166
- option :log_level, type: :string, default: 'info', desc: 'Log level (debug, info, warn, error)'
177
+ option :log_level, type: :string, desc: 'Log level (debug, info, warn, error)'
167
178
  option :api, type: :boolean, default: true, desc: 'Start the HTTP API server'
168
179
  option :http_port, type: :numeric, desc: 'HTTP API port (overrides settings)'
169
180
  option :lite, type: :boolean, default: false, desc: 'Start in lite mode (no external services)'
@@ -56,6 +56,9 @@ module Legion
56
56
 
57
57
  def reset!
58
58
  @entries = {}
59
+ @extension_catalog_available = nil
60
+ @extension_catalog_connection_id = nil
61
+ @warned_missing_extension_catalog = false
59
62
  end
60
63
 
61
64
  private
@@ -69,13 +72,17 @@ module Legion
69
72
  Legion::Transport::Connection.respond_to?(:session_open?) &&
70
73
  Legion::Transport::Connection.session_open?
71
74
 
72
- Legion::Transport::Messages::Dynamic.new(
73
- function: 'catalog_transition',
74
- routing_key: "legion.catalog.#{lex_name}.#{new_state}",
75
- args: { lex_name: lex_name, state: new_state.to_s, timestamp: Time.now.to_i }
76
- ).publish
75
+ payload = Legion::JSON.dump(
76
+ lex_name: lex_name,
77
+ state: new_state.to_s,
78
+ timestamp: Time.now.to_i
79
+ )
80
+
81
+ exchange = Legion::Transport::Exchange.new('legion.catalog')
82
+ exchange.publish(payload, routing_key: "legion.catalog.#{lex_name}.#{new_state}",
83
+ content_type: 'application/json', persistent: true)
77
84
  rescue StandardError => e
78
- Legion::Logging.debug { "Catalog publish failed: #{e.message}" } if defined?(Legion::Logging)
85
+ Legion::Logging.warn { "Catalog publish failed for #{lex_name}=#{new_state}: #{e.class}: #{e.message}" } if defined?(Legion::Logging)
79
86
  end
80
87
 
81
88
  def persist_transition(lex_name, new_state)
@@ -83,6 +90,9 @@ module Legion
83
90
  Legion::Data::Local.respond_to?(:connected?) &&
84
91
  Legion::Data::Local.connected?
85
92
 
93
+ ensure_local_migration_registered!
94
+ return warn_missing_extension_catalog_once unless extension_catalog_table_available?
95
+
86
96
  model = Legion::Data::Local.model(:extension_catalog)
87
97
  existing = model.where(lex_name: lex_name).first
88
98
  if existing
@@ -91,14 +101,70 @@ module Legion
91
101
  model.insert(lex_name: lex_name, state: new_state.to_s, created_at: Time.now, updated_at: Time.now)
92
102
  end
93
103
  rescue StandardError => e
94
- Legion::Logging.debug { "Catalog persist failed: #{e.message}" } if defined?(Legion::Logging)
104
+ Legion::Logging.warn { "Catalog persist failed for #{lex_name}=#{new_state}: #{e.class}: #{e.message}" } if defined?(Legion::Logging)
105
+ end
106
+
107
+ def extension_catalog_table_available?
108
+ connection = Legion::Data::Local.connection
109
+ return false unless connection
110
+
111
+ connection_id = connection.object_id
112
+ return true if @extension_catalog_connection_id == connection_id && @extension_catalog_available == true
113
+
114
+ available =
115
+ if connection.respond_to?(:tables)
116
+ connection.tables.include?(:extension_catalog)
117
+ else
118
+ connection.respond_to?(:table_exists?) && connection.table_exists?(:extension_catalog)
119
+ end
120
+
121
+ if available
122
+ @extension_catalog_connection_id = connection_id
123
+ @extension_catalog_available = true
124
+ else
125
+ @extension_catalog_connection_id = nil if @extension_catalog_connection_id == connection_id
126
+ @extension_catalog_available = nil
127
+ end
128
+
129
+ available
130
+ rescue StandardError => e
131
+ Legion::Logging.warn { "Catalog table availability check failed: #{e.class}: #{e.message}" } if defined?(Legion::Logging)
132
+ false
95
133
  end
96
- end
97
134
 
98
- if defined?(Legion::Data::Local)
99
- migrations_path = File.expand_path('../../data/local_migrations', __dir__)
100
- Legion::Data::Local.register_migrations(name: :extension_catalog, path: migrations_path) if Dir.exist?(migrations_path)
135
+ def ensure_local_migration_registered!
136
+ return unless defined?(Legion::Data::Local) &&
137
+ Legion::Data::Local.respond_to?(:register_migrations)
138
+
139
+ path = extension_catalog_migrations_path
140
+ return unless Dir.exist?(path)
141
+
142
+ registered = if Legion::Data::Local.respond_to?(:registered_migrations)
143
+ Legion::Data::Local.registered_migrations
144
+ else
145
+ {}
146
+ end
147
+ return if registered.is_a?(Hash) && registered.key?(:extension_catalog)
148
+
149
+ Legion::Data::Local.register_migrations(name: :extension_catalog, path: path)
150
+ rescue StandardError => e
151
+ Legion::Logging.warn { "Catalog migration registration failed: #{e.class}: #{e.message}" } if defined?(Legion::Logging)
152
+ end
153
+
154
+ def extension_catalog_migrations_path
155
+ File.expand_path('../data/local_migrations', __dir__)
156
+ end
157
+
158
+ def warn_missing_extension_catalog_once
159
+ return false if @warned_missing_extension_catalog
160
+
161
+ @warned_missing_extension_catalog = true
162
+ Legion::Logging.warn('Catalog persist skipped: extension_catalog table is missing in Legion::Data::Local') if defined?(Legion::Logging)
163
+ false
164
+ end
101
165
  end
166
+
167
+ send(:ensure_local_migration_registered!) if defined?(Legion::Data::Local)
102
168
  end
103
169
  end
104
170
  end
@@ -189,23 +189,25 @@ module Legion
189
189
  end
190
190
 
191
191
  def build_settings
192
+ defaults = deep_dup_settings_value(Legion::Settings[:default_extension_settings] || {})
193
+
192
194
  if Legion::Settings[:extensions].key?(lex_name.to_sym)
193
- Legion::Settings[:default_extension_settings].each do |key, value|
195
+ defaults.each do |key, value|
194
196
  Legion::Settings[:extensions][lex_name.to_sym][key.to_sym] = if Legion::Settings[:extensions][lex_name.to_sym].key?(key.to_sym)
195
- value.merge(Legion::Settings[:extensions][lex_name.to_sym][key.to_sym])
197
+ deep_dup_settings_value(value).merge(Legion::Settings[:extensions][lex_name.to_sym][key.to_sym])
196
198
  else
197
- value
199
+ deep_dup_settings_value(value)
198
200
  end
199
201
  end
200
202
  else
201
- Legion::Settings[:extensions][lex_name.to_sym] = Legion::Settings[:default_extension_settings]
203
+ Legion::Settings[:extensions][lex_name.to_sym] = defaults
202
204
  end
203
205
 
204
206
  default_settings.each do |key, value|
205
207
  Legion::Settings[:extensions][lex_name.to_sym][key.to_sym] = if Legion::Settings[:extensions][lex_name.to_sym].key?(key.to_sym)
206
- value.merge(Legion::Settings[:extensions][lex_name.to_sym][key.to_sym])
208
+ deep_dup_settings_value(value).merge(Legion::Settings[:extensions][lex_name.to_sym][key.to_sym])
207
209
  else
208
- value
210
+ deep_dup_settings_value(value)
209
211
  end
210
212
  end
211
213
  end
@@ -237,6 +239,21 @@ module Legion
237
239
  rescue StandardError => e
238
240
  handle_exception(e, lex: lex_name, operation: 'auto_generate_data')
239
241
  end
242
+
243
+ def deep_dup_settings_value(value)
244
+ case value
245
+ when Hash
246
+ value.each_with_object({}) do |(key, nested), duplicated|
247
+ duplicated[key.to_sym] = deep_dup_settings_value(nested)
248
+ end
249
+ when Array
250
+ value.map { |item| deep_dup_settings_value(item) }
251
+ else
252
+ value.dup
253
+ end
254
+ rescue TypeError
255
+ value
256
+ end
240
257
  end
241
258
  end
242
259
  end
@@ -78,6 +78,8 @@ module Legion
78
78
  end
79
79
 
80
80
  def vault_connected?
81
+ return Legion::Crypt.vault_connected? if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:vault_connected?)
82
+
81
83
  defined?(Legion::Settings) &&
82
84
  Legion::Settings[:crypt]&.dig(:vault, :connected) == true
83
85
  rescue StandardError