legionio 1.4.79 → 1.4.82

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: 833830caa5613e077d49c9a67c1dc00b907a473daf7be9033c9389c51546a5e3
4
- data.tar.gz: 9c93197f8b0a9ecd0f784598c9b81e98d0158ab03b94d4b822a3d6ef55419868
3
+ metadata.gz: be7718191541e7f3c7d122c0df0f74145ef0ed5aa8e4b8a67396932155e4cccf
4
+ data.tar.gz: 5b4f4e5453fe9667b6424433d8d0ec9cfe524b6f9c35be64f7e4b0edeae1c3f3
5
5
  SHA512:
6
- metadata.gz: 4689fa56cc6ad0cfd06f1b836fd36211edeaedacc65306ed888bdc97d36e1d430f5c7c6f3eb65427ff7c234cf1908f2fb14f26f448b8d6f2a414c726011c699f
7
- data.tar.gz: 3a127117aba989a57d1e1077e6972a2d32d07c02148fb53eae5c083e8752d509d4a9d84a9db9bb3086292a983e3154d8632e7bca41483a5e9740ccb1769c1ec0
6
+ metadata.gz: dd10e410a69d1cae47dadbaf02f287857410b44e8f8d305c1cd8b555a44a32d2a6b0a63950a3ee2e54d815ef28a25ee7dc08fd7e78a584ec39698b03385bcac0
7
+ data.tar.gz: ce22d223fd1b67df63878d1e1f4d54273437c01a4359f384dc0658ecf06e1e83481285d2b78afd511ee17fabe75a5cb8342826e5cab6ee63cc49a33ccb3c38e4
data/.dockerignore ADDED
@@ -0,0 +1,11 @@
1
+ .git
2
+ .github
3
+ spec
4
+ docs
5
+ *.md
6
+ .rubocop.yml
7
+ .rspec
8
+ tmp
9
+ log
10
+ .dockerignore
11
+ Dockerfile
@@ -0,0 +1,118 @@
1
+ # .github/workflow-templates/eval-gate.yml
2
+ #
3
+ # Eval gate workflow template for LegionIO CI/CD pipelines.
4
+ # Copy this file to .github/workflows/eval-gate.yml in your repo and adjust the
5
+ # env vars to match your dataset and threshold requirements.
6
+ #
7
+ # Required secrets:
8
+ # LEGIONIO_BOOTSTRAP_CONFIG (base64-encoded bootstrap JSON, or omit for defaults)
9
+ #
10
+ # Usage:
11
+ # - Trigger manually (workflow_dispatch) or on push/PR targeting main
12
+ # - Job exits 0 if avg_score >= threshold, exits 1 and fails the pipeline if below
13
+
14
+ name: Eval Gate
15
+
16
+ on:
17
+ push:
18
+ branches: [main]
19
+ pull_request:
20
+ branches: [main]
21
+ workflow_dispatch:
22
+ inputs:
23
+ dataset:
24
+ description: 'Dataset name to evaluate'
25
+ required: true
26
+ default: 'default'
27
+ threshold:
28
+ description: 'Pass/fail threshold (0.0 - 1.0)'
29
+ required: false
30
+ default: '0.8'
31
+ evaluator:
32
+ description: 'Evaluator name (leave blank for first builtin template)'
33
+ required: false
34
+ default: ''
35
+
36
+ env:
37
+ DATASET: ${{ github.event.inputs.dataset || 'default' }}
38
+ THRESHOLD: ${{ github.event.inputs.threshold || '0.8' }}
39
+ EVALUATOR: ${{ github.event.inputs.evaluator || '' }}
40
+
41
+ jobs:
42
+ eval-gate:
43
+ name: Eval Gate (${{ env.DATASET }} @ ${{ env.THRESHOLD }})
44
+ runs-on: ubuntu-latest
45
+
46
+ steps:
47
+ - name: Checkout
48
+ uses: actions/checkout@v4
49
+
50
+ - name: Set up Ruby
51
+ uses: ruby/setup-ruby@v1
52
+ with:
53
+ ruby-version: '3.4'
54
+ bundler-cache: true
55
+
56
+ - name: Install Legion
57
+ run: gem install legionio --no-document
58
+
59
+ - name: Bootstrap config (optional)
60
+ if: ${{ secrets.LEGIONIO_BOOTSTRAP_CONFIG != '' }}
61
+ env:
62
+ LEGIONIO_BOOTSTRAP_CONFIG: ${{ secrets.LEGIONIO_BOOTSTRAP_CONFIG }}
63
+ run: echo "Bootstrap config present"
64
+
65
+ - name: Run eval gate
66
+ id: eval
67
+ env:
68
+ LEGIONIO_BOOTSTRAP_CONFIG: ${{ secrets.LEGIONIO_BOOTSTRAP_CONFIG }}
69
+ run: |
70
+ EVAL_ARGS="--dataset $DATASET --threshold $THRESHOLD --exit-code --json"
71
+ if [ -n "$EVALUATOR" ]; then
72
+ EVAL_ARGS="$EVAL_ARGS --evaluator $EVALUATOR"
73
+ fi
74
+ legion eval run $EVAL_ARGS | tee eval-report.json
75
+
76
+ - name: Upload eval report
77
+ if: always()
78
+ uses: actions/upload-artifact@v4
79
+ with:
80
+ name: eval-report-${{ github.run_number }}
81
+ path: eval-report.json
82
+ retention-days: 30
83
+
84
+ - name: Annotate PR with eval results
85
+ if: github.event_name == 'pull_request' && always()
86
+ uses: actions/github-script@v7
87
+ with:
88
+ script: |
89
+ const fs = require('fs');
90
+ let report;
91
+ try {
92
+ report = JSON.parse(fs.readFileSync('eval-report.json', 'utf8'));
93
+ } catch (e) {
94
+ console.log('Could not parse eval report:', e.message);
95
+ return;
96
+ }
97
+ const gate = report.passed ? 'PASSED' : 'FAILED';
98
+ const score = (report.avg_score || 0).toFixed(3);
99
+ const thresh = report.threshold || 0;
100
+ const body = [
101
+ `## Eval Gate: ${gate}`,
102
+ '',
103
+ `| Metric | Value |`,
104
+ `|--------|-------|`,
105
+ `| Dataset | \`${report.dataset}\` |`,
106
+ `| Evaluator | \`${report.evaluator}\` |`,
107
+ `| Avg Score | ${score} |`,
108
+ `| Threshold | ${thresh} |`,
109
+ `| Total Rows | ${report.summary?.total ?? 'N/A'} |`,
110
+ `| Passed | ${report.summary?.passed ?? 'N/A'} |`,
111
+ `| Failed | ${report.summary?.failed ?? 'N/A'} |`,
112
+ ].join('\n');
113
+ github.rest.issues.createComment({
114
+ issue_number: context.issue.number,
115
+ owner: context.repo.owner,
116
+ repo: context.repo.repo,
117
+ body: body,
118
+ });
@@ -0,0 +1,67 @@
1
+ name: CI/CD
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ branches: [main]
7
+
8
+ jobs:
9
+ test:
10
+ name: Test
11
+ runs-on: ubuntu-latest
12
+ services:
13
+ rabbitmq:
14
+ image: rabbitmq:3.13-management
15
+ ports: ['5672:5672']
16
+ postgres:
17
+ image: postgres:16
18
+ env:
19
+ POSTGRES_PASSWORD: test
20
+ POSTGRES_DB: legion_test
21
+ ports: ['5432:5432']
22
+ options: >-
23
+ --health-cmd pg_isready
24
+ --health-interval 10s
25
+ --health-timeout 5s
26
+ --health-retries 5
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - uses: ruby/setup-ruby@v1
30
+ with:
31
+ ruby-version: '3.4'
32
+ bundler-cache: true
33
+ - run: bundle install && bundle exec rspec
34
+ - run: bundle exec rubocop
35
+
36
+ build:
37
+ name: Build Image
38
+ needs: test
39
+ if: github.ref == 'refs/heads/main'
40
+ runs-on: ubuntu-latest
41
+ permissions:
42
+ packages: write
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ - uses: docker/setup-buildx-action@v3
46
+ - uses: docker/login-action@v3
47
+ with:
48
+ registry: ghcr.io
49
+ username: ${{ github.actor }}
50
+ password: ${{ secrets.GITHUB_TOKEN }}
51
+ - uses: docker/build-push-action@v5
52
+ with:
53
+ context: .
54
+ push: true
55
+ tags: |
56
+ ghcr.io/legionio/legion:${{ github.sha }}
57
+ ghcr.io/legionio/legion:latest
58
+ cache-from: type=gha
59
+ cache-to: type=gha,mode=max
60
+
61
+ helm-lint:
62
+ name: Helm Lint
63
+ runs-on: ubuntu-latest
64
+ steps:
65
+ - uses: actions/checkout@v4
66
+ - uses: azure/setup-helm@v3
67
+ - run: helm lint deploy/helm/legion
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.82] - 2026-03-20
4
+
5
+ ### Added
6
+ - `legion check --privacy` command: verifies enterprise privacy mode (flag set, no cloud API keys, external endpoints unreachable)
7
+ - `PrivacyCheck` class with three probes: flag_set, no_cloud_keys, no_external_endpoints
8
+ - `Legion::Service.log_privacy_mode_status` logs enterprise privacy state at startup
9
+
10
+ ## [1.4.81] - 2026-03-20
11
+
12
+ ### Added
13
+ - `legion eval experiments` subcommand: list all experiment runs with status and summary
14
+ - `legion eval promote --experiment NAME --tag TAG` subcommand: tag a prompt version for production via lex-prompt
15
+ - `legion eval compare --run1 NAME --run2 NAME` subcommand: side-by-side diff of two experiment runs
16
+ - `require_prompt!` guard for lex-prompt extension availability
17
+
18
+ ## [1.4.80] - 2026-03-20
19
+
20
+ ### Added
21
+ - `legion eval run` CLI subcommand for CI/CD threshold-based eval gating
22
+ - `--dataset`, `--threshold`, `--evaluator`, `--exit-code` options on `eval run`
23
+ - JSON report output to stdout with per-row scores, summary, and timestamp
24
+ - `.github/workflow-templates/eval-gate.yml` reusable GitHub Actions workflow template
25
+ - PR annotation step in workflow template for inline eval result comments
26
+
3
27
  ## [1.4.79] - 2026-03-20
