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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a59ac2f5a5fd0220dcbb5b9831e61219dc4668b8e9d8f65fcbe32be1ed372d1
4
- data.tar.gz: 9452932592be1ff4be051490a444880d1974aca50c7f6ca815fe001213998c52
3
+ metadata.gz: 8682edaf8eaf214c03e6dca287e4298260dde6482506bacee7c6ef247ad1fc8f
4
+ data.tar.gz: 3f045eec756ee09f7d15ea25c7d40709fd95dd71d6e277845e400c4c6be19f98
5
5
  SHA512:
6
- metadata.gz: ca9715429a537318d634c59bf3d706656a07bdbbdda1348e675677078e0e714bc6c3c82f659ff17e36da03eeaca1c5128a4e439c89646633cbd9527e04f4f29a
7
- data.tar.gz: c9042fc99b86aef9fbd239597736dffa29497850ef8b9e6694aabb0a73b25861196ca73e57febfa7e313f82ba5a0421d0a68d562dd738f1496091c08a5fedaaf
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
@@ -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
- json_response({
159
- worker_id: params[:id],
160
- events: [],
161
- note: 'lifecycle event persistence is not yet implemented'
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.12'
4
+ VERSION = '1.6.13'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.12
4
+ version: 1.6.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity