lex-mesh 0.1.1 → 0.2.4

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/lex-mesh.gemspec +3 -1
  4. data/lib/legion/extensions/mesh/actors/pending_expiry.rb +37 -0
  5. data/lib/legion/extensions/mesh/actors/preference_listener.rb +44 -0
  6. data/lib/legion/extensions/mesh/actors/silence_watchdog.rb +37 -0
  7. data/lib/legion/extensions/mesh/client.rb +7 -2
  8. data/lib/legion/extensions/mesh/helpers/delegation.rb +126 -0
  9. data/lib/legion/extensions/mesh/helpers/peer_verify.rb +79 -0
  10. data/lib/legion/extensions/mesh/helpers/pending_requests.rb +57 -0
  11. data/lib/legion/extensions/mesh/helpers/registry.rb +12 -0
  12. data/lib/legion/extensions/mesh/runners/delegation.rb +87 -0
  13. data/lib/legion/extensions/mesh/runners/mesh.rb +20 -0
  14. data/lib/legion/extensions/mesh/runners/preferences.rb +120 -0
  15. data/lib/legion/extensions/mesh/transport/messages/mesh_departure.rb +34 -0
  16. data/lib/legion/extensions/mesh/transport/messages/preference_query.rb +34 -0
  17. data/lib/legion/extensions/mesh/transport/messages/preference_response.rb +34 -0
  18. data/lib/legion/extensions/mesh/transport/queues/preference.rb +27 -0
  19. data/lib/legion/extensions/mesh/version.rb +1 -1
  20. data/lib/legion/extensions/mesh.rb +12 -0
  21. data/spec/legion/extensions/mesh/actors/pending_expiry_spec.rb +57 -0
  22. data/spec/legion/extensions/mesh/actors/preference_listener_spec.rb +87 -0
  23. data/spec/legion/extensions/mesh/actors/silence_watchdog_spec.rb +55 -0
  24. data/spec/legion/extensions/mesh/client_spec.rb +40 -1
  25. data/spec/legion/extensions/mesh/helpers/delegation_spec.rb +125 -0
  26. data/spec/legion/extensions/mesh/helpers/peer_verify_spec.rb +76 -0
  27. data/spec/legion/extensions/mesh/helpers/pending_requests_spec.rb +80 -0
  28. data/spec/legion/extensions/mesh/helpers/registry_spec.rb +41 -0
  29. data/spec/legion/extensions/mesh/runners/delegation_spec.rb +84 -0
  30. data/spec/legion/extensions/mesh/runners/mesh_spec.rb +25 -0
  31. data/spec/legion/extensions/mesh/runners/preferences_spec.rb +164 -0
  32. data/spec/legion/extensions/mesh/transport/messages/mesh_departure_spec.rb +81 -0
  33. data/spec/legion/extensions/mesh/transport/messages/preference_query_spec.rb +85 -0
  34. data/spec/legion/extensions/mesh/transport/messages/preference_response_spec.rb +85 -0
  35. data/spec/legion/extensions/mesh/transport/queues/preference_spec.rb +61 -0
  36. metadata +41 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b30e6f5f2a0518bde780c8b661c910fe26ec503ddb285ce60883120f8826825
4
- data.tar.gz: 456c2c731747543cebb0515abfc44b497382f6c3ca10eb0ad52b21f5816367f8
3
+ metadata.gz: 25cafa977ed6aaf671c903e71895312253a412c32c141e8de9674b1a86747bff
4
+ data.tar.gz: c7bbdde6f4c065f0fdaea58a5a5124bd53e49f0c48e0d275de0828bd93ec2439
5
5
  SHA512:
6
- metadata.gz: 3b6044cd55148c45c91ce715476a0566a8f2aa6a4aafba4607d0049b80eeffe3578e31ceb6e53a5828b9e857335b0a72a6f4eb3af170fd2ca60c4ce4047da534
7
- data.tar.gz: c8002ef1a93733b0e8f7848bf4db6903c90146cd67882d015c6b2d229b3f03dcff3d2abc2fcdeefb0c7b1ab043d1b5a6c172a129475f1044e1063dad6273d63e
6
+ metadata.gz: 76357a6ca4c31089fd7f5317097f935b07df6349476b344bd2681dccfbd796d79b1061922b22d42ca45b92d045bdbf901ed8d6c502f525cff3a668099dfea56c
7
+ data.tar.gz: 9e17a698c0b941fd2000ab9affffa87cd3bad983b37acacce794ee0fb4d975a8cdc04eee44480f976856e712fb6af8dfa80f976d07c2fd94ea302ba61f31b389
data/Gemfile CHANGED
@@ -4,7 +4,7 @@ source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
6
 
