legionio 1.4.48 → 1.4.50

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: d7a08e02740d6be6ecb76c91c3ee6f3a2ed8f594801994e8005c1ec3e3a3b47c
4
- data.tar.gz: c45901fb93e4e97063e3044eee0d5646a50447acb818055ff75428a6c5f490a8
3
+ metadata.gz: 9f4884c77ed8ea44e74bdc3c9860dac014693cc90a3029fb06f1bc9e5a7edc27
4
+ data.tar.gz: 8da5570f871da743755e0c1c0fc1f81a8a5085061a6e27b5a8d9c2befe2400e0
5
5
  SHA512:
6
- metadata.gz: 23eb01d19c9e38ab19d0bf28f5f2f9d32cb7bd64a47abd1fa0833b2e7d7ba13963c62eda860b43d5745cc54d77e4bacd94086e4749351e0b3af0489dd7ee5dc5
7
- data.tar.gz: f436f935e81de29c1364022aad090c0e7c8bd7f1ee9419a1b33aaf1bb66800ffa117f699047ed02f314498161db79ad82193863893f5f538e9e08cf0147272b1
6
+ metadata.gz: be184813bae9bba912f9d190a06dbb9e3bc0a5b9c317019743fcaf1787de5c8aaad28fec85dcd14e1ffe27115cb80be3ea0970dd183cc002042aac1f2a12a7f1
7
+ data.tar.gz: e844ec3f09dd2f5bc6d8c908f884def2a6e4d71aa0ec5178d0e1ec1440c3a4cfe9d5d58996736acca92c67b7bced45ed8e8d2ffbc00a5f37a1ac4c6c4fb3ca37
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.50] - 2026-03-17
4
+
5
+ ### Added
6
+ - `Legion::Graph::Builder`: builds task relationship graph from relationships table with chain/worker filtering
7
+ - `Legion::Graph::Exporter`: renders graphs to Mermaid and DOT (Graphviz) formats
8
+ - `legion graph show`: CLI command with `--format mermaid|dot`, `--chain`, `--worker`, `--output`, `--limit` options
9
+
10
+ ## [1.4.49] - 2026-03-17
11
+
12
+ ### Added
13
+ - `Legion::TenantContext`: thread-local tenant context propagation (set, clear, with block)
14
+ - `Legion::Tenants`: tenant CRUD, suspension, and quota enforcement
15
+ - `Middleware::Tenant`: extracts tenant_id from JWT/header, sets TenantContext per request
16
+ - `GET/POST /api/tenants`: tenant listing and provisioning endpoints
17
+ - `POST /api/tenants/:id/suspend`: tenant suspension
18
+ - `GET /api/tenants/:id/quota/:resource`: quota check endpoint
19
+
3
20
  ## [1.4.48] - 2026-03-17
4
21
 
