lex-mesh 0.1.1 → 0.2.3

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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +0 -2
  3. data/lex-mesh.gemspec +0 -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/pending_requests.rb +57 -0
  9. data/lib/legion/extensions/mesh/helpers/registry.rb +12 -0
  10. data/lib/legion/extensions/mesh/runners/mesh.rb +20 -0
  11. data/lib/legion/extensions/mesh/runners/preferences.rb +120 -0
  12. data/lib/legion/extensions/mesh/transport/messages/mesh_departure.rb +34 -0
  13. data/lib/legion/extensions/mesh/transport/messages/preference_query.rb +34 -0
  14. data/lib/legion/extensions/mesh/transport/messages/preference_response.rb +34 -0
  15. data/lib/legion/extensions/mesh/transport/queues/preference.rb +27 -0
  16. data/lib/legion/extensions/mesh/version.rb +1 -1
  17. data/lib/legion/extensions/mesh.rb +9 -0
  18. data/spec/legion/extensions/mesh/actors/pending_expiry_spec.rb +57 -0
  19. data/spec/legion/extensions/mesh/actors/preference_listener_spec.rb +87 -0
  20. data/spec/legion/extensions/mesh/actors/silence_watchdog_spec.rb +55 -0
  21. data/spec/legion/extensions/mesh/client_spec.rb +40 -1
  22. data/spec/legion/extensions/mesh/helpers/pending_requests_spec.rb +80 -0
  23. data/spec/legion/extensions/mesh/helpers/registry_spec.rb +41 -0
  24. data/spec/legion/extensions/mesh/runners/mesh_spec.rb +25 -0
  25. data/spec/legion/extensions/mesh/runners/preferences_spec.rb +164 -0
  26. data/spec/legion/extensions/mesh/transport/messages/mesh_departure_spec.rb +81 -0
  27. data/spec/legion/extensions/mesh/transport/messages/preference_query_spec.rb +85 -0
  28. data/spec/legion/extensions/mesh/transport/messages/preference_response_spec.rb +85 -0
  29. data/spec/legion/extensions/mesh/transport/queues/preference_spec.rb +61 -0
  30. metadata +20 -16
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b30e6f5f2a0518bde780c8b661c910fe26ec503ddb285ce60883120f8826825
4
- data.tar.gz: 456c2c731747543cebb0515abfc44b497382f6c3ca10eb0ad52b21f5816367f8
3
+ metadata.gz: 75ab8e1dbc78d725a92257640520bfda3f3acb36ae1dc1f1315cc7e8453ca467
4
+ data.tar.gz: 269027a72a18770e8bf0338fa7c2941bc354546ffc6157bd2533fc258b054baf
5
5
  SHA512:
6
- metadata.gz: 3b6044cd55148c45c91ce715476a0566a8f2aa6a4aafba4607d0049b80eeffe3578e31ceb6e53a5828b9e857335b0a72a6f4eb3af170fd2ca60c4ce4047da534
7
- data.tar.gz: c8002ef1a93733b0e8f7848bf4db6903c90146cd67882d015c6b2d229b3f03dcff3d2abc2fcdeefb0c7b1ab043d1b5a6c172a129475f1044e1063dad6273d63e
6
+ metadata.gz: 78afa9c435f105a2c18b107d14de0c6d4b9c8850c5181ce394aafe46613c70dedccb24b5b19dbf5ee71f2fa71657905e656d2d4eb916f3429446f0c61398a46d
7
+ data.tar.gz: 766eb42fd99abaa287af0cd1f48b8daba505f9e4d20dccb927e748cb9734460839915ba2e8513aa8df5bf28817f032f230e08e5bf775f3330d9e4f34d48f47db
data/Gemfile CHANGED
@@ -6,5 +6,3 @@ gemspec
6
6
 
7
7
  gem 'rspec', '~> 3.13'
8
8
  gem 'rubocop', '~> 1.75', require: false
9
-
10
- gem 'legion-gaia', path: '../../legion-gaia'
data/lex-mesh.gemspec CHANGED
@@ -25,5 +25,4 @@ 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'
29
28
  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,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
