legionio 1.7.17 → 1.7.20

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: 6fc7545d6fe4e3dc832eba2265dc27fed7546fb3d770dec04b4375b35505479f
4
- data.tar.gz: 513b2ff1093560b1d88c427b176d6f27959a6c94abd3106173d462d29f8cd69c
3
+ metadata.gz: ba8e945b1c999e0c815583388817c35bc019134994745682a774afb6e645db1f
4
+ data.tar.gz: d21bf7aea3328dda51f7139ed36632b31f16e5c57bef938a9eb2f9f000c2c21e
5
5
  SHA512:
6
- metadata.gz: b6de374974924d397249494b4aed6ff9e99263116a36d47226530c12653d672371565d060b65913d249fce4d0ed808fd6ffae45a669a6e4b1b67d87a056dcbe8
7
- data.tar.gz: 2d64efa40365a2da40fcc97de5173e2c3bfd3458e8adf861a0efde9c35514d7b876ab200f2d5648ca68f08f5de879c29dde2c4d5c3f53d9c2240ae8f4575debe
6
+ metadata.gz: c5ef0de86e4ea0a8e92fc7433af4e36bb8b7088737142abcfa5edcaad3769ede8acf0205e2b8478e80cc4376052e6cb3ef3df384b2cf2c39abdc8205fd965a68
7
+ data.tar.gz: 389cf7d22ac2db9e44489c4118cbef323d0d92d9242b4ec0e8a4c7ed141425497249a6832b2cf1bbecdc9e6666d65251978fac36f299175195d2eefa8fb6e190
data/.rubocop.yml CHANGED
@@ -3,6 +3,9 @@ AllCops:
3
3
  NewCops: enable
4
4
  SuggestExtensions: false
5
5
 
6
+ Style/RedundantConstantBase:
7
+ Enabled: false
8
+
6
9
  Layout/LineLength:
7
10
  Max: 160
8
11
  Exclude:
data/CHANGELOG.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.7.20] - 2026-04-06
6
+ ### Added
7
+ - `Legion::Mode` module with `LEGACY_MAP`, ENV/Settings fallback chain, `agent?`/`worker?`/`infra?`/`lite?` predicates
8
+ - `Legion.instance_id` — UUID computed at load time, ENV override via `LEGIONIO_INSTANCE_ID`
9
+ - `Legion::Identity::Process` singleton — process identity with `bind!`, `bind_fallback!`, `queue_prefix` per-mode, `AtomicReference` thread safety
10
+ - `Legion::Identity::Request` — per-request immutable identity with `from_env`, `from_auth_context`, `to_caller_hash`, `to_rbac_principal`
11
+ - `Legion::Identity::Lease` — credential lease value object with `expired?`, `stale?` (50% TTL), `ttl_seconds`, `valid?`
12
+ - `Legion::Identity::LeaseRenewer` — background thread per provider, 50% TTL renewal, cooperative shutdown (no `Thread#kill`)
13
+ - `Legion::Identity::Broker` — provider management with groups cache (60s TTL, single-flight CAS), `token_for`, `credentials_for`, `shutdown`
14
+ - `Legion::Identity::Middleware` — Rack middleware bridging `legion.auth` to `legion.principal` (`Identity::Request`)
15
+ - `setup_identity` boot step 9 — parallel provider resolution via `Concurrent::Promises`, fallback to `ENV['USER']`
16
+ - Extension publish suppression — defers `LexRegister.publish` until identity resolves, `flush_pending_registrations!`
17
+ - Identity provider auto-registration during phased extension load (`identity_provider?` duck-type check)
18
+ - `GET /api/identity/audit` route with principal and duration filtering
19
+ - `legion doctor` checks: `ApiBindCheck` (non-loopback without auth), `ModeCheck` (no explicit process.mode)
20
+
21
+ ### Changed
22
+ - `Readiness.status` upgraded to `Concurrent::Hash` for thread safety; `:identity` added to `COMPONENTS`
23
+ - `READONLY_SECTIONS` extended with `:identity`, `:rbac`, `:api`
24
+ - Default API bind changed from `0.0.0.0` to `127.0.0.1`
25
+ - `ProcessRole` delegates `.current` to `Mode.current`; added `:agent` and `:infra` role entries
26
+ - `lite_mode?` delegates to `Mode.lite?`
27
+ - Reload path adds `Identity::Process.refresh_credentials` after transport reconnect
28
+ - Shutdown adds cooperative `Identity::Broker.shutdown` and JWKS background refresh stop
29
+
30
+ ## [1.7.19] - 2026-04-06
31
+
32
+ ### Added
33
+ - `ALWAYS_LOADED` constant in `Tools::Discovery` — pins apollo/knowledge and eval/evaluation runners to always-loaded regardless of extension DSL
34
+ - `always_loaded_names` method on `Tools::Registry` returning names of all non-deferred registered tools
35
+
36
+ ### Changed
37
+ - Tool name format changed from dot-separated to dash-separated (`legion-ext-runner-func`) for LLM provider compatibility
38
+ - Reduced noisy debug logging in `Tools::Discovery` and `Tools::Registry`
39
+
40
+ ## [1.7.18] - 2026-04-06
41
+
42
+ ### Added
43
+ - Multi-phase extension loading: identity providers (`lex-identity-*`) load in phase 0 before all other extensions in phase 1
44
+ - `identity` category in extension registry with prefix matching for `lex-identity-*` gems at tier 0, phase 0
45
+ - `group_by_phase` method groups discovered extensions by phase from the category registry
46
+ - `load_phase_extensions` replaces `load_extensions` — scopes parallel loading to a subset of entries per phase
47
+ - `hook_phase_actors` replaces `hook_all_actors` — hooks deferred actors after each phase completes
48
+ - Per-phase logging during extension loading shows cumulative actor counts
49
+
50
+ ### Changed
51
+ - `hook_extensions` now iterates phases sequentially (phase 0 then phase 1), running full load+hook cycle per phase
52
+ - `default_category_registry` includes `phase:` key on all categories; all non-identity categories default to phase 1
53
+ - Catalog transitions (`transition(:running)` + `flush_persisted_transitions`) happen after all phases complete
54
+ - Reserved prefixes list now includes `identity`
55
+
5
56
  ### Added