5
22
  ### Added
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Middleware
6
+ class Tenant
7
+ SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics].freeze
8
+
9
+ def initialize(app, opts = {})
10
+ @app = app
11
+ @opts = opts
12
+ end
13
+
14
+ def call(env)
15
+ return @app.call(env) if skip_path?(env['PATH_INFO'])
16
+
17
+ tenant_id = extract_tenant(env)
18
+ Legion::TenantContext.set(tenant_id) if tenant_id
19
+ @app.call(env)
20
+ ensure
21
+ Legion::TenantContext.clear
22
+ end
23
+
24
+ private
25
+
26
+ def skip_path?(path)
27
+ SKIP_PATHS.any? { |sp| path.start_with?(sp) }
28
+ end
29
+
30
+ def extract_tenant(env)
31
+ env['legion.tenant_id'] || env['HTTP_X_TENANT_ID']
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tenants'
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module Routes
8
+ module Tenants
9
+ def self.registered(app)
10
+ app.get '/api/tenants' do
11
+ tenants = Legion::Tenants.list
12
+ json_response(data: tenants)
13
+ end
14
+
15
+ app.post '/api/tenants' do
16
+ params = parsed_body
17
+ result = Legion::Tenants.create(
18
+ tenant_id: params['tenant_id'],
19
+ name: params['name'],
20
+ max_workers: params['max_workers'] || 10
21
+ )
22
+ status result[:error] ? 409 : 201
23
+ json_response(data: result)
24
+ end
25
+
26
+ app.get '/api/tenants/:tenant_id' do
27
+ tenant = Legion::Tenants.find(params[:tenant_id])
28
+ halt 404, json_response(error: 'not_found') unless tenant
29
+ json_response(data: tenant)
30
+ end
31
+
32
+ app.post '/api/tenants/:tenant_id/suspend' do
33
+ result = Legion::Tenants.suspend(tenant_id: params[:tenant_id])
34
+ json_response(data: result)
35
+ end
36
+
37
+ app.get '/api/tenants/:tenant_id/quota/:resource' do
38
+ result = Legion::Tenants.check_quota(
39
+ tenant_id: params[:tenant_id],
40
+ resource: params[:resource].to_sym
41
+ )
42
+ json_response(data: result)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+
5
+ module Legion
6
+ module CLI
7
+ class GraphCommand < Thor
8
+ namespace 'graph'
9
+
10
+ desc 'show', 'Display task relationship graph'
11
+ option :chain, type: :string, desc: 'Filter by chain ID'
12
+ option :worker, type: :string, desc: 'Filter by worker ID'
13
+ option :format, type: :string, default: 'mermaid', enum: %w[mermaid dot]
14
+ option :output, type: :string, desc: 'Write to file'
15
+ option :limit, type: :numeric, default: 100
16
+ def show
17
+ require 'legion/graph/builder'
18
+ require 'legion/graph/exporter'
19
+
20
+ graph = Legion::Graph::Builder.build(
21
+ chain_id: options[:chain],
22
+ worker_id: options[:worker],
23
+ limit: options[:limit]
24
+ )
25
+
26
+ rendered = case options[:format]
27
+ when 'dot' then Legion::Graph::Exporter.to_dot(graph)
28
+ else Legion::Graph::Exporter.to_mermaid(graph)
29
+ end
30
+
31
+ if options[:output]
32
+ File.write(options[:output], rendered)
33
+ say "Written to #{options[:output]}", :green
34
+ else
35
+ say rendered
36
+ end
37
+ end
38
+
39
+ default_task :show
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Graph
5
+ module Builder
6
+ class << self
7
+ def build(chain_id: nil, worker_id: nil, limit: 100) # rubocop:disable Lint/UnusedMethodArgument
8
+ return { nodes: {}, edges: [] } unless db_available?
9
+
10
+ ds = Legion::Data.connection[:relationships].limit(limit)
11
+ ds = ds.where(chain_id: chain_id) if chain_id
12
+
13
+ nodes = {}
14
+ edges = []
15
+
16
+ ds.each do |rel|
17
+ trigger = rel[:trigger] || "node_#{rel[:id]}_from"
18
+ action = rel[:action] || "node_#{rel[:id]}_to"
19
+
20
+ nodes[trigger] ||= { label: trigger, type: 'trigger' }
21
+ nodes[action] ||= { label: action, type: 'action' }
22
+ edges << {
23
+ from: trigger,
24
+ to: action,
25
+ label: rel[:runner_function] || '',
26
+ chain_id: rel[:chain_id]
27
+ }
28
+ end
29
+
30
+ { nodes: nodes, edges: edges }
31
+ end
32
+
33
+ private
34
+
35
+ def db_available?
36
+ defined?(Legion::Data) && Legion::Data.connection&.table_exists?(:relationships)
37
+ rescue StandardError
38
+ false
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Graph
5
+ module Exporter
6
+ class << self
7
+ def to_mermaid(graph)
8
+ lines = ['graph TD']
9
+ node_ids = {}
10
+ counter = 0
11
+
12
+ graph[:nodes].each do |key, node|
13
+ counter += 1
14
+ id = "N#{counter}"
15
+ node_ids[key] = id
16
+ lines << " #{id}[#{node[:label]}]"
17
+ end
18
+
19
+ graph[:edges].each do |edge|
20
+ from = node_ids[edge[:from]]
21
+ to = node_ids[edge[:to]]
22
+ next unless from && to
23
+
24
+ lines << if edge[:label] && !edge[:label].empty?
25
+ " #{from} -->|#{edge[:label]}| #{to}"
26
+ else
27
+ " #{from} --> #{to}"
28
+ end
29
+ end
30
+
31
+ lines.join("\n")
32
+ end
33
+
34
+ def to_dot(graph)
35
+ lines = ['digraph legion_tasks {', ' rankdir=LR;']
36
+
37
+ graph[:nodes].each do |key, node|
38
+ label = node[:label].gsub('"', '\\"')
39
+ shape = node[:type] == 'trigger' ? 'box' : 'ellipse'
40
+ lines << " \"#{key}\" [label=\"#{label}\" shape=#{shape}];"
41
+ end
42
+
43
+ graph[:edges].each do |edge|
44
+ label = edge[:label] && !edge[:label].empty? ? " [label=\"#{edge[:label]}\"]" : ''
45
+ lines << " \"#{edge[:from]}\" -> \"#{edge[:to]}\"#{label};"
46
+ end
47
+
48
+ lines << '}'
49
+ lines.join("\n")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module TenantContext
5
+ class << self
6
+ def current
7
+ Thread.current[:legion_tenant_id]
8
+ end
9
+
10
+ def set(tenant_id)
11
+ Thread.current[:legion_tenant_id] = tenant_id
12
+ end
13
+
14
+ def clear
15
+ Thread.current[:legion_tenant_id] = nil
16
+ end
17
+
18
+ def with(tenant_id)
19
+ prev = current
20
+ set(tenant_id)
21
+ yield
22
+ ensure
23
+ set(prev)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Tenants
5
+ class << self
6
+ def create(tenant_id:, name: nil, max_workers: 10, max_queue_depth: 10_000, **)
7
+ return { error: 'tenant_exists' } if find(tenant_id)
8
+
9
+ Legion::Data.connection[:tenants].insert(
10
+ tenant_id: tenant_id,
11
+ name: name || tenant_id,
12
+ max_workers: max_workers,
13
+ max_queue_depth: max_queue_depth,
14
+ status: 'active',
15
+ created_at: Time.now.utc,
16
+ updated_at: Time.now.utc
17
+ )
18
+ { created: true, tenant_id: tenant_id }
19
+ end
20
+
21
+ def find(tenant_id)
22
+ Legion::Data.connection[:tenants].where(tenant_id: tenant_id).first
23
+ rescue StandardError
24
+ nil
25
+ end
26
+
27
+ def suspend(tenant_id:, **)
28
+ Legion::Data.connection[:tenants]
29
+ .where(tenant_id: tenant_id)
30
+ .update(status: 'suspended', updated_at: Time.now.utc)
31
+ { suspended: true, tenant_id: tenant_id }
32
+ end
33
+
34
+ def list(**)
35
+ Legion::Data.connection[:tenants].all
36
+ end
37
+
38
+ def check_quota(tenant_id:, resource:, **)
39
+ tenant = find(tenant_id)
40
+ return { allowed: true } unless tenant
41
+
42
+ case resource
43
+ when :workers
44
+ count = Legion::Data.connection[:digital_workers].where(tenant_id: tenant_id).count
45
+ { allowed: count < tenant[:max_workers], current: count, limit: tenant[:max_workers] }
46
+ else
47
+ { allowed: true }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.48'
4
+ VERSION = '1.4.50'
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.4.48
4
+ version: 1.4.50
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -333,6 +333,7 @@ files:
333
333
  - lib/legion/api/middleware/auth.rb
