lex-swarm 0.1.1 → 0.2.0

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: 1b72b8c33768eb24380924cb9f33a342dd5e0e07fa1daaf151089de1d38e141e
4
- data.tar.gz: 790c2c25675b41bb049410a15f21585fc361a00963d771af73752b612c2d15e9
3
+ metadata.gz: 578c4f78e11c1cf390624da98867d76b55274db01186715405a0e423645b99ae
4
+ data.tar.gz: 4584ad7f4ebe1c53eef32337404d21f930e23703f182f5f2ea835bb2c6982778
5
5
  SHA512:
6
- metadata.gz: 9ca0b32d4b9c8af87086c777c71822c32228b195e0e1c7cb825c89c9a883dd97cee6e8815e228ca4d4a8a07ce1d31813eca91e09191405f06b45c9ed0c347453
7
- data.tar.gz: 6765b85f142bebdb96f537860d4984e56c762551d87da9b6fd3ab0a3b028c6f6fc3236e46b50bf445a020bf1d570c6fae3791af408c6cdc77d6ea77f68382c64
6
+ metadata.gz: be53ef935c5ae6c6196ed41ffa15b0cbfc8ce1c708d37fccf2ecc8d75c91a511f6e2132952a7449fa58e9d56d762a8a72876a0cb9c81a0b93ce214399b168f9b
7
+ data.tar.gz: 5707c99712feea161e00e88a70767e74bbdd3daddb9509d99cd4b94d5236a9f246cedf109cba9a64d1077311f540163418f1f81b47f0603583334661285ad4dc
data/Gemfile CHANGED
@@ -8,4 +8,4 @@ gem 'rspec', '~> 3.13'
8
8
  gem 'rubocop', '~> 1.75', require: false
9
9
  gem 'rubocop-rspec', require: false
10
10
 
11
- gem 'legion-gaia', path: '../../legion-gaia'
11
+ gem 'legion-gaia'
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Swarm
6
+ module Actor
7
+ class OrphanSweeper < Legion::Extensions::Actors::Every
8
+ def time = 300
9
+ def run_now? = false
10
+ def use_runner? = false
11
+ def check_subtask? = false
12
+ def generate_task? = false
13
+
14
+ def action(**_opts)
15
+ return { swept: 0 } unless defined?(Legion::Data)
16
+
17
+ tasks = Legion::Data.connection[:tasks]
18
+ orphans = tasks.exclude(parent_id: nil)
19
+ .exclude(status: %w[complete failed])
20
+ .all
21
+ .select do |t|
22
+ parent = tasks.where(id: t[:parent_id]).first
23
+ parent.nil? || %w[complete failed].include?(parent[:status])
24
+ end
25
+
26
+ orphans.each do |orphan|
27
+ tasks.where(id: orphan[:id]).update(status: 'failed')
28
+ end
29
+
30
+ { swept: orphans.size }
31
+ rescue StandardError => e
32
+ { swept: 0, error: e.message }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require_relative '../helpers/workspace'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module Swarm
9
+ module Actors
10
+ class WorkspaceSync
11
+ ROUTING_PREFIX = 'swarm.workspace'
12
+
13
+ def publish_change(charter_id:, key:, operation:, value: nil, author: nil, version: nil, **) # rubocop:disable Metrics/ParameterLists
14
+ return { success: true, skipped: :no_transport } unless defined?(Legion::Transport)
15
+
16
+ routing_key = "#{ROUTING_PREFIX}.#{charter_id}"
17
+ payload = { charter_id: charter_id, key: key, value: value,
18
+ author: author, version: version, operation: operation,
19
+ timestamp: Time.now.utc.to_s }
20
+
21
+ Legion::Transport.publish(routing_key: routing_key, payload: payload)
22
+ Legion::Logging.debug "[swarm-workspace-sync] published #{operation} #{key} to #{routing_key}"
23
+ { success: true, routing_key: routing_key }
24
+ rescue StandardError => e
25
+ Legion::Logging.warn "[swarm-workspace-sync] publish failed: #{e.message}"
26
+ { success: true, skipped: :publish_error, message: e.message }
27
+ end
28
+
29
+ def apply_incoming(charter_id:, key:, operation:, value: nil, author: nil, version: nil, timestamp: nil, **) # rubocop:disable Metrics/ParameterLists
30
+ op = operation.to_s
31
+ case op
32
+ when 'put'
33
+ ts = timestamp.is_a?(String) ? Time.parse(timestamp) : (timestamp || Time.now.utc)
34
+ applied = workspace.apply_remote(charter_id, key: key, value: value,
35
+ author: author, version: version || 1, timestamp: ts)
36
+ { success: true, applied: applied }
37
+ when 'delete'
38
+ workspace.delete(charter_id, key: key)
39
+ { success: true, applied: true }
40
+ else
41
+ { success: false, reason: :unknown_operation, operation: op }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def workspace
48
+ @workspace ||= Helpers::Workspace.new
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -3,12 +3,14 @@
3
3
  require 'legion/extensions/swarm/helpers/charter'