7
+ gem 'base64'
8
+ gem 'ed25519', '~> 1.3'
7
9
  gem 'rspec', '~> 3.13'
8
10
  gem 'rubocop', '~> 1.75', require: false
9
-
10
- gem 'legion-gaia', path: '../../legion-gaia'
data/lex-mesh.gemspec CHANGED
@@ -25,5 +25,7 @@ Gem::Specification.new do |spec|
25
25
  Dir.glob('{lib,spec}/**/*') + %w[lex-mesh.gemspec Gemfile]
26
26
  end
27
27
  spec.require_paths = ['lib']
28
- spec.add_development_dependency 'legion-gaia'
28
+
29
+ spec.add_dependency 'base64'
30
+ spec.add_dependency 'ed25519', '~> 1.3'
29
31
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Actor
9
+ class PendingExpiry < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::Mesh::Runners::Preferences
12
+ end
13
+
14
+ def runner_function
15
+ 'expire_pending_requests'
16
+ end
17
+
18
+ def time
19
+ 30
20
+ end
21
+
22
+ def use_runner?
23
+ false
24
+ end
25
+
26
+ def check_subtask?
27
+ false
28
+ end
29
+
30
+ def generate_task?
31
+ false
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/subscription'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Actor
9
+ class PreferenceListener < Legion::Extensions::Actors::Subscription
10
+ def runner_class
11
+ Legion::Extensions::Mesh::Runners::Preferences
12
+ end
13
+
14
+ def runner_function
15
+ 'dispatch_preference_message'
16
+ end
17
+
18
+ def check_subtask?
19
+ false
20
+ end
21
+
22
+ def generate_task?
23
+ false
24
+ end
25
+
26
+ def use_runner?
27
+ false
28
+ end
29
+
30
+ def queue
31
+ Legion::Extensions::Mesh::Transport::Queues::Preference
32
+ end
33
+
34
+ def enabled?
35
+ defined?(Legion::Extensions::Mesh::Runners::Preferences) &&
36
+ defined?(Legion::Transport)
37
+ rescue StandardError
38
+ false
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Actor
9
+ class SilenceWatchdog < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::Mesh::Runners::Mesh
12
+ end
13
+
14
+ def runner_function
15
+ 'expire_silent_agents'
16
+ end
17
+
18
+ def time
19
+ 15
20
+ end
21
+
22
+ def use_runner?
23
+ false
24
+ end
25
+
26
+ def check_subtask?
27
+ false
28
+ end
29
+
30
+ def generate_task?
31
+ false
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,16 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/extensions/mesh/runners/mesh'
4
+ require 'legion/extensions/mesh/runners/preferences'
5
+ require 'legion/extensions/mesh/helpers/preference_profile'
6
+ require 'legion/extensions/mesh/helpers/pending_requests'
3
7
  require 'legion/extensions/mesh/helpers/topology'
4
8
  require 'legion/extensions/mesh/helpers/registry'
5
- require 'legion/extensions/mesh/runners/mesh'
6
9
 
7
10
  module Legion
8
11
  module Extensions
9
12
  module Mesh
10
13
  class Client
11
14
  include Runners::Mesh
15
+ include Runners::Preferences
12
16
 
13
- def initialize(**)
17
+ def initialize(**opts)
18
+ @opts = opts
14
19
  @mesh_registry = Helpers::Registry.new
15
20
  end