@@ -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
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Mesh
8
+ module Runners
9
+ module Preferences
10
+ def query_preferences(target_agent_id:, domains: nil, callback: nil, ttl: 5, **)
11
+ default_profile = Helpers::PreferenceProfile.resolve(owner_id: target_agent_id)
12
+
13
+ return { success: true, source: :local_default, profile: default_profile } unless transport_available?
14
+
15
+ correlation_id = SecureRandom.uuid
16
+ cb = callback || default_preference_callback(target_agent_id: target_agent_id)
17
+ pending_requests.register(correlation_id: correlation_id, callback: cb, ttl: ttl)
18
+
19
+ publish_preference_query(
20
+ target_agent_id: target_agent_id,
21
+ correlation_id: correlation_id,
22
+ domains: domains
23
+ )
24
+
25
+ { success: true, source: :pending, correlation_id: correlation_id, profile: default_profile }
26
+ rescue StandardError => e
27
+ { success: true, source: :local_default, profile: default_profile, error: e.message }
28
+ end
29
+
30
+ def handle_preference_query(**)
31
+ owner_id = local_agent_id
32
+ profile = Helpers::PreferenceProfile.resolve(owner_id: owner_id)
33
+
34
+ { success: true, profile: profile, responding_agent_id: local_agent_id }
35
+ rescue StandardError => e
36
+ { success: false, error: e.message }
37
+ end
38
+
39
+ def handle_preference_response(correlation_id:, profile:, **)
40
+ resolved = pending_requests.resolve(correlation_id: correlation_id, result: profile)
41
+ { resolved: resolved }
42
+ end
43
+
44
+ def expire_pending_requests(**)
45
+ expired = pending_requests.expire
46
+ { expired: expired.size, correlation_ids: expired }
47
+ end
48
+
49
+ def dispatch_preference_message(type: nil, **msg)
50
+ case type
51
+ when 'preference_query'
52
+ profile = handle_preference_query(**msg)
53
+ publish_preference_response(msg, profile) if profile[:success]
54
+ profile
55
+ when 'preference_response'
56
+ handle_preference_response(
57
+ correlation_id: msg[:correlation_id],
58
+ profile: msg[:profile] || {}
59
+ )
60
+ else
61
+ { success: false, error: "unknown preference message type: #{type}" }
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def pending_requests
68
+ @pending_requests ||= Helpers::PendingRequests.new
69
+ end
70
+
71
+ def transport_available?
72
+ defined?(Legion::Transport::Connection) &&
73
+ Legion::Transport::Connection.respond_to?(:session)
74
+ end
75
+
76
+ def local_agent_id
77
+ if defined?(Legion::Settings)
78
+ Legion::Settings['client']['name']
79
+ else
80
+ 'unknown'
81
+ end
82
+ end
83
+
84
+ def publish_preference_response(msg, profile)
85
+ return unless transport_available?
86
+
87
+ Transport::Messages::PreferenceResponse.new(
88
+ target_agent_id: msg[:requesting_agent_id],
89
+ responding_agent_id: local_agent_id,
90
+ profile: profile[:profile],
91
+ correlation_id: msg[:correlation_id]
92
+ ).publish
93
+ rescue StandardError => e
94
+ log_debug("[mesh] failed to publish preference response: #{e.message}")
95
+ end
96
+
97
+ def publish_preference_query(target_agent_id:, correlation_id:, domains:)
98
+ Transport::Messages::PreferenceQuery.new(
99
+ target_agent_id: target_agent_id,
100
+ requesting_agent_id: local_agent_id,
101
+ domains: domains || [],
102
+ reply_to: "agent.#{local_agent_id}.preferences",
103
+ correlation_id: correlation_id
104
+ ).publish
105
+ end
106
+
107
+ def default_preference_callback(target_agent_id:)
108
+ lambda do |profile|
109
+ log_debug("[mesh] received preferences for #{target_agent_id}: #{profile.keys.join(', ')}")
110
+ end
111
+ end
112
+
113
+ def log_debug(msg)
114
+ Legion::Logging.debug(msg) if defined?(Legion::Logging)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Mesh
6
+ module Transport
7
+ module Messages
8
+ class MeshDeparture < Legion::Transport::Message
9
+ def exchange
10
+ Legion::Transport::Exchanges::Node
11
+ end
12
+
13
+ def routing_key
14
+ 'mesh.departure'
15
+ end
16
+
17
+ def message
18
+ {
19
+ type: 'mesh_departure',
20
+ agent_id: @options[:agent_id],
21
+ capabilities: @options[:capabilities] || [],
22
+ departed_at: Time.now.to_s
23
+ }
24
+ end
25
+
26
+ def type
27
+ 'mesh_departure'
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Mesh
6
+ module Transport
7
+ module Messages
8
+ class PreferenceQuery < Legion::Transport::Message
9
+ def exchange
10
+ Legion::Transport::Exchanges::Agent
11
+ end
12
+
13
+ def routing_key
14
+ "agent.#{@options[:target_agent_id]}.preferences"
15
+ end
16
+
17
+ def message
18
+ {
19
+ type: 'preference_query',
20
+ requesting_agent_id: @options[:requesting_agent_id],
21
+ domains: @options[:domains] || [],
22
+ requested_at: Time.now.to_s
23
+ }
24
+ end
25
+
26
+ def type
27
+ 'preference_query'
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Mesh
6
+ module Transport
7
+ module Messages
8
+ class PreferenceResponse < Legion::Transport::Message
9
+ def exchange
10
+ Legion::Transport::Exchanges::Agent
11
+ end
12
+
13
+ def routing_key
14
+ "agent.#{@options[:target_agent_id]}.preferences"
15
+ end
16
+
17
+ def message
18
+ {
19
+ type: 'preference_response',
20
+ responding_agent_id: @options[:responding_agent_id],
21
+ profile: @options[:profile] || {},
22
+ responded_at: Time.now.to_s
23
+ }
24
+ end
25
+
26
+ def type
27
+ 'preference_response'
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Mesh
6
+ module Transport
7
+ module Queues
8
+ class Preference < Legion::Transport::Queues::Agent
9
+ def initialize(agent_id: nil, **)
10
+ super
11
+ end
12
+
13
+ def queue_name
14
+ agent = @agent_id || Legion::Settings['client']['name']
15
+ "agent.#{agent}.preferences"
16
+ end
17
+
18
+ def bind_routing_key
19
+ agent = @agent_id || Legion::Settings['client']['name']
20
+ "agent.#{agent}.preferences"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Mesh
6
- VERSION = '0.1.1'
6
+ VERSION = '0.2.3'
7
7
  end