4
4
  require 'legion/extensions/swarm/helpers/swarm_store'
5
5
  require 'legion/extensions/swarm/runners/swarm'
6
+ require 'legion/extensions/swarm/runners/workspace'
6
7
 
7
8
  module Legion
8
9
  module Extensions
9
10
  module Swarm
10
11
  class Client
11
12
  include Runners::Swarm
13
+ include Runners::Workspace
12
14
 
13
15
  def initialize(**)
14
16
  @swarm_store = Helpers::SwarmStore.new
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Swarm
6
+ module Helpers
7
+ module SubAgent
8
+ def spawn(runner:, function:, payload:, parent_task_id:, **)
9
+ return { success: false, reason: :data_unavailable } unless defined?(Legion::Data::Model::Task)
10
+
11
+ parent = Legion::Data::Model::Task[parent_task_id]
12
+ return { success: false, reason: :parent_not_found } unless parent
13
+
14
+ new_depth = (parent.respond_to?(:depth) ? parent.depth.to_i : 0) + 1
15
+
16
+ return { success: false, reason: :depth_exceeded } if new_depth > max_depth
17
+ return { success: false, reason: :concurrent_exceeded } if concurrent_children_count > max_concurrent
18
+ return { success: false, reason: :per_parent_exceeded } if children_of(parent_task_id) > max_per_parent
19
+
20
+ if defined?(Legion::Ingress)
21
+ result = Legion::Ingress.run(
22
+ payload: payload,
23
+ runner_class: runner,
24
+ function: function,
25
+ source: 'sub_agent',
26
+ generate_task: true,
27
+ check_subtask: false
28
+ )
29
+ { success: true, task_id: result[:task_id], depth: new_depth }
30
+ else
31
+ { success: false, reason: :ingress_unavailable }
32
+ end
33
+ rescue StandardError => e
34
+ { success: false, reason: :error, message: e.message }
35
+ end
36
+
37
+ private
38
+
39
+ def max_depth
40
+ Legion::Settings.dig(:swarm, :max_depth) || 3
41
+ rescue StandardError
42
+ 3
43
+ end
44
+
45
+ def max_concurrent
46
+ Legion::Settings.dig(:swarm, :max_concurrent) || 20
47
+ rescue StandardError
48
+ 20
49
+ end
50
+
51
+ def max_per_parent
52
+ Legion::Settings.dig(:swarm, :max_per_parent) || 10
53
+ rescue StandardError
54
+ 10
55
+ end
56
+
57
+ def concurrent_children_count
58
+ return 0 unless defined?(Legion::Data)
59
+
60
+ Legion::Data.connection[:tasks]
61
+ .exclude(parent_id: nil)
62
+ .exclude(status: %w[complete failed])
63
+ .count
64
+ rescue StandardError
65
+ 0
66
+ end
67
+
68
+ def children_of(parent_task_id)
69
+ return 0 unless defined?(Legion::Data)
70
+
71
+ Legion::Data.connection[:tasks]
72
+ .where(parent_id: parent_task_id)
73
+ .exclude(status: %w[complete failed])
74
+ .count
75
+ rescue StandardError
76
+ 0
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Swarm
6
+ module Helpers
7
+ class Workspace
8
+ def initialize
9
+ @store = {}
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def put(charter_id, key:, value:, author:)
14
+ @mutex.synchronize do
15
+ charter_store = (@store[charter_id] ||= {})
16
+ existing = charter_store[key]
17
+ version = existing ? existing[:version] + 1 : 1
18
+ charter_store[key] = {
19
+ key: key,
20
+ value: value,
21
+ author: author,
22
+ version: version,
23
+ timestamp: Time.now.utc
24
+ }
25
+ end
26
+ end
27
+
28
+ def get(charter_id, key:)
29
+ @mutex.synchronize do
30
+ @store.dig(charter_id, key)
31
+ end
32
+ end
33
+
34
+ def list(charter_id)
35
+ @mutex.synchronize do
36
+ (@store[charter_id] || {}).dup
37
+ end
38
+ end
39
+
40
+ def delete(charter_id, key:)
41
+ @mutex.synchronize do
42
+ charter_store = @store[charter_id]
43
+ return nil unless charter_store
44
+
45
+ charter_store.delete(key)
46
+ end
47
+ end
48
+
49
+ def clear_charter(charter_id)
50
+ @mutex.synchronize do
51
+ @store.delete(charter_id)
52
+ end
53
+ end
54
+
55
+ def apply_remote(charter_id, **entry)
56
+ key = entry[:key]
57
+ value = entry[:value]
58
+ author = entry[:author]
59
+ version = entry[:version]
60
+ timestamp = entry[:timestamp]
61
+
62
+ @mutex.synchronize do
63
+ charter_store = (@store[charter_id] ||= {})
64
+ existing = charter_store[key]
65
+
66
+ if existing.nil? || version > existing[:version] ||
67
+ (version == existing[:version] && timestamp > existing[:timestamp])
68
+ charter_store[key] = {
69
+ key: key,
70
+ value: value,
71
+ author: author,
72
+ version: version,
73
+ timestamp: timestamp
74
+ }
75
+ return true
76
+ end
77
+ false
78
+ end
79
+ end
80
+
81
+ def stats
82
+ @mutex.synchronize do
83
+ total = @store.values.sum(&:size)
84
+ { charter_count: @store.size, total_entries: total }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Swarm
6
+ module Runners
7
+ module SpawnChild
8
+ include Helpers::SubAgent if defined?(Helpers::SubAgent)
9
+
10
+ def spawn_child(runner:, function:, payload:, parent_task_id:, **)
11
+ spawn(runner: runner, function: function, payload: payload, parent_task_id: parent_task_id)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/workspace'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Swarm
8
+ module Runners
9
+ module Workspace
10
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex)
12
+
13
+ def workspace_put(charter_id:, key:, value:, author:, **)
14
+ entry = workspace.put(charter_id, key: key, value: value, author: author)
15
+ Legion::Logging.debug "[swarm-workspace] put: charter=#{charter_id[0..7]} key=#{key} v=#{entry[:version]}"
16
+ { success: true, key: key, version: entry[:version], charter_id: charter_id }
17
+ end
18
+
19
+ def workspace_get(charter_id:, key:, **)
20
+ entry = workspace.get(charter_id, key: key)
21
+ if entry
22
+ { success: true, entry: entry }
23
+ else
24
+ { success: false, reason: :not_found, key: key, charter_id: charter_id }
25
+ end
26
+ end
27
+
28
+ def workspace_list(charter_id:, **)
29
+ entries = workspace.list(charter_id)
30
+ { success: true, entries: entries, count: entries.size }
31
+ end
32
+
33
+ def workspace_delete(charter_id:, key:, **)
34
+ removed = workspace.delete(charter_id, key: key)
35
+ if removed
36
+ Legion::Logging.debug "[swarm-workspace] delete: charter=#{charter_id[0..7]} key=#{key}"
37
+ { success: true, key: key, charter_id: charter_id }
38
+ else
39
+ { success: false, reason: :not_found, key: key, charter_id: charter_id }
40
+ end
41
+ end
42
+
43
+ def workspace_clear(charter_id:, **)
44
+ workspace.clear_charter(charter_id)
45
+ Legion::Logging.debug "[swarm-workspace] clear: charter=#{charter_id[0..7]}"
46
+ { success: true, charter_id: charter_id }
47
+ end
48
+
49
+ def workspace_stats(**)
50
+ workspace.stats.merge(success: true)
51
+ end
52
+
53
+ private
54
+
55
+ def workspace
56
+ @workspace ||= Helpers::Workspace.new
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Swarm
6
- VERSION = '0.1.1'
6
+ VERSION = '0.2.0'
7
7
  end