16
21
 
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Helpers
9
+ class Delegation
10
+ CONSENT_LEVELS = %i[read execute admin].freeze
11
+
12
+ attr_reader :delegations
13
+
14
+ def initialize(max_depth: 3, max_active_per_agent: 10)
15
+ @delegations = {}
16
+ @agent_delegations = Hash.new { |h, k| h[k] = [] }
17
+ @max_depth = max_depth
18
+ @max_active_per_agent = max_active_per_agent
19
+ end
20
+
21
+ def create(from:, to:, task_context:, consent_level:, parent_delegation_id: nil)
22
+ depth = compute_depth(parent_delegation_id)
23
+ return { error: :max_depth_exceeded } if depth >= @max_depth
24
+
25
+ if parent_delegation_id
26
+ parent = find(parent_delegation_id)
27
+ return { error: :consent_escalation } if parent && CONSENT_LEVELS.index(consent_level) > CONSENT_LEVELS.index(parent[:consent_level])
28
+ end
29
+
30
+ active_count = (@agent_delegations[from] || []).count do |id|
31
+ @delegations[id]&.fetch(:status, nil) == :active
32
+ end
33
+ return { error: :max_active_exceeded } if active_count >= @max_active_per_agent
34
+
35
+ id = "del-#{SecureRandom.uuid}"
36
+ record = {
37
+ delegation_id: id,
38
+ from_agent_id: from,
39
+ to_agent_id: to,
40
+ task_context: task_context,
41
+ consent_level: consent_level,
42
+ parent_delegation_id: parent_delegation_id,
43
+ depth: depth,
44
+ status: :active,
45
+ created_at: Time.now.utc,
46
+ completed_at: nil
47
+ }
48
+ @delegations[id] = record
49
+ @agent_delegations[from] << id
50
+ @agent_delegations[to] << id
51
+ record
52
+ end
53
+
54
+ def complete(delegation_id)
55
+ record = @delegations[delegation_id]
56
+ return nil unless record && record[:status] == :active
57
+
58
+ record[:status] = :completed
59
+ record[:completed_at] = Time.now.utc
60
+ record
61
+ end
62
+
63
+ def revoke(delegation_id)
64
+ record = @delegations[delegation_id]
65
+ return nil unless record && record[:status] == :active
66
+
67
+ record[:status] = :revoked
68
+ record[:completed_at] = Time.now.utc
69
+ cascade_revoke(delegation_id)
70
+ record
71
+ end
72
+
73
+ def chain(delegation_id)
74
+ result = []
75
+ current = @delegations[delegation_id]
76
+ while current
77
+ result.unshift(current)
78
+ current = current[:parent_delegation_id] ? @delegations[current[:parent_delegation_id]] : nil
79
+ end
80
+ result
81
+ end
82
+
83
+ def for_agent(agent_id, status: nil)
84
+ ids = @agent_delegations[agent_id] || []
85
+ results = ids.filter_map { |id| @delegations[id] }
86
+ results = results.select { |d| d[:status] == status } if status
87
+ results
88
+ end
89
+
90
+ def find(delegation_id)
91
+ @delegations[delegation_id]
92
+ end
93
+
94
+ def stats
95
+ active = @delegations.values.count { |d| d[:status] == :active }
96
+ depths = @delegations.values.map { |d| d[:depth] }
97
+ {
98
+ total: @delegations.size,
99
+ active: active,
100
+ avg_depth: depths.empty? ? 0 : depths.sum.to_f / depths.size,
101
+ max_depth: depths.max || 0
102
+ }
103
+ end
104
+
105
+ private
106
+
107
+ def compute_depth(parent_delegation_id)
108
+ return 0 unless parent_delegation_id
109
+
110
+ parent = find(parent_delegation_id)
111
+ (parent&.fetch(:depth, 0) || 0) + 1
112
+ end
113
+
114
+ def cascade_revoke(parent_id)
115
+ @delegations.each_value do |d|
116
+ next unless d[:parent_delegation_id] == parent_id && d[:status] == :active
117
+
118
+ d[:status] = :revoked
119
+ d[:completed_at] = Time.now.utc
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Helpers
9
+ module PeerVerify
10
+ class << self
11
+ def sign_message(payload, private_key_b64)
12
+ require 'ed25519'
13
+ key = Ed25519::SigningKey.new(Base64.strict_decode64(private_key_b64))
14
+ message_bytes = json_dump(payload)
15
+ signature = key.sign(message_bytes)
16
+ { payload: payload, signature: Base64.strict_encode64(signature), signed_bytes: message_bytes }
17
+ end
18
+
19
+ def verify_message(signed_message, org_id:)
20
+ peer = find_peer(org_id)
21
+ return { valid: false, org_id: org_id, reason: :unknown_peer } unless peer
22
+
23
+ require 'ed25519'
24
+ pub_key_b64 = peer[:public_key].sub(/\Aed25519:/, '')
25
+ verify_key = Ed25519::VerifyKey.new(Base64.strict_decode64(pub_key_b64))
26
+ signature = Base64.strict_decode64(signed_message[:signature])
27
+ message_bytes = signed_message[:signed_bytes] || json_dump(signed_message[:payload])
28
+ verify_key.verify(signature, message_bytes)
29
+ { valid: true, org_id: org_id }
30
+ rescue Ed25519::VerifyError
31
+ { valid: false, org_id: org_id, reason: :invalid_signature }
32
+ rescue StandardError => e
33
+ { valid: false, org_id: org_id, reason: :error, message: e.message }
34
+ end
35
+
36
+ def check_rate_limit(org_id)
37
+ @counters ||= Hash.new { |h, k| h[k] = { count: 0, window_start: Time.now.utc } }
38
+ counter = @counters[org_id]
39
+ peer = find_peer(org_id)
40
+ limit = peer&.dig(:rate_limit) || 100
41
+
42
+ if Time.now.utc - counter[:window_start] > 60
43
+ counter[:count] = 0
44
+ counter[:window_start] = Time.now.utc
45
+ end
46
+
47
+ counter[:count] += 1
48
+ return { allowed: true, remaining: limit - counter[:count] } if counter[:count] <= limit
49
+
50
+ { allowed: false, error: :rate_limited, org_id: org_id }
51
+ end
52
+
53
+ def reset_counters!
54
+ @counters = nil
55
+ end
56
+
57
+ private
58
+
59
+ def find_peer(org_id)
60
+ return nil unless defined?(Legion::Settings)
61
+
62
+ peers = Legion::Settings.dig(:mesh, :trusted_peers) || []
63
+ peers.find { |p| p[:org_id] == org_id }
64
+ end
65
+
66
+ def json_dump(data)
67
+ if defined?(Legion::JSON)
68
+ Legion::JSON.dump({ data: data })
69
+ else
70
+ require 'json'
71
+ ::JSON.dump({ data: data })
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Mesh
6
+ module Helpers
7
+ class PendingRequests
8
+ def initialize(default_ttl: 5)
9
+ @default_ttl = default_ttl
10
+ @requests = {}
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def register(correlation_id:, callback:, ttl: nil)
15
+ @mutex.synchronize do
16
+ @requests[correlation_id] = {
17
+ callback: callback,
18
+ registered_at: Time.now,
19
+ ttl: ttl || @default_ttl
20
+ }
21
+ end
22
+ end
23
+
24
+ def resolve(correlation_id:, result:) # rubocop:disable Naming/PredicateMethod
25
+ entry = @mutex.synchronize { @requests.delete(correlation_id) }
26
+ return false unless entry
27
+
28
+ entry[:callback]&.call(result)
29
+ true
30
+ end
31
+
32
+ def pending?(correlation_id)
33
+ @mutex.synchronize { @requests.key?(correlation_id) }
34
+ end
35
+
36
+ def pending_count
37
+ @mutex.synchronize { @requests.size }
38
+ end
39
+
40
+ def expire
41
+ now = Time.now
42
+ expired = []
43
+ @mutex.synchronize do
44
+ @requests.each do |id, entry|
45
+ next unless now - entry[:registered_at] >= entry[:ttl]
46
+
47
+ expired << id
48
+ end
49
+ expired.each { |id| @requests.delete(id) }
50
+ end
51
+ expired
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -65,6 +65,18 @@ module Legion
65
65
  msg