334
334
  - lib/legion/api/middleware/body_limit.rb
335
335
  - lib/legion/api/middleware/rate_limit.rb
336
+ - lib/legion/api/middleware/tenant.rb
336
337
  - lib/legion/api/nodes.rb
337
338
  - lib/legion/api/oauth.rb
338
339
  - lib/legion/api/openapi.rb
@@ -341,6 +342,7 @@ files:
341
342
  - lib/legion/api/schedules.rb
342
343
  - lib/legion/api/settings.rb
343
344
  - lib/legion/api/tasks.rb
345
+ - lib/legion/api/tenants.rb
344
346
  - lib/legion/api/token.rb
345
347
  - lib/legion/api/transport.rb
346
348
  - lib/legion/api/validators.rb
@@ -415,6 +417,7 @@ files:
415
417
  - lib/legion/cli/function.rb
416
418
  - lib/legion/cli/gaia_command.rb
417
419
  - lib/legion/cli/generate_command.rb
420
+ - lib/legion/cli/graph_command.rb
418
421
  - lib/legion/cli/init/config_generator.rb
419
422
  - lib/legion/cli/init/environment_detector.rb
420
423
  - lib/legion/cli/init_command.rb
@@ -511,6 +514,8 @@ files:
511
514
  - lib/legion/extensions/helpers/transport.rb
512
515
  - lib/legion/extensions/hooks/base.rb
513
516
  - lib/legion/extensions/transport.rb
517
+ - lib/legion/graph/builder.rb
518
+ - lib/legion/graph/exporter.rb
514
519
  - lib/legion/guardrails.rb
515
520
  - lib/legion/ingress.rb
516
521
  - lib/legion/isolation.rb
@@ -566,6 +571,8 @@ files:
566
571
  - lib/legion/service.rb
567
572
  - lib/legion/supervision.rb
568
573
  - lib/legion/telemetry.rb
574
+ - lib/legion/tenant_context.rb
575
+ - lib/legion/tenants.rb
569
576
  - lib/legion/version.rb
570
577
  - lib/legion/webhooks.rb
571
578
  - scripts/rollout-ci-workflow.sh