legionio 1.6.12 → 1.6.13
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/CHANGELOG.md +11 -0
- data/lib/legion/api/workers.rb +34 -6
- data/lib/legion/digital_worker/lifecycle.rb +15 -0
- data/lib/legion/digital_worker.rb +42 -0
- data/lib/legion/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8682edaf8eaf214c03e6dca287e4298260dde6482506bacee7c6ef247ad1fc8f
|
|
4
|
+
data.tar.gz: 3f045eec756ee09f7d15ea25c7d40709fd95dd71d6e277845e400c4c6be19f98
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b41ad3b698ae6318ae2a6e7f1c3e0f1d34a46df6dcc183bcf644f5850d458c0d5c1ca080ddee2f81d69dce7a8d5ecdcbb7ae509295a7fd56f852e8f5af8aa189
|
|
7
|
+
data.tar.gz: 860b776fc04f33f031f07c1969058f4278b8c6a124c09920a54c40b808bdbf708454b8062be32d3960741e8cef5afd1c2c38bb546ad382dd98e528c806a46bf4
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.6.13] - 2026-03-27
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `DigitalWorker.heartbeat` method for updating worker health status and last heartbeat timestamp
|
|
9
|
+
- `DigitalWorker.detect_orphans` method to find workers with stale or nil heartbeats
|
|
10
|
+
- `DigitalWorker.pause_orphans!` method to auto-pause orphaned workers with event emission
|
|
11
|
+
- Consent tier sync on lifecycle transitions: `worker.update` now includes `consent_tier` from `CONSENT_MAPPING`
|
|
12
|
+
- `Lifecycle.sync_consent_tier` calls `lex-consent` runner when available, graceful degradation when not
|
|
13
|
+
- Per-worker SSE events at `/api/workers/:id/events?stream=true` with queue-per-client filtering
|
|
14
|
+
- Polling fallback for per-worker events via ring buffer filtering (default mode)
|
|
15
|
+
|
|
5
16
|
## [1.6.11] - 2026-03-26
|
|
6
17
|
|
|
7
18
|
### Added
|
data/lib/legion/api/workers.rb
CHANGED
|
@@ -120,7 +120,7 @@ module Legion
|
|
|
120
120
|
end
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
-
def self.register_sub_resources(app) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
123
|
+
def self.register_sub_resources(app) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
124
124
|
app.get '/api/workers/:id/health' do
|
|
125
125
|
require_data!
|
|
126
126
|
worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id])
|
|
@@ -155,11 +155,39 @@ module Legion
|
|
|
155
155
|
worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id])
|
|
156
156
|
halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil?
|
|
157
157
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
158
|
+
if params[:stream] == 'true' && defined?(Legion::Events)
|
|
159
|
+
content_type 'text/event-stream'
|
|
160
|
+
headers 'Cache-Control' => 'no-cache',
|
|
161
|
+
'Connection' => 'keep-alive',
|
|
162
|
+
'X-Accel-Buffering' => 'no'
|
|
163
|
+
|
|
164
|
+
queue = Queue.new
|
|
165
|
+
listener = Legion::Events.on('*') do |event|
|
|
166
|
+
queue.push(event) if event[:worker_id] == params[:id]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
stream do |out|
|
|
170
|
+
Thread.new do
|
|
171
|
+
loop do
|
|
172
|
+
event = queue.pop
|
|
173
|
+
data = Legion::JSON.dump({ **event.transform_keys(&:to_s) })
|
|
174
|
+
out << "event: #{event[:event]}\ndata: #{data}\n\n"
|
|
175
|
+
rescue IOError, Errno::EPIPE
|
|
176
|
+
break
|
|
177
|
+
end
|
|
178
|
+
ensure
|
|
179
|
+
Legion::Events.off('*', listener)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
out.callback { Legion::Events.off('*', listener) }
|
|
183
|
+
out.errback { Legion::Events.off('*', listener) }
|
|
184
|
+
end
|
|
185
|
+
else
|
|
186
|
+
count = (params[:count] || 25).to_i
|
|
187
|
+
all_events = Routes::Events.recent_events([count * 4, 100].min)
|
|
188
|
+
filtered = all_events.select { |e| e['worker_id'] == params[:id] || e[:worker_id] == params[:id] }
|
|
189
|
+
json_response({ worker_id: params[:id], events: filtered.last(count) })
|
|
190
|
+
end
|
|
163
191
|
end
|
|
164
192
|
|
|
165
193
|
app.get '/api/workers/:id/costs' do
|
|
@@ -107,13 +107,16 @@ module Legion
|
|
|
107
107
|
end
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
new_consent = CONSENT_MAPPING[to_state]
|
|
110
111
|
worker.update(
|
|
111
112
|
lifecycle_state: to_state,
|
|
113
|
+
consent_tier: new_consent ? new_consent.to_s : worker.consent_tier,
|
|
112
114
|
updated_at: Time.now.utc,
|
|
113
115
|
retired_at: %w[retired terminated].include?(to_state) ? Time.now.utc : worker.retired_at,
|
|
114
116
|
retired_by: %w[retired terminated].include?(to_state) ? by : worker.retired_by,
|
|
115
117
|
retired_reason: reason || worker.retired_reason
|
|
116
118
|
)
|
|
119
|
+
sync_consent_tier(worker, new_consent) if new_consent
|
|
117
120
|
|
|
118
121
|
if defined?(Legion::Events)
|
|
119
122
|
Legion::Events.emit('worker.lifecycle', {
|
|
@@ -167,6 +170,18 @@ module Legion
|
|
|
167
170
|
def self.consent_tier(state)
|
|
168
171
|
CONSENT_MAPPING.fetch(state, :consult)
|
|
169
172
|
end
|
|
173
|
+
|
|
174
|
+
def self.sync_consent_tier(worker, tier)
|
|
175
|
+
return unless defined?(Legion::Extensions::Consent::Runners::Consent)
|
|
176
|
+
|
|
177
|
+
Legion::Extensions::Consent::Runners::Consent.update_tier(
|
|
178
|
+
worker_id: worker.worker_id,
|
|
179
|
+
tier: tier.to_s
|
|
180
|
+
)
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
Legion::Logging.debug("[Lifecycle] consent sync failed for #{worker.worker_id}: #{e.message}") if defined?(Legion::Logging)
|
|
183
|
+
end
|
|
184
|
+
private_class_method :sync_consent_tier
|
|
170
185
|
end
|
|
171
186
|
end
|
|
172
187
|
end
|
|
@@ -43,6 +43,48 @@ module Legion
|
|
|
43
43
|
Legion::Data::Model::DigitalWorker.where(team: team)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
def heartbeat(worker_id:, health_status: 'healthy', health_node: nil)
|
|
47
|
+
worker = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id)
|
|
48
|
+
return nil unless worker
|
|
49
|
+
|
|
50
|
+
updates = { last_heartbeat_at: Time.now.utc, health_status: health_status }
|
|
51
|
+
updates[:health_node] = health_node if health_node
|
|
52
|
+
worker.update(updates)
|
|
53
|
+
worker
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def detect_orphans(stale_days: 7)
|
|
57
|
+
cutoff = Time.now.utc - (stale_days * 86_400)
|
|
58
|
+
active = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active')
|
|
59
|
+
active.all.select do |w|
|
|
60
|
+
w.last_heartbeat_at.nil? || w.last_heartbeat_at < cutoff
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def pause_orphans!(stale_days: 7, by: 'system:orphan_detection')
|
|
65
|
+
orphans = detect_orphans(stale_days: stale_days)
|
|
66
|
+
orphans.each do |worker|
|
|
67
|
+
Lifecycle.transition!(
|
|
68
|
+
worker,
|
|
69
|
+
to_state: 'paused',
|
|
70
|
+
by: by,
|
|
71
|
+
reason: "no heartbeat for #{stale_days}+ days",
|
|
72
|
+
authority_verified: true
|
|
73
|
+
)
|
|
74
|
+
if defined?(Legion::Events)
|
|
75
|
+
Legion::Events.emit('worker.orphan_detected', {
|
|
76
|
+
worker_id: worker.worker_id,
|
|
77
|
+
owner_msid: worker.owner_msid,
|
|
78
|
+
last_heartbeat_at: worker.last_heartbeat_at,
|
|
79
|
+
at: Time.now.utc
|
|
80
|
+
})
|
|
81
|
+
end
|
|
82
|
+
rescue Lifecycle::InvalidTransition => e
|
|
83
|
+
Legion::Logging.debug("[OrphanDetection] skip #{worker.worker_id}: #{e.message}") if defined?(Legion::Logging)
|
|
84
|
+
end
|
|
85
|
+
orphans
|
|
86
|
+
end
|
|
87
|
+
|
|
46
88
|
def active_local_ids
|
|
47
89
|
return [] unless defined?(Registry)
|
|
48
90
|
|
data/lib/legion/version.rb
CHANGED