legionio 1.4.105 → 1.4.107
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 +36 -0
- data/Gemfile +2 -0
- data/legionio.gemspec +2 -0
- data/lib/legion/api/graphql/resolvers/extensions.rb +57 -0
- data/lib/legion/api/graphql/resolvers/node.rb +39 -0
- data/lib/legion/api/graphql/resolvers/tasks.rb +46 -0
- data/lib/legion/api/graphql/resolvers/workers.rb +78 -0
- data/lib/legion/api/graphql/schema.rb +27 -0
- data/lib/legion/api/graphql/types/base_object.rb +14 -0
- data/lib/legion/api/graphql/types/extension_type.rb +23 -0
- data/lib/legion/api/graphql/types/node_type.rb +21 -0
- data/lib/legion/api/graphql/types/query_type.rb +75 -0
- data/lib/legion/api/graphql/types/task_type.rb +24 -0
- data/lib/legion/api/graphql/types/worker_type.rb +24 -0
- data/lib/legion/api/graphql.rb +77 -0
- data/lib/legion/api/workers.rb +48 -1
- data/lib/legion/api.rb +2 -0
- data/lib/legion/cli/docs_command.rb +67 -0
- data/lib/legion/cli/worker_command.rb +62 -0
- data/lib/legion/cli.rb +4 -0
- data/lib/legion/digital_worker/airb.rb +156 -0
- data/lib/legion/digital_worker/lifecycle.rb +20 -14
- data/lib/legion/digital_worker/registration.rb +185 -0
- data/lib/legion/docs/site_generator.rb +247 -18
- data/lib/legion/phi/access_log.rb +124 -0
- data/lib/legion/phi/erasure.rb +115 -0
- data/lib/legion/phi.rb +129 -0
- data/lib/legion/version.rb +1 -1
- metadata +33 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 852b32f5818f330941b385f4298e266c2906ff506f8111c58b708be29f83b707
|
|
4
|
+
data.tar.gz: 7a1ba8bfb20275e5e6fec4c89658c501cf81a63db55e873c9a6c08c0cffaba70
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e8fd883eb244d806bc51a223ff300d12e06d160c8b5096a6e34898dd1f7c0577345c1e2d30f51e168d20da438d28108934cf57645df180c156f91c107d4eb964
|
|
7
|
+
data.tar.gz: e133ef05dd03f998185d2aa00fe1fe679a69f9e31db58b11987c8bff75ce2f9ed7baedcc4745ebb3a39362d228e1ca33943897b396dd585ce4981d83d1802709
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.107] - 2026-03-21
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- `Legion::Docs::SiteGenerator` — full static site generator with kramdown + rouge syntax highlighting
|
|
7
|
+
- Converts markdown guides to HTML with navigation sidebar and styled template
|
|
8
|
+
- CLI reference auto-generation via Thor command introspection
|
|
9
|
+
- Extension reference auto-generation via Bundler gem discovery
|
|
10
|
+
- `Legion::CLI::Docs` — `legion docs generate` and `legion docs serve` subcommands
|
|
11
|
+
- 39 new specs (2248 total, 0 failures)
|
|
12
|
+
|
|
13
|
+
## [1.4.106] - 2026-03-21
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `Legion::DigitalWorker::Registration` module with full approval workflow: `register`, `approve`, `reject`, `pending_approvals`, `approval_required?`, and `escalate`
|
|
17
|
+
- Workers with `high` or `critical` risk tiers are created in `pending_approval` state instead of `bootstrap`, triggering an AIRB intake
|
|
18
|
+
- `Legion::DigitalWorker::Airb` module for AIRB integration: `create_intake`, `check_status`, `sync_status` (mock API by default; live API activated via `Legion::Settings.dig(:airb)`)
|
|
19
|
+
- New lifecycle states `pending_approval` and `rejected` in `Lifecycle::TRANSITIONS`, with appropriate `EXTINCTION_MAPPING` and `CONSENT_MAPPING` entries
|
|
20
|
+
- Transition rules: `pending_approval -> active` (approve), `pending_approval -> rejected` (reject)
|
|
21
|
+
- CLI subcommands: `legion worker approvals`, `legion worker approve ID [--notes TEXT]`, `legion worker reject ID --reason TEXT`
|
|
22
|
+
- API routes: `GET /api/workers/approvals`, `POST /api/workers/:id/approve`, `POST /api/workers/:id/reject`
|
|
23
|
+
- 37 new specs across `registration_spec.rb` (28 examples) and `airb_spec.rb` (9 examples)
|
|
24
|
+
- `Legion::Phi` module — HIPAA/BAA PHI tagging and tracking: `PHI_TAG`, `tag`, `tagged?`, `phi_fields`, `redact`, `erase`, `auto_detect_fields`
|
|
25
|
+
- `Legion::Phi::AccessLog` module — PHI access audit trail: `log_access`, `log_access!`, `recent_access`; integrates with `Legion::Audit` when available, falls back to `Legion::Logging`
|
|
26
|
+
- `Legion::Phi::Erasure` module — cryptographic erasure: `erase_record` (AES-256-GCM with throwaway key), `erase_for_subject` (HIPAA right to deletion), `erasure_log`
|
|
27
|
+
- Pattern-based auto-detection of PHI fields (ssn, mrn, dob, patient_name, phone, email, address, diagnosis, npi, insurance_id, etc.) via configurable regex patterns in `legion-settings` at `phi.field_patterns`
|
|
28
|
+
- `Legion::Crypt` guarded throughout — falls back to stdlib OpenSSL when `legion-crypt` is not loaded
|
|
29
|
+
- 59 new specs across `phi_spec.rb` (30 examples), `phi/access_log_spec.rb` (15 examples), `phi/erasure_spec.rb` (14 examples)
|
|
30
|
+
- `Legion::API::Routes::GraphQL` — GraphQL API layer using graphql-ruby (optional dependency, guarded with `defined?(GraphQL)`)
|
|
31
|
+
- `POST /api/graphql` — executes GraphQL queries; parses `query`, `variables`, `operationName` from JSON body
|
|
32
|
+
- `GET /api/graphql` — serves GraphiQL browser IDE for interactive introspection
|
|
33
|
+
- `Legion::API::GraphQL::Schema` — root schema with `max_depth: 10`, `max_complexity: 200`
|
|
34
|
+
- `Legion::API::GraphQL::Types::QueryType` — root query with `workers`, `worker`, `extensions`, `extension`, `tasks`, `node` fields and filtering arguments
|
|
35
|
+
- `Legion::API::GraphQL::Types::WorkerType`, `ExtensionType`, `TaskType`, `NodeType` — field definitions for each domain object
|
|
36
|
+
- Data resolution falls back gracefully: uses `Legion::Data` models when connected, falls back to `Legion::DigitalWorker::Registry` / `Legion::Registry` in-memory stores otherwise
|
|
37
|
+
- 45 new specs in `spec/legion/api/graphql_spec.rb`
|
|
38
|
+
|
|
3
39
|
## [1.4.105] - 2026-03-21
|
|
4
40
|
|
|
5
41
|
### Added
|
data/Gemfile
CHANGED
data/legionio.gemspec
CHANGED
|
@@ -36,6 +36,8 @@ Gem::Specification.new do |spec|
|
|
|
36
36
|
|
|
37
37
|
spec.add_dependency 'legion-mcp'
|
|
38
38
|
|
|
39
|
+
spec.add_dependency 'kramdown', '>= 2.0'
|
|
40
|
+
|
|
39
41
|
spec.add_dependency 'bootsnap', '>= 1.18'
|
|
40
42
|
spec.add_dependency 'concurrent-ruby', '>= 1.2'
|
|
41
43
|
spec.add_dependency 'concurrent-ruby-ext', '>= 1.2'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Resolvers
|
|
7
|
+
module Extensions
|
|
8
|
+
def self.resolve(status: nil)
|
|
9
|
+
if defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
10
|
+
resolve_from_data(status: status)
|
|
11
|
+
else
|
|
12
|
+
resolve_from_registry(status: status)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.find(name:)
|
|
17
|
+
resolve.find { |e| e[:name] == name }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.resolve_from_data(status: nil)
|
|
21
|
+
return [] unless defined?(Legion::Data::Model::Extension)
|
|
22
|
+
|
|
23
|
+
dataset = Legion::Data::Model::Extension.order(:id)
|
|
24
|
+
dataset = dataset.where(status: status) if status
|
|
25
|
+
dataset.all.map { |e| extension_hash(e.values) }
|
|
26
|
+
rescue StandardError
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.resolve_from_registry(status: nil)
|
|
31
|
+
return [] unless defined?(Legion::Registry)
|
|
32
|
+
|
|
33
|
+
entries = Legion::Registry.respond_to?(:all) ? Legion::Registry.all : []
|
|
34
|
+
entries = entries.map { |e| e.is_a?(Hash) ? e : e.to_h }
|
|
35
|
+
entries = entries.select { |e| e[:status].to_s == status } if status
|
|
36
|
+
entries.map { |e| extension_hash(e) }
|
|
37
|
+
rescue StandardError
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.extension_hash(values)
|
|
42
|
+
{
|
|
43
|
+
name: values[:name],
|
|
44
|
+
version: values[:version],
|
|
45
|
+
status: values[:status]&.to_s || 'active',
|
|
46
|
+
description: values[:description],
|
|
47
|
+
risk_tier: values[:risk_tier],
|
|
48
|
+
runners: Array(values[:runners])
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private_class_method :resolve_from_data, :resolve_from_registry, :extension_hash
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Resolvers
|
|
7
|
+
module Node
|
|
8
|
+
def self.resolve
|
|
9
|
+
name = defined?(Legion::Settings) ? Legion::Settings[:client][:name] : 'legion'
|
|
10
|
+
version = defined?(Legion::VERSION) ? Legion::VERSION : nil
|
|
11
|
+
ready = defined?(Legion::Readiness) ? Legion::Readiness.ready? : true
|
|
12
|
+
uptime = defined?(Legion::Process) ? calculate_uptime : nil
|
|
13
|
+
|
|
14
|
+
{
|
|
15
|
+
name: name,
|
|
16
|
+
version: version,
|
|
17
|
+
uptime: uptime,
|
|
18
|
+
ready: ready
|
|
19
|
+
}
|
|
20
|
+
rescue StandardError
|
|
21
|
+
{ name: nil, version: nil, uptime: nil, ready: false }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.calculate_uptime
|
|
25
|
+
return nil unless defined?(Legion::Process) &&
|
|
26
|
+
Legion::Process.respond_to?(:started_at) &&
|
|
27
|
+
Legion::Process.started_at
|
|
28
|
+
|
|
29
|
+
(Time.now.utc - Legion::Process.started_at).to_i
|
|
30
|
+
rescue StandardError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private_class_method :calculate_uptime
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Resolvers
|
|
7
|
+
module Tasks
|
|
8
|
+
def self.resolve(status: nil, limit: nil)
|
|
9
|
+
return [] unless defined?(Legion::Data) &&
|
|
10
|
+
Legion::Data.respond_to?(:connection) &&
|
|
11
|
+
Legion::Data.connection
|
|
12
|
+
|
|
13
|
+
resolve_from_data(status: status, limit: limit)
|
|
14
|
+
rescue StandardError
|
|
15
|
+
[]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.resolve_from_data(status: nil, limit: nil)
|
|
19
|
+
return [] unless defined?(Legion::Data::Model::Task)
|
|
20
|
+
|
|
21
|
+
dataset = Legion::Data::Model::Task.order(Sequel.desc(:id))
|
|
22
|
+
dataset = dataset.where(status: status) if status
|
|
23
|
+
dataset = dataset.limit(limit) if limit
|
|
24
|
+
dataset.all.map { |t| task_hash(t.values) }
|
|
25
|
+
rescue StandardError
|
|
26
|
+
[]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.task_hash(values)
|
|
30
|
+
{
|
|
31
|
+
id: values[:id],
|
|
32
|
+
status: values[:status],
|
|
33
|
+
extension: values[:extension],
|
|
34
|
+
runner: values[:runner],
|
|
35
|
+
function: values[:function],
|
|
36
|
+
created_at: values[:created_at]&.to_s,
|
|
37
|
+
completed_at: values[:completed_at]&.to_s
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private_class_method :resolve_from_data, :task_hash
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Resolvers
|
|
7
|
+
module Workers
|
|
8
|
+
def self.resolve(status: nil, risk_tier: nil, limit: nil)
|
|
9
|
+
if defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
10
|
+
resolve_from_data(status: status, risk_tier: risk_tier, limit: limit)
|
|
11
|
+
else
|
|
12
|
+
resolve_from_registry(status: status, risk_tier: risk_tier, limit: limit)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.find(id:)
|
|
17
|
+
if defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection
|
|
18
|
+
find_from_data(id: id)
|
|
19
|
+
else
|
|
20
|
+
resolve(limit: nil).find { |w| w[:id].to_s == id.to_s }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.resolve_from_data(status: nil, risk_tier: nil, limit: nil)
|
|
25
|
+
return [] unless defined?(Legion::Data::Model::DigitalWorker)
|
|
26
|
+
|
|
27
|
+
dataset = Legion::Data::Model::DigitalWorker.order(:id)
|
|
28
|
+
dataset = dataset.where(lifecycle_state: status) if status
|
|
29
|
+
dataset = dataset.where(risk_tier: risk_tier) if risk_tier
|
|
30
|
+
dataset = dataset.limit(limit) if limit
|
|
31
|
+
dataset.all.map { |w| worker_hash(w.values) }
|
|
32
|
+
rescue StandardError
|
|
33
|
+
[]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.resolve_from_registry(status: nil, risk_tier: nil, limit: nil)
|
|
37
|
+
workers = []
|
|
38
|
+
|
|
39
|
+
if defined?(Legion::DigitalWorker::Registry)
|
|
40
|
+
ids = Legion::DigitalWorker::Registry.local_worker_ids
|
|
41
|
+
ids.each do |wid|
|
|
42
|
+
workers << { id: wid, name: "worker-#{wid}", status: 'active', risk_tier: nil, team: nil, extension: nil, created_at: nil }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
workers = workers.select { |w| w[:status] == status } if status
|
|
47
|
+
workers = workers.select { |w| w[:risk_tier] == risk_tier } if risk_tier
|
|
48
|
+
workers = workers.first(limit) if limit
|
|
49
|
+
workers
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.find_from_data(id:)
|
|
53
|
+
return nil unless defined?(Legion::Data::Model::DigitalWorker)
|
|
54
|
+
|
|
55
|
+
worker = Legion::Data::Model::DigitalWorker.first(id: id.to_i)
|
|
56
|
+
worker ? worker_hash(worker.values) : nil
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.worker_hash(values)
|
|
62
|
+
{
|
|
63
|
+
id: values[:id],
|
|
64
|
+
name: values[:name],
|
|
65
|
+
status: values[:lifecycle_state] || values[:status],
|
|
66
|
+
risk_tier: values[:risk_tier],
|
|
67
|
+
team: values[:team],
|
|
68
|
+
extension: values[:extension_name],
|
|
69
|
+
created_at: values[:created_at]&.to_s
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private_class_method :resolve_from_data, :resolve_from_registry, :find_from_data, :worker_hash
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(GraphQL)
|
|
4
|
+
|
|
5
|
+
require_relative 'types/base_object'
|
|
6
|
+
require_relative 'types/node_type'
|
|
7
|
+
require_relative 'types/worker_type'
|
|
8
|
+
require_relative 'types/extension_type'
|
|
9
|
+
require_relative 'types/task_type'
|
|
10
|
+
require_relative 'resolvers/node'
|
|
11
|
+
require_relative 'resolvers/workers'
|
|
12
|
+
require_relative 'resolvers/extensions'
|
|
13
|
+
require_relative 'resolvers/tasks'
|
|
14
|
+
require_relative 'types/query_type'
|
|
15
|
+
|
|
16
|
+
module Legion
|
|
17
|
+
class API < Sinatra::Base
|
|
18
|
+
module GraphQL
|
|
19
|
+
class Schema < ::GraphQL::Schema
|
|
20
|
+
query Types::QueryType
|
|
21
|
+
|
|
22
|
+
max_depth 10
|
|
23
|
+
max_complexity 200
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(GraphQL)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module GraphQL
|
|
8
|
+
module Types
|
|
9
|
+
class ExtensionType < BaseObject
|
|
10
|
+
graphql_name 'Extension'
|
|
11
|
+
description 'A LegionIO extension (LEX)'
|
|
12
|
+
|
|
13
|
+
field :name, String, null: true, description: 'Extension gem name'
|
|
14
|
+
field :version, String, null: true, description: 'Extension version'
|
|
15
|
+
field :status, String, null: true, description: 'Extension status'
|
|
16
|
+
field :description, String, null: true, description: 'Extension description'
|
|
17
|
+
field :risk_tier, String, null: true, description: 'Risk classification tier'
|
|
18
|
+
field :runners, [String], null: true, description: 'Runner class names'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(GraphQL)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module GraphQL
|
|
8
|
+
module Types
|
|
9
|
+
class NodeType < BaseObject
|
|
10
|
+
graphql_name 'Node'
|
|
11
|
+
description 'A LegionIO node'
|
|
12
|
+
|
|
13
|
+
field :name, String, null: true, description: 'Node name'
|
|
14
|
+
field :version, String, null: true, description: 'LegionIO version'
|
|
15
|
+
field :uptime, Integer, null: true, description: 'Uptime in seconds'
|
|
16
|
+
field :ready, Boolean, null: false, description: 'Whether the node is ready'
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(GraphQL)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module GraphQL
|
|
8
|
+
module Types
|
|
9
|
+
class QueryType < BaseObject
|
|
10
|
+
graphql_name 'Query'
|
|
11
|
+
description 'Root query type'
|
|
12
|
+
|
|
13
|
+
# ── workers ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
field :workers, [WorkerType], null: false, description: 'List digital workers' do
|
|
16
|
+
argument :status, String, required: false, description: 'Filter by lifecycle state'
|
|
17
|
+
argument :risk_tier, String, required: false, description: 'Filter by risk tier'
|
|
18
|
+
argument :limit, Integer, required: false, description: 'Maximum results'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
field :worker, WorkerType, null: true, description: 'Find a digital worker by ID' do
|
|
22
|
+
argument :id, ID, required: true, description: 'Worker ID'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# ── extensions ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
field :extensions, [ExtensionType], null: false, description: 'List loaded extensions' do
|
|
28
|
+
argument :status, String, required: false, description: 'Filter by status'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
field :extension, ExtensionType, null: true, description: 'Find an extension by name' do
|
|
32
|
+
argument :name, String, required: true, description: 'Extension gem name'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# ── tasks ─────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
field :tasks, [TaskType], null: false, description: 'List task records' do
|
|
38
|
+
argument :status, String, required: false, description: 'Filter by status'
|
|
39
|
+
argument :limit, Integer, required: false, description: 'Maximum results'
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# ── node ──────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
field :node, NodeType, null: true, description: 'Current node information'
|
|
45
|
+
|
|
46
|
+
# ── resolvers ────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
def workers(status: nil, risk_tier: nil, limit: nil)
|
|
49
|
+
Resolvers::Workers.resolve(status: status, risk_tier: risk_tier, limit: limit)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def worker(id:)
|
|
53
|
+
Resolvers::Workers.find(id: id)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def extensions(status: nil)
|
|
57
|
+
Resolvers::Extensions.resolve(status: status)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def extension(name:)
|
|
61
|
+
Resolvers::Extensions.find(name: name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def tasks(status: nil, limit: nil)
|
|
65
|
+
Resolvers::Tasks.resolve(status: status, limit: limit)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def node
|
|
69
|
+
Resolvers::Node.resolve
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(GraphQL)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module GraphQL
|
|
8
|
+
module Types
|
|
9
|
+
class TaskType < BaseObject
|
|
10
|
+
graphql_name 'Task'
|
|
11
|
+
description 'A LegionIO task execution record'
|
|
12
|
+
|
|
13
|
+
field :id, Integer, null: true, description: 'Task database ID'
|
|
14
|
+
field :status, String, null: true, description: 'Task status'
|
|
15
|
+
field :extension, String, null: true, description: 'Extension name'
|
|
16
|
+
field :runner, String, null: true, description: 'Runner namespace'
|
|
17
|
+
field :function, String, null: true, description: 'Function name'
|
|
18
|
+
field :created_at, String, null: true, description: 'Creation timestamp'
|
|
19
|
+
field :completed_at, String, null: true, description: 'Completion timestamp'
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(GraphQL)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module GraphQL
|
|
8
|
+
module Types
|
|
9
|
+
class WorkerType < BaseObject
|
|
10
|
+
graphql_name 'Worker'
|
|
11
|
+
description 'A LegionIO digital worker'
|
|
12
|
+
|
|
13
|
+
field :id, Integer, null: true, description: 'Worker database ID'
|
|
14
|
+
field :name, String, null: true, description: 'Worker name'
|
|
15
|
+
field :status, String, null: true, description: 'Lifecycle state'
|
|
16
|
+
field :risk_tier, String, null: true, description: 'AIRB risk tier'
|
|
17
|
+
field :team, String, null: true, description: 'Team name'
|
|
18
|
+
field :extension, String, null: true, description: 'Extension name'
|
|
19
|
+
field :created_at, String, null: true, description: 'Creation timestamp'
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(GraphQL)
|
|
4
|
+
|
|
5
|
+
require_relative 'graphql/schema'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
class API < Sinatra::Base
|
|
9
|
+
module Routes
|
|
10
|
+
module GraphQL
|
|
11
|
+
def self.registered(app)
|
|
12
|
+
app.post '/api/graphql' do
|
|
13
|
+
content_type :json
|
|
14
|
+
|
|
15
|
+
body_str = request.body.read
|
|
16
|
+
payload = body_str.empty? ? {} : Legion::JSON.load(body_str)
|
|
17
|
+
payload = payload.transform_keys(&:to_sym) if payload.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
query = payload[:query]
|
|
20
|
+
variables = payload[:variables] || {}
|
|
21
|
+
operation_name = payload[:operationName]
|
|
22
|
+
|
|
23
|
+
if query.nil? || query.strip.empty?
|
|
24
|
+
status 400
|
|
25
|
+
next Legion::JSON.dump({
|
|
26
|
+
errors: [{ message: 'query is required' }]
|
|
27
|
+
})
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
result = Legion::API::GraphQL::Schema.execute(
|
|
31
|
+
query,
|
|
32
|
+
variables: variables,
|
|
33
|
+
operation_name: operation_name,
|
|
34
|
+
context: { request: request }
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
status 200
|
|
38
|
+
Legion::JSON.dump(result.to_h)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Legion::Logging.error "GraphQL execution error: #{e.message}" if defined?(Legion::Logging)
|
|
41
|
+
status 500
|
|
42
|
+
Legion::JSON.dump({ errors: [{ message: e.message }] })
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
app.get '/api/graphql' do
|
|
46
|
+
content_type 'text/html'
|
|
47
|
+
Legion::API::Routes::GraphQL.graphiql_html
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.graphiql_html
|
|
52
|
+
<<~HTML
|
|
53
|
+
<!DOCTYPE html>
|
|
54
|
+
<html>
|
|
55
|
+
<head>
|
|
56
|
+
<title>LegionIO GraphiQL</title>
|
|
57
|
+
<link href="https://cdn.jsdelivr.net/npm/graphiql@3/graphiql.min.css" rel="stylesheet" />
|
|
58
|
+
</head>
|
|
59
|
+
<body style="margin:0">
|
|
60
|
+
<div id="graphiql" style="height:100vh"></div>
|
|
61
|
+
<script crossorigin src="https://cdn.jsdelivr.net/npm/react@18/umd/react.development.js"></script>
|
|
62
|
+
<script crossorigin src="https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.development.js"></script>
|
|
63
|
+
<script crossorigin src="https://cdn.jsdelivr.net/npm/graphiql@3/graphiql.min.js"></script>
|
|
64
|
+
<script>
|
|
65
|
+
const root = ReactDOM.createRoot(document.getElementById('graphiql'));
|
|
66
|
+
root.render(React.createElement(GraphiQL, {
|
|
67
|
+
fetcher: GraphiQL.createFetcher({ url: '/api/graphql' })
|
|
68
|
+
}));
|
|
69
|
+
</script>
|
|
70
|
+
</body>
|
|
71
|
+
</html>
|
|
72
|
+
HTML
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/legion/api/workers.rb
CHANGED
|
@@ -8,6 +8,7 @@ module Legion
|
|
|
8
8
|
register_collection(app)
|
|
9
9
|
register_member(app)
|
|
10
10
|
register_sub_resources(app)
|
|
11
|
+
register_approvals(app)
|
|
11
12
|
register_teams(app)
|
|
12
13
|
end
|
|
13
14
|
|
|
@@ -212,6 +213,52 @@ module Legion
|
|
|
212
213
|
end
|
|
213
214
|
end
|
|
214
215
|
|
|
216
|
+
def self.register_approvals(app)
|
|
217
|
+
require 'legion/digital_worker/registration'
|
|
218
|
+
|
|
219
|
+
app.get '/api/workers/approvals' do
|
|
220
|
+
require_data!
|
|
221
|
+
workers = Legion::DigitalWorker::Registration.pending_approvals
|
|
222
|
+
json_response({ data: workers.map(&:values), count: workers.size })
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
app.post '/api/workers/:id/approve' do
|
|
226
|
+
require_data!
|
|
227
|
+
body = parse_request_body
|
|
228
|
+
approver = body[:approver] || current_owner_msid || 'api'
|
|
229
|
+
notes = body[:notes]
|
|
230
|
+
|
|
231
|
+
worker = Legion::DigitalWorker::Registration.approve(params[:id], approver: approver, notes: notes)
|
|
232
|
+
json_response(worker.values)
|
|
233
|
+
rescue ArgumentError => e
|
|
234
|
+
json_error('invalid_request', e.message, status_code: 422)
|
|
235
|
+
rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e
|
|
236
|
+
json_error('invalid_transition', e.message, status_code: 422)
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
Legion::Logging.error "API worker approve error: #{e.message}"
|
|
239
|
+
json_error('approve_error', e.message, status_code: 500)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
app.post '/api/workers/:id/reject' do
|
|
243
|
+
require_data!
|
|
244
|
+
body = parse_request_body
|
|
245
|
+
approver = body[:approver] || current_owner_msid || 'api'
|
|
246
|
+
reason = body[:reason]
|
|
247
|
+
|
|
248
|
+
halt 422, json_error('missing_field', 'reason is required', status_code: 422) unless reason
|
|
249
|
+
|
|
250
|
+
worker = Legion::DigitalWorker::Registration.reject(params[:id], approver: approver, reason: reason)
|
|
251
|
+
json_response(worker.values)
|
|
252
|
+
rescue ArgumentError => e
|
|
253
|
+
json_error('invalid_request', e.message, status_code: 422)
|
|
254
|
+
rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e
|
|
255
|
+
json_error('invalid_transition', e.message, status_code: 422)
|
|
256
|
+
rescue StandardError => e
|
|
257
|
+
Legion::Logging.error "API worker reject error: #{e.message}"
|
|
258
|
+
json_error('reject_error', e.message, status_code: 500)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
215
262
|
def self.register_teams(app)
|
|
216
263
|
app.get '/api/teams/:team/workers' do
|
|
217
264
|
require_data!
|
|
@@ -232,7 +279,7 @@ module Legion
|
|
|
232
279
|
end
|
|
233
280
|
|
|
234
281
|
class << self
|
|
235
|
-
private :register_collection, :register_member, :register_sub_resources, :register_teams
|
|
282
|
+
private :register_collection, :register_member, :register_sub_resources, :register_approvals, :register_teams
|
|
236
283
|
end
|
|
237
284
|
end
|
|
238
285
|
end
|