66
66
  end
67
67
 
68
+ def expire_silent_agents(timeout: Topology::MESH_SILENCE_TIMEOUT)
69
+ cutoff = Time.now.utc - timeout
70
+ expired = []
71
+ @agents.each_value do |agent|
72
+ next unless agent[:status] == :online && agent[:last_seen] < cutoff
73
+
74
+ agent[:status] = :offline
75
+ expired << agent[:agent_id]
76
+ end
77
+ expired
78
+ end
79
+
68
80
  def online_agents
69
81
  @agents.values.select { |a| a[:status] == :online }
70
82
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/delegation'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Runners
9
+ module Delegation
10
+ include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers::Lex)
11
+
12
+ def delegate(from:, to:, task_context:, consent_level: :execute, parent_delegation_id: nil, **) # rubocop:disable Metrics/ParameterLists
13
+ result = delegation_tracker.create(
14
+ from: from,
15
+ to: to,
16
+ task_context: task_context,
17
+ consent_level: consent_level.to_sym,
18
+ parent_delegation_id: parent_delegation_id
19
+ )
20
+ return { success: false, **result } if result[:error]
21
+
22
+ publish_delegation_event('delegation.created', result)
23
+ { success: true, delegation_id: result[:delegation_id], depth: result[:depth] }
24
+ end
25
+
26
+ def complete_delegation(delegation_id:, **)
27
+ result = delegation_tracker.complete(delegation_id)
28
+ return { success: false, reason: :not_found_or_inactive } unless result
29
+
30
+ publish_delegation_event('delegation.completed', result)
31
+ { success: true, delegation_id: delegation_id }
32
+ end
33
+
34
+ def revoke_delegation(delegation_id:, **)
35
+ result = delegation_tracker.revoke(delegation_id)
36
+ return { success: false, reason: :not_found_or_inactive } unless result
37
+
38
+ publish_delegation_event('delegation.revoked', result)
39
+ { success: true, delegation_id: delegation_id }
40
+ end
41
+
42
+ def delegation_chain(delegation_id:, **)
43
+ chain = delegation_tracker.chain(delegation_id)
44
+ { success: true, chain: chain, depth: [chain.size - 1, 0].max }
45
+ end
46
+
47
+ def agent_delegations(agent_id:, status: nil, **)
48
+ results = delegation_tracker.for_agent(agent_id, status: status&.to_sym)
49
+ { success: true, delegations: results, count: results.size }
50
+ end
51
+
52
+ def delegation_stats(**)
53
+ delegation_tracker.stats.merge(success: true)
54
+ end
55
+
56
+ private
57
+
58
+ def publish_delegation_event(event_type, record)
59
+ return unless defined?(Legion::Extensions::Audit::Transport::Messages::Audit)
60
+
61
+ Legion::Extensions::Audit::Transport::Messages::Audit.new(
62
+ event_type: event_type,
63
+ principal_id: record[:from_agent_id],
64
+ action: event_type.split('.').last,
65
+ resource: "delegation:#{record[:delegation_id]}",
66
+ detail: { to: record[:to_agent_id], depth: record[:depth], consent: record[:consent_level] }
67
+ ).publish
68
+ rescue StandardError => e
69
+ Legion::Logging.warn "[mesh] failed to publish #{event_type}: #{e.message}" if defined?(Legion::Logging)
70
+ end
71
+
72
+ def delegation_tracker
73
+ @delegation_tracker ||= begin
74
+ max_depth = nil
75
+ max_active = nil
76
+ if defined?(Legion::Settings)
77
+ max_depth = Legion::Settings.dig(:mesh, :delegation, :max_depth)
78
+ max_active = Legion::Settings.dig(:mesh, :delegation, :max_active_per_agent)
79
+ end
80
+ Helpers::Delegation.new(max_depth: max_depth || 3, max_active_per_agent: max_active || 10)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -18,6 +18,7 @@ module Legion
18
18
  result = mesh_registry.unregister_agent(agent_id)
