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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/lex-mesh.gemspec +3 -1
- data/lib/legion/extensions/mesh/actors/pending_expiry.rb +37 -0
- data/lib/legion/extensions/mesh/actors/preference_listener.rb +44 -0
- data/lib/legion/extensions/mesh/actors/silence_watchdog.rb +37 -0
- data/lib/legion/extensions/mesh/client.rb +7 -2
- data/lib/legion/extensions/mesh/helpers/delegation.rb +126 -0
- data/lib/legion/extensions/mesh/helpers/peer_verify.rb +79 -0
- data/lib/legion/extensions/mesh/helpers/pending_requests.rb +57 -0
- data/lib/legion/extensions/mesh/helpers/registry.rb +12 -0
- data/lib/legion/extensions/mesh/runners/delegation.rb +87 -0
- data/lib/legion/extensions/mesh/runners/mesh.rb +20 -0
- data/lib/legion/extensions/mesh/runners/preferences.rb +120 -0
- data/lib/legion/extensions/mesh/transport/messages/mesh_departure.rb +34 -0
- data/lib/legion/extensions/mesh/transport/messages/preference_query.rb +34 -0
- data/lib/legion/extensions/mesh/transport/messages/preference_response.rb +34 -0
- data/lib/legion/extensions/mesh/transport/queues/preference.rb +27 -0
- data/lib/legion/extensions/mesh/version.rb +1 -1
- data/lib/legion/extensions/mesh.rb +12 -0
- data/spec/legion/extensions/mesh/actors/pending_expiry_spec.rb +57 -0
- data/spec/legion/extensions/mesh/actors/preference_listener_spec.rb +87 -0
- data/spec/legion/extensions/mesh/actors/silence_watchdog_spec.rb +55 -0
- data/spec/legion/extensions/mesh/client_spec.rb +40 -1
- data/spec/legion/extensions/mesh/helpers/delegation_spec.rb +125 -0
- data/spec/legion/extensions/mesh/helpers/peer_verify_spec.rb +76 -0
- data/spec/legion/extensions/mesh/helpers/pending_requests_spec.rb +80 -0
- data/spec/legion/extensions/mesh/helpers/registry_spec.rb +41 -0
- data/spec/legion/extensions/mesh/runners/delegation_spec.rb +84 -0
- data/spec/legion/extensions/mesh/runners/mesh_spec.rb +25 -0
- data/spec/legion/extensions/mesh/runners/preferences_spec.rb +164 -0
- data/spec/legion/extensions/mesh/transport/messages/mesh_departure_spec.rb +81 -0
- data/spec/legion/extensions/mesh/transport/messages/preference_query_spec.rb +85 -0
- data/spec/legion/extensions/mesh/transport/messages/preference_response_spec.rb +85 -0
- data/spec/legion/extensions/mesh/transport/queues/preference_spec.rb +61 -0
- metadata +41 -3
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'ed25519'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Mesh::Helpers::PeerVerify do
|
|
7
|
+
let(:signing_key) { Ed25519::SigningKey.generate }
|
|
8
|
+
let(:verify_key) { signing_key.verify_key }
|
|
9
|
+
let(:private_key_b64) { Base64.strict_encode64(signing_key.to_bytes) }
|
|
10
|
+
let(:public_key_b64) { Base64.strict_encode64(verify_key.to_bytes) }
|
|
11
|
+
|
|
12
|
+
let(:peer_config) do
|
|
13
|
+
[{ org_id: 'acme', public_key: "ed25519:#{public_key_b64}",
|
|
14
|
+
capabilities: %w[query task], rate_limit: 5 }]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
before do
|
|
18
|
+
described_class.reset_counters!
|
|
19
|
+
stub_const('Legion::Settings', Module.new) unless defined?(Legion::Settings)
|
|
20
|
+
allow(Legion::Settings).to receive(:dig).with(:mesh, :trusted_peers).and_return(peer_config)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '.sign_message' do
|
|
24
|
+
it 'signs a payload with Ed25519' do
|
|
25
|
+
result = described_class.sign_message({ hello: 'world' }, private_key_b64)
|
|
26
|
+
expect(result[:signature]).to be_a(String)
|
|
27
|
+
expect(result[:payload]).to eq({ hello: 'world' })
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '.verify_message' do
|
|
32
|
+
it 'verifies a validly signed message' do
|
|
33
|
+
signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
|
|
34
|
+
result = described_class.verify_message(signed, org_id: 'acme')
|
|
35
|
+
expect(result[:valid]).to be true
|
|
36
|
+
expect(result[:org_id]).to eq('acme')
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'rejects a tampered message' do
|
|
40
|
+
signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
|
|
41
|
+
signed[:signed_bytes] = 'tampered data'
|
|
42
|
+
result = described_class.verify_message(signed, org_id: 'acme')
|
|
43
|
+
expect(result[:valid]).to be false
|
|
44
|
+
expect(result[:reason]).to eq(:invalid_signature)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'rejects unknown peers' do
|
|
48
|
+
signed = described_class.sign_message({ hello: 'world' }, private_key_b64)
|
|
49
|
+
result = described_class.verify_message(signed, org_id: 'unknown-org')
|
|
50
|
+
expect(result[:valid]).to be false
|
|
51
|
+
expect(result[:reason]).to eq(:unknown_peer)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '.check_rate_limit' do
|
|
56
|
+
it 'allows messages within limit' do
|
|
57
|
+
result = described_class.check_rate_limit('acme')
|
|
58
|
+
expect(result[:allowed]).to be true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'blocks messages over limit' do
|
|
62
|
+
5.times { described_class.check_rate_limit('acme') }
|
|
63
|
+
result = described_class.check_rate_limit('acme')
|
|
64
|
+
expect(result[:allowed]).to be false
|
|
65
|
+
expect(result[:error]).to eq(:rate_limited)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'resets after window expires' do
|
|
69
|
+
5.times { described_class.check_rate_limit('acme') }
|
|
70
|
+
counters = described_class.instance_variable_get(:@counters)
|
|
71
|
+
counters['acme'][:window_start] = Time.now.utc - 61
|
|
72
|
+
result = described_class.check_rate_limit('acme')
|
|
73
|
+
expect(result[:allowed]).to be true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/mesh/helpers/pending_requests'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Mesh::Helpers::PendingRequests do
|
|
7
|
+
subject(:tracker) { described_class.new(default_ttl: 5) }
|
|
8
|
+
|
|
9
|
+
describe '#register' do
|
|
10
|
+
it 'stores a pending request' do
|
|
11
|
+
tracker.register(correlation_id: 'abc', callback: ->(_r) {})
|
|
12
|
+
expect(tracker.pending?('abc')).to be true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'increments pending_count' do
|
|
16
|
+
expect { tracker.register(correlation_id: 'x', callback: nil) }
|
|
17
|
+
.to change { tracker.pending_count }.by(1)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
describe '#resolve' do
|
|
22
|
+
it 'invokes callback and removes entry' do
|
|
23
|
+
received = nil
|
|
24
|
+
tracker.register(correlation_id: 'r1', callback: ->(result) { received = result })
|
|
25
|
+
result = tracker.resolve(correlation_id: 'r1', result: { verbosity: :concise })
|
|
26
|
+
expect(result).to be true
|
|
27
|
+
expect(received).to eq({ verbosity: :concise })
|
|
28
|
+
expect(tracker.pending?('r1')).to be false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'returns false for unknown correlation_id' do
|
|
32
|
+
result = tracker.resolve(correlation_id: 'nonexistent', result: {})
|
|
33
|
+
expect(result).to be false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'handles nil callback gracefully' do
|
|
37
|
+
tracker.register(correlation_id: 'nil-cb', callback: nil)
|
|
38
|
+
expect { tracker.resolve(correlation_id: 'nil-cb', result: {}) }.not_to raise_error
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#pending?' do
|
|
43
|
+
it 'returns false for unregistered id' do
|
|
44
|
+
expect(tracker.pending?('missing')).to be false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe '#pending_count' do
|
|
49
|
+
it 'returns correct count' do
|
|
50
|
+
tracker.register(correlation_id: 'a', callback: nil)
|
|
51
|
+
tracker.register(correlation_id: 'b', callback: nil)
|
|
52
|
+
expect(tracker.pending_count).to eq(2)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe '#expire' do
|
|
57
|
+
it 'removes entries past TTL' do
|
|
58
|
+
tracker.register(correlation_id: 'old', callback: nil, ttl: 1)
|
|
59
|
+
entry = tracker.instance_variable_get(:@requests)['old']
|
|
60
|
+
entry[:registered_at] = Time.now - 10
|
|
61
|
+
expired = tracker.expire
|
|
62
|
+
expect(expired).to include('old')
|
|
63
|
+
expect(tracker.pending?('old')).to be false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'does not remove entries within TTL' do
|
|
67
|
+
tracker.register(correlation_id: 'fresh', callback: nil, ttl: 60)
|
|
68
|
+
tracker.expire
|
|
69
|
+
expect(tracker.pending?('fresh')).to be true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'returns array of expired correlation ids' do
|
|
73
|
+
tracker.register(correlation_id: 'e1', callback: nil, ttl: 1)
|
|
74
|
+
tracker.register(correlation_id: 'e2', callback: nil, ttl: 1)
|
|
75
|
+
tracker.instance_variable_get(:@requests).each_value { |e| e[:registered_at] = Time.now - 10 }
|
|
76
|
+
expired = tracker.expire
|
|
77
|
+
expect(expired).to contain_exactly('e1', 'e2')
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -272,6 +272,47 @@ RSpec.describe Legion::Extensions::Mesh::Helpers::Registry do
|
|
|
272
272
|
end
|
|
273
273
|
end
|
|
274
274
|
|
|
275
|
+
describe '#expire_silent_agents' do
|
|
276
|
+
it 'marks stale agents as offline' do
|
|
277
|
+
registry.register_agent('a1')
|
|
278
|
+
registry.agents['a1'][:last_seen] = Time.now.utc - 60
|
|
279
|
+
expired = registry.expire_silent_agents(timeout: 30)
|
|
280
|
+
expect(expired).to eq(['a1'])
|
|
281
|
+
expect(registry.agents['a1'][:status]).to eq(:offline)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it 'does not expire agents within timeout' do
|
|
285
|
+
registry.register_agent('a1')
|
|
286
|
+
expired = registry.expire_silent_agents(timeout: 30)
|
|
287
|
+
expect(expired).to eq([])
|
|
288
|
+
expect(registry.agents['a1'][:status]).to eq(:online)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
it 'does not expire already-offline agents' do
|
|
292
|
+
registry.register_agent('a1')
|
|
293
|
+
registry.agents['a1'][:last_seen] = Time.now.utc - 60
|
|
294
|
+
registry.agents['a1'][:status] = :offline
|
|
295
|
+
expired = registry.expire_silent_agents(timeout: 30)
|
|
296
|
+
expect(expired).to eq([])
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
it 'returns multiple expired agent ids' do
|
|
300
|
+
registry.register_agent('a1')
|
|
301
|
+
registry.register_agent('a2')
|
|
302
|
+
registry.agents['a1'][:last_seen] = Time.now.utc - 60
|
|
303
|
+
registry.agents['a2'][:last_seen] = Time.now.utc - 60
|
|
304
|
+
expired = registry.expire_silent_agents(timeout: 30)
|
|
305
|
+
expect(expired).to contain_exactly('a1', 'a2')
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it 'uses MESH_SILENCE_TIMEOUT as default' do
|
|
309
|
+
registry.register_agent('a1')
|
|
310
|
+
registry.agents['a1'][:last_seen] = Time.now.utc - 31
|
|
311
|
+
expired = registry.expire_silent_agents
|
|
312
|
+
expect(expired).to eq(['a1'])
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
275
316
|
describe '#count' do
|
|
276
317
|
it 'returns 0 for an empty registry' do
|
|
277
318
|
expect(registry.count).to eq(0)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Mesh::Runners::Delegation do
|
|
6
|
+
subject { Object.new.extend(described_class) }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
subject.instance_variable_set(:@delegation_tracker, nil)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
describe '#delegate' do
|
|
13
|
+
it 'creates a delegation' do
|
|
14
|
+
result = subject.delegate(from: 'a', to: 'b', task_context: 'task-1', consent_level: :execute)
|
|
15
|
+
expect(result[:success]).to be true
|
|
16
|
+
expect(result[:delegation_id]).to start_with('del-')
|
|
17
|
+
expect(result[:depth]).to eq(0)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'returns failure for depth exceeded' do
|
|
21
|
+
d1 = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
22
|
+
d2 = subject.delegate(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
|
|
23
|
+
parent_delegation_id: d1[:delegation_id])
|
|
24
|
+
d3 = subject.delegate(from: 'c', to: 'd', task_context: 't', consent_level: :execute,
|
|
25
|
+
parent_delegation_id: d2[:delegation_id])
|
|
26
|
+
d4 = subject.delegate(from: 'd', to: 'e', task_context: 't', consent_level: :execute,
|
|
27
|
+
parent_delegation_id: d3[:delegation_id])
|
|
28
|
+
expect(d4[:success]).to be false
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '#complete_delegation' do
|
|
33
|
+
it 'completes an active delegation' do
|
|
34
|
+
d = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
35
|
+
result = subject.complete_delegation(delegation_id: d[:delegation_id])
|
|
36
|
+
expect(result[:success]).to be true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'fails for non-existent delegation' do
|
|
40
|
+
result = subject.complete_delegation(delegation_id: 'del-fake')
|
|
41
|
+
expect(result[:success]).to be false
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#revoke_delegation' do
|
|
46
|
+
it 'revokes an active delegation' do
|
|
47
|
+
d = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
48
|
+
result = subject.revoke_delegation(delegation_id: d[:delegation_id])
|
|
49
|
+
expect(result[:success]).to be true
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe '#delegation_chain' do
|
|
54
|
+
it 'returns the full chain' do
|
|
55
|
+
d1 = subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
56
|
+
d2 = subject.delegate(from: 'b', to: 'c', task_context: 't', consent_level: :execute,
|
|
57
|
+
parent_delegation_id: d1[:delegation_id])
|
|
58
|
+
result = subject.delegation_chain(delegation_id: d2[:delegation_id])
|
|
59
|
+
expect(result[:success]).to be true
|
|
60
|
+
expect(result[:chain].size).to eq(2)
|
|
61
|
+
expect(result[:depth]).to eq(1)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#agent_delegations' do
|
|
66
|
+
it 'lists delegations for an agent' do
|
|
67
|
+
subject.delegate(from: 'a', to: 'b', task_context: 't1', consent_level: :execute)
|
|
68
|
+
subject.delegate(from: 'a', to: 'c', task_context: 't2', consent_level: :execute)
|
|
69
|
+
result = subject.agent_delegations(agent_id: 'a')
|
|
70
|
+
expect(result[:success]).to be true
|
|
71
|
+
expect(result[:count]).to eq(2)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe '#delegation_stats' do
|
|
76
|
+
it 'returns statistics' do
|
|
77
|
+
subject.delegate(from: 'a', to: 'b', task_context: 't', consent_level: :execute)
|
|
78
|
+
result = subject.delegation_stats
|
|
79
|
+
expect(result[:success]).to be true
|
|
80
|
+
expect(result[:total]).to eq(1)
|
|
81
|
+
expect(result[:active]).to eq(1)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -23,6 +23,22 @@ RSpec.describe Legion::Extensions::Mesh::Runners::Mesh do
|
|
|
23
23
|
result = client.unregister(agent_id: 'unknown')
|
|
24
24
|
expect(result[:error]).to eq(:not_found)
|
|
25
25
|
end
|
|
26
|
+
|
|
27
|
+
it 'publishes mesh departure signal when transport available' do
|
|
28
|
+
client.register(agent_id: 'agent-1', capabilities: [:search])
|
|
29
|
+
|
|
30
|
+
mock_msg = instance_double('MeshDeparture', publish: nil)
|
|
31
|
+
stub_const('Legion::Extensions::Mesh::Transport::Messages::MeshDeparture', class_double('MeshDeparture', new: mock_msg))
|
|
32
|
+
|
|
33
|
+
client.unregister(agent_id: 'agent-1')
|
|
34
|
+
expect(mock_msg).to have_received(:publish)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'succeeds without transport available' do
|
|
38
|
+
client.register(agent_id: 'agent-1')
|
|
39
|
+
result = client.unregister(agent_id: 'agent-1')
|
|
40
|
+
expect(result[:unregistered]).to be true
|
|
41
|
+
end
|
|
26
42
|
end
|
|
27
43
|
|
|
28
44
|
describe '#send_message' do
|
|
@@ -65,4 +81,13 @@ RSpec.describe Legion::Extensions::Mesh::Runners::Mesh do
|
|
|
65
81
|
expect(status[:online]).to eq(1)
|
|
66
82
|
end
|
|
67
83
|
end
|
|
84
|
+
|
|
85
|
+
describe '#expire_silent_agents' do
|
|
86
|
+
it 'returns expired agent count' do
|
|
87
|
+
client.register(agent_id: 'a1')
|
|
88
|
+
result = client.expire_silent_agents
|
|
89
|
+
expect(result[:success]).to be true
|
|
90
|
+
expect(result[:count]).to eq(0)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
68
93
|
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/mesh/helpers/pending_requests'
|
|
5
|
+
require 'legion/extensions/mesh/helpers/preference_profile'
|
|
6
|
+
require 'legion/extensions/mesh/runners/preferences'
|
|
7
|
+
|
|
8
|
+
RSpec.describe Legion::Extensions::Mesh::Runners::Preferences do
|
|
9
|
+
let(:runner) { Object.new.extend(described_class) }
|
|
10
|
+
|
|
11
|
+
describe '#query_preferences' do
|
|
12
|
+
context 'without transport' do
|
|
13
|
+
it 'returns local_default profile' do
|
|
14
|
+
result = runner.query_preferences(target_agent_id: 'agent-99')
|
|
15
|
+
expect(result[:success]).to be true
|
|
16
|
+
expect(result[:source]).to eq(:local_default)
|
|
17
|
+
expect(result[:profile]).to be_a(Hash)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'includes default preference keys in profile' do
|
|
21
|
+
result = runner.query_preferences(target_agent_id: 'agent-99')
|
|
22
|
+
expect(result[:profile]).to have_key(:verbosity)
|
|
23
|
+
expect(result[:profile]).to have_key(:tone)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
context 'with transport available' do
|
|
28
|
+
before do
|
|
29
|
+
stub_const('Legion::Transport::Connection', Class.new do
|
|
30
|
+
def self.session; end
|
|
31
|
+
end)
|
|
32
|
+
stub_const('Legion::Extensions::Mesh::Transport::Messages::PreferenceQuery',
|
|
33
|
+
Class.new do
|
|
34
|
+
def initialize(**_opts); end
|
|
35
|
+
|
|
36
|
+
def publish; end
|
|
37
|
+
end)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'returns pending status with correlation_id' do
|
|
41
|
+
result = runner.query_preferences(target_agent_id: 'agent-42')
|
|
42
|
+
expect(result[:success]).to be true
|
|
43
|
+
expect(result[:source]).to eq(:pending)
|
|
44
|
+
expect(result[:correlation_id]).to be_a(String)
|
|
45
|
+
expect(result[:correlation_id]).not_to be_empty
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'registers a pending request' do
|
|
49
|
+
result = runner.query_preferences(target_agent_id: 'agent-42')
|
|
50
|
+
pending_req = runner.send(:pending_requests)
|
|
51
|
+
expect(pending_req.pending?(result[:correlation_id])).to be true
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe '#handle_preference_query' do
|
|
57
|
+
it 'returns local profile' do
|
|
58
|
+
result = runner.handle_preference_query(requesting_agent_id: 'agent-1')
|
|
59
|
+
expect(result[:success]).to be true
|
|
60
|
+
expect(result[:profile]).to be_a(Hash)
|
|
61
|
+
expect(result[:responding_agent_id]).not_to be_nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'includes verbosity in profile' do
|
|
65
|
+
result = runner.handle_preference_query(requesting_agent_id: 'agent-1')
|
|
66
|
+
expect(result[:profile]).to have_key(:verbosity)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe '#handle_preference_response' do
|
|
71
|
+
it 'resolves a pending request' do
|
|
72
|
+
received = nil
|
|
73
|
+
pending_req = runner.send(:pending_requests)
|
|
74
|
+
pending_req.register(
|
|
75
|
+
correlation_id: 'corr-1',
|
|
76
|
+
callback: ->(profile) { received = profile }
|
|
77
|
+
)
|
|
78
|
+
result = runner.handle_preference_response(
|
|
79
|
+
correlation_id: 'corr-1',
|
|
80
|
+
profile: { verbosity: :concise }
|
|
81
|
+
)
|
|
82
|
+
expect(result[:resolved]).to be true
|
|
83
|
+
expect(received).to eq({ verbosity: :concise })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'returns false for unknown correlation_id' do
|
|
87
|
+
result = runner.handle_preference_response(
|
|
88
|
+
correlation_id: 'unknown-corr',
|
|
89
|
+
profile: {}
|
|
90
|
+
)
|
|
91
|
+
expect(result[:resolved]).to be false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe '#dispatch_preference_message' do
|
|
96
|
+
context 'with preference_query type' do
|
|
97
|
+
it 'calls handle_preference_query and returns profile' do
|
|
98
|
+
result = runner.dispatch_preference_message(
|
|
99
|
+
type: 'preference_query',
|
|
100
|
+
requesting_agent_id: 'agent-1',
|
|
101
|
+
correlation_id: 'corr-123'
|
|
102
|
+
)
|
|
103
|
+
expect(result[:success]).to be true
|
|
104
|
+
expect(result[:profile]).to be_a(Hash)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
context 'with preference_response type' do
|
|
109
|
+
it 'resolves a pending request' do
|
|
110
|
+
pending_req = runner.send(:pending_requests)
|
|
111
|
+
pending_req.register(correlation_id: 'corr-abc', callback: ->(_p) {})
|
|
112
|
+
result = runner.dispatch_preference_message(
|
|
113
|
+
type: 'preference_response',
|
|
114
|
+
correlation_id: 'corr-abc',
|
|
115
|
+
profile: { verbosity: :concise }
|
|
116
|
+
)
|
|
117
|
+
expect(result[:resolved]).to be true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'returns false for unknown correlation_id' do
|
|
121
|
+
result = runner.dispatch_preference_message(
|
|
122
|
+
type: 'preference_response',
|
|
123
|
+
correlation_id: 'unknown',
|
|
124
|
+
profile: {}
|
|
125
|
+
)
|
|
126
|
+
expect(result[:resolved]).to be false
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
context 'with unknown type' do
|
|
131
|
+
it 'returns error' do
|
|
132
|
+
result = runner.dispatch_preference_message(type: 'bogus')
|
|
133
|
+
expect(result[:success]).to be false
|
|
134
|
+
expect(result[:error]).to include('unknown')
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
context 'with nil type' do
|
|
139
|
+
it 'returns error' do
|
|
140
|
+
result = runner.dispatch_preference_message(type: nil)
|
|
141
|
+
expect(result[:success]).to be false
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
describe '#expire_pending_requests' do
|
|
147
|
+
it 'cleans up expired entries and returns count' do
|
|
148
|
+
pending_req = runner.send(:pending_requests)
|
|
149
|
+
pending_req.register(correlation_id: 'old-1', callback: nil, ttl: 1)
|
|
150
|
+
pending_req.register(correlation_id: 'old-2', callback: nil, ttl: 1)
|
|
151
|
+
pending_req.instance_variable_get(:@requests).each_value { |e| e[:registered_at] = Time.now - 60 }
|
|
152
|
+
|
|
153
|
+
result = runner.expire_pending_requests
|
|
154
|
+
expect(result[:expired]).to eq(2)
|
|
155
|
+
expect(result[:correlation_ids]).to contain_exactly('old-1', 'old-2')
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'returns zero when nothing expired' do
|
|
159
|
+
result = runner.expire_pending_requests
|
|
160
|
+
expect(result[:expired]).to eq(0)
|
|
161
|
+
expect(result[:correlation_ids]).to be_empty
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
unless defined?(Legion::Transport::Message)
|
|
6
|
+
module Legion
|
|
7
|
+
module Transport
|
|
8
|
+
class Message
|
|
9
|
+
def initialize(**options)
|
|
10
|
+
@options = options
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
unless defined?(Legion::Transport::Exchanges::Node)
|
|
18
|
+
module Legion
|
|
19
|
+
module Transport
|
|
20
|
+
module Exchanges
|
|
21
|
+
class Node # rubocop:disable Lint/EmptyClass
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
require 'legion/extensions/mesh/transport/messages/mesh_departure'
|
|
29
|
+
|
|
30
|
+
RSpec.describe Legion::Extensions::Mesh::Transport::Messages::MeshDeparture do
|
|
31
|
+
subject(:msg) do
|
|
32
|
+
described_class.new(
|
|
33
|
+
agent_id: 'agent-42',
|
|
34
|
+
capabilities: %i[code_review search]
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '#exchange' do
|
|
39
|
+
it 'returns the Node exchange' do
|
|
40
|
+
expect(msg.exchange).to eq(Legion::Transport::Exchanges::Node)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe '#routing_key' do
|
|
45
|
+
it 'uses mesh.departure' do
|
|
46
|
+
expect(msg.routing_key).to eq('mesh.departure')
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe '#message' do
|
|
51
|
+
it 'includes type mesh_departure' do
|
|
52
|
+
expect(msg.message[:type]).to eq('mesh_departure')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'includes agent_id' do
|
|
56
|
+
expect(msg.message[:agent_id]).to eq('agent-42')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'includes capabilities' do
|
|
60
|
+
expect(msg.message[:capabilities]).to eq(%i[code_review search])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'includes departed_at timestamp' do
|
|
64
|
+
expect(msg.message[:departed_at]).to be_a(String)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe '#type' do
|
|
69
|
+
it 'returns mesh_departure' do
|
|
70
|
+
expect(msg.type).to eq('mesh_departure')
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe 'empty capabilities default' do
|
|
75
|
+
subject(:msg_no_caps) { described_class.new(agent_id: 'x') }
|
|
76
|
+
|
|
77
|
+
it 'defaults capabilities to empty array' do
|
|
78
|
+
expect(msg_no_caps.message[:capabilities]).to eq([])
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
unless defined?(Legion::Transport::Message)
|
|
6
|
+
module Legion
|
|
7
|
+
module Transport
|
|
8
|
+
class Message
|
|
9
|
+
def initialize(**options)
|
|
10
|
+
@options = options
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
unless defined?(Legion::Transport::Exchanges::Agent)
|
|
18
|
+
module Legion
|
|
19
|
+
module Transport
|
|
20
|
+
module Exchanges
|
|
21
|
+
class Agent
|
|
22
|
+
def self.name
|
|
23
|
+
'Legion::Transport::Exchanges::Agent'
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
require 'legion/extensions/mesh/transport/messages/preference_query'
|
|
32
|
+
|
|
33
|
+
RSpec.describe Legion::Extensions::Mesh::Transport::Messages::PreferenceQuery do
|
|
34
|
+
subject(:msg) do
|
|
35
|
+
described_class.new(
|
|
36
|
+
target_agent_id: 'agent-42',
|
|
37
|
+
requesting_agent_id: 'agent-1',
|
|
38
|
+
domains: %i[verbosity tone]
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#exchange' do
|
|
43
|
+
it 'returns the Agent exchange' do
|
|
44
|
+
expect(msg.exchange).to eq(Legion::Transport::Exchanges::Agent)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe '#routing_key' do
|
|
49
|
+
it 'routes to target agent' do
|
|
50
|
+
expect(msg.routing_key).to eq('agent.agent-42.preferences')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
describe '#message' do
|
|
55
|
+
it 'includes type preference_query' do
|
|
56
|
+
expect(msg.message[:type]).to eq('preference_query')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'includes requesting_agent_id' do
|
|
60
|
+
expect(msg.message[:requesting_agent_id]).to eq('agent-1')
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'includes domains' do
|
|
64
|
+
expect(msg.message[:domains]).to eq(%i[verbosity tone])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'includes requested_at timestamp' do
|
|
68
|
+
expect(msg.message[:requested_at]).to be_a(String)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
describe '#type' do
|
|
73
|
+
it 'returns preference_query' do
|
|
74
|
+
expect(msg.type).to eq('preference_query')
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe 'empty domains default' do
|
|
79
|
+
subject(:msg_no_domains) { described_class.new(target_agent_id: 'x', requesting_agent_id: 'y') }
|
|
80
|
+
|
|
81
|
+
it 'defaults domains to empty array' do
|
|
82
|
+
expect(msg_no_domains.message[:domains]).to eq([])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|