legionio 1.6.34 → 1.6.36
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/.rubocop.yml +5 -0
- data/CHANGELOG.md +21 -0
- data/extensions-agentic/lex-consent/db/migrations/001_create_consent_maps.rb +24 -0
- data/extensions-agentic/lex-consent/lex-consent.gemspec +26 -0
- data/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/actors/tier_evaluation.rb +112 -0
- data/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/models/consent_map.rb +74 -0
- data/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/runners/consent.rb +210 -0
- data/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/version.rb +11 -0
- data/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent.rb +15 -0
- data/extensions-agentic/lex-reconciliation/Gemfile +10 -0
- data/extensions-agentic/lex-reconciliation/README.md +73 -0
- data/extensions-agentic/lex-reconciliation/lex-reconciliation.gemspec +26 -0
- data/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/actors/reconciliation_cycle.rb +143 -0
- data/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/drift_log.rb +161 -0
- data/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/runners/drift_checker.rb +137 -0
- data/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/version.rb +9 -0
- data/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation.rb +13 -0
- data/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation/drift_log_spec.rb +40 -0
- data/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation/runners/drift_checker_spec.rb +107 -0
- data/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation_spec.rb +19 -0
- data/extensions-agentic/lex-reconciliation/spec/spec_helper.rb +15 -0
- data/lib/legion/cli/workflow_command.rb +140 -0
- data/lib/legion/cli.rb +4 -0
- data/lib/legion/extensions/helpers/knowledge.rb +50 -7
- data/lib/legion/extensions/helpers/llm.rb +11 -8
- data/lib/legion/extensions/helpers/logger.rb +3 -0
- data/lib/legion/extensions/helpers/task.rb +3 -0
- data/lib/legion/prompts.rb +24 -0
- data/lib/legion/version.rb +1 -1
- data/lib/legion/workflow/loader.rb +122 -0
- data/lib/legion/workflow/manifest.rb +59 -0
- data/lib/legion/workflow.rb +8 -0
- data/lib/legion.rb +4 -3
- data/workflows/autonomous-github-lifecycle.yml +67 -0
- metadata +26 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fd73445976635c61163ac93a058fd3253bb164956fff00d8b791817b1dd16ddb
|
|
4
|
+
data.tar.gz: 218c4b3975aa59cb4c0eec3735c2cdbe185cf23f85429b4268d4335a8cb7fdf9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 247d06dcfb4d405c1a156bac4c9a4a0aaa00eedd0cf2f3bfef74da95f9027acb39f303bc9afda3dbf50d2f14dee2a41eafd10d5522dbf074017548489f69ebd0
|
|
7
|
+
data.tar.gz: 840d3c68d9e48e11e3c92a40cf2a0d849079d5ab68aa39629368f88a75615d1dca151b952987de9a882517c84569bcc7aff9c93becbd9d8277b98152a5fdc942
|
data/.rubocop.yml
CHANGED
|
@@ -33,6 +33,11 @@ Metrics/BlockLength:
|
|
|
33
33
|
Exclude:
|
|
34
34
|
- 'spec/**/*'
|
|
35
35
|
- 'integration/**/*'
|
|
36
|
+
- 'extensions-agentic/**/spec/**/*'
|
|
37
|
+
- 'extensions-core/**/spec/**/*'
|
|
38
|
+
- 'extensions/**/spec/**/*'
|
|
39
|
+
- 'extensions-ai/**/spec/**/*'
|
|
40
|
+
- 'extensions-other/**/spec/**/*'
|
|
36
41
|
- 'legionio.gemspec'
|
|
37
42
|
- 'lib/legion/cli/chat_command.rb'
|
|
38
43
|
- 'lib/legion/cli/plan_command.rb'
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.6.36] - 2026-03-29
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Knowledge helper: `knowledge_connected?`, `knowledge_global_connected?`, `knowledge_local_connected?` status methods
|
|
9
|
+
- Knowledge helper: `knowledge_default_scope` and `knowledge_default_tags` LEX-overridable layered defaults
|
|
10
|
+
- LLM helper: now includes `Legion::LLM::Helper` following cache/transport pattern (with LoadError guard)
|
|
11
|
+
- Wrapper specs for cache and data helpers
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Logger helper: add missing `include Base` (was relying on transitive inclusion via Lex)
|
|
15
|
+
- Task helper: add missing `include Base`
|
|
16
|
+
- Knowledge helper: add missing `include Base`, `knowledge_default_tags` auto-merged into `ingest_knowledge`
|
|
17
|
+
|
|
18
|
+
## [1.6.35] - 2026-03-29
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- `Legion::Workflow::Manifest` — YAML workflow manifest parser with validation
|
|
22
|
+
- `Legion::Workflow::Loader` — installs/uninstalls workflow chains via lex-lex registry
|
|
23
|
+
- `legion workflow` CLI — install, list, uninstall, status subcommands
|
|
24
|
+
- `workflows/autonomous-github-lifecycle.yml` — sample workflow manifest for codegen pipeline
|
|
25
|
+
|
|
5
26
|
## [1.6.34] - 2026-03-29
|
|
6
27
|
|
|
7
28
|
### Fixed
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:consent_maps) do
|
|
6
|
+
primary_key :id
|
|
7
|
+
String :worker_id, null: false
|
|
8
|
+
String :from_tier, null: false
|
|
9
|
+
String :to_tier, null: false
|
|
10
|
+
String :requested_by, null: false
|
|
11
|
+
String :state, null: false, default: 'pending_approval'
|
|
12
|
+
String :resolved_by
|
|
13
|
+
Time :resolved_at
|
|
14
|
+
String :notes, text: true
|
|
15
|
+
String :context, text: true
|
|
16
|
+
Time :created_at
|
|
17
|
+
Time :updated_at
|
|
18
|
+
|
|
19
|
+
index :worker_id
|
|
20
|
+
index :state
|
|
21
|
+
index %i[worker_id state]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/agentic/consent/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-consent'
|
|
7
|
+
spec.version = Legion::Extensions::Agentic::Consent::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
spec.summary = 'LegionIO HITL consent gate for autonomous tier promotion'
|
|
11
|
+
spec.description = 'A LegionIO Extension (LEX) that gates agent autonomous tier promotion by human approval'
|
|
12
|
+
spec.homepage = 'https://github.com/LegionIO/lex-consent'
|
|
13
|
+
spec.license = 'MIT'
|
|
14
|
+
spec.required_ruby_version = '>= 3.4'
|
|
15
|
+
|
|
16
|
+
spec.metadata = {
|
|
17
|
+
'homepage_uri' => spec.homepage,
|
|
18
|
+
'source_code_uri' => spec.homepage,
|
|
19
|
+
'rubygems_mfa_required' => 'true'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
spec.files = Dir['lib/**/*', 'LICENSE', 'README.md']
|
|
23
|
+
spec.require_paths = ['lib']
|
|
24
|
+
|
|
25
|
+
spec.add_dependency 'legionio', '>= 1.2'
|
|
26
|
+
end
|
data/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/actors/tier_evaluation.rb
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(Legion::Extensions::Actors::Every)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Agentic
|
|
8
|
+
module Consent
|
|
9
|
+
module Actor
|
|
10
|
+
class TierEvaluation < Legion::Extensions::Actors::Every
|
|
11
|
+
# Run tier evaluation and pending approval expiry every hour
|
|
12
|
+
INTERVAL = 3600
|
|
13
|
+
|
|
14
|
+
def perform
|
|
15
|
+
expire_stale_approvals
|
|
16
|
+
evaluate_pending_workers
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
Legion::Logging.error "[TierEvaluation] perform failed: #{e.message}" if defined?(Legion::Logging)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def expire_stale_approvals
|
|
24
|
+
return unless runner_available?
|
|
25
|
+
|
|
26
|
+
runner = runner_instance
|
|
27
|
+
ttl_hours = Legion::Settings.dig(:consent, :pending_ttl_hours) || 72
|
|
28
|
+
result = runner.expire_pending_approvals(ttl_hours: ttl_hours)
|
|
29
|
+
return unless result[:expired].to_i.positive? && defined?(Legion::Logging)
|
|
30
|
+
|
|
31
|
+
Legion::Logging.info "[TierEvaluation] expired #{result[:expired]} stale consent requests"
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
Legion::Logging.warn "[TierEvaluation] expire_stale_approvals failed: #{e.message}" if defined?(Legion::Logging)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def evaluate_pending_workers
|
|
37
|
+
return unless defined?(Legion::Data::Model::DigitalWorker)
|
|
38
|
+
return unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap)
|
|
39
|
+
|
|
40
|
+
# Find active workers that may be eligible for autonomous tier promotion
|
|
41
|
+
# but do not yet have a pending approval request.
|
|
42
|
+
active_workers = Legion::Data::Model::DigitalWorker
|
|
43
|
+
.where(lifecycle_state: 'active')
|
|
44
|
+
.exclude(consent_tier: 'autonomous')
|
|
45
|
+
.all
|
|
46
|
+
|
|
47
|
+
active_workers.each do |worker|
|
|
48
|
+
evaluate_worker_for_promotion(worker)
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
Legion::Logging.warn "[TierEvaluation] evaluate failed for worker=#{worker.worker_id}: #{e.message}" if defined?(Legion::Logging)
|
|
51
|
+
end
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Legion::Logging.warn "[TierEvaluation] evaluate_pending_workers failed: #{e.message}" if defined?(Legion::Logging)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def evaluate_worker_for_promotion(worker)
|
|
57
|
+
return unless promotion_eligible?(worker)
|
|
58
|
+
return if pending_request_exists?(worker.worker_id)
|
|
59
|
+
|
|
60
|
+
from_tier = worker.consent_tier
|
|
61
|
+
to_tier = next_tier(from_tier)
|
|
62
|
+
return unless to_tier
|
|
63
|
+
|
|
64
|
+
runner = runner_instance
|
|
65
|
+
runner.request_promotion(
|
|
66
|
+
worker_id: worker.worker_id,
|
|
67
|
+
from_tier: from_tier,
|
|
68
|
+
to_tier: to_tier,
|
|
69
|
+
requested_by: 'system:tier_evaluation'
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def promotion_eligible?(worker)
|
|
74
|
+
return false unless worker.trust_score.to_f >= trust_threshold
|
|
75
|
+
return false unless (worker.risk_tier || 'low') == 'low'
|
|
76
|
+
|
|
77
|
+
true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def trust_threshold
|
|
81
|
+
Legion::Settings.dig(:consent, :promotion_trust_threshold) || 0.85
|
|
82
|
+
rescue StandardError
|
|
83
|
+
0.85
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def next_tier(current_tier)
|
|
87
|
+
hierarchy = %w[supervised inform consult autonomous]
|
|
88
|
+
idx = hierarchy.index(current_tier)
|
|
89
|
+
return nil unless idx
|
|
90
|
+
return nil if idx >= hierarchy.length - 1
|
|
91
|
+
|
|
92
|
+
hierarchy[idx + 1]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def pending_request_exists?(worker_id)
|
|
96
|
+
Legion::Extensions::Agentic::Consent::Models::ConsentMap
|
|
97
|
+
.pending_for_worker(worker_id).any?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def runner_available?
|
|
101
|
+
defined?(Legion::Extensions::Agentic::Consent::Runners::Consent)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def runner_instance
|
|
105
|
+
Object.new.extend(Legion::Extensions::Agentic::Consent::Runners::Consent)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/models/consent_map.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(Legion::Data)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Agentic
|
|
8
|
+
module Consent
|
|
9
|
+
module Models
|
|
10
|
+
class ConsentMap < Legion::Data::Model::Base
|
|
11
|
+
set_dataset :consent_maps
|
|
12
|
+
|
|
13
|
+
STATES = %w[pending_approval approved rejected expired].freeze
|
|
14
|
+
|
|
15
|
+
def self.pending
|
|
16
|
+
where(state: 'pending_approval')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.for_worker(worker_id)
|
|
20
|
+
where(worker_id: worker_id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.pending_for_worker(worker_id)
|
|
24
|
+
where(worker_id: worker_id, state: 'pending_approval')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def approve!(approver:, notes: nil)
|
|
28
|
+
update(
|
|
29
|
+
state: 'approved',
|
|
30
|
+
resolved_by: approver,
|
|
31
|
+
resolved_at: Time.now.utc,
|
|
32
|
+
notes: notes,
|
|
33
|
+
updated_at: Time.now.utc
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reject!(approver:, reason: nil)
|
|
38
|
+
update(
|
|
39
|
+
state: 'rejected',
|
|
40
|
+
resolved_by: approver,
|
|
41
|
+
resolved_at: Time.now.utc,
|
|
42
|
+
notes: reason,
|
|
43
|
+
updated_at: Time.now.utc
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def expire!
|
|
48
|
+
update(
|
|
49
|
+
state: 'expired',
|
|
50
|
+
updated_at: Time.now.utc
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def pending?
|
|
55
|
+
state == 'pending_approval'
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def approved?
|
|
59
|
+
state == 'approved'
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rejected?
|
|
63
|
+
state == 'rejected'
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def expired?
|
|
67
|
+
state == 'expired'
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Agentic
|
|
6
|
+
module Consent
|
|
7
|
+
module Runners
|
|
8
|
+
module Consent
|
|
9
|
+
# Request human approval for a worker's autonomous tier promotion.
|
|
10
|
+
# Creates a ConsentMap record in pending_approval state.
|
|
11
|
+
#
|
|
12
|
+
# @param worker_id [String] the worker requesting promotion
|
|
13
|
+
# @param from_tier [String] current consent tier
|
|
14
|
+
# @param to_tier [String] requested consent tier
|
|
15
|
+
# @param requested_by [String] identity requesting the promotion
|
|
16
|
+
# @param context [Hash] optional metadata about why promotion is requested
|
|
17
|
+
# @return [Hash]
|
|
18
|
+
def request_promotion(worker_id:, from_tier:, to_tier:, **opts)
|
|
19
|
+
requested_by = opts.fetch(:requested_by, 'system')
|
|
20
|
+
context = opts.fetch(:context, {})
|
|
21
|
+
|
|
22
|
+
return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap)
|
|
23
|
+
|
|
24
|
+
existing = Legion::Extensions::Agentic::Consent::Models::ConsentMap
|
|
25
|
+
.pending_for_worker(worker_id).first
|
|
26
|
+
|
|
27
|
+
if existing
|
|
28
|
+
Legion::Logging.info "[lex-consent] promotion already pending for worker=#{worker_id}" if defined?(Legion::Logging)
|
|
29
|
+
return { success: false, reason: :already_pending, consent_map_id: existing.id }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
record = Legion::Extensions::Agentic::Consent::Models::ConsentMap.create(
|
|
33
|
+
worker_id: worker_id,
|
|
34
|
+
from_tier: from_tier,
|
|
35
|
+
to_tier: to_tier,
|
|
36
|
+
requested_by: requested_by,
|
|
37
|
+
state: 'pending_approval',
|
|
38
|
+
context: defined?(Legion::JSON) ? Legion::JSON.dump(context) : context.to_json,
|
|
39
|
+
created_at: Time.now.utc,
|
|
40
|
+
updated_at: Time.now.utc
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if defined?(Legion::Events)
|
|
44
|
+
Legion::Events.emit('consent.promotion_requested', {
|
|
45
|
+
worker_id: worker_id,
|
|
46
|
+
from_tier: from_tier,
|
|
47
|
+
to_tier: to_tier,
|
|
48
|
+
requested_by: requested_by,
|
|
49
|
+
consent_map_id: record.id,
|
|
50
|
+
at: Time.now.utc
|
|
51
|
+
})
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Legion::Logging.info "[lex-consent] promotion requested worker=#{worker_id} #{from_tier}->#{to_tier} id=#{record.id}" if defined?(Legion::Logging)
|
|
55
|
+
|
|
56
|
+
{ success: true, consent_map_id: record.id, state: 'pending_approval' }
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
Legion::Logging.error "[lex-consent] request_promotion failed: #{e.message}" if defined?(Legion::Logging)
|
|
59
|
+
{ success: false, reason: e.message }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Approve a pending tier promotion request.
|
|
63
|
+
#
|
|
64
|
+
# @param consent_map_id [Integer] the ConsentMap record to approve
|
|
65
|
+
# @param approver [String] identity of the approver
|
|
66
|
+
# @param notes [String] optional approval notes
|
|
67
|
+
# @return [Hash]
|
|
68
|
+
def approve_promotion(consent_map_id:, approver:, notes: nil, **)
|
|
69
|
+
return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap)
|
|
70
|
+
|
|
71
|
+
record = Legion::Extensions::Agentic::Consent::Models::ConsentMap[consent_map_id.to_i]
|
|
72
|
+
return { success: false, reason: :not_found } unless record
|
|
73
|
+
return { success: false, reason: :not_pending, state: record.state } unless record.pending?
|
|
74
|
+
|
|
75
|
+
record.approve!(approver: approver, notes: notes)
|
|
76
|
+
|
|
77
|
+
apply_promotion(record)
|
|
78
|
+
|
|
79
|
+
if defined?(Legion::Events)
|
|
80
|
+
Legion::Events.emit('consent.promotion_approved', {
|
|
81
|
+
consent_map_id: record.id,
|
|
82
|
+
worker_id: record.worker_id,
|
|
83
|
+
from_tier: record.from_tier,
|
|
84
|
+
to_tier: record.to_tier,
|
|
85
|
+
approver: approver,
|
|
86
|
+
at: Time.now.utc
|
|
87
|
+
})
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Legion::Logging.info "[lex-consent] approved consent_map_id=#{record.id} worker=#{record.worker_id} by=#{approver}" if defined?(Legion::Logging)
|
|
91
|
+
|
|
92
|
+
{ success: true, consent_map_id: record.id, worker_id: record.worker_id, state: 'approved', to_tier: record.to_tier }
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Legion::Logging.error "[lex-consent] approve_promotion failed: #{e.message}" if defined?(Legion::Logging)
|
|
95
|
+
{ success: false, reason: e.message }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Reject a pending tier promotion request.
|
|
99
|
+
#
|
|
100
|
+
# @param consent_map_id [Integer] the ConsentMap record to reject
|
|
101
|
+
# @param approver [String] identity of the approver
|
|
102
|
+
# @param reason [String] rejection reason (required)
|
|
103
|
+
# @return [Hash]
|
|
104
|
+
def reject_promotion(consent_map_id:, approver:, reason:, **)
|
|
105
|
+
return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap)
|
|
106
|
+
|
|
107
|
+
return { success: false, reason: :missing_reason } if reason.nil? || reason.to_s.strip.empty?
|
|
108
|
+
|
|
109
|
+
record = Legion::Extensions::Agentic::Consent::Models::ConsentMap[consent_map_id.to_i]
|
|
110
|
+
return { success: false, reason: :not_found } unless record
|
|
111
|
+
return { success: false, reason: :not_pending, state: record.state } unless record.pending?
|
|
112
|
+
|
|
113
|
+
record.reject!(approver: approver, reason: reason)
|
|
114
|
+
|
|
115
|
+
if defined?(Legion::Events)
|
|
116
|
+
Legion::Events.emit('consent.promotion_rejected', {
|
|
117
|
+
consent_map_id: record.id,
|
|
118
|
+
worker_id: record.worker_id,
|
|
119
|
+
from_tier: record.from_tier,
|
|
120
|
+
to_tier: record.to_tier,
|
|
121
|
+
approver: approver,
|
|
122
|
+
reason: reason,
|
|
123
|
+
at: Time.now.utc
|
|
124
|
+
})
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
Legion::Logging.info "[lex-consent] rejected consent_map_id=#{record.id} worker=#{record.worker_id} by=#{approver}" if defined?(Legion::Logging)
|
|
128
|
+
|
|
129
|
+
{ success: true, consent_map_id: record.id, worker_id: record.worker_id, state: 'rejected' }
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
Legion::Logging.error "[lex-consent] reject_promotion failed: #{e.message}" if defined?(Legion::Logging)
|
|
132
|
+
{ success: false, reason: e.message }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Expire all pending promotion requests older than ttl_hours.
|
|
136
|
+
# Intended to be run on a schedule (e.g. every hour).
|
|
137
|
+
#
|
|
138
|
+
# @param ttl_hours [Integer] how many hours before a pending request expires (default 72)
|
|
139
|
+
# @return [Hash]
|
|
140
|
+
def expire_pending_approvals(ttl_hours: 72, **)
|
|
141
|
+
return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap)
|
|
142
|
+
|
|
143
|
+
cutoff = Time.now.utc - (ttl_hours * 3600)
|
|
144
|
+
expired_count = 0
|
|
145
|
+
|
|
146
|
+
Legion::Extensions::Agentic::Consent::Models::ConsentMap
|
|
147
|
+
.pending
|
|
148
|
+
.where { created_at < cutoff }
|
|
149
|
+
.each do |record|
|
|
150
|
+
record.expire!
|
|
151
|
+
expired_count += 1
|
|
152
|
+
|
|
153
|
+
if defined?(Legion::Events)
|
|
154
|
+
Legion::Events.emit('consent.promotion_expired', {
|
|
155
|
+
consent_map_id: record.id,
|
|
156
|
+
worker_id: record.worker_id,
|
|
157
|
+
from_tier: record.from_tier,
|
|
158
|
+
to_tier: record.to_tier,
|
|
159
|
+
at: Time.now.utc
|
|
160
|
+
})
|
|
161
|
+
end
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
Legion::Logging.warn "[lex-consent] expire failed for id=#{record.id}: #{e.message}" if defined?(Legion::Logging)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
Legion::Logging.info "[lex-consent] expired #{expired_count} pending approvals (ttl=#{ttl_hours}h)" if defined?(Legion::Logging)
|
|
167
|
+
|
|
168
|
+
{ success: true, expired: expired_count, ttl_hours: ttl_hours }
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
Legion::Logging.error "[lex-consent] expire_pending_approvals failed: #{e.message}" if defined?(Legion::Logging)
|
|
171
|
+
{ success: false, reason: e.message }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# List pending promotion requests.
|
|
175
|
+
#
|
|
176
|
+
# @param worker_id [String] optional filter by worker
|
|
177
|
+
# @return [Hash]
|
|
178
|
+
def list_pending(worker_id: nil, **)
|
|
179
|
+
return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap)
|
|
180
|
+
|
|
181
|
+
ds = Legion::Extensions::Agentic::Consent::Models::ConsentMap.pending
|
|
182
|
+
ds = ds.where(worker_id: worker_id) if worker_id
|
|
183
|
+
records = ds.all
|
|
184
|
+
|
|
185
|
+
{ success: true, count: records.size, pending: records.map(&:values) }
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
Legion::Logging.error "[lex-consent] list_pending failed: #{e.message}" if defined?(Legion::Logging)
|
|
188
|
+
{ success: false, reason: e.message }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def apply_promotion(record)
|
|
194
|
+
return unless defined?(Legion::Data::Model::DigitalWorker)
|
|
195
|
+
|
|
196
|
+
worker = Legion::Data::Model::DigitalWorker.first(worker_id: record.worker_id)
|
|
197
|
+
return unless worker
|
|
198
|
+
|
|
199
|
+
worker.update(consent_tier: record.to_tier, updated_at: Time.now.utc)
|
|
200
|
+
|
|
201
|
+
Legion::Logging.info "[lex-consent] applied tier promotion worker=#{record.worker_id} tier=#{record.to_tier}" if defined?(Legion::Logging)
|
|
202
|
+
rescue StandardError => e
|
|
203
|
+
Legion::Logging.warn "[lex-consent] apply_promotion failed for worker=#{record.worker_id}: #{e.message}" if defined?(Legion::Logging)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'consent/version'
|
|
4
|
+
require_relative 'consent/models/consent_map'
|
|
5
|
+
require_relative 'consent/runners/consent'
|
|
6
|
+
require_relative 'consent/actors/tier_evaluation'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module Agentic
|
|
11
|
+
module Consent
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# lex-reconciliation
|
|
2
|
+
|
|
3
|
+
A [LegionIO](https://github.com/LegionIO) extension for drift detection and reconciliation.
|
|
4
|
+
|
|
5
|
+
Detects drift between expected (desired) state and actual (observed) state for managed resources,
|
|
6
|
+
persists drift events to a log, and runs periodic reconciliation cycles that emit events for
|
|
7
|
+
downstream runners to act on.
|
|
8
|
+
|
|
9
|
+
## Components
|
|
10
|
+
|
|
11
|
+
### `Runners::DriftChecker`
|
|
12
|
+
|
|
13
|
+
Detects drift between expected and actual state for one or more resources.
|
|
14
|
+
|
|
15
|
+
ruby
|
|
16
|
+
result = drift_checker.check(
|
|
17
|
+
resource: 'my-service',
|
|
18
|
+
expected: { status: 'running', replicas: 3 },
|
|
19
|
+
actual: { status: 'stopped', replicas: 1 },
|
|
20
|
+
severity: 'high'
|
|
21
|
+
)
|
|
22
|
+
# => { drifted: true, drift_id: '...', differences: [...], summary: { total: 2 } }
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### `DriftLog`
|
|
26
|
+
|
|
27
|
+
Persistent drift event log backed by `legion-data`.
|
|
28
|
+
|
|
29
|
+
ruby
|
|
30
|
+
Legion::Extensions::Reconciliation::DriftLog.record(
|
|
31
|
+
resource: 'my-service',
|
|
32
|
+
expected: { status: 'running' },
|
|
33
|
+
actual: { status: 'stopped' },
|
|
34
|
+
severity: 'high'
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
Legion::Extensions::Reconciliation::DriftLog.open_entries(severity: 'high')
|
|
38
|
+
Legion::Extensions::Reconciliation::DriftLog.summary
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
### `Actors::ReconciliationCycle`
|
|
42
|
+
|
|
43
|
+
Interval actor (default: every 5 minutes) that checks all configured targets and emits
|
|
44
|
+
`reconciliation.drift_detected` and `reconciliation.reconcile_requested` events.
|
|
45
|
+
|
|
46
|
+
Configure targets in settings:
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
"extensions": {
|
|
51
|
+
"reconciliation": {
|
|
52
|
+
"interval": 300,
|
|
53
|
+
"targets": [
|
|
54
|
+
{
|
|
55
|
+
"resource": "my-service",
|
|
56
|
+
"expected": { "status": "running", "replicas": 3 },
|
|
57
|
+
"severity": "high"
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
## Installation
|
|
66
|
+
|
|
67
|
+
ruby
|
|
68
|
+
gem 'lex-reconciliation'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/reconciliation/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-reconciliation'
|
|
7
|
+
spec.version = Legion::Extensions::Reconciliation::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
spec.summary = 'A LegionIO Extension for drift detection and reconciliation'
|
|
11
|
+
spec.description = 'A LegionIO Extension (LEX) for detecting drift between expected and actual state and reconciling differences'
|
|
12
|
+
spec.homepage = 'https://github.com/LegionIO/lex-reconciliation'
|
|
13
|
+
spec.license = 'MIT'
|
|
14
|
+
spec.required_ruby_version = '>= 3.4'
|
|
15
|
+
|
|
16
|
+
spec.metadata = {
|
|
17
|
+
'homepage_uri' => spec.homepage,
|
|
18
|
+
'source_code_uri' => spec.homepage,
|
|
19
|
+
'rubygems_mfa_required' => 'true'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
spec.files = Dir['lib/**/*', 'LICENSE', 'README.md']
|
|
23
|
+
spec.require_paths = ['lib']
|
|
24
|
+
|
|
25
|
+
spec.add_dependency 'legionio', '>= 1.2'
|
|
26
|
+
end
|