19
19
  if result
20
20
  Legion::Logging.info "[mesh] unregistered: agent=#{agent_id}"
21
+ publish_mesh_departure(agent_id: agent_id, capabilities: result[:capabilities] || [])
21
22
  { unregistered: true }
22
23
  else
23
24
  Legion::Logging.debug "[mesh] unregister failed: agent=#{agent_id} not found"
@@ -55,8 +56,27 @@ module Legion
55
56
  { total: total, online: online.size, message_count: msgs }
56
57
  end
57
58
 
59
+ def expire_silent_agents(**)
60
+ expired = mesh_registry.expire_silent_agents
61
+ expired.each do |agent_id|
62
+ Legion::Logging.info "[mesh] expired silent agent: #{agent_id}"
63
+ end
64
+ { success: true, expired: expired, count: expired.size }
65
+ end
66
+
58
67
  private
59
68
 
69
+ def publish_mesh_departure(agent_id:, capabilities:)
70
+ return unless defined?(Legion::Extensions::Mesh::Transport::Messages::MeshDeparture)
71
+
72
+ Legion::Extensions::Mesh::Transport::Messages::MeshDeparture.new(
73
+ agent_id: agent_id, capabilities: capabilities
74
+ ).publish
75
+ Legion::Logging.debug "[mesh] departure signal published: agent=#{agent_id}"
76
+ rescue StandardError => e
77
+ Legion::Logging.warn "[mesh] failed to publish departure signal: #{e.message}"
78
+ end
79
+
60
80
  def mesh_registry
61
81
  @mesh_registry ||= Helpers::Registry.new
62
82
  end