6
57
  - `Legion::Tools::Base` - canonical tool base class with DSL
7
58
  - `Legion::Tools::Registry` - always/deferred tool classification
data/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/LegionIO
11
11
  **Gem**: `legionio`
12
- **Version**: 1.6.0
12
+ **Version**: 1.7.18
13
13
  **License**: Apache-2.0
14
14
  **Docker**: `legionio/legion`
15
15
  **Ruby**: >= 3.4
@@ -51,14 +51,14 @@ Legion.start
51
51
  ├── 10. setup_gaia (legion-gaia, cognitive coordination layer, optional)
52
52
  ├── 11. setup_telemetry (OpenTelemetry, optional)
53
53
  ├── 12. setup_supervision (process supervision)
54
- ├── 13. load_extensions (two-phase parallel: require+autobuild on FixedThreadPool, then hook_all_actors)
54
+ ├── 13. load_extensions (multi-phase: phase 0 (identity providers) loads and hooks actors first, then phase 1 (everything else))
55
55
  ├── 14. Legion::Crypt.cs (distribute cluster secret)
56
56
  └── 15. setup_api (start Sinatra/Puma on port 4567)
57
57
  ```
58
58
 
59
59
  Each phase calls `Legion::Readiness.mark_ready(:component)`. All phases are individually toggleable via `Service.new(transport: false, ...)`.
60
60
 
61
- Extension loading is two-phase and parallel: all extensions are `require`d and `autobuild` runs concurrently on a `Concurrent::FixedThreadPool(min(count, extensions.parallel_pool_size))`, collecting actors into a thread-safe `Concurrent::Array` of `@pending_actors`. Pool size defaults to 24, configurable via `Legion::Settings[:extensions][:parallel_pool_size]`. After all extensions are loaded, `hook_all_actors` starts AMQP subscriptions, timers, and other actor types sequentially. This prevents race conditions where early extensions start ticking while later ones haven't loaded yet. Thread safety relies on ThreadLocal AMQP channels, per-extension Settings keys, and sequential post-processing of Catalog transitions and Registry writes.
61
+ Extension loading is multi-phase and parallel: `hook_extensions` calls `group_by_phase` to partition discovered extensions by phase number (from the category registry), then iterates phases sequentially. Phase 0 contains identity providers (`lex-identity-*` gems, category `:identity`, tier 0); phase 1 contains all other extensions. Within each phase, extensions are `require`d and `autobuild` runs concurrently on a `Concurrent::FixedThreadPool(min(count, extensions.parallel_pool_size))`, collecting actors into a thread-safe `Concurrent::Array` of `@pending_actors`. Pool size defaults to 24, configurable via `Legion::Settings[:extensions][:parallel_pool_size]`. After each phase's extensions are loaded, `hook_phase_actors` starts AMQP subscriptions, timers, and other actor types for that phase sequentially ensuring identity providers are fully running before any other extension boots. Catalog transitions (`transition(:running)` and `flush_persisted_transitions`) happen after all phases complete. Thread safety relies on ThreadLocal AMQP channels, per-extension Settings keys, and sequential post-processing of Catalog transitions and Registry writes.
62
62
 
63
63
  ### Reload Sequence
64
64
 
@@ -85,7 +85,7 @@ Legion (lib/legion.rb)
85
85
  │ # Ingress.run(payload:, runner_class:, function:, source:)
86
86
  │ # Ingress.normalize returns message hash without executing
87
87
  ├── Extensions # LEX discovery, loading, and lifecycle management
88
- │ ├── Core # Mixin: data_required?, cache_required?, crypt_required?, etc.
88
+ │ ├── Core # Mixin: data_required?, cache_required?, crypt_required?, mcp_tools?, mcp_tools_deferred?, etc.
89
89
  │ ├── Actors/ # Actor execution modes
90
90
  │ │ ├── Base # Base actor class
91
91
  │ │ ├── Every # Run at interval (timer)
@@ -96,7 +96,7 @@ Legion (lib/legion.rb)
96
96
  │ │ └── Nothing # No-op actor
97
97
  │ ├── Builders/ # Build actors and runners from LEX definitions
98
98
  │ │ ├── Actors # Build actors from extension definitions
99
- │ │ ├── Runners # Build runners from extension definitions (stores runner_module ref)
99
+ │ │ ├── Runners # Build runners from extension definitions; exposes `runner_modules` accessor for Discovery
100
100
  │ │ ├── Helpers # Builder utilities
101
101
  │ │ ├── Hooks # Webhook hook system builder
102
102
  │ │ └── Routes # Auto-route builder: introspects runners, registers POST /api/extensions/* routes
@@ -149,7 +149,17 @@ Legion (lib/legion.rb)
149
149
  │ # Populated by Builders::Routes during autobuild via LexDispatch
150
150
 
151
151
  ├── MCP (legion-mcp gem) # Extracted to standalone gem — see legion-mcp/CLAUDE.md
152
- │ └── (58 tools, 2 resources, TierRouter, PatternStore, ContextGuard, Observer, EmbeddingIndex)
152
+ │ └── (tools, 2 resources, TierRouter, PatternStore, ContextGuard, Observer, EmbeddingIndex)
153
+
154
+ ├── Tools # Canonical tool layer — replaces Extensions::Capability and Catalog::Registry
155
+ │ ├── Base # Base class for all framework tools (Do, Status, Config are built-in statics)
156
+ │ ├── Registry # always/deferred classification for all tools; replaces Catalog::Registry
157
+ │ │ # Extensions declare tools via `mcp_tools?` / `mcp_tools_deferred?` DSL on Core
158
+ │ ├── Discovery # Auto-discovers tools from extension runner modules at boot
159
+ │ │ # `runner_modules` accessor on Builders::Runners feeds Discovery
160
+ │ │ # `loaded_extension_modules` on Extensions exposes the full set
161
+ │ └── EmbeddingCache # 5-tier persistent embedding cache:
162
+ │ # L0 in-memory hash → L1 Cache::Local → L2 Cache → L3 Data::Local → L4 Data
153
163
 
154
164
  ├── DigitalWorker # Digital worker platform (AI-as-labor governance)
155
165
  │ ├── Lifecycle # Worker state machine (active/paused/retired/terminated)
@@ -248,6 +258,16 @@ Legion (lib/legion.rb)
248
258
 
249
259
  `Legion::Extensions.find_extensions` discovers lex-* gems via `Bundler.load.specs` (when running under Bundler) or falls back to `Gem::Specification.all_names`. It also processes `Legion::Settings[:extensions]` for explicitly configured extensions, attempting `Gem.install` for missing ones if `auto_install` is enabled.
250
260
 
261
+ **Category registry**: Extensions are classified by `categorize_and_order` using `default_category_registry`. Each category has a `type` (`:list` or `:prefix`), `tier` (load order within a phase), and `phase`:
262
+
263
+ | Category | Type | Tier | Phase | Matches |
264
+ |----------|------|------|-------|---------|
265
+ | `identity` | prefix | 0 | 0 | `lex-identity-*` gems |
266
+ | `core` | list | 1 | 1 | explicitly listed core extensions |
267
+ | `ai` | list | 2 | 1 | explicitly listed AI provider extensions |
268
+ | `gaia` | list | 3 | 1 | explicitly listed GAIA extensions |
269
+ | `agentic` | prefix | 4 | 1 | `lex-agentic-*` gems |
270
+
251
271
  **Role-based filtering**: After discovery, `apply_role_filter` prunes extensions based on `Legion::Settings[:role][:profile]`:
252
272
 
253
273
  | Profile | What loads |
@@ -479,7 +499,7 @@ legion
479
499
 
480
500
  ### MCP Design
481
501
 
482
- Extracted to the `legion-mcp` gem (v0.5.9). See `legion-mcp/CLAUDE.md` for full architecture.
502
+ Extracted to the `legion-mcp` gem (v0.7.3). See `legion-mcp/CLAUDE.md` for full architecture.
483
503
 
484
504
  - `Legion::MCP.server` is memoized singleton — call `Legion::MCP.reset!` in tests
485
505
  - Tool naming: `legion.snake_case_name` (dot namespace, not slash)
@@ -571,7 +591,7 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
571
591
  | `lib/legion/readiness.rb` | Component readiness tracking (COMPONENTS constant, `ready?`, `to_h`) |
572
592
  | `lib/legion/events.rb` | In-process pub/sub: `on`, `emit`, `once`, `off`, wildcard `*` |
573
593
  | `lib/legion/ingress.rb` | Universal runner invocation: `normalize`, `run` |
574
- | `lib/legion/extensions.rb` | LEX discovery, loading, actor hooking, shutdown |
594
+ | `lib/legion/extensions.rb` | LEX discovery, loading, actor hooking, shutdown; exposes `loaded_extension_modules` for Tools::Discovery |
575
595
  | `lib/legion/extensions/core.rb` | Extension mixin (requirement flags, autobuild) |
576
596
  | `lib/legion/extensions/actors/` | Actor types: base, every, loop, once, poll, subscription, nothing, defaults |
577
597
  | `lib/legion/extensions/builders/` | Build actors, runners, helpers, hooks, routes from definitions |
@@ -586,7 +606,12 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
586
606
  | `lib/legion/isolation.rb` | Process isolation for untrusted extension execution |
587
607
  | `lib/legion/sandbox.rb` | Sandboxed execution environment for extensions |
588
608
  | `lib/legion/context.rb` | Thread-local execution context (request tracing, tenant) |
589
- | `lib/legion/catalog.rb` | Extension catalog: registry of available extensions with metadata |
609
+ | `lib/legion/catalog.rb` | Extension catalog: registry of available extensions with metadata (Catalog::Registry removed — replaced by Tools::Registry) |
610
+ | `lib/legion/tools.rb` | Tools module entry point |
611
+ | `lib/legion/tools/base.rb` | Tools::Base — canonical base class for all tools |
612
+ | `lib/legion/tools/registry.rb` | Tools::Registry — always/deferred classification, replaces Catalog::Registry |
613
+ | `lib/legion/tools/discovery.rb` | Tools::Discovery — auto-discovers tools from extension runner_modules at boot |
614
+ | `lib/legion/tools/embedding_cache.rb` | Tools::EmbeddingCache — 5-tier persistent embedding cache (L0–L4) |
590
615
  | `lib/legion/registry.rb` | Extension registry with security scanning |
591
616
  | `lib/legion/registry/security_scanner.rb` | Gem security scanner (CVE checks, signature verification) |
592
617
  | `lib/legion/webhooks.rb` | Webhook delivery system: HTTP POST with retry, HMAC signing |
@@ -9,7 +9,7 @@ module Legion
9
9
  {
10
10
  enabled: true,
11
11
  port: 4567,
12
- bind: '0.0.0.0',
12
+ bind: '127.0.0.1',
13
13
  puma: puma_defaults,
14
14
  bind_retries: 3,
15
15
  bind_retry_wait: 2,
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module IdentityAudit
7
+ def self.registered(app)
8
+ app.helpers IdentityAuditHelpers
9
+
10
+ app.get '/api/identity/audit' do
11
+ halt 503, json_error('unavailable', 'audit records not available') unless defined?(Legion::Data::Model::AuditRecord)
12
+
13
+ dataset = Legion::Data::Model::AuditRecord.where(entity_type: 'identity')
14
+
15
+ principal = params[:principal]
16
+ dataset = dataset.where(Sequel.lit("metadata->>'principal' = ?", principal)) if principal
17
+
18
+ since = params[:since]
19
+ if since
20
+ duration = parse_since_duration(since)
21
+ dataset = dataset.where { created_at >= Time.now - duration } if duration
22
+ end
23
+
24
+ records = dataset.order(Sequel.desc(:created_at)).limit(100).all
25
+ json_collection(records.map do |r|
26
+ { id: r.id, action: r.action, entity_type: r.entity_type, metadata: r.parsed_metadata, created_at: r.created_at }
27
+ end)
28
+ end
29
+ end
30
+
31
+ module IdentityAuditHelpers
32
+ def parse_since_duration(value)
33
+ return nil unless value.is_a?(String)
34
+
35
+ case value
36
+ when /\A(\d+)h\z/ then Regexp.last_match(1).to_i * 3600
37
+ when /\A(\d+)m\z/ then Regexp.last_match(1).to_i * 60
38
+ when /\A(\d+)s\z/ then Regexp.last_match(1).to_i
39
+ when /\A(\d+)d\z/ then Regexp.last_match(1).to_i * 86_400
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -5,7 +5,7 @@ module Legion
5
5
  module Routes
6
6
  module Settings
7
7
  SENSITIVE_KEYS = %i[password secret token key cert private_key api_key].freeze
8
- READONLY_SECTIONS = %i[crypt transport].freeze
8
+ READONLY_SECTIONS = %i[crypt transport identity rbac api].freeze
9
9
 
10
10
  def self.registered(app)
11
11
  app.get '/api/settings' do
data/lib/legion/api.rb CHANGED
@@ -60,6 +60,7 @@ require_relative 'api/tbi_patterns'
60
60
  require_relative 'api/webhooks'
61
61
  require_relative 'api/tenants'
62
62
  require_relative 'api/inbound_webhooks'
63
+ require_relative 'api/identity_audit'
63
64
  require_relative 'api/graphql' if defined?(GraphQL)
64
65
 
65
66
  module Legion
@@ -216,6 +217,7 @@ module Legion
216
217
  register Routes::Webhooks
217
218
  register Routes::Tenants
218
219
  register Routes::InboundWebhooks
220
+ register Routes::IdentityAudit
219
221
  register Routes::GraphQL if defined?(Routes::GraphQL)
220
222
 
221
223
  use Legion::API::Middleware::RequestLogger
@@ -158,7 +158,7 @@ module Legion
158
158
  end
159
159
  return nil unless matched
160
160
 
161
- matched.tool_name.split('.').last
161
+ matched.tool_name.split(/[-.]/).last
162
162
  end
163
163
 
164
164
  def build_runner_class(extension, runner)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Doctor
6
+ class ApiBindCheck
7
+ LOOPBACK_BINDS = %w[127.0.0.1 ::1 localhost].freeze
8
+
9
+ def name
10
+ 'API bind address'
11
+ end
12
+
13
+ def run
14
+ return skip_result unless defined?(Legion::Settings)
15
+
16
+ api_settings = Legion::Settings[:api]
17
+ return skip_result unless api_settings.is_a?(Hash)
18
+
19
+ bind = api_settings[:bind]
20
+ return skip_result if bind.nil?
21
+
22
+ if LOOPBACK_BINDS.include?(bind)
23
+ Result.new(
24
+ name: name,
25
+ status: :pass,
26
+ message: "API bound to loopback (#{bind})"
27
+ )
28
+ elsif api_settings.dig(:auth, :enabled) == true
29
+ Result.new(
30
+ name: name,
31
+ status: :pass,
32
+ message: "API bound to #{bind} with auth enabled"
33
+ )
34
+ else
35
+ Result.new(
36
+ name: name,
37
+ status: :warn,
38
+ message: "API bound to non-loopback address (#{bind}) without explicit auth configuration",
39
+ prescription: "Set api.auth.enabled: true or change api.bind to '127.0.0.1'"
40
+ )
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def skip_result
47
+ Result.new(
48
+ name: name,
49
+ status: :pass,
50
+ message: 'API settings not loaded'
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Doctor
6
+ class ModeCheck
7
+ def name
8
+ 'Process mode'
9
+ end
10
+
11
+ def run
12
+ unless defined?(Legion::Settings)
13
+ return Result.new(
14
+ name: name,
15
+ status: :pass,
16
+ message: 'Settings not loaded'
17
+ )
18
+ end
19
+
20
+ explicit_mode = Legion::Settings.dig(:process, :mode) || Legion::Settings[:mode]
21
+
22
+ if explicit_mode
23
+ Result.new(
24
+ name: name,
25
+ status: :pass,
26
+ message: "Explicit process mode configured: #{explicit_mode}"
27
+ )
28
+ else
29
+ Result.new(
30
+ name: name,
31
+ status: :warn,
32
+ message: 'No explicit process.mode configured (defaulting to agent)',
33
+ prescription: 'Set {"process": {"mode": "agent"}} in settings to prepare for Phase 9 default change to worker'
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -17,6 +17,8 @@ module Legion
17
17
  autoload :PidCheck, 'legion/cli/doctor/pid_check'
18
18
  autoload :PermissionsCheck, 'legion/cli/doctor/permissions_check'
19
19
  autoload :TlsCheck, 'legion/cli/doctor/tls_check'
20
+ autoload :ApiBindCheck, 'legion/cli/doctor/api_bind_check'
21
+ autoload :ModeCheck, 'legion/cli/doctor/mode_check'
20
22
 
21
23
  def self.exit_on_failure?
22
24
  true
@@ -37,6 +39,8 @@ module Legion
37
39
  PidCheck
38
40
  PermissionsCheck
39
41
  TlsCheck
42
+ ApiBindCheck
43
+ ModeCheck
40
44
  ].freeze
41
45
 
42
46
  # Weights: security > connectivity > convenience
@@ -21,12 +21,22 @@ module Legion
21
21
  @local_tasks = []
22
22
  @actors = []
23
23
  @running_instances = Concurrent::Array.new
24
- @pending_actors = Concurrent::Array.new
24
+ @loaded_extensions = []
25
+ @pending_registrations = Concurrent::Array.new
25
26
 
26
27
  find_extensions
27
- load_extensions
28
+
29
+ phases = group_by_phase
30
+ phases.each do |phase_num, entries|
31
+ @pending_actors = Concurrent::Array.new
32
+ load_phase_extensions(phase_num, entries)
33
+ hook_phase_actors(phase_num)
34
+ end
35
+
36
+ @loaded_extensions&.each { |name| Catalog.transition(name, :running) }
37
+ Catalog.flush_persisted_transitions
38
+
28
39
  load_yaml_agents
29
- hook_all_actors
30
40
  end
31
41
 
32
42
  attr_reader :local_tasks
@@ -97,6 +107,21 @@ module Legion
97
107
  Legion::Logging.info "Successfully shut down all actors (#{(Time.now - shutdown_start).round(1)}s)"
98
108
  end
99
109
 
110
+ def flush_pending_registrations!
111
+ return if @pending_registrations.nil? || @pending_registrations.empty?
112
+
113
+ registrations = @pending_registrations
114
+ count = registrations.size
115
+ @pending_registrations = nil
116
+
117
+ registrations.each do |registration|
118
+ registration.publish
119
+ rescue StandardError => e
120
+ Legion::Logging.warn "[Extensions] flush registration failed: #{e.message}" if defined?(Legion::Logging)
121
+ end
122
+ Legion::Logging.info "[Extensions] flushed #{count} pending registrations" if defined?(Legion::Logging)
123
+ end
124
+
100
125
  def pause_actors
101
126
  @running_instances&.each do |inst|
102
127
  timer = inst.instance_variable_get(:@timer)
@@ -107,11 +132,8 @@ module Legion
107
132
  Legion::Logging.warn 'All actors paused' if defined?(Legion::Logging)
108
133
  end
109
134
 
110
- def load_extensions
111
- @extensions ||= []
112
- @loaded_extensions ||= []
113
-
114
- eligible = @extensions.filter_map do |entry|
135
+ def load_phase_extensions(phase_num, entries)
136
+ eligible = entries.filter_map do |entry|
115
137
  gem_name = entry[:gem_name]
116
138
  ext_name = entry[:require_path].split('/').last
117
139
 
@@ -130,15 +152,35 @@ module Legion
130
152
  load_extensions_parallel(eligible)
131
153
 
132
154
  Legion::Logging.info(
133
- "#{@extensions.count} extensions loaded with " \
134
- "subscription:#{@subscription_tasks.count}," \
155
+ "Phase #{phase_num}: #{eligible.count} extensions loaded " \
156
+ "(subscription:#{@subscription_tasks.count}," \
135
157
  "every:#{@timer_tasks.count}," \
136
158
  "poll:#{@poll_tasks.count}," \
137
159
  "once:#{@once_tasks.count}," \
138
- "loop:#{@loop_tasks.count}"
160
+ "loop:#{@loop_tasks.count})"
139
161
  )
140
162
  end
141
163
 
164
+ def hook_phase_actors(phase_num)
165
+ return if @pending_actors.nil? || @pending_actors.empty?
166
+
167
+ Legion::Logging.info "Phase #{phase_num}: hooking #{@pending_actors.size} deferred actors"
168
+
169
+ groups = group_pending_actors
170
+
171
+ %i[once poll every loop].each do |type|
172
+ next if groups[type].empty?
173
+
174
+ groups[type].each { |actor| hook_actor(**actor) }
175
+ end
176
+
177
+ hook_subscription_actors_pooled(groups[:subscription]) unless groups[:subscription].empty?
178
+
179
+ dispatch_local_actors(@local_tasks) unless @local_tasks.empty?
180
+
181
+ @pending_actors.clear
182
+ end
183
+
142
184
  def load_extensions_parallel(eligible)
143
185
  return if eligible.empty?
144
186
 
@@ -227,8 +269,15 @@ module Legion
227
269
  has_logger = extension.respond_to?(:log)
228
270
  extension.autobuild
229
271
 
272
+ register_identity_provider(extension, entry) if identity_provider?(extension)
273
+
230
274
  require 'legion/transport/messages/lex_register'
231
- Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners).publish
275
+ registration = Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners)
276
+ if @pending_registrations
277
+ @pending_registrations << registration
278
+ else
279
+ registration.publish
280
+ end
232
281
 
233
282
  register_capabilities(entry[:gem_name], extension.runners) if extension.respond_to?(:runners)
234
283
  write_lex_cli_manifest(entry, extension)
@@ -273,38 +322,6 @@ module Legion
273
322
  false
274
323
  end
275
324
 
276
- def hook_all_actors
277
- return if @pending_actors.nil? || @pending_actors.empty?
278
-
279
- Legion::Logging.info "Hooking #{@pending_actors.size} deferred actors"
280
-
281
- groups = group_pending_actors
282
-
283
- %i[once poll every loop].each do |type|
284
- next if groups[type].empty?
285
-
286
- Legion::Logging.info "Starting #{type} actors (#{groups[type].size})"
287
- groups[type].each { |actor| hook_actor(**actor) }
288
- end
289
- unless groups[:subscription].empty?
290
- Legion::Logging.info "Starting subscription actors (#{groups[:subscription].size})"
291
- hook_subscription_actors_pooled(groups[:subscription])
292
- end
293
- dispatch_local_actors(@local_tasks) unless @local_tasks.empty?
294
-
295
- @pending_actors.clear
296
- Legion::Logging.info(
297
- "Actors hooked: subscription:#{@subscription_tasks.count}," \
298
- "every:#{@timer_tasks.count}," \
299
- "poll:#{@poll_tasks.count}," \
300
- "once:#{@once_tasks.count}," \
301
- "loop:#{@loop_tasks.count}," \
302
- "local:#{@local_tasks.count}"
303
- )
304
- @loaded_extensions&.each { |name| Catalog.transition(name, :running) }
305
- Catalog.flush_persisted_transitions
306
- end
307
-
308
325
  ACTOR_TYPE_MAP = {
309
326
  Once: :once,
310
327
  Poll: :poll,
@@ -313,6 +330,17 @@ module Legion
313
330
  Subscription: :subscription
314
331
  }.freeze
315
332
 
333
+ def group_by_phase
334
+ settings_cats = ::Legion::Settings.dig(:extensions, :categories) || {}
335
+ categories = default_category_registry.merge(settings_cats)
336
+ default_phase = 1
337
+
338
+ @extensions.group_by do |entry|
339
+ cat = entry[:category]
340
+ categories.dig(cat, :phase) || default_phase
341
+ end.sort_by(&:first)
342
+ end
343
+
316
344
  def group_pending_actors
317
345
  groups = { once: [], poll: [], every: [], loop: [], subscription: [] }
318
346
  @pending_actors.each do |actor|
@@ -400,6 +428,39 @@ module Legion
400
428
 
401
429
  private
402
430
 
431
+ def identity_provider?(extension)
432
+ extension.respond_to?(:provider_name) &&
433
+ extension.respond_to?(:provider_type) &&
434
+ extension.respond_to?(:facing)
435
+ end
436
+
437
+ def register_identity_provider(extension, entry)
438
+ return unless defined?(Legion::Data) && Legion::Data.connected?
439
+ return unless defined?(Legion::Data::Model::IdentityProvider)
440
+
441
+ name = extension.provider_name.to_s
442
+ attrs = {
443
+ provider_type: extension.provider_type.to_s,
444
+ facing: extension.facing.to_s,
445
+ priority: extension.respond_to?(:priority) ? extension.priority : 100,
446
+ trust_weight: extension.respond_to?(:trust_weight) ? extension.trust_weight : 50,
447
+ capabilities: extension.respond_to?(:capabilities) ? Array(extension.capabilities).map(&:to_s) : [],
448
+ source: 'gem',
449
+ enabled: true
450
+ }
451
+
452
+ existing = Legion::Data::Model::IdentityProvider.where(name: name).first
453
+ if existing
454
+ diverged = attrs.any? { |k, v| existing.send(k).to_s != v.to_s }
455
+ Legion::Logging.info "[identity][provider] name=#{name} source=db/gem diverged=#{diverged}" if defined?(Legion::Logging)
456
+ else
457
+ Legion::Data::Model::IdentityProvider.insert_conflict(target: :name, update: attrs).insert(attrs.merge(name: name))
458
+ Legion::Logging.info "[identity][provider] name=#{name} registered" if defined?(Legion::Logging)
459
+ end
460
+ rescue StandardError => e
461
+ Legion::Logging.warn "[identity][provider] registration failed for #{entry[:gem_name]}: #{e.message}" if defined?(Legion::Logging)
462
+ end
463
+
403
464
  def write_lex_cli_manifest(entry, extension)
404
465
  require 'legion/cli/lex_cli_manifest'
405
466
 
@@ -661,9 +722,10 @@ module Legion
661
722
  ext_settings = ::Legion::Settings[:extensions] || {}
662
723
  categories = ext_settings[:categories] || default_category_registry
663
724
  lists = {
664
- core: Array(ext_settings[:core]),
665
- ai: Array(ext_settings[:ai]),
666
- gaia: Array(ext_settings[:gaia])
725
+ identity: Array(ext_settings[:identity]),
726
+ core: Array(ext_settings[:core]),
727
+ ai: Array(ext_settings[:ai]),
728
+ gaia: Array(ext_settings[:gaia])
667
729
  }
668
730
  ctx = {
669
731
  blocked: Array(ext_settings[:blocked]),
@@ -696,7 +758,7 @@ module Legion
696
758
  Legion::Logging.debug "Extensions#check_reserved_words failed to read reserved_prefixes: #{e.message}" if defined?(Legion::Logging)
697
759
  []
698
760
  end
699
- reserved_prefixes = configured_prefixes.empty? ? %w[core ai agentic gaia] : configured_prefixes
761
+ reserved_prefixes = configured_prefixes.empty? ? %w[core ai agentic gaia identity] : configured_prefixes
700
762
 
701
763
  configured_words = begin
702
764
  Array(::Legion::Settings.dig(:extensions, :reserved_words))
@@ -881,10 +943,11 @@ module Legion
881
943
 
882
944
  def default_category_registry
883
945
  {
884
- core: { type: :list, tier: 1 },
885
- ai: { type: :list, tier: 2 },
886
- gaia: { type: :list, tier: 3 },
887
- agentic: { type: :prefix, tier: 4 }
946
+ identity: { type: :prefix, tier: 0, phase: 0 },
947
+ core: { type: :list, tier: 1, phase: 1 },
948
+ ai: { type: :list, tier: 2, phase: 1 },
949
+ gaia: { type: :list, tier: 3, phase: 1 },
950
+ agentic: { type: :prefix, tier: 4, phase: 1 }
888
951
  }
889
952
  end
890
953