4
28
 
5
29
  ### Added
data/Dockerfile CHANGED
@@ -1,9 +1,26 @@
1
- FROM ruby:3.4-alpine
2
- LABEL maintainer="Matthew Iverson <matthewdiverson@gmail.com>"
1
+ # Build stage
2
+ FROM ruby:3.4-slim AS builder
3
+ WORKDIR /app
4
+ RUN apt-get update && \
5
+ apt-get install -y --no-install-recommends build-essential libpq-dev git && \
6
+ rm -rf /var/lib/apt/lists/*
7
+ COPY Gemfile Gemfile.lock ./
8
+ RUN bundle config set --local deployment true && \
9
+ bundle config set --local without 'development test' && \
10
+ bundle install --jobs 4 --retry 3
11
+ COPY . .
3
12
 
4
- RUN mkdir /etc/legionio
5
- RUN apk update && apk add build-base postgresql-dev mysql-client mariadb-dev tzdata gcc git
6
-
7
- COPY . ./
8
- RUN gem install legionio tzinfo-data tzinfo --no-document --no-prerelease
9
- CMD ruby --yjit $(which legion)
13
+ # Runtime stage
14
+ FROM ruby:3.4-slim AS runtime
15
+ RUN apt-get update && \
16
+ apt-get install -y --no-install-recommends libpq5 curl && \
17
+ rm -rf /var/lib/apt/lists/* && \
18
+ groupadd -r legion && useradd -r -g legion -d /app -s /sbin/nologin legion
19
+ WORKDIR /app
20
+ COPY --from=builder --chown=legion:legion /app /app
21
+ USER legion
22
+ EXPOSE 4567
23
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
24
+ CMD curl -sf http://localhost:4567/api/health || exit 1
25
+ ENTRYPOINT ["bundle", "exec"]
26
+ CMD ["legion", "start"]
@@ -0,0 +1,6 @@
1
+ apiVersion: v2
2
+ name: legion
3
+ description: LegionIO async job engine
4
+ version: 0.1.0
5
+ appVersion: "1.4.13"
6
+ type: application
@@ -0,0 +1,16 @@
1
+ {{- define "legion.fullname" -}}
2
+ {{- .Release.Name }}-legion
3
+ {{- end }}
4
+
5
+ {{- define "legion.labels" -}}
6
+ app.kubernetes.io/name: legion
7
+ app.kubernetes.io/instance: {{ .Release.Name }}
8
+ app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
9
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
10
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
11
+ {{- end }}
12
+
13
+ {{- define "legion.selectorLabels" -}}
14
+ app.kubernetes.io/name: legion
15
+ app.kubernetes.io/instance: {{ .Release.Name }}
16
+ {{- end }}
@@ -0,0 +1,55 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: {{ include "legion.fullname" . }}-api
5
+ labels:
6
+ {{- include "legion.labels" . | nindent 4 }}
7
+ app.kubernetes.io/component: api
8
+ spec:
9
+ replicas: {{ .Values.api.replicas }}
10
+ selector:
11
+ matchLabels:
12
+ {{- include "legion.selectorLabels" . | nindent 6 }}
13
+ app.kubernetes.io/component: api
14
+ template:
15
+ metadata:
16
+ labels:
17
+ {{- include "legion.selectorLabels" . | nindent 8 }}
18
+ app.kubernetes.io/component: api
19
+ spec:
20
+ serviceAccountName: {{ .Values.serviceAccount.name }}
21
+ containers:
22
+ - name: api
23
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
24
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
25
+ command: ["bundle", "exec", "legion", "api"]
26
+ ports:
27
+ - containerPort: {{ .Values.api.port }}
28
+ protocol: TCP
29
+ livenessProbe:
30
+ httpGet:
31
+ path: /api/health
32
+ port: {{ .Values.api.port }}
33
+ initialDelaySeconds: 10
34
+ periodSeconds: 30
35
+ readinessProbe:
36
+ httpGet:
37
+ path: /api/health
38
+ port: {{ .Values.api.port }}
39
+ initialDelaySeconds: 5
40
+ periodSeconds: 10
41
+ resources:
42
+ {{- toYaml .Values.api.resources | nindent 12 }}
43
+ env:
44
+ - name: LEGION_TRANSPORT_HOST
45
+ value: {{ .Values.rabbitmq.host | quote }}
46
+ - name: LEGION_DATA_URL
47
+ value: "postgres://$(DB_USER):$(DB_PASS)@{{ .Values.postgresql.host }}:{{ .Values.postgresql.port }}/{{ .Values.postgresql.database }}"
48
+ {{- with .Values.api.env }}
49
+ {{- toYaml . | nindent 12 }}
50
+ {{- end }}
51
+ envFrom:
52
+ - secretRef:
53
+ name: {{ .Values.rabbitmq.existingSecret }}
54
+ - secretRef:
55
+ name: {{ .Values.postgresql.existingSecret }}
@@ -0,0 +1,46 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: {{ include "legion.fullname" . }}-worker
5
+ labels:
6
+ {{- include "legion.labels" . | nindent 4 }}
7
+ app.kubernetes.io/component: worker
8
+ spec:
9
+ replicas: {{ .Values.worker.replicas }}
10
+ selector:
11
+ matchLabels:
12
+ {{- include "legion.selectorLabels" . | nindent 6 }}
13
+ app.kubernetes.io/component: worker
14
+ template:
15
+ metadata:
16
+ labels:
17
+ {{- include "legion.selectorLabels" . | nindent 8 }}
18
+ app.kubernetes.io/component: worker
19
+ spec:
20
+ serviceAccountName: {{ .Values.serviceAccount.name }}
21
+ containers:
22
+ - name: worker
23
+ image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
24
+ imagePullPolicy: {{ .Values.image.pullPolicy }}
25
+ command: ["bundle", "exec", "legion", "start"]
26
+ resources:
27
+ {{- toYaml .Values.worker.resources | nindent 12 }}
28
+ env:
29
+ - name: LEGION_TRANSPORT_HOST
30
+ value: {{ .Values.rabbitmq.host | quote }}
31
+ - name: LEGION_TRANSPORT_PORT
32
+ value: {{ .Values.rabbitmq.port | quote }}
33
+ - name: LEGION_DATA_URL
34
+ value: "postgres://$(DB_USER):$(DB_PASS)@{{ .Values.postgresql.host }}:{{ .Values.postgresql.port }}/{{ .Values.postgresql.database }}"
35
+ {{- with .Values.worker.env }}
36
+ {{- toYaml . | nindent 12 }}
37
+ {{- end }}
38
+ envFrom:
39
+ - secretRef:
40
+ name: {{ .Values.rabbitmq.existingSecret }}
41
+ - secretRef:
42
+ name: {{ .Values.postgresql.existingSecret }}
43
+ {{- with .Values.nodeSelector }}
44
+ nodeSelector:
45
+ {{- toYaml . | nindent 8 }}
46
+ {{- end }}
@@ -0,0 +1,22 @@
1
+ {{- if .Values.worker.hpa.enabled }}
2
+ apiVersion: autoscaling/v2
3
+ kind: HorizontalPodAutoscaler
4
+ metadata:
5
+ name: {{ include "legion.fullname" . }}-worker
6
+ labels:
7
+ {{- include "legion.labels" . | nindent 4 }}
8
+ spec:
9
+ scaleTargetRef:
10
+ apiVersion: apps/v1
11
+ kind: Deployment
12
+ name: {{ include "legion.fullname" . }}-worker
13
+ minReplicas: {{ .Values.worker.hpa.minReplicas }}
14
+ maxReplicas: {{ .Values.worker.hpa.maxReplicas }}
15
+ metrics:
16
+ - type: Resource
17
+ resource:
18
+ name: cpu
19
+ target:
20
+ type: Utilization
21
+ averageUtilization: {{ .Values.worker.hpa.targetCPUUtilization }}
22
+ {{- end }}
@@ -0,0 +1,13 @@
1
+ {{- if .Values.podDisruptionBudget.enabled }}
2
+ apiVersion: policy/v1
3
+ kind: PodDisruptionBudget
4
+ metadata:
5
+ name: {{ include "legion.fullname" . }}
6
+ labels:
7
+ {{- include "legion.labels" . | nindent 4 }}
8
+ spec:
9
+ minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
10
+ selector:
11
+ matchLabels:
12
+ {{- include "legion.selectorLabels" . | nindent 6 }}
13
+ {{- end }}
@@ -0,0 +1,15 @@
1
+ apiVersion: v1
2
+ kind: Service
3
+ metadata:
4
+ name: {{ include "legion.fullname" . }}-api
5
+ labels:
6
+ {{- include "legion.labels" . | nindent 4 }}
7
+ spec:
8
+ type: {{ .Values.api.service.type }}
9
+ ports:
10
+ - port: {{ .Values.api.port }}
11
+ targetPort: {{ .Values.api.port }}
12
+ protocol: TCP
13
+ selector:
14
+ {{- include "legion.selectorLabels" . | nindent 4 }}
15
+ app.kubernetes.io/component: api
@@ -0,0 +1,8 @@
1
+ {{- if .Values.serviceAccount.create }}
2
+ apiVersion: v1
3
+ kind: ServiceAccount
4
+ metadata:
5
+ name: {{ .Values.serviceAccount.name }}
6
+ labels:
7
+ {{- include "legion.labels" . | nindent 4 }}
8
+ {{- end }}
@@ -0,0 +1,69 @@
1
+ image:
2
+ repository: ghcr.io/legionio/legion
3
+ tag: latest
4
+ pullPolicy: IfNotPresent
5
+
6
+ worker:
7
+ replicas: 2
8
+ resources:
9
+ requests:
10
+ cpu: 250m
11
+ memory: 256Mi
12
+ limits:
13
+ cpu: 1000m
14
+ memory: 512Mi
15
+ hpa:
16
+ enabled: false
17
+ minReplicas: 2
18
+ maxReplicas: 20
19
+ targetCPUUtilization: 70
20
+ env: []
21
+
22
+ api:
23
+ replicas: 2
24
+ port: 4567
25
+ resources:
26
+ requests:
27
+ cpu: 100m
28
+ memory: 128Mi
29
+ limits:
30
+ cpu: 500m
31
+ memory: 256Mi
32
+ service:
33
+ type: ClusterIP
34
+ ingress:
35
+ enabled: false
36
+ env: []
37
+
38
+ rabbitmq:
39
+ host: rabbitmq
40
+ port: 5672
41
+ vhost: /
42
+ existingSecret: legion-rabbitmq
43
+
44
+ postgresql:
45
+ host: postgresql
46
+ port: 5432
47
+ database: legion
48
+ existingSecret: legion-postgresql
49
+
50
+ redis:
51
+ host: redis
52
+ port: 6379
53
+
54
+ vault:
55
+ enabled: false
56
+ address: ""
57
+ role: legion
58
+
59
+ serviceAccount:
60
+ create: true
61
+ name: legion
62
+
63
+ podDisruptionBudget:
64
+ enabled: true
65
+ minAvailable: 1
66
+
67
+ nodeSelector: {}
68
+ tolerations: []
69
+ affinity: {}
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ module Check
6
+ class PrivacyCheck
7
+ CLOUD_PROVIDERS = %i[bedrock anthropic openai gemini azure].freeze
8
+
9
+ def run
10
+ @results = {}
11
+ @results[:flag_set] = check_flag_set
12
+ @results[:no_cloud_keys] = check_no_cloud_keys
13
+ @results[:no_external_endpoints] = check_no_external_endpoints
14
+ @results
15
+ end
16
+
17
+ def overall_pass?
18
+ run.values.all? { |v| v == :pass }
19
+ end
20
+
21
+ private
22
+
23
+ def check_flag_set
24
+ if settings_loaded? && Legion::Settings.enterprise_privacy?
25
+ :pass
26
+ else
27
+ :fail
28
+ end
29
+ end
30
+
31
+ def check_no_cloud_keys
32
+ llm = Legion::Settings[:llm]
33
+ return :pass unless llm.is_a?(Hash)
34
+
35
+ providers = (llm[:providers] || llm['providers'] || {}).transform_keys(&:to_sym)
36
+ CLOUD_PROVIDERS.each do |provider|
37
+ cfg = providers[provider]
38
+ return :fail if raw_credential?(cfg)
39
+ end
40
+
41
+ :pass
42
+ rescue StandardError
43
+ :skip
44
+ end
45
+
46
+ def raw_credential?(cfg)
47
+ return false unless cfg.is_a?(Hash)
48
+
49
+ key = cfg[:api_key] || cfg['api_key'] ||
50
+ cfg[:bearer_token] || cfg['bearer_token'] ||
51
+ cfg[:secret_key] || cfg['secret_key']
52
+
53
+ key.is_a?(String) && !key.empty? && !key.start_with?('env://', 'vault://')
54
+ end
55
+
56
+ def check_no_external_endpoints
57
+ endpoints = [
58
+ ['api.anthropic.com', 443],
59
+ ['api.openai.com', 443],
60
+ ['generativelanguage.googleapis.com', 443]
61
+ ]
62
+ endpoints.each do |host, port|
63
+ return :fail if tcp_reachable?(host, port)
64
+ end
65
+ :pass
66
+ rescue StandardError
67
+ :skip
68
+ end
69
+
70
+ def tcp_reachable?(host, port)
71
+ socket = ::TCPSocket.new(host, port)
72
+ socket.close
73
+ true
74
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, Errno::ENETUNREACH
75
+ false
76
+ end
77
+
78
+ def settings_loaded?
79
+ defined?(Legion::Settings) && Legion::Settings.respond_to?(:enterprise_privacy?)
80
+ rescue StandardError
81
+ false
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -17,7 +17,53 @@ module Legion
17
17
  api: :transport
18
18
  }.freeze
19
19
 
20
+ autoload :PrivacyCheck, 'legion/cli/check/privacy_check'
21
+
22
+ PROBE_LABELS = {
23
+ flag_set: 'Privacy flag set',
24
+ no_cloud_keys: 'No cloud API keys configured',
25
+ no_external_endpoints: 'External endpoints unreachable'
26
+ }.freeze
27
+
20
28
  class << self
29
+ def run_privacy(formatter, options)
30
+ require 'legion/settings'
31
+ dir = Connection.send(:resolve_config_dir)
32
+ Legion::Settings.load(config_dir: dir)
33
+
34
+ checker = PrivacyCheck.new
35
+ results = checker.run
36
+
37
+ if options[:json]
38
+ formatter.json({ results: results, overall: checker.overall_pass? ? 'pass' : 'fail' })
39
+ return checker.overall_pass? ? 0 : 1
40
+ end
41
+
42
+ formatter.header('Enterprise Privacy Mode Check')
43
+ formatter.spacer
44
+
45
+ results.each do |probe, status|
46
+ label = PROBE_LABELS.fetch(probe, probe.to_s).ljust(36)
47
+ case status
48
+ when :pass
49
+ puts " #{label}#{formatter.colorize('pass', :green)}"
50
+ when :fail
51
+ puts " #{label}#{formatter.colorize('FAIL', :red)}"
52
+ when :skip
53
+ puts " #{label}#{formatter.colorize('skip', :yellow)}"
54
+ end
55
+ end
56
+
57
+ formatter.spacer
58
+ if checker.overall_pass?
59
+ formatter.success('Privacy mode fully engaged')
60
+ else
61
+ formatter.error('Privacy mode check failed — see items above')
62
+ end
63
+
64
+ checker.overall_pass? ? 0 : 1
65
+ end
66
+
21
67
  def run(formatter, options)
22
68
  level = if options[:full]
23
69
  :full
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Legion
6
+ module CLI
7
+ class Eval < Thor
8
+ def self.exit_on_failure?
9
+ true
10
+ end
11
+
12
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
13
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
14
+ class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging'
15
+ class_option :config_dir, type: :string, desc: 'Config directory path'
16
+
17
+ desc 'run', 'Run eval against a dataset and gate on a threshold'
18
+ map 'run' => :execute
19
+ option :dataset, type: :string, required: true, aliases: '-d', desc: 'Dataset name'
20
+ option :threshold, type: :numeric, default: 0.8, aliases: '-t', desc: 'Pass/fail threshold (0.0-1.0)'
21
+ option :evaluator, type: :string, default: nil, aliases: '-e', desc: 'Evaluator name'
22
+ option :exit_code, type: :boolean, default: false, desc: 'Exit 1 if gate fails (for CI use)'
23
+ def execute
24
+ setup_connection
25
+ require_eval!
26
+ require_dataset!
27
+
28
+ rows = fetch_dataset_rows(options[:dataset])
29
+ report = run_evaluations(rows)
30
+
31
+ avg_score = report.dig(:summary, :avg_score) || 0.0
32
+ passed = avg_score >= options[:threshold]
33
+
34
+ ci_report = build_ci_report(report, avg_score, passed)
35
+
36
+ if options[:json]
37
+ formatter.json(ci_report)
38
+ else
39
+ render_human_report(ci_report, avg_score, passed)
40
+ end
41
+
42
+ exit(1) if options[:exit_code] && !passed
43
+ ensure
44
+ Connection.shutdown
45
+ end
46
+
47
+ desc 'experiments', 'List all tracked experiments'
48
+ def experiments
49
+ setup_connection
50
+ require_dataset!
51
+
52
+ client = Legion::Extensions::Dataset::Client.new
53
+ rows = client.list_experiments
54
+ out = formatter
55
+
56
+ if rows.empty?
57
+ out.warn('no experiments found')
58
+ return
59
+ end
60
+
61
+ if options[:json]
62
+ out.json(experiments: rows)
63
+ else
64
+ out.header('Experiments')
65
+ out.spacer
66
+ table_rows = rows.map do |r|
67
+ [r[:id].to_s, r[:name].to_s, r[:status].to_s, r[:created_at].to_s, r[:summary].to_s[0, 60]]
68
+ end
69
+ out.table(%w[id name status created summary], table_rows)
70
+ end
71
+ ensure
72
+ Connection.shutdown
73
+ end
74
+
75
+ desc 'promote', 'Tag a prompt version from a passing experiment for production'
76
+ option :experiment, type: :string, required: true, aliases: '-e', desc: 'Experiment name'
77
+ option :tag, type: :string, required: true, aliases: '-t', desc: 'Tag to apply (e.g. production)'
78
+ def promote
79
+ setup_connection
80
+ require_dataset!
81
+ require_prompt!
82
+
83
+ dataset_client = Legion::Extensions::Dataset::Client.new
84
+ experiment = dataset_client.get_experiment(name: options[:experiment])
85
+ raise CLI::Error, "Experiment '#{options[:experiment]}' not found" if experiment.nil?
86
+ raise CLI::Error, "Experiment '#{options[:experiment]}' has no prompt linked" if experiment[:prompt_name].nil?
87
+
88
+ prompt_client = Legion::Extensions::Prompt::Client.new
89
+ result = prompt_client.tag_prompt(
90
+ name: experiment[:prompt_name],
91
+ tag: options[:tag],
92
+ version: experiment[:prompt_version]
93
+ )
94
+
95
+ out = formatter
96
+ if options[:json]
97
+ out.json(result)
98
+ else
99
+ out.success("Tagged prompt '#{experiment[:prompt_name]}' v#{experiment[:prompt_version]} as '#{options[:tag]}'")
100
+ end
101
+ ensure
102
+ Connection.shutdown
103
+ end
104
+
105
+ desc 'compare', 'Compare two experiment runs side by side'
106
+ option :run1, type: :string, required: true, desc: 'First experiment name'
107
+ option :run2, type: :string, required: true, desc: 'Second experiment name'
108
+ def compare
109
+ setup_connection
110
+ require_dataset!
111
+
112
+ client = Legion::Extensions::Dataset::Client.new
113
+ diff = client.compare_experiments(exp1_name: options[:run1], exp2_name: options[:run2])
114
+ raise CLI::Error, 'One or both experiments not found' if diff[:error]
115
+
116
+ out = formatter
117
+ if options[:json]
118
+ out.json(diff)
119
+ else
120
+ out.header("Compare: #{diff[:exp1]} vs #{diff[:exp2]}")
121
+ out.spacer
122
+ table_rows = [
123
+ ['Rows compared', diff[:rows_compared].to_s],
124
+ ['Regressions', diff[:regression_count].to_s],
125
+ ['Improvements', diff[:improvement_count].to_s]
126
+ ]
127
+ out.table(%w[metric value], table_rows)
128
+ end
129
+ ensure
130
+ Connection.shutdown
131
+ end
132
+
133
+ no_commands do # rubocop:disable Metrics/BlockLength
134
+ def formatter
135
+ @formatter ||= Output::Formatter.new(
136
+ json: options[:json],
137
+ color: !options[:no_color]
138
+ )
139
+ end
140
+
141
+ def setup_connection
142
+ Connection.config_dir = options[:config_dir] if options[:config_dir]
143
+ Connection.log_level = options[:verbose] ? 'debug' : 'error'
144
+ Connection.ensure_data
145
+ end
146
+
147
+ def require_eval!
148
+ return if defined?(Legion::Extensions::Eval::Client)
149
+
150
+ raise CLI::Error, 'lex-eval extension is not loaded. Install and enable it first.'
151
+ end
152
+
153
+ def require_dataset!
154
+ return if defined?(Legion::Extensions::Dataset::Client)
155
+
156
+ raise CLI::Error, 'lex-dataset extension is not loaded. Install and enable it first.'
157
+ end
158
+
159
+ def require_prompt!
160
+ return if defined?(Legion::Extensions::Prompt::Client)
161
+
162
+ raise CLI::Error, 'lex-prompt extension is not loaded. Install and enable it first.'
163
+ end
164
+
165
+ def fetch_dataset_rows(name)
166
+ client = Legion::Extensions::Dataset::Client.new
167
+ result = client.get_dataset(name: name)
168
+ raise CLI::Error, "Dataset '#{name}' not found" if result[:error]
169
+
170
+ result[:rows].map do |r|
171
+ { input: r[:input], output: r[:input], expected: r[:expected_output] }
172
+ end
173
+ end
174
+
175
+ def run_evaluations(rows)
176
+ Legion::Extensions::Eval::Client.new.run_evaluation(inputs: rows)
177
+ end
178
+
179
+ def build_ci_report(report, avg_score, passed)
180
+ {
181
+ dataset: options[:dataset],
182
+ evaluator: report[:evaluator],
183
+ threshold: options[:threshold],
184
+ avg_score: avg_score,
185
+ passed: passed,
186
+ summary: report[:summary],
187
+ results: report[:results],
188
+ timestamp: Time.now.utc.iso8601
189
+ }
190
+ end
191
+
192
+ def render_human_report(report, avg_score, passed)
193
+ out = formatter
194
+ out.header("Eval Gate: #{report[:dataset]}")
195
+ out.spacer
196
+ out.detail({
197
+ dataset: report[:dataset],
198
+ evaluator: report[:evaluator],
199
+ total: report.dig(:summary, :total),
200
+ passed: report.dig(:summary, :passed),
201
+ failed: report.dig(:summary, :failed),
202
+ avg_score: format('%.3f', avg_score),
203
+ threshold: report[:threshold],
204
+ gate: passed ? 'PASSED' : 'FAILED'
205
+ })
206
+ out.spacer
207
+
208
+ if passed
209
+ out.success("Gate PASSED (avg_score=#{format('%.3f', avg_score)} >= threshold=#{report[:threshold]})")
210
+ else
211
+ out.warn("Gate FAILED (avg_score=#{format('%.3f', avg_score)} < threshold=#{report[:threshold]})")
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
data/lib/legion/cli.rb CHANGED
@@ -36,6 +36,7 @@ module Legion
36
36
  autoload :Rbac, 'legion/cli/rbac_command'
37
37
  autoload :Audit, 'legion/cli/audit_command'
38
38
  autoload :Detect, 'legion/cli/detect_command'
39
+ autoload :Eval, 'legion/cli/eval_command'
39
40
  autoload :Update, 'legion/cli/update_command'
40
41
  autoload :Init, 'legion/cli/init_command'
41
42
  autoload :Skill, 'legion/cli/skill_command'
@@ -137,8 +138,13 @@ module Legion
137
138
  DESC
138
139
  option :extensions, type: :boolean, default: false, desc: 'Also load extensions'
139
140
  option :full, type: :boolean, default: false, desc: 'Full boot cycle (extensions + API)'
141
+ option :privacy, type: :boolean, default: false, desc: 'Verify enterprise privacy mode'
140
142
  def check
141
- exit_code = Legion::CLI::Check.run(formatter, options)
143
+ exit_code = if options[:privacy]
144
+ Legion::CLI::Check.run_privacy(formatter, options)
145
+ else
146
+ Legion::CLI::Check.run(formatter, options)
147
+ end
142
148
  exit(exit_code) if exit_code != 0
143
149
  end
144
150
 
@@ -242,6 +248,9 @@ module Legion
242
248
  desc 'tty', 'Rich terminal UI (onboarding, AI chat, dashboard)'
243
249
  subcommand 'tty', Legion::CLI::Tty
244
250
 
251
+ desc 'eval SUBCOMMAND', 'Eval gating and experiment management'
252
+ subcommand 'eval', Legion::CLI::Eval
253
+
245
254
  desc 'observe SUBCOMMAND', 'MCP tool observation stats'
246
255
  subcommand 'observe', Legion::CLI::ObserveCommand
247
256
 
@@ -141,6 +141,7 @@ module Legion
141
141
  Legion::Settings.load(config_dir: config_directory)
142
142
  Legion::Readiness.mark_ready(:settings)
143
143
  Legion::Logging.info('Legion::Settings Loaded')
144
+ self.class.log_privacy_mode_status
144
145
  end
145
146
 
146
147
  def apply_cli_overrides(http_port: nil)
@@ -402,5 +403,27 @@ module Legion
402
403
  require 'legion/runner'
403
404
  Legion::Extensions.hook_extensions
404
405
  end
406
+
407
+ def self.log_privacy_mode_status
408
+ privacy = if Legion.const_defined?('Settings') && Legion::Settings.respond_to?(:enterprise_privacy?)
409
+ Legion::Settings.enterprise_privacy?
410
+ else
411
+ ENV['LEGION_ENTERPRISE_PRIVACY'] == 'true'
412
+ end
413
+
414
+ message = if privacy
415
+ 'enterprise_data_privacy enabled: cloud LLM blocked, telemetry suppressed'
416
+ else
417
+ 'enterprise_data_privacy disabled: all tiers available'
418
+ end
419
+
420
+ if Legion.const_defined?('Logging')
421
+ Legion::Logging.info(message)
422
+ else
423
+ $stdout.puts "[Legion] #{message}"
424
+ end
425
+ rescue StandardError
426
+ nil
427
+ end
405
428
  end
406
429
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.79'
4
+ VERSION = '1.4.82'
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.79
4
+ version: 1.4.82
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -313,6 +313,9 @@ executables:
313
313
  extensions: []
314
314
  extra_rdoc_files: []
315
315
  files:
316
+ - ".dockerignore"
317
+ - ".github/workflow-templates/eval-gate.yml"
318
+ - ".github/workflows/ci-cd.yml"
316
319
  - ".github/workflows/ci.yml"
317
320
  - ".gitignore"
318
321
  - ".rubocop.yml"
@@ -326,6 +329,15 @@ files:
326
329
  - completions/_legionio
327
330
  - completions/legion.bash
328
331
  - completions/legionio.bash
332
+ - deploy/helm/legion/Chart.yaml
333
+ - deploy/helm/legion/templates/_helpers.tpl
334
+ - deploy/helm/legion/templates/deployment-api.yaml
335
+ - deploy/helm/legion/templates/deployment-worker.yaml
336
+ - deploy/helm/legion/templates/hpa-worker.yaml
337
+ - deploy/helm/legion/templates/pdb.yaml
338
+ - deploy/helm/legion/templates/service-api.yaml
339
+ - deploy/helm/legion/templates/serviceaccount.yaml
340
+ - deploy/helm/legion/values.yaml
329
341
  - docker_deploy.rb
330
342
  - docs/README.md
331
343
  - docs/plans/2026-03-18-config-import-vault-multicluster-design.md
@@ -418,6 +430,7 @@ files:
418
430
  - lib/legion/cli/chat/web_fetch.rb
419
431
  - lib/legion/cli/chat/web_search.rb
420
432
  - lib/legion/cli/chat_command.rb
433
+ - lib/legion/cli/check/privacy_check.rb
421
434
  - lib/legion/cli/check_command.rb
422
435
  - lib/legion/cli/cohort.rb
423
436
  - lib/legion/cli/coldstart_command.rb
@@ -446,6 +459,7 @@ files:
446
459
  - lib/legion/cli/doctor/vault_check.rb
447
460
  - lib/legion/cli/doctor_command.rb
448
461
  - lib/legion/cli/error.rb
462
+ - lib/legion/cli/eval_command.rb
449
463
  - lib/legion/cli/function.rb
450
464
  - lib/legion/cli/gaia_command.rb
451
465
  - lib/legion/cli/generate_command.rb