8
8
  end
9
9
  end
@@ -3,7 +3,11 @@
3
3
  require 'legion/extensions/swarm/version'
4
4
  require 'legion/extensions/swarm/helpers/charter'
5
5
  require 'legion/extensions/swarm/helpers/swarm_store'
6
+ require 'legion/extensions/swarm/helpers/sub_agent'
7
+ require 'legion/extensions/swarm/helpers/workspace'
6
8
  require 'legion/extensions/swarm/runners/swarm'
9
+ require 'legion/extensions/swarm/runners/spawn_child'
10
+ require 'legion/extensions/swarm/runners/workspace'
7
11
 
8
12
  module Legion
9
13
  module Extensions
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Actors
6
+ unless defined?(Every)
7
+ class Every # rubocop:disable Lint/EmptyClass
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
13
+
14
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
15
+
16
+ require_relative '../../../../../lib/legion/extensions/swarm/actors/orphan_sweeper'
17
+
18
+ RSpec.describe Legion::Extensions::Swarm::Actor::OrphanSweeper do
19
+ subject(:actor) { described_class.new }
20
+
21
+ describe '#time' do
22
+ it 'returns 300 seconds' do
23
+ expect(actor.time).to eq(300)
24
+ end
25
+ end
26
+
27
+ describe '#run_now?' do
28
+ it 'returns false' do
29
+ expect(actor.run_now?).to be false
30
+ end
31
+ end
32
+
33
+ describe '#action' do
34
+ context 'when Legion::Data is not available' do
35
+ it 'returns zero swept' do
36
+ result = actor.action
37
+ expect(result[:swept]).to eq(0)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require_relative '../../../../../lib/legion/extensions/swarm/actors/workspace_sync'
5
+
6
+ RSpec.describe Legion::Extensions::Swarm::Actors::WorkspaceSync do
7
+ subject(:actor) { described_class.allocate }
8
+
9
+ describe '#publish_change' do
10
+ it 'returns skipped when transport is not available' do
11
+ result = actor.publish_change(charter_id: 'c1', key: 'k', value: 'v',
12
+ author: 'a', version: 1, operation: :put)
13
+ expect(result[:success]).to be true
14
+ expect(result[:skipped]).to eq(:no_transport)
15
+ end
16
+ end
17
+
18
+ describe '#apply_incoming' do
19
+ let(:workspace) { Legion::Extensions::Swarm::Helpers::Workspace.new }
20
+
21
+ before { actor.instance_variable_set(:@workspace, workspace) }
22
+
23
+ it 'applies a remote put operation' do
24
+ result = actor.apply_incoming(
25
+ charter_id: 'c1', key: 'shared', value: 'remote-data',
26
+ author: 'remote-agent', version: 1, timestamp: Time.now.utc.to_s, operation: 'put'
27
+ )
28
+ expect(result[:success]).to be true
29
+ expect(result[:applied]).to be true
30
+ expect(workspace.get('c1', key: 'shared')[:value]).to eq('remote-data')
31
+ end
32
+
33
+ it 'applies a remote delete operation' do
34
+ workspace.put('c1', key: 'doomed', value: 'bye', author: 'local')
35
+ result = actor.apply_incoming(
36
+ charter_id: 'c1', key: 'doomed', operation: 'delete'
37
+ )
38
+ expect(result[:success]).to be true
39
+ expect(workspace.get('c1', key: 'doomed')).to be_nil
40
+ end
41
+
42
+ it 'rejects unknown operations' do
43
+ result = actor.apply_incoming(charter_id: 'c1', key: 'x', operation: 'explode')
44
+ expect(result[:success]).to be false
45
+ expect(result[:reason]).to eq(:unknown_operation)
46
+ end
47
+ end
48
+ end
@@ -13,4 +13,24 @@ RSpec.describe Legion::Extensions::Swarm::Client do
13
13
  expect(client).to respond_to(:active_swarms)