8
8
  end
9
9
  end
@@ -4,7 +4,16 @@ require 'legion/extensions/mesh/version'
4
4
  require 'legion/extensions/mesh/helpers/topology'
5
5
  require 'legion/extensions/mesh/helpers/registry'
6
6
  require 'legion/extensions/mesh/helpers/preference_profile'
7
+ require 'legion/extensions/mesh/helpers/pending_requests'
7
8
  require 'legion/extensions/mesh/runners/mesh'
9
+ require 'legion/extensions/mesh/runners/preferences'
10
+
11
+ if defined?(Legion::Transport)
12
+ require 'legion/extensions/mesh/transport/messages/preference_query'
13
+ require 'legion/extensions/mesh/transport/messages/preference_response'
14
+ require 'legion/extensions/mesh/transport/messages/mesh_departure'
15
+ require 'legion/extensions/mesh/transport/queues/preference'
16
+ end
8
17
 
9
18
  module Legion
10
19
  module Extensions
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stub the framework actor base class
4
+ unless defined?(Legion::Extensions::Actors::Every)
5
+ module Legion
6
+ module Extensions
7
+ module Actors
8
+ class Every # rubocop:disable Lint/EmptyClass
9
+ end
10
+ end
11
+ end
12
+ end
13
+
14
+ $LOADED_FEATURES << 'legion/extensions/actors/every' unless $LOADED_FEATURES.include?('legion/extensions/actors/every')
15
+ end
16
+
17
+ require 'legion/extensions/mesh/actors/pending_expiry'
18
+
19
+ RSpec.describe Legion::Extensions::Mesh::Actor::PendingExpiry do
20
+ subject(:actor) { described_class.new }
21
+
22
+ describe '#runner_class' do
23
+ it 'returns the Preferences runner module' do
24
+ expect(actor.runner_class).to eq(Legion::Extensions::Mesh::Runners::Preferences)
25
+ end
26
+ end
27
+
28
+ describe '#runner_function' do
29
+ it 'returns expire_pending_requests' do
30
+ expect(actor.runner_function).to eq('expire_pending_requests')
31
+ end
32
+ end
33
+
34
+ describe '#time' do
35
+ it 'returns 30' do
36
+ expect(actor.time).to eq(30)
37
+ end
38
+ end
39
+
40
+ describe '#use_runner?' do
41
+ it 'returns false' do
42
+ expect(actor.use_runner?).to be false
43
+ end
44
+ end
45
+
46
+ describe '#check_subtask?' do
47
+ it 'returns false' do
48
+ expect(actor.check_subtask?).to be false
49
+ end
50
+ end
51
+
52
+ describe '#generate_task?' do
53
+ it 'returns false' do
54
+ expect(actor.generate_task?).to be false
55
+ end
56
+ end
57
+ end