14
14
  expect(client).to respond_to(:swarm_status)
15
15
  end
16
+
17
+ it 'responds to workspace runner methods' do
18
+ client = described_class.new
19
+ expect(client).to respond_to(:workspace_put)
20
+ expect(client).to respond_to(:workspace_get)
21
+ expect(client).to respond_to(:workspace_list)
22
+ expect(client).to respond_to(:workspace_delete)
23
+ expect(client).to respond_to(:workspace_clear)
24
+ expect(client).to respond_to(:workspace_stats)
25
+ end
26
+
27
+ describe '#workspace_put and #workspace_get' do
28
+ it 'stores and retrieves workspace entries via client' do
29
+ client = described_class.new
30
+ client.workspace_put(charter_id: 'c1', key: 'data', value: 'hello', author: 'a')
31
+ result = client.workspace_get(charter_id: 'c1', key: 'data')
32
+ expect(result[:success]).to be true
33
+ expect(result[:entry][:value]).to eq('hello')
34
+ end
35
+ end
16
36
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Swarm::Helpers::SubAgent do
6
+ let(:helper_instance) { Class.new { include Legion::Extensions::Swarm::Helpers::SubAgent }.new }
7
+
8
+ before do
9
+ stub_const('Legion::Settings', Module.new do
10
+ def self.dig(*keys)
11
+ case keys
12
+ when %i[swarm max_depth] then 3
13
+ when %i[swarm max_concurrent] then 20
14
+ when %i[swarm max_per_parent] then 10
15
+ end
16
+ end
17
+ end)
18
+ end
19
+
20
+ describe '#max_depth' do
21
+ it 'returns configured max depth' do
22
+ expect(helper_instance.send(:max_depth)).to eq(3)
23
+ end
24
+ end
25
+
26
+ describe '#max_concurrent' do
27
+ it 'returns configured max concurrent' do
28
+ expect(helper_instance.send(:max_concurrent)).to eq(20)
29
+ end
30
+ end
31
+
32
+ describe '#max_per_parent' do
33
+ it 'returns configured max per parent' do
34
+ expect(helper_instance.send(:max_per_parent)).to eq(10)
35
+ end
36
+ end
37
+
38
+ describe '#spawn' do
39
+ context 'when legion-data is not available' do
40
+ it 'returns failure' do
41
+ result = helper_instance.spawn(runner: 'SomeRunner', function: 'do_thing',
42
+ payload: {}, parent_task_id: 1)
43
+ expect(result[:success]).to be false
44
+ expect(result[:reason]).to eq(:data_unavailable)
45
+ end
46
+ end
47
+
48
+ context 'when depth would be exceeded' do
49
+ let(:parent_task) { double('Task', depth: 3, id: 1) }
50
+
51
+ before do
52
+ pt = parent_task
53
+ task_model = Class.new do
54
+ define_method(:[]) { |_id| pt }
55
+ end.new
56
+ stub_const('Legion::Data::Model::Task', task_model)
57
+ end
58
+
59
+ it 'returns depth_exceeded' do
60
+ result = helper_instance.spawn(runner: 'SomeRunner', function: 'do_thing',
61
+ payload: {}, parent_task_id: 1)
62
+ expect(result[:success]).to be false
63
+ expect(result[:reason]).to eq(:depth_exceeded)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Swarm::Helpers::Workspace do
6
+ subject(:workspace) { described_class.new }
7
+
8
+ let(:charter_id) { 'charter-abc' }
9
+
10
+ describe '#put and #get' do
11
+ it 'stores and retrieves an entry' do
12
+ workspace.put(charter_id, key: 'findings', value: { data: [1, 2, 3] }, author: 'agent-a')
13
+ entry = workspace.get(charter_id, key: 'findings')
14
+ expect(entry[:value]).to eq({ data: [1, 2, 3] })
15
+ expect(entry[:author]).to eq('agent-a')
16
+ expect(entry[:version]).to eq(1)
17
+ end
18
+
19
+ it 'increments version on overwrite' do
20
+ workspace.put(charter_id, key: 'result', value: 'v1', author: 'agent-a')
21
+ workspace.put(charter_id, key: 'result', value: 'v2', author: 'agent-b')
22
+ entry = workspace.get(charter_id, key: 'result')
23
+ expect(entry[:value]).to eq('v2')
24
+ expect(entry[:author]).to eq('agent-b')
25
+ expect(entry[:version]).to eq(2)
26
+ end
27
+
28
+ it 'returns nil for missing key' do
29
+ expect(workspace.get(charter_id, key: 'nope')).to be_nil
30
+ end
31
+
32
+ it 'returns nil for missing charter' do
33
+ expect(workspace.get('nonexistent', key: 'x')).to be_nil
34
+ end
35
+ end
36
+
37
+ describe '#list' do
38
+ it 'returns all entries for a charter' do
39
+ workspace.put(charter_id, key: 'a', value: 1, author: 'x')
40
+ workspace.put(charter_id, key: 'b', value: 2, author: 'y')
41
+ entries = workspace.list(charter_id)
42
+ expect(entries.keys).to contain_exactly('a', 'b')
43
+ end
44
+
45
+ it 'returns empty hash for unknown charter' do
46
+ expect(workspace.list('unknown')).to eq({})
47
+ end
48
+ end
49
+
50
+ describe '#delete' do
51
+ it 'removes an entry and returns it' do
52
+ workspace.put(charter_id, key: 'temp', value: 'data', author: 'a')
53
+ removed = workspace.delete(charter_id, key: 'temp')
54
+ expect(removed[:value]).to eq('data')
55
+ expect(workspace.get(charter_id, key: 'temp')).to be_nil
56
+ end
57
+
58
+ it 'returns nil when key does not exist' do
59
+ expect(workspace.delete(charter_id, key: 'nope')).to be_nil
60
+ end
61
+ end
62
+
63
+ describe '#clear_charter' do
64
+ it 'removes all entries for a charter' do
65
+ workspace.put(charter_id, key: 'a', value: 1, author: 'x')
66
+ workspace.put(charter_id, key: 'b', value: 2, author: 'x')
67
+ workspace.clear_charter(charter_id)
68
+ expect(workspace.list(charter_id)).to eq({})
69
+ end
70
+ end
71
+
72
+ describe '#apply_remote' do
73
+ it 'applies a remote put when version is higher' do
74
+ workspace.put(charter_id, key: 'shared', value: 'local', author: 'a')
75
+ workspace.apply_remote(charter_id, key: 'shared', value: 'remote', author: 'b',
76
+ version: 5, timestamp: Time.now.utc)
77
+ expect(workspace.get(charter_id, key: 'shared')[:value]).to eq('remote')
78
+ expect(workspace.get(charter_id, key: 'shared')[:version]).to eq(5)
79
+ end
80
+
81
+ it 'ignores remote put when version is lower' do
82
+ workspace.put(charter_id, key: 'shared', value: 'local', author: 'a')
83
+ workspace.put(charter_id, key: 'shared', value: 'local2', author: 'a')
84
+ workspace.apply_remote(charter_id, key: 'shared', value: 'stale', author: 'b',
85
+ version: 1, timestamp: Time.now.utc)
86
+ expect(workspace.get(charter_id, key: 'shared')[:value]).to eq('local2')
87
+ end
88
+
89
+ it 'uses timestamp to break version ties' do
90
+ workspace.put(charter_id, key: 'tie', value: 'old', author: 'a')
91
+ later = Time.now.utc + 1
92
+ workspace.apply_remote(charter_id, key: 'tie', value: 'newer', author: 'b',
93
+ version: 1, timestamp: later)
94
+ expect(workspace.get(charter_id, key: 'tie')[:value]).to eq('newer')
95
+ end
96
+ end
97
+
98
+ describe '#stats' do
99
+ it 'returns charter count and total entries' do
100
+ workspace.put('c1', key: 'a', value: 1, author: 'x')
101
+ workspace.put('c1', key: 'b', value: 2, author: 'x')
102
+ workspace.put('c2', key: 'c', value: 3, author: 'y')
103
+ stats = workspace.stats
104
+ expect(stats[:charter_count]).to eq(2)
105
+ expect(stats[:total_entries]).to eq(3)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Swarm::Runners::SpawnChild do
6
+ let(:runner_instance) { Class.new { include Legion::Extensions::Swarm::Runners::SpawnChild }.new }
7
+
8
+ before do
9
+ stub_const('Legion::Settings', Module.new do
10
+ def self.dig(*_keys) = nil
11
+ end)
12
+ end
13
+
14
+ describe '#spawn_child' do
15
+ it 'delegates to SubAgent.spawn and returns result' do
16
+ result = runner_instance.spawn_child(runner: 'R', function: 'f',
17
+ payload: {}, parent_task_id: 1)
18
+ expect(result).to be_a(Hash)
19
+ expect(result).to have_key(:success)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Swarm::Runners::Workspace do
6
+ subject(:runner) { Object.new.extend(described_class) }
7
+
8
+ let(:charter_id) { 'charter-123' }
9
+
10
+ before { runner.instance_variable_set(:@workspace, nil) }
11
+
12
+ describe '#workspace_put' do
13
+ it 'stores a value and returns success' do
14
+ result = runner.workspace_put(charter_id: charter_id, key: 'notes', value: 'hello', author: 'agent-a')
15
+ expect(result[:success]).to be true
16
+ expect(result[:key]).to eq('notes')
17
+ expect(result[:version]).to eq(1)
18
+ end
19
+ end
20
+
21
+ describe '#workspace_get' do
22
+ it 'retrieves a stored value' do
23
+ runner.workspace_put(charter_id: charter_id, key: 'data', value: [1, 2], author: 'a')
24
+ result = runner.workspace_get(charter_id: charter_id, key: 'data')
25
+ expect(result[:success]).to be true
26
+ expect(result[:entry][:value]).to eq([1, 2])
27
+ end
28
+
29
+ it 'returns not_found for missing key' do
30
+ result = runner.workspace_get(charter_id: charter_id, key: 'nope')
31
+ expect(result[:success]).to be false
32
+ expect(result[:reason]).to eq(:not_found)
33
+ end
34
+ end
35
+
36
+ describe '#workspace_list' do
37
+ it 'lists all entries for a charter' do
38
+ runner.workspace_put(charter_id: charter_id, key: 'a', value: 1, author: 'x')
39
+ runner.workspace_put(charter_id: charter_id, key: 'b', value: 2, author: 'x')
40
+ result = runner.workspace_list(charter_id: charter_id)
41
+ expect(result[:success]).to be true
42
+ expect(result[:count]).to eq(2)
43
+ end
44
+ end
45
+
46
+ describe '#workspace_delete' do
47
+ it 'deletes an entry and returns success' do
48
+ runner.workspace_put(charter_id: charter_id, key: 'temp', value: 'x', author: 'a')
49
+ result = runner.workspace_delete(charter_id: charter_id, key: 'temp')
50
+ expect(result[:success]).to be true
51
+ end
52
+
53
+ it 'returns not_found for missing key' do
54
+ result = runner.workspace_delete(charter_id: charter_id, key: 'nope')
55
+ expect(result[:success]).to be false
56
+ expect(result[:reason]).to eq(:not_found)
57
+ end
58
+ end
59
+
60
+ describe '#workspace_clear' do
61
+ it 'clears all entries for a charter' do
62
+ runner.workspace_put(charter_id: charter_id, key: 'a', value: 1, author: 'x')
63
+ result = runner.workspace_clear(charter_id: charter_id)
64
+ expect(result[:success]).to be true
65
+ expect(runner.workspace_list(charter_id: charter_id)[:count]).to eq(0)
66
+ end
67
+ end
68
+
69
+ describe '#workspace_stats' do
70
+ it 'returns workspace statistics' do
71
+ runner.workspace_put(charter_id: 'c1', key: 'a', value: 1, author: 'x')
72
+ runner.workspace_put(charter_id: 'c2', key: 'b', value: 2, author: 'y')
73
+ result = runner.workspace_stats
74
+ expect(result[:success]).to be true
75
+ expect(result[:charter_count]).to eq(2)
76
+ expect(result[:total_entries]).to eq(2)
77
+ end
78
+ end
79
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-swarm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -33,17 +33,29 @@ files:
33
33
  - Gemfile
34
34
  - lex-swarm.gemspec
35
35
  - lib/legion/extensions/swarm.rb
36
+ - lib/legion/extensions/swarm/actors/orphan_sweeper.rb
36
37
  - lib/legion/extensions/swarm/actors/stale_check.rb
38
+ - lib/legion/extensions/swarm/actors/workspace_sync.rb
37
39
  - lib/legion/extensions/swarm/client.rb
38
40
  - lib/legion/extensions/swarm/helpers/charter.rb
41
+ - lib/legion/extensions/swarm/helpers/sub_agent.rb
39
42
  - lib/legion/extensions/swarm/helpers/swarm_store.rb
43
+ - lib/legion/extensions/swarm/helpers/workspace.rb
44
+ - lib/legion/extensions/swarm/runners/spawn_child.rb
40
45
  - lib/legion/extensions/swarm/runners/swarm.rb
46
+ - lib/legion/extensions/swarm/runners/workspace.rb
41
47
  - lib/legion/extensions/swarm/version.rb
48
+ - spec/legion/extensions/swarm/actors/orphan_sweeper_spec.rb
42
49
  - spec/legion/extensions/swarm/actors/stale_check_spec.rb
50
+ - spec/legion/extensions/swarm/actors/workspace_sync_spec.rb
43
51
  - spec/legion/extensions/swarm/client_spec.rb
44
52
  - spec/legion/extensions/swarm/helpers/charter_spec.rb
53
+ - spec/legion/extensions/swarm/helpers/sub_agent_spec.rb
45
54
  - spec/legion/extensions/swarm/helpers/swarm_store_spec.rb
55
+ - spec/legion/extensions/swarm/helpers/workspace_spec.rb
56
+ - spec/legion/extensions/swarm/runners/spawn_child_spec.rb
46
57
  - spec/legion/extensions/swarm/runners/swarm_spec.rb
58
+ - spec/legion/extensions/swarm/runners/workspace_spec.rb
47
59
  - spec/spec_helper.rb
48
60
  homepage: https://github.com/LegionIO/lex-swarm
49
61
  licenses: