diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..115f2138 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +spec +docs +*.md +.rubocop.yml +.rspec +tmp +log +.dockerignore +Dockerfile diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..09034078 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Auto-generated from team-config.yml +# Team: core +# +# To apply: scripts/apply-codeowners.sh LegionIO + +* @LegionIO/maintainers @LegionIO/core + +# Path-specific reviewers +lib/legion/cli/chat/ @LegionIO/ai +lib/legion/api/ @LegionIO/core +lib/legion/extensions/ @LegionIO/extensions +.github/ @LegionIO/infra diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..79ea87c8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: bundler + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - "type:dependencies" + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + labels: + - "type:dependencies" diff --git a/.github/workflow-templates/eval-gate.yml b/.github/workflow-templates/eval-gate.yml new file mode 100644 index 00000000..f13231bf --- /dev/null +++ b/.github/workflow-templates/eval-gate.yml @@ -0,0 +1,118 @@ +# .github/workflow-templates/eval-gate.yml +# +# Eval gate workflow template for LegionIO CI/CD pipelines. +# Copy this file to .github/workflows/eval-gate.yml in your repo and adjust the +# env vars to match your dataset and threshold requirements. +# +# Required secrets: +# LEGIONIO_BOOTSTRAP_CONFIG (base64-encoded bootstrap JSON, or omit for defaults) +# +# Usage: +# - Trigger manually (workflow_dispatch) or on push/PR targeting main +# - Job exits 0 if avg_score >= threshold, exits 1 and fails the pipeline if below + +name: Eval Gate + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + inputs: + dataset: + description: 'Dataset name to evaluate' + required: true + default: 'default' + threshold: + description: 'Pass/fail threshold (0.0 - 1.0)' + required: false + default: '0.8' + evaluator: + description: 'Evaluator name (leave blank for first builtin template)' + required: false + default: '' + +env: + DATASET: ${{ github.event.inputs.dataset || 'default' }} + THRESHOLD: ${{ github.event.inputs.threshold || '0.8' }} + EVALUATOR: ${{ github.event.inputs.evaluator || '' }} + +jobs: + eval-gate: + name: Eval Gate (${{ env.DATASET }} @ ${{ env.THRESHOLD }}) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + + - name: Install Legion + run: gem install legionio --no-document + + - name: Bootstrap config (optional) + if: ${{ secrets.LEGIONIO_BOOTSTRAP_CONFIG != '' }} + env: + LEGIONIO_BOOTSTRAP_CONFIG: ${{ secrets.LEGIONIO_BOOTSTRAP_CONFIG }} + run: echo "Bootstrap config present" + + - name: Run eval gate + id: eval + env: + LEGIONIO_BOOTSTRAP_CONFIG: ${{ secrets.LEGIONIO_BOOTSTRAP_CONFIG }} + run: | + EVAL_ARGS="--dataset $DATASET --threshold $THRESHOLD --exit-code --json" + if [ -n "$EVALUATOR" ]; then + EVAL_ARGS="$EVAL_ARGS --evaluator $EVALUATOR" + fi + legion eval run $EVAL_ARGS | tee eval-report.json + + - name: Upload eval report + if: always() + uses: actions/upload-artifact@v4 + with: + name: eval-report-${{ github.run_number }} + path: eval-report.json + retention-days: 30 + + - name: Annotate PR with eval results + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + let report; + try { + report = JSON.parse(fs.readFileSync('eval-report.json', 'utf8')); + } catch (e) { + console.log('Could not parse eval report:', e.message); + return; + } + const gate = report.passed ? 'PASSED' : 'FAILED'; + const score = (report.avg_score || 0).toFixed(3); + const thresh = report.threshold || 0; + const body = [ + `## Eval Gate: ${gate}`, + '', + `| Metric | Value |`, + `|--------|-------|`, + `| Dataset | \`${report.dataset}\` |`, + `| Evaluator | \`${report.evaluator}\` |`, + `| Avg Score | ${score} |`, + `| Threshold | ${thresh} |`, + `| Total Rows | ${report.summary?.total ?? 'N/A'} |`, + `| Passed | ${report.summary?.passed ?? 'N/A'} |`, + `| Failed | ${report.summary?.failed ?? 'N/A'} |`, + ].join('\n'); + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body, + }); diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 00000000..d5816c5a --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,13 @@ +name: CI/CD +on: + pull_request: + branches: [main] + +jobs: + helm-lint: + name: Helm Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: azure/setup-helm@v3 + - run: helm lint deploy/helm/legion diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..f817ac0a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 9 * * 1' + +jobs: + ci: + uses: LegionIO/.github/.github/workflows/ci.yml@main + with: + needs-rabbitmq: true + + security: + uses: LegionIO/.github/.github/workflows/security-scan.yml@main + with: + brakeman-enabled: false + + version-changelog: + uses: LegionIO/.github/.github/workflows/version-changelog.yml@main + + dependency-review: + uses: LegionIO/.github/.github/workflows/dependency-review.yml@main + + stale: + if: github.event_name == 'schedule' + uses: LegionIO/.github/.github/workflows/stale.yml@main + + release: + needs: [ci] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: LegionIO/.github/.github/workflows/release.yml@main + secrets: + rubygems-api-key: ${{ secrets.RUBYGEMS_API_KEY }} + + trigger-homebrew: + needs: release + if: needs.release.outputs.released == 'true' + runs-on: ubuntu-latest + steps: + - name: Trigger unified Homebrew build + env: + GH_TOKEN: ${{ secrets.HOMEBREW_DISPATCH_TOKEN }} + LEGIONIO_VERSION: ${{ needs.release.outputs.version }} + run: | + gh api repos/LegionIO/homebrew-tap/dispatches \ + -f event_type=build-legion \ + -f "client_payload[legionio_version]=$LEGIONIO_VERSION" \ + -f "client_payload[ruby_version]=3.4.8" \ + -f "client_payload[package_revision]=1" + + docker-build: + name: Build Docker Image + needs: release + if: needs.release.outputs.released == 'true' + runs-on: ubuntu-latest + permissions: + packages: write + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ghcr.io/legionio/legion:${{ needs.release.outputs.version }} + ghcr.io/legionio/legion:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/publish-homebrew.yml b/.github/workflows/publish-homebrew.yml new file mode 100644 index 00000000..1ccf9933 --- /dev/null +++ b/.github/workflows/publish-homebrew.yml @@ -0,0 +1,31 @@ +name: Publish to Homebrew + +on: + release: + types: [published] + +jobs: + trigger-homebrew: + runs-on: ubuntu-latest + if: startsWith(github.event.release.tag_name, 'v') + steps: + - name: Extract version from tag + id: version + run: | + TAG="${RELEASE_TAG}" + VERSION="${TAG#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Extracted version: $VERSION" + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + + - name: Trigger build-legion on homebrew-tap + run: | + gh api repos/LegionIO/homebrew-tap/dispatches \ + -f event_type=build-legion \ + -f "client_payload[legionio_version]=${LEGIONIO_VERSION}" \ + -f "client_payload[ruby_version]=3.4.8" \ + -f "client_payload[package_revision]=1" + env: + GH_TOKEN: ${{ secrets.HOMEBREW_DISPATCH_TOKEN }} + LEGIONIO_VERSION: ${{ steps.version.outputs.version }} diff --git a/.github/workflows/rspec.yml b/.github/workflows/rspec.yml deleted file mode 100644 index 67b7700d..00000000 --- a/.github/workflows/rspec.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: RSpec -on: [push, pull_request] - -jobs: - rspec: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - ruby: [2.7] - runs-on: ${{ matrix.os }} - services: - redis: - image: redis - ports: - - 6379:6379 - - steps: - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - name: RSpec run - run: | - bash -c " - bundle exec rspec - [[ $? -ne 2 ]] - " - rspec-all: - needs: rspec - strategy: - fail-fast: false - matrix: - os: [ ubuntu-latest ] - ruby: [2.5, 2.6, '3.0', head, truffleruby] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - run: bundle exec rspec - diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml deleted file mode 100644 index 0a07e18b..00000000 --- a/.github/workflows/rubocop.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Rubocop -on: [push, pull_request] -jobs: - rubocop: - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest] - ruby: [2.7] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v2 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: ${{ matrix.ruby }} - bundler-cache: true - - name: Install Rubocop - run: gem install rubocop code-scanning-rubocop - - name: Rubocop run --no-doc - run: | - bash -c " - rubocop --require code_scanning --format CodeScanning::SarifFormatter -o rubocop.sarif - [[ $? -ne 2 ]] - " - - name: Upload Sarif output - uses: github/codeql-action/upload-sarif@v1 - with: - sarif_file: rubocop.sarif \ No newline at end of file diff --git a/.github/workflows/sourcehawk-scan.yml b/.github/workflows/sourcehawk-scan.yml deleted file mode 100644 index 72a2af84..00000000 --- a/.github/workflows/sourcehawk-scan.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: Sourcehawk Scan -on: - push: - branches: - - main - - master - pull_request: - branches: - - main - - master -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Sourcehawk Scan - uses: optum/sourcehawk-scan-github-action@main - - - diff --git a/.gitignore b/.gitignore index 3854c935..66ceb2b0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /.bundle/ /.yardoc -/Gemfile.lock +Gemfile.lock +*.gem /_yardoc/ /coverage/ /doc/ @@ -12,4 +13,24 @@ *.key # rspec failure tracking .rspec_status -legionio.key \ No newline at end of file +legionio.key +# runtime artifacts +.DS_Store +*.db +*.json +logs/ +# local settings (may contain secrets) +settings/ +# design reference files +legion_colors*.html +legionio_animated*.html +legionio_wallpaper*.svg +# generated executive briefs +legionio_overview* +# git worktrees +.worktrees/ +# local-only directories +docs/ +config/tls/ +# generated integration specs +spec/integration/self_generate_spec.rb diff --git a/.rspec b/.rspec new file mode 100644 index 00000000..e758c905 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--color +--exclude-pattern spec/live/**/*_spec.rb diff --git a/.rubocop.yml b/.rubocop.yml index 2257e0c4..013ea648 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,90 +1,104 @@ +AllCops: + TargetRubyVersion: 3.4 + NewCops: enable + SuggestExtensions: false + +Style/RedundantConstantBase: + Enabled: false + Layout/LineLength: Max: 160 - IgnoredPatterns: - - .unknown - - .fatal - - .error - - .warn - - .info - - .debug - - .trace -Metrics: - IgnorePatterns: - - .unknown - - .fatal - - .error - - .warn - - .info - - .debug - - .trace + Exclude: + - 'Gemfile' + +Layout/SpaceAroundEqualsInParameterDefault: + EnforcedStyle: space + +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table + Metrics/MethodLength: - Max: 50 - IgnoredMethods: - - .unknown - - .fatal - - .error - - .warn - - .info - - .debug - - .trace + Max: 80 + Exclude: + - 'lib/legion/cli/chat_command.rb' + - 'lib/legion/api/openapi.rb' + - 'lib/legion/api/llm.rb' + - 'lib/legion/digital_worker/lifecycle.rb' + Metrics/ClassLength: Max: 1500 + Metrics/ModuleLength: Max: 1500 + Exclude: + - 'lib/legion/api/openapi.rb' + Metrics/BlockLength: - Max: 40 - IgnoredMethods: - - .unknown - - .fatal - - .error - - .warn - - .info - - .debug - - .trace + Max: 80 + Exclude: + - 'spec/**/*' + - 'integration/**/*' + - 'extensions-agentic/**/spec/**/*' + - 'extensions-core/**/spec/**/*' + - 'extensions/**/spec/**/*' + - 'extensions-ai/**/spec/**/*' + - 'extensions-other/**/spec/**/*' + - 'legionio.gemspec' + - 'lib/legion/cli/chat_command.rb' + - 'lib/legion/cli/plan_command.rb' + - 'lib/legion/cli/swarm_command.rb' + - 'lib/legion/cli/gaia_command.rb' + - 'lib/legion/cli/schedule_command.rb' + - 'lib/legion/cli/update_command.rb' + - 'lib/legion/api/auth.rb' + - 'lib/legion/api/auth_worker.rb' + - 'lib/legion/api/auth_human.rb' + - 'lib/legion/cli/auth_command.rb' + - 'lib/legion/cli/detect_command.rb' + - 'lib/legion/cli/prompt_command.rb' + - 'lib/legion/cli/image_command.rb' + - 'lib/legion/cli/notebook_command.rb' + - 'lib/legion/api/llm.rb' + - 'lib/legion/api/acp.rb' + - 'lib/legion/api/auth_saml.rb' + - 'lib/legion/cli/failover_command.rb' + - 'lib/legion/cli/setup_command.rb' + - 'lib/legion/cli/trace_command.rb' + - 'lib/legion/cli/features_command.rb' + - 'lib/legion/cli/absorb_command.rb' + - 'lib/legion/cli/mode_command.rb' + Metrics/AbcSize: - Max: 60 - IgnoredMethods: - - .unknown - - .fatal - - .error - - .warn - - .info - - .debug - - .trace + Max: 80 + Exclude: + - 'lib/legion/cli/chat_command.rb' + - 'lib/legion/api/llm.rb' + - 'lib/legion/digital_worker/lifecycle.rb' + Metrics/CyclomaticComplexity: - Max: 15 - IgnoredMethods: - - .unknown - - .fatal - - .error - - .warn - - .info - - .debug - - .trace + Max: 25 + Exclude: + - 'lib/legion/cli/chat_command.rb' + - 'lib/legion/api/auth_human.rb' + - 'lib/legion/api/llm.rb' + - 'lib/legion/digital_worker/lifecycle.rb' + Metrics/PerceivedComplexity: - Max: 17 - IgnoredMethods: - - .unknown - - .fatal - - .error - - .warn - - .info - - .debug - - .trace -Layout/SpaceAroundEqualsInParameterDefault: - EnforcedStyle: space -Style/SymbolArray: - Enabled: true -Layout/HashAlignment: - EnforcedHashRocketStyle: table - EnforcedColonStyle: table + Max: 25 + Exclude: + - 'lib/legion/api/auth_human.rb' + - 'lib/legion/api/llm.rb' + - 'lib/legion/digital_worker/lifecycle.rb' + Style/Documentation: Enabled: false -AllCops: - TargetRubyVersion: 2.5 - NewCops: enable - SuggestExtensions: false +Style/SymbolArray: + Enabled: true Style/FrozenStringLiteralComment: - Enabled: false + Enabled: true + EnforcedStyle: always Naming/FileName: Enabled: false +Naming/PredicateMethod: + Enabled: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..fe50028e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,53 @@ +# LegionIO — Agent Notes + +`legionio` is the **primary gem** of the LegionIO framework: it orchestrates all `legion-*` gems and +loads `lex-*` extensions. It's an async job engine, an AI coding assistant, an MCP server, and a +cognitive platform in one. See `CLAUDE.md` for the full boot sequence, module map, and conventions; +`README.md` for the user-facing tour. + +## Fast Start + +```bash +bundle install +bundle exec rspec # ~3500+ examples — 0 failures required before commit +bundle exec rubocop # 0 offenses required +``` + +Run **both** in full and fix everything before committing. No exceptions — the PR CI gate is green +and must stay green. + +## Primary Entry Points + +- `lib/legion.rb` — `Legion.start`, `.shutdown`, `.reload` +- `lib/legion/service.rb` — the 15-phase startup orchestrator (logging → settings → crypt → + transport → cache → data → rbac → llm → apollo → gaia → telemetry → supervision → extensions → + cluster secret → api) +- `lib/legion/cli.rb` + `lib/legion/cli/` — Thor CLI across the two binaries (`legion`, `legionio`) +- `lib/legion/cli/chat/` — the interactive AI REPL +- `lib/legion/api.rb` + `lib/legion/api/` — Sinatra REST API (port 4567) + middleware +- `lib/legion/extensions/` — LEX discovery/loading/actors/builders +- `lib/legion/tools/` — canonical tool layer (Registry, Discovery, EmbeddingCache) +- `exe/legion`, `exe/legionio` — the binaries; perf opts applied before any code loads + +## Guardrails / Gotchas (these prevent real bugs) + +- **`Legion::JSON` only** — `Legion::JSON.load` returns **symbol keys**; `.dump` takes exactly one + positional arg (wrap kwargs in `{}`). Inside the `Legion::` namespace, **`::JSON` and `::Process` + must be explicit** (they resolve to `Legion::JSON` / `Legion::Process` otherwise). +- **Thor 1.5+ reserves `run`** — use `map 'run' => :trigger` in the Task subcommand. +- **Sinatra 4** — `set :host_authorization, permitted: :any`. API response shape is + `{ data:, meta: { timestamp:, node: } }`; errors `{ error: { code:, message: }, meta: }`. +- **LLM routes are owned by `legion-llm`** and mounted from it — do not re-add in-app LLM routes or a + provider gateway fallback (that migration is intentional). +- **Bootsnap is opt-in** (`LEGION_BOOTSNAP=true`), not always-on. +- **Never swallow exceptions** — every `rescue` re-raises or `handle_exception`s; use `log.*`, never + `puts`. **No personal/company identifiers in VCS**; never force-push. +- Extensions declare `data_required?` / `cache_required?` / `crypt_required?` / `vault_required?` / + `llm_required?` and are skipped when the dependency is absent — keep that contract intact. +- `LEGION_MODE=lite` must keep working end-to-end (in-process transport + in-memory cache, no + RabbitMQ/Redis). + +## Validation + +Run targeted specs for the area you touched, then full `rspec` + `rubocop` before handoff. Specs use +`rack-test`; the suite runs without external infrastructure. diff --git a/CHANGELOG.md b/CHANGELOG.md index bd1608ba..1eb3a0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,3089 @@ # Legion Changelog +## [1.9.42] - 2026-06-07 + +### Performance +- Extensions: batched extension registration into a single `LexRegister` publish after all extensions load, eliminating N individual queue messages and DB transactions during boot +- Removed redundant `flush_pending_registrations!` call from `setup_identity` ensure block, consolidating to a single flush point in `reload!` + +## [1.9.41] - 2026-06-02 + +### Fixed +- CLI: `setup proxy-mode` now upserts `[model_providers.legionio]` with `api_key = "legion"` into `~/.codex/config.toml` instead of writing the deprecated `profile = "legionio"` key (removed by Codex) +- CLI: model catalog format corrected to use `slug`/`display_name`/`supported_reasoning_levels` fields +- CLI: `model_catalog_json` removed from top-level `config.toml` (breaks Mac app strict schema parsing); kept only in `legionio.config.toml` for `--profile legionio` CLI use + +## [1.9.40] - 2026-06-01 + +### Added +- + +## [1.9.39] - 2026-05-30 + +### Fixed +- CLI: remove `--clear-sources` from `gem install` in bootstrap and setup commands (breaks pack reinstall when custom sources are configured) + +## [1.9.38] - 2026-05-30 + +### Fixed +- Gemspec: require legion-llm >= 0.10.1 (message translation, streaming, curator fixes required for Claude Code and Codex CLI agentic tool loops via vLLM) + +## [1.9.37] - 2026-05-29 + +### Added +- LLM: namespace API enabled by default — LegionIO now routes all `/v1/` and `/api/llm/` traffic + through `Namespaces::Registration` (Sinatra::Namespace, Phases 0-4 complete in legion-llm ≥ 0.8.50) +- CLI: `legion setup proxy-mode` (alias: `proxy`) writes `~/.codex/config.toml` and + `~/.claude/settings.json` env block so Codex CLI and Claude Code connect to LegionIO at + `http://localhost:4567` out of the box. Supports `--port`, `--host`, `--force`, `--json`. + +### Fixed +- LLM: Anthropic namespace message translation now properly converts `tool_use`/`tool_result` content blocks to OpenAI format for vLLM dispatch (requires legion-llm ≥ 0.10.1) +- LLM: streaming tool_use blocks emitted inline with guaranteed ordering before `message_stop` +- LLM: curator preserves recent turns — no longer curates tool results from the current/previous turn + +## [1.9.36] - 2026-05-22 + +### Fixed +- Identity: preload identity provider gems and resolve process identity before LLM setup so `llm.registry` availability events include Legion identity headers. +- Identity: use the persisted `identity.json` value as a cached resolver fallback ahead of unverified system identity when fresh auth providers are unavailable. +- Bundler: load sibling Legion and LLM provider path dependencies outside the test group when those local checkouts exist, so local service boots can use the active workspace gems. + +## [1.9.35] - 2026-05-22 + +### Added +- CLI: `legionio service start|stop|restart|status` subcommand for direct launchd control +- Logging transport forwarding now publishes structured log headers/properties, including identity and Legion version headers supplied by `legion-logging`. + +### Fixed +- CLI: `legionio bootstrap --start` now calls `launchctl kickstart` after brew services start to force immediate spawn on macOS 26+ (Tahoe defers `RunAtLoad` for mid-session bootstraps) + +## [1.9.34] - 2026-05-18 + +### Added +- API: `GET /api/extensions/tools` endpoint with extension, runner, deferred, and triggered filters +- Tools::Discovery: writes to `Legion::Settings::Extensions.register_tool` (bridges discovery to LLM pipeline) + +### Fixed +- Extensions: `extension_parts_from_const` no longer converts underscores to dashes (fixes lex-microsoft_teams filtering) +- Core: `generate_runner_messages` strips `?` and `!` from method names before creating constants + +## [1.9.33] - 2026-05-15 + +### Added +- `Legion::Identity::Process` stores and exposes `db_principal_id` and `db_identity_id` integer PKs — present in `EMPTY_STATE`, persisted through `bind!`, and included in `identity_hash`. Both default to nil until an identity provider populates them. + +## [1.9.32] - 2026-05-14 + +### Removed +- Removed `gem 'ruby_llm'` dependency from Gemfile; all 40 CLI chat tools now use `Legion::Tools::Base` natively. + +### Changed +- Migrated all 40 CLI chat tool classes from `RubyLLM::Tool` to `Legion::Tools::Base`: + - `param` DSL replaced with `input_schema` (JSON Schema hash) + - `def execute` instance method replaced with `def self.call` class method + - Explicit `tool_name 'legion.'` added to each tool + - Private instance helpers converted to class methods +- Updated `tool_registry.rb`: removed `require 'ruby_llm'` and `begin/rescue LoadError` guard. +- Updated `extension_tool_loader.rb`: `klass < RubyLLM::Tool` changed to `klass < Legion::Tools::Base`. +- Updated `generate_command.rb` tool template to emit `Legion::Tools::Base` with `input_schema` and `def self.call`. +- `Permissions::Gate` now prepends on the singleton class to intercept `self.call` correctly. + +## [1.9.31] - 2026-05-14 + +### Added +- `GET /api/identity` endpoint returning live process identity, provider resolution status, and registered provider metadata. +- `autobuild_submodules` recursive walk in `Legion::Extensions` — nested sub-modules (e.g. `Delegated`, `Application`, `ManagedIdentity`, `WorkloadIdentity` inside `lex-identity-entra`) now have their actors autobuilt and started. + +### Fixed +- `Extensions::Helpers::Base#full_path` now walks up gem name segments to find the parent gem when a sub-module gem doesn't exist as a standalone gem (e.g. `lex-identity-entra-delegated`). + +## [1.9.30] - 2026-05-12 + +### Changed +- Slimmed agentic pack to only cognitive/coordination extensions; removed non-agentic gems (`lex-audit`, `lex-autofix`, `lex-codegen`, `lex-cost-scanner`, `lex-dataset`, `lex-factory`, `lex-finops`, `lex-governance`, `lex-llm-ledger`, `lex-onboard`, `lex-pilot-infra-monitor`, `lex-pilot-knowledge-assist`, `lex-prompt`, `lex-react`, `lex-swarm`, `lex-swarm-github`, `lex-transformer`). +- Added `legion-mcp` to the LLM pack. +- Added `lex-kerberos` to the identity pack. +- Added new `developer` pack: `lex-developer`, `lex-dynatrace`, `lex-eval`, `lex-exec`, `lex-github`, `lex-http`, `lex-jfrog`, `lex-skill-superpowers`, `lex-ssh`. + +## [1.9.29] - 2026-05-11 + +### Fixed +- `Subscription#activate` now checks `channel.open?` before calling `subscribe_with`; if closed, it re-prepares once and retries, preventing silent activation failures when a channel is closed between prepare and activate. +- `Transport` mixin now guards `auto_create_dlx_exchange` and `auto_create_dlx_queue` with a `remote_invocable?` check — non-remote extensions no longer attempt to create dead-letter exchanges and queues they never use. +- DLX exchange type corrected from `fanout` to `topic` for consistency with the rest of the exchange topology. +- Identity resolver DB persistence now uses Sequel models (`Identity::Provider`, `Identity::Principal`, `Identity::Identity`, `Identity::AuditLog`) instead of raw `Legion::Data.db` dataset calls that didn't exist on the module. +- Identity audit API endpoint now references correct column names (`detail_payload`, `node_ref`, `session_ref`) matching the schema. +- Fixed `LeaseRenewer#log_renewal_failure` to fall back to `$stderr` when `Legion::Logging` is not yet loaded, matching the original contract. +- Fixed `Legion::Service#log_privacy_mode_status`, `#shutdown_component`, and TLS-fallback logging specs to assert against `emit_tagged` (the actual dispatch path used by `Legion::Logging::Helper`) rather than `Legion::Logging.warn/info` directly. +- Fixed `Cluster::Leader` boot integration spec to stub `Legion::Settings[:logging]` so `log` helper initialization does not raise on unexpectedly-received arguments. + +### Changed +- Added `remote_invocable_extension?` helper to the `Transport` module; returns `lex_class.remote_invocable?` when available, `true` otherwise. +- Refactored `Identity::Resolver#persist_to_db` into extracted helpers (`upsert_providers`, `upsert_principal`, `upsert_identities`, `upsert_single_identity`) to reduce method complexity and improve readability. +- Replaced hand-rolled `log_warn`/`log_debug` methods in `Identity::Resolver`, `Identity::Broker`, and `Identity::LeaseRenewer` with `include Legion::Logging::Helper` and standard `log.debug`/`log.warn` calls. +- Added debug logging throughout `Identity::Resolver` for registration, resolution, auth racing, binding, and DB persistence. +- LLM inference API now passes `instance` and `tier` routing hints from request body through to `Legion::LLM::Inference::Request`. + +## [1.9.28] - 2026-05-08 + +### Fixed +- Task outcome observation now ignores internal runner completions without task ids, preventing periodic mesh gossip ticks from feeding meta-learning and Apollo ingestion. +- Identity resolver database persistence now targets the current identity provider, principal, identity, and audit log schema. + +## [1.9.27] - 2026-05-08 + +### Fixed +- Preserve omitted `/api/llm/inference` client tool definitions as absent instead of `tools: []`, allowing legion-llm registry and trigger-based tool injection to run for normal API requests. +- Added an opt-in live daemon integration spec suite that uses explicit Faraday test dependencies and its own isolated RSpec helper. + +## [1.9.26] - 2026-05-07 + +### Fixed +- Use the local `Legion::Identity::Process` identity for unauthenticated loopback API principals even when the process is only fallback-bound, avoiding generic `system:system` attribution in downstream LLM audit flows. + +## [1.9.25] - 2026-05-07 + +### Fixed +- Updated identity model references in `identity_audit.rb` and `identity/broker.rb` to use the portable namespace (`Identity::AuditLog`, `Identity::Principal`, `Identity::GroupMembership`) after legacy top-level identity models were removed from legion-data. + +## [1.9.24] - 2026-05-07 + +### Changed +- Removed deprecated direct AI provider extensions (`lex-azure-ai`, `lex-bedrock`, `lex-claude`, `lex-foundry`, `lex-gemini`, `lex-ollama`, `lex-openai`) from the extension catalog; use their `lex-llm-*` counterparts instead. + +## [1.9.23] - 2026-05-07 + +### Fixed +- Fixed encrypted subscription handling to accept both string-keyed and symbol-keyed IV headers before decrypting `encrypted/cs` AMQP payloads. + +## [1.9.22] - 2026-05-06 + +### Changed +- Hot-reloading a `lex-llm-*` provider extension now asks `Legion::LLM::Call::Providers` to rediscover loaded provider modules, keeping LLM provider instances aligned after extension updates. +- Bumped the packaged `legion-llm` dependency floor to `>= 0.9.1` for LLM-owned provider registration and reload-safe registry rebuilds. + +## [1.9.21] - 2026-05-06 + +### Changed +- LegionIO now mounts `Legion::LLM::Routes` through the library route selector when `legion-llm` is available, leaving LLM API ownership with `legion-llm` instead of registering partial fallback routes first. +- LLM provider health API and CLI output now require native `Legion::LLM::Inventory` data and return a clear unavailable response when inventory is not loaded. +- Bumped packaged dependency floors to `legion-llm >= 0.9.0` and `legion-data >= 1.8.0` for the coordinated LLM route/schema sweep. + +### Fixed +- Lite and local mode startup now write development mode through the public `Legion::Settings.set_prop` API. + +### Removed +- Removed active `lex-llm-gateway` fallback paths from LLM chat, provider health, extension catalog, role filtering, and README documentation. + +## [1.9.20] - 2026-05-06 + +### Fixed +- Nested LEX extensions now merge default settings into their nested `extensions` path (for example `lex-foo-bar` -> `extensions.foo.bar`) while underscored flat extensions continue to use the flat key (for example `lex-foo_bar` -> `extensions.foo_bar`). +- Extension load-time settings checks now use the discovered settings path for nested extensions, keeping `enabled`, `min_version`, `workers`, and `remote_invocable` overrides aligned with where defaults are merged. + +## [1.9.19] - 2026-05-05 + +### Added +- `UnrecoverableMessageError` for messages that should be dead-lettered immediately (e.g., missing IV header on encrypted messages) instead of retried. +- Subscription actors now extract `message_id` and `correlation_id` from AMQP metadata into the message hash for downstream tracing. +- Runner builder auto-includes `Helpers::Lex` into runner modules when available, ensuring all runners have LEX metadata helpers. + +### Fixed +- Encrypted messages (`encrypted/cs`) with a missing `iv` header now raise `UnrecoverableMessageError` and are dead-lettered rather than crashing with a nil argument to `Crypt.decrypt`. + +## [1.9.18] - 2026-04-29 + +### Fixed +- API-submitted LLM tools now build native `Legion::LLM::Types::ToolDefinition` objects instead of attempting to require RubyLLM at runtime. +- Provider route coverage now locks LegionIO's `/api/llm/providers` compatibility response ahead of later colliding LLM library route registrations. + +## [1.9.17] - 2026-04-29 + +### Fixed +- LegionIO now requires `legion-llm >= 0.8.47` and only uses a local sibling `legion-llm` checkout when it satisfies that release floor, preventing stale local worktrees from breaking Bundler resolution. +- Native LLM provider health API responses now preserve model, type, health, and instance fields when inventory offerings are loaded from string-keyed data. + +## [1.9.16] - 2026-04-28 + +### Fixed +- LegionIO now requires `legion-llm >= 0.8.44` so packaged installs include the unified identity migration for LLM caller metadata and Broker token audit context. + +## [1.9.15] - 2026-04-28 + +### Fixed +- The static extension catalog now classifies `lex-llm-gateway` as legacy compatibility, and setup pack tests explicitly prevent it from returning to default LLM or agentic installs. +- README LLM documentation now calls out `lex-llm-gateway` as legacy-only compatibility glue that is not installed by default. + +## [1.9.14] - 2026-04-28 + +### Fixed +- LegionIO now requires `legion-tty >= 0.5.4` so packaged installs include the Legion-native LLM probe instead of the legacy direct RubyLLM probe. + +## [1.9.13] - 2026-04-28 + +### Fixed +- LegionIO now requires `legion-llm >= 0.8.43` so packaged installs get the optional RubyLLM compatibility layer and native dispatch fallback defaults. + +## [1.9.12] - 2026-04-28 + +### Fixed +- LegionIO now requires `legion-llm >= 0.8.42` so packaged installs resolve the validated LLM routing uplift release. + +## [1.9.11] - 2026-04-28 + +### Fixed +- LLM chat API routing now prefers native `Legion::LLM.chat` even when legacy `lex-llm-gateway` compatibility code is loaded. + +## [1.9.10] - 2026-04-28 + +### Fixed +- LLM provider health endpoints and CLI health checks now use the native `legion-llm` provider inventory before falling back to legacy `lex-llm-gateway` provider stats. + +## [1.9.9] - 2026-04-28 + +### Fixed +- Registry governance and security scanning now accept nested `lex-*` extension gem names such as `lex-llm-openai` and `lex-llm-azure-foundry`. + +## [1.9.8] - 2026-04-28 + +### Fixed +- The `agentic` setup pack now installs the Legion-native `lex-llm-*` provider stack without also installing retired legacy LLM provider gems. +- Role profiles now treat `lex-llm-*` gems as the active AI extension set and exclude legacy LLM providers from default `core`, `dev`, and `cognitive` profile loading. +- LegionIO now requires `legion-llm >= 0.8.41` so packaged installs get the router dependency cleanup that removes retired legacy provider runtime dependencies. + +## [1.9.7] - 2026-04-28 + +### Fixed +- Extension discovery now maps `lex-llm-azure-foundry` to `Legion::Extensions::Llm::AzureFoundry` and `legion/extensions/llm/azure_foundry`. +- LegionIO now requires `legion-llm >= 0.8.40` so packaged installs include the native provider bridge needed by the Legion-native LLM stack. +- README LLM provider documentation now describes the `lex-llm-*` provider stack instead of the retired legacy provider list. + +## [1.9.6] - 2026-04-28 + +### Fixed +- LLM API gateway checks now use the `Legion::Extensions::Llm::Gateway` namespace loaded by Legion extension autoloading. +- LLM inference and skill invocation routes now call the current `Legion::LLM::Inference` request/executor API instead of the retired pipeline constants. +- `legionio llm ping` now routes through `Legion::LLM.ask_direct` instead of bypassing Legion routing with a raw RubyLLM call. +- API client tool construction now degrades cleanly when the RubyLLM tool base is unavailable. + +## [1.9.5] - 2026-04-28 + +### Added +- Extension catalog, setup packs, and local development wiring now include the Legion-native `lex-llm` provider stack, including Bedrock, Azure Foundry, and Vertex hosted provider extensions. + +### Fixed +- Local development Gemfile wiring now includes guarded `lex-llm-ledger` resolution so the local bundle matches the LLM setup pack. +- Local development Gemfile wiring now points `lex-llm-gateway` at the workspace extension path actually used by LegionIO checkouts. +- Default setup packs no longer install legacy `lex-llm-gateway`; the extension catalog now labels it as compatibility glue rather than active LLM routing. +- `require 'legion/extensions'` now loads its logging dependency directly instead of relying on `require 'legion'` order. + +## [1.9.4] - 2026-04-27 + +### Added +- Extension boot now runs a dedicated LLM load phase so `lex-llm` loads before any `lex-llm-*` extension gems. +- `/api/health` now reports `uptime_seconds` and `uptime` for dashboard and monitor consumers. Fixes #168 +- `/api/extensions` now returns a flat loaded-extension summary for dashboard consumers. Fixes #169 + +### Fixed +- `legionio doctor` no longer reports extension-loader config keys as missing `lex-*` gems. Fixes #157 +- `/api/metering` now returns dashboard headline totals instead of the routing breakdown shape. Fixes #170 +- Extension autobuild now runs per-extension data migrations when migration files are present, even when an extension does not opt into general data models. Fixes #171 +- `/api/webhooks` now loads its `Legion::Webhooks` runtime dependency before route handlers execute. Fixes #172 +- `/api/tenants` now passes positional response data and uses `json_error` for missing tenants. Fixes #173 + +## [1.9.3] - 2026-04-27 + +### Fixed +- Extension catalog persistence now skips no-op startup updates when the stored state already matches, reducing local SQLite write churn. Fixes #176 + +## [1.9.2] - 2026-04-27 + +### Fixed +- `POST /api/knowledge/status` no longer silently defaults to the daemon's cwd. Uses `knowledge.default_corpus_path` setting or `LEGION_CORPUS_PATH` env var; returns 400 when unresolvable. Prevents `Errno::EPERM` crashes on macOS when the daemon is launched from `~` and `Find.find` walks into TCC-protected subdirs like `~/Library/Accounts`. + +## [1.9.1] - 2026-04-25 + +### Added +- Extension runtime handles now expose authoritative active/latest versions, reload state, pending-reload status, hot-reload eligibility, and owned runtime resources through `/api/extension_catalog`. +- Extension dispatch quiescing now blocks API, ingress, and subscription runner dispatch while an extension is stopping or actively reloading. +- `Legion::Tools::Registry.unregister_extension` removes callable tools owned by an extension during unload/reload cleanup. + +### Fixed +- Runtime handle `loaded?` no longer reports `stopped` or `failed` extensions as loaded. +- Extension registration publication now happens after extension autobuild and runtime side effects complete, avoiding durable registration of failed loads. +- Extension runtime handles now transition to loaded only after `require` and extension side effects succeed, and multi-segment extension modules keep their hyphenated lex identity. + +## [1.9.0] - 2026-04-24 + +### Added +- `Legion::Identity::Resolver` — composite identity resolution chain with parallel provider execution, DB persistence, and transport event publishing +- `Legion::Identity::Trust` — trust level enum (verified, authenticated, configured, cached, unverified) +- `Legion::Identity::Grant` — frozen value object for credential access auditing +- `Identity::Process` extended with trust, aliases, providers, profile composite state +- `Identity::Broker` upgraded to `[provider, qualifier]` tuple-keyed multi-instance storage with `for_context` routing and bounded async audit queue +- `Resolver.upgrade!` for post-boot identity trust escalation with canonical_name change support +- Settings client name updated from resolved identity for correct queue naming + +### Changed +- `setup_identity` gate relaxed to run with DB-only nodes (not just transport) +- `register_credential_providers` gate relaxed for DB-only nodes +- Reload lifecycle: `Resolver.reset!` preserves providers, re-resolves with existing registrations +- Middleware `system_principal` uses Resolver identity when available + +### Fixed +- `Request.from_auth_context` canonical normalization now matches DB constraint `^[a-z0-9][a-z0-9_-]*$` +- `/api/identity/audit` reads from `identity_audit_log` table instead of `AuditRecord` + +### Removed +- Legacy tree-walk identity discovery (`resolve_identity_providers`, `find_identity_providers`, `collect_identity_providers`) +- `identity_provider?` and `register_identity_provider` from extensions.rb + +## [1.8.16] - 2026-04-22 + +### Added +- `legion mind-growth wire ID` CLI command — wires a built extension into the cognitive tick cycle via `Orchestrator.post_build_pipeline`; accepts `--phase` override option + +### Fixed +- `MindGrowth#wire` rescue block now logs errors via `Legion::Logging.error` before displaying them, ensuring errors are captured in the daemon log and not only printed to the terminal + +## [1.8.15] - 2026-04-22 + +### Fixed +- `legionio knowledge ingest ` no longer returns `API 500 for /api/knowledge/ingest`. Two halves of the same contract mismatch: (a) the CLI previously forwarded `dry_run:` on every call (now only when `--dry-run` is passed), and (b) the `/api/knowledge/ingest` route forwarded `dry_run:` to `Legion::Extensions::Knowledge::Runners::Ingest.ingest_file`, whose signature in `lex-knowledge` 0.6.7 is `ingest_file(file_path:, force:)` — causing `ArgumentError: unknown keyword: :dry_run`. The kwarg remains honored for directory (corpus) ingests, which support preview scans. Adds regression coverage in `spec/legion/cli/knowledge_command_spec.rb` (negative-case for file ingest) and a new `spec/api/knowledge_spec.rb` covering the file/directory/dry_run branches of the route. + +## [1.8.14] - 2026-04-18 + +### Fixed +- Optional subsystem `LoadError`s (RBAC, Data, LLM, Apollo, Gaia, Telemetry) now log at the caller-specified level instead of always ERROR with a full stack trace — `handle_exception` respects the `level:` kwarg. Fixes #155 +- `web_fetch` tool in `/api/llm/*` endpoints now delegates to `Legion::CLI::Chat::WebFetch.fetch` instead of bare `Net::HTTP.get`, gaining SSL, redirect-following, HTML-to-markdown conversion, and `maxLength` support. Fixes #153 +- `web_search` tool in `/api/llm/*` endpoints no longer falls through to the generic "not executable server-side" error — added dispatch branch delegating to `Legion::CLI::Chat::WebSearch.search`. Fixes #154 + +## [1.8.13] - 2026-04-17 + +### Added +- `Absorbers::Base#query_knowledge` — scope-aware knowledge retrieval (`:local`, `:global`, `:all`) with deduplication, matching the pattern established by `Helpers::Knowledge` + +### Fixed +- `Absorbers::Base` now routes ingestion by scope: `absorb_to_knowledge`, `absorb_raw`, and `ingest_chunks` resolve `Legion::Apollo::Local` for `:local` scope and `Legion::Apollo` for `:global`, instead of always hitting the global store +- Added `apollo_local_available?` and `resolve_apollo_target` private helpers for scope-driven Apollo target selection + +## [1.8.12] - 2026-04-17 + +### Fixed +- `Actors::Subscription` now supports `pattern` class method as a DSL accessor for routing key hints, delegating to `routing_key_hint` — extensions calling `pattern 'some.routing.key'` no longer raise `NoMethodError`. Fixes #143 +- `Absorbers::Base` removed deprecated `alias handle absorb` — use `#absorb` directly +- Generator template (`legion generate absorber`) now emits `def absorb(...)` instead of `def handle(...)` +- `Matchers::File` is now required and registered alongside `Matchers::Url` in the absorber loader +- Absorber base spec updated to use `#absorb` instead of removed `#handle` alias + +## [1.8.11] - 2026-04-17 + +### Fixed +- `Legion::CLI::Chat::WebFetch` — eliminated all remaining polynomial regex patterns (CodeQL `rb/polynomial-redos`): replaced `convert_blocks!`, `convert_headings!`, `convert_lists!`, `convert_formatting!`, and `strip_remaining_tags!` with index-based tag scanning helpers (`replace_tag_blocks!`, `replace_open_tags!`, `replace_close_tags!`, `replace_self_closing!`). No regex with `[^>]*` or `[^>]+` remains in the HTML-to-markdown pipeline. + +## [1.8.10] - 2026-04-17 + +### Fixed +- `Legion::CLI::Chat::WebFetch#convert_links!` polynomial regex on uncontrolled data (CodeQL `rb/polynomial-redos`) — replaced backtracking `]*href=...>` regex with index-based scanner that walks tag boundaries without backtracking +- Thor `[WARNING] Attempted to create command` noise during rspec — prepend `RSpec::Mocks::AnyInstance::Recorder` to wrap `observe!`, `mark_invoked!`, `restore_original_method!`, and `remove_dummy_method!` inside `Thor.no_commands_context` when the target class is a Thor subclass + +## [1.8.9] - 2026-04-17 + +### Fixed +- `Legion::DigitalWorker::Registry#emit_blocked` passed positional hash to `Legion::Events.emit` which expects kwargs — caused `ArgumentError` masking intended domain exceptions (`WorkerNotFound`, `WorkerNotActive`, `InsufficientConsent`). Fixes #114 + +### Added +- `Legion::Audit::HashChain` now includes `seq` in `CANONICAL_FIELDS` and `verify_chain` detects gaps in sequence numbers, preventing undetected record deletion from the tamper-evident audit chain. Backwards-compatible: gap check is skipped when `seq` is absent. Fixes #149 + +## [1.8.8] - 2026-04-17 + +### Fixed +- `Legion::Ingress` code injection (CodeQL `rb/code-injection`) — replaced `Kernel.const_get` with allowlist lookup against registered extension modules; `resolve_runner_class` now only resolves classes present in `loaded_extension_modules` or `local_tasks` +- `Legion::Graph::Exporter#to_dot` incomplete string escaping (CodeQL `rb/incomplete-sanitization`) — extracted `dot_escape` helper using char-by-char escaping of backslashes and quotes for DOT labels +- `Legion::CLI::Chat::WebFetch#strip_invisible!` polynomial regex / incomplete sanitization / bad tag filter (CodeQL `rb/polynomial-redos`, `rb/incomplete-multi-character-sanitization`, `rb/bad-tag-filter`) — replaced regex `gsub!` with iterative `strip_tag_blocks!` that finds open/close tags by index, eliminating backtracking and handling malformed closing tags + +## [1.8.7] - 2026-04-17 + +### Fixed +- `Legion::CLI::Chat::WebSearch#extract_real_url` incomplete URL substring sanitization (CodeQL `rb/incomplete-url-substring-sanitization`) — replaced `include?('duckduckgo.com')` with `URI.parse` host check using `end_with?` +- `Legion::Tools::EmbeddingCache.clear` now flushes L1/L2 cache tiers in addition to L0 memory, preventing stale lookups after clear + +## [1.8.6] - 2026-04-15 + +### Added +- `Tools::Base#sticky` accessor — tool classes can opt out of sticky runner injection (defaults `true`) +- `Tools::Discovery` propagates `sticky_tools?` from extension to tool class `sticky` attribute; nil treated as opt-out (conservative) +- `Extensions::Core#sticky_tools?` — defaults `true`, extensions may override with `def self.sticky_tools? false end` + +## [1.8.5] - 2026-04-15 + +### Fixed +- `Legion::Extensions::Core#trigger_words` now defaults to `lex_name.split('_')` (e.g. `['github']` for lex-github) instead of `[]`, ensuring extensions auto-surface in TriggerIndex without requiring explicit declaration. Closes #139 +- `Legion::Extensions::Builder::Runners#build_runner_entry` now always populates `trigger_words`, defaulting to `[runner_name]` when the runner module does not define them explicitly. Closes #139 +- `Legion::Tools::Discovery#synthesize_functions` now builds a real JSON Schema from Ruby method reflection data (`Method#parameters`) — required kwargs become required schema properties, optional kwargs become optional properties — so the LLM receives accurate parameter information instead of an empty schema. Closes #140 +- `Legion::Tools::Discovery#synthesize_functions` now uses `definition[:desc]` for tool description when a `definition` DSL entry exists, falling back to the method name rather than `"method_name function"`. Closes #140 +- `Legion::Tools::Discovery#tool_attributes` now reads `definition[:inputs]` when present and non-empty, using it as the input schema in preference to `meta[:options]`. Closes #140 +- `Legion::Tools::Discovery#register_function` fixed asymmetric default: `resolve_exposed` now defaults to `true` when the extension does not respond to `mcp_tools?`, matching the behaviour of `resolve_mcp_tools_enabled`. Closes #140 + +## [1.8.4] - 2026-04-14 + +### Added +- `legionio fleet` CLI subcommand tree: `status`, `pending`, `approve`, `add`, `config` +- `legionio setup fleet` two-phase command: phase 1 installs fleet gems, phase 2 wires relationships via `Workflow::Loader`, seeds conditioner rules, registers settings via `load_module_settings`, merges LLM routing overrides, applies RabbitMQ planner consumer timeout policy +- Fleet pipeline YAML manifest with 10 relationships (1-8 plus 4b, 4c) connecting assessor, planner, developer, and validator +- `Legion::Fleet::SettingsDefaults` — file-based fleet settings persistence +- `Legion::Fleet::ConditionerRules` — supplementary conditioner rule seeds (skip-planning-trivial, skip-validation-trivial, escalate-max-iterations, critical-production-max-capability, governance-mind-growth) +- Fleet API routes: `POST /api/fleet/sources`, `GET /api/fleet/pending` (filters both `fleet.shipping` and `fleet.escalation`), `POST /api/fleet/approve`, `GET /api/fleet/sources`, `GET /api/fleet/status` + +## [1.8.3] - 2026-04-14 + +### Fixed +- `runner_class` resolution for actors nested under `Actor::` namespace — `sub(/Actor$/, 'Runners')` only matched `Actor` at end-of-string, failing for `Extension::Actor::ClassName` patterns (e.g., `Health::Actor::Watchdog`, `Node::Actor::Beat`). Changed to `sub(/::Actor::/, '::Runners::')` which matches the path segment. Affects 9+ actors across lex-health, lex-node, lex-tasker, lex-conditioner, lex-transformer. +- Added defensive guard in `manual` method — raises descriptive `NoMethodError` when `runner_class` resolves to the actor itself and the function is not defined, instead of a generic undefined method error. + +## [1.8.2] - 2026-04-13 + +### Added +- `Legion::Extensions::Actors::RetryPolicy` — configurable retry threshold module with `should_retry?`, `extract_retry_count`, and `retry_threshold` helpers +- Subscription actor `reject_or_retry` — counts retries via `x-retry-count` header, republishes with incremented header and exponential backoff (`2^n * base_delay`, capped at `max_delay`), dead-letters to DLX when threshold exceeded +- Settings: `fleet.poison_message_threshold` (primary), `transport.retry_threshold` (fallback), `fleet.transport.retry_base_delay_seconds`, `fleet.transport.retry_max_delay_seconds` + +## [1.7.37] - 2026-04-09 + +### Added +- Trigger word tool injection: extensions and runners declare trigger words that auto-promote deferred tools when detected in LLM messages +- `Legion::Tools::TriggerIndex` — Concurrent::Map-backed reverse index for O(1) trigger word lookup +- `trigger_words` DSL on Extensions::Core, runner modules, and Tools::Base +- also fixed the stupid thor rspec issue + +## [1.8.0] - 2026-04-12 + +### Added +- `Legion::Extensions::Builder::Skills` — parallel to `Builders::Runners`, discovers and registers `lex-skill-*` gems into `Legion::LLM::Skills::Registry` at boot +- `Legion::Extensions::Core` — `skills_required?` guard; extensions declaring this flag are skipped when legion-llm is not loaded +- `Legion::Chat::Skills` rewritten — delegates to `Legion::LLM::Skills::Registry` instead of YAML file discovery; `discover` returns an Array of skill objects +- `Legion::API::Skills` — REST endpoints: `GET /api/skills`, `GET /api/skills/:namespace/:name`, `POST /api/skills/invoke`, `DELETE /api/skills/active/:conversation_id` +- `Legion::CLI::SkillCommand` rewritten — delegates to daemon API instead of local YAML parsing; `list`, `show`, `run` subcommands +- `Legion::Extensions::Builder::Skills` wired into `Extensions::Core#autobuild` after `Builders::Runners` + +## [1.7.36] - 2026-04-09 + +### Changed +- `Legion::Python::VENV_DIR` reads `LEGION_PYTHON_VENV` env var first, falls back to `~/.legionio/python` + +## [1.7.35] - 2026-04-09 + +### Added +- `Legion::Python` central module — single source of truth for venv paths, package list, and interpreter resolution +- `legionio setup python` CLI command for creating/repairing Python venv with document/data packages +- `PythonEnvCheck` doctor check for Python venv health +- Homebrew packaging note: `LEGION_PYTHON` and `LEGION_PYTHON_VENV` are exported by Homebrew wrapper scripts in the companion tap, not by changes in this gem repository + +### Fixed +- `notebook create` crash: removed `python:` kwarg that `Generator.generate` does not accept (`ArgumentError`) +- `docs serve` now uses `Legion::Python.interpreter` instead of inline path resolution + +## [1.7.33] - 2026-04-09 + +### Added +- Phase 8 prerequisites: `Broker.lease_for(name)` returns raw Lease, `Broker.renewer_for(name)` returns LeaseRenewer +- `LeaseRenewer` now exposes `attr_reader :provider` for structured credential access +- Non-renewing registration path: static API key providers (expires_at: nil, renewable: false) stored in `Concurrent::AtomicReference` without background LeaseRenewer thread +- `Broker.refresh_credential(name)` for manual refresh of static credentials +- `Broker.providers` and `Broker.leases` include both dynamic and static registrations +- `register_provider_with_broker` in service.rb — winning auth provider auto-registered with Broker after identity resolution + +### Changed (Copilot review #126) +- Renamed extension catalog routes from `/api/extensions` to `/api/extension_catalog` to eliminate route conflict with LexDispatch's `GET /api/extensions/:lex_name/:component_type/:component_name/:method_name` wildcard +- Updated `GET /api/extension_catalog/available` (was `/api/extensions/available`) +- Updated OpenAPI spec paths and `list_extensions` chat tool to match new route prefix +- Froze individual entry hashes in `Catalog::Available::EXTENSIONS` via `.each(&:freeze).freeze`; `all`, `by_category`, and `find` now return dup copies to prevent caller mutation +- Added explicit `require 'legion/api/helpers'` and `require 'legion/api/extensions'` to `spec/legion/api/extensions_spec.rb` for deterministic spec loading +- Added `loader.settings[:data]`, `[:transport]`, and `[:extensions]` initialization to extensions spec `before(:all)` for isolation + +## [1.7.32] - 2026-04-09 + +### Changed +- Rewrote `/api/extensions` routes to use in-memory state from `Catalog` instead of database queries — no `require_data!` dependency +- All extension routes now use `:name` (string identifier like `lex-node`) instead of numeric `:id` params +- Added `GET /api/extensions/available` route backed by `Catalog::Available.all` (static ecosystem list, filterable by `?category=`) +- Added `Legion::Extensions::Catalog::Available` module with 120+ known LEX gems organized by category +- Extension helper methods (`find_extension_module`, `find_runner_info`, `runner_summaries`, `halt_not_found`) moved into `Legion::API::Helpers` for reuse across all API tests + +## [1.7.31] - 2026-04-08 + +### Added +- Phase 7 RBAC enrichment: `Identity::Request` gains `roles:` constructor kwarg, `#roles` reader, `#id` alias for `principal_id`, and `roles:` in `identity_hash` +- `Identity::Middleware#build_request` now separates `claims[:groups]` (group OIDs/names) from `claims[:roles]` (Entra app roles), fixing the pre-existing conflation via `||` +- Worker token principal_id now correctly uses `claims[:worker_id]` when present, preventing worker tokens owned by a human from sharing the human's RBAC identity +- `Identity::Middleware` enriches resolved roles via `Legion::Rbac::GroupRoleMapper` when legion-rbac is loaded and enabled (including audit mode) +- `Identity::Middleware` builds `env['legion.rbac_principal']` (a `Legion::Rbac::Principal`) after setting `env['legion.principal']`, bridging identity to RBAC +- Middleware mount order fix: `Legion::Rbac::Middleware` removed from class-level `use` in `api.rb`; both `Identity::Middleware` and `Rbac::Middleware` now registered in `service.rb#setup_api` in the correct order (Identity first, then RBAC) + +### Changed +- `Legion::Identity::Request.from_auth_context` now reads `claims[:resolved_roles]` to populate `roles` + +## [1.7.30] - 2026-04-08 + +### Added +- SSE streaming inference now emits real-time `tool-call`, `tool-result`, `tool-error`, and `model-fallback` events via `executor.tool_event_handler` as tools execute (with wall-clock `startedAt`/`finishedAt`/`durationMs` timing) +- `event: done` payload extended with `conversation_id`, `stop_reason`, `cache_read_tokens`, and `cache_write_tokens` fields (nil values compacted out) +- Post-hoc `model-fallback` events emitted from `pipeline_response.warnings` for non-streaming tool paths +- `admin purge-topology` CLI command to remove stale v2.0 `legion.*` AMQP exchanges that have `lex.*` counterparts +- Parallel tool execution in `CLI::Chat::DaemonChat`: all tools in a response now run concurrently via `Thread.new`, preserving original order for message replay +- `build_tool_result_object` now carries `tool_call_id`/`id` so the Interlink frontend can match results to tool calls by ID rather than name (fixes parallel same-type tool matching) + +### Changed +- SSE tool-call events now use camelCase keys (`toolCallId`, `toolName`, `args`) matching the Interlink wire protocol + +## [1.7.29] - 2026-04-07 + +### Changed +- Skip secret resolution for all CLI commands that only need local settings: `config`, `mode`, `lex`, `doctor`, `auth`, `marketplace`, `debug`, `failover status` — eliminates noisy Vault/lease warnings on local-only operations + +## [1.7.28] - 2026-04-07 + +### Fixed +- `legionio setup` pack marker and packs.json writes now rescue `Errno::EPERM`/`EACCES`, fixing Homebrew post-install crash when sandbox blocks writes to `~/.legionio/` + +## [1.7.27] - 2026-04-07 + +### Changed +- `Connection.ensure_settings` accepts `resolve_secrets:` keyword (default `true`) to skip Vault/lease resolution for CLI commands that don't need infrastructure credentials +- `legionio update` now skips secret resolution, eliminating noisy "Vault not connected" and "LeaseManager not available" warnings + +## [1.7.26] - 2026-04-07 + +### Added +- Phase 5 Credential Scoping — service.rb integration (§8 of `docs/plans/2026-04-07-credential-scoping-design.md`) +- Boot: call `Legion::Crypt.fetch_bootstrap_rmq_creds` after `Crypt.start` to acquire short-lived bootstrap RMQ credentials from Vault before transport connects (no-op when `dynamic_rmq_creds: false`) +- `setup_identity`: after identity resolves, call `Legion::Crypt.swap_to_identity_creds(mode:)` to swap from bootstrap to identity-scoped RMQ credentials — gated on `vault_connected? && dynamic_rmq_creds? && !lite?`; fallback identity still gets scoped creds +- `shutdown`: call `Legion::Crypt.revoke_bootstrap_lease` before Crypt shutdown for defense-in-depth lease cleanup +- `reload`: call `fetch_bootstrap_rmq_creds` after Crypt.start, `resolve_secrets!` after settings reload, and `setup_identity` (replacing static `mark_ready(:identity)`) so reloaded processes acquire identity-scoped credentials +- Specs for all Phase 5 service.rb integration paths: boot credential fetch, identity swap per mode, vault/flag/lite guards, swap failure recovery, shutdown revocation, reload credential flow + +## [1.7.25] - 2026-04-06 + +### Added +- Wire Format Phase 3 Group 2: `Identity::Request::SOURCE_NORMALIZATION` constant — maps middleware-emitted source values (`:api_key`, `:local`, `:jwt`, `:kerberos`, `:system`) to canonical credential enum at `from_auth_context` construction time +- Wire Format Phase 3 Group 2: `response_meta` in `API::Helpers` now includes `caller` block (`canonical_name`, `kind`, `source`) when the request is authenticated and `env['legion.principal']` is set by `Identity::Middleware` +- Wire Format Phase 3 Group 2: `POST /api/llm/inference` wires `to_caller_hash` from the authenticated principal into the pipeline `caller:` field, replacing the hardcoded `{ type: :user, credential: :api }` fallback + +## [1.7.24] - 2026-04-06 + +### Fixed +- `Routes::Events` SSE stream: qualify `stream_queue` call with `Routes::Events.` to fix NoMethodError on Legion::API instance + +### Added +- `Identity::Process.source` accessor — exposes provider source in identity hash (Wire Format Phase 3) +- `source:` key in `Identity::Process.identity_hash`, `bind!`, `bind_fallback!`, and `EMPTY_STATE` + +## [1.7.22] - 2026-04-06 +### Added +- Elastic APM integration for Sinatra API via `elastic-apm` gem +- Full APM config under `api.elastic_apm` settings: server_url, api_key, secret_token, api_buffer_size, api_request_size, api_request_time, capture_body, capture_headers, capture_env, disable_send, enabled, environment, hostname, ignore_url_patterns, pool_size, service_name, service_node_name, service_version, sample_rate +- `setup_apm` / `shutdown_apm` lifecycle in Service (boot, shutdown, reload) +- `ElasticAPM::Middleware` wired into API when available +- Health/ready endpoints excluded from APM tracing by default + +## [1.7.21] - 2026-04-06 +### Fixed +- Optional components (rbac, llm, apollo, gaia) no longer block readiness when not installed +- Split `Readiness::COMPONENTS` into `REQUIRED_COMPONENTS` and `OPTIONAL_COMPONENTS` +- Added `Readiness.mark_skipped` for components that are absent or disabled +- Reload path now correctly marks optional components as skipped when not loaded + +## [1.7.20] - 2026-04-06 +### Added +- `Legion::Mode` module with `LEGACY_MAP`, ENV/Settings fallback chain, `agent?`/`worker?`/`infra?`/`lite?` predicates +- `Legion.instance_id` — UUID computed at load time, ENV override via `LEGIONIO_INSTANCE_ID` +- `Legion::Identity::Process` singleton — process identity with `bind!`, `bind_fallback!`, `queue_prefix` per-mode, `AtomicReference` thread safety +- `Legion::Identity::Request` — per-request immutable identity with `from_env`, `from_auth_context`, `to_caller_hash`, `to_rbac_principal` +- `Legion::Identity::Lease` — credential lease value object with `expired?`, `stale?` (50% TTL), `ttl_seconds`, `valid?` +- `Legion::Identity::LeaseRenewer` — background thread per provider, 50% TTL renewal, cooperative shutdown (no `Thread#kill`) +- `Legion::Identity::Broker` — provider management with groups cache (60s TTL, single-flight CAS), `token_for`, `credentials_for`, `shutdown` +- `Legion::Identity::Middleware` — Rack middleware bridging `legion.auth` to `legion.principal` (`Identity::Request`) +- `setup_identity` boot step 9 — parallel provider resolution via `Concurrent::Promises`, fallback to `ENV['USER']` +- Extension publish suppression — defers `LexRegister.publish` until identity resolves, `flush_pending_registrations!` +- Identity provider auto-registration during phased extension load (`identity_provider?` duck-type check) +- `GET /api/identity/audit` route with principal and duration filtering +- `legion doctor` checks: `ApiBindCheck` (non-loopback without auth), `ModeCheck` (no explicit process.mode) + +### Changed +- `Readiness.status` upgraded to `Concurrent::Hash` for thread safety; `:identity` added to `COMPONENTS` +- `READONLY_SECTIONS` extended with `:identity`, `:rbac`, `:api` +- Default API bind changed from `0.0.0.0` to `127.0.0.1` +- `ProcessRole` delegates `.current` to `Mode.current`; added `:agent` and `:infra` role entries +- `lite_mode?` delegates to `Mode.lite?` +- Reload path adds `Identity::Process.refresh_credentials` after transport reconnect +- Shutdown adds cooperative `Identity::Broker.shutdown` and JWKS background refresh stop + +## [1.7.19] - 2026-04-06 + +### Added +- `ALWAYS_LOADED` constant in `Tools::Discovery` — pins apollo/knowledge and eval/evaluation runners to always-loaded regardless of extension DSL +- `always_loaded_names` method on `Tools::Registry` returning names of all non-deferred registered tools + +### Changed +- Tool name format changed from dot-separated to dash-separated (`legion-ext-runner-func`) for LLM provider compatibility +- Reduced noisy debug logging in `Tools::Discovery` and `Tools::Registry` + +## [1.7.18] - 2026-04-06 + +### Added +- Multi-phase extension loading: identity providers (`lex-identity-*`) load in phase 0 before all other extensions in phase 1 +- `identity` category in extension registry with prefix matching for `lex-identity-*` gems at tier 0, phase 0 +- `group_by_phase` method groups discovered extensions by phase from the category registry +- `load_phase_extensions` replaces `load_extensions` — scopes parallel loading to a subset of entries per phase +- `hook_phase_actors` replaces `hook_all_actors` — hooks deferred actors after each phase completes +- Per-phase logging during extension loading shows cumulative actor counts + +### Changed +- `hook_extensions` now iterates phases sequentially (phase 0 then phase 1), running full load+hook cycle per phase +- `default_category_registry` includes `phase:` key on all categories; all non-identity categories default to phase 1 +- Catalog transitions (`transition(:running)` + `flush_persisted_transitions`) happen after all phases complete +- Reserved prefixes list now includes `identity` + +### Added +- `Legion::Tools::Base` - canonical tool base class with DSL +- `Legion::Tools::Registry` - always/deferred tool classification +- `Legion::Tools::Discovery` - auto-discovers tools from extension runners with hierarchical DSL +- `Legion::Tools::EmbeddingCache` - 5-tier persistent embedding cache (L0 memory + Cache + Data) +- `mcp_tools?` and `mcp_tools_deferred?` extension Core DSL +- `runner_modules` accessor on extension builders +- `loaded_extension_modules` accessor on `Legion::Extensions` +- Static tools: `Do`, `Status`, `Config` with `Legion::Logging::Helper` + +### Changed +- Boot registers tools into Tools::Registry after extension load +- Embedding index build is async (non-blocking) +- API inference reads from Tools::Registry instead of MCP +- Capability registration methods are now no-ops (replaced by Tools::Discovery) + +### Removed +- Direct MCP dependency for tool access in API inference + +## [1.7.16] - 2026-04-03 + +### Fixed +- Inference endpoint now injects daemon MCP tools alongside client tools via class-level cached adapters +- MCP server pre-warmed in background thread during boot to avoid blocking first inference +- Gaia ticks route added to fallback API routes +- Reload endpoint disabled (418) to prevent accidental restart loops + +## [1.7.15] - 2026-04-03 + +### Added +- Every actors now support `delay` method to defer timer start (used by lex-microsoft_teams) +- Request logger emits `[api][request-start]` on inbound, warns on responses > 5s + +### Changed +- `/api/reload` disabled (returns 418) to prevent accidental full-restart loops + +## [1.7.14] - 2026-04-03 + +### Fixed +- Actor boot ordering: once → poll → every → loop → subscriptions, preventing timer actors from competing with AMQP channel setup +- Builder now respects `remote_invocable? false` and skips auto-generated subscription actors for local-only extensions +- Catalog exchange cached and reused instead of creating a new channel + exchange_declare per transition +- Catalog SQLite persists batched into a single transaction at end of boot instead of per-transition writes from concurrent threads + +## [1.7.13] - 2026-04-03 + +### Changed +- Bump legion-crypt >= 1.5.1, legion-transport >= 1.4.14, legion-cache >= 1.3.22 + +## [1.7.12] - 2026-04-03 + +### Fixed +- Fixes #110: normal daemon boot now prefers library-owned LLM and Apollo API routes, `/api/tenants` uses canonical JSON parsing with correct status codes, SSE listeners drain worker threads on disconnect, paginated collections avoid unconditional `COUNT(*)` unless explicitly requested, and service startup skips duplicate settings loads once configuration is already bootstrapped + +## [1.7.11] - 2026-04-02 + +### Fixed +- Fixes #113: webhook deliveries now retry non-2xx responses and transport exceptions up to `max_retries`, record per-attempt delivery rows, dead-letter terminal failures, and cache active webhook pattern matching to reduce per-event dispatch overhead + +## [1.7.10] - 2026-04-02 + +### Changed +- Bumped minimum dependency floors for Legion core gems, including `legion-logging >= 1.5.0`, `legion-settings >= 1.3.25`, and updated transport, data, cache, crypt, Apollo, and MCP minimums +- Stabilized the `LegionIO` spec suite by fixing the OAuth callback, catalog, and service shutdown regression specs +- CLI startup now honors settings-driven log levels, normalizes `start --help` into the standard Thor help flow, and routes chat/error logging through the newer helper-backed logger path +- `Legion::Service`, telemetry, and webhook runtime paths now use structured helper logging more consistently, respect configured logging when no CLI override is passed, and avoid brittle settings reads during boot +- Extension runtime wiring now deep-dups merged settings, lazily registers the local `extension_catalog` migration, publishes catalog transitions directly to transport, and surfaces auto-binding failures more clearly +- Secret, region, and task-outcome helpers now use canonical Vault connectivity checks, cache metadata misses more safely, and create meta-learning domains on demand before recording learning episodes + +## [1.7.8] - 2026-04-01 + +### Added +- `Legion::API::Settings` module with registered defaults via `merge_settings('api', ...)`, matching the pattern used by all other LegionIO gems +- Puma `persistent_timeout` (20s) and `first_data_timeout` (30s) now configurable via `Settings[:api][:puma]` + +### Changed +- Removed all inline `||` and `.fetch(..., default)` fallbacks for API settings in `service.rb` and `check_command.rb` — defaults now guaranteed by `merge_settings` + +## [1.7.7] - 2026-04-01 + +### Changed +- Integrated legion-logging 1.4.3 Helper refactor: all log output now uses structured segment tagging, colored exception output, and thread-local task context +- Slimmed `Extensions::Helpers::Logger` to thin override; `derive_component_type`, `lex_gem_name`, `gem_spec_for_lex`, `log_lex_name` now live in legion-logging gem +- Added `handle_runner_exception` for runner-specific exception handling (TaskLog publish + HandledTask raise) +- Added `Legion::Context.with_task_context` and `.current_task_context` for thread-local task propagation +- Wrapped all 5 dispatch paths (Runner.run, Subscription#dispatch_runner, Base#runner, Ingress local/remote) with context propagation +- Migrated 13 `log.log_exception` call sites to `handle_exception` across actors, core, transport, and task helpers + +## [1.7.6] - 2026-04-01 + +### Changed +- `POST /api/llm/inference` now routes through `Legion::LLM::Pipeline::Executor` instead of raw `Legion::LLM.chat` session, enabling the full 18-step pipeline (RBAC, RAG context, MCP discovery, metering, audit, knowledge capture) +- GAIA bridge added: user prompt from `/api/llm/inference` is pushed as an `InputFrame` to the GAIA sensory buffer when GAIA is started +- SSE streaming support added: `stream: true` + `Accept: text/event-stream` returns `text/event-stream` with `text-delta`, `tool-call`, `enrichment`, and `done` events +- `build_client_tool` renamed to `build_client_tool_class`; now returns a `Class` (not an instance) so the pipeline can inject it correctly via `tool.is_a?(Class)` check +- Typed error mapping added: `AuthError` → 401, `RateLimitError` → 429, `TokenBudgetExceeded` → 413, `ProviderDown`/`ProviderError` → 502 + +## [1.7.5] - 2026-04-01 + +### Added +- `POST /api/reload` endpoint to trigger daemon reload from CLI mode command +- `GET /api/mesh/status` and `GET /api/mesh/peers` endpoints with 10s cache +- `GET /api/metering`, `/api/metering/rollup`, `/api/metering/by_model` endpoints wired to lex-metering +- `GET /api/webhooks` and `GET /api/tenants` routes registered (were defined but never mounted) +- Knowledge monitor v2/v3 route aliases for Interlink compatibility +- Server-side MCP tool injection into `/api/llm/inference` via `McpToolAdapter` (64 tools) +- Deferred tool loading: 18 always-loaded tools, ~46 on-demand (cuts inference from 24s to 6-9s) +- Client-side tools (`sh`, `file_read`, `list_directory`, etc.) now execute server-side in the inference endpoint + +### Fixed +- Knowledge ingest API route calls `ingest_content` instead of `ingest_file` when `content` body param is present +- Catalog API queries `extensions.name` instead of non-existent `gem_name` column +- Inference endpoint tool declarations use `RubyLLM::Tool` subclass with proper `name` instance method +- Prompts API guards against missing `prompts` table (returns 503 instead of 500) +- All API rescue blocks use `Legion::Logging.log_exception` instead of swallowing errors + +## [1.7.0] - 2026-03-31 + +### Added +- `Legion::Provider` base class with DAG-ordered registry for boot lifecycle (#71) +- `TaskOutcomeObserver` wires task completion to reflection and learning persistence (#70) +- GenAI semantic convention attributes (`gen_ai.*`) on OpenInference spans (#69) +- `legionio doctor` scored audit report with weighted health score and letter grades (#77) +- Local skill drop-in directory with `.rb` and `.md` support and execution (#76) +- Dynamic gem sources for extension installs via `extensions.sources` setting (#52) +- `legionio mode` CLI command for profile and process role switching (#72) +- Cross-project session resume with CWD context and `--resume-latest` flag (#105) +- Away summary recap via LLM when user returns after idle period (#100) +- Wire `LexCliManifest.write_manifest` into extension autobuild pipeline (#97) +- Inbound webhook normalizer and HTTP-to-AMQP event bridge (`Legion::Trigger`) (#74) +- Interrupt detection and session recovery for chat resume (#98) +- Configurable output styles for LLM responses via `.legionio/output-styles/` (#103) +- Route RunCommand through lex-exec sandbox when `chat.sandboxed_commands.enabled` is true (#96) +- Cross-session memory consolidation with 3-gate trigger system (#99) +- Per-model `/cost` breakdown with token counts, cache hits, and `CostEstimator` pricing (#102) +- Team memory sync via Apollo knowledge store with repo-scoped tags (#104) + +### Fixed +- Puma no longer steals SIGINT/SIGTERM traps, preventing graceful shutdown (#91) + +## [1.6.47] - 2026-03-31 + +### Added +- CLI chat identity wiring: `DaemonChat` generates stable `conversation_id` and resolves user identity into `caller_context` (Kerberos principal -> ENV['USER'] fallback) +- `DaemonChat` forwards `caller` and `conversation_id` to daemon inference endpoint +- GAIA observation hook in `chat_command.rb`: `setup_gaia_observation` registers an `:llm_complete` callback that ingests user messages into GAIA's observation pipeline +- `:llm_complete` session event now includes `user_message` in payload + +## [1.6.46] - 2026-03-31 + +### Fixed +- `write_pack_marker` no longer uses `FileUtils.touch` — avoids `EPERM` (`Operation not permitted @ apply2files`) on macOS Sequoia when marker file already exists + +## [1.6.45] - 2026-03-31 + +### Added +- `Legion::CLI::ApiClient` shared module — extracts api_get/api_post/api_put/api_delete helpers into a reusable mixin for all CLI commands that talk to the daemon API +- `/api/knowledge/*` API routes — query, retrieve, ingest, status, health, maintain, quality, and monitor CRUD endpoints for lex-knowledge + +### Changed +- `legionio knowledge` commands now route through the local API instead of loading extension classes directly (fixes NameError when daemon not running) +- `legionio schedule` commands now route through the existing `/api/schedules/*` API instead of querying Sequel models directly +- `legionio codegen` commands now route through the existing `/api/codegen/*` API instead of checking `defined?` guards that always fail in CLI context +- `legionio absorb` commands now use the shared `ApiClient` module instead of inline HTTP helpers + +## [1.6.44] - 2026-03-31 + +### Added +- `legionio setup ` now writes `~/.legionio/.packs/` marker file and `~/.legionio/settings/packs.json` on successful install, enabling automatic pack reinstall after `brew upgrade` (companion to homebrew-tap#19) + +## [1.6.43] - 2026-03-31 + +### Added +- `POST /api/absorbers/dispatch` API endpoint for async absorber dispatch — CLI no longer loads extension classes directly +- Absorb dispatch runs in a background thread, returning job ID immediately + +### Changed +- `legionio absorb url` now routes through the local API instead of loading extension classes in-process (fixes `NameError` when extensions not loaded in CLI context) +- CLI absorb output updated to show async dispatch status with job ID + +## [1.6.42] - 2026-03-31 + +### Fixed +- `Every` and `Poll` actors now guard against overlapping executions using `Concurrent::AtomicBoolean` — if the previous tick is still running when the next interval fires, the new tick is skipped with a debug log instead of stacking up concurrent executions + +## [1.6.41] - 2026-03-30 + +### Fixed +- Add missing `info` method to `Legion::CLI::Output::Formatter` — `auth teams` command called `out.info(...)` but the method did not exist, raising `NoMethodError` + +## [1.6.40] - 2026-03-30 + +### Fixed +- `Helpers::Lex` now includes Cache, Transport, Task, and Data helpers so all actors, runners, absorbers, and hooks automatically get `cache_connected?`, `transport_connected?`, `data_connected?`, `generate_task_id`, and related methods +- `Absorbers::Base` now includes `Helpers::Lex` (previously included zero helpers, causing `NoMethodError` for `log`, `cache_connected?`, etc.) + +## [1.6.39] - 2026-03-30 + +### Added +- `legionio config reset` subcommand to wipe all JSON config files from settings directory (#88) +- `legionio bootstrap --clean` flag to clear settings before import (#88) + +### Changed +- `legionio bootstrap` no longer runs `ConfigScaffold` when a source is provided — scaffolded empty files were conflicting with imported config (#88) + +## [1.6.38] - 2026-03-30 + +### Removed +- Remove deprecated `lex-cortex` from agentic setup pack (replaced by legion-gaia) + +## [1.6.37] - 2026-03-30 + +### Added +- TBI Patterns API: `POST /api/tbi/patterns/export`, `GET /api/tbi/patterns`, `GET /api/tbi/patterns/:id`, `PATCH /api/tbi/patterns/:id/score`, `GET /api/tbi/patterns/discover` (501 stub) +- TBI Pattern model and local migration (`create_tbi_patterns`) +- OpenInference telemetry integration (`Legion::Telemetry::OpenInference`) + +### Fixed +- Governance lifecycle integration specs expanded and hardened + +## [1.6.36] - 2026-03-29 + +### Added +- Knowledge helper: `knowledge_connected?`, `knowledge_global_connected?`, `knowledge_local_connected?` status methods +- Knowledge helper: `knowledge_default_scope` and `knowledge_default_tags` LEX-overridable layered defaults +- LLM helper: now includes `Legion::LLM::Helper` following cache/transport pattern (with LoadError guard) +- Wrapper specs for cache and data helpers + +### Fixed +- Logger helper: add missing `include Base` (was relying on transitive inclusion via Lex) +- Task helper: add missing `include Base` +- Knowledge helper: add missing `include Base`, `knowledge_default_tags` auto-merged into `ingest_knowledge` + +## [1.6.35] - 2026-03-29 + +### Added +- `Legion::Workflow::Manifest` — YAML workflow manifest parser with validation +- `Legion::Workflow::Loader` — installs/uninstalls workflow chains via lex-lex registry +- `legion workflow` CLI — install, list, uninstall, status subcommands +- `workflows/autonomous-github-lifecycle.yml` — sample workflow manifest for codegen pipeline + +## [1.6.34] - 2026-03-29 + +### Fixed +- `POST /api/logs` no longer raises `NoMethodError: undefined method 'values' for nil` — replaced `Legion::Transport::Messages::Dynamic.new(...).publish` with a direct `Legion::Transport::Exchanges::Logging` publish call; `Dynamic` requires a `function_id` for database lookup which log payloads do not have +- `legion knowledge` CLI commands (`require_monitor!`, `require_knowledge!`, `require_ingest!`, `require_maintenance!`) now use `Connection.ensure_knowledge` to dynamically load `lex-knowledge` when not yet loaded, instead of raising a generic error + +### Added +- `Connection.ensure_knowledge` — lazily loads the `lex-knowledge` gem on demand, consistent with `ensure_llm` and other lazy loaders + +## [1.6.33] - 2026-03-28 + +### Added +- `knowledge` registered as top-level CLI subcommand (previously only accessible via `legionio ai knowledge`). Fixes knowledge capture hooks that call `legionio knowledge capture commit/transcript`. + +### Fixed +- Claude Code hook format in `setup claude-code`: PostToolUse and Stop hooks now emit the new `hooks` array wrapper format with `type: command` entries. Detection supports both old and new formats via `hook_commands` helper. + +## [1.6.32] - 2026-03-28 + +### Added +- `POST /api/logs` endpoint (`Routes::Logs`) — accepts `error`/`warn` level messages from CLI, normalizes with server-side metadata (timestamp, node, legion_versions, ruby_version, pid), computes `error_fingerprint` via `EventBuilder.fingerprint` when `exception_class` is present, and publishes to the `legion.logging` exchange with routing key `legion.logging.exception.{level}.cli.{source}` or `legion.logging.log.{level}.cli.{source}` +- `Legion::CLI::ErrorForwarder` module — fire-and-forget HTTP helper that POSTs CLI errors/warnings to the daemon API; silently swallows all failures so daemon unavailability never crashes the CLI +- `ErrorForwarder.forward_error` wired into both rescue blocks in `CLI::Main.start` (fires before `exit(1)`) + +## [1.6.31] - 2026-03-28 + +### Fixed +- `build_hook_list` in `Builders::Hooks` now calls `runner_class` on a hook instance (instance method) instead of the class, preventing the `TypeError: no implicit conversion of nil into String` boot crash caused by `Helpers::Base#runner_class` being inherited at the class level and calling `sub!` on a string that contains no `'Actor'` substring +- `Helpers::Base#runner_class` changed `sub!` to `sub` (non-destructive) as a defensive fix — `sub!` returns `nil` when no substitution is made, which caused `Kernel.const_get(nil)` to raise `TypeError` +- Runner reference returned by `hook_class.new.runner_class` is now resolved safely: string class names are resolved via `Kernel.const_defined?` + `Kernel.const_get`; Class objects are used directly; `nil` falls back to `hook_class` + +## [1.6.30] - 2026-03-28 + +### Fixed +- `Legion::Extensions::Hooks::Base` now defines the `mount(path)` DSL method and `mount_path` reader — fixes `NoMethodError` boot crash in any extension hook that calls `mount` (e.g. `lex-microsoft_teams` `Hooks::Auth`) + +## [1.6.29] - 2026-03-28 + +### Removed +- `ClassMethods` module (`expose_as_mcp_tool`, `mcp_tool_prefix`) from `Legion::Extensions::Helpers::Lex` — deprecated since the definition DSL was introduced; zero extensions use them + +### Fixed +- Fallback route guards in `api.rb` now check `router.library_names.include?` instead of `defined?` — prevents 404s when gem modules are loaded but routes are not yet mounted (fixes #53) + +## [1.6.28] - 2026-03-28 + +### Fixed +- `legion lex list` now displays extensions in clean aligned tables with Name, Version, Status, Runners, Actors columns +- Grouped view drops redundant category/tier columns from rows (already shown in group header); sorts alphabetically within each group +- Flat/category-filtered view uses Name, Version, Category, Status, Runners, Actors columns; sorts alphabetically +- Runners and actors are formatted as comma-joined names (up to 3) or a count summary instead of raw `Array#to_s` output +- JSON output for both flat and grouped list modes is now handled directly in the render methods + +## [1.6.27] - 2026-03-28 + +### Fixed +- `Connection.ensure_crypt` now calls `resolve_secrets!` a second time after `Legion::Crypt.start` so that `lease://` URI refs are resolved once the LeaseManager is running (closes #50) + +## [1.6.26] - 2026-03-28 + +### Added +- Absorber Router registration: `builders/absorbers.rb` now registers absorbers with `Legion::API.router` for v3.0 API discovery and dispatch (component_type: `absorbers`) +- Hook-aware LexDispatch: `POST /api/extensions/:lex/hooks/:name/:method` applies verify/route/transform lifecycle for `Hooks::Base` subclasses; auto-generated hooks pass through unchanged +- Transport message auto-generation: `auto_generate_messages` in `extensions/transport.rb` creates `Legion::Transport::Message` subclasses from runner definitions with inputs at boot time; explicit classes always take precedence +- `legion broker purge-topology` CLI command: detects old v2.0 AMQP exchanges (`legion.*`) that have v3.0 counterparts (`lex.*`) and optionally deletes them via RabbitMQ management API; defaults to `--dry-run` +- `spec/api/lex_dispatch_spec.rb`: 10-example spec covering v3.0 LexDispatch routes (replaces old lex_spec.rb) +- `spec/api/lex_dispatch_hooks_spec.rb`: 5-example spec for hook-aware dispatch (401/422/success/passthrough) +- `spec/api/old_systems_removed_spec.rb`: 10-example spec verifying old registries are gone +- `spec/cli/admin_command_spec.rb`: 21-example spec for topology detection logic +- `spec/extensions/builders/absorbers_spec.rb`: 10-example spec for absorber builder + Router registration +- `spec/extensions/transport_auto_messages_spec.rb`: 14-example spec for message auto-generation +- `unless defined?` guards on `Routes::Gaia`, `Routes::Transport`, `Routes::Rbac` registration for library gem self-registration + +### Removed +- `Routes::Lex` (`api/lex.rb`): old `/api/lex/*` wildcard dispatcher — use `/api/extensions/:lex/runners/:name/:method` +- `Routes::Hooks` (`api/hooks.rb`): old `/api/hooks/lex/*` handler — use `/api/extensions/:lex/hooks/:name/:method` +- `Legion::API.hook_registry`, `.register_hook`, `.find_hook`, `.find_hook_by_path`, `.registered_hooks` — hooks auto-register via builder +- `Legion::API.route_registry`, `.register_route`, `.find_route_by_path`, `.registered_routes` — routes auto-register via builder + +### Changed +- Routes builder log message now uses v3.0 path format (`/api/extensions/...` instead of `/api/lex/...`) + +## [1.6.25] - 2026-03-28 + +### Added +- `Legion::Extensions::Absorbers::Dispatch`: module-function dispatch pipeline — `dispatch(input, context:)`, depth limiting, cycle detection via ancestor_chain, `dispatch_children`, `extract_urls`, thread-safe `@dispatched` registry +- `Legion::Extensions::Absorbers::PatternMatcher`: URL/file pattern registry — `register(absorber_class)`, `resolve(input)`, priority-ordered matching, `reset!` +- `Legion::Extensions::Absorbers::Transport`: v3.0 AMQP topology — `publish_absorb_request`, `build_message`, `lex_name_from_absorber_class`, `absorber_name_from_class`; exchanges named `lex.{lex_name}`, routing keys `lex.{lex_name}.absorbers.{name}.absorb` +- `Legion::Extensions::Absorbers::Base`: updated with `TokenRevocationError`, `TokenUnavailableError`, and `with_token(provider:, &block)` helper for OAuth-gated absorbers +- `Legion::Extensions::Absorbers::Matchers::File`: file-path pattern matcher using `File.fnmatch` +- `Legion::Auth::OauthCallback`: ephemeral TCP server for OAuth redirect callback — `wait_for_callback`, `parse_callback`; per-port lifecycle +- `Legion::Auth::TokenManager`: `TokenExpiredError`, `mark_revoked!`, `revoked?` for token lifecycle and revocation detection +- `Legion::CLI::ConnectCommand`: `legion connect microsoft`, `legion connect github`, `legion connect status`, `legion connect disconnect` — browser OAuth flow entry points registered as `legion connect` subcommand +- Chat URL detection: `Session#check_for_absorbable_urls` auto-dispatches matched URLs after each user message +- `spec/integration/absorber_pipeline_spec.rb`: 12-example end-to-end integration spec covering PatternMatcher resolution, Dispatch routing, transport suppression in lite mode, absorber → Apollo.ingest pipeline, depth/cycle guards + +## [1.6.24] - 2026-03-28 + +### Added +- `Legion::API.register_library_routes(gem_name, routes_module)` class method: library gems self-register their Sinatra route modules at boot via `router.register_library` + Sinatra `register`. Implemented in `lib/legion/api/library_routes.rb`. +- `Legion::API::SyncDispatch.dispatch(exchange_name, routing_key, payload, envelope, timeout:)`: synchronous AMQP dispatch using a temporary exclusive reply_to queue with configurable timeout (default 30s). Implemented in `lib/legion/api/sync_dispatch.rb`. +- Remote dispatch in `LexDispatch`: when a registered extension route's runner class is not loaded in the current process, the request is forwarded via AMQP — async (202) by default or sync (blocks on reply queue) when `X-Legion-Sync: true` header is present. Returns 403 when `definition[:remote_invocable] == false`. +- `Routes::Llm` and `Routes::Apollo` registration now guarded: skipped in `api.rb` when `Legion::LLM::Routes` / `Legion::Apollo::Routes` are already defined (i.e. self-registered by the library gem). + +### Changed +- `api.rb`: requires `api/library_routes` and `api/sync_dispatch`; LLM and Apollo route registration conditional on gem self-registration not already having run. + +## [1.6.23] - 2026-03-28 + +### Added +- `Legion::Extensions::Definitions` mixin: class-level `definition` DSL for method contracts (`desc`, `inputs`, `outputs`, `remote_invocable`, `mcp_exposed`, `idempotent`, `risk_tier`, `tags`, `requires`). Auto-extended onto every runner module at boot by the builder. +- `Legion::Extensions::Actors::Dsl` mixin: `define_dsl_accessor` generates class-level getter/setter DSL with inheritance and instance delegation. Wired into all actor base classes (`Every`, `Poll`, `Subscription`, `Once`, `Base`). +- `Absorbers::Base#absorb`: canonical entry point replacing `handle`. `alias handle absorb` preserves backward compatibility. + +### Changed +- `Builders::Runners#build_runner_list`: auto-extends `Legion::Extensions::Definitions` onto every discovered runner module unless it already responds to `:definition`. +- `Hooks::Base`: extended with `Definitions` mixin; `mount` DSL removed (paths fully derived from naming). +- `Absorbers::Base`: extended with `Definitions` mixin. +- `AbsorberDispatch`: calls `absorber.absorb` instead of `absorber.handle`. +- `Helpers::Lex`: all `function_*` helpers and `expose_as_mcp_tool`/`mcp_tool_prefix` marked `@deprecated` — use `definition` DSL instead. + +## [1.6.22] - 2026-03-27 + +### Added +- `POST /api/llm/inference` daemon endpoint: accepts a full messages array plus optional tool schemas, runs a single LLM completion pass, and returns `{ content, tool_calls, stop_reason, model, input_tokens, output_tokens }` — the client owns the tool execution loop +- `Legion::CLI::Chat::DaemonChat` adapter: drop-in replacement for the `RubyLLM::Chat` object that routes all inference through the daemon, executes tool calls locally, and loops until the LLM produces a final text response +- `spec/legion/api/llm_inference_spec.rb`: 12 examples covering the new `/api/llm/inference` endpoint +- `spec/legion/cli/chat/daemon_chat_spec.rb`: 25 examples covering `DaemonChat` initialization, tool registration, tool execution loop, streaming, and error handling + +### Changed +- `legion chat setup_connection`: replaced `Connection.ensure_llm` (local LLM boot) with a daemon availability check via `Legion::LLM::DaemonClient.available?` — **hard fails with a descriptive error if the daemon is not running** +- `legion chat create_chat`: now returns a `DaemonChat` instance instead of a direct `RubyLLM::Chat` object; all LLM calls route through the daemon + +## [1.6.21] - 2026-03-27 + +### Added +- `legionio knowledge capture transcript` CLI command: ingests Claude Code session transcripts into Apollo knowledge store +- Stop hook for automatic transcript capture at session end (installed via `legion setup claude-code`) + +## [1.6.20] - 2026-03-27 + +### Changed +- Bump `legion-logging` dependency to `>= 1.4.0` (required for `log_exception`, writer lambdas) + +### Fixed +- `subscription.rb` (both `on_delivery` and `subscribe` blocks): initialize `fn = nil` before `process_message` so the rescue interpolation never raises `NameError` if message processing fails before `fn` is assigned +- `Helpers::Logger#lex_name` removed to avoid overriding `Helpers::Base#lex_name` (underscore contract used by settings/routing); renamed to private `log_lex_name` used only within this module for gem name derivation +- `Helpers::Logger#handle_exception`: use `spec&.version&.to_s` so nil spec version produces `nil` rather than `""` in structured log output +- README: update version badge from `v1.6.18` to `v1.6.20` + +## [1.6.19] - 2026-03-27 + +### Fixed +- `teardown_logging_transport`: rescue block in `setup_logging_transport` now calls `teardown_logging_transport` to clean up any partially-created `@log_session` on failure +- `teardown_logging_transport`: guard `open?` call with `respond_to?(:open?)` check to avoid `NoMethodError` on session objects that do not implement the method +- `service_logging_transport_spec`: early-return specs now assert `create_dedicated_session` was not called and `@log_session` remains nil, rather than the vacuous `respond_to(:call)` check +- `service_logging_transport_spec`: replaced vacuous `not_to eq(owner)` assertion with `have_received(:create_dedicated_session)` to verify the dedicated session was actually created + +## [1.6.18] - 2026-03-27 + +### Added +- `setup_logging_transport`: dedicated AMQP session for log and exception forwarding, replacing the previous `register_logging_hooks` approach; writer lambda wiring is gated by `Settings[:logging][:transport]` feature flags +- `teardown_logging_transport`: cleanly shuts down the dedicated logging AMQP session during the shutdown sequence + +### Changed +- Split `log.error(e.message); log.error(e.backtrace)` patterns replaced with `log.log_exception` across 14 files for structured, single-call exception logging +- `Extensions::Helpers::Logger#handle_exception` rewritten to use `log.log_exception` with full lex context + +### Fixed +- `legionio pipeline image analyze`: `call_llm` no longer passes unsupported `messages:` keyword to `Legion::LLM.chat`; now creates a chat object and sends multimodal content via `chat.ask`, returning a plain hash with `:content` and `:usage` keys +- `legionio ai trace search/summarize`: both commands now call `setup_connection` before invoking `TraceSearch`, ensuring `Legion::LLM` is booted so `TraceSearch.generate_filter` can use structured LLM output instead of returning "no filter generated"; added `class_option :config_dir` and `class_option :verbose` to `TraceCommand` + +## [1.6.17] - 2026-03-27 + +### Fixed +- `legionio check`: `resolve_secrets!` is now called after a successful crypt check so `lease://`, `vault://`, and `env://` credential URIs are resolved before transport/data checks attempt to connect +- `legionio check transport`: raises an early descriptive error when transport credentials are still unresolved URI references (Vault lease pending), instead of failing with a confusing connection error +- `legionio check data`: raises an early descriptive error when database credentials are still unresolved URI references (Vault lease pending) +- `legionio llm status/providers/models`: `boot_llm_settings` now calls `resolve_secrets!` so `env://` and `vault://` API key references are resolved before provider enabled-state is evaluated +- `legionio llm providers`: providers with unresolved credential URIs are now shown as `deferred (credentials pending Vault)` in yellow instead of incorrectly `disabled` +- `Connection.ensure_settings`: calls `resolve_secrets!` after loading settings so `env://` references are resolved in all CLI commands that use the lazy connection manager + +## [1.6.16] - 2026-03-27 + +### Fixed +- `config validate` transport host check now reads from `transport.connection.host` instead of `transport.host` (correct config nesting) +- `doctor diagnose` now loads settings via `Connection.ensure_settings` before running checks, so cache/database/vault/extensions checks no longer skip due to `Legion::Settings` being undefined; also adds `ensure Connection.shutdown` for clean teardown + +## [1.6.15] - 2026-03-27 + +### Added +- Absorbers: new LEX component type for pattern-matched content acquisition +- `Absorbers::Base` class with `pattern`/`description` DSL and knowledge helpers (`absorb_to_knowledge`, `absorb_raw`, `translate`, `report_progress`) +- `Absorbers::Matchers::Base` auto-registering matcher interface with `Matchers::Url` for URL glob matching +- `Absorbers::PatternMatcher` for thread-safe input-to-absorber resolution with priority-based dispatch +- `Builders::Absorbers` for auto-discovery of absorber classes during extension boot +- `Capability.from_absorber` factory method for Capability Registry integration +- `AbsorberDispatch` module for pattern resolution and handler execution +- `legionio absorb` CLI command with `url`, `list`, and `resolve` subcommands +- `legionio dev generate absorber` scaffolding template + +## [1.6.14] - 2026-03-27 + +### Added +- `Legion::Compliance` module rewritten with DEFAULTS hash, `merge_settings` registration, and clean API +- `Compliance.setup` registers max-classification defaults: PHI, PCI, PII, FedRAMP all enabled by default +- `Compliance.enabled?`, `.phi_enabled?`, `.pci_enabled?`, `.pii_enabled?`, `.fedramp_enabled?` convenience methods +- `Compliance.classification_level` returns `'confidential'` by default (highest level) +- `Compliance.profile` returns a hash with all compliance flags for downstream consumers +- `setup_compliance` wired into Service boot sequence after settings load +- Compliance profile spec (8 examples) + +### Changed +- `Compliance.phi_enabled?` now uses `Settings.dig(:compliance, :phi_enabled)` instead of chaining `[]` calls +- Existing PhiTag and PhiAccessLog specs updated to use `merge_settings` instead of stubbing `Settings.[]` + +## [1.6.13] - 2026-03-27 + +### Added +- `DigitalWorker.heartbeat` method for updating worker health status and last heartbeat timestamp +- `DigitalWorker.detect_orphans` method to find workers with stale or nil heartbeats +- `DigitalWorker.pause_orphans!` method to auto-pause orphaned workers with event emission +- Consent tier sync on lifecycle transitions: `worker.update` now includes `consent_tier` from `CONSENT_MAPPING` +- `Lifecycle.sync_consent_tier` calls `lex-consent` runner when available, graceful degradation when not +- Per-worker SSE events at `/api/workers/:id/events?stream=true` with queue-per-client filtering +- Polling fallback for per-worker events via ring buffer filtering (default mode) + +## [1.6.11] - 2026-03-26 + +### Added +- `Legion::Dispatch` module with pluggable strategy interface and `Local` implementation using `Concurrent::FixedThreadPool` +- Local dispatch wiring in `extensions.rb`: `dispatch_local_actors` registers non-remote extensions in thread pool +- `Ingress.local_runner?` short-circuit: runners for `remote_invocable? false` extensions skip AMQP round-trip +- `setup_dispatch` in `Service` boot sequence with graceful shutdown +- `legion broker stats` and `legion broker cleanup` CLI commands for RabbitMQ management +- End-to-end integration test for TBI Phase 5 self-generating functions loop (9 examples) +- Test dependencies: lex-codegen, lex-eval added to Gemfile for integration testing +- Specs for `legion codegen` CLI subcommand (8 subcommands, 22 examples) +- Specs for `/api/codegen/*` API routes (8 routes, 20 examples) +- Specs for `setup_generated_functions` boot loading in Service (4 examples) + +### Fixed +- Guard `Legion::Transport::Messages::Dynamic` stub definition in integration spec with `unless defined?` to prevent redefinition conflicts when real implementations are present +- Wrap `lex-codegen` and `lex-eval` requires in `LoadError` rescue guards in integration spec; sets `LEGION_CODEGEN_EXTENSION_AVAILABLE` / `LEGION_EVAL_EXTENSION_AVAILABLE` flags and skips entire example group via `before(:all)` when extensions are unavailable +- Move `Legion::LLM.chat` stub to `RSpec.configure before(:each)` block so it always intercepts regardless of whether the real `legion-llm` gem is loaded, preventing external LLM calls in integration tests +- Fix `service_setup_apollo_spec` "starts Apollo::Local" example: stub `Legion::Apollo.start` to prevent internal double-call of `Apollo::Local.start` + +## [1.6.10] - 2026-03-26 + +### Changed +- `ConfigImport.write_config` now splits recognized subsystem keys (`microsoft_teams`, `rbac`, `api`, `logging`, `gaia`, `extensions`, `llm`, `data`, `cache_local`, `cache`, `transport`, `crypt`, `role`) into individual `{key}.json` files +- Remaining unrecognized keys written to `bootstrapped_settings.json` (replaces `imported.json`) +- Subsystem files are always overwritten on bootstrap; remainder file respects `--force` for merge behavior +- `write_config` returns an array of written paths instead of a single path +- `legion bootstrap` and `legion config import` updated to display per-file write confirmations + +## [1.6.9] - 2026-03-26 + +### Added +- `Helpers::Secret` mixin with `SecretAccessor` for per-user and per-lex Vault KV v2 secret access +- Identity resolution chain: Kerberos principal -> Entra UPN -> explicit user -> ENV['USER'] +- `secret[:name]` / `secret[:name] = { ... }` / `secret.write` / `secret.exist?` / `secret.delete` +- `shared: true` option for extension-scoped (non-user) secrets + +## [1.6.8] - 2026-03-26 + +### Added +- `legionio bootstrap SOURCE` command: combines `config import`, `config scaffold`, and `setup agentic` into one command +- Pre-flight checks for klist (Kerberos ticket), brew availability, and legionio binary +- `--skip-packs`, `--start`, `--force`, `--json` flags for bootstrap command +- Self-awareness system prompt enrichment: `Context.to_system_prompt` appends live metacognition self-narrative from `lex-agentic-self` when loaded; guarded with `defined?()` and `rescue StandardError` + +## [1.6.7] - 2026-03-26 + +### Fixed +- `setup_generated_functions` now runs only when `extensions: true` (inside the extensions gate) preventing unexpected boot side-effects in CLI flows that disable extensions +- Consumer tag entropy upgraded from `SecureRandom.hex(4)` (32-bit) to `SecureRandom.uuid` (122-bit) in both `prepare` and `subscribe` paths of subscription actor, eliminating the theoretical RabbitMQ `NOT_ALLOWED` tag collision + +## [1.6.4] - 2026-03-26 + +### Fixed +- fix consumer tag collision on boot: subscription actors using `Thread.current.object_id` produced duplicate tags when `FixedThreadPool` reused threads, causing RabbitMQ `NOT_ALLOWED` connection kill and cascading errors; replaced with `SecureRandom.hex(4)` + +## [1.6.3] - 2026-03-26 + +### Changed +- `legionio update` now uses `gem outdated` instead of custom HTTP client to check rubygems.org +- Remove `concurrent-ruby`, `net/http`, `json` dependencies from update command +- 4 persistent keep-alive connections replaced by single `gem outdated` call (~8s, 100% reliable) + +## [1.6.2] - 2026-03-26 + +### Fixed +- `legionio update` remote check failed for all gems due to TCP connection exhaustion (24 parallel SSL connections to rubygems.org) +- Replace thread pool with 4 batched threads using persistent HTTP keep-alive connections (55 gems in ~4s) + +## [1.6.1] - 2026-03-26 + +### Fixed +- `legionio update` now shows "(remote check failed)" instead of "(already latest)" when rubygems.org fetch fails +- Add HTTP timeouts (5s connect, 10s read) to remote version checks to prevent thread pool exhaustion +- Install failures now show "(install may have failed)" instead of "(already latest)" +- Distinct statuses: current, check_failed, installed, failed (was single ambiguous "updated" for all) + +## [1.6.0] - 2026-03-26 + +### Added +- `legion codegen` CLI subcommand (status, list, show, approve, reject, retry, gaps, cycle) +- `/api/codegen/*` API routes for generated function management +- Boot loading for generated functions via GeneratedRegistry +- Function metadata DSL (function_outputs, function_category, function_tags, function_risk_tier, function_idempotent, function_requires, function_expose) +- ClassMethods for MCP tool exposure (expose_as_mcp_tool, mcp_tool_prefix) +- End-to-end integration test for self-generating functions +- `legion knowledge monitor add/list/remove/status` — multi-directory corpus monitor management +- `legion knowledge capture commit` — capture git commit as knowledge (hook-compatible) +- `legion knowledge capture session` — capture session summary as knowledge (hook-compatible) +- `legion setup claude-code` now installs write-back hooks for automatic knowledge capture +- `resolve_corpus_path` falls back to first registered monitor when no explicit path given + +## [1.5.23] - 2026-03-26 + +### Changed +- Remove all lex-memory references from service.rb, API coldstart, and OpenAPI docs; use lex-agentic-memory namespace everywhere + +## [1.5.22] - 2026-03-26 + +### Fixed +- `coldstart ingest` no longer crashes when lex-memory is absent; uses lex-agentic-memory trace store instead + +### Changed +- Consolidate 48 root CLI commands into 7 groups + 19 root commands +- New groups: `ai`, `git`, `pipeline`, `ops`, `serve`, `admin`, `dev` +- `ai`: chat, llm, gaia, apollo, knowledge, memory, mind-growth, swarm, plan, trace +- `git`: commit, pr, review +- `pipeline`: skill, prompt, eval, dataset, image, notebook +- `ops`: telemetry, observe, detect, cost, payroll, audit, debug, failover +- `serve`: mcp, acp +- `admin`: rbac, auth, worker, team +- `dev`: generate, docs, openapi, completion, marketplace, features +- Root keepers: start, stop, status, version, check, doctor, setup, update, config, init, lex, task, chain, schedule, coldstart, tty, do, ask, dream, tree + +## [1.5.21] - 2026-03-26 + +### Changed +- `legionio setup agentic` now installs the full cognitive stack (63 gems): core libs, all agentic domains, all AI providers, and key operational extensions +- Added `brains` and `give-me-all-the-brains` as aliases for the `agentic` subcommand + +## [1.5.20] - 2026-03-26 + +### Added +- `legion knowledge health` — local/Apollo/sync health report +- `legion knowledge maintain` — orphan chunk detection and cleanup (dry-run by default) +- `legion knowledge quality` — hot/cold/low-confidence chunk quality report + +## [1.5.19] - 2026-03-26 + +### Added +- `legion knowledge` CLI subcommand: query, retrieve, ingest, status (closes #36) + - `legion knowledge query QUESTION` — synthesized LLM answer + ranked source chunks + - `legion knowledge retrieve QUESTION` — raw source chunks without synthesis + - `legion knowledge ingest PATH` — ingest file or directory corpus + - `legion knowledge status` — show corpus file count and size + +## [1.5.18] - 2026-03-25 + +### Added +- `scope:` parameter on `Helpers::Knowledge` (`ingest_knowledge` and `query_knowledge`) +- Scope routing: `:local` -> `Apollo::Local`, `:global` -> `Apollo`, `:all` -> both with local-first dedup +- Default query scope configurable via `Settings[:apollo][:local][:default_query_scope]` +- `setup_apollo` now starts `Apollo::Local` when available + +## [1.5.17] - 2026-03-25 + +### Added +- `Helpers::Knowledge` — universal `ingest_knowledge` and `query_knowledge` mixin for all extensions; included automatically in `Extensions::Core` +- Automatic file extraction via `Legion::Data::Extract` when a file path is passed to `ingest_knowledge` +- Graceful degradation when `Legion::Apollo` or `Legion::Data::Extract` are not available +- `setup_apollo` in `Service` boot sequence (between LLM and GAIA); wires `Legion::Apollo.start` with `LoadError`/`StandardError` rescue +- `:apollo` added to `Readiness::COMPONENTS` between `:llm` and `:gaia` +- `legion-apollo >= 0.2.1` dependency in gemspec +- `Helpers::LLM#llm_embed` in LegionIO now forwards all keyword arguments (`provider:`, `dimensions:`, etc.) via anonymous `**` forwarding + +## [1.5.15] - 2026-03-25 + +### Removed +- CLI chat Apollo writeback prototype (replaced by pipeline step 19 in legion-llm) + +## [1.5.14] - 2026-03-25 + +### Fixed +- Shutdown no longer hangs when network is unreachable — all component shutdowns wrapped in bounded timeouts via `shutdown_component` helper (#30) +- Reload path also wrapped with same timeout guards to prevent hangs during network-triggered reload (#30) + +### Added +- Network watchdog: background `Concurrent::TimerTask` monitors transport/data/cache connectivity, pauses actors after sustained failures, triggers `Legion.reload` when network restores (#30) +- `Legion::Extensions.pause_actors` suspends all `Every` timer tasks without destroying instances (#30) +- Watchdog is feature-flagged via `network.watchdog.enabled` (default: false), configurable threshold and interval (#30) + +## [1.5.13] - 2026-03-25 + +### Fixed +- API startup no longer spams Puma banner on port conflict — pre-checks port with lightweight TCP probe before attempting Puma boot +- Reduced API bind retries from 10 to 3 (6s total vs 30s) so boot completes quickly when port is occupied +- Daemon remains fully functional (shutdown, Ctrl+C) even when API fails to bind + +## [1.5.12] - 2026-03-25 + +### Added +- `GET /api/stats` endpoint — comprehensive daemon runtime stats: extensions (loaded/actor counts), gaia (status/channels/phases), transport (session/channels), cache/cache_local (pool stats), llm (provider health/routing), data/data_local (pool/tuning via legion-data stats), api (puma threads/routes) + +### Changed +- Bumped gemspec dependency: legion-data >= 1.6.0 (required for `Legion::Data.stats`) + +## [1.5.11] - 2026-03-25 + +### Added +- `legionio debug` command — full diagnostic dump (16 sections: versions, doctor, config, gems, extensions, RBAC, LLM, GAIA, transport, events, Apollo, remote/local Redis, PostgreSQL, RabbitMQ, API health) output as markdown or JSON, suitable for piping to an LLM session +- `legionio update --cleanup` flag — removes old gem versions after update via `Gem::Uninstaller` (default: no cleanup) + +### Fixed +- `update_command.rb` `snapshot_versions` now uses `find_all_by_name` + max version instead of `find_by_name`, which returned the already-activated (potentially stale) gem version +- `service.rb` `setup_api` guard prevents duplicate Puma start when `@api_thread` is already alive + +### Changed +- Bumped gemspec dependencies: legion-data >= 1.5.3, legion-gaia >= 0.9.24, legion-llm >= 0.5.8, legion-tty >= 0.4.35 + +## [1.5.10] - 2026-03-25 + +### Changed +- Guard bootsnap behind `LEGION_BOOTSNAP=true` env var in `exe/legion` and `exe/legionio`, default to disabled +- Bootsnap also requires `~/.legionio` to exist (prevents premature directory creation on first run) + +## [1.5.9] - 2026-03-25 + +### Fixed +- `Subscription#activate` nil guard — skip activate when `@consumer` is nil (prepare failed silently) +- `Extensions#shutdown` tracks real actor instances in `@running_instances`, cancels them with deadline-based drain +- `Extensions::Helpers::Base` runner_class derivation improvements for self-contained actors + +### Changed +- Bumped gemspec dependencies: legion-cache >= 1.3.16, legion-settings >= 1.3.19, legion-transport >= 1.4.0, legion-mcp >= 0.5.1 + +## [1.5.8] - 2026-03-24 + +### Added +- `Legion::Compliance::PhiTag` — PHI data classification tagging with `phi?`, `tag`, `tagged_cache_key` methods; gated by `compliance.phi_enabled` setting +- `Legion::Compliance::PhiAccessLog` — PHI access audit bridge that calls `Legion::Audit.record` with `event_type: 'phi_access'`; gated by `compliance.phi_enabled` setting +- `Legion::Compliance::PhiErasure` — orchestrates cryptographic erasure via `Legion::Crypt::Erasure`, cache key purge, access log, and verification; all steps guarded by `defined?` checks + +## [1.5.7] - 2026-03-24 + +### Added +- `Legion::Audit::Archiver` — tiered hot/warm/cold audit retention orchestrator; delegates hot→warm to `Legion::Data::Retention`, exports warm→cold as compressed JSONL via `ColdStorage`, records manifests, verifies hash chain after each run +- `Legion::Audit::ColdStorage` — upload/download abstraction with `:local` (filesystem) and `:s3` (aws-sdk-s3, optional) backends; raises `BackendNotAvailableError` when aws-sdk-s3 not installed +- `Legion::Audit::ArchiverActor` — thread-based weekly scheduled actor with hour/day-of-week cron guard; started by `Service#setup_audit_archiver` after telemetry +- `legion audit archive --dry-run / --execute` — preview or execute tiered archival from CLI +- `legion audit verify_chain --tier --start --end` — direct hash chain integrity check for hot or warm tier +- `legion audit restore --date` — restore cold JSONL archives back to warm tier for querying +- Feature flag: `audit.retention.enabled` (default `false`); settings: `hot_days`, `warm_days`, `cold_years`, `cold_storage`, `cold_backend`, `archive_schedule`, `verify_on_archive` + +### Changed +- `Legion::Service` starts `CertRotation` after `Crypt.start` when `security.mtls.enabled: true` +- `Legion::Service#shutdown` stops `CertRotation` before `Crypt.shutdown` +- `setup_mtls_rotation` gracefully handles missing mtls support in older `legion-crypt` versions via `LoadError` rescue + +## [1.5.6] - 2026-03-24 + +### Changed +- `Service#register_logging_hooks` uses dedicated `log_channel` from `Connection` instead of shared channel; passes `channel:` to `Exchanges::Logging` to avoid contention +- `Service#reload` re-setup sequence now includes `register_logging_hooks`, cache re-setup, and guarded `setup_rbac`/`setup_llm`/`setup_gaia` calls +- `Readiness::COMPONENTS` expanded with `:rbac` and `:llm` for accurate startup tracking +- LLM and GAIA boot blocks gated so `mark_ready` only fires on success path +- `Cache` and `Data` boot blocks wrap remote failures with graceful fallback to local adapters + +## [1.5.5] - 2026-03-24 + +### Added +- `Legion::Service#setup_api`: optional Puma TLS via `api.tls.enabled` feature flag (default false); falls back to plain HTTP if cert/key missing +- `Legion::CLI::Doctor::TlsCheck`: `legion doctor` check for TLS configuration across all components (transport, data, api) +- `config/tls/settings-tls.json`: complete TLS settings template for all components +- `config/tls/generate-certs.sh`: dev self-signed CA + server/client cert generator +- `config/tls/README.md`: TLS setup and validation instructions + +## [1.5.4] - 2026-03-24 + +### Added +- `Cluster::Leader` wired into `Service` boot behind `cluster.leader_election` feature flag (default: off) +- `Actors::Singleton` upgraded to dual-backend (Redis + PG advisory locks via `Cluster::Lock`) +- `Singleton` gating controlled by `cluster.singleton_enabled` feature flag (default: off — every node runs, no behavior change) +- `Cluster::Lock.extend_lock` method (Redis: Lua TTL extend; PG: always true; none: false) +- `Singleton` mixin added to lex-health watchdog and lex-metering cleanup/cost_optimizer actors + +### Changed +- `@cluster_leader.stop` called on `Service#shutdown` (before extensions shutdown) + +## [1.5.3] - 2026-03-24 + +### Added +- Extinction escalation verification in lifecycle integration tests (stub_const approach) +- De-escalation on worker resume: `transition!` calls `Client#deescalate` when extinction level decreases +- Credential revocation on worker termination: calls `VaultSecrets.delete_client_secret` guarded by `defined?` +- Ownership transfer integration tests with event and audit verification +- Retirement cycle integration tests with full audit chain and extinction L3/L4 coverage + +## [1.5.2] - 2026-03-24 + +### Fixed +- `check_cache_local` in CLI now reads display values from `Legion::Settings[:cache_local]` instead of static code defaults + +## [1.5.1] - 2026-03-24 + +### Added +- Wire lex-extinction into digital worker lifecycle transitions +- `EXTINCTION_MAPPING` maps lifecycle states to containment levels (0-4) +- Guarded `Client#escalate` call during `transition!` when containment level increases + +## [1.5.0] - 2026-03-24 + +### Added +- `legion setup agentic` — install full cognitive stack (legion-gaia + legion-llm + all transitive deps) in one command +- `legion setup llm` — install LLM routing only +- `legion setup channels` — install channel adapters (lex-slack, lex-microsoft_teams) +- `legion setup packs` — show installed/missing feature packs +- `--dry-run` flag on all pack install commands +- `legion detect` now recommends `legion setup agentic` when legion-gaia or legion-llm are missing +- `legionio version --full` displays all installed lex-* extension versions +- `legionio version` now lists all 13 legion-* gems with `(not installed)` for missing ones + +### Changed +- Overhaul `legionio check` with proper namespace labels (Legion::Settings, Legion::Transport, etc.) +- Each check returns connection detail strings (config dir, amqp:// URL, driver -> servers, adapter -> host:port/db) +- Add Legion::Cache::Local and Legion::Data::Local checks with dependency chaining +- Fix dependency skip logic to cascade through transitive dependencies (skip-on-skip, not just skip-on-fail) +- Add privacy mode sub-check (`legionio check --privacy`) +- Comment out Bootsnap.setup in exe/legion (matching exe/legionio) +- Bump gemspec minimum: legion-data >= 1.5.0 + +### Fixed +- Runner log output now tagged with extension name (e.g. `[mesh][Runner]` instead of bare `[Runner]`) +- Extension Transport and Routes builders use tagged `log` helper instead of bare `Legion::Logging` +- Runner.run now sets `status = 'task.exception'` before calling `handle_exception`, preventing null function/result in CheckSubtask messages when handle_exception raises + +## [1.4.198] - 2026-03-24 + +### Changed +- Comment out Bootsnap.setup in exe/legion (matching exe/legionio) + +## [1.4.198] - 2026-03-24 + +### Changed +- Bump gemspec minimum: legion-transport >= 1.3.11 (InProcess adapter, shutdown hang fix, Helper mixin) +- Bump gemspec minimum: legion-tty >= 0.4.34 (latest fixes) + +## [1.4.197] - 2026-03-24 + +### Changed +- Add debug logging to 8 swallowed `rescue StandardError` blocks in chat tools and session store: ModelComparison, SystemStatus (fetch_health, fetch_ready), SessionStore (generate_summary, read_session_meta), SaveMemory (ingest_to_apollo), GenerateInsights (scheduling_status, llm_status) + +## [1.4.196] - 2026-03-24 + +### Added +- LLM fallback in `legion do` command: when keyword matching (`find_by_intent`) returns no results, classifies intent via `Legion::LLM.ask` against the full Capability Registry catalog +- Graceful degradation: LLM path only activates when both `Legion::LLM` and `Catalog::Registry` are loaded; errors fall through silently + +## [1.4.195] - 2026-03-24 + +### Added +- `legion do "TEXT"` CLI command: natural language intent router that matches free-text to Capability Registry entries and dispatches via daemon API or in-process Ingress +- `DoCommand` module with two resolution paths: daemon HTTP dispatch (like `dream`) and in-process `Registry.find_by_intent` + `Ingress.run` fallback + +## [1.4.194] - 2026-03-24 + +### Added +- `--lite` flag on `legion start` command: sets `LEGION_MODE=lite` and `LEGION_LOCAL=true` env vars, assigns `:lite` process role +- `:lite` process role in `ProcessRole::ROLES`: all subsystems enabled except `crypt: false` (Vault not needed in lite mode) +- `Service#lite_mode?` checks `LEGION_MODE` env var and `settings[:mode]` +- `setup_local_mode` handles lite mode: sets dev flag, loads Transport::Local, loads mock_vault if Crypt is defined + +## [1.4.193] - 2026-03-24 + +### Added +- `legion mind-growth` CLI subcommand with 10 commands: status, propose, approve, reject, build, proposals, profile, health, report, history +- Delegates to `Legion::Extensions::MindGrowth::Client` (lex-mind-growth extension) +- Guards with `require_mind_growth!` — raises `CLI::Error` when extension is not loaded +- Supports `--json` and `--no-color` class options on all subcommands + +## [1.4.192] - 2026-03-24 + +### Fixed +- fix `undefined method 'key?' for module Legion::Settings` in extension loader — use `Legion::Settings[:llm].nil?` instead of `.key?(:llm)` since Settings is a module with `[]` accessor, not a Hash + +## [1.4.191] - 2026-03-23 + +### Changed +- Add `caller: { source: 'cli', command: 'chat' }` to `Legion::LLM.chat` call in `CLI::ChatCommand#create_chat`, completing Wave 5 consumer migration + +## [1.4.190] - 2026-03-23 + +### Changed +- Migrate `Guardrails::RAGRelevancy` to use `Legion::LLM.chat` (public API) instead of the private `chat_single` method +- Add `Guardrails::SYSTEM_CALLER` constant with system pipeline identity to prevent infinite recursion when guardrails calls the LLM through the pipeline +- The `:system` profile skips governance steps (rbac, classification, billing, gaia_advisory, rag_context, context_load) — guardrails is internal infrastructure, not a user request +- Add specs covering `SYSTEM_CALLER` structure and LLM call behavior in `RAGRelevancy` + +## [1.4.189] - 2026-03-23 + +### Changed +- Add `caller:` identity to all LLM calls in API, CLI, extensions, and internal modules + - `API::Routes::Llm` sync path: `caller: { source: 'api', path: request.path }` + - `API::Routes::Prompts`: `caller: { source: 'api', endpoint: 'prompts' }` + - `CLI::Commit`, `CLI::Pr`, `CLI::Review`, `CLI::Prompt`, `CLI::Image`: `caller: { source: 'cli', command: '' }` + - `Notebook::Generator`: `caller: { source: 'cli', command: 'notebook' }` + - `TraceSearch`: `caller: { source: 'cli', command: 'trace' }` + - `Extensions` inline LLM runners: `caller: { source: 'extension', command: 'llm_runner' }` + +## [1.4.188] - 2026-03-23 + +### Changed +- Bump legion-mcp dependency to >= 0.5.1 +- Bump legion-data dependency to >= 1.4.19 + +## [1.4.187] - 2026-03-23 + +### Added +- `Legion::Extensions::Capability` Data.define struct for extension capability registration +- `Legion::Extensions::Catalog::Registry` in-memory capability registry with register, find, find_by_intent, for_mcp, for_override, find_by_mcp_name +- `register_capabilities` populates Catalog::Registry from extension runners at boot +- `unregister_capabilities` removes capabilities from Catalog on extension unload +- `Catalog::Registry.on_change` callback for notifying consumers on registry changes + +## [1.4.186] - 2026-03-23 + +### Fixed +- `CLI::Connection#resolve_config_dir` expands tilde in user-provided `config_dir` before existence check (#25) +- `.github/CODEOWNERS` combined duplicate `*` patterns so both teams are applied (#25) + +### Added +- `Service#setup_settings` spec coverage for canonical directory filtering (#25) +- `CLI::Connection` spec for tilde expansion in `config_dir` (#25) + +## [1.4.185] - 2026-03-23 + +### Fixed +- Restrict settings search paths to canonical directories (`~/.legionio/settings`, `/etc/legionio/settings`) (#25) +- Remove broken/dead paths from `Service#default_paths` (`~/legionio`, `$home/legionio`, `./settings`) +- `CLI::Connection#resolve_config_dir` now delegates to `Loader.default_directories` instead of hardcoded list +- Add `legion-settings` local path to Gemfile for development + +### Changed +- `Service#setup_settings` loads all matching directories via `config_dirs:` instead of first-match-wins + +## [1.4.184] - 2026-03-23 + +### Added +- MemoryStatus chat tool: shows persistent memory entries, Apollo knowledge store stats, and saved session overview +- Supports "overview", "memories", "apollo", and "sessions" actions +- 40th built-in chat tool registered in ToolRegistry + +## [1.4.183] - 2026-03-23 + +### Added +- ContextManager: conversation context window management with dedup, compression, and summarization strategies +- `/compact [strategy]` now supports auto, dedup, and summarize strategies (was LLM-only) +- `/context` slash command shows message count, estimated tokens, and auto-compact status +- Integrates with Legion::LLM::Compressor for Jaccard deduplication and stopword compression + +## [1.4.182] - 2026-03-23 + +### Changed +- GenerateInsights now includes Apollo graph topology, LLM scheduling status, escalation count, and shadow eval count +- Insights report provides a more comprehensive system overview + +## [1.4.181] - 2026-03-23 + +### Added +- SchedulingStatus chat tool: view LLM peak/off-peak scheduling and batch queue state +- Supports "overview", "scheduling" (detail), and "batch" (queue detail) actions +- 39th built-in chat tool registered in ToolRegistry + +## [1.4.180] - 2026-03-23 + +### Added +- GraphExplore chat tool: explore Apollo knowledge graph topology, agent expertise, and disputed entries +- Apollo API endpoints: GET /api/apollo/graph (topology) and GET /api/apollo/expertise (expertise map) +- 38th built-in chat tool registered in ToolRegistry + +## [1.4.179] - 2026-03-23 + +### Added +- EscalationStatus chat tool: show model escalation history and upgrade frequency +- Supports "summary" (by reason, target model, recent entries) and "rate" (escalation frequency) actions +- 37th built-in chat tool registered in ToolRegistry + +## [1.4.178] - 2026-03-23 + +### Added +- ArbitrageStatus chat tool: view LLM cost arbitrage table, cheapest model per capability tier +- Supports overview mode (full cost table + tier picks) and per-tier detail mode +- 36th built-in chat tool registered in ToolRegistry + +## [1.4.177] - 2026-03-23 + +### Added +- EntityExtract chat tool: extract named entities (people, services, repos, concepts) from text via Apollo +- Supports entity type filtering and configurable confidence thresholds +- Groups results by type with confidence percentages +- 35th built-in chat tool registered in ToolRegistry + +## [1.4.176] - 2026-03-23 + +### Added +- ShadowEvalStatus chat tool: view shadow evaluation results comparing primary vs cheaper models +- Supports "summary" (cost savings, length ratios) and "history" (recent comparisons) actions +- 34th built-in chat tool registered in ToolRegistry + +## [1.4.175] - 2026-03-23 + +### Added +- ModelComparison chat tool: compare LLM model pricing side-by-side with cost projections +- Supports filtering by model name, custom token count estimates, and price ratio analysis +- Uses CostTracker pricing when available, falls back to built-in defaults +- 33rd built-in chat tool registered in ToolRegistry + +## [1.4.174] - 2026-03-23 + +### Added +- REST API endpoints for LLM provider health: GET /api/llm/providers and GET /api/llm/providers/:name +- Returns circuit breaker state, health status, routing adjustments, and circuit summary +- 4 new specs covering gateway unavailable, health report, and single provider detail + +## [1.4.173] - 2026-03-23 + +### Added +- ProviderHealth chat tool: displays LLM provider circuit breaker state, health status, and routing adjustments +- Supports all-provider report and single-provider detail views +- 33rd built-in chat tool registered in ToolRegistry + +## [1.4.172] - 2026-03-23 + +### Added +- BudgetStatus chat tool: shows session cost budget status, spending, remaining, and per-model breakdown +- Works locally via in-memory CostTracker (no daemon required) +- Supports "status" and "summary" actions +- 32nd built-in chat tool registered in ToolRegistry + +## [1.4.171] - 2026-03-23 + +### Added +- SearchTraces chat tool: natural language search across cognitive memory traces (people, conversations, meetings) +- Person name variant matching, fuzzy search, keyword ranking, and structured field extraction +- 11th built-in chat tool registered in ToolRegistry + +## [1.4.170] - 2026-03-23 + +### Added +- Costs REST API: GET /api/costs/summary, /api/costs/workers, /api/costs/extensions +- Aggregates metering_records cost_usd by time period, worker, and extension +- 8 specs with in-memory SQLite for realistic query testing + +## [1.4.169] - 2026-03-23 + +### Fixed +- TraceSearch column name mismatches: `created_at` to `recorded_at`, `tokens_in` to `input_tokens`, `tokens_out` to `output_tokens` to match metering_records schema +- SCHEMA_TEMPLATE and ALLOWED_COLUMNS now reference correct database column names + +## [1.4.168] - 2026-03-23 + +### Added +- GenerateInsights chat tool: combines anomaly detection, trends, Apollo stats, and worker health into actionable report +- Automatic recommendations based on detected anomalies and trend patterns +- 7 specs covering comprehensive report generation, anomaly details, recommendations, and error handling +- Chat tool registry now has 30 built-in tools + +## [1.4.167] - 2026-03-23 + +### Added +- TriggerDream chat tool: trigger dream cycles on daemon and view latest dream journal entries +- Searches gem path, project, and user directories for dream journal markdown files +- 6 specs covering trigger, journal, error handling, truncation, and connection refused +- Chat tool registry now has 29 built-in tools + +## [1.4.166] - 2026-03-23 + +### Added +- ViewTrends chat tool: tabular trend visualization with direction indicators (rising/falling/stable) +- Shows cost, latency, volume, and failure rate trends over configurable time ranges +- 6 specs covering trend formatting, direction labels, empty data, API errors, and connection handling +- Chat tool registry now has 28 built-in tools + +## [1.4.165] - 2026-03-23 + +### Added +- TraceSearch.trend: time-bucketed metrics trend analysis over configurable time ranges +- GET /api/traces/trend endpoint with hours and buckets parameters +- 7 new specs covering trend data structure, bucket contents, defaults, and API endpoint + +## [1.4.164] - 2026-03-23 + +### Added +- DetectAnomalies chat tool: proactive anomaly detection via daemon API with configurable threshold +- Reports cost spikes, latency increases, and failure rate changes with severity levels +- 6 specs covering healthy system, anomaly detection, custom threshold, API errors, connection refused, and singular grammar +- Chat tool registry now has 27 built-in tools + +## [1.4.163] - 2026-03-23 + +### Added +- Traces REST API: POST /api/traces/search, POST /api/traces/summary, GET /api/traces/anomalies +- require_trace_search! API helper guards routes when LLM subsystem is unavailable +- SearchTraces chat tool for natural language memory trace search via lex-agentic-memory +- 10 new API specs covering all trace endpoints with availability guards and parameter handling + +## [1.4.162] - 2026-03-23 + +### Added +- TraceSearch.detect_anomalies: compares last-hour metrics against 24h baseline to detect cost, latency, and failure rate spikes +- Anomaly detection uses configurable threshold (default 2x) with severity levels (warning/critical) +- 4 new TraceSearch specs covering anomaly report structure, cost spike detection, normal metrics, and zero baseline handling + +## [1.4.161] - 2026-03-23 + +### Added +- WorkerStatus chat tool: list digital workers, show details, and health summary via daemon API +- WorkerStatus spec with 7 examples covering list, filter, show, health, empty state, and connection errors +- Chat tool registry now has 26 built-in tools + +## [1.4.160] - 2026-03-23 + +### Added +- ManageSchedules chat tool: list, show, logs, and create scheduled tasks via daemon API +- ManageSchedules spec with 10 examples covering all actions, validation, empty states, and connection errors +- Chat tool registry now has 25 built-in tools + +## [1.4.159] - 2026-03-23 + +### Added +- Reflect chat tool: extracts key learnings from conversation text using LLM, ingests into Apollo knowledge graph and project memory +- Reflect spec with 5 examples covering raw text ingest, LLM extraction, Apollo-down fallback, no entries, and domain passthrough +- Chat tool registry now has 24 built-in tools + +## [1.4.158] - 2026-03-23 + +### Added +- CostSummary chat tool: query cost/token usage from daemon (summary, top consumers, per-worker) +- CostSummary spec with 7 examples covering summary, top, worker, missing worker_id, empty workers, daemon down, API errors +- Chat tool registry now has 23 built-in tools + +## [1.4.157] - 2026-03-23 + +### Added +- ViewEvents chat tool: view recent events from the Legion event bus ring buffer with count control +- ViewEvents spec with 7 examples covering formatted output, empty events, count parameter, clamping, connection refused, API errors, and events without details +- Chat tool registry now has 22 built-in tools + +## [1.4.156] - 2026-03-23 + +### Changed +- Session store now saves summary (first user message, truncated to 120 chars), message count, and model in session metadata +- Session list includes summary, message_count, and model for at-a-glance session browsing +- 4 new session_store specs covering message count, summary generation, long summary truncation, and list metadata + +## [1.4.155] - 2026-03-23 + +### Changed +- SaveMemory tool now auto-ingests entries into Apollo knowledge graph when daemon is running +- Apollo ingest includes type (memory), source (chat:project/global), and tags for categorization +- Updated save_memory specs with 6 examples covering apollo integration, confirmation, and fallback + +## [1.4.154] - 2026-03-23 + +### Changed +- SearchMemory tool now also queries Apollo knowledge graph when available, combining file-based memory with semantic knowledge +- Apollo results include type, content, and confidence score for richer context retrieval +- Updated search_memory specs with 6 examples covering combined memory+apollo, apollo-only, memory-only, and error handling + +## [1.4.153] - 2026-03-23 + +### Changed +- TraceSearch schema context now injects current date/time dynamically for accurate time-relative queries +- Added guidance for "today", "last hour", "this week", "yesterday" relative time references in LLM prompt +- 2 new trace_search specs covering schema_context current date injection and relative time guidance + +## [1.4.152] - 2026-03-23 + +### Added +- Daemon awareness in chat context: system prompt now includes running daemon version and port when healthy +- daemon_hint method probes /api/health with 1-second timeout for non-blocking detection +- 5 new context specs covering daemon hint and cognitive awareness with daemon running + +## [1.4.151] - 2026-03-23 + +### Added +- SystemStatus chat tool: check daemon health, component readiness, uptime, version, and extension count from chat +- SystemStatus spec with 6 examples covering full status, daemon down, endpoints failing, uptime formatting, and empty components +- Chat tool registry now has 21 built-in tools + +## [1.4.150] - 2026-03-23 + +### Added +- ManageTasks chat tool: list, show, logs, and trigger tasks through the Legion Ingress pipeline with metering data display +- ManageTasks spec with 15 examples covering list/show/logs/trigger actions, validation, filters, payload forwarding, and error handling +- Chat tool registry now has 20 built-in tools + +## [1.4.149] - 2026-03-23 + +### Added +- ListExtensions chat tool: discover loaded extensions and their runners/functions via REST API with active filtering and detail views +- ListExtensions spec with 7 examples covering list, empty, active_only filter, detail with runners, no runners, connection refused, and API errors +- Chat tool registry now has 19 built-in tools + +## [1.4.148] - 2026-03-23 + +### Added +- Cognitive awareness in chat context: system prompt now includes memory entry counts and Apollo knowledge graph status when available +- Context cognitive_awareness, memory_hint, and apollo_hint methods with 1-second timeout for non-blocking probes +- 8 new context specs covering cognitive awareness, memory hints, and apollo availability detection + +## [1.4.147] - 2026-03-23 + +### Added +- SummarizeTraces chat tool: aggregate metering database analytics with token usage, cost, latency, status breakdown, and top extensions/workers via natural language queries +- SummarizeTraces spec with 5 examples covering formatted output, error handling, empty data, and unavailable dependencies +- Chat tool registry now has 18 built-in tools + +## [1.4.146] - 2026-03-23 + +### Added +- KnowledgeStats chat tool: inspect Apollo knowledge graph health with entry counts, status/type breakdowns, recent activity, and average confidence +- KnowledgeStats spec with 5 examples covering formatted output, empty breakdowns, API errors, connection refused, and missing fields +- Chat tool registry now has 17 built-in tools + +## [1.4.145] - 2026-03-23 + +### Added +- KnowledgeMaintenance chat tool: trigger Apollo knowledge graph decay cycles and corroboration checks from chat sessions +- KnowledgeMaintenance spec with 8 examples covering decay, corroboration, invalid actions, API errors, and edge cases +- Chat tool registry now has 16 built-in tools + +## [1.4.144] - 2026-03-23 + +### Added +- RelateKnowledge chat tool: find related entries in the Apollo knowledge graph with depth traversal, relation type filtering, and confidence scoring +- RelateKnowledge spec with 7 examples covering formatted results, empty results, API errors, connection refused, depth clamping, and relation type passthrough +- SearchTraces chat tool registered in tool registry (15 built-in tools) + +## [1.4.143] - 2026-03-23 + +### Added +- TraceSearch.summarize: aggregate statistics for trace queries (total cost, tokens, latency, status breakdown, top extensions/workers) +- `legion trace summarize` CLI subcommand with formatted output and JSON mode +- Trace command spec expanded with 8 summarize examples + +## [1.4.142] - 2026-03-23 + +### Added +- ConsolidateMemory chat tool: LLM-powered memory consolidation that deduplicates, merges related entries, and cleans up cluttered memory files with dry-run preview support +- ConsolidateMemory spec with 10 examples covering consolidation, dry-run, global scope, LLM unavailable, and error handling +- Task command spec with 13 examples covering list, show, logs, purge, and helper methods +- Chain command spec with 6 examples covering list, create, delete, and confirmation flow +- Generate command spec with 14 examples covering runner, actor, exchange, queue, message, and tool scaffolding +- Audit command spec with 6 examples covering list filters, JSON output, and chain verification +- RBAC command spec with 9 examples covering roles, show, assignments, assign, revoke, and access check + +## [1.4.141] - 2026-03-23 + +### Added +- IngestKnowledge chat tool: save facts, observations, concepts, procedures, and decisions to the Apollo knowledge graph from within chat sessions +- IngestKnowledge spec with 9 examples covering success, content types, tags, API errors, and daemon unavailability +- Extension tool loader spec with 13 examples covering discovery, permission tiers, and tool collection +- Skill command spec with 14 examples covering list, show, create, and run +- Swarm command spec with 16 examples covering list, show, start, and pipeline failures +- Graph command, builder, and exporter specs with 37 examples covering mermaid/dot rendering, filters, and empty graphs +- Cost command spec with 16 examples covering summary, worker, top, and export + +## [1.4.140] - 2026-03-23 + +### Added +- SearchTraces chat tool: search cognitive memory traces for Teams messages, conversations, meetings, and people with keyword ranking, person name variants, and fuzzy matching +- SearchTraces spec with 15 examples covering keyword search, person/domain/type filters, payload parsing, age formatting, and limit clamping +- TraceSearch spec expanded from 8 to 20 examples: `.search` entry point, `.apply_date_filters`, `.apply_ordering` (ascending/descending), `.safe_parse_time` edge cases, `FILTER_SCHEMA` properties + +## [1.4.139] - 2026-03-23 + +### Added +- Gaia API: `GET /api/gaia/channels`, `GET /api/gaia/buffer`, `GET /api/gaia/sessions` endpoints +- Gaia CLI: `legion gaia channels`, `legion gaia buffer`, `legion gaia sessions` subcommands +- Gaia spec coverage expanded from 12 to 25 examples + +## [1.4.138] - 2026-03-23 + +### Added +- QueryKnowledge chat tool: query Apollo knowledge graph from chat sessions for facts, concepts, and observations +- QueryKnowledge spec with 11 examples covering results, errors, filters, and limit clamping + +## [1.4.137] - 2026-03-23 + +### Changed +- Rewrite `legion trace search` with formatter support, JSON mode, truncation display, detailed output (cost, tokens, wall clock, worker) +- Register trace subcommand in main CLI (`legion trace search QUERY`) + +### Added +- Trace command spec with 13 examples covering all output paths + +## [1.4.136] - 2026-03-23 + +### Added +- Apollo CLI command: `legion apollo status`, `stats`, `query`, `ingest`, `maintain` subcommands +- SearchTraces chat tool for natural language trace search within chat sessions + +## [1.4.135] - 2026-03-23 + +### Added +- Apollo maintenance endpoint: `POST /api/apollo/maintenance` triggers decay_cycle or corroboration check +- Apollo maintenance in OpenAPI spec with action validation + +## [1.4.134] - 2026-03-23 + +### Added +- Apollo stats endpoint: `GET /api/apollo/stats` returns entry counts by status, content type, 24h activity, and average confidence +- Apollo stats in OpenAPI spec + +## [1.4.133] - 2026-03-23 + +### Changed +- TraceSearch: add safe date coercion via `Time.parse` with fallback for unparseable LLM-generated date strings +- TraceSearch: add `total` and `truncated` fields to response when results exceed limit +- Extract `apply_date_filters`, `safe_parse_time`, and `apply_ordering` helpers from `execute_filter` + +## [1.4.132] - 2026-03-23 + +### Added +- Apollo knowledge graph REST API: status, query, ingest, and related entries endpoints +- Apollo API spec with 11 examples covering all routes and parameter passing + +## [1.4.131] - 2026-03-23 + +### Changed +- Add logging to Every actor tick cycle and Subscription actor message processing +- Add logging to actor builder discovery +- Register SearchTraces tool with LLM ToolRegistry via API llm routes +- Comment out bootsnap setup in legionio executable for local development + +## [1.4.130] - 2026-03-22 + +### Changed +- `Extensions::Helpers::Data` now delegates to `Legion::Data::Helper` from legion-data gem +- `Extensions::Helpers::Transport` now delegates to `Legion::Transport::Helper` from legion-transport gem +- `Extensions::Helpers::Lex` now includes `Legion::JSON::Helper` for `json_load`/`json_dump` convenience methods +- Require legion-data >= 1.4.17, legion-json >= 1.2.1, legion-transport >= 1.3.9 + +## [1.4.129] - 2026-03-22 + +### Added +- SearchTraces chat tool for querying cognitive memory traces (Teams messages, conversations, meetings, people) +- Keyword-ranked search with person, domain, and trace type filtering +- Structured output formatting with age, strength, and domain tag metadata + +## [1.4.128] - 2026-03-22 + +### Changed +- `Extensions::Helpers::Cache` now delegates to `Legion::Cache::Helper` from legion-cache gem +- Require legion-cache >= 1.3.11 and legion-crypt >= 1.4.9 + +## [1.4.127] - 2026-03-22 + +### Changed +- `Extensions::Helpers::Core` now delegates `settings` to `Legion::Settings::Helper` from legion-settings gem +- Require legion-settings >= 1.3.14 for the new Helper module + +## [1.4.126] - 2026-03-22 + +### Changed +- `Extensions::Helpers::Logger` now delegates `log` to `Legion::Logging::Helper` from legion-logging gem +- Require legion-logging >= 1.3.2 for the new Helper module + +## [1.4.125] - 2026-03-22 + +### Changed +- Parallelize update command version checks using RubyGems HTTP API and concurrent-ruby thread pool +- Skip `gem install` entirely when all gems are already at latest version +- Only install gems that are actually outdated instead of reinstalling all gems + +## [1.4.124] - 2026-03-22 + +### Changed +- Update gemspec dependency version constraints for all legion-* gems to match current releases + +## [1.4.123] - 2026-03-22 + +### Changed +- Add logging to silent rescue blocks: all rescue blocks now capture the exception variable and emit `Legion::Logging.debug` or `.warn` calls so errors are visible in logs rather than silently swallowed + +## [1.4.122] - 2026-03-22 + +### Added +- GraphQL API via `graphql-ruby` gem: `POST /api/graphql` endpoint alongside existing REST API +- Schema types: QueryType, WorkerType, TaskType, ExtensionType, TeamType with field-level resolvers +- Resolver modules for workers, tasks, extensions, and teams (safe stubs with `defined?` guards) +- 45 new specs for GraphQL schema, queries, and error handling + +## [1.4.121] - 2026-03-22 + +### Added +- Route `/api/llm/chat` through full Legion pipeline (Ingress -> RBAC -> Events -> Task -> Gateway metering -> LLM) when `lex-llm-gateway` is loaded +- `gateway_available?` helper to detect gateway runner presence +- Proper result extraction from `ingress_result[:result]` with support for RubyLLM response objects, error hashes, and plain strings +- Error logging in async LLM rescue block (previously silent) + +## [1.4.120] - 2026-03-22 + +### Added +- Comprehensive logging throughout the framework: 55 files, 443 lines of `.info`, `.warn`, `.error`, `.debug` calls +- API routes: every non-2xx response logs at warn (4xx) or error (5xx), every mutation logs at info, debug for request entry +- Core framework: ingress, runner, extensions, actors, service lifecycle, readiness, events all log state transitions +- Extension system: autobuild, actor hooking, transport setup, builder phases all log at debug/info +- Digital worker lifecycle, capacity model, catalog, guardrails, webhooks, alerts, audit, telemetry all instrumented +- CLI error handler logs matched patterns (warn) and unhandled errors (error) + +## [1.4.119] - 2026-03-22 + +### Added +- `legion setup claude-code` installs Legion MCP server entry into `~/.claude/settings.json` and writes the `/legion` slash command skill to `~/.claude/commands/legion.md` +- `legion setup cursor` installs Legion MCP server entry into `.cursor/mcp.json` in the current project directory +- `legion setup vscode` installs Legion MCP server entry into `.vscode/mcp.json` using the VS Code stdio server format +- `legion setup status` shows which platforms (Claude Code, Cursor, VS Code) have Legion MCP configured +- All `legion setup` subcommands support `--force` to overwrite existing entries and `--json` for machine-readable output +- MCP installs merge with existing server configs rather than overwriting unrelated entries + +## [1.4.118] - 2026-03-22 + +### Added +- `legion detect --install` interactive extension picker: multi-select via tty-prompt (when available) or numbered list fallback +- `legion detect --install-all` for non-interactive bulk install of all missing extensions +- Signal context shown in picker (e.g., which app/formula triggered the recommendation) + +## [1.4.117] - 2026-03-22 + +### Added +- `Legion::CLI::Error` gains `suggestions`, `code` attributes and `.actionable` factory method +- `Legion::CLI::ErrorHandler` module: 6-pattern matcher maps common exceptions (RabbitMQ, DB, extensions, permissions, data, Vault) to actionable errors with fix suggestions +- `ErrorHandler.wrap` wraps any `StandardError` into a `CLI::Error` with suggestions when a pattern matches +- `ErrorHandler.format_error` prints suggestions below the error line when the error is actionable +- `Legion::CLI::Main.start` overrides Thor's entry point to wrap unhandled exceptions through `ErrorHandler` before exiting + +## [1.4.116] - 2026-03-22 + +### Added +- `legion detect scan --format sarif|markdown|json` option for CI-friendly output formats + +## [1.4.115] - 2026-03-22 + +### Changed +- Extension parallel pool size now reads from `Legion::Settings[:extensions][:parallel_pool_size]` (default: 24) instead of hardcoded 4 +- Significantly faster boot with many extensions: all load concurrently instead of in batches of 4 + +## [1.4.114] - 2026-03-22 + +### Changed +- Parallelize extension loading using Concurrent::Promises thread pool (4 workers) +- Use Concurrent::Array for thread-safe pending_actors during parallel load +- ~4x faster boot: extensions load concurrently instead of serially + +## [1.4.112] - 2026-03-21 + +### Added +- `Legion::Lock` distributed locking module (Redis SET NX PX acquire, Lua compare-and-delete release) +- `Legion::Leader` leader election module with periodic renewal via distributed lock +- `Legion::Extensions::Actors::Singleton` mixin for singleton actor enforcement (one instance per cluster) +- `Legion::Leader.reset!` called in shutdown sequence to release leadership before process exit + +## [1.4.111] - 2026-03-21 + +### Added +- Register logging hooks in boot sequence: fatal/error/warn published to `legion.logging` RMQ exchange +- Routing key pattern: `legion..` (e.g., `legion.core.fatal`, `legion.lex-slack.error`) +- `Legion::Region` module: cloud metadata detection (AWS IMDSv2, Azure IMDS), region affinity routing +- `Legion::Region::Failover`: promote regions with replication lag checks, --dry-run, --force +- `legion failover` CLI: promote and status subcommands for region failover management + +## [1.4.110] - 2026-03-21 + +### Added +- Domain restrictions in extension Sandbox (allowed_domains on Policy, domain_allowed? check) +- Sandbox.allowed? class method for combined capability + domain checks + +## [1.4.109] - 2026-03-21 + +### Added +- `Legion::Cluster::Lock` Redis backend: SETNX + TTL acquire, Lua compare-and-delete release, thread-safe token storage via `Concurrent::Map` +- `Legion::Cluster::Lock.backend` auto-detection: `:redis` (preferred), `:postgres` (advisory locks), or `:none` +- `Legion::ProcessRole` module: role presets (full, api, worker, router) controlling which Service subsystems start +- `Legion::Service#initialize` role integration: `role:` parameter resolves via `ProcessRole`, explicit kwargs override role defaults +- 24 new specs (2404 total, 0 failures) + +## [1.4.108] - 2026-03-21 + +### Added +- `Legion::Registry::SecurityScanner` static analysis check — detects dangerous Ruby patterns (eval, system, exec, backtick, IO.popen, Open3) in extension source files +- `Legion::Registry::Persistence` module — syncs in-memory registry with `extensions_registry` DB table (load at boot, persist on register/update) +- Boot-time auto-population of `Legion::Registry` from discovered extensions with gemspec capability reading +- `Legion::Sandbox` auto-wiring from gemspec `legion.capabilities` metadata at extension load +- `legion marketplace install NAME` command — validates lex- naming, installs gem, registers in registry +- `legion marketplace publish` command — full pipeline: rspec, rubocop, gem build, gem push, security scan, register +- `Legion::Registry::Governance` module — naming convention enforcement, auto-approve by risk tier, review requirements via `Legion::Settings` +- 65 new specs (2380 total, 0 failures) + +## [1.4.107] - 2026-03-21 + +### Added +- `Legion::Docs::SiteGenerator` — full static site generator with kramdown + rouge syntax highlighting +- Converts markdown guides to HTML with navigation sidebar and styled template +- CLI reference auto-generation via Thor command introspection +- Extension reference auto-generation via Bundler gem discovery +- `Legion::CLI::Docs` — `legion docs generate` and `legion docs serve` subcommands +- 39 new specs (2248 total, 0 failures) + +## [1.4.106] - 2026-03-21 + +### Added +- `Legion::DigitalWorker::Registration` module with full approval workflow: `register`, `approve`, `reject`, `pending_approvals`, `approval_required?`, and `escalate` +- Workers with `high` or `critical` risk tiers are created in `pending_approval` state instead of `bootstrap`, triggering an AIRB intake +- `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)`) +- New lifecycle states `pending_approval` and `rejected` in `Lifecycle::TRANSITIONS`, with appropriate `EXTINCTION_MAPPING` and `CONSENT_MAPPING` entries +- Transition rules: `pending_approval -> active` (approve), `pending_approval -> rejected` (reject) +- CLI subcommands: `legion worker approvals`, `legion worker approve ID [--notes TEXT]`, `legion worker reject ID --reason TEXT` +- API routes: `GET /api/workers/approvals`, `POST /api/workers/:id/approve`, `POST /api/workers/:id/reject` +- 37 new specs across `registration_spec.rb` (28 examples) and `airb_spec.rb` (9 examples) +- `Legion::Phi` module — HIPAA/BAA PHI tagging and tracking: `PHI_TAG`, `tag`, `tagged?`, `phi_fields`, `redact`, `erase`, `auto_detect_fields` +- `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` +- `Legion::Phi::Erasure` module — cryptographic erasure: `erase_record` (AES-256-GCM with throwaway key), `erase_for_subject` (HIPAA right to deletion), `erasure_log` +- 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` +- `Legion::Crypt` guarded throughout — falls back to stdlib OpenSSL when `legion-crypt` is not loaded +- 59 new specs across `phi_spec.rb` (30 examples), `phi/access_log_spec.rb` (15 examples), `phi/erasure_spec.rb` (14 examples) +- `Legion::API::Routes::GraphQL` — GraphQL API layer using graphql-ruby (optional dependency, guarded with `defined?(GraphQL)`) +- `POST /api/graphql` — executes GraphQL queries; parses `query`, `variables`, `operationName` from JSON body +- `GET /api/graphql` — serves GraphiQL browser IDE for interactive introspection +- `Legion::API::GraphQL::Schema` — root schema with `max_depth: 10`, `max_complexity: 200` +- `Legion::API::GraphQL::Types::QueryType` — root query with `workers`, `worker`, `extensions`, `extension`, `tasks`, `node` fields and filtering arguments +- `Legion::API::GraphQL::Types::WorkerType`, `ExtensionType`, `TaskType`, `NodeType` — field definitions for each domain object +- Data resolution falls back gracefully: uses `Legion::Data` models when connected, falls back to `Legion::DigitalWorker::Registry` / `Legion::Registry` in-memory stores otherwise +- 45 new specs in `spec/legion/api/graphql_spec.rb` + +## [1.4.105] - 2026-03-21 + +### Added +- `Legion::API::Routes::AuthSaml` — SAML 2.0 SP authentication flow +- `GET /api/auth/saml/metadata` — generates SP metadata XML (delegates to `OneLogin::RubySaml::Metadata`) +- `GET /api/auth/saml/login` — initiates IdP redirect via `OneLogin::RubySaml::Authrequest` +- `POST /api/auth/saml/acs` — validates SAML assertion, extracts claims (nameid, email, displayName, groups), maps groups to Legion RBAC roles, and issues a Legion JWT +- Routes are only registered when `OneLogin::RubySaml` is defined and `auth.saml.enabled` is true +- Claims mapping delegates to `Legion::Rbac::ClaimsMapper.groups_to_roles` when available, falls back to `['worker']` +- Configuration via `Legion::Settings.dig(:auth, :saml)` — keys: `idp_sso_url`, `idp_cert`, `sp_entity_id`, `sp_acs_url`, `group_map`, `default_role`, `want_assertions_signed`, `want_assertions_encrypted` +- `Legion::Registry` review workflow: `submit_for_review`, `approve`, `reject`, `deprecate`, `pending_reviews`, `usage_stats` class methods +- `Legion::Registry::Entry` gains `status`, `review_notes`, `reject_reason`, `successor`, `sunset_date`, and timestamp fields; `deprecated?` and `pending_review?` predicates +- `legion marketplace submit NAME` — submit extension for review +- `legion marketplace review` — list extensions pending review +- `legion marketplace approve NAME [--notes TEXT]` — approve an extension +- `legion marketplace reject NAME [--reason TEXT]` — reject an extension +- `legion marketplace deprecate NAME [--successor NAME] [--sunset-date DATE]` — mark extension as deprecated +- `legion marketplace stats NAME` — show usage statistics (install count, active instances, downloads) +- `Legion::API::Routes::Marketplace` — full REST API: `GET /api/marketplace`, `GET /api/marketplace/:name`, `POST /api/marketplace/:name/submit`, `POST /api/marketplace/:name/approve`, `POST /api/marketplace/:name/reject`, `POST /api/marketplace/:name/deprecate`, `GET /api/marketplace/:name/stats` +- 123 new specs across registry, CLI, and API layers + +## [1.4.104] - 2026-03-21 + +### Added +- `legion notebook read PATH` — parse and display a .ipynb notebook with Rouge syntax highlighting +- `legion notebook cells PATH` — list all cells with index numbers and line counts +- `legion notebook export PATH --format md|script` — export notebook to markdown or Python script +- `legion notebook create PATH --description "..."` — generate a new notebook from natural language via LLM (requires legion-llm) +- `Legion::Notebook::Parser` — parse .ipynb JSON into structured data (metadata, kernel, language, cells with outputs) +- `Legion::Notebook::Renderer` — display notebook cells in terminal with Rouge syntax highlighting +- `Legion::Notebook::Generator` — generate notebooks from natural language; strips LLM markdown fences; validates .ipynb structure + +## [1.4.103] - 2026-03-21 + +### Added +- `Legion::Team` module — team registry backed by settings (current, members, find, list) +- `Legion::Team::CostAttribution` — tags LLM request metadata with team and user context +- `legion team` CLI subcommand — list, show, current, set, create, add-member + +## [1.4.102] - 2026-03-21 + +### Added +- `legion image analyze PATH` — analyze an image file via LLM; supports `--prompt`, `--model`, `--provider`, `--format text|json` +- `legion image compare PATH1 PATH2` — compare two images side by side via LLM with same options +- Supports png, jpg, jpeg, gif, webp; base64-encodes image data and builds multimodal content blocks for the LLM message + +## [1.4.101] - 2026-03-21 + +### Fixed +- add post-extension GAIA rediscovery in service boot sequence +- fix self-contained actor dispatch to call instance methods instead of class methods + +## [1.4.100] - 2026-03-21 + +### Changed +- `hook_actor` FATAL now logs actor class name and ancestors for debugging unmatched actors +- `hook_all_actors` logs actor type counts after hooking (subscription/every/poll/once/loop) + +## [1.4.99] - 2026-03-21 + +### Fixed +- `Base#manual` resolves String `runner_class` via `Kernel.const_get` before calling `.send` — fixes NoMethodError on lex-telemetry Publisher and lex-llm-gateway SpoolFlush actors +- `Base#manual` falls back to `:action` when `runner_function` is not defined — fixes NameError on self-contained actors (lex-lex AgentWatcher, lex-detect ObserverTick) + +## [1.4.98] - 2026-03-20 + +### Fixed +- `auto_generate_data` and `auto_generate_transport` use `lex_class.const_defined?(:Data, false)` instead of `Kernel.const_defined?` — fixes constant overwrite when extensions pre-define their own Data/Transport modules (e.g. lex-synapse) + +## [1.4.97] - 2026-03-20 + +### Fixed +- Suppress Puma startup banner by adding `quiet: true` to server settings (routes all API logging through Legion::Logging) + +## [1.4.96] - 2026-03-20 + +### Fixed +- `auto_generate_data` and `auto_generate_transport` in `core.rb` now extend existing namespace modules (e.g. `Synapse::Data::Model`) with the appropriate `Legion::Extensions::Data` or `Legion::Extensions::Transport` mixin when `build` is not already defined, instead of returning early and leaving them without a `build` method + +## [1.4.95] - 2026-03-20 + +### Added +- `GET /api/prompts` — list all prompt templates via lex-prompt Client +- `GET /api/prompts/:name` — show prompt details for the latest version +- `POST /api/prompts/:name/run` — render a prompt template with variables and run it through Legion::LLM; returns rendered_prompt, response, usage, model, version +- 503 guard for missing lex-prompt dependency (LoadError rescue in `prompt_client` helper) +- 503 guard for LLM subsystem unavailable on the `/run` endpoint +- 404 on prompt not found, 422 on version not found for `/run` +- 32 new specs in `spec/legion/api/prompts_spec.rb` covering all routes and error paths + +## [1.4.94] - 2026-03-20 + +### Added +- `legion prompt play NAME` subcommand: renders a prompt template with variables and sends it to an LLM via `Legion::LLM.chat` +- `--variables` (JSON), `--version`, `--model`, `--provider`, and `--compare` options on `play` +- Compare mode (`--compare VERSION`): renders two prompt versions, calls LLM for each, displays side-by-side responses and diff when they differ +- JSON output mode for `play` and compare via `--json` +- `Connection.ensure_llm` called inside `with_prompt_client` so LLM is available to all prompt subcommands +- 14 new specs for `play` covering single-version, compare, LLM unavailable, JSON output, and error paths + +## [1.4.93] - 2026-03-20 + +### Added +- `legion prompt` CLI subcommand for versioned LLM prompt template management (list, show, create, tag, diff) +- `legion dataset` CLI subcommand for versioned dataset management (list, show, import, export) +- Both commands wrap `lex-prompt` and `lex-dataset` extension clients via `begin/rescue LoadError` guards +- Both commands guard with `Connection.ensure_data` and follow existing `with_*_client` pattern +- Tab completion entries for `prompt` and `dataset` in `completions/legion.bash` and `completions/_legion` + +## [1.4.92] - 2026-03-20 + +### Added +- `--template` option on `legion lex create` to scaffold pattern-specific extensions: `llm-agent`, `service-integration`, `data-pipeline` (default: `basic`) +- `--list-templates` option on `legion lex create` to display available templates with descriptions +- `LexTemplates::TemplateOverlay` class renders ERB template files into the target extension directory +- ERB scaffold templates under `lib/legion/cli/lex/templates/`: `llm_agent/`, `service_integration/`, `data_pipeline/` +- `llm-agent` template: LLM runner with `Legion::LLM.chat` and structured output, helpers/client.rb with model/temperature kwargs, default prompt YAML, spec with LLM mock +- `service-integration` template: CRUD runners (list/get/create/update/delete), Faraday HTTP client helper with api_key/bearer/basic auth, auth helper, specs with WebMock stubs +- `data-pipeline` template: transform runner with validate/process/publish pattern, subscription ingest actor, transport exchange/queue/message scaffolds, runner and actor specs +- Template registry extended with `data-pipeline`, `template_dir` class method, new `llm-agent`/`service-integration` entries with `template_dir` keys + +## [1.4.91] - 2026-03-20 + +### Fixed +- Guard `auto_generate_data` against overwriting existing `Data` module on extensions (fixes lex-synapse constant collision) + +## [1.4.90] - 2026-03-20 + +### Fixed +- Extension migrator uses `true` instead of `1` for PostgreSQL boolean `active` column +- Shutdown guards `Legion::Gaia.started?` with `respond_to?` to handle partial GAIA load failures + +## [1.4.89] - 2026-03-20 + +### Added +- ACP provider routes: `GET /.well-known/agent.json`, `POST /api/acp/tasks`, `GET /api/acp/tasks/:id`, `DELETE /api/acp/tasks/:id` (501 stub) +- `Legion::API::Routes::Acp` module for bidirectional ACP interoperability +- `build_agent_card`, `discover_capabilities`, `find_task`, `translate_status` API helpers for ACP support + +## [1.4.88] - 2026-03-20 + +### Added +- ACP provider spec: 25 tests covering agent card discovery, task submission, task status, task cancellation stub, and status translation +- Refactored ACP helpers into `Legion::API::Helpers::Acp` module for testability + +## [1.4.87] - 2026-03-20 + +### Added +- OpenInference OTel span instrumentation (Ingress TOOL spans, Subscription CHAIN spans) +- SafetyMetrics sliding window module with 4 default alert rules +- Fingerprint mixin for actor skip-if-unchanged optimization + +## [1.4.86] - 2026-03-20 + +### Added +- `legion payroll` CLI subcommand for workforce cost visibility (summary, report, forecast, budget) +- Integrated with `Helpers::Economics` from lex-metering for labor economics data + +## [1.4.85] - 2026-03-20 + +### Added +- `legion lex fixes` CLI command to list pending auto-fix patches (filterable by status) +- `legion lex approve-fix FIX_ID` CLI command to approve LLM-generated fixes +- `legion lex reject-fix FIX_ID` CLI command to reject LLM-generated fixes +- `with_data` helper to `legion lex` subcommand class for data-required operations + +## [1.4.84] - 2026-03-20 + +### Added +- `Legion::Extensions.load_yaml_agents` — loads YAML/JSON agent definitions from `~/.legionio/agents/` or configured directory +- `generate_yaml_runner` — dynamically generates a runner Module for each agent with `llm`, `script`, and `http` function types +- YAML agent loading integrated into `hook_extensions` boot sequence +- Governance API routes under `/api/governance/approvals` (list, show, submit, approve, reject) +- HTML governance dashboard at `/governance/` with approve/reject buttons, 30s auto-poll, and reviewer dialog +- Static file serving enabled for `public/` directory in Sinatra + +## [1.4.83] - 2026-03-20 + +### Added +- `Helpers::Context` for filesystem-based inter-agent context sharing +- Org chart API endpoint (`GET /api/org-chart`) with dashboard panel +- Workflow relationship graph API (`GET /api/relationships/graph`) +- Workflow visualizer web page (`public/workflow/`) with Cytoscape.js +- `--worktree` flag for `legion chat` with auto-checkpointing +- `.legion-context/` and `.legion-worktrees/` in generated `.gitignore` + +## [1.4.82] - 2026-03-20 + +### Added +- `legion check --privacy` command: verifies enterprise privacy mode (flag set, no cloud API keys, external endpoints unreachable) +- `PrivacyCheck` class with three probes: flag_set, no_cloud_keys, no_external_endpoints +- `Legion::Service.log_privacy_mode_status` logs enterprise privacy state at startup + +## [1.4.81] - 2026-03-20 + +### Added +- Fingerprint mixin for actor skip-if-unchanged optimization (`Legion::Extensions::Actors::Fingerprint`) +- SHA256-based `skip_or_run` gate: skips execution when `fingerprint_source` is stable +- Fingerprint integrated into `Every` and `Poll` actors via `include Fingerprint` +- Extracted `poll_cycle` method from Poll actor for clean separation of timer vs logic +- `legion eval experiments` subcommand: list all experiment runs with status and summary +- `legion eval promote --experiment NAME --tag TAG` subcommand: tag a prompt version for production via lex-prompt +- `legion eval compare --run1 NAME --run2 NAME` subcommand: side-by-side diff of two experiment runs +- `require_prompt!` guard for lex-prompt extension availability + +## [1.4.80] - 2026-03-20 + +### Added +- OpenInference OTel span helpers (LLM, EMBEDDING, TOOL, CHAIN, EVALUATOR, AGENT) +- SafetyMetrics sliding window module for behavioral monitoring +- 4 safety alert rules (action burst, scope escalation spike, probe detected, confidence collapse) +- OpenInference TOOL spans in Ingress.run +- OpenInference CHAIN spans in Subscription actor dispatch +- SafetyMetrics wired into service boot sequence +- `legion eval run` CLI subcommand for CI/CD threshold-based eval gating +- `--dataset`, `--threshold`, `--evaluator`, `--exit-code` options on `eval run` +- JSON report output to stdout with per-row scores, summary, and timestamp +- `.github/workflow-templates/eval-gate.yml` reusable GitHub Actions workflow template +- PR annotation step in workflow template for inline eval result comments + +## [1.4.79] - 2026-03-20 + +### Added +- Unified LEX routing layer: auto-expose runner functions as POST endpoints at `/api/lex/{ext}/{runner}/{action}` +- `Builders::Routes` auto-discovers runner public methods during extension autobuild +- `Routes::Lex` wildcard handler dispatches through Ingress with JWT + RBAC +- `GET /api/lex` listing endpoint for route discovery +- Settings-based configuration at `api.lex_routes` (global enable, per-extension enable, runner/function exclusions) +- `skip_routes` DSL for runner modules to opt out of auto-route exposure +- Auto-routes included in OpenAPI spec generation +- `runner_module` reference stored in builders runner hash for introspection + +## [1.4.78] - 2026-03-19 + +### Added +- Response headers support in `render_custom_response`: runners can return `response[:headers]` hash for custom HTTP headers + +### Removed +- Legacy `POST /api/hooks/:lex_name/:hook_name` route (superseded by `GET|POST /api/hooks/lex/*` splat routes in v1.4.76) +- Hardcoded `GET /api/auth/negotiate` Kerberos route (migrated to lex-kerberos hook at `/api/hooks/lex/kerberos/negotiate`) +- `Routes::AuthKerberos` module and `api/auth_kerberos.rb` file + +## [1.4.77] - 2026-03-19 + +### Added +- Hardcoded deny list in `Extensions::Permissions` blocking access to `~/.ssh`, `~/.gnupg`, `~/.aws/credentials` +- Deny list overrides all other permission checks including explicit approvals + +## [1.4.76] - 2026-03-19 + +### Added +- `Hooks::Base.mount(path)` DSL for extension-derived URL suffixes (e.g., `/callback`) +- `GET /api/hooks/lex/*` splat route for hook discovery via GET requests +- `POST /api/hooks/lex/*` splat route with `route_path`-based hook dispatch +- `Legion::API.find_hook_by_path(path)` for direct route-path lookup in hook registry +- `route_path` field stored in hook registry entries and returned in `GET /api/hooks` listing +- Runner-controlled responses: `result[:response]` hash with `:status`, `:content_type`, `:body` +- `build_payload`, `dispatch_hook`, `render_custom_response` extracted helpers in Routes::Hooks + +### Changed +- `register_hook` now accepts `route_path:` keyword; defaults to `lex_name/hook_name` if omitted +- `builders/hooks.rb` computes `route_path` from `extension_name/hook_name + mount_path` +- `extensions/core.rb` passes `route_path:` when calling `Legion::API.register_hook` +- `GET /api/hooks` listing now includes `route_path` and updated `endpoint` field +- Removed `Routes::OAuth` (moved OAuth callback to lex-microsoft_teams hook with mount path) +- `handle_hook_request` refactored into smaller helpers to stay within complexity limits + +## [1.4.75] - 2026-03-19 + +### Added +- `Legion::Extensions::Catalog` singleton state machine tracking extension lifecycle (registered/loaded/starting/running/stopping/stopped) +- `Legion::Extensions::Permissions` three-layer file permission model (sandbox, declared paths, auto-approve globs) +- `GET /api/catalog` and `GET /api/catalog/:name` extension capability manifest endpoints +- Tier 0 routing in `POST /api/llm/chat` via `Legion::MCP::TierRouter` for LLM-free cached responses +- Data::Local migrations for extension_catalog and extension_permissions tables +- Catalog lifecycle wired into extension loader (register/loaded/running/stopping/stopped transitions) + +## [1.4.74] - 2026-03-19 + +### Changed +- Extracted `Legion::MCP` to dedicated `legion-mcp` gem (v0.1.0) +- Replaced `mcp` gem dependency with `legion-mcp` + +## [1.4.73] - 2026-03-19 + +### Added +- TBI Phase 3: semantic tool retrieval via embedding vectors +- `Legion::MCP::EmbeddingIndex` module: in-memory embedding cache with pure-Ruby cosine similarity +- `ContextCompiler` semantic score blending: 60% semantic + 40% keyword when embeddings available, keyword-only fallback +- `Server.populate_embedding_index`: auto-populates tool embeddings on MCP server build (no-op if LLM unavailable) +- `legion observe embeddings` subcommand: index size, coverage, and populated status +- 61 new specs (1666 total): EmbeddingIndex unit, ContextCompiler semantic blending, integration wiring, CLI + +## [1.4.72] - 2026-03-19 + +### Added +- TBI Phase 0+2: MCP tool observation pipeline and usage-based filtering +- `Legion::MCP::Observer` module: in-memory tool call recording with counters, ring buffer, and intent tracking +- `Legion::MCP::UsageFilter` module: scores tools by frequency, recency, and keyword match; prunes dead tools +- MCP `instrumentation_callback` wiring: automatically records all `tools/call` invocations via Observer +- MCP `tools_list_handler` wiring: dynamically filters and ranks tools per-request based on usage data +- `legion observe` CLI command: `stats`, `recent`, `reset` subcommands for MCP tool usage inspection +- 96 new specs covering Observer, UsageFilter, CLI command, and integration wiring + +## [1.4.71] - 2026-03-19 + +### Added +- `POST /api/llm/chat` daemon endpoint with async (202) and sync (201) response paths +- `ContextCompiler` module: categorizes 35 MCP tools into 9 groups with keyword matching +- `legion.do` meta-tool: natural language intent routing to best-matching MCP tool +- `legion.tools` meta-tool: compressed catalog, category browsing, and intent-matched discovery + +### Fixed +- `ContextCompiler.build_tool_index` now handles `MCP::Tool::InputSchema` objects (not just hashes) + +## [1.4.70] - 2026-03-19 + +### Added +- GAIA cognitive layer as a core boot phase: `setup_gaia` runs between LLM and telemetry in the startup sequence +- Two-phase extension loading: all extensions are fully loaded (require + autobuild) before any actors are hooked (AMQP subscriptions, timers, etc.), preventing race conditions during boot +- `gaia: true` parameter on `Service.new` to control GAIA initialization +- GAIA graceful shutdown and reload support (shuts down before extensions, restarts after data) + +### Changed +- Boot order is now deterministic: Logging -> Settings -> Crypt -> Transport -> Cache -> Data -> RBAC -> LLM -> GAIA -> Telemetry -> Extensions -> API +- Extension actors are collected into `@pending_actors` during `load_extensions`, then started all at once via `hook_all_actors` + +## [1.4.69] - 2026-03-19 + +### Fixed +- Constant resolution bug in transport/subscription layers: `const_defined?` and `const_get` now pass `inherit: false` to prevent Ruby from finding top-level gem constants (`::Redis`, `::Vault`, `::Data`) through `Object` when checking dynamically created `Module.new` namespaces (`Transport::Exchanges`, `Transport::Queues`) +- `Subscription#queue` now uses `queues.const_get(actor_const, false)` instead of `Kernel.const_get(queue_string)` to search only the Queues module's own constants +- Added `llm-gateway` to `core_extension_names` so it is included under `:core` role profile +- `build_extension_entry` now forces nesting for multi-segment gem names (e.g. `lex-llm-gateway`) to produce correct require paths regardless of call-site `nesting:` argument + +## [1.4.68] - 2026-03-19 + +### Added +- `legionio llm` subcommand for LLM provider diagnostics + - `llm status` (default) — show LLM state, enabled providers, routing, system memory + - `llm providers` — list all providers with enabled/disabled and reachability status + - `llm models` — list available models per enabled provider (Ollama discovery + cloud defaults) + - `llm ping` — test connectivity to each enabled provider with latency measurement + - All subcommands support `--json` output +- `legionio version` now shows legion-llm, legion-gaia, and legion-tty in components list +- `legionio version --json` now includes components hash and extension count + +### Fixed +- `legionio update` now correctly detects gem version changes (was showing "already latest" for every gem due to stale in-memory gem spec cache after subprocess install) + +## [1.4.67] - 2026-03-18 + +### Added +- `legionio detect` subcommand — scan environment and recommend extensions (requires lex-detect gem) + - `detect scan` (default) — show detected software and recommended extensions + - `detect catalog` — show full detection catalog + - `detect missing` — list extensions that should be installed + - `--install` flag to install missing extensions after scan + - `--json` output mode +- `legionio update` now suggests new extensions via lex-detect after updating gems + +## [1.4.66] - 2026-03-18 + +### Fixed +- Doctor config check now looks in `~/.legionio/settings` (the actual default settings directory) +- Doctor permissions check now checks `~/.legionio/` directories instead of `/var/run` + +## [1.4.65] - 2026-03-18 + +### Fixed +- Remove local path references from Gemfile (40 sibling repo paths) + +## [1.4.64] - 2026-03-18 + +### Fixed +- Remove legacy `exe/legion-tty` from legionio gem (conflicts with legion-tty gem executable) +- Explicitly list executables as `legion` and `legionio` in gemspec instead of glob pattern + +## [1.4.63] - 2026-03-18 + +### Added +- `legionio config import SOURCE` command for importing config from URL or local file +- Supports raw JSON and base64-encoded JSON payloads +- Deep merges with existing `~/.legionio/settings/imported.json` (or `--force` to overwrite) +- Displays imported sections and vault cluster count + +## [1.4.62] - 2026-03-18 + +### Added +- `legionio` binary for daemon and operational CLI +- `Legion::CLI::Interactive` Thor class for dev-workflow commands (chat, commit, pr, review, memory, plan, init, tty) +- `legion-tty` as runtime dependency +- Shell completions for both `legion` and `legionio` binaries + +### Changed +- `exe/legion` now routes bare invocation to TTY shell, args to Interactive CLI +- `exe/legionio` handles all daemon and operational commands + +## [1.4.61] - 2026-03-18 + +### Added +- Chat persistent settings defaults via `Legion::Settings` (issue #5) +- `chat_setting(*keys)` helper for centralized settings access with error handling +- Settings priority chain: CLI flag > `Legion::Settings.dig(:chat, ...)` > hardcoded default +- Configurable via settings: model, provider, personality, permissions, markdown, incognito, max_budget_usd, subagent concurrency/timeout, headless max_turns +- `chat` subsystem added to `config scaffold` with full template +- `Subagent.configure_from_settings` reads concurrency and timeout from settings +- 22 new specs (19 settings integration + 3 subagent settings) + +## [1.4.60] - 2026-03-18 + +### Fixed +- Empty Enter in chat REPL no longer exits the session; returns empty string instead of nil to disambiguate from Ctrl+D (EOF) + +## [1.4.59] - 2026-03-17 + +### Added +- `remote_invocable?` flag for LEX extensions: when `false`, the auto-generated Subscription actor is skipped (no RabbitMQ queue, no thread pool, no AMQP binding) +- 5-level resolution order: per-runner settings, extension settings, runner class method, extension module method, default `true` +- `@local_tasks` list tracks subscription actors skipped due to `remote_invocable? false` for introspection +- `remote_invocable?` default method added to `Legion::Extensions::Core` and `Legion::Extensions::Actors::Base` +- Fully backward compatible — all existing extensions unaffected + +## [1.4.58] - 2026-03-17 + +### Added +- `legion lex list` now groups output by category (tier order) by default. +- `legion lex list CATEGORY` filters the list to a specific category (e.g., `legion lex list agentic`). +- `--flat` option to `legion lex list` restores the original flat table without grouping. +- `category` and `tier` columns added to the extension table in all display modes. +- `discover_all` now includes `:category` and `:tier` keys in each extension info hash, + derived via `Legion::Extensions::Helpers::Segments.categorize_gem`. +- Results sorted by tier then name for deterministic ordering. + +## [1.4.57] - 2026-03-17 + +### Added +- `--category` option to `legion lex create`: generates categorized extension gems with nested module + declarations, nested directory structure, and correct `VERSION` constant paths. + Example: `legion lex create cognitive-anchor --category agentic` produces gem `lex-agentic-cognitive-anchor` + with module `Legion::Extensions::Agentic::Cognitive::Anchor`. +- `LexGenerator` now accepts `gem_name:` keyword argument and uses `Legion::Extensions::Helpers::Segments` + to derive all namespace, const, and require-path values for both flat and nested extensions. +- `legion lex create` emits a warning via `Legion::Extensions.check_reserved_words` when reserved + category prefixes or framework words are used in the gem name. + +## [1.4.56] - 2026-03-17 + +### Fixed +- `lex_class` now returns the full extension module constant by walking the namespace up to the first `NAMESPACE_BOUNDARIES` word, instead of always stopping at index 2. For nested extensions (`Legion::Extensions::Agentic::Cognitive::Anchor`), this returns `Legion::Extensions::Agentic::Cognitive::Anchor` rather than the incorrect `Legion::Extensions::Agentic`. +- `lex_const` now derives from `lex_class.to_s.split('::').last` so it returns the extension's root constant name (`Anchor`) rather than always returning the third element of the namespace array. +- `full_path` now builds the gem name from dash-joined segments (`lex-agentic-cognitive-anchor`) instead of underscore-joined `lex_name`, so `Gem::Specification.find_by_name` works for nested extensions. + +## [1.4.55] - 2026-03-17 + +### Changed +- `build_default_exchange` now sets `exchange_name` on dynamically created exchange classes to return `amqp_prefix` (dot-joined segments with `legion.` prefix) instead of defaulting to the parent class behavior +- `auto_create_exchange` now derives `exchange_name` from `amqp_prefix` + the exchange's own downcased class name, replacing the index-based `split('::')[5].downcase` extraction that broke for nested extension namespaces + +### Fixed +- `legion config scaffold` now writes to `~/.legionio/settings/` by default instead of `./settings/` +- Removed Thor `default: './settings'` that shadowed the Ruby fallback in `ConfigScaffold.run` +- Added `~/.legionio/settings` to `legion config path` search paths to match `Service#default_paths` + +## [1.4.54] - 2026-03-17 + +### Changed +- `Helpers::Logger#log` now passes `lex_segments:` array to `Legion::Logging::Logger` when the object responds to `:segments` +- Falls back to `lex:` string for legacy flat extensions that do not implement `:segments` + +## [1.4.53] - 2026-03-17 + +### Fixed +- Extension discovery now correctly parses multi-hyphenated gem names (e.g., `lex-cognitive-reappraisal`) +- `gem_names_for_discovery` returns structured data instead of ambiguous `name-version` strings +- Updated fallback path to use `Gem::Specification.latest_specs` instead of `all_names` + +## [1.4.52] - 2026-03-17 + +### Added +- `legion dashboard`: TUI operational dashboard with auto-refresh polling +- `Dashboard::DataFetcher`: polls REST API for workers, health, and recent events +- `Dashboard::Renderer`: terminal-based dashboard rendering with sections for workers, events, health +- Configurable API URL (`--url`) and refresh interval (`--refresh`) + +## [1.4.51] - 2026-03-17 + +### Added +- `Legion::TraceSearch`: natural language to safe JSON filter translation via legion-llm structured output +- `legion trace search "query"`: CLI command for NL trace search +- Column allowlist enforcement for query safety (no eval, JSON-only filter DSL) +- Schema-aware prompt for metering_records table + +## [1.4.50] - 2026-03-17 + +### Added +- `Legion::Graph::Builder`: builds task relationship graph from relationships table with chain/worker filtering +- `Legion::Graph::Exporter`: renders graphs to Mermaid and DOT (Graphviz) formats +- `legion graph show`: CLI command with `--format mermaid|dot`, `--chain`, `--worker`, `--output`, `--limit` options + +## [1.4.49] - 2026-03-17 + +### Added +- `Legion::TenantContext`: thread-local tenant context propagation (set, clear, with block) +- `Legion::Tenants`: tenant CRUD, suspension, and quota enforcement +- `Middleware::Tenant`: extracts tenant_id from JWT/header, sets TenantContext per request +- `GET/POST /api/tenants`: tenant listing and provisioning endpoints +- `POST /api/tenants/:id/suspend`: tenant suspension +- `GET /api/tenants/:id/quota/:resource`: quota check endpoint + +## [1.4.48] - 2026-03-17 + +### Added +- `Legion::Capacity::Model`: workforce capacity calculation (throughput, utilization, forecast, per-worker stats) +- `GET /api/capacity`: aggregate capacity across active workers +- `GET /api/capacity/forecast`: projected capacity with configurable growth rate and period +- `GET /api/capacity/workers`: per-worker capacity breakdown + +## [1.4.47] - 2026-03-17 + +### Fixed +- `gem_load` rescue block referenced undefined `gem_path` variable, causing secondary NameError that masked original LoadError +- `meta_actors` type guard checked `is_a?(Array)` but called `each_value` (Hash method), so meta actors were never hooked +- `build_actor_list` crashed entire extension load when actor file didn't define expected constant (now skips gracefully) +- `build_transport` raised NoMethodError on extensions with custom Transport modules missing `build` (now falls back to auto-generate) + +## [1.4.46] - 2026-03-17 + +### Added +- `Legion::Telemetry.configure_exporter`: OTLP and console span exporters +- OTLP exporter uses BatchSpanProcessor for production performance +- Settings: `telemetry.tracing.exporter`, `endpoint`, `headers`, `batch_size` +- Graceful fallback when opentelemetry-exporter-otlp gem absent + +## [1.4.45] - 2026-03-17 + +### Added +- `GET /api/auth/authorize`: redirects to Entra authorization endpoint for browser-based OAuth2 login +- `GET /api/auth/callback`: exchanges authorization code for tokens, validates id_token via JWKS, maps claims, issues Legion human JWT +- Auth middleware SKIP_PATHS now includes `/api/auth/authorize` and `/api/auth/callback` + +## [1.4.44] - 2026-03-17 + +### Added +- `POST /api/auth/worker-token`: Entra client credentials token exchange endpoint (validates client_credentials grant via JWKS, looks up worker by appid, issues scoped Legion worker JWT) +- Auth middleware SKIP_PATHS now includes `/api/auth/token` and `/api/auth/worker-token` + +## [1.4.43] - 2026-03-17 + +### Fixed +- Auth token exchange route used `Legion::Settings.dig` which doesn't exist — replaced with bracket access +- Auth spec required `legion/rbac` gem directly — replaced with inline stub for standalone test execution + +## [1.4.42] - 2026-03-17 + +### Added +- `POST /api/auth/token`: Entra ID token exchange endpoint (validates external JWT via JWKS, maps claims via EntraClaimsMapper, issues Legion token) + +## [1.4.41] - 2026-03-17 + +### Added +- `Legion::CLI::LexTemplates`: extension template registry (basic, llm-agent, service-integration, scheduled-task, webhook-handler) +- `Legion::Docs::SiteGenerator`: documentation site generation from existing markdown files + +## [1.4.40] - 2026-03-17 + +### Added +- `Legion::Guardrails`: embedding similarity and RAG relevancy safety checks +- `Legion::Context`: session/user tracking with thread-local `SessionContext` +- `Legion::Catalog`: AI catalog registration for MCP tools and workers + +## [1.4.39] - 2026-03-17 + +### Added +- `Legion::Webhooks`: outbound webhook dispatcher with HMAC-SHA256 signing +- Webhook registration, delivery tracking, and dead letter queue +- API routes: `GET/POST/DELETE /api/webhooks` + +## [1.4.38] - 2026-03-17 + +### Added +- `Legion::Isolation`: per-agent data and tool access enforcement with thread-local context +- `Isolation::Context`: tool allowlist, data filter, and risk tier per agent + +## [1.4.37] - 2026-03-17 + +### Added +- `POST /api/channels/teams/webhook`: Bot Framework activity delivery to GAIA sensory buffer + +## [1.4.36] - 2026-03-17 + +### Added +- `Audit::HashChain`: SHA-256 hash chain for tamper-evident audit records +- `Audit::SiemExport`: SIEM-compatible JSON and NDJSON export with integrity metadata +- `Audit::HashChain.verify_chain` validates hash chain between records + +## [1.4.35] - 2026-03-17 + +### Added +- `Chat::Team`: multi-user context tracking with thread-local user, env detection +- `Chat::ProgressBar`: progress indicator for long-running operations with ETA +- `legion notebook read/export`: Jupyter notebook reading and export (markdown/script) + +## [1.4.34] - 2026-03-17 + +### Added +- `Legion::Registry`: central extension metadata store with search, risk tier filtering, AIRB status +- `Legion::Sandbox`: capability-based extension sandboxing with enforcement toggle +- `Legion::Registry::SecurityScanner`: naming convention, checksum, and gemspec metadata validation +- `legion marketplace`: CLI for search, info, list, scan operations + +## [1.4.33] - 2026-03-17 + +### Added +- `legion cost summary`: overall cost summary (today/week/month) +- `legion cost worker `: per-worker cost breakdown +- `legion cost top`: top cost consumers ranked by spend +- `legion cost export`: export cost data as JSON or CSV +- `Legion::CLI::CostData::Client`: API client for cost data retrieval + +### Fixed +- `Connection.resolve_config_dir` spec now correctly stubs `~/.legionio/settings` path + +## [1.4.32] - 2026-03-17 + +### Fixed +- `NotificationBridge` missing `require_relative 'notification_queue'` causing `NameError` on `legion chat` + +## [1.4.31] - 2026-03-16 + +### Added +- Skills system: `.legion/skills/` and `~/.legionio/skills/` YAML frontmatter markdown files +- `Legion::Chat::Skills`: discovery, parsing, and find for skill files +- `/skill-name` invocation in chat resolves user-defined skills +- `legion skill list`, `legion skill show`, `legion skill create`, `legion skill run` CLI subcommands + +## [1.4.30] - 2026-03-16 + +### Added +- `MCP::Auth`: token-based MCP authentication (JWT + API key) +- `MCP::ToolGovernance`: risk-tier-aware tool filtering and invocation audit +- `MCP.server_for(token:)` builds identity-scoped MCP server instances +- HTTP transport auth: Bearer token validation with 401 response on failure +- MCP settings: `mcp.auth.enabled`, `mcp.auth.allowed_api_keys`, `mcp.governance.enabled`, `mcp.governance.tool_risk_tiers` + +## [1.4.29] - 2026-03-16 + +### Added +- `legion init`: one-command workspace setup with environment detection +- `InitHelpers::EnvironmentDetector`: checks for RabbitMQ, database, Vault, Redis, git, existing config +- `InitHelpers::ConfigGenerator`: ERB template-based config generation, `.legion/` workspace scaffolding +- `--local` flag for zero-dependency development mode +- `--force` flag to overwrite existing config files + +## [1.4.28] - 2026-03-16 + +### Added +- `Legion::Telemetry` module: opt-in OpenTelemetry tracing with `with_span` wrapper +- `setup_telemetry` in Service: initializes OTel SDK with OTLP exporter when `telemetry.enabled: true` +- `sanitize_attributes` helper for safe OTel attribute conversion +- `record_exception` helper for span error recording + +## [1.4.27] - 2026-03-16 + +### Added +- `legion update` CLI command: updates all Legion gems (`legionio`, `legion-*`, `lex-*`) using the current Ruby's gem binary +- `--dry-run` flag to check available updates without installing +- `--json` flag for machine-readable output +- Updates install into the running Ruby's GEM_HOME (safe for Homebrew bundled installs) + +## [1.4.26] - 2026-03-16 + +### Added +- `Legion::Metrics` module: opt-in Prometheus metrics via `prometheus-client` gem +- `GET /metrics` endpoint returning Prometheus text-format output +- 9 metrics: uptime, active_workers, tasks_total, tasks_per_second, error_rate, consent_violations, llm_requests, llm_tokens +- Event-driven counters + pull-based gauge refresh on scrape +- `/metrics` added to Auth middleware SKIP_PATHS +- Wired into Service startup and shutdown + +## [1.4.25] - 2026-03-16 + +### Added +- `Legion::Chat::NotificationQueue`: thread-safe priority queue for background notifications +- `Legion::Chat::NotificationBridge`: event-driven bridge matching Legion events to chat notifications +- Chat REPL displays pending notifications before each prompt (critical in red, info in yellow) +- Configurable notification patterns via `chat.notifications.patterns` setting + +## [1.4.24] - 2026-03-16 + +### Added +- `Legion::Audit.recent_for` — query audit records by principal and time window +- `Legion::Audit.count_for` — count audit records by principal and time window +- `Legion::Audit.failure_count_for` / `success_count_for` — convenience wrappers +- `Legion::Audit.resources_for` — distinct resources invoked by a principal +- `Legion::Audit.recent` — most recent N records with optional filters +- All query methods return safe defaults (`[]` or `0`) when legion-data is unavailable + +## [1.4.23] - 2026-03-16 + +### Added +- `Middleware::BodyLimit`: request body size limit (1MB max, returns 413) +- `API::Validators` helper module: `validate_required!`, `validate_string_length!`, `validate_enum!`, `validate_uuid!`, `validate_integer!` +- Ingress payload validation: 512KB size limit, runner_class/function format checks + +### Security +- Ingress validates runner_class format before `Kernel.const_get` to prevent arbitrary constant resolution +- Ingress validates function format before `.send` to prevent method injection + +## [1.4.22] - 2026-03-16 + +### Added +- `Legion::Alerts`: configurable alerting rules engine with event pattern matching +- `Alerts::Engine`: count-based conditions, cooldown deduplication, multi-channel dispatch +- 4 default rules: consent_violation, extinction_trigger, error_spike, budget_exceeded +- Channel dispatch: events (via `Legion::Events`), log (via `Legion::Logging`), webhook +- Settings: `alerts.enabled`, `alerts.rules` +- Wired into `Service` startup (opt-in via `alerts.enabled: true`) + +## [1.4.21] - 2026-03-16 + +### Added +- `Middleware::ApiVersion`: rewrites `/api/v1/` paths to `/api/` for future versioned API support +- Deprecation headers (`Deprecation`, `Sunset`, `Link`) on unversioned `/api/` paths +- `X-API-Version` request header set for versioned paths +- Skip paths: `/api/health`, `/api/ready`, `/api/openapi.json`, `/metrics` + +## [1.4.20] - 2026-03-16 + +### Added +- `Middleware::RateLimit`: sliding-window rate limiting with per-IP, per-agent, per-tenant tiers +- In-memory store (default) with lazy reap; distributed store via `Legion::Cache` when available +- Standard headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` (429 only) +- Skip paths: `/api/health`, `/api/ready`, `/api/metrics`, `/api/openapi.json` + +## [1.4.19] - 2026-03-16 + +### Added +- Local development mode: `LEGION_LOCAL=true` env var or `local_mode: true` in settings +- Auto-configures in-memory transport, mock Vault, and dev settings + +## [1.4.18] - 2026-03-16 + +### Added +- `legion config scaffold` auto-detects environment variables and enables providers +- Detects: AWS_BEARER_TOKEN_BEDROCK, ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, VAULT_TOKEN, RABBITMQ_USER/PASSWORD +- Detects running Ollama on localhost:11434 +- First detected LLM provider becomes the default; credentials use `env://` references +- JSON output includes `detected` array for automation + +## [1.4.17] - 2026-03-16 + +### Added +- `Legion::Audit` publisher module for immutable audit logging via AMQP +- Audit hook in `Runner.run` records every runner execution (event_type, duration, status) +- Audit hook in `DigitalWorker::Lifecycle.transition!` records state transitions +- `GET /api/audit` endpoint with filters (event_type, principal_id, source, status, since, until) +- `GET /api/audit/verify` endpoint for hash chain integrity verification +- `legion audit list` and `legion audit verify` CLI commands +- Silent degradation: audit never interferes with normal operation (triple guard + rescue) + +## [1.4.16] - 2026-03-16 + +### Added +- `legion worker create NAME` CLI command: provisions digital worker in bootstrap state with DB record + optional Vault secret storage + +## [1.4.15] - 2026-03-16 + +### Added +- RAI invariant #2: Ingress.run calls Registry.validate_execution! when worker_id is present +- Unregistered or inactive workers are blocked with structured error (no exception propagation) +- Registration check fires before RBAC authorization (registration precedes permission) + +## [1.4.14] - 2026-03-16 + +### Added +- Optional RBAC integration via legion-rbac gem (`if defined?(Legion::Rbac)` guards) +- `GET /api/workers/:id/health` endpoint returns worker health status with node metrics +- `health_status` query filter on `GET /api/workers` +- Thread-safe local worker tracking in `DigitalWorker::Registry` for heartbeat reporting +- `Legion::DigitalWorker.active_local_ids` delegate method +- `setup_rbac` lifecycle hook in Service (after setup_data) +- `authorize_execution!` guard in Ingress for task execution +- Rack middleware registration in API when legion-rbac loaded +- REST API routes for RBAC management (roles, assignments, grants, cross-team grants, check) +- `legion rbac` CLI subcommand (roles, show, assignments, assign, revoke, grants, grant, check) +- MCP tools: legion.rbac_check, legion.rbac_assignments, legion.rbac_grants + +## [1.4.13] - 2026-03-16 + +### Changed +- SIGHUP signal now triggers `Legion.reload` instead of logging only + +## [1.4.12] - 2026-03-16 + +### Added +- `--http-port` CLI flag for `legion start` to override API port without editing settings +- `apply_cli_overrides` method in `Service` applies CLI-provided overrides after settings load + +## [1.4.11] - 2026-03-16 + +### Fixed +- Sinatra and Puma no longer write startup banners directly to stdout +- API logging routed through `Legion::Logging` for consistent log format +- Puma log writer silenced via `StringIO` redirect in `setup_api` + +## [1.4.10] - 2026-03-16 + +### Fixed +- API startup no longer crashes when port is already in use (rolling restart support) +- `setup_api` retries binding up to 10 times with 3s wait (configurable via `api.bind_retries` and `api.bind_retry_wait`) +- Port bind failure after retries marks API as not-ready instead of killing the thread + +## [1.4.9] - 2026-03-16 + +### Added +- YJIT enabled at process start for 15-30% runtime throughput improvement (Ruby 3.1+ builds) +- GC tuning ENV defaults for large gem count workloads (overridable via environment) +- bootsnap bytecode and load-path caching at `~/.legionio/cache/bootsnap/` +- Role-based extension profiles: nil (all), core, cognitive, service, dev, custom +- Extension discovery uses Bundler specs when available for faster boot + +### Changed +- `find_extensions` uses `Bundler.load.specs` instead of `Gem::Specification.all_names` under Bundler +- `lex-` prefix check uses `start_with?` instead of string slicing + +## v1.4.8 + +### Fixed +- Relationships API routes now fully functional (removed 501 stub guards, backed by legion-data migration) +- Relationships MCP tool no longer checks for missing model +- Gaia API route returns 503 instead of 500 when `Legion::Gaia` is defined but lacks `started?` method + +## v1.4.7 + +### Added +- Extension-powered chat tools: LEX extensions can ship optional `tools/` directories with `RubyLLM::Tool` subclasses +- `ExtensionToolLoader` lazily discovers extension tools at chat startup +- `permission_tier` DSL for extension tools (`:read`, `:write`, `:shell`) with settings override +- Session mode ceiling: read_only blocks write/shell extension tools regardless of tool declaration +- Plan mode uses tier-based filtering (no longer hardcoded tool list) +- `legion generate tool ` scaffolds tool + spec in current LEX +- `legion lex create` now includes empty `tools/` directory +- Tab completion updated for `legion generate tool` +- `Permissions.register_extension_tier` and `Permissions.clear_extension_tiers!` for extension tool tier management +- System prompt includes extension tool names when available + +## v1.4.6 + +### Added +- `legion doctor` CLI command diagnoses the LegionIO environment and prescribes fixes +- 10 environment checks: Ruby version, bundle status, config files, RabbitMQ, database, cache, Vault, extensions, PID files, permissions +- `--fix` flag for auto-remediation of fixable issues (stale PIDs, missing gems, missing config) +- `--json` flag for machine-readable diagnosis output with pass/fail/warn/skip per check +- `Doctor::Result` value object with status, message, prescription, and auto_fixable fields +- Exit code 1 when any check fails, 0 when all checks pass or warn + +## v1.4.5 + +### Added +- `legion openapi generate` CLI command outputs OpenAPI 3.1.0 spec JSON to stdout or file (-o) +- `legion openapi routes` CLI command lists all API routes with HTTP method and summary +- `GET /api/openapi.json` endpoint serves the full OpenAPI 3.1.0 spec at runtime (auth skipped) +- `Legion::API::OpenAPI` module with `.spec` (returns Hash) and `.to_json` class methods +- OpenAPI spec covers all 44 routes across 16 resource groups with request/response schemas +- Auth middleware SKIP_PATHS updated to include `/api/openapi.json` + +## v1.4.4 + +### Added +- `legion completion bash` subcommand outputs bash tab completion script +- `legion completion zsh` subcommand outputs zsh tab completion script +- `legion completion install` subcommand prints shell-specific installation instructions +- `completions/legion.bash` bash completion script with full command tree coverage +- `completions/_legion` zsh completion script with descriptions for all commands and flags +- `legion lex create` now scaffolds a standalone `Client` class in new extensions + +## v1.4.3 + +### Added +- `legion gaia status` CLI subcommand (probes GET /api/gaia/status, shows cognitive layer health) +- `GET /api/gaia/status` API route returns GAIA boot state, active channels, heartbeat health +- `legion schedule` CLI subcommand (list, show, add, remove, logs) wrapping /api/schedules +- `/commit` chat slash command (AI-generated commit message from staged changes) +- `/workers` chat slash command (list digital workers from running daemon) +- `/dream` chat slash command (trigger dream cycle on running daemon) + +## v1.4.2 + +### Added +- Multiline input support in chat REPL via backslash continuation (end a line with `\` to continue) +- Continuation prompt (`...`) for multiline input lines +- Specs for `read_user_input` method (12 examples) + +## v1.4.1 + +### Added +- CLI status indicators using TTY::Spinner for chat REPL +- Session lifecycle events (:llm_start, :llm_first_token, :llm_complete, :tool_start, :tool_complete) +- StatusIndicator class subscribes to session events and manages spinner display +- Purple-themed braille dot spinner with phase labels (thinking..., running tool_name...) +- Tool counter prefix ([1/3]) for multi-tool loops +- Graceful degradation for non-TTY output (piped, redirected) + +## v1.4.0 + +### Added +- File edit checkpointing system with `/rewind` to undo edits (per-edit, N steps, or per-file) +- Persistent memory system (`/memory`, `.legion/memory.md`, `~/.legion/memory/global.md`) +- `legion memory` CLI subcommand for managing persistent memory entries +- Web search via DuckDuckGo HTML scraping (`/search` slash command) +- Background subagent spawning via headless subprocess (`/agent`, `SpawnAgent` tool) +- Custom agent definitions (`.legion/agents/*.json` or `.yaml`) with `@name` delegation +- Plan mode toggle (`/plan`) — restricts tools to read-only for exploration +- `legion plan` CLI subcommand for standalone read-only exploration sessions +- Multi-agent swarm orchestration (`/swarm`, `legion swarm` CLI subcommand) +- `SaveMemory` and `SearchMemory` LLM tools for auto-remembering +- `WebSearch` LLM tool for web search during conversations +- Checkpoint integration in `WriteFile` and `EditFile` tools (auto-save before writes) + +### Changed +- Rubocop exclusions added for plan_command.rb and swarm_command.rb (BlockLength) +- Rubocop exclusions added for chat_command.rb (MethodLength, CyclomaticComplexity) + +## v1.3.0 + +### Added +- `legion chat` interactive REPL and headless prompt mode with LLM integration +- `legion commit` command for AI-generated commit messages +- `legion pr` command for AI-generated pull request descriptions +- `legion review` command for AI-powered code review +- `/fetch` slash command for injecting web page context into chat sessions +- Chat permission system with read/write/shell tiers and auto-approve mode +- Chat session persistence (save/load/list) and `/compact` context compression +- `--max-budget-usd` cost cap for chat sessions +- `--incognito` mode to disable automatic session history saving +- Markdown rendering for chat responses (via rouge) +- Purple palette theme, orbital ASCII banner, and branded CLI output +- Chat logger for structured debug/info logging + +### Changed +- Worker lifecycle CLI passes `authority_verified`/`governance_override` flags +- Worker API accepts governance flags from request body +- Config `path` command now respects `--config-dir` option + +### Fixed +- Config `sensitive_key?` false positive: `cluster_secret_timeout` no longer redacted +- `check_command` now rescues `LoadError` (missing gems no longer crash the check run) +- Config `show`/`path`/`validate` commands call `Connection.shutdown` in ensure blocks +- Config `path` and `validate` rescue `CLI::Error` properly +- Worker CLI/API handle `GovernanceRequired` and `AuthorityRequired` exceptions +- Removed unused `--json`/`--no-color` class_options from generate and mcp commands + ## v1.2.1 * Updating LEX CLI templates * Fixing issue with LEX schema migrator ## v1.2.0 -Moving from BitBucket to GitHub inside the Optum org. All git history is reset from this point on +Moving from BitBucket to GitHub. All git history is reset from this point on diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..2d38ac37 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# LegionIO + +Primary gem. Orchestrates all `legion-*` gems and loads LEX extensions. + +**GitHub**: https://github.com/LegionIO/LegionIO +**Gem**: `legionio` | **Ruby**: >= 3.4 + +## Binary Split + +| Binary | Purpose | +|--------|---------| +| `legion` | Interactive TTY shell + dev-workflow (chat, commit, review, plan, memory) | +| `legionio` | Daemon lifecycle + operational commands (start, stop, lex, task, config, mcp) | + +## Boot Sequence + +Executables enable YJIT + GC tuning (600k heap slots). Bootsnap is **opt-in** — +set `LEGION_BOOTSNAP=true` (it is no longer applied unconditionally). + +``` +Legion.start → Legion::Service.new + 1. setup_logging + 2. setup_settings + 3. Legion::Crypt.start + 4. setup_transport (RabbitMQ) + 5. require legion-cache + 6. setup_data (optional) + 7. setup_rbac (optional) + 8. setup_llm (optional) + 9. setup_apollo (optional) + 10. setup_gaia (optional) + 11. setup_telemetry (optional) + 12. setup_supervision + 13. load_extensions (multi-phase: phase 0 identity, phase 1 everything else, parallel) + 14. Legion::Crypt.cs (distribute cluster secret) + 15. setup_api (Sinatra/Puma on port 4567) +``` + +Extension loading: phase 0 = `lex-identity-*` (sequential), phase 1 = everything else on `Concurrent::FixedThreadPool(24)`. After all phases: catalog transitions + registry writes. + +## Extension Discovery + +`find_extensions` discovers `lex-*` gems via Bundler or `Gem::Specification`. Category registry determines load phase and tier. Extensions declare requirements via `data_required?`, `cache_required?`, `crypt_required?`, `vault_required?`, `llm_required?` — skipped if dependency unavailable. + +Role profiles filter extensions: `nil` (all), `:core` (14), `:cognitive` (core + agentic), `:service` (core + integrations), `:dev` (core + AI + essential agentic), `:custom` (explicit list). + +## CLI Design Rules + +- Thor 1.5+ reserves `run` — use `map 'run' => :trigger` in Task subcommand +- `::Process` must be explicit (resolves to `Legion::Process` otherwise) +- `::JSON` must be explicit (resolves to `Legion::JSON` otherwise) +- All commands support `--json` and `--no-color` at class_option level +- `Connection` module has class-level `ensure_*` methods, not instance-based + +## API Design + +- `Legion::API < Sinatra::Base` with `set :host_authorization, permitted: :any` +- Response: `{ data: ..., meta: { timestamp:, node: } }` +- Error: `{ error: { code:, message: }, meta: ... }` +- `Legion::JSON.dump` — 1 positional arg, wrap kwargs in `{}` +- `Legion::JSON.load` — returns symbol keys + +## Module Structure (Key Parts) + +``` +Legion +├── Service # Lifecycle orchestrator +├── Process # PID, signals, daemonization +├── Readiness # Component readiness tracking +├── Events # In-process pub/sub (on/emit/once/off, wildcard *) +├── Ingress # Universal runner entry point (normalize + run) +├── Extensions # Discovery, loading, actors, builders, helpers +│ ├── Core # Mixin: requirement flags, autobuild +│ ├── Actors/ # Every, Loop, Once, Poll, Subscription, Nothing +│ └── Builders/ # Actors, Runners, Helpers, Hooks, Routes +├── Tools # Registry (always/deferred), Discovery, EmbeddingCache +├── API # Sinatra routes, middleware (Auth, Tenant, RateLimit, BodyLimit) +├── DigitalWorker # AI-as-labor: Lifecycle, Registry, RiskTier, ValueMetrics +├── CLI # Thor commands (40+ subcommands) +│ └── Chat # Interactive AI REPL (sessions, tools, memory, agents, skills) +└── Graph # Task relationship visualization (Mermaid/DOT) +``` + +## Where Things Live (most-touched) + +| Path | Purpose | +|------|---------| +| `lib/legion.rb` | Entry: `Legion.start`, `.shutdown`, `.reload` | +| `lib/legion/service.rb` | 15-phase startup orchestrator | +| `lib/legion/cli.rb` + `lib/legion/cli/` | Thor CLI — two binaries, 40+ subcommands | +| `lib/legion/cli/chat/` | Interactive AI REPL (sessions, tools, agents, memory, skills) | +| `lib/legion/api.rb` + `lib/legion/api/` | Sinatra REST API + middleware (Auth, Tenant, RateLimit, BodyLimit) | +| `lib/legion/extensions/` | LEX discovery, loading, actors, builders | +| `lib/legion/tools/` | Canonical tool layer (Registry, Discovery, EmbeddingCache) | +| `lib/legion/digital_worker/` | AI-as-labor governance (Lifecycle, RiskTier, ValueMetrics) | +| `exe/legion`, `exe/legionio` | The two binaries; perf opts (YJIT/GC, opt-in bootsnap) applied here | +| `spec/` | RSpec suite (~3500+ examples) | + +LLM HTTP routes are **owned by `legion-llm`** and mounted from it — LegionIO no longer +defines its own LLM routes or a provider gateway fallback. + +## Lite Mode + +`LEGION_MODE=lite` — `InProcess` transport adapter + `Memory` cache adapter. No RabbitMQ/Redis needed. + +## Development + +```bash +bundle exec rspec # ~3500+ examples +bundle exec rubocop # 0 offenses +``` + +Always run both before committing. Specs use `rack-test`. `Legion::JSON.load` returns symbol keys. + +## Rubocop + +`.rubocop.yml` excludes `spec/**/*` from `Metrics/BlockLength`. `chat_command.rb` excluded from most Metrics cops. Hash alignment: `table` style. diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..51f91678 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,39 @@ +# Default owner — all files +* @Esity @LegionIO/core + +# Core library code +# lib/ @Esity @future-core-team + +# CLI commands +# lib/legion/cli/ @Esity + +# REST API +# lib/legion/api/ @Esity + +# Extensions loader +# lib/legion/extensions/ @Esity + +# Service orchestrator and boot sequence +# lib/legion/service.rb @Esity @future-core-team + +# Digital Worker platform +# lib/legion/digital_worker/ @Esity @future-platform-team + +# Chat and AI REPL +# lib/legion/cli/chat/ @Esity @future-ai-team + +# Audit and compliance +# lib/legion/audit/ @Esity @future-security-team +# lib/legion/api/audit.rb @Esity @future-security-team + +# API middleware +# lib/legion/api/middleware/ @Esity @future-platform-team + +# Specs +# spec/ @Esity @future-contributors + +# Documentation +# *.md @Esity @future-docs-team + +# CI/CD +# .github/ @Esity diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 52c7f950..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,75 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -nationality, personal appearance, race, religion, or sexual identity and -orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or -advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project email -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at [opensource@optum.com][email]. All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [http://contributor-covenant.org/version/1/4][version] - -[homepage]: http://contributor-covenant.org -[version]: http://contributor-covenant.org/version/1/4/ -[email]: mailto:opensource@optum.com \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b0c397d2..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,55 +0,0 @@ -# Contribution Guidelines - -Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. Please also review our [Contributor License Agreement ("CLA")](INDIVIDUAL_CONTRIBUTOR_LICENSE.md) prior to submitting changes to the project. You will need to attest to this agreement following the instructions in the [Paperwork for Pull Requests](#paperwork-for-pull-requests) section below. - ---- - -# How to Contribute - -Now that we have the disclaimer out of the way, let's get into how you can be a part of our project. There are many different ways to contribute. - -## Issues - -We track our work using Issues in GitHub. Feel free to open up your own issue to point out areas for improvement or to suggest your own new experiment. If you are comfortable with signing the waiver linked above and contributing code or documentation, grab your own issue and start working. - -## Coding Standards - -We have some general guidelines towards contributing to this project. -Please run RSpec and Rubocop while developing code for LegionIO - -### Languages - -*Ruby* - -## Pull Requests - -If you've gotten as far as reading this section, then thank you for your suggestions. - -## Paperwork for Pull Requests - -* Please read this guide and make sure you agree with our [Contributor License Agreement ("CLA")](INDIVIDUAL_CONTRIBUTOR_LICENSE.md). -* Make sure git knows your name and email address: - ``` - $ git config user.name "J. Random User" - $ git config user.email "j.random.user@example.com" - ``` ->The name and email address must be valid as we cannot accept anonymous contributions. -* Write good commit messages. -> Concise commit messages that describe your changes help us better understand your contributions. -* The first time you open a pull request in this repository, you will see a comment on your PR with a link that will allow you to sign our Contributor License Agreement (CLA) if necessary. -> The link will take you to a page that allows you to view our CLA. You will need to click the `Sign in with GitHub to agree button` and authorize the cla-assistant application to access the email addresses associated with your GitHub account. Agreeing to the CLA is also considered to be an attestation that you either wrote or have the rights to contribute the code. All committers to the PR branch will be required to sign the CLA, but you will only need to sign once. This CLA applies to all repositories in the Optum org. - -## General Guidelines - -Ensure your pull request (PR) adheres to the following guidelines: - -* Try to make the name concise and descriptive. -* Give a good description of the change being made. Since this is very subjective, see the [Updating Your Pull Request (PR)](#updating-your-pull-request-pr) section below for further details. -* Every pull request should be associated with one or more issues. If no issue exists yet, please create your own. -* Make sure that all applicable issues are mentioned somewhere in the PR description. This can be done by typing # to bring up a list of issues. - -### Updating Your Pull Request (PR) - -A lot of times, making a PR adhere to the standards above can be difficult. If the maintainers notice anything that we'd like changed, we'll ask you to edit your PR before we merge it. This applies to both the content documented in the PR and the changed contained within the branch being merged. There's no need to open a new PR. Just edit the existing one. - -[email]: mailto:opensource@optum.com \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 0c6c8811..3066e3c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,27 @@ -FROM ruby:3-alpine -LABEL maintainer="Matthew Iverson " +# Build stage +FROM ruby:3.4-slim AS builder +WORKDIR /app +RUN apt-get update && \ + apt-get install -y --no-install-recommends build-essential libpq-dev default-libmysqlclient-dev git && \ + rm -rf /var/lib/apt/lists/* +COPY Gemfile legionio.gemspec ./ +COPY lib/legion/version.rb lib/legion/ +RUN bundle lock && \ + bundle config set --local without 'development test' && \ + bundle install --jobs 4 --retry 3 +COPY . . -RUN mkdir /etc/legionio -RUN apk update && apk add build-base postgresql-dev mysql-client mariadb-dev tzdata gcc git - -COPY . ./ -RUN gem install legionio tzinfo-data tzinfo --no-document --no-prerelease -CMD ruby --jit $(which legionio) +# Runtime stage +FROM ruby:3.4-slim AS runtime +RUN apt-get update && \ + apt-get install -y --no-install-recommends libpq5 default-mysql-client-core curl && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd -r legion && useradd -r -g legion -d /app -s /sbin/nologin legion +WORKDIR /app +COPY --from=builder --chown=legion:legion /app /app +USER legion +EXPOSE 4567 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -sf http://localhost:4567/api/health || exit 1 +ENTRYPOINT ["bundle", "exec"] +CMD ["legion", "start"] diff --git a/Gemfile b/Gemfile index edaf6575..181321f1 100755 --- a/Gemfile +++ b/Gemfile @@ -1,10 +1,61 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gemspec +gem 'pg' + +gem 'kramdown', '>= 2.0' +gem 'mysql2' + +gem 'legion-data', path: '../legion-data' if File.exist?(File.expand_path('../legion-data', __dir__)) +gem 'legion-logging', path: '../legion-logging' if File.exist?(File.expand_path('../legion-logging', __dir__)) +gem 'legion-settings', path: '../legion-settings' if File.exist?(File.expand_path('../legion-settings', __dir__)) + +gem 'legion-apollo', path: '../legion-apollo' if File.exist?(File.expand_path('../legion-apollo', __dir__)) +gem 'legion-gaia', path: '../legion-gaia' if File.exist?(File.expand_path('../legion-gaia', __dir__)) +gem 'legion-llm', path: '../legion-llm' if File.exist?(File.expand_path('../legion-llm', __dir__)) +gem 'legion-mcp', path: '../legion-mcp' if File.exist?(File.expand_path('../legion-mcp', __dir__)) +gem 'legion-tty', path: '../legion-tty' if File.exist?(File.expand_path('../legion-tty', __dir__)) + +gem 'lex-apollo', path: '../extensions/lex-apollo' if File.exist?(File.expand_path('../extensions/lex-apollo', __dir__)) +gem 'lex-llm', path: '../extensions-ai/lex-llm' if File.exist?(File.expand_path('../extensions-ai/lex-llm', __dir__)) +# gem 'lex-microsoft_teams', path: '../extensions/lex-microsoft_teams' if File.exist?(File.expand_path('../extensions/lex-microsoft_teams', __dir__)) + +gem 'lex-lex', path: '../extensions/lex-lex' if File.exist?(File.expand_path('../extensions/lex-lex', __dir__)) +gem 'lex-llm-ledger', path: '../extensions-ai/lex-llm-ledger' if File.exist?(File.expand_path('../extensions-ai/lex-llm-ledger', __dir__)) +gem 'lex-scheduler', path: '../extensions/lex-scheduler' if File.exist?(File.expand_path('../extensions/lex-scheduler', __dir__)) +gem 'lex-tasker', path: '../extensions/lex-tasker' if File.exist?(File.expand_path('../extensions/lex-tasker', __dir__)) + +if File.exist?(File.expand_path('../extensions-identity/lex-identity-entra', __dir__)) + gem 'lex-identity-entra', path: '../extensions-identity/lex-identity-entra' +end +if File.exist?(File.expand_path('../extensions-identity/lex-identity-kerberos', __dir__)) + gem 'lex-identity-kerberos', path: '../extensions-identity/lex-identity-kerberos' +end + +gem 'lex-kerberos', path: '../extensions-identity/lex-kerberos' if File.exist?(File.expand_path('../extensions-identity/lex-kerberos', __dir__)) + +if File.exist?(File.expand_path('../extensions-identity/lex-identity-system', __dir__)) + gem 'lex-identity-system', path: '../extensions-identity/lex-identity-system' +end + +%w[anthropic azure-foundry bedrock gemini mlx ollama openai vertex vllm].each do |provider| + provider_path = "../extensions-ai/lex-llm-#{provider}" + gem "lex-llm-#{provider}", path: provider_path if File.exist?(File.expand_path(provider_path, __dir__)) +end + group :test do + gem 'faraday' + gem 'faraday-net_http' + gem 'graphql' + gem 'lex-codegen' + gem 'lex-eval' + gem 'rack-test' gem 'rake' gem 'rspec' - gem 'rspec_junit_formatter' gem 'rubocop' + gem 'rubocop-legion' + gem 'rubocop-rspec' gem 'simplecov' end diff --git a/INDIVIDUAL_CONTRIBUTOR_LICENSE.md b/INDIVIDUAL_CONTRIBUTOR_LICENSE.md deleted file mode 100644 index 79460dc6..00000000 --- a/INDIVIDUAL_CONTRIBUTOR_LICENSE.md +++ /dev/null @@ -1,30 +0,0 @@ -# Individual Contributor License Agreement ("Agreement") V2.0 - -Thank you for your interest in this Optum project (the "PROJECT"). In order to clarify the intellectual property license granted with Contributions from any person or entity, the PROJECT must have a Contributor License Agreement ("CLA") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of the PROJECT and its users; it does not change your rights to use your own Contributions for any other purpose. - -You accept and agree to the following terms and conditions for Your present and future Contributions submitted to the PROJECT. In return, the PROJECT shall not use Your Contributions in a way that is inconsistent with stated project goals in effect at the time of the Contribution. Except for the license granted herein to the PROJECT and recipients of software distributed by the PROJECT, You reserve all right, title, and interest in and to Your Contributions. -1. Definitions. - -"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with the PROJECT. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. - -"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the PROJECT for inclusion in, or documentation of, any of the products owned or managed by the PROJECT (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the PROJECT or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the PROJECT for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." - -2. Grant of Copyright License. - -Subject to the terms and conditions of this Agreement, You hereby grant to the PROJECT and to recipients of software distributed by the PROJECT a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works. - -3. Grant of Patent License. - -Subject to the terms and conditions of this Agreement, You hereby grant to the PROJECT and to recipients of software distributed by the PROJECT a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. - -4. Representations. - - (a) You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to the PROJECT, or that your employer has executed a separate Corporate CLA with the PROJECT. - - (b) You represent that each of Your Contributions is Your original creation (see section 6 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions. - -5. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. - -6. Should You wish to submit work that is not Your original creation, You may submit it to the PROJECT separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]". - -7. You agree to notify the PROJECT of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 93234d85..20cba511 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2021 Optum + Copyright 2021 Esity Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/NOTICE.txt b/NOTICE.txt deleted file mode 100644 index a4b923a6..00000000 --- a/NOTICE.txt +++ /dev/null @@ -1,9 +0,0 @@ -LegionIO -Copyright 2021 Optum - -Project Description: -==================== -LegionIO is an automation framework for create IFTTT style relationships, scheduling and managing sync and async tasks/jobs - -Author(s): -Esity \ No newline at end of file diff --git a/README.md b/README.md index c6eac31f..9122eec4 100644 --- a/README.md +++ b/README.md @@ -1,162 +1,621 @@ -# LegionIO - -LegionIO is a framework for automating and connecting things. You can see all the docs inside confluence -https://legionio.atlassian.net/wiki/spaces/LEGION/overview -https://legionio.atlassian.net/wiki/spaces/LEX/pages/7864551/Extensions -*Soon to be migrated to GitHub Wiki* - -### What does it do? -LegionIO is an async job engine designed for scheduling tasks and creating relationships between things that wouldn't -otherwise be connected. Relationships do not have to be a single path. Both of these would work -* `foo → bar → cat → dog` -``` -a → b → c - b → e → z - e → g -``` -In the second scenario, when a runs, it causes b to run which then causes both c and e to run in parallel - -It supports both conditions and transformation. The idea of a transformation is you can't connect two indepedent services -and expect them to know how to talk to each other. - -### Running -Run `gem install legionio` to install legion. If you want to use database features, you will need to -run `gem install legion-data` also. - -After installing gem you can use the commands `legionio` to start legion, `legion` to access things -and `lex_gen` to generate a new legion extension - -### Example Legion Extensions(LEX) -* [lex-http](https://github.com/LegionIO/lex-http/src/master/) - Gives legion the ability to make http requests, [docs](https://legionio.atlassian.net/wiki/spaces/LEX/pages/12910593/Lex+Http) -* [lex-influxdb](https://github.com/LegionIO/lex-influxdb/src/master/) - Write, read, and manage influxdb nodes, [docs](https://legionio.atlassian.net/wiki/spaces/LEX/pages/614891774/Lex+Influxdb) -* [lex-log](https://github.com/LegionIO/lex-log/src/master/) - Send log items to either stdout or a file with lex-log, [docs](https://legionio.atlassian.net/wiki/spaces/LEX/pages/614858995/Lex+Log) -* [lex-memcache](https://github.com/LegionIO/lex-memcached/src/master/) - run memcached commands like set, add, append, delete, flush, reset_stats against memcached servers, [docs](https://legionio.atlassian.net/wiki/spaces/LEX/pages/614858753/Lex+Memcached) -* [lex-pihole](https://github.com/LegionIO/lex-pihole/src/master/) - Allows Legion to interact with [Pi-Hole](https://pi-hole.net/). Can do things like get status, add/remove domains from the list, etc -* [lex-ping](https://github.com/LegionIO/lex-ping/src/master/) - You can ping things?, [docs](https://legionio.atlassian.net/wiki/spaces/LEX/pages/631373895/Lex+Ping) -* [lex-pushover](https://github.com/LegionIO/lex-pushover/src/master/) - Connects Legion to [Pushover](https://pushover.net/), [docs]() -* [lex-redis](https://github.com/LegionIO/lex-redis/src/master/) - similiar to lex-memcached but for redis -* [lex-sleepiq](https://github.com/LegionIO/lex-sleepiq/src/master/) - Control your SleepIQ bed with Legion! -* [lex-ssh](https://github.com/LegionIO/lex-ssh/src/master/) - Send commands to a server via SSH in an async fashion, [docs](https://legionio.atlassian.net/wiki/spaces/LEX/pages/614891551/Lex+SSH) - -Bitbucket repos for extensions that are active or being worked on -[lex list](https://github.com/LegionIO/workspace/projects/LEX) -A nice list in the wiki to view all the extensions, their docs and status -[Legion Extensions](https://github.com/topics/legionio?l=ruby) - -### Scheduling Tasks -1) Ensure you have the Legion::Data gem installed and configured -2) Make sure to have `lex-scheduler` extension installed so that it generates the schedules table in the database -3) From there you can add a function to be run at a given cron syntax or interval -4) Setting the interval column will make the job run X seconds after the last time it is completed and will ignore the cron colum -5) Setting the cron column will ensure the job runs at the given times regardless of when it was run last, only works if interval is null -6) Cron supports both `*/5 * * * *` style and verbose like `every minute` and `every day at noon` - -### Creating Relationships -*To be populated* +

LegionIO

+ +

+ One Ruby gem that is a distributed async job engine, an AI coding assistant, an MCP server, + and a cognitive-computing platform — and runs with zero required infrastructure. +

+ +

+ Gem Version + Ruby + License + HA +

+ +``` + ╭──────────────────────────────────────╮ + │ L E G I O N I O │ + │ │ + │ async jobs · AI chat · MCP │ + │ REST API · HA · cognitive AI │ + │ zero-infra lite mode · Vault │ + ╰──────────────────────────────────────╯ +``` + +> Schedule tasks, chain services into dependency graphs, run them concurrently across a RabbitMQ +> fleet, and orchestrate AI-powered workflows — from a single `legion` command. Then run the whole +> thing with **no RabbitMQ, no Redis, nothing** via lite mode. + +## Why LegionIO + +- 🧩 **Four products in one gem.** A RabbitMQ-backed async **job engine**, an **AI coding assistant** (chat, commit, review, PR, multi-agent), an **MCP server** that exposes your infrastructure to any agent, and a brain-modeled **cognitive platform** — all in one `gem install`. +- 🪶 **Zero-infrastructure lite mode.** `LEGION_MODE=lite` swaps RabbitMQ for in-process pub/sub and Redis/Memcached for an in-memory cache. Every feature still works — `gem install` to a running daemon in seconds, no services to stand up. +- 🔗 **Dependency-graph orchestration.** Chain tasks with JSON conditions and ERB transformations, fan out in parallel, and scale by simply launching more processes — RabbitMQ distributes the work automatically (tested to 100+ workers). +- 🤖 **AI workflows built in.** `legion chat`, `commit`, `review`, `pr`, multi-agent `swarm`, persistent cross-session memory, and a shared knowledge store — powered by [legion-llm](https://github.com/LegionIO/legion-llm)'s any-client → any-provider routing. +- 🧠 **Cognitive architecture.** 240+ brain-modeled extensions across 18 domains (emotion, reasoning, social, metacognition…), coordinated by a tick-cycle scheduler ([legion-gaia](https://github.com/LegionIO/legion-gaia)). +- 🔌 **MCP-native.** Exposes itself as an MCP server (stdio or streamable HTTP), so Claude Desktop or any agent SDK can run tasks, manage extensions, and query your infrastructure directly. +- 🛡️ **Operational from day one.** Vault secrets, AES-256 message encryption, RBAC, JWT / API-key auth, sliding-window rate limiting, Prometheus metrics, an OpenAPI 3.1 spec, and live `SIGHUP` reload. **No paid tiers, no feature gates, full HA out of the box.** + +## The Legion Ecosystem + +LegionIO is the orchestrator; the heavy lifting lives in a family of focused, independently-versioned gems. Here's the one-line version — follow a link to dig in: + +| Gem | What it is | +|-----|-----------| +| [legion-llm](https://github.com/LegionIO/legion-llm) | Universal LLM proxy — any client dialect → any provider, with routing, escalation, and metering | +| [legion-gaia](https://github.com/LegionIO/legion-gaia) | Cognitive coordination layer — tick-cycle scheduler + weighted routing across cognitive modules | +| [legion-apollo](https://github.com/LegionIO/legion-apollo) | Shared + local knowledge store — RAG retrieval, embeddings, and a knowledge graph | +| [legion-data](https://github.com/LegionIO/legion-data) | Persistence — task history, scheduling, and chains over SQLite / PostgreSQL / MySQL | +| [legion-transport](https://github.com/LegionIO/legion-transport) | Messaging abstraction — RabbitMQ AMQP plus the in-process lite adapter | +| [legion-cache](https://github.com/LegionIO/legion-cache) | Caching abstraction — Redis / Memcached plus the in-memory lite adapter | +| [legion-crypt](https://github.com/LegionIO/legion-crypt) | Secrets & encryption — Vault integration, AES-256, JWT auth | +| [legion-rbac](https://github.com/LegionIO/legion-rbac) | Role-based access control with Vault-style flat policies | +| [legion-mcp](https://github.com/LegionIO/legion-mcp) | Model Context Protocol server/client implementation | +| [legion-settings](https://github.com/LegionIO/legion-settings) | Layered configuration + secret resolution (`vault://`, `env://`) | +| [legion-logging](https://github.com/LegionIO/legion-logging) | Structured logging used across every gem | +| [legion-tty](https://github.com/LegionIO/legion-tty) | Terminal UI components — spinners, tables, prompts | + +Capabilities (`lex-*` extensions) are a separate, much larger catalog — see [Extensions](#extensions) below. + +## What Does It Do? + +LegionIO routes work between services asynchronously. Tasks chain into dependency graphs with conditions and transformations controlling data flow: + +``` +Task A ──→ [condition] ──→ Task B ──→ [transform] ──→ Task C + └──→ Task D (parallel) + └──→ Task E ──→ Task F +``` + +When A completes, B runs. B triggers C, D, and E in parallel. Conditions gate execution. Transformations reshape payloads between steps. Add more workers by running more processes — RabbitMQ handles distribution automatically. + +But that's just the foundation. LegionIO is also: + +- **An AI coding assistant** — interactive chat with tools, code review, commit messages, PR generation, and multi-agent workflows +- **An MCP server** — 60 tools that let any AI agent run tasks, manage extensions, and query your infrastructure +- **A cognitive computing platform** — 242 brain-modeled extensions across 18 cognitive domains +- **A digital worker platform** — AI-as-labor with governance, risk tiers, and cost tracking + +## Quick Start + +```bash +gem install legionio +legionio check # verify subsystem connections +legionio start # start the daemon +``` + +For the AI features: + +```bash +legion # launch the interactive TTY shell +legion chat # interactive AI REPL with 40 built-in tools +legion commit # AI-generated commit message from staged changes +legion review # AI code review of your code +``` + +### Two Binaries + +| Binary | Purpose | +|--------|---------| +| `legion` | Interactive TTY shell + dev-workflow commands (`chat`, `commit`, `review`, `plan`, `memory`, `init`) | +| `legionio` | Daemon lifecycle + all operational commands (`start`, `stop`, `lex`, `task`, `config`, `mcp`, and 40+ more) | + +`legion` with no args drops into the interactive TTY shell. `legionio` is the full operational CLI. + +## Installation + +```bash +gem install legionio +``` + +Or add to your Gemfile: + +```ruby +gem 'legionio' +``` + +### Optional Capabilities + +| Gem | What It Unlocks | +|-----|-----------------| +| `legion-data` | Task history, scheduling, chains (SQLite/PostgreSQL/MySQL) | +| `legion-llm` | AI chat, commit, review, agents, multi-provider LLM routing | +| `legion-cache` | Redis/Memcached caching for extensions | +| `legion-crypt` | Vault integration, encryption, JWT auth | +| `legion-tty` | TTY UI components (spinners, tables, prompts) | + +## Zero-Infrastructure Mode (Lite) + +Run LegionIO without RabbitMQ, Redis, or Memcached: + +```bash +LEGION_MODE=lite legion start # environment variable +legion start --lite # CLI flag +``` + +In lite mode, `legion-transport` uses an in-process pub/sub adapter (no RabbitMQ required) and `legion-cache` uses a pure in-memory store with TTL (no Redis/Memcached required). All extensions and features work normally. Useful for single-machine development, CI, and trying LegionIO with no infrastructure. + +## Natural Language Intent Router + +```bash +legion do "list all running tasks" +legion do "start the email extension" +``` + +`legion do` routes free-text to the Capability Registry. Routes through the running daemon (MCP Tier 0 fast path) when available, or runs in-process otherwise. + +## Infrastructure + +| Component | Role | Required? | +|-----------|------|-----------| +| **RabbitMQ** | Task distribution (AMQP 0.9.1) | No (lite mode replaces with InProcess adapter) | +| **SQLite/PostgreSQL/MySQL** | Persistence (tasks, scheduling, chains) | Optional | +| **Redis/Memcached** | Extension caching | No (lite mode replaces with Memory adapter) | +| **HashiCorp Vault** | Secrets, PKI, encrypted settings | Optional | + +## The CLI + +Operational commands run through `legionio`. Dev-workflow and AI commands run through `legion`. + +### Daemon & Health + +```bash +legionio start # foreground +legionio start -d # daemonize +legionio start --http-port 8080 # custom API port +legionio status # service status +legionio stop # graceful shutdown +legionio check # smoke-test all connections +legionio check --extensions # also verify extensions +legionio check --full # full boot including API +``` + +### Extensions (LEX) + +Extensions are gems named `lex-*`, auto-discovered at startup: + +```bash +legion lex list # installed extensions +legion lex info # runners, actors, dependencies +legion lex create # scaffold a new extension +legion lex enable # enable / disable +``` + +### Tasks + +```bash +legion task run http.request.get url:https://example.com # dot notation +legion task run -e http -r request -f get # explicit flags +legion task run # interactive picker +legion task list # recent tasks +legion task show # detail + logs +``` + +### AI Chat + +An interactive AI coding assistant with project awareness, persistent memory, tool use, and multi-agent coordination. Requires `legion-llm`. + +```bash +legion chat # interactive REPL +legion chat prompt "explain main.rb" # single-prompt mode +echo "fix the bug" | legion chat prompt - # pipe from stdin +``` + +**40 built-in tools**: read_file, write_file, edit_file, search_files, search_content, run_command, save_memory, search_memory, web_search, spawn_agent, search_traces, query_knowledge, ingest_knowledge, consolidate_memory, relate_knowledge, knowledge_maintenance, knowledge_stats, summarize_traces, list_extensions, manage_tasks, system_status, view_events, cost_summary, reflect, manage_schedules, worker_status, detect_anomalies, view_trends, trigger_dream, generate_insights, budget_status, provider_health, model_comparison, shadow_eval_status, entity_extract, arbitrage_status, escalation_status, graph_explore, scheduling_status, memory_status + +**Slash commands**: `/help` `/quit` `/cost` `/status` `/clear` `/new` `/save` `/load` `/sessions` `/compact` `/fetch URL` `/search QUERY` `/diff` `/copy` `/rewind` `/memory` `/agent` `/agents` `/plan` `/swarm` `/review` `/permissions` `/personality` `/model` `/edit` `/commit` `/workers` `/dream` + +**Bang commands**: `!ls -la` — run shell commands with output injected into context + +**At-mentions**: `@reviewer check main.rb` — delegate to custom agents in `.legion/agents/` + +### AI Workflows + +```bash +legion commit # AI-generated commit message +legion pr # AI-generated PR title + description +legion pr --base develop --draft # target branch, draft mode +legion review # AI code review of staged changes +legion review src/main.rb # review specific files +legion review --diff # review uncommitted diff +``` + +### Multi-Agent Orchestration + +```bash +legion plan # read-only exploration mode (AI reasons, no writes) +legion swarm start deploy-pipeline # run multi-agent workflow +legion swarm list # available workflows +``` + +### Memory + +Persistent project and global memory that survives across sessions: + +```bash +legion memory list # project memories +legion memory add "always use rspec" +legion memory search "testing" +legion memory forget 3 +``` + +### Knowledge + +Query and manage the Apollo shared knowledge store and local knowledge index: + +```bash +legion knowledge query "how does transport routing work?" +legion knowledge retrieve "embedding cosine similarity" --scope global +legion knowledge ingest /path/to/docs/ +legion knowledge status # index stats, embedding coverage +legion knowledge health # detect orphans, quality metrics +legion knowledge maintain # cleanup orphans, reindex +legion knowledge quality # quality report +``` + +### Mind Growth + +Autonomous cognitive architecture expansion system. Analyzes gaps, proposes new cognitive extensions, and builds them via a staged pipeline: + +```bash +legion mind-growth status # current growth cycle state +legion mind-growth analyze # gap analysis against 5 reference models +legion mind-growth propose # propose a new concept +legion mind-growth evaluate # evaluate a proposal +legion mind-growth build # run staged build pipeline +legion mind-growth list # list proposals +legion mind-growth approve # manually approve +legion mind-growth reject # manually reject +legion mind-growth profile # cognitive profile across all models +legion mind-growth health # extension fitness validation +``` + +Requires `lex-mind-growth`. Also exposes 6 MCP tools in the `legion.mind_growth_*` namespace. + +### Digital Workers + +AI-as-labor with governance, risk tiers, and cost tracking: + +```bash +legion worker list # list workers +legion worker show # worker detail +legion worker create # register new worker (bootstrap state) +legion worker pause # pause / activate / retire +legion worker costs --days 30 # cost report +``` + +### Code Generation + +Run inside a `lex-*` directory: + +```bash +legion generate runner # add runner + spec +legion generate actor # add actor + spec +legion g exchange # 'g' is an alias +``` + +### Scheduling + +Requires `lex-scheduler`: + +```bash +legion schedule add alerts "*/5 * * * *" http.request.get +legion schedule add daily "every day at noon" report.generate.summary +legion schedule list +``` + +### Configuration + +```bash +legion config show # resolved config (redacted) +legion config validate # verify settings + subsystem health +legion config scaffold # generate starter config files (auto-detects env vars) +``` + +`config scaffold` auto-detects environment variables (`ANTHROPIC_API_KEY`, `AWS_BEARER_TOKEN_BEDROCK`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `VAULT_TOKEN`, `RABBITMQ_USER`/`PASSWORD`) and a running Ollama instance, enabling providers and setting `env://` references automatically. + +Settings load from the first directory found: `/etc/legionio/` → `~/.legionio/settings/` → `~/legionio/` → `./settings/` + +### Observability + +```bash +legion dashboard # TUI operational dashboard with auto-refresh +legion cost summary # cost overview (today/week/month) +legion cost worker # per-worker cost breakdown +legion trace search "failed tasks last hour" # natural language trace search +legion graph show --format mermaid # task relationship graph +``` + +### Audit & RBAC + +```bash +legion audit list # query audit log +legion audit export --format csv +legion rbac roles # list roles +legion rbac check +``` + +### Diagnostics + +```bash +legion doctor # diagnose environment, suggest fixes +legion doctor --fix # auto-remediate fixable issues (stale PIDs, missing gems) +legion doctor --json # machine-readable output +``` + +Checks Ruby version, bundle status, config files, RabbitMQ, database, cache, Vault, extensions, PID files, and permissions. Exits 1 if any check fails. + +### Updating + +```bash +legion update # update all legion gems in-place +legion update --dry-run # check what's available without installing +``` + +Uses the same Ruby that `legion` is running from — safe for Homebrew installs (updates go into the bundled gem directory, not your system Ruby). + +All commands support `--json` for structured output and `--no-color` to strip ANSI codes. + +## REST API + +The daemon exposes a REST API on port 4567 (configurable): + +| Route | Description | +|-------|-------------| +| `GET /api/health` | Health check | +| `GET /api/ready` | Readiness + component status | +| `GET/POST /api/tasks` | List / create tasks | +| `GET /api/extensions` | Installed extensions + runners | +| `GET /api/nodes` | Cluster nodes | +| `GET/POST/PUT/DELETE /api/schedules` | Cron / interval scheduling | +| `GET /api/settings` | Config (sensitive values redacted) | +| `GET /api/transport` | RabbitMQ connection status | +| `GET /api/events` | SSE event stream | +| `GET/POST/PUT/DELETE /api/workers` | Digital worker lifecycle | +| `GET /api/capacity` | Workforce capacity and forecasting | +| `GET /api/tenants` | Multi-tenant management | +| `GET /api/audit` | Audit log query and export | +| `GET /api/rbac/*` | Role-based access control | +| `GET /api/webhooks` | Webhook subscription management | +| `GET /api/openapi.json` | OpenAPI 3.1.0 specification | +| `GET /metrics` | Prometheus metrics | +| `POST /api/coldstart/ingest` | Context ingestion | -### Conditions -You can create complex conditional statements to ensure that when a triggers b, b only runs if certain conditions -are met. Example conditional statement ```json { - "all": [{ - "fact": "pet.type", - "value": "dog", - "operator": "equal" - },{ - "fact":"pet.hungry", - "operator":"is_true" - }] + "data": { "..." }, + "meta": { "timestamp": "2026-03-15T12:00:00Z", "node": "legion-01" } } +``` +## MCP Server + +LegionIO exposes itself as an [MCP](https://modelcontextprotocol.io/) server, letting any AI agent run tasks, manage extensions, and query infrastructure directly. + +```bash +legion mcp # stdio transport (Claude Desktop, agent SDKs) +legion mcp http # streamable HTTP on localhost:9393 +legion mcp http --port 8080 --host 0.0.0.0 ``` -You can nest conditions in an unlimited fashion to create and/or scenarios to meet your needs + +**60 tools** in the `legion.*` namespace: + +| Category | Tools | +|----------|-------| +| **Agentic** | `run_task`, `describe_runner` | +| **Tasks** | `list_tasks`, `get_task`, `delete_task`, `get_task_logs` | +| **Extensions** | `list_extensions`, `get_extension`, `enable_extension`, `disable_extension` | +| **Chains** | `list_chains`, `create_chain`, `update_chain`, `delete_chain` | +| **Relationships** | `list_relationships`, `create_relationship`, `update_relationship`, `delete_relationship` | +| **Schedules** | `list_schedules`, `create_schedule`, `update_schedule`, `delete_schedule` | +| **System** | `get_status`, `get_config` | +| **Workers** | `list_workers`, `show_worker`, `worker_lifecycle`, `worker_costs`, `team_summary` | +| **RBAC** | `rbac_assignments`, `rbac_check`, `rbac_grants` | +| **Analytics** | `routing_stats` | +| **Knowledge** | `query_knowledge`, `knowledge_health` | +| **Mind Growth** | `mind_growth_status`, `mind_growth_analyze`, `mind_growth_propose`, `mind_growth_evaluate`, `mind_growth_build`, `mind_growth_profile` | + +**Resources**: `legion://runners` (full runner catalog), `legion://extensions/{name}` (extension detail) + +## Task Relationships + +### Conditions + +JSON rule engine via `lex-conditioner`. Supports nested `all`/`any` with operators: + ```json { "all": [ - "any":[ - {"fact":"pet.type", "value":"dog","operator":"equal"}, - {"fact":"pet.type", "value":"cat","operator":"equal"} - ], - { - "fact": "pet.hungry", - "operator": "is_true" - },{ - "fact":"pet.overweight", - "operator":"is_false" - }] + {"fact": "pet.type", "value": "dog", "operator": "equal"}, + {"fact": "pet.hungry", "operator": "is_true"} + ] } ``` -*Conditions are supported by the `lex-conditioner` extension and are not required to be run inside the legion framework* -You can read the docs with more examples in the [wiki](https://legionio.atlassian.net/wiki/spaces/LEX/pages/614957181/Lex+Conditioner) - ### Transformations -Transformations are a critical piece of interconnecting two independent items. Without it, service B doesn't know what -to do with the result from service A -`lex-conditioner` uses a combination of the [tilt](https://rubygems.org/gems/tilt) gem and erb style syntax. -##### Examples -Creating a new pagerduty incident + +ERB templates via `lex-transformer`. Map data between services: + ```json -{"message":"New PagerDuty incident assigned to <%= assignee %> with a priority of <%= severity %>","from":"PagerDuty"} +{"message": "Incident assigned to <%= assignee %> with priority <%= severity %>"} ``` -Example transformation to make the `lex-log` extension output a message -```json -{"message":"transform2","level":"fatal"} + +Access Vault secrets inline: `<%= Legion::Crypt.read('pushover/token') %>` + +## Extensions + +Browse: [LegionIO GitHub](https://github.com/LegionIO) | [legionio topic](https://github.com/topics/legionio?l=ruby) + +### Core (14 operational extensions) + +`lex-node` `lex-tasker` `lex-conditioner` `lex-transformer` `lex-scheduler` `lex-health` `lex-log` `lex-ping` `lex-exec` `lex-lex` `lex-codegen` `lex-metering` `lex-telemetry` `lex-task_pruner` + +### Agentic (242 cognitive extensions) + +Brain-modeled cognitive architecture. 20 core orchestration extensions plus 222 expanded modules across 18 domains: + +| Domain | Examples | +|--------|----------| +| **Orchestration** | `lex-tick`, `lex-cortex`, `lex-dream`, `lex-memory`, `lex-identity` | +| **Emotion** | `lex-emotion`, `lex-mood`, `lex-empathy` | +| **Reasoning** | `lex-prediction`, `lex-planning`, `lex-logic` | +| **Social** | `lex-trust`, `lex-consent`, `lex-governance` | +| **Metacognition** | `lex-reflection`, `lex-awareness`, `lex-curiosity` | + +Coordinated by [legion-gaia](https://github.com/LegionIO/legion-gaia), the cognitive coordination layer with tick-cycle scheduling, channel abstraction, and weighted routing across cognitive modules. + +### AI / LLM + +`legion-llm` `lex-llm` `lex-llm-anthropic` `lex-llm-azure-foundry` `lex-llm-bedrock` `lex-llm-gemini` `lex-llm-ledger` `lex-llm-mlx` `lex-llm-ollama` `lex-llm-openai` `lex-llm-vertex` `lex-llm-vllm` + +Powered by [legion-llm](https://github.com/LegionIO/legion-llm) with provider-neutral model offerings, local and fleet routing, hosted cloud providers, health tracking, metering, and automatic model discovery. + +LLM API routes are mounted from `legion-llm` when available; LegionIO only hosts those route modules and does not provide a provider gateway fallback. + +### Service Integrations (8 common + 15 additional) + +**Common**: `lex-http` `lex-redis` `lex-s3` `lex-github` `lex-consul` `lex-tfe` `lex-vault` `lex-kerberos` `lex-microsoft_teams` + +**Additional**: `lex-ssh` `lex-slack` `lex-smtp` `lex-influxdb` `lex-pagerduty` `lex-elasticsearch` `lex-chef` `lex-pushover` `lex-twilio` `lex-todoist` `lex-pushbullet` `lex-sleepiq` `lex-elastic_app_search` `lex-memcached` `lex-sonos` + +### Build Your Own + +```bash +legion lex create myextension +cd lex-myextension +legion generate runner myrunner +legion generate actor myactor +bundle exec rspec ``` -You can also call Legion services to get the data you need, example sending a pushover message + +## Role Profiles + +Control which extensions load at startup via `settings/legion.json`: + ```json -{"message":"This is my pushover body", "title": "this is my title", "token":"<%= Legion::Settings['lex']['pushover']['token'] %>" } +{"role": {"profile": "dev"}} ``` -Or if you wanted to make a real time call via `Legion::Crypt` to get a [Hashicorp Vault](https://www.vaultproject.io/) value -```json -{"message":"this is another body", "title":"vault token example", "token":"<%= Legion::Crypt.read('pushover/token') %> "} -``` -*Transformations are supported by the `lex-transformation` extension and are not "technically" required to be run inside the legion framework* -You can read the docs with more examples in the [wiki](https://legionio.atlassian.net/wiki/spaces/LEX/pages/612270222/Lex+Transformer) - -## FAQ -### Does it scale? -Yes. Actually quite well. The framework uses RabbitMQ to ensure jobs are scheduled and run in a FIFO order. As you add -more works, it just subscribes to the queues the workers can support and does more work. It is really geared towards a -docker/K8 type of environment however it can be run locally, on a VM, etc. - -As of right now, it has been tested to around 100 workers running in docker without any performance issues. You will -likely see performance issues on the DB or RabbitMQ side before Legion has issues. - -Another benefit is that you can run multiple LEXs in one worker or you could have dedicated workers that only run a single LEX. -In example if you have to make a ton of ssh connections via `lex-ssh`, maybe you want to run 10 pods with no other extensions in them -but then run a pod with `lex-pagerduty`, `lex-log` and `lex-http` to send out notifications after each ssh task is completed - -### High Availability -Because you can run this thing with multiple processes and it will distribute the work, it is naturally HA oriented. -if a worker goes down for some reason, another one should pick it up(assuming another work has that LEX enabled). There -are no hidden features, pay walls, etc to get HA. Just run more instances of LegionIO - -### Price and License -LegionIO is completely free. It was build using free time. There are no features held back, no private repos. -Everything is under an MIT license to keep it as open as possible. With that, the devs can't always help with support, -well because it's free. - -### Who is it geared for? -Anyone? Everyone? It could be used in a homelab to automate updating VMs. It could be used by someone to take ESPHome -sensor data and pipe it to influxdb. At least that is what @Esity does. It could also be used by a company or enterprise looking -to replace other tools. - -### But it is written in ruby -Yep. - -### Similiar projects -There are multiple projects that are similiar. Some things like IFTTT are great(but is it?) but then again, cost money. -* [Node-Red](https://nodered.org/) - No HA but has some good features and a great drag and drop interface -* [n8n.io](https://n8n.io/) - Working on HA but [not there yet](https://github.com/n8n-io/n8n/pull/1294) -* [StackStorm](https://stackstorm.com/) - Written in Python, has potential but I feel they are removing features to convince you to pay for it -* [Jenkins](https://www.jenkins.io/) - It's jenkins. I don't need to say anything else -* [Huginn]() - Another IFTTT style app written in ruby. Not sure on this one but it doesn't have HA from what I can tell [github issue](https://github.com/huginn/huginn/issues/2198) - -### Other fun facts -* Supports Hashicorp vault for storing secrets/settings/etc -* Can enable global message encryption so that all messages going through RMQ are encrypted with aes-256-cbc -* Each worker generates a private/public key that can be used for internode communication, it also will generate a cluster secret -for all nodes to have so they can share data accross the entire cluster. The cluster secret by default is stored only in memory and -and is generated when the first worker starts + +| Profile | What loads | +|---------|-----------| +| *(default)* | Everything — no filtering | +| `core` | 14 core operational extensions only | +| `cognitive` | core + all agentic extensions | +| `service` | core + service + other integrations | +| `dev` | core + native LLM providers + essential agentic (~20 extensions) | +| `custom` | only what's listed in `role.extensions` | + +Faster boot and lower memory footprint for dedicated worker roles. + +## Scaling + +Task distribution uses RabbitMQ FIFO queues. Add workers by running more Legion processes — each subscribes to the same queues and picks up work automatically. Tested to 100+ workers. + +Run different LEX combinations per worker: 10 pods focused on `lex-ssh`, a separate pod for `lex-pagerduty` + `lex-log` notifications. + +No paid tiers. No feature gates. Full HA out of the box. + +## Security + +- **Message encryption**: AES-256-CBC via `legion-crypt` +- **Vault integration**: Secrets, PKI, encrypted settings +- **Node identity**: Each worker generates a keypair for inter-node communication +- **Cluster secret**: Generated at first startup, distributed via Vault or in-memory +- **JWT auth**: Bearer token authentication on the REST API +- **API key support**: `X-API-Key` header authentication +- **RBAC**: Role-based access control with Vault-style flat policies +- **Rate limiting**: Sliding-window per-IP/agent/tenant rate limiting +- **API versioning**: `/api/v1/` prefix with deprecation headers +- **Kerberos**: SPNEGO/GSSAPI authentication with LDAP group resolution + +## Docker + +```bash +docker pull legionio/legion +``` + +```dockerfile +FROM ruby:3-alpine +RUN gem install legionio +CMD ruby --yjit $(which legion) start +``` + +## Architecture + +Before any Legion code loads, the executable applies performance optimizations: + +- **YJIT** — `RubyVM::YJIT.enable` for 15-30% runtime throughput (Ruby 3.1+ builds) +- **GC tuning** — pre-allocates 600k heap slots and raises malloc limits (ENV overrides respected) +- **bootsnap** *(opt-in)* — set `LEGION_BOOTSNAP=true` to cache YARV bytecode and `$LOAD_PATH` resolution at `~/.legionio/cache/bootsnap/` + +``` +legion start + └── Legion::Service + ├── 1. Logging (legion-logging) + ├── 2. Settings (legion-settings — /etc/legionio, ~/legionio, ./settings) + ├── 3. Crypt (legion-crypt — Vault connection) + ├── 4. Transport (legion-transport — RabbitMQ) + ├── 5. Cache (legion-cache — Redis/Memcached) + ├── 6. Data (legion-data — database + migrations) + ├── 7. RBAC (legion-rbac — optional role-based access control) + ├── 8. LLM (legion-llm — AI provider setup + routing) + ├── 9. Apollo (legion-apollo — shared/local knowledge store) + ├── 10. GAIA (legion-gaia — cognitive coordination layer) + ├── 11. Telemetry (OpenTelemetry — optional) + ├── 12. Supervision (process supervision) + ├── 13. Extensions (two-phase parallel load: require+autobuild, then hook_all_actors) + ├── 14. Cluster Secret (distribute via Vault or memory) + └── 15. API (Sinatra/Puma on port 4567) +``` + +Each phase registers with `Legion::Readiness`. All phases are individually toggleable. + +`SIGHUP` triggers a live reload (`Legion.reload`) — subsystems shut down in reverse order and restart fresh without killing the process. Useful for rolling restarts and config changes. + +## Similar Projects + +| Project | Language | HA | AI | Cognitive | +|---------|----------|----|----|-----------| +| **LegionIO** | Ruby | Yes | Chat, MCP, agents, LLM routing | 242 extensions | +| [Node-RED](https://nodered.org/) | JS | No | No | No | +| [n8n.io](https://n8n.io/) | TS | Limited | Limited | No | +| [StackStorm](https://stackstorm.com/) | Python | Yes | No | No | +| [Huginn](https://github.com/huginn/huginn) | Ruby | No | No | No | + +## Development + +```bash +git clone https://github.com/LegionIO/LegionIO.git +cd LegionIO +bundle install +bundle exec rspec # ~3500+ examples, 0 failures +bundle exec rubocop # 0 offenses +``` + +Always run `bundle exec rspec` and `bundle exec rubocop -A` and fix all errors before committing. + +### Project Structure + +| Path | Purpose | +|------|---------| +| `lib/legion.rb` | Entry point: `Legion.start`, `.shutdown`, `.reload` | +| `lib/legion/service.rb` | 15-phase startup orchestrator | +| `lib/legion/cli.rb` | Thor CLI: 40+ subcommands across two binaries | +| `lib/legion/api.rb` | Sinatra REST API with middleware stack | +| `lib/legion/extensions/` | LEX discovery, loading, actors, builders | +| `lib/legion/tools/` | Canonical tool layer (Registry, Discovery, EmbeddingCache) | +| `lib/legion/digital_worker/` | AI-as-labor governance platform | +| `lib/legion/cli/chat/` | Interactive AI REPL with 40 tools | +| `spec/` | RSpec suite (~3500+ examples) | + +### Contributing + +1. Fork the repo and create a feature branch +2. Write specs for new functionality +3. Ensure `bundle exec rspec` passes with 0 failures +4. Ensure `bundle exec rubocop` passes with 0 offenses +5. Open a PR targeting `main` + +## License + +Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index acc4d53b..00000000 --- a/SECURITY.md +++ /dev/null @@ -1,9 +0,0 @@ -# Security Policy - -## Supported Versions -| Version | Supported | -| ------- | ------------------ | -| 1.x.x | :white_check_mark: | - -## Reporting a Vulnerability -To be added diff --git a/attribution.txt b/attribution.txt deleted file mode 100644 index e4c875cd..00000000 --- a/attribution.txt +++ /dev/null @@ -1 +0,0 @@ -Add attributions here. \ No newline at end of file diff --git a/completions/_legion b/completions/_legion new file mode 100644 index 00000000..e6e3a736 --- /dev/null +++ b/completions/_legion @@ -0,0 +1,963 @@ +#compdef legion +# zsh completion for the legion CLI (interactive / dev-workflow commands) +# Generated by LegionIO +# +# Installation: +# # One-time (current session): +# source <(legion completion zsh) +# +# # Permanent — add to a directory in your $fpath: +# legion completion zsh > "${fpath[1]}/_legion" +# +# # Or with oh-my-zsh: +# legion completion zsh > ~/.oh-my-zsh/completions/_legion +# +# Then reload: exec zsh +# +# Note: `legion` is the interactive TTY shell + dev-workflow binary. +# Use `legionio` for daemon lifecycle and all operational commands. + +_legion() { + local state line + typeset -A opt_args + + local -a global_opts + global_opts=( + '--json[Output as JSON]' + '--no-color[Disable color output]' + '--verbose[Verbose logging]' + '--config-dir[Config directory path]:directory:_directories' + '--help[Show help]' + ) + + _arguments -C \ + $global_opts \ + '(-v --version)'{-v,--version}'[Show version]' \ + '1: :_legion_commands' \ + '*:: :->subcmd' + + case $state in + subcmd) + case $words[1] in + chat) _legion_chat ;; + memory) _legion_memory ;; + plan) _legion_plan ;; + commit) _legion_commit ;; + pr) _legion_pr ;; + review) _legion_review ;; + prompt) _legion_prompt ;; + dataset) _legion_dataset ;; + esac + ;; + esac +} + +_legion_commands() { + local -a commands + commands=( + 'chat:Interactive AI conversation' + 'commit:Generate AI commit message from staged changes' + 'pr:Create pull request with AI-generated title and description' + 'review:AI code review of changes' + 'memory:Persistent project memory across sessions' + 'plan:Start plan mode (read-only exploration)' + 'init:Interactive project setup wizard' + 'tty:Launch interactive TTY shell' + 'ask:Quick AI prompt (shortcut for chat prompt)' + 'prompt:Manage versioned LLM prompt templates' + 'dataset:Manage versioned datasets' + 'version:Show version information' + 'help:Show help' + ) + _describe 'command' commands +} + +_legion_lex() { + local -a subcmds + subcmds=( + 'list:List all installed extensions' + 'info:Show detailed extension information' + 'create:Scaffold a new Legion extension' + 'enable:Enable an extension in settings' + 'disable:Disable an extension in settings' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'lex command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '(-a --all)'{-a,--all}'[Include disabled extensions]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + info) + _arguments \ + ':extension name:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + create) + _arguments \ + ':extension name:' \ + '--rspec[Include RSpec setup]' \ + '--no-rspec[Skip RSpec setup]' \ + '--github-ci[Include GitHub Actions CI]' \ + '--no-github-ci[Skip GitHub Actions CI]' \ + '--git-init[Initialize git repository]' \ + '--no-git-init[Skip git init]' \ + '--bundle-install[Run bundle install]' \ + '--no-bundle-install[Skip bundle install]' + ;; + enable|disable) + _arguments \ + ':extension name:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_task() { + local -a subcmds + subcmds=( + 'list:List recent tasks' + 'show:Show task details' + 'logs:Show task execution logs' + 'run:Trigger a task directly' + 'purge:Delete old tasks' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'task command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '(-n --limit)'{-n,--limit}'[Number of tasks]:count:' \ + '(-s --status)'{-s,--status}'[Filter by status]:status:(completed failed queued running)' \ + '(-e --extension)'{-e,--extension}'[Filter by extension]:name:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + show) + _arguments \ + ':task ID:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + logs) + _arguments \ + ':task ID:' \ + '(-n --limit)'{-n,--limit}'[Number of log entries]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + run) + _arguments \ + ':function (ext.runner.func):' \ + '(-e --extension)'{-e,--extension}'[Extension name]:name:' \ + '(-r --runner)'{-r,--runner}'[Runner name]:name:' \ + '(-f --function)'{-f,--function}'[Function name]:name:' \ + '--delay[Delay execution by N seconds]:seconds:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + purge) + _arguments \ + '--days[Keep tasks newer than N days]:days:' \ + '(-y --confirm)'{-y,--confirm}'[Skip confirmation]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_chain() { + local -a subcmds + subcmds=( + 'list:List task chains' + 'create:Create a new task chain' + 'delete:Delete a chain and its relationships' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'chain command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '(-n --limit)'{-n,--limit}'[Number of chains]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + create) + _arguments \ + ':chain name:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + delete) + _arguments \ + ':chain ID:' \ + '(-y --confirm)'{-y,--confirm}'[Skip confirmation]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_config() { + local -a subcmds + subcmds=( + 'show:Show resolved configuration' + 'path:Show configuration file search paths' + 'validate:Validate current configuration' + 'scaffold:Generate starter config files for each subsystem' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'config command' subcmds ;; + args) + case $words[1] in + show) + _arguments \ + '(-s --section)'{-s,--section}'[Show only a specific section]:section:(transport data cache crypt extensions api llm)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + path|validate) + _arguments \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + scaffold) + _arguments \ + '--dir[Output directory]:directory:_directories' \ + '--only[Comma-separated subsystems]:subsystems:(transport data cache crypt logging llm)' \ + '--full[Include all fields with defaults]' \ + '--force[Overwrite existing files]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_generate() { + local -a subcmds + subcmds=( + 'runner:Add a runner to the current LEX' + 'actor:Add an actor to the current LEX' + 'exchange:Add a transport exchange to the current LEX' + 'queue:Add a transport queue to the current LEX' + 'message:Add a transport message to the current LEX' + 'tool:Add a chat tool to the current LEX' + ) + + _arguments -C \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'generate command' subcmds ;; + args) + case $words[1] in + runner) + _arguments \ + ':runner name:' \ + '--functions[Comma-separated function names]:functions:' + ;; + actor) + _arguments \ + ':actor name:' \ + '--type[Actor execution type]:type:(subscription every poll once loop)' \ + '--runner[Associated runner name]:runner:' \ + '--interval[Interval in seconds]:seconds:' + ;; + exchange|queue|message|tool) + _arguments ':name:' + ;; + esac + ;; + esac +} + +_legion_mcp() { + local -a subcmds + subcmds=( + 'stdio:Start MCP server with stdio transport (default)' + 'http:Start MCP server with streamable HTTP transport' + ) + + _arguments -C \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'mcp command' subcmds ;; + args) + case $words[1] in + stdio) _arguments '--help[Show help]' ;; + http) + _arguments \ + '--port[Port to listen on]:port:' \ + '--host[Host to bind to]:host:' + ;; + esac + ;; + esac +} + +_legion_worker() { + local -a subcmds + subcmds=( + 'list:List digital workers' + 'show:Show digital worker details' + 'pause:Pause a digital worker' + 'retire:Retire a digital worker' + 'terminate:Terminate a digital worker (irreversible)' + 'activate:Activate a worker (from bootstrap or paused)' + 'costs:Show cost summary for a worker' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'worker command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '--team[Filter by team]:team:' \ + '--owner[Filter by owner MSID]:owner:' \ + '--state[Filter by lifecycle state]:state:(active paused retired terminated bootstrap)' \ + '--limit[Max results]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + show|pause|retire|activate) + _arguments \ + ':worker ID:' \ + '--reason[Reason]:reason:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + terminate) + _arguments \ + ':worker ID:' \ + '--reason[Reason for termination]:reason:' \ + '(-y --yes)'{-y,--yes}'[Skip confirmation]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + costs) + _arguments \ + ':worker ID:' \ + '--period[Period]:period:(daily weekly monthly)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_coldstart() { + local -a subcmds + subcmds=( + 'ingest:Ingest Claude memory/CLAUDE.md files into lex-memory traces' + 'preview:Preview what traces would be created (alias for ingest --dry-run)' + 'status:Show cold start progress' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'coldstart command' subcmds ;; + args) + case $words[1] in + ingest) + _arguments \ + '*:path:_files' \ + '--dry-run[Preview traces without storing]' \ + '--pattern[Glob pattern for directory mode]:pattern:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + preview) + _arguments \ + '*:path:_files' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + status) + _arguments \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_chat() { + local -a subcmds + subcmds=( + 'interactive:Start interactive AI conversation' + 'prompt:Send a single prompt and exit (headless mode)' + ) + + local -a chat_opts + chat_opts=( + '(-m --model)'{-m,--model}'[Model ID]:model:' + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' + '--system[System prompt override]:prompt:' + '(-y --auto-approve)'{-y,--auto-approve}'[Auto-approve all tool executions]' + '--no-markdown[Disable markdown rendering]' + '--max-budget-usd[Maximum estimated cost in USD]:amount:' + '--incognito[Disable automatic session history saving]' + '(-c --continue)'{-c,--continue}'[Resume the most recent session]' + '--resume[Resume a saved session by name]:name:' + '--fork[Fork a saved session]:name:' + '--add-dir[Additional directories to include in context]:dir:_directories' + '--personality[Communication style]:style:(concise verbose educational)' + '--json[Output as JSON]' + '--no-color[Disable color output]' + ) + + _arguments -C \ + $chat_opts \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'chat command' subcmds ;; + args) + case $words[1] in + interactive) + _arguments $chat_opts + ;; + prompt) + _arguments \ + ':prompt text:' \ + '--output-format[Output format]:format:(text json)' \ + '--max-turns[Maximum tool-use turns]:count:' \ + $chat_opts + ;; + esac + ;; + esac +} + +_legion_memory() { + local -a subcmds + subcmds=( + 'list:List all memory entries' + 'add:Add a memory entry' + 'forget:Remove memory entries matching pattern' + 'search:Search memory entries' + 'clear:Clear all memory entries' + ) + + _arguments -C \ + '(-g --global)'{-g,--global}'[Use global memory instead of project memory]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'memory command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '(-g --global)'{-g,--global}'[Use global memory]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + add) + _arguments \ + ':text to remember:' \ + '(-g --global)'{-g,--global}'[Use global memory]' \ + '--json[Output as JSON]' + ;; + forget) + _arguments \ + ':pattern:' \ + '(-g --global)'{-g,--global}'[Use global memory]' + ;; + search) + _arguments \ + ':query:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + clear) + _arguments \ + '(-g --global)'{-g,--global}'[Use global memory]' \ + '(-y --yes)'{-y,--yes}'[Skip confirmation]' \ + '--json[Output as JSON]' + ;; + esac + ;; + esac +} + +_legion_plan() { + local -a subcmds + subcmds=('interactive:Start plan mode (read-only exploration)') + + _arguments -C \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' \ + '--no-markdown[Disable markdown rendering]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' + + case $state in + cmd) _describe 'plan command' subcmds ;; + esac +} + +_legion_swarm() { + local -a subcmds + subcmds=( + 'start:Start a swarm workflow' + 'list:List available swarm workflows' + 'show:Show details of a swarm workflow' + ) + + _arguments -C \ + '(-m --model)'{-m,--model}'[Default model for agents]:model:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'swarm command' subcmds ;; + args) + case $words[1] in + start|show) + _arguments \ + ':workflow name:' \ + '(-m --model)'{-m,--model}'[Model for agents]:model:' \ + '--json[Output as JSON]' + ;; + list) + _arguments '--json[Output as JSON]' '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_commit() { + local -a subcmds + subcmds=('generate:Generate a commit message from staged changes') + + _arguments -C \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'commit command' subcmds ;; + args) + case $words[1] in + generate) + _arguments \ + '(-a --all)'{-a,--all}'[Stage all modified files first]' \ + '--amend[Amend the last commit]' \ + '(-y --yes)'{-y,--yes}'[Auto-approve (skip confirmation)]' \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:' \ + '--json[Output as JSON]' + ;; + esac + ;; + esac +} + +_legion_pr() { + local -a subcmds + subcmds=('create:Create a pull request with AI-generated title and description') + + _arguments -C \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'pr command' subcmds ;; + args) + case $words[1] in + create) + _arguments \ + '(-b --base)'{-b,--base}'[Base branch]:branch:' \ + '--draft[Create as draft PR]' \ + '(-y --yes)'{-y,--yes}'[Auto-approve (skip confirmation)]' \ + '--push[Push branch before creating PR]' \ + '--no-push[Do not push branch]' \ + '--token[GitHub token]:token:' \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:' \ + '--json[Output as JSON]' + ;; + esac + ;; + esac +} + +_legion_review() { + local -a subcmds + subcmds=('diff:Review code changes via LLM') + + _arguments -C \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'review command' subcmds ;; + args) + case $words[1] in + diff) + _arguments \ + '--staged[Review only staged changes]' \ + '--base[Base branch for comparison]:branch:' \ + '--pr[Review a GitHub PR by number]:number:' \ + '--fix[Generate and apply fixes]' \ + '(-y --yes)'{-y,--yes}'[Auto-approve fixes]' \ + '--token[GitHub token]:token:' \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:' \ + '--json[Output as JSON]' + ;; + esac + ;; + esac +} + +_legion_gaia() { + local -a subcmds + subcmds=('status:Show GAIA cognitive coordination status') + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'gaia command' subcmds ;; + args) + case $words[1] in + status) + _arguments \ + '--port[API port]:port:' \ + '--host[API host]:host:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_schedule() { + local -a subcmds + subcmds=( + 'list:List schedules' + 'show:Show schedule details' + 'add:Create a new schedule' + 'remove:Delete a schedule' + 'logs:Show schedule run logs' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'schedule command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '--active[Show only active schedules]' \ + '--limit[Max results]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + show) + _arguments \ + ':schedule ID:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + add) + _arguments \ + '--function-id[Function ID to schedule]:id:' \ + '--cron[Cron expression]:expression:' \ + '--interval[Interval in seconds]:seconds:' \ + '--description[Schedule description]:desc:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + remove) + _arguments \ + ':schedule ID:' \ + '(-y --yes)'{-y,--yes}'[Skip confirmation]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + logs) + _arguments \ + ':schedule ID:' \ + '--limit[Max results]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_completion() { + local -a subcmds + subcmds=( + 'bash:Output bash completion script' + 'zsh:Output zsh completion script' + 'install:Print installation instructions' + ) + + _arguments -C \ + '--help[Show help]' \ + '1: :->cmd' + + case $state in + cmd) _describe 'completion command' subcmds ;; + esac +} + +_legion_start() { + _arguments \ + '(-d --daemonize)'{-d,--daemonize}'[Run as background daemon]' \ + '(-p --pidfile)'{-p,--pidfile}'[PID file path]:file:_files' \ + '(-l --logfile)'{-l,--logfile}'[Log file path]:file:_files' \ + '(-t --time-limit)'{-t,--time-limit}'[Run for N seconds then exit]:seconds:' \ + '--log-level[Log level]:level:(debug info warn error)' \ + '--api[Start the HTTP API server]' \ + '--no-api[Do not start the HTTP API server]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' +} + +_legion_stop() { + _arguments \ + '(-p --pidfile)'{-p,--pidfile}'[PID file path]:file:_files' \ + '--signal[Signal to send]:signal:(INT TERM QUIT)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' +} + +_legion_check() { + _arguments \ + '--extensions[Also load extensions]' \ + '--full[Full boot cycle (extensions + API)]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' +} + +_legion_prompt() { + local -a subcmds + subcmds=( + 'list:List all prompts' + 'show:Show a prompt template and parameters' + 'create:Create a new prompt' + 'tag:Tag a prompt version' + 'diff:Show text diff between two versions of a prompt' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'prompt command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + show) + _arguments \ + ':prompt name:' \ + '--version[Specific version number]:version:' \ + '--tag[Tag name to resolve]:tag:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + create) + _arguments \ + ':prompt name:' \ + '--template[Prompt template text]:template:' \ + '--description[Short description]:desc:' \ + '--model-params[Model parameters as JSON]:json:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + tag) + _arguments \ + ':prompt name:' \ + ':tag name:' \ + '--version[Version to tag]:version:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + diff) + _arguments \ + ':prompt name:' \ + ':version 1:' \ + ':version 2:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion_dataset() { + local -a subcmds + subcmds=( + 'list:List all datasets' + 'show:Show dataset info and first 10 rows' + 'import:Import a dataset from a file' + 'export:Export a dataset to a file' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'dataset command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + show) + _arguments \ + ':dataset name:' \ + '--version[Specific version number]:version:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + import) + _arguments \ + ':dataset name:' \ + ':file path:_files' \ + '--format[File format]:format:(json csv jsonl)' \ + '--description[Dataset description]:desc:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + export) + _arguments \ + ':dataset name:' \ + ':output path:_files' \ + '--format[File format]:format:(json csv jsonl)' \ + '--version[Version to export]:version:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legion "$@" diff --git a/completions/_legionio b/completions/_legionio new file mode 100644 index 00000000..4438f452 --- /dev/null +++ b/completions/_legionio @@ -0,0 +1,871 @@ +#compdef legionio +# zsh completion for the legionio CLI (daemon lifecycle + all operational commands) +# Generated by LegionIO +# +# Installation: +# # One-time (current session): +# source <(legionio completion zsh) +# +# # Permanent — add to a directory in your $fpath: +# legionio completion zsh > "${fpath[1]}/_legionio" +# +# # Or with oh-my-zsh: +# legionio completion zsh > ~/.oh-my-zsh/completions/_legionio +# +# Then reload: exec zsh +# +# Note: `legionio` is the full operational CLI — daemon lifecycle + all 40+ subcommands. +# Use `legion` for the interactive TTY shell and dev-workflow commands. + +_legionio() { + local state line + typeset -A opt_args + + local -a global_opts + global_opts=( + '--json[Output as JSON]' + '--no-color[Disable color output]' + '--verbose[Verbose logging]' + '--config-dir[Config directory path]:directory:_directories' + '--help[Show help]' + ) + + _arguments -C \ + $global_opts \ + '(-v --version)'{-v,--version}'[Show version]' \ + '1: :_legionio_commands' \ + '*:: :->subcmd' + + case $state in + subcmd) + case $words[1] in + lex) _legionio_lex ;; + task) _legionio_task ;; + chain) _legionio_chain ;; + config) _legionio_config ;; + generate|g) _legionio_generate ;; + mcp) _legionio_mcp ;; + worker) _legionio_worker ;; + coldstart) _legionio_coldstart ;; + chat) _legionio_chat ;; + memory) _legionio_memory ;; + plan) _legionio_plan ;; + swarm) _legionio_swarm ;; + commit) _legionio_commit ;; + pr) _legionio_pr ;; + review) _legionio_review ;; + gaia) _legionio_gaia ;; + schedule) _legionio_schedule ;; + completion) _legionio_completion ;; + start) _legionio_start ;; + stop) _legionio_stop ;; + check) _legionio_check ;; + dream) _arguments '--wait[Wait for dream cycle]' $global_opts ;; + esac + ;; + esac +} + +_legionio_commands() { + local -a commands + commands=( + 'start:Start the Legion daemon' + 'stop:Stop a running Legion daemon' + 'status:Show running service status' + 'check:Verify Legion can start successfully' + 'version:Show version information' + 'lex:Manage Legion extensions (LEXs)' + 'task:Manage tasks' + 'chain:Manage task chains' + 'config:View and validate configuration' + 'generate:Code generators for LEX components' + 'mcp:Start MCP server for AI agent integration' + 'worker:Manage digital workers' + 'coldstart:Cold start bootstrap and Claude memory ingestion' + 'chat:Interactive AI conversation' + 'memory:Persistent project memory across sessions' + 'plan:Start plan mode (read-only exploration)' + 'swarm:Multi-agent swarm orchestration' + 'commit:Generate AI commit message from staged changes' + 'pr:Create pull request with AI-generated title and description' + 'review:AI code review of changes' + 'gaia:GAIA cognitive coordination' + 'schedule:Manage schedules' + 'completion:Shell tab completion scripts' + 'tree:Print a tree of all available commands' + 'ask:Quick AI prompt (shortcut for chat prompt)' + 'dream:Trigger a dream cycle on the running daemon' + ) + _describe 'command' commands +} + +_legionio_lex() { + local -a subcmds + subcmds=( + 'list:List all installed extensions' + 'info:Show detailed extension information' + 'create:Scaffold a new Legion extension' + 'enable:Enable an extension in settings' + 'disable:Disable an extension in settings' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'lex command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '(-a --all)'{-a,--all}'[Include disabled extensions]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + info) + _arguments \ + ':extension name:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + create) + _arguments \ + ':extension name:' \ + '--rspec[Include RSpec setup]' \ + '--no-rspec[Skip RSpec setup]' \ + '--github-ci[Include GitHub Actions CI]' \ + '--no-github-ci[Skip GitHub Actions CI]' \ + '--git-init[Initialize git repository]' \ + '--no-git-init[Skip git init]' \ + '--bundle-install[Run bundle install]' \ + '--no-bundle-install[Skip bundle install]' + ;; + enable|disable) + _arguments \ + ':extension name:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legionio_task() { + local -a subcmds + subcmds=( + 'list:List recent tasks' + 'show:Show task details' + 'logs:Show task execution logs' + 'run:Trigger a task directly' + 'purge:Delete old tasks' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'task command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '(-n --limit)'{-n,--limit}'[Number of tasks]:count:' \ + '(-s --status)'{-s,--status}'[Filter by status]:status:(completed failed queued running)' \ + '(-e --extension)'{-e,--extension}'[Filter by extension]:name:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + show) + _arguments \ + ':task ID:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + logs) + _arguments \ + ':task ID:' \ + '(-n --limit)'{-n,--limit}'[Number of log entries]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + run) + _arguments \ + ':function (ext.runner.func):' \ + '(-e --extension)'{-e,--extension}'[Extension name]:name:' \ + '(-r --runner)'{-r,--runner}'[Runner name]:name:' \ + '(-f --function)'{-f,--function}'[Function name]:name:' \ + '--delay[Delay execution by N seconds]:seconds:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + purge) + _arguments \ + '--days[Keep tasks newer than N days]:days:' \ + '(-y --confirm)'{-y,--confirm}'[Skip confirmation]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legionio_chain() { + local -a subcmds + subcmds=( + 'list:List task chains' + 'create:Create a new task chain' + 'delete:Delete a chain and its relationships' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'chain command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '(-n --limit)'{-n,--limit}'[Number of chains]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + create) + _arguments \ + ':chain name:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + delete) + _arguments \ + ':chain ID:' \ + '(-y --confirm)'{-y,--confirm}'[Skip confirmation]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legionio_config() { + local -a subcmds + subcmds=( + 'show:Show resolved configuration' + 'path:Show configuration file search paths' + 'validate:Validate current configuration' + 'scaffold:Generate starter config files for each subsystem' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'config command' subcmds ;; + args) + case $words[1] in + show) + _arguments \ + '(-s --section)'{-s,--section}'[Show only a specific section]:section:(transport data cache crypt extensions api llm)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + path|validate) + _arguments \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + scaffold) + _arguments \ + '--dir[Output directory]:directory:_directories' \ + '--only[Comma-separated subsystems]:subsystems:(transport data cache crypt logging llm)' \ + '--full[Include all fields with defaults]' \ + '--force[Overwrite existing files]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legionio_generate() { + local -a subcmds + subcmds=( + 'runner:Add a runner to the current LEX' + 'actor:Add an actor to the current LEX' + 'exchange:Add a transport exchange to the current LEX' + 'queue:Add a transport queue to the current LEX' + 'message:Add a transport message to the current LEX' + 'tool:Add a chat tool to the current LEX' + ) + + _arguments -C \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'generate command' subcmds ;; + args) + case $words[1] in + runner) + _arguments \ + ':runner name:' \ + '--functions[Comma-separated function names]:functions:' + ;; + actor) + _arguments \ + ':actor name:' \ + '--type[Actor execution type]:type:(subscription every poll once loop)' \ + '--runner[Associated runner name]:runner:' \ + '--interval[Interval in seconds]:seconds:' + ;; + exchange|queue|message|tool) + _arguments ':name:' + ;; + esac + ;; + esac +} + +_legionio_mcp() { + local -a subcmds + subcmds=( + 'stdio:Start MCP server with stdio transport (default)' + 'http:Start MCP server with streamable HTTP transport' + ) + + _arguments -C \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'mcp command' subcmds ;; + args) + case $words[1] in + stdio) _arguments '--help[Show help]' ;; + http) + _arguments \ + '--port[Port to listen on]:port:' \ + '--host[Host to bind to]:host:' + ;; + esac + ;; + esac +} + +_legionio_worker() { + local -a subcmds + subcmds=( + 'list:List digital workers' + 'show:Show digital worker details' + 'pause:Pause a digital worker' + 'retire:Retire a digital worker' + 'terminate:Terminate a digital worker (irreversible)' + 'activate:Activate a worker (from bootstrap or paused)' + 'costs:Show cost summary for a worker' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'worker command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '--team[Filter by team]:team:' \ + '--owner[Filter by owner MSID]:owner:' \ + '--state[Filter by lifecycle state]:state:(active paused retired terminated bootstrap)' \ + '--limit[Max results]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + show|pause|retire|activate) + _arguments \ + ':worker ID:' \ + '--reason[Reason]:reason:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + terminate) + _arguments \ + ':worker ID:' \ + '--reason[Reason for termination]:reason:' \ + '(-y --yes)'{-y,--yes}'[Skip confirmation]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + costs) + _arguments \ + ':worker ID:' \ + '--period[Period]:period:(daily weekly monthly)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legionio_coldstart() { + local -a subcmds + subcmds=( + 'ingest:Ingest Claude memory/CLAUDE.md files into lex-memory traces' + 'preview:Preview what traces would be created (alias for ingest --dry-run)' + 'status:Show cold start progress' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'coldstart command' subcmds ;; + args) + case $words[1] in + ingest) + _arguments \ + '*:path:_files' \ + '--dry-run[Preview traces without storing]' \ + '--pattern[Glob pattern for directory mode]:pattern:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + preview) + _arguments \ + '*:path:_files' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + status) + _arguments \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legionio_chat() { + local -a subcmds + subcmds=( + 'interactive:Start interactive AI conversation' + 'prompt:Send a single prompt and exit (headless mode)' + ) + + local -a chat_opts + chat_opts=( + '(-m --model)'{-m,--model}'[Model ID]:model:' + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' + '--system[System prompt override]:prompt:' + '(-y --auto-approve)'{-y,--auto-approve}'[Auto-approve all tool executions]' + '--no-markdown[Disable markdown rendering]' + '--max-budget-usd[Maximum estimated cost in USD]:amount:' + '--incognito[Disable automatic session history saving]' + '(-c --continue)'{-c,--continue}'[Resume the most recent session]' + '--resume[Resume a saved session by name]:name:' + '--fork[Fork a saved session]:name:' + '--add-dir[Additional directories to include in context]:dir:_directories' + '--personality[Communication style]:style:(concise verbose educational)' + '--json[Output as JSON]' + '--no-color[Disable color output]' + ) + + _arguments -C \ + $chat_opts \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'chat command' subcmds ;; + args) + case $words[1] in + interactive) + _arguments $chat_opts + ;; + prompt) + _arguments \ + ':prompt text:' \ + '--output-format[Output format]:format:(text json)' \ + '--max-turns[Maximum tool-use turns]:count:' \ + $chat_opts + ;; + esac + ;; + esac +} + +_legionio_memory() { + local -a subcmds + subcmds=( + 'list:List all memory entries' + 'add:Add a memory entry' + 'forget:Remove memory entries matching pattern' + 'search:Search memory entries' + 'clear:Clear all memory entries' + ) + + _arguments -C \ + '(-g --global)'{-g,--global}'[Use global memory instead of project memory]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'memory command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '(-g --global)'{-g,--global}'[Use global memory]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + add) + _arguments \ + ':text to remember:' \ + '(-g --global)'{-g,--global}'[Use global memory]' \ + '--json[Output as JSON]' + ;; + forget) + _arguments \ + ':pattern:' \ + '(-g --global)'{-g,--global}'[Use global memory]' + ;; + search) + _arguments \ + ':query:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + clear) + _arguments \ + '(-g --global)'{-g,--global}'[Use global memory]' \ + '(-y --yes)'{-y,--yes}'[Skip confirmation]' \ + '--json[Output as JSON]' + ;; + esac + ;; + esac +} + +_legionio_plan() { + local -a subcmds + subcmds=('interactive:Start plan mode (read-only exploration)') + + _arguments -C \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' \ + '--no-markdown[Disable markdown rendering]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' + + case $state in + cmd) _describe 'plan command' subcmds ;; + esac +} + +_legionio_swarm() { + local -a subcmds + subcmds=( + 'start:Start a swarm workflow' + 'list:List available swarm workflows' + 'show:Show details of a swarm workflow' + ) + + _arguments -C \ + '(-m --model)'{-m,--model}'[Default model for agents]:model:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'swarm command' subcmds ;; + args) + case $words[1] in + start|show) + _arguments \ + ':workflow name:' \ + '(-m --model)'{-m,--model}'[Model for agents]:model:' \ + '--json[Output as JSON]' + ;; + list) + _arguments '--json[Output as JSON]' '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legionio_commit() { + local -a subcmds + subcmds=('generate:Generate a commit message from staged changes') + + _arguments -C \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'commit command' subcmds ;; + args) + case $words[1] in + generate) + _arguments \ + '(-a --all)'{-a,--all}'[Stage all modified files first]' \ + '--amend[Amend the last commit]' \ + '(-y --yes)'{-y,--yes}'[Auto-approve (skip confirmation)]' \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:' \ + '--json[Output as JSON]' + ;; + esac + ;; + esac +} + +_legionio_pr() { + local -a subcmds + subcmds=('create:Create a pull request with AI-generated title and description') + + _arguments -C \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'pr command' subcmds ;; + args) + case $words[1] in + create) + _arguments \ + '(-b --base)'{-b,--base}'[Base branch]:branch:' \ + '--draft[Create as draft PR]' \ + '(-y --yes)'{-y,--yes}'[Auto-approve (skip confirmation)]' \ + '--push[Push branch before creating PR]' \ + '--no-push[Do not push branch]' \ + '--token[GitHub token]:token:' \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:' \ + '--json[Output as JSON]' + ;; + esac + ;; + esac +} + +_legionio_review() { + local -a subcmds + subcmds=('diff:Review code changes via LLM') + + _arguments -C \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:(bedrock anthropic openai gemini ollama)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'review command' subcmds ;; + args) + case $words[1] in + diff) + _arguments \ + '--staged[Review only staged changes]' \ + '--base[Base branch for comparison]:branch:' \ + '--pr[Review a GitHub PR by number]:number:' \ + '--fix[Generate and apply fixes]' \ + '(-y --yes)'{-y,--yes}'[Auto-approve fixes]' \ + '--token[GitHub token]:token:' \ + '(-m --model)'{-m,--model}'[Model ID]:model:' \ + '--provider[LLM provider]:provider:' \ + '--json[Output as JSON]' + ;; + esac + ;; + esac +} + +_legionio_gaia() { + local -a subcmds + subcmds=('status:Show GAIA cognitive coordination status') + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'gaia command' subcmds ;; + args) + case $words[1] in + status) + _arguments \ + '--port[API port]:port:' \ + '--host[API host]:host:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legionio_schedule() { + local -a subcmds + subcmds=( + 'list:List schedules' + 'show:Show schedule details' + 'add:Create a new schedule' + 'remove:Delete a schedule' + 'logs:Show schedule run logs' + ) + + _arguments -C \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' \ + '--help[Show help]' \ + '1: :->cmd' \ + '*:: :->args' + + case $state in + cmd) _describe 'schedule command' subcmds ;; + args) + case $words[1] in + list) + _arguments \ + '--active[Show only active schedules]' \ + '--limit[Max results]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + show) + _arguments \ + ':schedule ID:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + add) + _arguments \ + '--function-id[Function ID to schedule]:id:' \ + '--cron[Cron expression]:expression:' \ + '--interval[Interval in seconds]:seconds:' \ + '--description[Schedule description]:desc:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + remove) + _arguments \ + ':schedule ID:' \ + '(-y --yes)'{-y,--yes}'[Skip confirmation]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + logs) + _arguments \ + ':schedule ID:' \ + '--limit[Max results]:count:' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' + ;; + esac + ;; + esac +} + +_legionio_completion() { + local -a subcmds + subcmds=( + 'bash:Output bash completion script' + 'zsh:Output zsh completion script' + 'install:Print installation instructions' + ) + + _arguments -C \ + '--help[Show help]' \ + '1: :->cmd' + + case $state in + cmd) _describe 'completion command' subcmds ;; + esac +} + +_legionio_start() { + _arguments \ + '(-d --daemonize)'{-d,--daemonize}'[Run as background daemon]' \ + '(-p --pidfile)'{-p,--pidfile}'[PID file path]:file:_files' \ + '(-l --logfile)'{-l,--logfile}'[Log file path]:file:_files' \ + '(-t --time-limit)'{-t,--time-limit}'[Run for N seconds then exit]:seconds:' \ + '--log-level[Log level]:level:(debug info warn error)' \ + '--api[Start the HTTP API server]' \ + '--no-api[Do not start the HTTP API server]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' +} + +_legionio_stop() { + _arguments \ + '(-p --pidfile)'{-p,--pidfile}'[PID file path]:file:_files' \ + '--signal[Signal to send]:signal:(INT TERM QUIT)' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' +} + +_legionio_check() { + _arguments \ + '--extensions[Also load extensions]' \ + '--full[Full boot cycle (extensions + API)]' \ + '--json[Output as JSON]' \ + '--no-color[Disable color output]' +} + +_legionio "$@" diff --git a/completions/legion.bash b/completions/legion.bash new file mode 100644 index 00000000..48d614e3 --- /dev/null +++ b/completions/legion.bash @@ -0,0 +1,310 @@ +# bash completion for the legion CLI (interactive / dev-workflow commands) +# Generated by LegionIO +# +# Installation: +# # One-time (current session): +# source <(legion completion bash) +# +# # Permanent (add to ~/.bashrc or ~/.bash_profile): +# echo 'source <(legion completion bash)' >> ~/.bashrc +# +# # Or copy to bash completions directory: +# legion completion bash > /etc/bash_completion.d/legion +# # macOS with bash-completion@2: +# legion completion bash > $(brew --prefix)/etc/bash_completion.d/legion +# +# Note: `legion` is the interactive TTY shell + dev-workflow binary. +# Use `legionio` for daemon lifecycle and all operational commands. + +_legion_complete() { + local cur prev words cword + _init_completion 2>/dev/null || { + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + } + + local top_commands="chat commit pr review memory plan init tty ask prompt dataset version help" + local global_flags="--json --no-color --verbose --config-dir --help" + + # Top-level command + if [[ $cword -eq 1 ]]; then + COMPREPLY=($(compgen -W "${top_commands}" -- "${cur}")) + return 0 + fi + + local cmd="${words[1]}" + + # Subcommand completions + case "${cmd}" in + lex) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list info create enable disable" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--all --json --no-color --help" -- "${cur}")) ;; + info) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + create) COMPREPLY=($(compgen -W "--rspec --no-rspec --github-ci --no-github-ci --git-init --no-git-init --bundle-install --no-bundle-install --help" -- "${cur}")) ;; + enable) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + disable) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + task) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list show logs run purge" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--limit --status --extension --json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + logs) COMPREPLY=($(compgen -W "--limit --json --no-color --help" -- "${cur}")) ;; + run) COMPREPLY=($(compgen -W "--extension --runner --function --delay --json --no-color --help" -- "${cur}")) ;; + purge) COMPREPLY=($(compgen -W "--days --confirm --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + chain) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list create delete" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--limit --json --no-color --help" -- "${cur}")) ;; + create) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + delete) COMPREPLY=($(compgen -W "--confirm --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + config) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "show path validate scaffold" -- "${cur}")) + else + case "${words[2]}" in + show) COMPREPLY=($(compgen -W "--section --json --no-color --help" -- "${cur}")) ;; + path) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + validate) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + scaffold) COMPREPLY=($(compgen -W "--dir --only --full --force --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + generate|g) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "runner actor exchange queue message tool" -- "${cur}")) + else + case "${words[2]}" in + runner) COMPREPLY=($(compgen -W "--functions --help" -- "${cur}")) ;; + actor) COMPREPLY=($(compgen -W "--type --runner --interval --help" -- "${cur}")) ;; + exchange) COMPREPLY=($(compgen -W "--help" -- "${cur}")) ;; + queue) COMPREPLY=($(compgen -W "--exchange --help" -- "${cur}")) ;; + message) COMPREPLY=($(compgen -W "--help" -- "${cur}")) ;; + tool) COMPREPLY=($(compgen -W "--help" -- "${cur}")) ;; + esac + fi + ;; + + mcp) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "stdio http" -- "${cur}")) + else + case "${words[2]}" in + stdio) COMPREPLY=($(compgen -W "--help" -- "${cur}")) ;; + http) COMPREPLY=($(compgen -W "--port --host --help" -- "${cur}")) ;; + esac + fi + ;; + + worker) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list show pause retire terminate activate costs" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--team --owner --state --limit --json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + pause) COMPREPLY=($(compgen -W "--reason --json --no-color --help" -- "${cur}")) ;; + retire) COMPREPLY=($(compgen -W "--reason --json --no-color --help" -- "${cur}")) ;; + terminate) COMPREPLY=($(compgen -W "--reason --yes --json --no-color --help" -- "${cur}")) ;; + activate) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + costs) COMPREPLY=($(compgen -W "--period --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + coldstart) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "ingest preview status" -- "${cur}")) + else + case "${words[2]}" in + ingest) COMPREPLY=($(compgen -W "--dry-run --pattern --json --no-color --help" -- "${cur}")) ;; + preview) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + status) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + chat) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "interactive prompt" -- "${cur}")) + else + local chat_flags="--model --provider --system --auto-approve --no-markdown --max-budget-usd --incognito --continue --resume --fork --add-dir --personality --json --no-color --help" + case "${words[2]}" in + interactive) COMPREPLY=($(compgen -W "${chat_flags}" -- "${cur}")) ;; + prompt) COMPREPLY=($(compgen -W "--output-format --max-turns ${chat_flags}" -- "${cur}")) ;; + esac + fi + ;; + + memory) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list add forget search clear" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--global --json --no-color --help" -- "${cur}")) ;; + add) COMPREPLY=($(compgen -W "--global --json --no-color --help" -- "${cur}")) ;; + forget) COMPREPLY=($(compgen -W "--global --json --no-color --help" -- "${cur}")) ;; + search) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + clear) COMPREPLY=($(compgen -W "--global --yes --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + plan) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "interactive" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--model --provider --no-markdown --json --no-color --help" -- "${cur}")) + fi + ;; + + swarm) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "start list show" -- "${cur}")) + else + case "${words[2]}" in + start) COMPREPLY=($(compgen -W "--model --json --no-color --help" -- "${cur}")) ;; + list) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + commit) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "generate" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--all --amend --yes --model --provider --json --no-color --help" -- "${cur}")) + fi + ;; + + pr) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "create" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--base --draft --yes --push --no-push --token --model --provider --json --no-color --help" -- "${cur}")) + fi + ;; + + review) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "diff" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--staged --base --pr --fix --yes --token --model --provider --json --no-color --help" -- "${cur}")) + fi + ;; + + gaia) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "status" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--port --host --json --no-color --help" -- "${cur}")) + fi + ;; + + schedule) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list show add remove logs" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--active --limit --json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + add) COMPREPLY=($(compgen -W "--function-id --cron --interval --description --json --no-color --help" -- "${cur}")) ;; + remove) COMPREPLY=($(compgen -W "--yes --json --no-color --help" -- "${cur}")) ;; + logs) COMPREPLY=($(compgen -W "--limit --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + completion) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "bash zsh install" -- "${cur}")) + fi + ;; + + start) + COMPREPLY=($(compgen -W "--daemonize --pidfile --logfile --time-limit --log-level --api --no-api --json --no-color --help" -- "${cur}")) + ;; + + stop) + COMPREPLY=($(compgen -W "--pidfile --signal --json --no-color --help" -- "${cur}")) + ;; + + status) + COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) + ;; + + check) + COMPREPLY=($(compgen -W "--extensions --full --json --no-color --help" -- "${cur}")) + ;; + + version) + COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) + ;; + + prompt) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list show create tag diff" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--version --tag --json --no-color --help" -- "${cur}")) ;; + create) COMPREPLY=($(compgen -W "--template --description --model-params --json --no-color --help" -- "${cur}")) ;; + tag) COMPREPLY=($(compgen -W "--version --json --no-color --help" -- "${cur}")) ;; + diff) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + dataset) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list show import export" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--version --json --no-color --help" -- "${cur}")) ;; + import) COMPREPLY=($(compgen -W "--format --description --json --no-color --help" -- "${cur}")) ;; + export) COMPREPLY=($(compgen -W "--format --version --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + dream) + COMPREPLY=($(compgen -W "--wait --json --no-color --help" -- "${cur}")) + ;; + + ask) + COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) + ;; + + *) + COMPREPLY=($(compgen -W "${global_flags}" -- "${cur}")) + ;; + esac + + return 0 +} + +complete -F _legion_complete legion diff --git a/completions/legionio.bash b/completions/legionio.bash new file mode 100644 index 00000000..eb7150df --- /dev/null +++ b/completions/legionio.bash @@ -0,0 +1,283 @@ +# bash completion for the legionio CLI (daemon lifecycle + all operational commands) +# Generated by LegionIO +# +# Installation: +# # One-time (current session): +# source <(legionio completion bash) +# +# # Permanent (add to ~/.bashrc or ~/.bash_profile): +# echo 'source <(legionio completion bash)' >> ~/.bashrc +# +# # Or copy to bash completions directory: +# legionio completion bash > /etc/bash_completion.d/legionio +# # macOS with bash-completion@2: +# legionio completion bash > $(brew --prefix)/etc/bash_completion.d/legionio +# +# Note: `legionio` is the full operational CLI — daemon lifecycle + all 40+ subcommands. +# Use `legion` for the interactive TTY shell and dev-workflow commands. + +_legionio_complete() { + local cur prev words cword + _init_completion 2>/dev/null || { + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + } + + local top_commands="start stop status check version lex task chain config generate mcp worker coldstart chat memory plan swarm commit pr review gaia schedule completion tree ask dream" + local global_flags="--json --no-color --verbose --config-dir --help" + + # Top-level command + if [[ $cword -eq 1 ]]; then + COMPREPLY=($(compgen -W "${top_commands}" -- "${cur}")) + return 0 + fi + + local cmd="${words[1]}" + + # Subcommand completions + case "${cmd}" in + lex) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list info create enable disable" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--all --json --no-color --help" -- "${cur}")) ;; + info) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + create) COMPREPLY=($(compgen -W "--rspec --no-rspec --github-ci --no-github-ci --git-init --no-git-init --bundle-install --no-bundle-install --help" -- "${cur}")) ;; + enable) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + disable) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + task) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list show logs run purge" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--limit --status --extension --json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + logs) COMPREPLY=($(compgen -W "--limit --json --no-color --help" -- "${cur}")) ;; + run) COMPREPLY=($(compgen -W "--extension --runner --function --delay --json --no-color --help" -- "${cur}")) ;; + purge) COMPREPLY=($(compgen -W "--days --confirm --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + chain) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list create delete" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--limit --json --no-color --help" -- "${cur}")) ;; + create) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + delete) COMPREPLY=($(compgen -W "--confirm --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + config) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "show path validate scaffold" -- "${cur}")) + else + case "${words[2]}" in + show) COMPREPLY=($(compgen -W "--section --json --no-color --help" -- "${cur}")) ;; + path) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + validate) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + scaffold) COMPREPLY=($(compgen -W "--dir --only --full --force --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + generate|g) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "runner actor exchange queue message tool" -- "${cur}")) + else + case "${words[2]}" in + runner) COMPREPLY=($(compgen -W "--functions --help" -- "${cur}")) ;; + actor) COMPREPLY=($(compgen -W "--type --runner --interval --help" -- "${cur}")) ;; + exchange) COMPREPLY=($(compgen -W "--help" -- "${cur}")) ;; + queue) COMPREPLY=($(compgen -W "--exchange --help" -- "${cur}")) ;; + message) COMPREPLY=($(compgen -W "--help" -- "${cur}")) ;; + tool) COMPREPLY=($(compgen -W "--help" -- "${cur}")) ;; + esac + fi + ;; + + mcp) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "stdio http" -- "${cur}")) + else + case "${words[2]}" in + stdio) COMPREPLY=($(compgen -W "--help" -- "${cur}")) ;; + http) COMPREPLY=($(compgen -W "--port --host --help" -- "${cur}")) ;; + esac + fi + ;; + + worker) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list show pause retire terminate activate costs" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--team --owner --state --limit --json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + pause) COMPREPLY=($(compgen -W "--reason --json --no-color --help" -- "${cur}")) ;; + retire) COMPREPLY=($(compgen -W "--reason --json --no-color --help" -- "${cur}")) ;; + terminate) COMPREPLY=($(compgen -W "--reason --yes --json --no-color --help" -- "${cur}")) ;; + activate) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + costs) COMPREPLY=($(compgen -W "--period --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + coldstart) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "ingest preview status" -- "${cur}")) + else + case "${words[2]}" in + ingest) COMPREPLY=($(compgen -W "--dry-run --pattern --json --no-color --help" -- "${cur}")) ;; + preview) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + status) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + chat) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "interactive prompt" -- "${cur}")) + else + local chat_flags="--model --provider --system --auto-approve --no-markdown --max-budget-usd --incognito --continue --resume --fork --add-dir --personality --json --no-color --help" + case "${words[2]}" in + interactive) COMPREPLY=($(compgen -W "${chat_flags}" -- "${cur}")) ;; + prompt) COMPREPLY=($(compgen -W "--output-format --max-turns ${chat_flags}" -- "${cur}")) ;; + esac + fi + ;; + + memory) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list add forget search clear" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--global --json --no-color --help" -- "${cur}")) ;; + add) COMPREPLY=($(compgen -W "--global --json --no-color --help" -- "${cur}")) ;; + forget) COMPREPLY=($(compgen -W "--global --json --no-color --help" -- "${cur}")) ;; + search) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + clear) COMPREPLY=($(compgen -W "--global --yes --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + plan) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "interactive" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--model --provider --no-markdown --json --no-color --help" -- "${cur}")) + fi + ;; + + swarm) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "start list show" -- "${cur}")) + else + case "${words[2]}" in + start) COMPREPLY=($(compgen -W "--model --json --no-color --help" -- "${cur}")) ;; + list) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + commit) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "generate" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--all --amend --yes --model --provider --json --no-color --help" -- "${cur}")) + fi + ;; + + pr) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "create" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--base --draft --yes --push --no-push --token --model --provider --json --no-color --help" -- "${cur}")) + fi + ;; + + review) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "diff" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--staged --base --pr --fix --yes --token --model --provider --json --no-color --help" -- "${cur}")) + fi + ;; + + gaia) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "status" -- "${cur}")) + else + COMPREPLY=($(compgen -W "--port --host --json --no-color --help" -- "${cur}")) + fi + ;; + + schedule) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "list show add remove logs" -- "${cur}")) + else + case "${words[2]}" in + list) COMPREPLY=($(compgen -W "--active --limit --json --no-color --help" -- "${cur}")) ;; + show) COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) ;; + add) COMPREPLY=($(compgen -W "--function-id --cron --interval --description --json --no-color --help" -- "${cur}")) ;; + remove) COMPREPLY=($(compgen -W "--yes --json --no-color --help" -- "${cur}")) ;; + logs) COMPREPLY=($(compgen -W "--limit --json --no-color --help" -- "${cur}")) ;; + esac + fi + ;; + + completion) + if [[ $cword -eq 2 ]]; then + COMPREPLY=($(compgen -W "bash zsh install" -- "${cur}")) + fi + ;; + + start) + COMPREPLY=($(compgen -W "--daemonize --pidfile --logfile --time-limit --log-level --api --no-api --json --no-color --help" -- "${cur}")) + ;; + + stop) + COMPREPLY=($(compgen -W "--pidfile --signal --json --no-color --help" -- "${cur}")) + ;; + + status) + COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) + ;; + + check) + COMPREPLY=($(compgen -W "--extensions --full --json --no-color --help" -- "${cur}")) + ;; + + version) + COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) + ;; + + dream) + COMPREPLY=($(compgen -W "--wait --json --no-color --help" -- "${cur}")) + ;; + + ask) + COMPREPLY=($(compgen -W "--json --no-color --help" -- "${cur}")) + ;; + + *) + COMPREPLY=($(compgen -W "${global_flags}" -- "${cur}")) + ;; + esac + + return 0 +} + +complete -F _legionio_complete legionio diff --git a/deploy/helm/legion/Chart.yaml b/deploy/helm/legion/Chart.yaml new file mode 100644 index 00000000..e886c383 --- /dev/null +++ b/deploy/helm/legion/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: legion +description: LegionIO async job engine +version: 0.1.0 +appVersion: "1.4.13" +type: application diff --git a/deploy/helm/legion/templates/_helpers.tpl b/deploy/helm/legion/templates/_helpers.tpl new file mode 100644 index 00000000..38f12768 --- /dev/null +++ b/deploy/helm/legion/templates/_helpers.tpl @@ -0,0 +1,16 @@ +{{- define "legion.fullname" -}} +{{- .Release.Name }}-legion +{{- end }} + +{{- define "legion.labels" -}} +app.kubernetes.io/name: legion +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }} +{{- end }} + +{{- define "legion.selectorLabels" -}} +app.kubernetes.io/name: legion +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/deploy/helm/legion/templates/deployment-api.yaml b/deploy/helm/legion/templates/deployment-api.yaml new file mode 100644 index 00000000..0f3291ed --- /dev/null +++ b/deploy/helm/legion/templates/deployment-api.yaml @@ -0,0 +1,55 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "legion.fullname" . }}-api + labels: + {{- include "legion.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + replicas: {{ .Values.api.replicas }} + selector: + matchLabels: + {{- include "legion.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: api + template: + metadata: + labels: + {{- include "legion.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: api + spec: + serviceAccountName: {{ .Values.serviceAccount.name }} + containers: + - name: api + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["bundle", "exec", "legion", "api"] + ports: + - containerPort: {{ .Values.api.port }} + protocol: TCP + livenessProbe: + httpGet: + path: /api/health + port: {{ .Values.api.port }} + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /api/health + port: {{ .Values.api.port }} + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.api.resources | nindent 12 }} + env: + - name: LEGION_TRANSPORT_HOST + value: {{ .Values.rabbitmq.host | quote }} + - name: LEGION_DATA_URL + value: "postgres://$(DB_USER):$(DB_PASS)@{{ .Values.postgresql.host }}:{{ .Values.postgresql.port }}/{{ .Values.postgresql.database }}" + {{- with .Values.api.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - secretRef: + name: {{ .Values.rabbitmq.existingSecret }} + - secretRef: + name: {{ .Values.postgresql.existingSecret }} diff --git a/deploy/helm/legion/templates/deployment-worker.yaml b/deploy/helm/legion/templates/deployment-worker.yaml new file mode 100644 index 00000000..e254bf0f --- /dev/null +++ b/deploy/helm/legion/templates/deployment-worker.yaml @@ -0,0 +1,46 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "legion.fullname" . }}-worker + labels: + {{- include "legion.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + replicas: {{ .Values.worker.replicas }} + selector: + matchLabels: + {{- include "legion.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker + template: + metadata: + labels: + {{- include "legion.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: worker + spec: + serviceAccountName: {{ .Values.serviceAccount.name }} + containers: + - name: worker + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: ["bundle", "exec", "legion", "start"] + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} + env: + - name: LEGION_TRANSPORT_HOST + value: {{ .Values.rabbitmq.host | quote }} + - name: LEGION_TRANSPORT_PORT + value: {{ .Values.rabbitmq.port | quote }} + - name: LEGION_DATA_URL + value: "postgres://$(DB_USER):$(DB_PASS)@{{ .Values.postgresql.host }}:{{ .Values.postgresql.port }}/{{ .Values.postgresql.database }}" + {{- with .Values.worker.env }} + {{- toYaml . | nindent 12 }} + {{- end }} + envFrom: + - secretRef: + name: {{ .Values.rabbitmq.existingSecret }} + - secretRef: + name: {{ .Values.postgresql.existingSecret }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/legion/templates/hpa-worker.yaml b/deploy/helm/legion/templates/hpa-worker.yaml new file mode 100644 index 00000000..296d665c --- /dev/null +++ b/deploy/helm/legion/templates/hpa-worker.yaml @@ -0,0 +1,22 @@ +{{- if .Values.worker.hpa.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "legion.fullname" . }}-worker + labels: + {{- include "legion.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "legion.fullname" . }}-worker + minReplicas: {{ .Values.worker.hpa.minReplicas }} + maxReplicas: {{ .Values.worker.hpa.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.worker.hpa.targetCPUUtilization }} +{{- end }} diff --git a/deploy/helm/legion/templates/pdb.yaml b/deploy/helm/legion/templates/pdb.yaml new file mode 100644 index 00000000..2ce2e42c --- /dev/null +++ b/deploy/helm/legion/templates/pdb.yaml @@ -0,0 +1,13 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "legion.fullname" . }} + labels: + {{- include "legion.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: + {{- include "legion.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/deploy/helm/legion/templates/service-api.yaml b/deploy/helm/legion/templates/service-api.yaml new file mode 100644 index 00000000..ba0734f6 --- /dev/null +++ b/deploy/helm/legion/templates/service-api.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "legion.fullname" . }}-api + labels: + {{- include "legion.labels" . | nindent 4 }} +spec: + type: {{ .Values.api.service.type }} + ports: + - port: {{ .Values.api.port }} + targetPort: {{ .Values.api.port }} + protocol: TCP + selector: + {{- include "legion.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: api diff --git a/deploy/helm/legion/templates/serviceaccount.yaml b/deploy/helm/legion/templates/serviceaccount.yaml new file mode 100644 index 00000000..40c23b6b --- /dev/null +++ b/deploy/helm/legion/templates/serviceaccount.yaml @@ -0,0 +1,8 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Values.serviceAccount.name }} + labels: + {{- include "legion.labels" . | nindent 4 }} +{{- end }} diff --git a/deploy/helm/legion/values.yaml b/deploy/helm/legion/values.yaml new file mode 100644 index 00000000..b1987ef1 --- /dev/null +++ b/deploy/helm/legion/values.yaml @@ -0,0 +1,69 @@ +image: + repository: ghcr.io/legionio/legion + tag: latest + pullPolicy: IfNotPresent + +worker: + replicas: 2 + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 512Mi + hpa: + enabled: false + minReplicas: 2 + maxReplicas: 20 + targetCPUUtilization: 70 + env: [] + +api: + replicas: 2 + port: 4567 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 256Mi + service: + type: ClusterIP + ingress: + enabled: false + env: [] + +rabbitmq: + host: rabbitmq + port: 5672 + vhost: / + existingSecret: legion-rabbitmq + +postgresql: + host: postgresql + port: 5432 + database: legion + existingSecret: legion-postgresql + +redis: + host: redis + port: 6379 + +vault: + enabled: false + address: "" + role: legion + +serviceAccount: + create: true + name: legion + +podDisruptionBudget: + enabled: true + minAvailable: 1 + +nodeSelector: {} +tolerations: [] +affinity: {} diff --git a/docker_deploy.rb b/docker_deploy.rb index 19c427ee..b9067d35 100755 --- a/docker_deploy.rb +++ b/docker_deploy.rb @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true require './lib/legion/version' puts "Building docker image for Legion v#{Legion::VERSION}" diff --git a/exe/legion b/exe/legion index 051bdb78..808f1b42 100755 --- a/exe/legion +++ b/exe/legion @@ -1,4 +1,29 @@ #!/usr/bin/env ruby -require 'thor' -require 'legion/cli' -Legion::CLI.start +# frozen_string_literal: true + +RubyVM::YJIT.enable if defined?(RubyVM::YJIT) + +ENV['RUBY_GC_HEAP_INIT_SLOTS'] ||= '600000' +ENV['RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO'] ||= '0.20' +ENV['RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO'] ||= '0.40' +ENV['RUBY_GC_MALLOC_LIMIT'] ||= '64000000' +ENV['RUBY_GC_MALLOC_LIMIT_MAX'] ||= '128000000' + +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) + +# Bare `legion` (no args, interactive terminal) launches the TTY shell +# Bare `legion` (piped stdin) goes to headless chat prompt +# `legion ` routes to the Interactive CLI (dev-workflow commands) +if ARGV.empty? + if $stdin.tty? + require 'legion/tty' + Legion::TTY::App.run + else + require 'legion/cli' + ARGV.replace(['chat', 'prompt', '']) + Legion::CLI::Main.start(ARGV) + end +else + require 'legion/cli' + Legion::CLI::Interactive.start(ARGV) +end diff --git a/exe/legionio b/exe/legionio index 1f0039c4..bc0a1e9b 100755 --- a/exe/legionio +++ b/exe/legionio @@ -1,53 +1,26 @@ #!/usr/bin/env ruby # frozen_string_literal: true -$LOAD_PATH << File.expand_path(File.join(File.dirname(__FILE__), '..', 'lib')) - -require 'optparse' -options = { action: :run } - -daemonize_help = 'run daemonized in the background (default: false)' -pidfile_help = 'the pid filename' -logfile_help = 'the log filename' -include_help = 'an additional $LOAD_PATH (may be used more than once)' -debug_help = 'set $DEBUG to true' -warn_help = 'enable warnings' -time_help = 'only run legion for X seconds' - -op = OptionParser.new -op.banner = 'An example of how to daemonize a long running Ruby process.' -op.separator '' -op.separator 'Usage: server [options]' -op.separator '' - -op.separator '' -op.separator 'Process options:' -op.on('-d', '--daemonize', daemonize_help) { options[:daemonize] = true } -op.on('-p', '--pid PIDFILE', pidfile_help) { |value| options[:pidfile] = value } -op.on('-l', '--log LOGFILE', logfile_help) { |value| options[:logfile] = value } -op.on('-t', '--time 10', time_help) { |value| options[:time_limit] = value } - -op.separator '' -op.separator 'Ruby options:' -op.on('-I', '--include PATH', include_help) do |value| - $LOAD_PATH.unshift(*value.split(':').map do |v| - File.expand_path(v) - end) +RubyVM::YJIT.enable if defined?(RubyVM::YJIT) + +ENV['RUBY_GC_HEAP_INIT_SLOTS'] ||= '600000' +ENV['RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO'] ||= '0.20' +ENV['RUBY_GC_HEAP_FREE_SLOTS_MAX_RATIO'] ||= '0.40' +ENV['RUBY_GC_MALLOC_LIMIT'] ||= '64000000' +ENV['RUBY_GC_MALLOC_LIMIT_MAX'] ||= '128000000' + +if ENV['LEGION_BOOTSNAP'] == 'true' && Dir.exist?(File.expand_path('~/.legionio')) + require 'bootsnap' + Bootsnap.setup( + cache_dir: File.expand_path('~/.legionio/cache/bootsnap'), + development_mode: false, + load_path_cache: true, + compile_cache_iseq: true, + compile_cache_yaml: true + ) end -op.on('--debug', debug_help) { $DEBUG = true } -op.on('--warn', warn_help) { $-w = true } - -op.separator '' -op.separator 'Common options:' -op.on('-h', '--help') { options[:action] = :help } -op.on('-v', '--version') { options[:action] = :version } -op.separator '' -op.parse!(ARGV) +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) -unless options[:action] == :help - require 'legion' - Legion.start - require 'legion/process' - Legion::Process.new(options).run! -end +require 'legion/cli' +Legion::CLI::Main.start(ARGV) diff --git a/exe/lex_gen b/exe/lex_gen deleted file mode 100755 index 44c1963f..00000000 --- a/exe/lex_gen +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env ruby -require 'thor' -require 'legion/lex' - -Legion::Cli::LexBuilder.start diff --git a/exe/replay_ledger b/exe/replay_ledger new file mode 100755 index 00000000..b3832f0f --- /dev/null +++ b/exe/replay_ledger @@ -0,0 +1,395 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# One-off script to migrate local ledger data to prod database. +# Handles FK remapping for identity tables by matching on natural keys. +# +# Usage: +# LOCAL_DB_URL="postgres://localhost/legionio" PROD_DB_URL="postgres://user:pass@prod/legionio" bundle exec exe/replay_ledger +# +# Optional: +# REPLAY_DRY_RUN=true (show counts, don't write) + +require 'sequel' +require 'logger' +require 'uri' +require 'json' +require 'fileutils' + +log = Logger.new($stdout) +log.level = Logger::INFO + +# --- Signal handling for clean exit --- +module MigrationState + @shutdown = false + class << self + attr_accessor :shutdown + end +end + +%w[INT TERM HUP].each do |sig| + Signal.trap(sig) do + if MigrationState.shutdown + warn "\nForce quit." + exit!(1) + end + MigrationState.shutdown = true + warn "\nShutdown requested — finishing current row, then saving manifests and exiting cleanly..." + end +end + +local_url = ENV.fetch('LOCAL_DB_URL', 'postgres://localhost/legionio') +dry_run = ENV['REPLAY_DRY_RUN'] == 'true' + +# Read prod creds from ~/.legionio/settings/z_data_override.json +prod_settings_path = File.expand_path('~/.legionio/settings/z_data_override.json') +abort "Missing #{prod_settings_path} — create it with host/database/user/password" unless File.exist?(prod_settings_path) +prod_config = JSON.parse(File.read(prod_settings_path)) +prod_creds = prod_config.dig('data', 'creds') || abort("No data.creds in #{prod_settings_path}") +prod_url = "postgres://#{prod_creds['user']}:#{URI.encode_www_form_component(prod_creds['password'])}@#{prod_creds['host']}/#{prod_creds['database']}" + +LOCAL = Sequel.connect(local_url) +PROD = Sequel.connect(prod_url) + +log.info "Local: #{local_url.sub(/:[^:@]+@/, ':***@')}" +log.info "Prod: postgres://#{prod_creds['user']}:***@#{prod_creds['host']}/#{prod_creds['database']}" +log.info "Mode: #{dry_run ? 'DRY RUN' : 'LIVE'}" + +# ============================================================ +# PHASE 1: Sync identity_providers (match on name) +# ============================================================ + +log.info '--- Phase 1: identity_providers ---' +local_providers = LOCAL[:identity_providers].all +prod_providers = PROD[:identity_providers].all +prod_provider_by_name = prod_providers.to_h { |p| [p[:name], p] } +provider_id_map = {} # local_id → prod_id + +local_providers.each do |lp| + prod_match = prod_provider_by_name[lp[:name]] + if prod_match + provider_id_map[lp[:id]] = prod_match[:id] + else + row = lp.except(:id) + if dry_run + log.info " [DRY] Would insert provider: #{lp[:name]}" + provider_id_map[lp[:id]] = -1 + else + new_id = PROD[:identity_providers].insert(row) + provider_id_map[lp[:id]] = new_id + log.info " Inserted provider: #{lp[:name]} → id=#{new_id}" + end + end +end +log.info " Provider map: #{provider_id_map.size} entries (#{provider_id_map.count { |_, v| v != -1 }} matched)" + +# ============================================================ +# PHASE 2: Sync identity_principals (match on canonical_name) +# ============================================================ + +log.info '--- Phase 2: identity_principals ---' +local_principals = LOCAL[:identity_principals].all +prod_principals = PROD[:identity_principals].all +prod_principal_by_name = prod_principals.to_h { |p| [p[:canonical_name], p] } +principal_id_map = {} # local_id → prod_id + +local_principals.each do |lp| + prod_match = prod_principal_by_name[lp[:canonical_name]] + if prod_match + principal_id_map[lp[:id]] = prod_match[:id] + else + row = lp.except(:id) + if dry_run + log.info " [DRY] Would insert principal: #{lp[:canonical_name]}" + principal_id_map[lp[:id]] = -1 + else + new_id = PROD[:identity_principals].insert(row) + principal_id_map[lp[:id]] = new_id + log.info " Inserted principal: #{lp[:canonical_name]} → id=#{new_id}" + end + end +end +log.info " Principal map: #{principal_id_map.size} entries (#{principal_id_map.count { |_, v| v != -1 }} matched)" + +# ============================================================ +# PHASE 3: Sync identities (match on principal_id + provider_identity_key) +# ============================================================ + +log.info '--- Phase 3: identities ---' +local_identities = LOCAL[:identities].all +prod_identities = PROD[:identities].all +prod_identity_by_key = prod_identities.to_h { |i| ["#{i[:principal_id]}:#{i[:provider_identity_key]}", i] } +identity_id_map = {} # local_id → prod_id + +local_identities.each do |li| + mapped_principal = principal_id_map[li[:principal_id]] + mapped_provider = provider_id_map[li[:provider_id]] + prod_key = "#{mapped_principal}:#{li[:provider_identity_key]}" + prod_match = prod_identity_by_key[prod_key] + + if prod_match + identity_id_map[li[:id]] = prod_match[:id] + else + row = li.except(:id) + row[:principal_id] = mapped_principal + row[:provider_id] = mapped_provider + if dry_run + log.info " [DRY] Would insert identity: #{li[:provider_identity_key]} (principal=#{mapped_principal})" + identity_id_map[li[:id]] = -1 + else + new_id = PROD[:identities].insert(row) + identity_id_map[li[:id]] = new_id + log.info " Inserted identity: #{li[:provider_identity_key]} → id=#{new_id}" + end + end +end +log.info " Identity map: #{identity_id_map.size} entries (#{identity_id_map.count { |_, v| v != -1 }} matched)" + +# ============================================================ +# PHASE 4: LLM tables — remap identity FKs, preserve UUID links +# ============================================================ + +def remap_identity_columns(row, principal_map, identity_map) + result = row.dup + # Different tables use different column names + %i[principal_id caller_principal_id].each do |col| + result[col] = principal_map[result[col]] if result.key?(col) && result[col] && principal_map.key?(result[col]) + end + %i[identity_id caller_identity_id].each do |col| + result[col] = identity_map[result[col]] if result.key?(col) && result[col] && identity_map.key?(result[col]) + end + result +end + +MANIFEST_DIR = '/tmp/legion_migration_manifests' + +# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists +def migrate_table(local_db, prod_db, table, id_map_out, log:, dry_run:, fk_maps: {}, identity_maps: {}) + count = local_db[table].count + if count.zero? + log.info " #{table}: 0 rows, skipping" + return + end + + log.info " #{table}: #{count} rows to migrate..." + + # Check for UUID column to detect duplicates + columns = local_db[table].columns + has_uuid = columns.include?(:uuid) + log.info " #{table}: loading existing UUIDs from prod..." if has_uuid + prod_uuids = if has_uuid + PROD[table].select_map(:uuid).to_set + else + Set.new + end + log.info " #{table}: #{prod_uuids.size} existing UUIDs on prod" if has_uuid + + FileUtils.mkdir_p(MANIFEST_DIR) + manifest_path = File.join(MANIFEST_DIR, "#{table}.json") + ids_path = File.join(MANIFEST_DIR, "#{table}_ids.jsonl") + + inserted = 0 + skipped = 0 + start_time = Time.now + + # Use block form to avoid file descriptor leaks + File.open(ids_path, 'a') do |ids_file| + local_db[table].order(:id).each do |row| + if MigrationState.shutdown + log.warn " #{table}: INTERRUPTED at row #{inserted + skipped}/#{count} — exiting cleanly" + break + end + + local_id = row[:id] + + # Skip if already exists on prod (by UUID) + if has_uuid && row[:uuid] && prod_uuids.include?(row[:uuid]) + prod_row = prod_db[table].where(uuid: row[:uuid]).first + id_map_out[local_id] = prod_row[:id] if prod_row + skipped += 1 + next + end + + new_row = row.except(:id) + + # Remap FK columns — if map is empty, NULL the column (deferred backfill) + fk_maps.each do |column, map| + next unless new_row.key?(column) && new_row[column] + + new_row[column] = if map.empty? + nil + elsif map.key?(new_row[column]) + map[new_row[column]] + end + end + + # Remap identity columns + new_row = remap_identity_columns(new_row, identity_maps[:principals] || {}, identity_maps[:identities] || {}) unless identity_maps.empty? + + if dry_run + inserted += 1 + id_map_out[local_id] = -1 + else + begin + new_id = prod_db[table].insert(new_row) + id_map_out[local_id] = new_id + ids_file.puts("#{local_id},#{new_id}") + ids_file.flush + inserted += 1 + rescue Sequel::ForeignKeyConstraintViolation => e + log.warn " #{table}: FK violation on local_id=#{local_id} — #{e.message.lines.first.strip} — skipping" + skipped += 1 + rescue Sequel::UniqueConstraintViolation => e + log.warn " #{table}: duplicate on local_id=#{local_id} — #{e.message.lines.first.strip} — skipping" + skipped += 1 + rescue Sequel::DatabaseError => e + log.error " #{table}: DB error on local_id=#{local_id} — #{e.message.lines.first.strip} — skipping" + skipped += 1 + end + end + + # Progress logging every 100 rows + next unless ((inserted + skipped) % 100).zero? + + elapsed = (Time.now - start_time).round(1) + rate = ((inserted + skipped) / [elapsed, 0.1].max).round(1) + log.info " #{table}: progress #{inserted + skipped}/#{count} (inserted=#{inserted} skipped=#{skipped}) #{elapsed}s #{rate} rows/s" + end + end + + # Write final summary manifest + manifest = { + table: table.to_s, + started_at: start_time.iso8601, + completed_at: Time.now.iso8601, + inserted_count: inserted, + skipped_count: skipped, + interrupted: MigrationState.shutdown, + ids_file: ids_path + } + File.write(manifest_path, JSON.pretty_generate(manifest)) + + elapsed = (Time.now - start_time).round(1) + log.info " #{table}: done inserted=#{inserted} skipped=#{skipped} elapsed=#{elapsed}s" + log.info " #{table}: manifest → #{manifest_path}" + log.info " #{table}: IDs → #{ids_path}" +end +# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/ParameterLists + +identity_maps = { principals: principal_id_map, identities: identity_id_map } + +# --- llm_conversations --- +conversation_id_map = {} +unless MigrationState.shutdown + log.info '--- Phase 4: llm_conversations ---' + migrate_table(LOCAL, PROD, :llm_conversations, conversation_id_map, + identity_maps: identity_maps, log: log, dry_run: dry_run) +end + +# --- llm_message_inference_requests --- +request_id_map = {} +unless MigrationState.shutdown + log.info '--- Phase 5: llm_message_inference_requests ---' + migrate_table(LOCAL, PROD, :llm_message_inference_requests, request_id_map, + fk_maps: { conversation_id: conversation_id_map, latest_message_id: {} }, + identity_maps: identity_maps, log: log, dry_run: dry_run) +end + +# --- llm_message_inference_responses --- +response_id_map = {} +unless MigrationState.shutdown + log.info '--- Phase 6: llm_message_inference_responses ---' + migrate_table(LOCAL, PROD, :llm_message_inference_responses, response_id_map, + fk_maps: { message_inference_request_id: request_id_map, response_message_id: {} }, + identity_maps: identity_maps, log: log, dry_run: dry_run) +end + +# --- llm_message_inference_metrics --- +metrics_id_map = {} +unless MigrationState.shutdown + log.info '--- Phase 7: llm_message_inference_metrics ---' + migrate_table(LOCAL, PROD, :llm_message_inference_metrics, metrics_id_map, + fk_maps: { message_inference_request_id: request_id_map, + message_inference_response_id: response_id_map }, + identity_maps: identity_maps, log: log, dry_run: dry_run) +end + +# --- llm_messages --- +message_id_map = {} +unless MigrationState.shutdown + log.info '--- Phase 8: llm_messages ---' + migrate_table(LOCAL, PROD, :llm_messages, message_id_map, + fk_maps: { conversation_id: conversation_id_map, + message_inference_request_id: request_id_map, + message_inference_response_id: response_id_map, + parent_message_id: message_id_map }, + identity_maps: identity_maps, log: log, dry_run: dry_run) +end + +# --- llm_tool_calls --- +tool_call_id_map = {} +unless MigrationState.shutdown + log.info '--- Phase 9: llm_tool_calls ---' + migrate_table(LOCAL, PROD, :llm_tool_calls, tool_call_id_map, + fk_maps: { conversation_id: conversation_id_map, + message_inference_response_id: response_id_map, + requested_by_message_id: message_id_map, + result_message_id: message_id_map }, + identity_maps: identity_maps, log: log, dry_run: dry_run) +end + +# --- llm_tool_call_attempts --- +tool_attempt_id_map = {} +unless MigrationState.shutdown + log.info '--- Phase 10: llm_tool_call_attempts ---' + migrate_table(LOCAL, PROD, :llm_tool_call_attempts, tool_attempt_id_map, + fk_maps: { tool_call_id: tool_call_id_map }, + identity_maps: identity_maps, log: log, dry_run: dry_run) +end + +# --- Update latest_message_id and response_message_id now that messages exist --- +unless dry_run || MigrationState.shutdown + log.info '--- Phase 11: Backfill deferred FK columns ---' + + backfilled = 0 + LOCAL[:llm_message_inference_requests].where(Sequel.~(latest_message_id: nil)).each do |row| + break if MigrationState.shutdown + + prod_req_id = request_id_map[row[:id]] + prod_msg_id = message_id_map[row[:latest_message_id]] + next unless prod_req_id && prod_msg_id + + PROD[:llm_message_inference_requests].where(id: prod_req_id).update(latest_message_id: prod_msg_id) + backfilled += 1 + end + + LOCAL[:llm_message_inference_responses].where(Sequel.~(response_message_id: nil)).each do |row| + break if MigrationState.shutdown + + prod_resp_id = response_id_map[row[:id]] + prod_msg_id = message_id_map[row[:response_message_id]] + next unless prod_resp_id && prod_msg_id + + PROD[:llm_message_inference_responses].where(id: prod_resp_id).update(response_message_id: prod_msg_id) + backfilled += 1 + end + + log.info " Deferred FK backfill complete: #{backfilled} updates" +end + +# --- Summary --- +log.info '=== Migration Summary ===' +log.info " Providers: #{provider_id_map.size}" +log.info " Principals: #{principal_id_map.size}" +log.info " Identities: #{identity_id_map.size}" +log.info " Conversations: #{conversation_id_map.size}" +log.info " Requests: #{request_id_map.size}" +log.info " Responses: #{response_id_map.size}" +log.info " Metrics: #{metrics_id_map.size}" +log.info " Messages: #{message_id_map.size}" +log.info " Tool calls: #{tool_call_id_map.size}" +log.info " Tool attempts: #{tool_attempt_id_map.size}" +log.info " Manifests: #{MANIFEST_DIR}/" +log.info dry_run ? '[DRY RUN COMPLETE]' : '[MIGRATION COMPLETE]' + +log.info ' To rollback: bundle exec exe/rollback_ledger' diff --git a/exe/rollback_ledger b/exe/rollback_ledger new file mode 100755 index 00000000..607e2878 --- /dev/null +++ b/exe/rollback_ledger @@ -0,0 +1,91 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Rollback a ledger migration using the manifests generated by replay_ledger. +# +# Usage: +# bundle exec exe/rollback_ledger +# ROLLBACK_DRY_RUN=true bundle exec exe/rollback_ledger (show what would be deleted) + +require 'sequel' +require 'logger' +require 'uri' +require 'json' + +log = Logger.new($stdout) +log.level = Logger::INFO + +dry_run = ENV['ROLLBACK_DRY_RUN'] == 'true' +manifest_dir = ENV.fetch('MANIFEST_DIR', '/tmp/legion_migration_manifests') + +abort "No manifest directory at #{manifest_dir} — nothing to rollback" unless File.directory?(manifest_dir) + +# Read prod creds +prod_settings_path = File.expand_path('~/.legionio/settings/z_data_override.json') +abort "Missing #{prod_settings_path}" unless File.exist?(prod_settings_path) +prod_config = JSON.parse(File.read(prod_settings_path)) +prod_creds = prod_config.dig('data', 'creds') || abort("No data.creds in #{prod_settings_path}") +prod_url = "postgres://#{prod_creds['user']}:#{URI.encode_www_form_component(prod_creds['password'])}@#{prod_creds['host']}/#{prod_creds['database']}" + +PROD = Sequel.connect(prod_url) +log.info "Prod: postgres://#{prod_creds['user']}:***@#{prod_creds['host']}/#{prod_creds['database']}" +log.info "Mode: #{dry_run ? 'DRY RUN' : 'LIVE ROLLBACK'}" +log.info "Manifests: #{manifest_dir}" + +# Delete in reverse FK order +TABLES_REVERSE = %w[ + llm_tool_call_attempts + llm_tool_calls + llm_messages + llm_message_inference_metrics + llm_message_inference_responses + llm_message_inference_requests + llm_conversations +].freeze + +total_deleted = 0 + +TABLES_REVERSE.each do |table| + ids_file = File.join(manifest_dir, "#{table}_ids.jsonl") + unless File.exist?(ids_file) + log.info " #{table}: no IDs file, skipping" + next + end + + # Read prod IDs (second column in "local_id,prod_id" format) + ids = File.readlines(ids_file, chomp: true).filter_map do |line| + next if line.strip.empty? + + line.split(',')[1]&.to_i + end + + if ids.empty? + log.info " #{table}: 0 inserted IDs, skipping" + next + end + + log.info " #{table}: #{ids.size} rows to delete..." + + if dry_run + log.info " [DRY] Would delete #{ids.size} rows from #{table} (first 5 IDs: #{ids.first(5).join(',')})" + else + ids.each_slice(500) do |batch| + deleted = PROD[table.to_sym].where(id: batch).delete + total_deleted += deleted + end + log.info " #{table}: deleted #{ids.size} rows" + end +end + +# Also handle deferred FK backfills (latest_message_id, response_message_id) +# These were UPDATEs, not INSERTs — set them back to NULL +unless dry_run + requests_manifest = File.join(manifest_dir, 'llm_message_inference_requests.json') + if File.exist?(requests_manifest) + JSON.parse(File.read(requests_manifest))['inserted_ids'] || [] + # These were inserted rows that got deleted above, so no backfill undo needed + end +end + +log.info "=== Rollback #{dry_run ? 'Preview' : 'Complete'} ===" +log.info " Total deleted: #{dry_run ? '(dry run)' : total_deleted}" diff --git a/extensions-agentic/lex-consent/db/migrations/001_create_consent_maps.rb b/extensions-agentic/lex-consent/db/migrations/001_create_consent_maps.rb new file mode 100644 index 00000000..f9a57610 --- /dev/null +++ b/extensions-agentic/lex-consent/db/migrations/001_create_consent_maps.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +Sequel.migration do + change do + create_table(:consent_maps) do + primary_key :id + String :worker_id, null: false + String :from_tier, null: false + String :to_tier, null: false + String :requested_by, null: false + String :state, null: false, default: 'pending_approval' + String :resolved_by + Time :resolved_at + String :notes, text: true + String :context, text: true + Time :created_at + Time :updated_at + + index :worker_id + index :state + index %i[worker_id state] + end + end +end diff --git a/extensions-agentic/lex-consent/lex-consent.gemspec b/extensions-agentic/lex-consent/lex-consent.gemspec new file mode 100644 index 00000000..b817b219 --- /dev/null +++ b/extensions-agentic/lex-consent/lex-consent.gemspec @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'lib/legion/extensions/agentic/consent/version' + +Gem::Specification.new do |spec| + spec.name = 'lex-consent' + spec.version = Legion::Extensions::Agentic::Consent::VERSION + spec.authors = ['Esity'] + spec.email = ['matthewdiverson@gmail.com'] + spec.summary = 'LegionIO HITL consent gate for autonomous tier promotion' + spec.description = 'A LegionIO Extension (LEX) that gates agent autonomous tier promotion by human approval' + spec.homepage = 'https://github.com/LegionIO/lex-consent' + spec.license = 'MIT' + spec.required_ruby_version = '>= 3.4' + + spec.metadata = { + 'homepage_uri' => spec.homepage, + 'source_code_uri' => spec.homepage, + 'rubygems_mfa_required' => 'true' + } + + spec.files = Dir['lib/**/*', 'LICENSE', 'README.md'] + spec.require_paths = ['lib'] + + spec.add_dependency 'legionio', '>= 1.2' +end diff --git a/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent.rb b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent.rb new file mode 100644 index 00000000..88541053 --- /dev/null +++ b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'consent/version' +require_relative 'consent/models/consent_map' +require_relative 'consent/runners/consent' +require_relative 'consent/actors/tier_evaluation' + +module Legion + module Extensions + module Agentic + module Consent + end + end + end +end diff --git a/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/actors/tier_evaluation.rb b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/actors/tier_evaluation.rb new file mode 100644 index 00000000..f6d121fc --- /dev/null +++ b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/actors/tier_evaluation.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +return unless defined?(Legion::Extensions::Actors::Every) + +module Legion + module Extensions + module Agentic + module Consent + module Actor + class TierEvaluation < Legion::Extensions::Actors::Every + # Run tier evaluation and pending approval expiry every hour + INTERVAL = 3600 + + def perform + expire_stale_approvals + evaluate_pending_workers + rescue StandardError => e + Legion::Logging.error "[TierEvaluation] perform failed: #{e.message}" if defined?(Legion::Logging) + end + + private + + def expire_stale_approvals + return unless runner_available? + + runner = runner_instance + ttl_hours = Legion::Settings.dig(:consent, :pending_ttl_hours) || 72 + result = runner.expire_pending_approvals(ttl_hours: ttl_hours) + return unless result[:expired].to_i.positive? && defined?(Legion::Logging) + + Legion::Logging.info "[TierEvaluation] expired #{result[:expired]} stale consent requests" + rescue StandardError => e + Legion::Logging.warn "[TierEvaluation] expire_stale_approvals failed: #{e.message}" if defined?(Legion::Logging) + end + + def evaluate_pending_workers + return unless defined?(Legion::Data::Model::DigitalWorker) + return unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap) + + # Find active workers that may be eligible for autonomous tier promotion + # but do not yet have a pending approval request. + active_workers = Legion::Data::Model::DigitalWorker + .where(lifecycle_state: 'active') + .exclude(consent_tier: 'autonomous') + .all + + active_workers.each do |worker| + evaluate_worker_for_promotion(worker) + rescue StandardError => e + Legion::Logging.warn "[TierEvaluation] evaluate failed for worker=#{worker.worker_id}: #{e.message}" if defined?(Legion::Logging) + end + rescue StandardError => e + Legion::Logging.warn "[TierEvaluation] evaluate_pending_workers failed: #{e.message}" if defined?(Legion::Logging) + end + + def evaluate_worker_for_promotion(worker) + return unless promotion_eligible?(worker) + return if pending_request_exists?(worker.worker_id) + + from_tier = worker.consent_tier + to_tier = next_tier(from_tier) + return unless to_tier + + runner = runner_instance + runner.request_promotion( + worker_id: worker.worker_id, + from_tier: from_tier, + to_tier: to_tier, + requested_by: 'system:tier_evaluation' + ) + end + + def promotion_eligible?(worker) + return false unless worker.trust_score.to_f >= trust_threshold + return false unless (worker.risk_tier || 'low') == 'low' + + true + end + + def trust_threshold + Legion::Settings.dig(:consent, :promotion_trust_threshold) || 0.85 + rescue StandardError + 0.85 + end + + def next_tier(current_tier) + hierarchy = %w[supervised inform consult autonomous] + idx = hierarchy.index(current_tier) + return nil unless idx + return nil if idx >= hierarchy.length - 1 + + hierarchy[idx + 1] + end + + def pending_request_exists?(worker_id) + Legion::Extensions::Agentic::Consent::Models::ConsentMap + .pending_for_worker(worker_id).any? + end + + def runner_available? + defined?(Legion::Extensions::Agentic::Consent::Runners::Consent) + end + + def runner_instance + Object.new.extend(Legion::Extensions::Agentic::Consent::Runners::Consent) + end + end + end + end + end + end +end diff --git a/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/models/consent_map.rb b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/models/consent_map.rb new file mode 100644 index 00000000..47a0a702 --- /dev/null +++ b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/models/consent_map.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +return unless defined?(Legion::Data) + +module Legion + module Extensions + module Agentic + module Consent + module Models + class ConsentMap < Legion::Data::Model::Base + set_dataset :consent_maps + + STATES = %w[pending_approval approved rejected expired].freeze + + def self.pending + where(state: 'pending_approval') + end + + def self.for_worker(worker_id) + where(worker_id: worker_id) + end + + def self.pending_for_worker(worker_id) + where(worker_id: worker_id, state: 'pending_approval') + end + + def approve!(approver:, notes: nil) + update( + state: 'approved', + resolved_by: approver, + resolved_at: Time.now.utc, + notes: notes, + updated_at: Time.now.utc + ) + end + + def reject!(approver:, reason: nil) + update( + state: 'rejected', + resolved_by: approver, + resolved_at: Time.now.utc, + notes: reason, + updated_at: Time.now.utc + ) + end + + def expire! + update( + state: 'expired', + updated_at: Time.now.utc + ) + end + + def pending? + state == 'pending_approval' + end + + def approved? + state == 'approved' + end + + def rejected? + state == 'rejected' + end + + def expired? + state == 'expired' + end + end + end + end + end + end +end diff --git a/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/runners/consent.rb b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/runners/consent.rb new file mode 100644 index 00000000..104969ce --- /dev/null +++ b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/runners/consent.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Agentic + module Consent + module Runners + module Consent + # Request human approval for a worker's autonomous tier promotion. + # Creates a ConsentMap record in pending_approval state. + # + # @param worker_id [String] the worker requesting promotion + # @param from_tier [String] current consent tier + # @param to_tier [String] requested consent tier + # @param requested_by [String] identity requesting the promotion + # @param context [Hash] optional metadata about why promotion is requested + # @return [Hash] + def request_promotion(worker_id:, from_tier:, to_tier:, **opts) + requested_by = opts.fetch(:requested_by, 'system') + context = opts.fetch(:context, {}) + + return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap) + + existing = Legion::Extensions::Agentic::Consent::Models::ConsentMap + .pending_for_worker(worker_id).first + + if existing + Legion::Logging.info "[lex-consent] promotion already pending for worker=#{worker_id}" if defined?(Legion::Logging) + return { success: false, reason: :already_pending, consent_map_id: existing.id } + end + + record = Legion::Extensions::Agentic::Consent::Models::ConsentMap.create( + worker_id: worker_id, + from_tier: from_tier, + to_tier: to_tier, + requested_by: requested_by, + state: 'pending_approval', + context: defined?(Legion::JSON) ? Legion::JSON.dump(context) : context.to_json, + created_at: Time.now.utc, + updated_at: Time.now.utc + ) + + if defined?(Legion::Events) + Legion::Events.emit('consent.promotion_requested', { + worker_id: worker_id, + from_tier: from_tier, + to_tier: to_tier, + requested_by: requested_by, + consent_map_id: record.id, + at: Time.now.utc + }) + end + + Legion::Logging.info "[lex-consent] promotion requested worker=#{worker_id} #{from_tier}->#{to_tier} id=#{record.id}" if defined?(Legion::Logging) + + { success: true, consent_map_id: record.id, state: 'pending_approval' } + rescue StandardError => e + Legion::Logging.error "[lex-consent] request_promotion failed: #{e.message}" if defined?(Legion::Logging) + { success: false, reason: e.message } + end + + # Approve a pending tier promotion request. + # + # @param consent_map_id [Integer] the ConsentMap record to approve + # @param approver [String] identity of the approver + # @param notes [String] optional approval notes + # @return [Hash] + def approve_promotion(consent_map_id:, approver:, notes: nil, **) + return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap) + + record = Legion::Extensions::Agentic::Consent::Models::ConsentMap[consent_map_id.to_i] + return { success: false, reason: :not_found } unless record + return { success: false, reason: :not_pending, state: record.state } unless record.pending? + + record.approve!(approver: approver, notes: notes) + + apply_promotion(record) + + if defined?(Legion::Events) + Legion::Events.emit('consent.promotion_approved', { + consent_map_id: record.id, + worker_id: record.worker_id, + from_tier: record.from_tier, + to_tier: record.to_tier, + approver: approver, + at: Time.now.utc + }) + end + + Legion::Logging.info "[lex-consent] approved consent_map_id=#{record.id} worker=#{record.worker_id} by=#{approver}" if defined?(Legion::Logging) + + { success: true, consent_map_id: record.id, worker_id: record.worker_id, state: 'approved', to_tier: record.to_tier } + rescue StandardError => e + Legion::Logging.error "[lex-consent] approve_promotion failed: #{e.message}" if defined?(Legion::Logging) + { success: false, reason: e.message } + end + + # Reject a pending tier promotion request. + # + # @param consent_map_id [Integer] the ConsentMap record to reject + # @param approver [String] identity of the approver + # @param reason [String] rejection reason (required) + # @return [Hash] + def reject_promotion(consent_map_id:, approver:, reason:, **) + return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap) + + return { success: false, reason: :missing_reason } if reason.nil? || reason.to_s.strip.empty? + + record = Legion::Extensions::Agentic::Consent::Models::ConsentMap[consent_map_id.to_i] + return { success: false, reason: :not_found } unless record + return { success: false, reason: :not_pending, state: record.state } unless record.pending? + + record.reject!(approver: approver, reason: reason) + + if defined?(Legion::Events) + Legion::Events.emit('consent.promotion_rejected', { + consent_map_id: record.id, + worker_id: record.worker_id, + from_tier: record.from_tier, + to_tier: record.to_tier, + approver: approver, + reason: reason, + at: Time.now.utc + }) + end + + Legion::Logging.info "[lex-consent] rejected consent_map_id=#{record.id} worker=#{record.worker_id} by=#{approver}" if defined?(Legion::Logging) + + { success: true, consent_map_id: record.id, worker_id: record.worker_id, state: 'rejected' } + rescue StandardError => e + Legion::Logging.error "[lex-consent] reject_promotion failed: #{e.message}" if defined?(Legion::Logging) + { success: false, reason: e.message } + end + + # Expire all pending promotion requests older than ttl_hours. + # Intended to be run on a schedule (e.g. every hour). + # + # @param ttl_hours [Integer] how many hours before a pending request expires (default 72) + # @return [Hash] + def expire_pending_approvals(ttl_hours: 72, **) + return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap) + + cutoff = Time.now.utc - (ttl_hours * 3600) + expired_count = 0 + + Legion::Extensions::Agentic::Consent::Models::ConsentMap + .pending + .where { created_at < cutoff } + .each do |record| + record.expire! + expired_count += 1 + + if defined?(Legion::Events) + Legion::Events.emit('consent.promotion_expired', { + consent_map_id: record.id, + worker_id: record.worker_id, + from_tier: record.from_tier, + to_tier: record.to_tier, + at: Time.now.utc + }) + end + rescue StandardError => e + Legion::Logging.warn "[lex-consent] expire failed for id=#{record.id}: #{e.message}" if defined?(Legion::Logging) + end + + Legion::Logging.info "[lex-consent] expired #{expired_count} pending approvals (ttl=#{ttl_hours}h)" if defined?(Legion::Logging) + + { success: true, expired: expired_count, ttl_hours: ttl_hours } + rescue StandardError => e + Legion::Logging.error "[lex-consent] expire_pending_approvals failed: #{e.message}" if defined?(Legion::Logging) + { success: false, reason: e.message } + end + + # List pending promotion requests. + # + # @param worker_id [String] optional filter by worker + # @return [Hash] + def list_pending(worker_id: nil, **) + return { success: false, reason: :model_unavailable } unless defined?(Legion::Extensions::Agentic::Consent::Models::ConsentMap) + + ds = Legion::Extensions::Agentic::Consent::Models::ConsentMap.pending + ds = ds.where(worker_id: worker_id) if worker_id + records = ds.all + + { success: true, count: records.size, pending: records.map(&:values) } + rescue StandardError => e + Legion::Logging.error "[lex-consent] list_pending failed: #{e.message}" if defined?(Legion::Logging) + { success: false, reason: e.message } + end + + private + + def apply_promotion(record) + return unless defined?(Legion::Data::Model::DigitalWorker) + + worker = Legion::Data::Model::DigitalWorker.first(worker_id: record.worker_id) + return unless worker + + worker.update(consent_tier: record.to_tier, updated_at: Time.now.utc) + + Legion::Logging.info "[lex-consent] applied tier promotion worker=#{record.worker_id} tier=#{record.to_tier}" if defined?(Legion::Logging) + rescue StandardError => e + Legion::Logging.warn "[lex-consent] apply_promotion failed for worker=#{record.worker_id}: #{e.message}" if defined?(Legion::Logging) + end + end + end + end + end + end +end diff --git a/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/version.rb b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/version.rb new file mode 100644 index 00000000..19d5f321 --- /dev/null +++ b/extensions-agentic/lex-consent/lib/legion/extensions/agentic/consent/version.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Agentic + module Consent + VERSION = '0.1.0' + end + end + end +end diff --git a/extensions-agentic/lex-reconciliation/Gemfile b/extensions-agentic/lex-reconciliation/Gemfile new file mode 100644 index 00000000..0964b2d2 --- /dev/null +++ b/extensions-agentic/lex-reconciliation/Gemfile @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' +gemspec + +group :development, :test do + gem 'rspec', '~> 3.12' + gem 'rubocop', '~> 1.50' + gem 'rubocop-rspec', '~> 2.20' +end diff --git a/extensions-agentic/lex-reconciliation/README.md b/extensions-agentic/lex-reconciliation/README.md new file mode 100644 index 00000000..2bc7b45f --- /dev/null +++ b/extensions-agentic/lex-reconciliation/README.md @@ -0,0 +1,73 @@ +# lex-reconciliation + +A [LegionIO](https://github.com/LegionIO) extension for drift detection and reconciliation. + +Detects drift between expected (desired) state and actual (observed) state for managed resources, +persists drift events to a log, and runs periodic reconciliation cycles that emit events for +downstream runners to act on. + +## Components + +### `Runners::DriftChecker` + +Detects drift between expected and actual state for one or more resources. + +ruby +result = drift_checker.check( + resource: 'my-service', + expected: { status: 'running', replicas: 3 }, + actual: { status: 'stopped', replicas: 1 }, + severity: 'high' +) +# => { drifted: true, drift_id: '...', differences: [...], summary: { total: 2 } } + + +### `DriftLog` + +Persistent drift event log backed by `legion-data`. + +ruby +Legion::Extensions::Reconciliation::DriftLog.record( + resource: 'my-service', + expected: { status: 'running' }, + actual: { status: 'stopped' }, + severity: 'high' +) + +Legion::Extensions::Reconciliation::DriftLog.open_entries(severity: 'high') +Legion::Extensions::Reconciliation::DriftLog.summary + + +### `Actors::ReconciliationCycle` + +Interval actor (default: every 5 minutes) that checks all configured targets and emits +`reconciliation.drift_detected` and `reconciliation.reconcile_requested` events. + +Configure targets in settings: + + +{ + "extensions": { + "reconciliation": { + "interval": 300, + "targets": [ + { + "resource": "my-service", + "expected": { "status": "running", "replicas": 3 }, + "severity": "high" + } + ] + } + } +} + + +## Installation + +ruby +gem 'lex-reconciliation' + + +## License + +MIT diff --git a/extensions-agentic/lex-reconciliation/lex-reconciliation.gemspec b/extensions-agentic/lex-reconciliation/lex-reconciliation.gemspec new file mode 100644 index 00000000..24a877aa --- /dev/null +++ b/extensions-agentic/lex-reconciliation/lex-reconciliation.gemspec @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'lib/legion/extensions/reconciliation/version' + +Gem::Specification.new do |spec| + spec.name = 'lex-reconciliation' + spec.version = Legion::Extensions::Reconciliation::VERSION + spec.authors = ['Esity'] + spec.email = ['matthewdiverson@gmail.com'] + spec.summary = 'A LegionIO Extension for drift detection and reconciliation' + spec.description = 'A LegionIO Extension (LEX) for detecting drift between expected and actual state and reconciling differences' + spec.homepage = 'https://github.com/LegionIO/lex-reconciliation' + spec.license = 'MIT' + spec.required_ruby_version = '>= 3.4' + + spec.metadata = { + 'homepage_uri' => spec.homepage, + 'source_code_uri' => spec.homepage, + 'rubygems_mfa_required' => 'true' + } + + spec.files = Dir['lib/**/*', 'LICENSE', 'README.md'] + spec.require_paths = ['lib'] + + spec.add_dependency 'legionio', '>= 1.2' +end diff --git a/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation.rb b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation.rb new file mode 100644 index 00000000..a5cd74c8 --- /dev/null +++ b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require_relative 'reconciliation/version' +require_relative 'reconciliation/drift_log' +require_relative 'reconciliation/runners/drift_checker' +require_relative 'reconciliation/actors/reconciliation_cycle' + +module Legion + module Extensions + module Reconciliation + end + end +end diff --git a/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/actors/reconciliation_cycle.rb b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/actors/reconciliation_cycle.rb new file mode 100644 index 00000000..58a54682 --- /dev/null +++ b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/actors/reconciliation_cycle.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Reconciliation + module Actors + # Periodic reconciliation actor. + # + # Runs on a configurable interval (default: every 5 minutes). On each tick + # it invokes the DriftChecker against all registered reconciliation targets + # (read from settings) and attempts to reconcile any open drift entries by + # emitting reconciliation events that downstream runners can act on. + class ReconciliationCycle < Legion::Extensions::Actors::Every + # Default interval in seconds (5 minutes). Override via settings: + # extensions.reconciliation.interval + def every + interval = settings_value(:interval) || 300 + interval.to_i + end + + def action + log.info '[ReconciliationCycle] starting reconciliation cycle' if respond_to?(:log) + + targets = load_targets + if targets.empty? + log.debug '[ReconciliationCycle] no reconciliation targets configured' if respond_to?(:log) + return + end + + resources = build_resource_snapshots(targets) + result = drift_checker.check_all(resources: resources) + + log.info "[ReconciliationCycle] checked=#{result[:checked]} drifted=#{result[:drifted]}" if respond_to?(:log) + + attempt_reconciliation(result[:results]) if result[:drifted].positive? + + emit_cycle_event(result) + rescue StandardError => e + log_error("[ReconciliationCycle] cycle failed: #{e.message}") + end + + private + + # Load reconciliation targets from settings. + # Expected settings shape: + # extensions.reconciliation.targets: + # - resource: "my-service" + # expected: { ... } + # severity: "medium" + def load_targets + return [] unless defined?(Legion::Settings) + + Array(Legion::Settings.dig(:extensions, :reconciliation, :targets)) + rescue StandardError => e + log_error("[ReconciliationCycle] load_targets failed: #{e.message}") + [] + end + + # Build resource snapshots by resolving the actual state for each target. + # Subclasses or downstream runners may override actual-state resolution. + def build_resource_snapshots(targets) + targets.map do |target| + resource = target[:resource] || target['resource'] + expected = target[:expected] || target['expected'] || {} + severity = target[:severity] || target['severity'] || 'medium' + actual = resolve_actual_state(resource, expected) + + { resource: resource, expected: expected, actual: actual, severity: severity } + end.compact + end + + # Resolve the actual (live) state for a given resource. + # Default implementation returns the expected state (no drift). + # Override this method or provide a :state_resolver in settings to add + # real state introspection. + def resolve_actual_state(resource, expected) + resolver_class = settings_value(:state_resolver) + if resolver_class + klass = Kernel.const_get(resolver_class) + return klass.new.resolve(resource: resource) if klass.method_defined?(:resolve) + end + + # Default: no drift (returns expected unchanged) + expected + rescue StandardError => e + log_error("[ReconciliationCycle] resolve_actual_state failed for #{resource}: #{e.message}") + expected + end + + # For each drifted result, emit a reconciliation event so that + # downstream runners can take corrective action. + def attempt_reconciliation(results) + results.select { |r| r[:drifted] }.each do |result| + emit_reconciliation_event(result) + end + end + + def emit_reconciliation_event(result) + return unless defined?(Legion::Events) + + Legion::Events.emit('reconciliation.reconcile_requested', + resource: result[:resource], + drift_id: result[:drift_id], + differences: result[:differences], + severity: result.dig(:summary, :severity), + at: Time.now.utc) + rescue StandardError => e + log_error("[ReconciliationCycle] emit_reconciliation_event failed: #{e.message}") + end + + def emit_cycle_event(result) + return unless defined?(Legion::Events) + + Legion::Events.emit('reconciliation.cycle_complete', + checked: result[:checked], + drifted: result[:drifted], + at: Time.now.utc) + rescue StandardError => e + log_error("[ReconciliationCycle] emit_cycle_event failed: #{e.message}") + end + + def drift_checker + @drift_checker ||= Object.new.extend(Runners::DriftChecker) + end + + def settings_value(key) + Legion::Settings.dig(:extensions, :reconciliation, key) + rescue StandardError + nil + end + + def log_error(msg) + if defined?(Legion::Logging) + Legion::Logging.error(msg) + else + warn(msg) + end + end + end + end + end + end +end diff --git a/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/drift_log.rb b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/drift_log.rb new file mode 100644 index 00000000..284aa63f --- /dev/null +++ b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/drift_log.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Reconciliation + # Persistent drift event log. + # Records each detected drift event with resource identity, expected state, + # actual state, severity, and resolution status. + module DriftLog + SEVERITY_LEVELS = %w[low medium high critical].freeze + STATUS_VALUES = %w[open resolved ignored].freeze + + class << self + # Record a new drift event. + # + # @param resource [String] identifier of the drifted resource + # @param expected [Hash] the expected (desired) state + # @param actual [Hash] the observed (actual) state + # @param drift_type [String] category of drift (e.g. 'config', 'state', 'schema') + # @param severity [String] one of SEVERITY_LEVELS + # @param reconciled_by [String] runner or actor that detected the drift + # @return [Hash] the recorded drift entry + def record(resource:, expected:, actual:, **opts) + entry = build_entry( + resource: resource, + expected: expected, + actual: actual, + drift_type: opts.fetch(:drift_type, 'state'), + severity: opts.fetch(:severity, 'medium'), + reconciled_by: opts.fetch(:reconciled_by, 'drift_checker') + ) + + persist(entry) + emit_event(entry) + entry + rescue StandardError => e + Legion::Logging.error "[DriftLog] record failed for #{resource}: #{e.message}" if defined?(Legion::Logging) + nil + end + + # Mark a drift entry as resolved. + # + # @param drift_id [String] the drift entry identifier + # @param resolved_by [String] actor or runner that performed reconciliation + # @return [Boolean] true if updated, false if not found + def resolve(drift_id:, resolved_by: 'reconciliation_cycle') + return false unless data_available? + + count = Legion::Data.connection[:reconciliation_drift_log] + .where(drift_id: drift_id, status: 'open') + .update( + status: 'resolved', + resolved_by: resolved_by, + resolved_at: Time.now.utc + ) + count.positive? + rescue StandardError => e + Legion::Logging.error "[DriftLog] resolve failed for #{drift_id}: #{e.message}" if defined?(Legion::Logging) + false + end + + # Query open drift entries, optionally filtered by resource or severity. + # + # @param resource [String, nil] filter by resource identifier + # @param severity [String, nil] filter by severity level + # @param limit [Integer] maximum number of entries to return + # @return [Array] + def open_entries(resource: nil, severity: nil, limit: 100) + return [] unless data_available? + + ds = Legion::Data.connection[:reconciliation_drift_log].where(status: 'open') + ds = ds.where(resource: resource) if resource + ds = ds.where(severity: severity) if severity + ds.order(Sequel.desc(:detected_at)).limit(limit).all + rescue StandardError => e + Legion::Logging.error "[DriftLog] open_entries query failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + # Return a summary count of drift entries grouped by severity and status. + # + # @return [Hash] + def summary + return { open: 0, resolved: 0, by_severity: {} } unless data_available? + + rows = Legion::Data.connection[:reconciliation_drift_log] + .group_and_count(:status, :severity) + .all + + result = { open: 0, resolved: 0, by_severity: {} } + rows.each do |row| + result[:open] += row[:count] if row[:status] == 'open' + result[:resolved] += row[:count] if row[:status] == 'resolved' + sev = row[:severity].to_s + result[:by_severity][sev] ||= { open: 0, resolved: 0 } + result[:by_severity][sev][row[:status].to_sym] += row[:count] + end + result + rescue StandardError => e + Legion::Logging.error "[DriftLog] summary failed: #{e.message}" if defined?(Legion::Logging) + { open: 0, resolved: 0, by_severity: {} } + end + + private + + def build_entry(resource:, expected:, actual:, drift_type:, severity:, reconciled_by:) # rubocop:disable Metrics/ParameterLists + require 'securerandom' + { + drift_id: SecureRandom.uuid, + resource: resource.to_s, + expected: safe_serialize(expected), + actual: safe_serialize(actual), + drift_type: drift_type.to_s, + severity: SEVERITY_LEVELS.include?(severity.to_s) ? severity.to_s : 'medium', + status: 'open', + detected_by: reconciled_by.to_s, + detected_at: Time.now.utc, + resolved_by: nil, + resolved_at: nil + } + end + + def persist(entry) + return unless data_available? + + Legion::Data.connection[:reconciliation_drift_log].insert(entry) + rescue Sequel::Error => e + Legion::Logging.warn "[DriftLog] persist failed (table may not exist): #{e.message}" if defined?(Legion::Logging) + end + + def emit_event(entry) + return unless defined?(Legion::Events) + + Legion::Events.emit('reconciliation.drift_detected', + drift_id: entry[:drift_id], + resource: entry[:resource], + drift_type: entry[:drift_type], + severity: entry[:severity], + at: entry[:detected_at]) + rescue StandardError => e + Legion::Logging.warn "[DriftLog] event emit failed: #{e.message}" if defined?(Legion::Logging) + end + + def data_available? + defined?(Legion::Data) && + Legion::Data.respond_to?(:connection) && + !Legion::Data.connection.nil? + end + + def safe_serialize(obj) + return obj.to_s unless defined?(Legion::JSON) + + Legion::JSON.dump(obj) + rescue StandardError + obj.to_s + end + end + end + end + end +end diff --git a/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/runners/drift_checker.rb b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/runners/drift_checker.rb new file mode 100644 index 00000000..9eefc8a9 --- /dev/null +++ b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/runners/drift_checker.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Reconciliation + module Runners + # Detects drift between expected (desired) state and actual (observed) state. + # + # Callers supply a resource identifier, the expected state hash, and the actual + # state hash. The runner performs a deep comparison, records any deviations to + # DriftLog, and returns a structured result describing what drifted. + module DriftChecker + # Check a single resource for drift. + # + # @param resource [String] identifier for the resource being checked + # @param expected [Hash] the desired / expected state + # @param actual [Hash] the currently observed state + # @param severity [String] override severity ('low','medium','high','critical') + # @return [Hash] { drifted: Boolean, drift_entries: Array, summary: Hash } + def check(resource:, expected:, actual:, severity: 'medium', **_opts) + log.debug "[DriftChecker] checking resource: #{resource}" if respond_to?(:log) + + differences = deep_diff(expected, actual) + + if differences.empty? + log.debug "[DriftChecker] no drift detected for #{resource}" if respond_to?(:log) + return { drifted: false, resource: resource, drift_entries: [], summary: { total: 0 } } + end + + log.warn "[DriftChecker] drift detected for #{resource}: #{differences.size} difference(s)" if respond_to?(:log) + + drift_entry = DriftLog.record( + resource: resource, + expected: expected, + actual: actual, + drift_type: infer_drift_type(differences), + severity: severity, + reconciled_by: 'drift_checker' + ) + + { + drifted: true, + resource: resource, + drift_id: drift_entry&.dig(:drift_id), + differences: differences, + drift_entries: drift_entry ? [drift_entry] : [], + summary: { + total: differences.size, + severity: severity, + paths: differences.map { |d| d[:path] } + } + } + rescue StandardError => e + error_msg = "[DriftChecker] check failed for #{resource}: #{e.message}" + defined?(Legion::Logging) ? Legion::Logging.error(error_msg) : warn(error_msg) + { drifted: false, resource: resource, error: e.message, drift_entries: [], summary: { total: 0 } } + end + + # Check multiple resources in one call. + # + # @param resources [Array] each element must have :resource, :expected, :actual + # @return [Hash] { checked: Integer, drifted: Integer, results: Array } + def check_all(resources:, severity: 'medium', **_opts) + results = resources.map do |r| + check( + resource: r[:resource], + expected: r[:expected], + actual: r[:actual], + severity: r[:severity] || severity + ) + end + + { + checked: results.size, + drifted: results.count { |r| r[:drifted] }, + results: results + } + rescue StandardError => e + error_msg = "[DriftChecker] check_all failed: #{e.message}" + defined?(Legion::Logging) ? Legion::Logging.error(error_msg) : warn(error_msg) + { checked: 0, drifted: 0, results: [], error: e.message } + end + + # Return a summary of current open drift entries from the log. + # + # @return [Hash] + def drift_summary(**_opts) + DriftLog.summary + rescue StandardError => e + error_msg = "[DriftChecker] drift_summary failed: #{e.message}" + defined?(Legion::Logging) ? Legion::Logging.error(error_msg) : warn(error_msg) + { open: 0, resolved: 0, by_severity: {}, error: e.message } + end + + private + + # Perform a recursive diff between two hashes/values. + # Returns an array of { path:, expected:, actual: } for each differing leaf. + def deep_diff(expected, actual, path = '') + differences = [] + + case expected + when Hash + all_keys = (expected.keys + (actual.is_a?(Hash) ? actual.keys : [])).uniq + all_keys.each do |key| + child_path = path.empty? ? key.to_s : "#{path}.#{key}" + exp_val = expected[key] + act_val = actual.is_a?(Hash) ? actual[key] : nil + differences.concat(deep_diff(exp_val, act_val, child_path)) + end + when Array + if !actual.is_a?(Array) || expected != actual + differences << { path: path.empty? ? '(root)' : path, expected: expected, actual: actual } + end + else + if expected != actual + differences << { path: path.empty? ? '(root)' : path, expected: expected, actual: actual } + end + end + + differences + end + + # Infer a human-readable drift type from the set of differences. + def infer_drift_type(differences) + paths = differences.map { |d| d[:path].to_s } + return 'schema' if paths.any? { |p| p.include?('schema') || p.include?('type') } + return 'config' if paths.any? { |p| p.include?('config') || p.include?('setting') } + return 'version' if paths.any? { |p| p.include?('version') } + + 'state' + end + end + end + end + end +end diff --git a/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/version.rb b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/version.rb new file mode 100644 index 00000000..62d93cf4 --- /dev/null +++ b/extensions-agentic/lex-reconciliation/lib/legion/extensions/reconciliation/version.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Reconciliation + VERSION = '0.1.0' + end + end +end diff --git a/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation/drift_log_spec.rb b/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation/drift_log_spec.rb new file mode 100644 index 00000000..a8c161dd --- /dev/null +++ b/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation/drift_log_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Reconciliation::DriftLog do + describe '.record' do + context 'when data is unavailable' do + before { hide_const('Legion::Data') if defined?(Legion::Data) } + + it 'returns nil gracefully' do + result = described_class.record( + resource: 'test', + expected: { status: 'ok' }, + actual: { status: 'fail' } + ) + expect(result).to be_nil.or be_a(Hash) + end + end + end + + describe '.summary' do + context 'when data is unavailable' do + before { hide_const('Legion::Data') if defined?(Legion::Data) } + + it 'returns a zero summary' do + result = described_class.summary + expect(result[:open]).to eq(0) + expect(result[:resolved]).to eq(0) + end + end + end + + describe '.open_entries' do + context 'when data is unavailable' do + before { hide_const('Legion::Data') if defined?(Legion::Data) } + + it 'returns an empty array' do + expect(described_class.open_entries).to eq([]) + end + end + end +end diff --git a/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation/runners/drift_checker_spec.rb b/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation/runners/drift_checker_spec.rb new file mode 100644 index 00000000..c028c3a6 --- /dev/null +++ b/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation/runners/drift_checker_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Reconciliation::Runners::DriftChecker do + subject(:checker) { Object.new.extend(described_class) } + + let(:resource) { 'test-service' } + let(:expected) { { status: 'running', version: '1.2.0', replicas: 3 } } + + describe '#check' do + context 'when expected and actual states match' do + it 'returns drifted: false' do + result = checker.check(resource: resource, expected: expected, actual: expected.dup) + expect(result[:drifted]).to be false + end + + it 'returns empty drift_entries' do + result = checker.check(resource: resource, expected: expected, actual: expected.dup) + expect(result[:drift_entries]).to be_empty + end + + it 'returns zero total in summary' do + result = checker.check(resource: resource, expected: expected, actual: expected.dup) + expect(result.dig(:summary, :total)).to eq(0) + end + end + + context 'when actual state differs from expected' do + let(:actual) { { status: 'stopped', version: '1.2.0', replicas: 1 } } + + before do + allow(Legion::Extensions::Reconciliation::DriftLog).to receive(:record).and_return( + { drift_id: 'test-uuid', resource: resource, status: 'open' } + ) + end + + it 'returns drifted: true' do + result = checker.check(resource: resource, expected: expected, actual: actual) + expect(result[:drifted]).to be true + end + + it 'includes the differing paths in summary' do + result = checker.check(resource: resource, expected: expected, actual: actual) + expect(result.dig(:summary, :paths)).to include('status', 'replicas') + end + + it 'records a drift log entry' do + expect(Legion::Extensions::Reconciliation::DriftLog).to receive(:record) + .with(hash_including(resource: resource)) + .and_return({ drift_id: 'test-uuid' }) + checker.check(resource: resource, expected: expected, actual: actual) + end + end + + context 'when an error occurs' do + before do + allow(Legion::Extensions::Reconciliation::DriftLog).to receive(:record).and_raise(StandardError, 'db error') + end + + it 'returns drifted: false with error key' do + actual = { status: 'stopped' } + result = checker.check(resource: resource, expected: expected, actual: actual) + expect(result[:error]).not_to be_nil + end + end + end + + describe '#check_all' do + let(:resources) do + [ + { resource: 'svc-a', expected: { status: 'ok' }, actual: { status: 'ok' } }, + { resource: 'svc-b', expected: { status: 'ok' }, actual: { status: 'fail' } } + ] + end + + before do + allow(Legion::Extensions::Reconciliation::DriftLog).to receive(:record).and_return( + { drift_id: 'uuid-b', resource: 'svc-b', status: 'open' } + ) + end + + it 'returns the correct checked count' do + result = checker.check_all(resources: resources) + expect(result[:checked]).to eq(2) + end + + it 'returns the correct drifted count' do + result = checker.check_all(resources: resources) + expect(result[:drifted]).to eq(1) + end + + it 'returns individual results' do + result = checker.check_all(resources: resources) + expect(result[:results].size).to eq(2) + end + end + + describe '#drift_summary' do + before do + allow(Legion::Extensions::Reconciliation::DriftLog).to receive(:summary) + .and_return({ open: 3, resolved: 10, by_severity: { 'medium' => { open: 3, resolved: 10 } } }) + end + + it 'delegates to DriftLog.summary' do + expect(checker.drift_summary[:open]).to eq(3) + end + end +end diff --git a/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation_spec.rb b/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation_spec.rb new file mode 100644 index 00000000..5b60fd5c --- /dev/null +++ b/extensions-agentic/lex-reconciliation/spec/legion/extensions/reconciliation_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::Reconciliation do + it 'has a version number' do + expect(Legion::Extensions::Reconciliation::VERSION).not_to be_nil + end + + it 'defines DriftLog' do + expect(defined?(Legion::Extensions::Reconciliation::DriftLog)).to eq('constant') + end + + it 'defines Runners::DriftChecker' do + expect(defined?(Legion::Extensions::Reconciliation::Runners::DriftChecker)).to eq('constant') + end + + it 'defines Actors::ReconciliationCycle' do + expect(defined?(Legion::Extensions::Reconciliation::Actors::ReconciliationCycle)).to eq('constant') + end +end diff --git a/extensions-agentic/lex-reconciliation/spec/spec_helper.rb b/extensions-agentic/lex-reconciliation/spec/spec_helper.rb new file mode 100644 index 00000000..a2ab9c51 --- /dev/null +++ b/extensions-agentic/lex-reconciliation/spec/spec_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'legion/extensions/reconciliation' + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups +end diff --git a/integration/self_generate_spec.rb b/integration/self_generate_spec.rb new file mode 100644 index 00000000..d1e41d7e --- /dev/null +++ b/integration/self_generate_spec.rb @@ -0,0 +1,398 @@ +# frozen_string_literal: true + +require 'rspec' +require 'tmpdir' +require 'fileutils' + +# Load core gems +require 'legion/json' +require 'legion/logging' +require 'legion/settings' + +# Stub modules that may not be available in isolation +unless defined?(Legion::Transport::Messages::Dynamic) + module Legion + module Transport + module Messages + class Dynamic + attr_reader :function, :data + + def initialize(function:, data:, **) + @function = function + @data = data + end + + def publish + Legion::Transport::Local.publish('codegen', @function, Legion::JSON.dump(@data)) + end + end + end + end + end +end + +# Ensure Legion::LLM module exists so it can be stubbed, but don't overwrite a real implementation. +unless defined?(Legion::LLM) + module Legion + module LLM + end + end +end + +# Load transport Local for InProcess mode +require 'legion/transport/local' + +# Load codegen extension +begin + require 'legion/extensions/codegen' + LEGION_CODEGEN_EXTENSION_AVAILABLE = true +rescue LoadError => e + LEGION_CODEGEN_EXTENSION_AVAILABLE = false + warn "lex-codegen / legion codegen extension not available; skipping dependent behavior: #{e.message}" +end + +# Load eval extension (only code_review runner + security evaluator) +begin + require 'legion/extensions/eval' + LEGION_EVAL_EXTENSION_AVAILABLE = true +rescue LoadError => e + LEGION_EVAL_EXTENSION_AVAILABLE = false + warn "lex-eval / legion eval extension not available; skipping dependent behavior: #{e.message}" +end + +# Stub MCP Server if not available +unless defined?(Legion::MCP::Server) + module Legion + module MCP + module Server + @tool_registry = [] + @tool_registry_lock = Mutex.new + + class << self + attr_reader :tool_registry + + def register_tool(tool_class) + @tool_registry_lock.synchronize do + return if tool_registry.any? { |tc| tc.respond_to?(:tool_name) && tc.tool_name == tool_class.tool_name } + + tool_registry << tool_class + end + end + + def unregister_tool(tool_name) + @tool_registry_lock.synchronize do + tool_registry.reject! { |tc| tc.respond_to?(:tool_name) && tc.tool_name == tool_name } + end + end + + def reset_caches!; end + end + end + end + end +end + +LLM_STUB_CODE = <<~RUBY + # frozen_string_literal: true + + module Legion + module Generated + module GreetUser + extend self + + def greet(name:) + { success: true, greeting: "Hello, \#{name}!" } + end + end + end + end +RUBY + +RSpec.configure do |config| + config.disable_monkey_patching! + config.expect_with(:rspec) { |c| c.syntax = :expect } + + config.before(:each) do + allow(Legion::LLM).to receive(:chat) do |messages:, _caller: nil, **_kwargs| + messages.last[:content] + Struct.new(:content).new(LLM_STUB_CODE) + end + end +end + +RSpec.describe 'Self-Generating Functions End-to-End' do + # Skip this entire example group if the required extensions are not available. + before(:all) do + extensions_unavailable = + (defined?(LEGION_CODEGEN_EXTENSION_AVAILABLE) && !LEGION_CODEGEN_EXTENSION_AVAILABLE) || + (defined?(LEGION_EVAL_EXTENSION_AVAILABLE) && !LEGION_EVAL_EXTENSION_AVAILABLE) + + skip('Legion Codegen/Eval extensions are not available; skipping self-generate integration specs.') if extensions_unavailable + end + + let(:output_dir) { Dir.mktmpdir('legion_e2e_codegen') } + + before do + # Reset Local transport + Legion::Transport::Local.reset! if Legion::Transport::Local.respond_to?(:reset!) + + # Reset GeneratedRegistry (only if Codegen extension is loaded) + Legion::Extensions::Codegen::Helpers::GeneratedRegistry.reset! if defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry) + + # Configure settings for test + allow(Legion::Settings).to receive(:dig).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :enabled).and_return(true) + allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :cooldown_seconds).and_return(0) + allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :max_gaps_per_cycle).and_return(5) + allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :runner_method, :output_dir).and_return(output_dir) + allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :validation).and_return( + { syntax_check: true, run_specs: false, llm_review: false, max_retries: 2 } + ) + allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :corroboration, :enabled).and_return(false) + allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :corroboration, :min_agents).and_return(2) + allow(Legion::Settings).to receive(:dig).with(:node, :name).and_return('test-node') + allow(Legion::Settings).to receive(:[]).and_return(nil) + end + + after do + FileUtils.rm_rf(output_dir) + end + + describe 'gap detection -> generation -> validation -> registration' do + let(:synthetic_gap) do + { + gap_id: 'gap_e2e_001', + gap_type: 'unmatched_intent', + intent: 'greet user', + occurrence_count: 3, + priority: 0.7, + metadata: {} + } + end + + it 'generates code from a gap and passes validation' do + # Phase 1: GapSubscriber receives a gap and generates code + subscriber = Object.new + subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber) + + generation = subscriber.action(synthetic_gap) + + expect(generation[:success]).to be true + expect(generation[:generation_id]).to start_with('gen_') + expect(generation[:tier]).to eq(:simple) + expect(generation[:code]).to include('module Legion') + expect(generation[:file_path]).to start_with(output_dir) + expect(File.exist?(generation[:file_path])).to be true + end + + it 'validates generated code through the review pipeline' do + # Phase 1: Generate + subscriber = Object.new + subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber) + generation = subscriber.action(synthetic_gap) + expect(generation[:success]).to be true + + # Phase 2: Review (simulating what CodeReviewSubscriber does) + review = Legion::Extensions::Eval::Runners::CodeReview.review_generated( + code: generation[:code], + spec_code: generation[:spec_code], + context: { gap_type: 'unmatched_intent', intent: 'greet user' } + ) + + expect(review[:passed]).to be true + expect(review[:verdict]).to eq(:approve) + expect(review[:confidence]).to be > 0.0 + expect(review[:stages][:syntax][:passed]).to be true + expect(review[:stages][:security][:passed]).to be true + end + + it 'registers approved code via ReviewHandler' do + # Phase 1: Generate + subscriber = Object.new + subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber) + generation = subscriber.action(synthetic_gap) + expect(generation[:success]).to be true + + # Phase 2: Persist to registry + registry_record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist( + generation: { + id: generation[:generation_id], + gap_id: generation[:gap_id], + gap_type: generation[:gap_type], + tier: generation[:tier], + name: 'greet_user', + file_path: generation[:file_path], + spec_path: generation[:spec_path] + } + ) + expect(registry_record[:status]).to eq('pending') + + # Phase 3: Review + review_result = { + generation_id: generation[:generation_id], + verdict: :approve, + confidence: 0.95, + issues: [], + scores: {} + } + + # Phase 4: ReviewHandler processes the verdict + result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(review: review_result) + + expect(result[:success]).to be true + expect(result[:action]).to eq(:approved) + + # Verify registry updated + record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.get(id: generation[:generation_id]) + expect(record[:status]).to eq('approved') + end + + it 'parks rejected code' do + subscriber = Object.new + subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber) + generation = subscriber.action(synthetic_gap) + expect(generation[:success]).to be true + + Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist( + generation: { + id: generation[:generation_id], + gap_id: generation[:gap_id], + gap_type: generation[:gap_type], + tier: generation[:tier], + name: 'greet_user', + file_path: generation[:file_path], + spec_path: generation[:spec_path] + } + ) + + result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict( + review: { generation_id: generation[:generation_id], verdict: :reject, confidence: 0.1, issues: ['unsafe code'] } + ) + + expect(result[:success]).to be true + expect(result[:action]).to eq(:parked) + + record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.get(id: generation[:generation_id]) + expect(record[:status]).to eq('parked') + end + + it 'retries on revise verdict up to max_retries then parks' do + subscriber = Object.new + subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber) + generation = subscriber.action(synthetic_gap) + expect(generation[:success]).to be true + + Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist( + generation: { + id: generation[:generation_id], + gap_id: generation[:gap_id], + gap_type: generation[:gap_type], + tier: generation[:tier], + name: 'greet_user', + file_path: generation[:file_path], + spec_path: generation[:spec_path], + attempt_count: 2 + } + ) + + result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict( + review: { generation_id: generation[:generation_id], verdict: :revise, confidence: 0.4, issues: ['needs improvement'] } + ) + + expect(result[:success]).to be true + expect(result[:action]).to eq(:parked) + end + + it 'exercises the full loop: generate -> validate -> register -> boot load' do + # Step 1: Generate + subscriber = Object.new + subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber) + generation = subscriber.action(synthetic_gap) + expect(generation[:success]).to be true + + # Step 2: Validate + review = Legion::Extensions::Eval::Runners::CodeReview.review_generated( + code: generation[:code], spec_code: generation[:spec_code], context: {} + ) + expect(review[:verdict]).to eq(:approve) + + # Step 3: Persist + Approve + Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist( + generation: { + id: generation[:generation_id], + gap_id: generation[:gap_id], + gap_type: generation[:gap_type], + tier: generation[:tier], + name: 'greet_user', + file_path: generation[:file_path], + spec_path: generation[:spec_path] + } + ) + + Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict( + review: { generation_id: generation[:generation_id], verdict: :approve, confidence: 0.95, issues: [] } + ) + + # Step 4: Boot load (simulates service restart) + loaded = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.load_on_boot + expect(loaded).to eq(1) + + # Step 5: Verify the generated module is actually loaded + expect(defined?(Legion::Generated::GreetUser)).to be_truthy + result = Legion::Generated::GreetUser.greet(name: 'World') + expect(result[:success]).to be true + expect(result[:greeting]).to eq('Hello, World!') + end + end + + describe 'tier classification' do + it 'classifies low occurrence gaps as simple' do + tier = Legion::Extensions::Codegen::Helpers::TierClassifier.classify(gap: { occurrence_count: 5 }) + expect(tier).to eq(:simple) + end + + it 'classifies high occurrence gaps as complex' do + allow(Legion::Settings).to receive(:dig).with(:codegen, :self_generate, :tier, :simple_max_occurrences).and_return(10) + tier = Legion::Extensions::Codegen::Helpers::TierClassifier.classify(gap: { occurrence_count: 15 }) + expect(tier).to eq(:complex) + end + end + + describe 'ReviewSubscriber actor' do + it 'routes verdict through ReviewHandler' do + subscriber = Object.new + subscriber.extend(Legion::Extensions::Codegen::Actor::GapSubscriber) + generation = subscriber.action( + gap_id: 'gap_rs_001', gap_type: 'unmatched_intent', intent: 'greet user', + occurrence_count: 3, priority: 0.7 + ) + expect(generation[:success]).to be true + + Legion::Extensions::Codegen::Helpers::GeneratedRegistry.persist( + generation: { + id: generation[:generation_id], + gap_id: generation[:gap_id], + gap_type: generation[:gap_type], + tier: generation[:tier], + name: 'greet_user', + file_path: generation[:file_path], + spec_path: generation[:spec_path] + } + ) + + review_sub = Object.new + review_sub.extend(Legion::Extensions::Codegen::Actor::ReviewSubscriber) + + result = review_sub.action( + generation_id: generation[:generation_id], + verdict: 'approve', + confidence: 0.9, + issues: [], + scores: {} + ) + + expect(result[:success]).to be true + expect(result[:action]).to eq(:approved) + end + end +end diff --git a/legionio.gemspec b/legionio.gemspec index f531c3ed..54b3144c 100755 --- a/legionio.gemspec +++ b/legionio.gemspec @@ -12,42 +12,57 @@ Gem::Specification.new do |spec| spec.summary = 'The primary gem to run the LegionIO Framework' spec.description = 'LegionIO is an extensible framework for running, scheduling and building relationships of tasks in a concurrent matter' - spec.homepage = 'https://github.com/Optum/LegionIO' + spec.homepage = 'https://github.com/LegionIO/LegionIO' spec.license = 'Apache-2.0' spec.require_paths = ['lib'] - spec.required_ruby_version = '>= 2.5.0' + spec.required_ruby_version = '>= 3.4' spec.metadata = { - 'bug_tracker_uri' => 'https://github.com/Optum/LegionIO/issues', - 'changelog_uri' => 'https://github.com/Optum/LegionIO/src/main/CHANGELOG.md', - 'documentation_uri' => 'https://github.com/Optum/LegionIO', - 'homepage_uri' => 'https://github.com/Optum/LegionIO', - 'source_code_uri' => 'https://github.com/Optum/LegionIO', - 'wiki_uri' => 'https://github.com/Optum/LegionIO' + 'bug_tracker_uri' => 'https://github.com/LegionIO/LegionIO/issues', + 'changelog_uri' => 'https://github.com/LegionIO/LegionIO/blob/main/CHANGELOG.md', + 'documentation_uri' => 'https://github.com/LegionIO/LegionIO', + 'homepage_uri' => 'https://github.com/LegionIO/LegionIO', + 'source_code_uri' => 'https://github.com/LegionIO/LegionIO', + 'wiki_uri' => 'https://github.com/LegionIO/LegionIO', + 'rubygems_mfa_required' => 'true' } spec.files = `git ls-files -z`.split("\x0").reject do |f| f.match(%r{^(test|spec|features)/}) end - spec.test_files = spec.files.select { |p| p =~ %r{^test/.*_test.rb} } spec.bindir = 'exe' - spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.executables = %w[legion legionio] - spec.add_dependency 'concurrent-ruby', '>= 1.1.7' - spec.add_dependency 'concurrent-ruby-ext', '>= 1.1.7' - spec.add_dependency 'daemons', '>= 1.3.1' - spec.add_dependency 'oj', '>= 3.10' - spec.add_dependency 'thor', '>= 1' + spec.add_dependency 'legion-mcp', '>= 0.7.1' - spec.add_dependency 'legion-cache', '>= 0.2.0' - spec.add_dependency 'legion-crypt', '>= 0.2.0' - spec.add_dependency 'legion-json', '>= 0.2.0' - spec.add_dependency 'legion-logging', '>= 0.2.0' - spec.add_dependency 'legion-settings', '>= 0.2.0' - spec.add_dependency 'legion-transport', '>= 1.1.9' + spec.add_dependency 'kramdown', '>= 2.0' - spec.add_dependency 'lex-node' + spec.add_dependency 'bootsnap', '>= 1.18' + spec.add_dependency 'concurrent-ruby', '>= 1.2' + spec.add_dependency 'concurrent-ruby-ext', '>= 1.2' + spec.add_dependency 'daemons', '>= 1.4' + spec.add_dependency 'graphql', '>= 2.0' + spec.add_dependency 'oj', '>= 3.16' + spec.add_dependency 'puma', '>= 6.0' + spec.add_dependency 'rackup', '>= 2.0' + spec.add_dependency 'reline', '>= 0.5' + spec.add_dependency 'rouge', '>= 4.0' + spec.add_dependency 'sinatra', '>= 4.0' + spec.add_dependency 'thor', '>= 1.3' + spec.add_dependency 'tty-spinner', '~> 0.9' + + spec.add_dependency 'legion-cache', '>= 1.3.22' + spec.add_dependency 'legion-crypt', '>= 1.5.1' + spec.add_dependency 'legion-data', '>= 1.8.0' + spec.add_dependency 'legion-json', '>= 1.2.1' + spec.add_dependency 'legion-logging', '>= 1.5.0' + spec.add_dependency 'legion-settings', '>= 1.3.25' + spec.add_dependency 'legion-transport', '>= 1.4.14' - spec.add_development_dependency 'legion-data' + spec.add_dependency 'legion-apollo', '>= 0.4.0' + spec.add_dependency 'legion-gaia', '>= 0.9.26' + spec.add_dependency 'legion-llm', '>= 0.10.1' + spec.add_dependency 'legion-tty', '>= 0.5.4' + spec.add_dependency 'lex-node' end diff --git a/lib/legion.rb b/lib/legion.rb old mode 100755 new mode 100644 index d0c5d636..c721fa04 --- a/lib/legion.rb +++ b/lib/legion.rb @@ -1,12 +1,30 @@ +# frozen_string_literal: true + Process.setproctitle('Legion') require 'concurrent' require 'securerandom' require 'legion/version' +require 'legion/logging' +require 'legion/events' +require 'legion/mode' +require 'legion/ingress' require 'legion/process' require 'legion/service' require 'legion/extensions' +require 'legion/tools' module Legion + autoload :Region, 'legion/region' + autoload :Lock, 'legion/lock' + autoload :Leader, 'legion/leader' + autoload :Prompts, 'legion/prompts' + + @instance_id = ENV.fetch('LEGIONIO_INSTANCE_ID') { SecureRandom.uuid }.downcase.strip.gsub(/[^a-z0-9-]/, '') + + def self.instance_id + @instance_id + end + attr_reader :service def self.start diff --git a/lib/legion/alerts.rb b/lib/legion/alerts.rb new file mode 100644 index 00000000..641dc1d6 --- /dev/null +++ b/lib/legion/alerts.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +module Legion + module Alerts + AlertRule = Struct.new(:name, :event_pattern, :condition, :severity, :channels, :cooldown_seconds) + + DEFAULT_RULES = [ + { name: 'consent_violation', event_pattern: 'governance.consent_violation', severity: 'critical', + channels: %w[events log], cooldown_seconds: 300 }, + { name: 'extinction_trigger', event_pattern: 'extinction.*', severity: 'critical', + channels: %w[events log], cooldown_seconds: 0 }, + { name: 'error_spike', event_pattern: 'runner.failure', + condition: { count_threshold: 10, window_seconds: 60 }, severity: 'warning', + channels: %w[events log], cooldown_seconds: 300 }, + { name: 'budget_exceeded', event_pattern: 'finops.budget_exceeded', severity: 'warning', + channels: %w[events log], cooldown_seconds: 3600 }, + { name: 'safety_action_burst', event_pattern: 'ingress.received', + condition: { count_threshold: 100, window_seconds: 60 }, severity: 'warning', + channels: %w[events log], cooldown_seconds: 300 }, + { name: 'safety_scope_escalation_spike', event_pattern: 'rbac.deny', + condition: { count_threshold: 5, window_seconds: 300 }, severity: 'critical', + channels: %w[events log], cooldown_seconds: 300 }, + { name: 'safety_probe_detected', event_pattern: 'privatecore.probe_detected', severity: 'critical', + channels: %w[events log], cooldown_seconds: 0 }, + { name: 'safety_confidence_collapse', event_pattern: 'synapse.confidence_update', + condition: { count_threshold: 3, window_seconds: 300 }, severity: 'warning', + channels: %w[events log], cooldown_seconds: 300 } + ].freeze + + class Engine + attr_reader :rules + + def initialize(rules: []) + @rules = rules.map { |r| r.is_a?(AlertRule) ? r : AlertRule.new(**r.transform_keys(&:to_sym)) } + @counters = {} + @last_fired = {} + end + + def evaluate(event_name, payload = {}) + fired = [] + @rules.each do |rule| + next unless event_matches?(event_name, rule.event_pattern) + + Legion::Logging.debug "[Alerts] evaluating rule=#{rule.name} for event=#{event_name}" if defined?(Legion::Logging) + next unless condition_met?(rule, event_name) + next if in_cooldown?(rule) + + fire_alert(rule, event_name, payload) + fired << rule.name + end + fired + end + + private + + def event_matches?(name, pattern) + File.fnmatch?(pattern, name) + end + + def condition_met?(rule, event_name) + cond = rule.condition + return true unless cond.is_a?(Hash) + + key = "#{rule.name}:#{event_name}" + @counters[key] ||= { count: 0, window_start: Time.now } + + window = cond[:window_seconds] || 60 + @counters[key] = { count: 0, window_start: Time.now } if Time.now - @counters[key][:window_start] > window + + @counters[key][:count] += 1 + @counters[key][:count] >= (cond[:count_threshold] || 1) + end + + def in_cooldown?(rule) + last = @last_fired[rule.name] + return false unless last + + Time.now - last < (rule.cooldown_seconds || 0) + end + + def fire_alert(rule, event_name, payload) + @last_fired[rule.name] = Time.now + alert = { rule: rule.name, event: event_name, severity: rule.severity, + payload: payload, fired_at: Time.now.utc } + + Legion::Logging.info "[Alerts] alert fired: rule=#{rule.name} event=#{event_name} severity=#{rule.severity}" if defined?(Legion::Logging) + + (rule.channels || []).each do |channel| + case channel.to_sym + when :events + Legion::Events.emit('alert.fired', alert) if defined?(Legion::Events) + when :log + Legion::Logging.warn "[Alerts] #{rule.name}: #{event_name} (#{rule.severity})" if defined?(Legion::Logging) + when :webhook + Legion::Webhooks.dispatch('alert.fired', alert) if defined?(Legion::Webhooks) + end + end + end + end + + class << self + def setup + rules = load_rules + @engine = Engine.new(rules: rules) + register_listener + Legion::Logging.debug "Alerts: #{rules.size} rules loaded" if defined?(Legion::Logging) + end + + attr_reader :engine + + def reset! + @engine = nil + end + + private + + def load_rules + custom = begin + Legion::Settings[:alerts][:rules] + rescue StandardError => e + Legion::Logging.debug "Alerts#load_rules failed to read settings: #{e.message}" if defined?(Legion::Logging) + nil + end + custom && !custom.empty? ? custom : DEFAULT_RULES + end + + def register_listener + return unless defined?(Legion::Events) + + Legion::Events.on('*') do |event_name, **payload| + @engine&.evaluate(event_name, payload) + end + end + end + end +end diff --git a/lib/legion/api.rb b/lib/legion/api.rb new file mode 100644 index 00000000..62c54fae --- /dev/null +++ b/lib/legion/api.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require 'sinatra/base' +require 'legion/json' +require_relative 'events' +require_relative 'readiness' +require_relative 'api/default_settings' + +require_relative 'api/middleware/auth' +require_relative 'api/middleware/body_limit' +require_relative 'api/middleware/rate_limit' +require_relative 'api/middleware/request_logger' +require_relative 'api/helpers' +require_relative 'api/validators' +require_relative 'api/tasks' +require_relative 'api/extensions' +require_relative 'api/nodes' +require_relative 'api/schedules' +require_relative 'api/relationships' +require_relative 'api/chains' +require_relative 'api/settings' +require_relative 'api/events' +require_relative 'api/transport' +require_relative 'api/workers' +require_relative 'api/coldstart' +require_relative 'api/gaia' +require_relative 'api/openapi' +require_relative 'api/rbac' +require_relative 'api/auth' +require_relative 'api/auth_teams' +require_relative 'api/auth_worker' +require_relative 'api/auth_human' +require_relative 'api/auth_saml' +require_relative 'api/capacity' +require_relative 'api/audit' +require_relative 'api/metrics' +require_relative 'api/skills' +require_relative 'api/catalog' +require_relative 'api/org_chart' +require_relative 'api/workflow' +require_relative 'api/governance' +require_relative 'api/acp' +require_relative 'api/prompts' +require_relative 'api/marketplace' +require_relative 'api/apollo' +require_relative 'api/costs' +require_relative 'api/traces' +require_relative 'api/stats' +require_relative 'api/absorbers' +require_relative 'api/codegen' +require_relative 'api/knowledge' +require_relative 'api/mesh' +require_relative 'api/metering' +require_relative 'api/logs' +require_relative 'api/router' +require_relative 'api/library_routes' +require_relative 'api/sync_dispatch' +require_relative 'api/lex_dispatch' +require_relative 'api/tbi_patterns' +require_relative 'api/webhooks' +require_relative 'api/tenants' +require_relative 'api/inbound_webhooks' +require_relative 'api/identity_audit' +require_relative 'api/fleet' +require_relative 'api/graphql' if defined?(GraphQL) + +module Legion + class API < Sinatra::Base + START_TIME = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + + helpers Legion::API::Helpers + helpers Legion::API::Validators + + set :show_exceptions, false + set :raise_errors, false + set :public_folder, File.expand_path('../../public', __dir__) + set :static, true + + configure do + set :logging, nil + set :quiet, true + set :logger, Legion::Logging.log if Legion.const_defined?(:Logging) + set :host_authorization, permitted: :any + end + + # OpenAPI spec (no auth required) + get '/api/openapi.json' do + content_type :json + Legion::API::OpenAPI.to_json + end + + # Root discovery — lists all tiers + get '/api/discovery' do + content_type :json + Legion::JSON.dump({ + infrastructure: [ + { path: '/api/health', method: 'GET' }, + { path: '/api/ready', method: 'GET' }, + { path: '/api/openapi.json', method: 'GET' }, + { path: '/api/discovery', method: 'GET' } + ], + libraries: Legion::API.router.library_names, + extensions: Legion::API.router.extension_names + }) + end + + # Health and readiness + get '/api/health' do + uptime_seconds = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - START_TIME).to_i + json_response({ status: 'ok', version: Legion::VERSION, uptime_seconds: uptime_seconds, uptime: uptime_seconds }) + end + + get '/api/ready' do + ready = Legion::Readiness.ready? + json_response({ ready: ready, components: Legion::Readiness.to_h }, status_code: ready ? 200 : 503) + end + + post '/api/reload' do + log.error "[api] reload attempted by #{request.ip} — blocked" + halt 418, { 'Content-Type' => 'application/json' }, + Legion::JSON.dump({ error: 'reload is disabled', status: 418 }) + end + + # Global error handlers + not_found do + content_type :json + Legion::Logging.warn "API #{request.request_method} #{request.path_info} returned 404: no route matches" + Legion::JSON.dump({ + task_id: nil, + conversation_id: nil, + status: 'failed', + error: { + code: 404, + message: "no route matches #{request.request_method} #{request.path_info}" + }, + meta: { timestamp: Time.now.utc.iso8601, node: Legion::Settings[:client][:name] } + }) + end + + error do + content_type :json + err = env['sinatra.error'] + Legion::Logging.log_exception(err, payload_summary: "API #{request.request_method} #{request.path_info} returned 500", component_type: :api) + Legion::JSON.dump({ + task_id: nil, + conversation_id: nil, + status: 'failed', + error: { code: 500, message: err.message }, + meta: { timestamp: Time.now.utc.iso8601, node: Legion::Settings[:client][:name] } + }) + end + + # Tier-aware router (three-tier namespace) + class << self + def router + @router ||= Legion::API::Router.new + end + + def mount_library_routes(gem_name, fallback_module, preferred_constant_path) + preferred = constant_from_path(preferred_constant_path) + if preferred.is_a?(Module) + register_library_routes(gem_name, preferred) + elsif router.library_names.include?(gem_name) + register_library_routes(gem_name, router.library_routes.fetch(gem_name)) + else + register fallback_module + end + end + + private + + def constant_from_path(path) + path.to_s.split('::').reject(&:empty?).reduce(Object) { |scope, name| scope.const_get(name) } + rescue NameError + nil + end + end + + # Mount route modules + register Routes::LexDispatch + register Routes::Tasks + register Routes::Extensions + register Routes::Nodes + register Routes::Schedules + register Routes::Workflow + register Routes::Relationships + register Routes::Chains + register Routes::Settings + register Routes::Events + register Routes::Transport unless router.library_names.include?('transport') + register Routes::Workers + register Routes::Coldstart + register Routes::Gaia unless router.library_names.include?('gaia') + register Routes::Rbac unless router.library_names.include?('rbac') + register Routes::Auth + register Routes::AuthTeams + register Routes::AuthWorker + register Routes::AuthHuman + register Routes::AuthSaml + register Routes::Capacity + register Routes::Audit + register Routes::Metrics + register Routes::Skills + register Routes::ExtensionCatalog + register Routes::OrgChart + register Routes::Governance + register Routes::Acp + register Routes::Prompts + register Routes::Marketplace + # Legion::LLM routes are registered directly from legion-llm. + # setup_llm runs before setup_api so Legion::LLM is always defined when this loads. + mount_library_routes('llm', Legion::LLM::API, 'Legion::LLM::Routes') if defined?(Legion::LLM::API) + mount_library_routes('apollo', Routes::Apollo, 'Legion::Apollo::Routes') + register Routes::Costs + register Routes::Traces + register Routes::Stats + register Routes::Absorbers + register Routes::Codegen + register Routes::Knowledge + register Routes::Mesh + register Routes::Metering + register Routes::Logs + register Routes::TbiPatterns + register Routes::Webhooks + register Routes::Tenants + register Routes::InboundWebhooks + register Routes::IdentityAudit + register Routes::Fleet + register Routes::GraphQL if defined?(Routes::GraphQL) + + use Legion::API::Middleware::RequestLogger + use ElasticAPM::Middleware if defined?(ElasticAPM::Middleware) && + Legion::Settings.dig(:api, :elastic_apm, :enabled) + end +end diff --git a/lib/legion/api/absorbers.rb b/lib/legion/api/absorbers.rb new file mode 100644 index 00000000..de0764a8 --- /dev/null +++ b/lib/legion/api/absorbers.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Absorbers + def self.registered(app) + app.get '/api/absorbers' do + patterns = Legion::Extensions::Absorbers::PatternMatcher.list + items = patterns.map do |p| + { + type: p[:type], + value: p[:value], + priority: p[:priority], + description: p[:description], + absorber_class: p[:absorber_class]&.name + } + end + json_response(items) + end + + app.post '/api/absorbers/dispatch' do + body = parse_request_body + input = body[:url] || body[:input] + halt 400, json_error('missing_param', 'url parameter is required') unless input + + require 'legion/extensions/actors/absorber_dispatch' + context = body[:context] || {} + job_id = SecureRandom.hex(8) + + Thread.new do + Legion::Extensions::Actors::AbsorberDispatch.dispatch( + input: input, job_id: job_id, context: context + ) + rescue StandardError => e + Legion::Logging.error("Async absorb #{job_id} failed: #{e.message}") if defined?(Legion::Logging) + end + + json_response({ success: true, job_id: job_id, absorber: PatternMatcher.resolve(input)&.name, status: :accepted }) + end + + app.get '/api/absorbers/resolve' do + input = params[:url] || params[:input] + halt 400, json_error('missing_param', 'url parameter is required') unless input + + absorber = Legion::Extensions::Absorbers::PatternMatcher.resolve(input) + json_response({ + input: input, + match: !absorber.nil?, + absorber: absorber&.name + }) + end + end + end + end + end +end diff --git a/lib/legion/api/acp.rb b/lib/legion/api/acp.rb new file mode 100644 index 00000000..75a9365a --- /dev/null +++ b/lib/legion/api/acp.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Helpers + module Acp + def build_agent_card + name = begin + Legion::Settings[:client][:name] + rescue StandardError => e + Legion::Logging.debug "Acp#build_agent_card failed to read client name: #{e.message}" if defined?(Legion::Logging) + 'legion' + end + port = begin + settings.port || 4567 + rescue StandardError => e + Legion::Logging.debug "Acp#build_agent_card failed to read port: #{e.message}" if defined?(Legion::Logging) + 4567 + end + { + name: name, + description: 'LegionIO digital worker', + url: "http://#{request.host}:#{port}/api/acp", + version: '2.0', + protocol: 'acp/1.0', + capabilities: discover_capabilities, + authentication: { schemes: ['bearer'] }, + defaultInputModes: ['text/plain', 'application/json'], + defaultOutputModes: ['text/plain', 'application/json'] + } + end + + def discover_capabilities + if defined?(Legion::Extensions::Mesh::Helpers::Registry) + Legion::Extensions::Mesh::Helpers::Registry.new.capabilities.keys.map(&:to_s) + else + [] + end + rescue StandardError => e + Legion::Logging.warn "Acp#discover_capabilities failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def find_task(id) + return nil unless defined?(Legion::Data) + + Legion::Data::Model::Task[id.to_i]&.values + rescue StandardError => e + Legion::Logging.warn "Acp#find_task failed for id=#{id}: #{e.message}" if defined?(Legion::Logging) + nil + end + + def translate_status(status) + case status&.to_s + when /completed/ then 'completed' + when /exception|failed/ then 'failed' + when /queued|scheduled/ then 'queued' + else 'in_progress' + end + end + end + end + + module Routes + module Acp + def self.registered(app) + app.helpers Legion::API::Helpers::Acp + + app.get '/.well-known/agent.json' do + card = build_agent_card + content_type :json + Legion::JSON.dump(card) + end + + app.post '/api/acp/tasks' do + body = parse_request_body + payload = (body[:input] || {}).transform_keys(&:to_sym) + + result = Legion::Ingress.run( + payload: payload, + runner_class: body[:runner_class], + function: body[:function], + source: 'acp' + ) + + json_response({ task_id: result[:task_id], status: 'queued' }, status_code: 202) + end + + app.get '/api/acp/tasks/:id' do + task = find_task(params[:id]) + halt 404, json_error(404, 'Task not found') unless task + + json_response({ + task_id: task[:id], + status: translate_status(task[:status]), + output: { data: task[:result] }, + created_at: task[:created_at]&.to_s, + completed_at: task[:completed_at]&.to_s + }) + end + + app.delete '/api/acp/tasks/:id' do + halt 501, json_error(501, 'Task cancellation not implemented') + end + end + end + end + end +end diff --git a/lib/legion/api/apollo.rb b/lib/legion/api/apollo.rb new file mode 100644 index 00000000..1e7ca074 --- /dev/null +++ b/lib/legion/api/apollo.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Apollo + def self.registered(app) + app.helpers ApolloHelpers + register_status_route(app) + register_stats_route(app) + register_query_route(app) + register_ingest_route(app) + register_related_route(app) + register_maintenance_route(app) + register_graph_route(app) + register_expertise_route(app) + end + + def self.register_status_route(app) + app.get '/api/apollo/status' do + if apollo_loaded? + json_response({ available: true, data_connected: apollo_data_connected? }) + else + json_response({ available: false }, status_code: 503) + end + end + end + + def self.register_stats_route(app) + app.get '/api/apollo/stats' do + halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded? + + json_response(apollo_stats) + end + end + + def self.register_query_route(app) + app.post '/api/apollo/query' do + halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded? + + body = parse_request_body + result = apollo_runner.handle_query( + query: body[:query], + limit: body[:limit] || 10, + min_confidence: body[:min_confidence] || 0.3, + status: body[:status] || [:confirmed], + tags: body[:tags], + domain: body[:domain], + agent_id: body[:agent_id] || 'api' + ) + json_response(result) + end + end + + def self.register_ingest_route(app) + app.post '/api/apollo/ingest' do + halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded? + + body = parse_request_body + result = apollo_runner.handle_ingest( + content: body[:content], + content_type: body[:content_type] || :observation, + tags: body[:tags] || [], + source_agent: body[:source_agent] || 'api', + source_provider: body[:source_provider], + source_channel: body[:source_channel] || 'rest_api', + knowledge_domain: body[:knowledge_domain], + context: body[:context] || {} + ) + json_response(result, status_code: 201) + end + end + + def self.register_related_route(app) + app.get '/api/apollo/entries/:id/related' do + halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded? + + result = apollo_runner.related_entries( + entry_id: params[:id].to_i, + relation_types: params[:relation_types]&.split(','), + depth: (params[:depth] || 2).to_i + ) + json_response(result) + end + end + + def self.register_maintenance_route(app) + app.post '/api/apollo/maintenance' do + halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded? + + body = parse_request_body + action = body[:action]&.to_sym + halt 400, json_error('invalid_action', 'action must be decay_cycle or corroboration') unless %i[ + decay_cycle corroboration + ].include?(action) + + result = run_maintenance(action) + json_response(result) + end + end + + def self.register_graph_route(app) + app.get '/api/apollo/graph' do + halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded? + + json_response(apollo_graph_topology) + end + end + + def self.register_expertise_route(app) + app.get '/api/apollo/expertise' do + halt 503, json_error('apollo_unavailable', 'apollo is not available', status_code: 503) unless apollo_loaded? + + json_response(apollo_expertise_map) + end + end + end + + module ApolloHelpers + def apollo_loaded? + defined?(Legion::Extensions::Apollo::Runners::Knowledge) && apollo_data_connected? + end + + def apollo_data_connected? + defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && !Legion::Data.connection.nil? + rescue StandardError => e + Legion::Logging.debug("Apollo#apollo_data_connected? check failed: #{e.message}") if defined?(Legion::Logging) + false + end + + def apollo_runner + @apollo_runner ||= Object.new.extend(Legion::Extensions::Apollo::Runners::Knowledge) + end + + def apollo_maintenance_runner + @apollo_maintenance_runner ||= Object.new.extend(Legion::Extensions::Apollo::Runners::Maintenance) + end + + def run_maintenance(action) + case action + when :decay_cycle + apollo_maintenance_runner.run_decay_cycle + when :corroboration + apollo_maintenance_runner.check_corroboration + end + end + + def apollo_graph_topology + conn = Legion::Data.connection + entries = conn[:apollo_entries] + relations = conn[:apollo_relations] + + by_domain = entries.group_and_count(:knowledge_domain).all + .to_h { |r| [r[:knowledge_domain] || 'general', r[:count]] } + by_agent = entries.group_and_count(:source_agent).all + .to_h { |r| [r[:source_agent] || 'unknown', r[:count]] } + by_relation = relations.group_and_count(:relation_type).all + .to_h { |r| [r[:relation_type], r[:count]] } + disputed = entries.where(status: 'disputed').count + confirmed = entries.where(status: 'confirmed').count + candidates = entries.where(status: 'candidate').count + + { + domains: by_domain, + agents: by_agent, + relation_types: by_relation, + total_relations: relations.count, + disputed_entries: disputed, + confirmed: confirmed, + candidates: candidates + } + rescue Sequel::Error => e + { error: e.message } + end + + def apollo_expertise_map + conn = Legion::Data.connection + rows = conn[:apollo_expertise].order(Sequel.desc(:proficiency)).all + + by_domain = {} + rows.each do |row| + domain = row[:domain] || 'general' + by_domain[domain] ||= [] + by_domain[domain] << { + agent_id: row[:agent_id], + proficiency: row[:proficiency]&.round(3), + entry_count: row[:entry_count] + } + end + + { domains: by_domain, total_agents: rows.map { |r| r[:agent_id] }.uniq.size, + total_domains: by_domain.size } + rescue Sequel::Error => e + { error: e.message } + end + + def apollo_stats + entries = Legion::Data.connection[:apollo_entries] + { + total_entries: entries.count, + by_status: entries.group_and_count(:status).all.to_h { |r| [r[:status], r[:count]] }, + by_content_type: entries.group_and_count(:content_type).all.to_h { |r| [r[:content_type], r[:count]] }, + recent_24h: entries.where { created_at >= (Time.now.utc - 86_400) }.count, + avg_confidence: entries.avg(:confidence)&.round(3) || 0.0 + } + rescue Sequel::Error + { total_entries: 0, error: 'apollo_entries table not available' } + end + end + end + end +end diff --git a/lib/legion/api/audit.rb b/lib/legion/api/audit.rb new file mode 100644 index 00000000..d079ca9f --- /dev/null +++ b/lib/legion/api/audit.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Audit + def self.registered(app) + app.get '/api/audit' do + require_data! + dataset = Legion::Data::Model::AuditLog.order(Sequel.desc(:id)) + dataset = dataset.where(event_type: params[:event_type]) if params[:event_type] + dataset = dataset.where(principal_id: params[:principal_id]) if params[:principal_id] + dataset = dataset.where(source: params[:source]) if params[:source] + dataset = dataset.where(status: params[:status]) if params[:status] + dataset = dataset.where { created_at >= Time.parse(params[:since]) } if params[:since] + dataset = dataset.where { created_at <= Time.parse(params[:until]) } if params[:until] + json_collection(dataset) + rescue StandardError => e + Legion::Logging.error "API GET /api/audit: #{e.class} — #{e.message}" + json_error('audit_error', e.message, status_code: 500) + end + + app.get '/api/audit/verify' do + require_data! + unless defined?(Legion::Extensions::Audit::Runners::Audit) + Legion::Logging.warn 'API GET /api/audit/verify returned 503: lex-audit is not loaded' + halt 503, json_error('unavailable', 'lex-audit is not loaded', status_code: 503) + end + + runner = Object.new.extend(Legion::Extensions::Audit::Runners::Audit) + result = runner.verify + json_response(result) + rescue StandardError => e + Legion::Logging.error "API GET /api/audit/verify: #{e.class} — #{e.message}" + json_error('audit_error', e.message, status_code: 500) + end + end + end + end + end +end diff --git a/lib/legion/api/auth.rb b/lib/legion/api/auth.rb new file mode 100644 index 00000000..9f358f01 --- /dev/null +++ b/lib/legion/api/auth.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Auth + def self.registered(app) + register_token_exchange(app) + end + + def self.register_token_exchange(app) + app.post '/api/auth/token' do + Legion::Logging.debug "API: POST /api/auth/token params=#{params.keys}" + body = parse_request_body + grant_type = body[:grant_type] + subject_token = body[:subject_token] + + unless grant_type == 'urn:ietf:params:oauth:grant-type:token-exchange' + Legion::Logging.warn "API POST /api/auth/token returned 400: unsupported grant_type=#{grant_type}" + halt 400, json_error('unsupported_grant_type', 'expected urn:ietf:params:oauth:grant-type:token-exchange', + status_code: 400) + end + + unless subject_token + Legion::Logging.warn 'API POST /api/auth/token returned 400: subject_token is required' + halt 400, json_error('missing_subject_token', 'subject_token is required', status_code: 400) + end + + unless defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks) + halt 501, json_error('jwks_validation_not_available', 'legion-crypt JWKS support not loaded', + status_code: 501) + end + + rbac_settings = (Legion::Settings[:rbac].is_a?(Hash) && Legion::Settings[:rbac][:entra]) || {} + tenant_id = rbac_settings[:tenant_id] + unless tenant_id + Legion::Logging.error 'API POST /api/auth/token returned 500: rbac.entra.tenant_id not set' + halt 500, json_error('entra_tenant_not_configured', 'rbac.entra.tenant_id not set', status_code: 500) + end + + jwks_url = "https://login.microsoftonline.com/#{tenant_id}/discovery/v2.0/keys" + issuer = "https://login.microsoftonline.com/#{tenant_id}/v2.0" + + begin + entra_claims = Legion::Crypt::JWT.verify_with_jwks( + subject_token, jwks_url: jwks_url, issuers: [issuer] + ) + rescue Legion::Crypt::JWT::ExpiredTokenError + Legion::Logging.warn 'API POST /api/auth/token returned 401: Entra token has expired' + halt 401, json_error('token_expired', 'Entra token has expired', status_code: 401) + rescue Legion::Crypt::JWT::InvalidTokenError => e + Legion::Logging.warn "API POST /api/auth/token returned 401: #{e.message}" + halt 401, json_error('invalid_token', e.message, status_code: 401) + rescue Legion::Crypt::JWT::Error => e + Legion::Logging.error "API POST /api/auth/token returned 502: #{e.message}" + halt 502, json_error('identity_provider_unavailable', e.message, status_code: 502) + end + + unless defined?(Legion::Rbac::EntraClaimsMapper) + halt 501, json_error('claims_mapper_not_available', 'legion-rbac EntraClaimsMapper not loaded', + status_code: 501) + end + + mapped = Legion::Rbac::EntraClaimsMapper.map_claims( + entra_claims, + role_map: rbac_settings[:role_map] || Legion::Rbac::EntraClaimsMapper::DEFAULT_ROLE_MAP, + group_map: rbac_settings[:group_map] || {}, + default_role: rbac_settings[:default_role] || 'worker' + ) + + ttl = 28_800 + token = Legion::API::Token.issue_human_token( + msid: mapped[:sub], name: mapped[:name], + roles: mapped[:roles], ttl: ttl + ) + + Legion::Logging.info "API: issued human token for sub=#{mapped[:sub]} roles=#{mapped[:roles]&.join(',')}" + json_response({ + access_token: token, + token_type: 'Bearer', + expires_in: ttl, + roles: mapped[:roles], + team: mapped[:team] + }) + end + end + + class << self + private :register_token_exchange + end + end + end + end +end diff --git a/lib/legion/api/auth_human.rb b/lib/legion/api/auth_human.rb new file mode 100644 index 00000000..17b266c1 --- /dev/null +++ b/lib/legion/api/auth_human.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'net/http' +require 'securerandom' + +module Legion + class API < Sinatra::Base + module Routes + module AuthHuman + def self.registered(app) + register_authorize(app) + register_callback(app) + end + + def self.resolve_entra_settings + return {} unless defined?(Legion::Settings) + + rbac = Legion::Settings[:rbac] + entra = rbac.is_a?(Hash) ? rbac[:entra] : nil + return entra if entra.is_a?(Hash) + + {} + rescue StandardError => e + Legion::Logging.debug "AuthHuman#resolve_entra_settings failed: #{e.message}" if defined?(Legion::Logging) + {} + end + + def self.exchange_code(entra, code) + uri = URI("https://login.microsoftonline.com/#{entra[:tenant_id]}/oauth2/v2.0/token") + response = Net::HTTP.post_form(uri, { + 'client_id' => entra[:client_id], + 'client_secret' => entra[:client_secret], + 'code' => code, + 'redirect_uri' => entra[:redirect_uri], + 'grant_type' => 'authorization_code' + }) + + return nil unless response.is_a?(Net::HTTPSuccess) + + Legion::JSON.load(response.body) + rescue StandardError => e + Legion::Logging.warn "AuthHuman#exchange_code failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def self.register_authorize(app) + app.get '/api/auth/authorize' do + entra = Routes::AuthHuman.resolve_entra_settings + unless entra[:tenant_id] && entra[:client_id] + Legion::Logging.error 'API GET /api/auth/authorize returned 500: Entra OAuth settings are missing' + halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500) + end + + state = Legion::Crypt::JWT.issue( + { nonce: SecureRandom.hex(16), purpose: 'oauth_state' }, + ttl: 300 + ) + + query = URI.encode_www_form({ + 'client_id' => entra[:client_id], + 'redirect_uri' => entra[:redirect_uri], + 'response_type' => 'code', + 'scope' => 'openid profile', + 'state' => state + }) + + redirect "https://login.microsoftonline.com/#{entra[:tenant_id]}/oauth2/v2.0/authorize?#{query}" + end + end + + def self.register_callback(app) + app.get '/api/auth/callback' do + entra = Routes::AuthHuman.resolve_entra_settings + unless entra[:tenant_id] && entra[:client_id] + Legion::Logging.error 'API GET /api/auth/callback returned 500: Entra OAuth settings are missing' + halt 500, json_error('entra_not_configured', 'Entra OAuth settings are missing', status_code: 500) + end + + if params[:error] + Legion::Logging.warn "API GET /api/auth/callback returned 400: #{params[:error_description] || params[:error]}" + halt 400, json_error('oauth_error', params[:error_description] || params[:error], status_code: 400) + end + unless params[:code] + Legion::Logging.warn 'API GET /api/auth/callback returned 400: authorization code is required' + halt 400, json_error('missing_code', 'authorization code is required', status_code: 400) + end + + if params[:state] + begin + Legion::Crypt::JWT.verify(params[:state]) + rescue Legion::Crypt::JWT::Error + Legion::Logging.warn 'API GET /api/auth/callback returned 400: CSRF state token is invalid or expired' + halt 400, json_error('invalid_state', 'CSRF state token is invalid or expired', status_code: 400) + end + end + + token_response = Routes::AuthHuman.exchange_code(entra, params[:code]) + unless token_response + Legion::Logging.error 'API GET /api/auth/callback returned 502: Failed to exchange code for tokens' + halt 502, json_error('token_exchange_failed', 'Failed to exchange code for tokens', status_code: 502) + end + + id_token = token_response[:id_token] || token_response['id_token'] + unless id_token + Legion::Logging.error 'API GET /api/auth/callback returned 502: Entra did not return an id_token' + halt 502, json_error('no_id_token', 'Entra did not return an id_token', status_code: 502) + end + + jwks_url = "https://login.microsoftonline.com/#{entra[:tenant_id]}/discovery/v2.0/keys" + issuer = "https://login.microsoftonline.com/#{entra[:tenant_id]}/v2.0" + + begin + claims = Legion::Crypt::JWT.verify_with_jwks(id_token, jwks_url: jwks_url, issuers: [issuer]) + rescue Legion::Crypt::JWT::Error => e + Legion::Logging.warn "API GET /api/auth/callback returned 401: #{e.message}" + halt 401, json_error('invalid_id_token', e.message, status_code: 401) + end + + unless defined?(Legion::Rbac::EntraClaimsMapper) + halt 501, json_error('claims_mapper_not_available', 'EntraClaimsMapper is not loaded', status_code: 501) + end + + mapped = Legion::Rbac::EntraClaimsMapper.map_claims( + claims, + role_map: entra[:role_map] || Legion::Rbac::EntraClaimsMapper::DEFAULT_ROLE_MAP, + group_map: entra[:group_map] || {}, + default_role: entra[:default_role] || 'worker' + ) + + ttl = 28_800 + token = Legion::API::Token.issue_human_token( + msid: mapped[:sub], name: mapped[:name], roles: mapped[:roles], ttl: ttl + ) + + Legion::Logging.info "API: human OAuth callback issued token for sub=#{mapped[:sub]}" + if request.env['HTTP_ACCEPT']&.include?('application/json') + json_response({ + access_token: token, + token_type: 'Bearer', + expires_in: ttl, + roles: mapped[:roles], + name: mapped[:name] + }) + else + redirect_url = entra[:success_redirect] || '/api/health' + redirect "#{redirect_url}#access_token=#{token}" + end + end + end + + class << self + private :register_authorize, :register_callback + end + end + end + end +end diff --git a/lib/legion/api/auth_saml.rb b/lib/legion/api/auth_saml.rb new file mode 100644 index 00000000..0ef22e3f --- /dev/null +++ b/lib/legion/api/auth_saml.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module AuthSaml + def self.registered(app) + return unless saml_enabled? + + app.helpers do + define_method(:saml_settings) { Routes::AuthSaml.build_saml_settings } + end + + register_metadata(app) + register_login(app) + register_acs(app) + end + + def self.saml_enabled? + return false unless defined?(OneLogin::RubySaml) + + cfg = resolve_saml_config + cfg.is_a?(Hash) && cfg[:enabled] + end + + def self.resolve_saml_config + return {} unless defined?(Legion::Settings) + + auth = Legion::Settings[:auth] + saml = auth.is_a?(Hash) ? auth[:saml] : nil + return saml if saml.is_a?(Hash) + + {} + rescue StandardError => e + Legion::Logging.debug "AuthSaml#resolve_saml_config failed: #{e.message}" if defined?(Legion::Logging) + {} + end + + def self.build_saml_settings + cfg = resolve_saml_config + + settings = OneLogin::RubySaml::Settings.new + settings.idp_sso_service_url = cfg[:idp_sso_url] + settings.idp_cert = cfg[:idp_cert] + settings.sp_entity_id = cfg[:sp_entity_id] + settings.assertion_consumer_service_url = cfg[:sp_acs_url] + settings.name_identifier_format = cfg[:name_id_format] || + 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' + settings.security[:authn_requests_signed] = false + settings.security[:want_assertions_signed] = cfg.fetch(:want_assertions_signed, true) + settings.security[:want_assertions_encrypted] = cfg.fetch(:want_assertions_encrypted, false) + settings + end + + def self.register_metadata(app) + app.get '/api/auth/saml/metadata' do + halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml) + + meta = OneLogin::RubySaml::Metadata.new + content_type 'application/xml' + meta.generate(saml_settings, true) + end + end + + def self.register_login(app) + app.get '/api/auth/saml/login' do + halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml) + + cfg = Routes::AuthSaml.resolve_saml_config + unless cfg[:idp_sso_url] && cfg[:sp_entity_id] + halt 500, json_error('saml_misconfigured', 'auth.saml.idp_sso_url and sp_entity_id are required', + status_code: 500) + end + + auth_request = OneLogin::RubySaml::Authrequest.new + redirect auth_request.create(saml_settings) + end + end + + def self.register_acs(app) + app.post '/api/auth/saml/acs' do + halt 503, json_error('saml_not_configured', 'SAML SP is not configured', status_code: 503) unless defined?(OneLogin::RubySaml) + + unless params['SAMLResponse'] + halt 400, json_error('missing_saml_response', 'SAMLResponse parameter is required', + status_code: 400) + end + + response = OneLogin::RubySaml::Response.new( + params['SAMLResponse'], + settings: saml_settings + ) + + unless response.is_valid? + errors = response.errors.join(', ') + halt 401, json_error('saml_invalid', "SAML assertion is invalid: #{errors}", status_code: 401) + end + + claims = Routes::AuthSaml.extract_claims(response) + roles = Routes::AuthSaml.map_roles(claims[:groups]) + + ttl = 28_800 + token = Legion::API::Token.issue_human_token( + msid: claims[:nameid], + name: claims[:display_name], + roles: roles, + ttl: ttl + ) + + json_response({ + access_token: token, + token_type: 'Bearer', + expires_in: ttl, + roles: roles, + name: claims[:display_name] + }) + end + end + + def self.extract_claims(response) + attrs = response.attributes + + email = first_attr(attrs, 'email', 'mail', 'emailAddress', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress') + display_name = first_attr(attrs, 'displayName', 'name', + 'http://schemas.microsoft.com/identity/claims/displayname', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name') + groups = multi_attr(attrs, 'groups', + 'http://schemas.microsoft.com/ws/2008/06/identity/claims/groups') + + { + nameid: response.nameid, + email: email, + display_name: display_name || email, + groups: groups + } + end + + def self.map_roles(groups) + if defined?(Legion::Rbac::ClaimsMapper) && Legion::Rbac::ClaimsMapper.respond_to?(:groups_to_roles) + cfg = resolve_saml_config + group_map = cfg[:group_map] || {} + default_role = cfg[:default_role] || 'worker' + Legion::Rbac::ClaimsMapper.groups_to_roles(groups, group_map: group_map, default_role: default_role) + else + ['worker'] + end + end + + class << self + private + + def first_attr(attrs, *names) + names.each do |n| + v = safe_attr(attrs, n) + return v if v + end + nil + end + + def multi_attr(attrs, *names) + names.each do |n| + v = attrs.multi(n) + return Array(v) if v + rescue StandardError => e + Legion::Logging.debug "AuthSaml#multi_attr failed for attr=#{n}: #{e.message}" if defined?(Legion::Logging) + nil + end + [] + end + + def safe_attr(attrs, name) + attrs[name] + rescue StandardError => e + Legion::Logging.debug "AuthSaml#safe_attr failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + nil + end + + private :register_metadata, :register_login, :register_acs + end + end + end + end +end diff --git a/lib/legion/api/auth_teams.rb b/lib/legion/api/auth_teams.rb new file mode 100644 index 00000000..8b3f6353 --- /dev/null +++ b/lib/legion/api/auth_teams.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + class API < Sinatra::Base + module Routes + module AuthTeams + # In-memory pending auth states (state -> { verifier:, created_at:, result: }) + @pending = {} + @mutex = Mutex.new + + class << self + attr_reader :pending, :mutex + end + + def self.registered(app) + register_store_helper(app) + register_authorize(app) + register_status(app) + register_callback(app) + end + + def self.register_authorize(app) + app.post '/api/auth/teams/authorize' do + teams_settings = Legion::Settings[:microsoft_teams] || {} + auth_settings = teams_settings[:auth] || {} + + tenant_id = teams_settings[:tenant_id] || auth_settings[:tenant_id] + client_id = teams_settings[:client_id] || auth_settings[:client_id] + + halt 422, json_error('missing_config', 'microsoft_teams.tenant_id and client_id required', status_code: 422) unless tenant_id && client_id + + body = parse_request_body + delegated = auth_settings[:delegated] || {} + scopes = body[:scopes] || delegated[:scopes] || + 'OnlineMeetings.Read OnlineMeetingTranscript.Read.All offline_access' + + state = SecureRandom.hex(32) + verifier = SecureRandom.urlsafe_base64(32) + challenge = Base64.urlsafe_encode64( + Digest::SHA256.digest(verifier), padding: false + ) + + port = Legion::Settings.dig(:api, :port) || 4567 + redirect_uri = "http://127.0.0.1:#{port}/api/auth/teams/callback" + + authorize_url = "https://login.microsoftonline.com/#{tenant_id}/oauth2/v2.0/authorize?" \ + "client_id=#{client_id}&response_type=code&redirect_uri=#{::URI.encode_www_form_component(redirect_uri)}" \ + "&scope=#{::URI.encode_www_form_component(scopes)}" \ + "&state=#{state}&code_challenge=#{challenge}&code_challenge_method=S256" + + AuthTeams.mutex.synchronize do + AuthTeams.pending[state] = { verifier: verifier, created_at: Time.now, result: nil, + tenant_id: tenant_id, client_id: client_id, + redirect_uri: redirect_uri, scopes: scopes } + end + + json_response({ authorize_url: authorize_url, state: state }) + end + end + + def self.register_status(app) + app.get '/api/auth/teams/status' do + state = params[:state] + halt 422, json_error('missing_state', 'state parameter required', status_code: 422) unless state + + entry = AuthTeams.mutex.synchronize { AuthTeams.pending[state] } + halt 404, json_error('unknown_state', 'no pending auth for this state', status_code: 404) unless entry + + if entry[:result] + AuthTeams.mutex.synchronize { AuthTeams.pending.delete(state) } + json_response(entry[:result]) + else + json_response({ authenticated: false, waiting: true }) + end + end + end + + def self.register_callback(app) + app.get '/api/auth/teams/callback' do + code = params[:code] + state = params[:state] + error = params[:error] + + entry = AuthTeams.mutex.synchronize { AuthTeams.pending[state] } + + if error || !entry + msg = error || 'unknown state' + AuthTeams.mutex.synchronize { entry[:result] = { authenticated: false, error: msg } } if entry + content_type :html + return '

Authentication failed.

You can close this tab.

' + end + + # Exchange code for token + require 'net/http' + token_uri = ::URI.parse("https://login.microsoftonline.com/#{entry[:tenant_id]}/oauth2/v2.0/token") + token_response = ::Net::HTTP.post_form(token_uri, { + 'client_id' => entry[:client_id], + 'grant_type' => 'authorization_code', + 'code' => code, + 'redirect_uri' => entry[:redirect_uri], + 'code_verifier' => entry[:verifier], + 'scope' => entry[:scopes] + }) + + token_body = Legion::JSON.load(token_response.body) + + if token_body[:access_token] + # Store token via TokenCache if available + store_teams_token(token_body, entry[:scopes]) + AuthTeams.mutex.synchronize { entry[:result] = { authenticated: true } } + content_type :html + '

Authentication successful!

You can close this tab.

' + else + err = token_body[:error_description] || token_body[:error] || 'token exchange failed' + Legion::Logging.error "Teams OAuth token exchange failed: #{err}" if defined?(Legion::Logging) + AuthTeams.mutex.synchronize { entry[:result] = { authenticated: false, error: err } } + content_type :html + "

Authentication failed.

#{err}

" + end + rescue StandardError => e + Legion::Logging.error "Teams OAuth callback error: #{e.message}" if defined?(Legion::Logging) + AuthTeams.mutex.synchronize { entry[:result] = { authenticated: false, error: e.message } } if entry + content_type :html + '

Authentication error.

Check daemon logs.

' + end + end + + module TeamsTokenHelper + def store_teams_token(token_body, scopes) + require 'legion/extensions/microsoft_teams/helpers/token_cache' + cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new + cache.store_delegated_token( + access_token: token_body[:access_token], + refresh_token: token_body[:refresh_token], + expires_in: token_body[:expires_in] || 3600, + scopes: scopes + ) + cache.save_to_vault + Legion::Logging.info 'Teams delegated token stored' if defined?(Legion::Logging) + rescue StandardError => e + Legion::Logging.warn "Failed to store Teams token: #{e.message}" if defined?(Legion::Logging) + end + end + + def self.register_store_helper(app) + app.helpers TeamsTokenHelper + end + + class << self + private :register_authorize, :register_status, :register_callback, :register_store_helper + end + end + end + end +end diff --git a/lib/legion/api/auth_worker.rb b/lib/legion/api/auth_worker.rb new file mode 100644 index 00000000..418333aa --- /dev/null +++ b/lib/legion/api/auth_worker.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module AuthWorker + def self.registered(app) + register_worker_token_exchange(app) + end + + def self.register_worker_token_exchange(app) + app.post '/api/auth/worker-token' do + Legion::Logging.debug "API: POST /api/auth/worker-token params=#{params.keys}" + body = parse_request_body + grant_type = body[:grant_type] + entra_token = body[:entra_token] + + unless grant_type == 'client_credentials' + Legion::Logging.warn "API POST /api/auth/worker-token returned 400: unsupported grant_type=#{grant_type}" + halt 400, json_error('unsupported_grant_type', 'grant_type must be client_credentials', + status_code: 400) + end + + unless entra_token + Legion::Logging.warn 'API POST /api/auth/worker-token returned 400: entra_token is required' + halt 400, json_error('missing_entra_token', 'entra_token is required', status_code: 400) + end + + unless defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks) + halt 501, json_error('jwks_validation_not_available', + 'JWKS validation is not available', status_code: 501) + end + + entra_settings = Routes::AuthWorker.resolve_entra_settings + tenant_id = entra_settings[:tenant_id] + unless tenant_id + Legion::Logging.error 'API POST /api/auth/worker-token returned 500: Entra tenant_id is not configured' + halt 500, json_error('entra_tenant_not_configured', + 'Entra tenant_id is not configured', status_code: 500) + end + + jwks_url = "https://login.microsoftonline.com/#{tenant_id}/discovery/v2.0/keys" + issuer = "https://login.microsoftonline.com/#{tenant_id}/v2.0" + + begin + claims = Legion::Crypt::JWT.verify_with_jwks( + entra_token, jwks_url: jwks_url, issuers: [issuer] + ) + rescue Legion::Crypt::JWT::ExpiredTokenError + Legion::Logging.warn 'API POST /api/auth/worker-token returned 401: Entra token has expired' + halt 401, json_error('token_expired', 'Entra token has expired', status_code: 401) + rescue Legion::Crypt::JWT::InvalidTokenError => e + Legion::Logging.warn "API POST /api/auth/worker-token returned 401: #{e.message}" + halt 401, json_error('invalid_token', e.message, status_code: 401) + rescue Legion::Crypt::JWT::Error => e + Legion::Logging.error "API POST /api/auth/worker-token returned 502: #{e.message}" + halt 502, json_error('identity_provider_unavailable', e.message, status_code: 502) + end + + app_id = claims[:appid] || claims[:azp] || claims['appid'] || claims['azp'] + unless app_id + Legion::Logging.warn 'API POST /api/auth/worker-token returned 401: missing appid claim' + halt 401, json_error('invalid_token', 'missing appid claim', status_code: 401) + end + + halt 503, json_error('data_unavailable', 'legion-data not connected', status_code: 503) unless defined?(Legion::Data::Model::DigitalWorker) + + worker = Legion::Data::Model::DigitalWorker.first(entra_app_id: app_id) + unless worker + Legion::Logging.warn "API POST /api/auth/worker-token returned 404: no worker for entra_app_id=#{app_id}" + halt 404, json_error('worker_not_found', + "no worker registered for entra_app_id #{app_id}", status_code: 404) + end + + unless worker.lifecycle_state == 'active' + Legion::Logging.warn "API POST /api/auth/worker-token returned 403: worker #{worker.worker_id} is in #{worker.lifecycle_state} state" + halt 403, json_error('worker_not_active', + "worker is in #{worker.lifecycle_state} state", status_code: 403) + end + + ttl = 3600 + token = Legion::API::Token.issue_worker_token( + worker_id: worker.worker_id, owner_msid: worker.owner_msid, ttl: ttl + ) + + Legion::Logging.info "API: issued worker token for worker_id=#{worker.worker_id}" + json_response({ + access_token: token, + token_type: 'Bearer', + expires_in: ttl, + worker_id: worker.worker_id, + scope: 'worker' + }) + end + end + + def self.resolve_entra_settings + return {} unless defined?(Legion::Settings) + + identity = Legion::Settings[:identity] + entra = identity.is_a?(Hash) ? identity[:entra] : nil + return entra if entra.is_a?(Hash) + + rbac = Legion::Settings[:rbac] + entra = rbac.is_a?(Hash) ? rbac[:entra] : nil + return entra if entra.is_a?(Hash) + + {} + rescue StandardError => e + Legion::Logging.debug "AuthWorker#resolve_entra_settings failed: #{e.message}" if defined?(Legion::Logging) + {} + end + + class << self + private :register_worker_token_exchange + end + end + end + end +end diff --git a/lib/legion/api/capacity.rb b/lib/legion/api/capacity.rb new file mode 100644 index 00000000..d0897fb4 --- /dev/null +++ b/lib/legion/api/capacity.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative '../capacity/model' + +module Legion + class API < Sinatra::Base + module Routes + module Capacity + def self.registered(app) + app.get '/api/capacity' do + workers = Routes::Capacity.fetch_worker_list + model = Legion::Capacity::Model.new(workers: workers) + json_response(model.aggregate) + rescue StandardError => e + Legion::Logging.error "API GET /api/capacity: #{e.class} — #{e.message}" + json_error('capacity_error', e.message, status_code: 500) + end + + app.get '/api/capacity/forecast' do + workers = Routes::Capacity.fetch_worker_list + model = Legion::Capacity::Model.new(workers: workers) + forecast = model.forecast( + days: (params[:days] || 30).to_i, + growth_rate: (params[:growth_rate] || 0).to_f + ) + json_response(forecast) + rescue StandardError => e + Legion::Logging.error "API GET /api/capacity/forecast: #{e.class} — #{e.message}" + json_error('capacity_error', e.message, status_code: 500) + end + + app.get '/api/capacity/workers' do + workers = Routes::Capacity.fetch_worker_list + model = Legion::Capacity::Model.new(workers: workers) + json_response(model.per_worker_stats) + rescue StandardError => e + Legion::Logging.error "API GET /api/capacity/workers: #{e.class} — #{e.message}" + json_error('capacity_error', e.message, status_code: 500) + end + end + + def self.fetch_worker_list + return [] unless defined?(Legion::Data::Model::DigitalWorker) + + Legion::Data::Model::DigitalWorker.all.map do |w| + { worker_id: w.worker_id, status: w.lifecycle_state } + end + rescue StandardError => e + Legion::Logging.warn "Capacity#fetch_worker_list failed: #{e.message}" if defined?(Legion::Logging) + [] + end + end + end + end +end diff --git a/lib/legion/api/catalog.rb b/lib/legion/api/catalog.rb new file mode 100644 index 00000000..c19bc3d9 --- /dev/null +++ b/lib/legion/api/catalog.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module ExtensionCatalog + def self.registered(app) + app.get '/api/catalog' do + entries = Legion::Extensions::Catalog.all.map do |name, entry| + build_catalog_manifest(name, entry) + end + json_response(entries) + end + + app.get '/api/catalog/:name' do + name = params[:name] + entry = Legion::Extensions::Catalog.entry(name) + unless entry + halt 404, { 'Content-Type' => 'application/json' }, + Legion::JSON.dump({ error: { code: 'not_found', message: "Extension #{name} not found" } }) + end + + json_response(build_catalog_manifest(name, entry)) + end + end + end + end + + helpers do + def build_catalog_manifest(name, entry) + { + name: name, + state: entry[:state].to_s, + started_at: entry[:started_at]&.iso8601, + permissions: build_catalog_permissions(name), + runners: build_catalog_runners(name), + known_intents: build_catalog_known_intents(name) + } + end + + def build_catalog_permissions(name) + declared = Legion::Extensions::Permissions.declared_paths(name) + { + sandbox: Legion::Extensions::Permissions.sandbox_path(name), + read_paths: declared[:read_paths], + write_paths: declared[:write_paths] + } + rescue StandardError => e + Legion::Logging.warn "API#build_catalog_permissions failed for #{name}: #{e.message}" if defined?(Legion::Logging) + { sandbox: Legion::Extensions::Permissions.sandbox_path(name), read_paths: [], write_paths: [] } + end + + def build_catalog_runners(name) + return {} unless defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected? + + ext = Legion::Data::Model::Extension.where(name: name).first + return {} unless ext + + ext.runners.to_h do |runner| + [runner.values[:name], { + methods: runner.functions.map { |f| f.values[:name] }, + description: runner.values[:description] + }] + end + rescue StandardError => e + Legion::Logging.warn "API#build_catalog_runners failed for #{name}: #{e.message}" if defined?(Legion::Logging) + {} + end + + def build_catalog_known_intents(name) + return [] unless defined?(Legion::MCP::PatternStore) + + matched = Legion::MCP::PatternStore.patterns.select do |_hash, pattern| + pattern[:tool_chain]&.any? { |t| t.start_with?(name) } + end + matched.map do |_hash, pattern| + { intent: pattern[:intent_text], tool_chain: pattern[:tool_chain], confidence: pattern[:confidence] } + end + rescue StandardError => e + Legion::Logging.warn "API#build_catalog_known_intents failed for #{name}: #{e.message}" if defined?(Legion::Logging) + [] + end + end + end +end diff --git a/lib/legion/api/chains.rb b/lib/legion/api/chains.rb new file mode 100644 index 00000000..68d8ded1 --- /dev/null +++ b/lib/legion/api/chains.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Chains + def self.registered(app) # rubocop:disable Metrics/AbcSize + app.get '/api/chains' do + require_data! + halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) unless Legion::Data::Model.const_defined?(:Chain) + + json_collection(Legion::Data::Model::Chain.order(:id)) + end + + app.post '/api/chains' do + Legion::Logging.debug "API: POST /api/chains params=#{params.keys}" + require_data! + unless Legion::Data::Model.const_defined?(:Chain) + Legion::Logging.warn 'API POST /api/chains returned 501: chain data model is not yet available' + halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) + end + + body = parse_request_body + unless body[:name] + Legion::Logging.warn 'API POST /api/chains returned 422: name is required' + halt 422, json_error('missing_field', 'name is required', status_code: 422) + end + + id = Legion::Data::Model::Chain.insert(body) + record = Legion::Data::Model::Chain[id] + Legion::Logging.info "API: created chain #{id} (#{body[:name]})" + json_response(record.values, status_code: 201) + end + + app.get '/api/chains/:id' do + require_data! + unless Legion::Data::Model.const_defined?(:Chain) + Legion::Logging.warn "API GET /api/chains/#{params[:id]} returned 501: chain data model is not yet available" + halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) + end + + record = find_or_halt(Legion::Data::Model::Chain, params[:id]) + json_response(record.values) + end + + app.put '/api/chains/:id' do + Legion::Logging.debug "API: PUT /api/chains/#{params[:id]} params=#{params.keys}" + require_data! + unless Legion::Data::Model.const_defined?(:Chain) + Legion::Logging.warn "API PUT /api/chains/#{params[:id]} returned 501: chain data model is not yet available" + halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) + end + + record = find_or_halt(Legion::Data::Model::Chain, params[:id]) + body = parse_request_body + record.update(body) + record.refresh + Legion::Logging.info "API: updated chain #{params[:id]}" + json_response(record.values) + end + + app.delete '/api/chains/:id' do + require_data! + unless Legion::Data::Model.const_defined?(:Chain) + Legion::Logging.warn "API DELETE /api/chains/#{params[:id]} returned 501: chain data model is not yet available" + halt 501, json_error('not_implemented', 'chain data model is not yet available', status_code: 501) + end + + record = find_or_halt(Legion::Data::Model::Chain, params[:id]) + record.delete + Legion::Logging.info "API: deleted chain #{params[:id]}" + json_response({ deleted: true }) + end + end + end + end + end +end diff --git a/lib/legion/api/codegen.rb b/lib/legion/api/codegen.rb new file mode 100644 index 00000000..bd7cc249 --- /dev/null +++ b/lib/legion/api/codegen.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Codegen + def self.registered(app) + app.get '/api/codegen/status' do + halt 503, json_error('codegen_unavailable', 'codegen not available', status_code: 503) unless defined?(Legion::MCP::SelfGenerate) + + json_response(Legion::MCP::SelfGenerate.status) + end + + app.get '/api/codegen/generated' do + unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry) + halt 503, json_error('codegen_unavailable', 'codegen not available', status_code: 503) + end + + status_filter = params[:status] + records = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.list(status: status_filter) + json_response(records) + end + + app.get '/api/codegen/generated/:id' do |id| + unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry) + halt 503, json_error('codegen_unavailable', 'codegen not available', status_code: 503) + end + + record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.get(id: id) + halt 404, json_error('not_found', 'record not found', status_code: 404) unless record + + json_response(record) + end + + app.post '/api/codegen/generated/:id/approve' do |id| + unless defined?(Legion::Extensions::Codegen::Runners::ReviewHandler) + halt 503, json_error('codegen_unavailable', 'review handler not available', status_code: 503) + end + + result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict( + review: { generation_id: id, verdict: :approve, confidence: 1.0 } + ) + json_response(result) + end + + app.post '/api/codegen/generated/:id/reject' do |id| + unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry) + halt 503, json_error('codegen_unavailable', 'codegen not available', status_code: 503) + end + + Legion::Extensions::Codegen::Helpers::GeneratedRegistry.update_status(id: id, status: 'rejected') + json_response({ id: id, status: 'rejected' }) + end + + app.post '/api/codegen/generated/:id/retry' do |id| + unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry) + halt 503, json_error('codegen_unavailable', 'codegen not available', status_code: 503) + end + + Legion::Extensions::Codegen::Helpers::GeneratedRegistry.update_status(id: id, status: 'pending') + json_response({ id: id, status: 'pending' }) + end + + app.get '/api/codegen/gaps' do + data = if defined?(Legion::MCP::GapDetector) + Legion::MCP::GapDetector.detect_gaps + else + [] + end + json_response(data) + end + + app.post '/api/codegen/cycle' do + return json_response({ triggered: false, reason: 'self_generate not available' }) unless defined?(Legion::MCP::SelfGenerate) + + Legion::MCP::SelfGenerate.instance_variable_set(:@last_cycle_at, nil) + result = Legion::MCP::SelfGenerate.run_cycle + json_response(result) + end + end + end + end + end +end diff --git a/lib/legion/api/coldstart.rb b/lib/legion/api/coldstart.rb new file mode 100644 index 00000000..fe8c55bb --- /dev/null +++ b/lib/legion/api/coldstart.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Coldstart + def self.registered(app) + app.post '/api/coldstart/ingest' do + Legion::Logging.debug "API: POST /api/coldstart/ingest params=#{params.keys}" + body = parse_request_body + path = body[:path] + if path.nil? || path.empty? + Legion::Logging.warn 'API POST /api/coldstart/ingest returned 422: path is required' + halt 422, json_error('missing_field', 'path is required', status_code: 422) + end + + unless defined?(Legion::Extensions::Coldstart) + Legion::Logging.warn 'API POST /api/coldstart/ingest returned 503: lex-coldstart is not loaded' + halt 503, json_error('coldstart_unavailable', 'lex-coldstart is not loaded', status_code: 503) + end + + unless defined?(Legion::Extensions::Agentic::Memory::Trace) + Legion::Logging.warn 'API POST /api/coldstart/ingest returned 503: lex-agentic-memory is not loaded' + halt 503, json_error('memory_unavailable', 'lex-agentic-memory is not loaded', status_code: 503) + end + + runner = Object.new.extend(Legion::Extensions::Coldstart::Runners::Ingest) + runner.define_singleton_method(:log) { Legion::Logging } unless runner.respond_to?(:log) + + result = if File.file?(path) + runner.ingest_file(file_path: File.expand_path(path)) + elsif File.directory?(path) + runner.ingest_directory( + dir_path: File.expand_path(path), + pattern: body[:pattern] || '**/{CLAUDE,MEMORY}.md' + ) + else + Legion::Logging.warn "API POST /api/coldstart/ingest returned 404: path not found: #{path}" + halt 404, json_error('path_not_found', "path not found: #{path}", status_code: 404) + end + + Legion::Logging.info "API: coldstart ingest completed for path=#{path}" + json_response(result, status_code: 201) + rescue StandardError => e + Legion::Logging.error "API POST /api/coldstart/ingest: #{e.class} — #{e.message}" + json_error('execution_error', e.message, status_code: 500) + end + end + end + end + end +end diff --git a/lib/legion/api/costs.rb b/lib/legion/api/costs.rb new file mode 100644 index 00000000..7843e705 --- /dev/null +++ b/lib/legion/api/costs.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Costs + def self.registered(app) + app.helpers CostHelpers + register_summary(app) + register_by_worker(app) + register_by_extension(app) + end + + def self.register_summary(app) + app.get '/api/costs/summary' do + halt 503, json_error('data_unavailable', 'metering data not available', status_code: 503) unless metering_available? + + period = params[:period] || 'month' + json_response(cost_summary(period)) + end + end + + def self.register_by_worker(app) + app.get '/api/costs/workers' do + halt 503, json_error('data_unavailable', 'metering data not available', status_code: 503) unless metering_available? + + limit = (params[:limit] || 10).to_i.clamp(1, 100) + json_response(costs_by_worker(limit)) + end + end + + def self.register_by_extension(app) + app.get '/api/costs/extensions' do + halt 503, json_error('data_unavailable', 'metering data not available', status_code: 503) unless metering_available? + + limit = (params[:limit] || 10).to_i.clamp(1, 100) + json_response(costs_by_extension(limit)) + end + end + + class << self + private :register_summary, :register_by_worker, :register_by_extension + end + end + + module CostHelpers + def metering_available? + defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && !Legion::Data.connection.nil? + rescue StandardError => e + Legion::Logging.debug("CostHelpers#metering_available? check failed: #{e.message}") if defined?(Legion::Logging) + false + end + + def metering_records + Legion::Data.connection[:metering_records] + end + + def cost_summary(period) + now = Time.now.utc + today_start = Time.utc(now.year, now.month, now.day) + week_start = today_start - ((today_start.wday % 7) * 86_400) + month_start = Time.utc(now.year, now.month, 1) + + ds = metering_records + worker_count = ds.distinct.select(:worker_id).exclude(worker_id: nil).count + + { + today: sum_cost_since(ds, today_start), + week: sum_cost_since(ds, week_start), + month: sum_cost_since(ds, month_start), + workers: worker_count, + period: period + } + rescue ::Sequel::Error => e + { today: 0.0, week: 0.0, month: 0.0, workers: 0, error: e.message } + end + + def costs_by_worker(limit) + metering_records + .group(:worker_id) + .select( + :worker_id, + ::Sequel.function(:sum, :cost_usd).as(:total_cost), + ::Sequel.function(:count, ::Sequel.lit('*')).as(:call_count) + ) + .order(::Sequel.desc(:total_cost)) + .limit(limit) + .all + rescue ::Sequel::Error + [] + end + + def costs_by_extension(limit) + metering_records + .exclude(extension: nil) + .group(:extension) + .select( + :extension, + ::Sequel.function(:sum, :cost_usd).as(:total_cost), + ::Sequel.function(:count, ::Sequel.lit('*')).as(:call_count) + ) + .order(::Sequel.desc(:total_cost)) + .limit(limit) + .all + rescue ::Sequel::Error + [] + end + + private + + def sum_cost_since(dataset, since_time) + (dataset.where(::Sequel.lit('recorded_at >= ?', since_time)).sum(:cost_usd) || 0.0).to_f.round(6) + end + end + end + end +end diff --git a/lib/legion/api/default_settings.rb b/lib/legion/api/default_settings.rb new file mode 100644 index 00000000..9bcaeb6e --- /dev/null +++ b/lib/legion/api/default_settings.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Legion + class API < Sinatra::Base + module Settings + def self.default + { + enabled: true, + port: 4567, + bind: '127.0.0.1', + puma: puma_defaults, + bind_retries: 3, + bind_retry_wait: 2, + tls: tls_defaults, + elastic_apm: elastic_apm_defaults + } + end + + def self.puma_defaults + { + min_threads: 10, + max_threads: 16, + persistent_timeout: 20, + first_data_timeout: 30 + } + end + + def self.tls_defaults + { + enabled: false + } + end + + def self.elastic_apm_defaults + { + enabled: false, + server_url: 'http://localhost:8200', + api_key: nil, + secret_token: nil, + api_buffer_size: 256, + api_request_size: '750kb', + api_request_time: '10s', + capture_body: 'off', + capture_headers: true, + capture_env: true, + disable_send: false, + environment: nil, + hostname: nil, + ignore_url_patterns: %w[/api/health /api/ready], + pool_size: 1, + service_name: 'LegionIO', + service_node_name: nil, + service_version: nil, + sample_rate: 1.0, + verify_server_cert: true, + central_config: true, + span_frames_min_duration: '5ms' + } + end + end + end +end + +begin + Legion::Settings.merge_settings('api', Legion::API::Settings.default) if Legion.const_defined?('Settings', false) +rescue StandardError => e + if Legion.const_defined?('Logging', false) && Legion::Logging.respond_to?(:fatal) + Legion::Logging.fatal(e.message) + Legion::Logging.fatal(e.backtrace) + else + puts e.message + puts e.backtrace + end +end diff --git a/lib/legion/api/events.rb b/lib/legion/api/events.rb new file mode 100644 index 00000000..ebd5f589 --- /dev/null +++ b/lib/legion/api/events.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Events + BUFFER_SIZE = 100 + SSE_STOP = Object.new.freeze + + class << self + def event_buffer + @event_buffer ||= [] + end + + def buffer_mutex + @buffer_mutex ||= Mutex.new + end + + def push_event(event) + buffer_mutex.synchronize do + event_buffer.push(event) + event_buffer.shift if event_buffer.length > BUFFER_SIZE + end + end + + def recent_events(count = 25) + buffer_mutex.synchronize do + event_buffer.last(count) + end + end + + def install_listener + return if @listener_installed + return unless defined?(Legion::Events) + + Legion::Events.on('*') do |event| + push_event(event.transform_keys(&:to_s)) + end + @listener_installed = true + end + + def write_sse_event(out, event) + payload = event.transform_keys(&:to_s) + out << "event: #{payload['event']}\ndata: #{Legion::JSON.dump(payload)}\n\n" + end + + def stop_queue_stream(queue:, worker:, listener:) + Legion::Events.off('*', listener) if defined?(Legion::Events) + return unless worker&.alive? + + queue.push(SSE_STOP) + worker.join(0.1) + rescue ThreadError, IOError, Errno::EPIPE => e + Legion::Logging.debug("Events SSE cleanup failed: #{e.message}") if defined?(Legion::Logging) + end + + def stream_queue(out:, queue:, listener:) + worker = Thread.new do + loop do + event = queue.pop + break if event.equal?(SSE_STOP) + + write_sse_event(out, event) + rescue IOError, Errno::EPIPE => e + Legion::Logging.debug("Events SSE stream broken for #{event[:event]}: #{e.message}") if defined?(Legion::Logging) + break + end + ensure + Legion::Events.off('*', listener) if defined?(Legion::Events) + end + + cleanup = proc { stop_queue_stream(queue: queue, worker: worker, listener: listener) } + out.callback(&cleanup) + out.errback(&cleanup) + worker + end + + def registered(app) + install_listener if defined?(Legion::Events) + + app.get '/api/events' do + content_type 'text/event-stream' + headers 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no' + + queue = Queue.new + listener = Legion::Events.on('*') do |event| + queue.push(event) + end + + stream do |out| + Routes::Events.stream_queue(out: out, queue: queue, listener: listener) + end + end + + app.get '/api/events/recent' do + count = (params[:count] || 25).to_i + count = [count, BUFFER_SIZE].min + events = Events.recent_events(count) + json_response(events) + end + end + end + end + end + end +end diff --git a/lib/legion/api/extensions.rb b/lib/legion/api/extensions.rb new file mode 100644 index 00000000..0d59b5e9 --- /dev/null +++ b/lib/legion/api/extensions.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Extensions + def self.registered(app) + register_loaded_summary_route(app) + register_tools_route(app) + register_available_route(app) + register_extension_routes(app) + register_runner_routes(app) + register_function_routes(app) + register_invoke_route(app) + end + + def self.register_loaded_summary_route(app) + app.get '/api/extensions' do + items = Legion::Extensions.loaded_extension_modules.map do |mod| + version = mod.const_get(:VERSION, false).to_s if mod.const_defined?(:VERSION, false) + name = if mod.respond_to?(:lex_name) + mod.lex_name + else + mod.name.to_s.split('::').last.to_s.downcase + end + { name: name, module: mod.name, version: version }.compact + end + + json_response(items) + end + end + + def self.register_tools_route(app) + app.get '/api/extensions/tools' do + entries = filter_tool_entries(Array(Legion::Settings::Extensions.tools), params) + tools = entries.map { |e| serialize_tool_entry(e) } + json_response({ total: tools.size, tools: tools }) + end + end + + def self.register_available_route(app) + app.get '/api/extension_catalog/available' do + entries = Legion::Extensions::Catalog::Available.all + entries = entries.select { |e| e[:category] == params[:category] } if params[:category] + json_response(entries) + end + end + + def self.register_extension_routes(app) + app.get '/api/extension_catalog' do + entries = Routes::Extensions.extension_entries + entries = entries.select { |e| e[:state] == params[:state] } if params[:state] + json_response(entries) + end + + app.get '/api/extension_catalog/:name' do + name = params[:name] + entry = Routes::Extensions.extension_entry(name) + halt_not_found("extension '#{name}' not found") unless entry + + ext_mod = find_extension_module(name) + + runners = ext_mod ? runner_summaries(ext_mod) : [] + + json_response(entry.merge(runners: runners).compact) + end + end + + def self.register_runner_routes(app) + app.get '/api/extension_catalog/:name/runners' do + name = params[:name] + halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name) + + ext_mod = find_extension_module(name) + halt_not_found("extension '#{name}' not loaded") unless ext_mod + + json_response(runner_summaries(ext_mod)) + end + + app.get '/api/extension_catalog/:name/runners/:runner_name' do + name = params[:name] + halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name) + + ext_mod = find_extension_module(name) + halt_not_found("extension '#{name}' not loaded") unless ext_mod + + info = find_runner_info(ext_mod, params[:runner_name]) + halt_not_found("runner '#{params[:runner_name]}' not found") unless info + + runner_mod = info[:runner_module] + functions = runner_mod.instance_methods(false).map(&:to_s) + + json_response({ + name: info[:runner_name], + runner_class: info[:runner_class], + functions: functions + }) + end + end + + def self.register_function_routes(app) + app.get '/api/extension_catalog/:name/runners/:runner_name/functions' do + name = params[:name] + halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name) + + ext_mod = find_extension_module(name) + halt_not_found("extension '#{name}' not loaded") unless ext_mod + + info = find_runner_info(ext_mod, params[:runner_name]) + halt_not_found("runner '#{params[:runner_name]}' not found") unless info + + functions = info[:runner_module].instance_methods(false).map do |m| + args = info.dig(:class_methods, m, :args) + { name: m.to_s, args: args } + end + json_response(functions) + end + + app.get '/api/extension_catalog/:name/runners/:runner_name/functions/:function_name' do + name = params[:name] + halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name) + + ext_mod = find_extension_module(name) + halt_not_found("extension '#{name}' not loaded") unless ext_mod + + info = find_runner_info(ext_mod, params[:runner_name]) + halt_not_found("runner '#{params[:runner_name]}' not found") unless info + + func_sym = params[:function_name].to_sym + halt_not_found("function '#{params[:function_name]}' not found") unless info[:runner_module].method_defined?(func_sym, false) + + args = info.dig(:class_methods, func_sym, :args) + json_response({ name: params[:function_name], runner: params[:runner_name], args: args }) + end + end + + def self.register_invoke_route(app) + app.post '/api/extension_catalog/:name/runners/:runner_name/functions/:function_name/invoke' do + name = params[:name] + halt_not_found("extension '#{name}' not found") unless Legion::Extensions::Catalog.entry(name) + + ext_mod = find_extension_module(name) + halt_not_found("extension '#{name}' not loaded") unless ext_mod + + info = find_runner_info(ext_mod, params[:runner_name]) + halt_not_found("runner '#{params[:runner_name]}' not found") unless info + + func_sym = params[:function_name].to_sym + halt_not_found("function '#{params[:function_name]}' not found") unless info[:runner_module].method_defined?(func_sym, false) + + body = parse_request_body + + result = Legion::Ingress.run( + payload: body, + runner_class: info[:runner_class], + function: func_sym, + source: 'api', + check_subtask: body.fetch(:check_subtask, true), + generate_task: body.fetch(:generate_task, true) + ) + json_response(result, status_code: 201) + rescue NameError => e + json_error('invalid_runner', e.message, status_code: 422) + rescue StandardError => e + Legion::Logging.error "API POST /api/extension_catalog invoke: #{e.class} - #{e.message}" if defined?(Legion::Logging) + json_error('execution_error', e.message, status_code: 500) + end + end + + class << self + def extension_entries + handles = if Legion::Extensions.respond_to?(:extension_handles) + Legion::Extensions.extension_handles + else + [] + end + return handles.map { |handle| serialize_handle(handle) } unless handles.empty? + + Legion::Extensions::Catalog.all.filter_map do |name, entry| + serialize_catalog_entry(name, entry) + end + end + + def extension_entry(name) + handle = Legion::Extensions.extension_handle(name) if Legion::Extensions.respond_to?(:extension_handle) + return serialize_handle(handle) if handle + + serialize_catalog_entry(name, Legion::Extensions::Catalog.entry(name)) + end + + def serialize_handle(handle) + { + name: handle.lex_name, + state: handle.state.to_s, + active_version: handle.active_version&.to_s, + latest_installed_version: handle.latest_installed_version&.to_s, + reload_state: handle.reload_state.to_s, + pending_reload: handle.pending_reload?, + hot_reloadable: handle.hot_reloadable, + loaded_at: handle.loaded_at&.iso8601, + last_error: handle.last_error, + routes: handle.routes, + tools: handle.tools, + absorbers: handle.absorbers, + owned_runners: handle.runners + }.compact + end + + def serialize_catalog_entry(name, entry) + return nil unless entry + + { name: name, state: entry[:state].to_s, + registered_at: entry[:registered_at]&.iso8601, + started_at: entry[:started_at]&.iso8601 } + end + + def filter_tool_entries(entries, params) + entries = entries.select { |e| e[:extension].to_s == params[:extension] } if params[:extension] + entries = entries.select { |e| e[:runner].to_s == params[:runner] } if params[:runner] + entries = entries.select { |e| e[:deferred] == (params[:deferred] == 'true') } if params.key?(:deferred) + entries = entries.select { |e| Array(e[:trigger_words]).any? } if params[:triggered] == 'true' + entries + end + + def serialize_tool_entry(entry) + { + name: entry[:name], + description: entry[:description], + extension: entry[:extension], + runner: entry[:runner], + deferred: entry[:deferred], + trigger_words: entry[:trigger_words], + source: entry[:source], + sticky: entry[:sticky] + }.compact + end + + private :register_loaded_summary_route, :register_tools_route, :register_available_route, + :register_extension_routes, :register_runner_routes, :register_function_routes, + :register_invoke_route + end + end + end + end +end diff --git a/lib/legion/api/fleet.rb b/lib/legion/api/fleet.rb new file mode 100644 index 00000000..d80715ca --- /dev/null +++ b/lib/legion/api/fleet.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Fleet + def self.registered(app) + app.helpers FleetHelpers + + app.get '/api/fleet/status' do + json_response(fleet_status) + end + + app.get '/api/fleet/pending' do + items = fleet_pending_approvals + json_response(items) + end + + app.post '/api/fleet/approve' do + body = parse_request_body + id = body[:id] + halt 400, json_error('missing_id', 'id is required', status_code: 400) unless id + + result = fleet_approve(id.to_i) + if result[:success] + json_response(result) + else + json_error('approve_failed', result[:error].to_s, status_code: 422) + end + end + + app.get '/api/fleet/sources' do + sources = Legion::Settings.dig(:fleet, :sources) || [] + json_response({ sources: sources }) + end + + app.post '/api/fleet/sources' do + body = parse_request_body + source = body[:source] + halt 400, json_error('missing_source', 'source is required', status_code: 400) unless source + + result = fleet_add_source(body) + if result[:success] + json_response(result, status_code: 201) + else + json_error('add_source_failed', result[:error].to_s, status_code: 422) + end + end + end + + module FleetHelpers + def fleet_status + queues = [] + active = 0 + workers = 0 + + if defined?(Legion::Transport) && Legion::Settings.dig(:transport, :connected) + %w[assessor planner developer validator].each do |ext| + queue_name = "lex.#{ext}.runners.#{ext}" + depth = fleet_queue_depth(queue_name) + queues << { name: queue_name, depth: depth } if depth + end + end + + { queues: queues, active_work_items: active, workers: workers } + end + + def fleet_queue_depth(queue_name) + return nil unless defined?(Legion::Transport::Session) + + channel = Legion::Transport::Session.channel + queue = channel.queue(queue_name, passive: true) + queue.message_count + rescue StandardError + nil + end + + def fleet_pending_approvals + approval_types = %w[fleet.shipping fleet.escalation] + + if defined?(Legion::Data::Model::Task) + Legion::Data::Model::Task + .where(status: 'pending_approval') + .where(Sequel.lit('JSON_EXTRACT(payload, ?) IN ?', + '$.approval_type', approval_types)) + .order(Sequel.desc(:created_at)) + .limit(page_limit) + .all + .map(&:values) + else + [] + end + rescue StandardError => e + Legion::Logging.warn "Fleet#fleet_pending_approvals: #{e.message}" if defined?(Legion::Logging) + [] + end + + def fleet_approve(_id) + { success: false, error: 'approval system not available' } + end + + def fleet_add_source(body) + source = body[:source] + case source + when 'github' + fleet_setup_github_source(body) + else + { success: false, error: "Unknown source: #{source}" } + end + end + + def fleet_setup_github_source(body) + sources = Legion::Settings.dig(:fleet, :sources) || [] + entry = { + type: 'github', + owner: body[:owner], + repo: body[:repo] + } + sources << entry + + Legion::Settings.loader.settings[:fleet] ||= {} + Legion::Settings.loader.settings[:fleet][:sources] = sources + + { success: true, source: 'github', absorber: 'issues' } + rescue StandardError => e + { success: false, error: e.message } + end + end + end + end + end +end diff --git a/lib/legion/api/gaia.rb b/lib/legion/api/gaia.rb new file mode 100644 index 00000000..da38aae8 --- /dev/null +++ b/lib/legion/api/gaia.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Gaia + def self.registered(app) + register_status_route(app) + register_ticks_route(app) + register_channels_route(app) + register_buffer_route(app) + register_sessions_route(app) + register_teams_webhook_route(app) + end + + def self.register_ticks_route(app) + app.get '/api/gaia/ticks' do + halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available? + + limit = (params[:limit] || 50).to_i.clamp(1, 200) + events = defined?(Legion::Gaia) ? Legion::Gaia.tick_history&.recent(limit: limit) : [] + json_response({ events: events || [] }) + end + end + + def self.register_status_route(app) + app.get '/api/gaia/status' do + if gaia_available? + json_response(Legion::Gaia.status) + else + json_response({ started: false }, status_code: 503) + end + end + end + + def self.register_channels_route(app) + app.helpers GaiaHelpers + + app.get '/api/gaia/channels' do + halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available? + + registry = Legion::Gaia.channel_registry + return json_response({ channels: [] }) unless registry + + channels = registry.active_channels.map do |ch_id| + adapter = registry.adapter_for(ch_id) + build_channel_info(ch_id, adapter) + end + + json_response({ channels: channels, count: channels.size }) + end + end + + def self.register_buffer_route(app) + app.get '/api/gaia/buffer' do + halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available? + + buffer = Legion::Gaia.sensory_buffer + json_response({ + depth: buffer&.size || 0, + empty: buffer.nil? || buffer.empty?, + max_size: gaia_buffer_max_size + }) + end + end + + def self.register_sessions_route(app) + app.get '/api/gaia/sessions' do + halt 503, json_error('gaia_unavailable', 'gaia is not started', status_code: 503) unless gaia_available? + + store = Legion::Gaia.session_store + json_response({ + count: store&.size || 0, + active: gaia_available? + }) + end + end + + def self.register_teams_webhook_route(app) + app.post '/api/channels/teams/webhook' do + Legion::Logging.debug "API: POST /api/channels/teams/webhook params=#{params.keys}" + body = request.body.read + activity = Legion::JSON.load(body) + + adapter = Routes::Gaia.teams_adapter + unless adapter + Legion::Logging.warn 'API POST /api/channels/teams/webhook returned 503: teams adapter not available' + halt 503, json_response({ error: 'teams adapter not available' }, status_code: 503) + end + + input_frame = adapter.translate_inbound(activity) + Legion::Gaia.sensory_buffer&.push(input_frame) if defined?(Legion::Gaia) + Legion::Logging.info "API: accepted Teams webhook frame_id=#{input_frame&.id}" + json_response({ status: 'accepted', frame_id: input_frame&.id }) + end + end + + def self.teams_adapter + return nil unless defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:channel_registry) + return nil unless Legion::Gaia.channel_registry + + Legion::Gaia.channel_registry.adapter_for(:teams) + rescue StandardError => e + Legion::Logging.warn "Gaia#teams_adapter failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + module GaiaHelpers + def gaia_available? + defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:started?) && Legion::Gaia.started? + end + + def gaia_buffer_max_size + return nil unless defined?(Legion::Gaia::SensoryBuffer) + + Legion::Gaia::SensoryBuffer::MAX_BUFFER_SIZE + rescue NameError + nil + end + + def build_channel_info(channel_id, adapter) + info = { id: channel_id, started: adapter&.started? || false } + info[:capabilities] = adapter.capabilities if adapter.respond_to?(:capabilities) + info[:type] = adapter.class.name.split('::').last if adapter + info + end + end + end + end + end +end diff --git a/lib/legion/api/governance.rb b/lib/legion/api/governance.rb new file mode 100644 index 00000000..bba2327b --- /dev/null +++ b/lib/legion/api/governance.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Governance + def self.registered(app) + app.helpers GovernanceHelpers + register_approvals(app) + end + + module GovernanceHelpers + def run_governance_runner(method, **) + require 'legion/extensions/audit/runners/approval_queue' + runner = Object.new.extend(Legion::Extensions::Audit::Runners::ApprovalQueue) + runner.send(method, **) + rescue LoadError => e + Legion::Logging.warn "Governance#run_governance_runner failed to load lex-audit: #{e.message}" if defined?(Legion::Logging) + halt 503, json_error('service_unavailable', 'lex-audit not available', status_code: 503) + end + end + + def self.register_approvals(app) + app.get '/api/governance/approvals' do + require_data! + result = run_governance_runner(:list_pending, + tenant_id: params[:tenant_id], + limit: (params[:limit] || 50).to_i) + json_response(result) + end + + app.get '/api/governance/approvals/:id' do + require_data! + result = run_governance_runner(:show_approval, id: params[:id].to_i) + if result[:success] + json_response(result) + else + halt 404, json_error('not_found', 'Approval not found', status_code: 404) + end + end + + app.post '/api/governance/approvals' do + require_data! + body = parse_request_body + halt 422, json_error('missing_field', 'approval_type is required', status_code: 422) unless body[:approval_type] + halt 422, json_error('missing_field', 'requester_id is required', status_code: 422) unless body[:requester_id] + + result = run_governance_runner(:submit, + approval_type: body[:approval_type], + payload: body[:payload] || {}, + requester_id: body[:requester_id], + tenant_id: body[:tenant_id]) + json_response(result, status_code: 201) + end + + app.put '/api/governance/approvals/:id/approve' do + require_data! + body = parse_request_body + halt 422, json_error('missing_field', 'reviewer_id is required', status_code: 422) unless body[:reviewer_id] + + result = run_governance_runner(:approve, + id: params[:id].to_i, + reviewer_id: body[:reviewer_id]) + json_response(result) + end + + app.put '/api/governance/approvals/:id/reject' do + require_data! + body = parse_request_body + halt 422, json_error('missing_field', 'reviewer_id is required', status_code: 422) unless body[:reviewer_id] + + result = run_governance_runner(:reject, + id: params[:id].to_i, + reviewer_id: body[:reviewer_id]) + json_response(result) + end + end + end + end + end +end diff --git a/lib/legion/api/graphql.rb b/lib/legion/api/graphql.rb new file mode 100644 index 00000000..224c0619 --- /dev/null +++ b/lib/legion/api/graphql.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +return unless defined?(GraphQL) + +require_relative 'graphql/schema' + +module Legion + class API < Sinatra::Base + module Routes + module GraphQL + def self.registered(app) + app.post '/api/graphql' do + content_type :json + Legion::Logging.debug "API: POST /api/graphql params=#{params.keys}" if defined?(Legion::Logging) + + body_str = request.body.read + payload = body_str.empty? ? {} : Legion::JSON.load(body_str) + payload = payload.transform_keys(&:to_sym) if payload.is_a?(Hash) + + query = payload[:query] + variables = payload[:variables] || {} + operation_name = payload[:operationName] + + if query.nil? || query.strip.empty? + Legion::Logging.warn 'API POST /api/graphql returned 400: query is required' if defined?(Legion::Logging) + status 400 + next Legion::JSON.dump({ + errors: [{ message: 'query is required' }] + }) + end + + result = Legion::API::GraphQL::Schema.execute( + query, + variables: variables, + operation_name: operation_name, + context: { request: request } + ) + + status 200 + Legion::JSON.dump(result.to_h) + rescue StandardError => e + Legion::Logging.error "API POST /api/graphql: #{e.class} — #{e.message}" if defined?(Legion::Logging) + status 500 + Legion::JSON.dump({ errors: [{ message: e.message }] }) + end + + app.get '/api/graphql' do + content_type 'text/html' + Legion::API::Routes::GraphQL.graphiql_html + end + end + + def self.graphiql_html + <<~HTML + + + + LegionIO GraphiQL + + + +
+ + + + + + + HTML + end + end + end + end +end diff --git a/lib/legion/api/graphql/resolvers/extensions.rb b/lib/legion/api/graphql/resolvers/extensions.rb new file mode 100644 index 00000000..8e89a38a --- /dev/null +++ b/lib/legion/api/graphql/resolvers/extensions.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module GraphQL + module Resolvers + module Extensions + def self.resolve(status: nil) + if defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection + resolve_from_data(status: status) + else + resolve_from_registry(status: status) + end + end + + def self.find(name:) + resolve.find { |e| e[:name] == name } + end + + def self.resolve_from_data(status: nil) + return [] unless defined?(Legion::Data::Model::Extension) + + dataset = Legion::Data::Model::Extension.order(:id) + dataset = dataset.where(status: status) if status + dataset.all.map { |e| extension_hash(e.values) } + rescue StandardError => e + Legion::Logging.warn "GraphQL::Extensions#resolve_from_data failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def self.resolve_from_registry(status: nil) + return [] unless defined?(Legion::Registry) + + entries = Legion::Registry.respond_to?(:all) ? Legion::Registry.all : [] + entries = entries.map { |e| e.is_a?(Hash) ? e : e.to_h } + entries = entries.select { |e| e[:status].to_s == status } if status + entries.map { |e| extension_hash(e) } + rescue StandardError => e + Legion::Logging.warn "GraphQL::Extensions#resolve_from_registry failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def self.extension_hash(values) + { + name: values[:name], + version: values[:version], + status: values[:status]&.to_s || 'active', + description: values[:description], + risk_tier: values[:risk_tier], + runners: Array(values[:runners]) + } + end + + private_class_method :resolve_from_data, :resolve_from_registry, :extension_hash + end + end + end + end +end diff --git a/lib/legion/api/graphql/resolvers/node.rb b/lib/legion/api/graphql/resolvers/node.rb new file mode 100644 index 00000000..c9cbb8f0 --- /dev/null +++ b/lib/legion/api/graphql/resolvers/node.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module GraphQL + module Resolvers + module Node + def self.resolve + name = defined?(Legion::Settings) ? Legion::Settings[:client][:name] : 'legion' + version = defined?(Legion::VERSION) ? Legion::VERSION : nil + ready = defined?(Legion::Readiness) ? Legion::Readiness.ready? : true + uptime = defined?(Legion::Process) ? calculate_uptime : nil + + { + name: name, + version: version, + uptime: uptime, + ready: ready + } + rescue StandardError => e + Legion::Logging.warn "GraphQL::Node#resolve failed: #{e.message}" if defined?(Legion::Logging) + { name: nil, version: nil, uptime: nil, ready: false } + end + + def self.calculate_uptime + return nil unless defined?(Legion::Process) && + Legion::Process.respond_to?(:started_at) && + Legion::Process.started_at + + (Time.now.utc - Legion::Process.started_at).to_i + rescue StandardError => e + Legion::Logging.debug "GraphQL::Node#calculate_uptime failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + private_class_method :calculate_uptime + end + end + end + end +end diff --git a/lib/legion/api/graphql/resolvers/tasks.rb b/lib/legion/api/graphql/resolvers/tasks.rb new file mode 100644 index 00000000..ae069ffe --- /dev/null +++ b/lib/legion/api/graphql/resolvers/tasks.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module GraphQL + module Resolvers + module Tasks + def self.resolve(status: nil, limit: nil) + return [] unless defined?(Legion::Data) && + Legion::Data.respond_to?(:connection) && + Legion::Data.connection + + resolve_from_data(status: status, limit: limit) + rescue StandardError => e + Legion::Logging.warn "GraphQL::Tasks#resolve failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def self.resolve_from_data(status: nil, limit: nil) + return [] unless defined?(Legion::Data::Model::Task) + + dataset = Legion::Data::Model::Task.order(Sequel.desc(:id)) + dataset = dataset.where(status: status) if status + dataset = dataset.limit(limit) if limit + dataset.all.map { |t| task_hash(t.values) } + rescue StandardError => e + Legion::Logging.warn "GraphQL::Tasks#resolve_from_data failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def self.task_hash(values) + { + id: values[:id], + status: values[:status], + extension: values[:extension], + runner: values[:runner], + function: values[:function], + created_at: values[:created_at]&.to_s, + completed_at: values[:completed_at]&.to_s + } + end + + private_class_method :resolve_from_data, :task_hash + end + end + end + end +end diff --git a/lib/legion/api/graphql/resolvers/workers.rb b/lib/legion/api/graphql/resolvers/workers.rb new file mode 100644 index 00000000..96bf6b39 --- /dev/null +++ b/lib/legion/api/graphql/resolvers/workers.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module GraphQL + module Resolvers + module Workers + def self.resolve(status: nil, risk_tier: nil, limit: nil) + if defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection + resolve_from_data(status: status, risk_tier: risk_tier, limit: limit) + else + resolve_from_registry(status: status, risk_tier: risk_tier, limit: limit) + end + end + + def self.find(id:) + if defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection + find_from_data(id: id) + else + resolve(limit: nil).find { |w| w[:id].to_s == id.to_s } + end + end + + def self.resolve_from_data(status: nil, risk_tier: nil, limit: nil) + return [] unless defined?(Legion::Data::Model::DigitalWorker) + + dataset = Legion::Data::Model::DigitalWorker.order(:id) + dataset = dataset.where(lifecycle_state: status) if status + dataset = dataset.where(risk_tier: risk_tier) if risk_tier + dataset = dataset.limit(limit) if limit + dataset.all.map { |w| worker_hash(w.values) } + rescue StandardError => e + Legion::Logging.warn "GraphQL::Workers#resolve_from_data failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def self.resolve_from_registry(status: nil, risk_tier: nil, limit: nil) + workers = [] + + if defined?(Legion::DigitalWorker::Registry) + ids = Legion::DigitalWorker::Registry.local_worker_ids + ids.each do |wid| + workers << { id: wid, name: "worker-#{wid}", status: 'active', risk_tier: nil, team: nil, extension: nil, created_at: nil } + end + end + + workers = workers.select { |w| w[:status] == status } if status + workers = workers.select { |w| w[:risk_tier] == risk_tier } if risk_tier + workers = workers.first(limit) if limit + workers + end + + def self.find_from_data(id:) + return nil unless defined?(Legion::Data::Model::DigitalWorker) + + worker = Legion::Data::Model::DigitalWorker.first(id: id.to_i) + worker ? worker_hash(worker.values) : nil + rescue StandardError => e + Legion::Logging.warn "GraphQL::Workers#find_from_data failed for id=#{id}: #{e.message}" if defined?(Legion::Logging) + nil + end + + def self.worker_hash(values) + { + id: values[:id], + name: values[:name], + status: values[:lifecycle_state] || values[:status], + risk_tier: values[:risk_tier], + team: values[:team], + extension: values[:extension_name], + created_at: values[:created_at]&.to_s + } + end + + private_class_method :resolve_from_data, :resolve_from_registry, :find_from_data, :worker_hash + end + end + end + end +end diff --git a/lib/legion/api/graphql/schema.rb b/lib/legion/api/graphql/schema.rb new file mode 100644 index 00000000..6738ad55 --- /dev/null +++ b/lib/legion/api/graphql/schema.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +return unless defined?(GraphQL) + +require_relative 'types/base_object' +require_relative 'types/node_type' +require_relative 'types/worker_type' +require_relative 'types/extension_type' +require_relative 'types/task_type' +require_relative 'resolvers/node' +require_relative 'resolvers/workers' +require_relative 'resolvers/extensions' +require_relative 'resolvers/tasks' +require_relative 'types/query_type' + +module Legion + class API < Sinatra::Base + module GraphQL + class Schema < ::GraphQL::Schema + query Types::QueryType + + max_depth 10 + max_complexity 200 + end + end + end +end diff --git a/lib/legion/api/graphql/types/base_object.rb b/lib/legion/api/graphql/types/base_object.rb new file mode 100644 index 00000000..1f09349a --- /dev/null +++ b/lib/legion/api/graphql/types/base_object.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +return unless defined?(GraphQL) + +module Legion + class API < Sinatra::Base + module GraphQL + module Types + class BaseObject < ::GraphQL::Schema::Object + end + end + end + end +end diff --git a/lib/legion/api/graphql/types/extension_type.rb b/lib/legion/api/graphql/types/extension_type.rb new file mode 100644 index 00000000..d45c86cb --- /dev/null +++ b/lib/legion/api/graphql/types/extension_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +return unless defined?(GraphQL) + +module Legion + class API < Sinatra::Base + module GraphQL + module Types + class ExtensionType < BaseObject + graphql_name 'Extension' + description 'A LegionIO extension (LEX)' + + field :name, String, null: true, description: 'Extension gem name' + field :version, String, null: true, description: 'Extension version' + field :status, String, null: true, description: 'Extension status' + field :description, String, null: true, description: 'Extension description' + field :risk_tier, String, null: true, description: 'Risk classification tier' + field :runners, [String], null: true, description: 'Runner class names' + end + end + end + end +end diff --git a/lib/legion/api/graphql/types/node_type.rb b/lib/legion/api/graphql/types/node_type.rb new file mode 100644 index 00000000..1d8f9e31 --- /dev/null +++ b/lib/legion/api/graphql/types/node_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +return unless defined?(GraphQL) + +module Legion + class API < Sinatra::Base + module GraphQL + module Types + class NodeType < BaseObject + graphql_name 'Node' + description 'A LegionIO node' + + field :name, String, null: true, description: 'Node name' + field :version, String, null: true, description: 'LegionIO version' + field :uptime, Integer, null: true, description: 'Uptime in seconds' + field :ready, Boolean, null: false, description: 'Whether the node is ready' + end + end + end + end +end diff --git a/lib/legion/api/graphql/types/query_type.rb b/lib/legion/api/graphql/types/query_type.rb new file mode 100644 index 00000000..c43cd28b --- /dev/null +++ b/lib/legion/api/graphql/types/query_type.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +return unless defined?(GraphQL) + +module Legion + class API < Sinatra::Base + module GraphQL + module Types + class QueryType < BaseObject + graphql_name 'Query' + description 'Root query type' + + # ── workers ────────────────────────────────────────────────────────── + + field :workers, [WorkerType], null: false, description: 'List digital workers' do + argument :status, String, required: false, description: 'Filter by lifecycle state' + argument :risk_tier, String, required: false, description: 'Filter by risk tier' + argument :limit, Integer, required: false, description: 'Maximum results' + end + + field :worker, WorkerType, null: true, description: 'Find a digital worker by ID' do + argument :id, ID, required: true, description: 'Worker ID' + end + + # ── extensions ─────────────────────────────────────────────────────── + + field :extensions, [ExtensionType], null: false, description: 'List loaded extensions' do + argument :status, String, required: false, description: 'Filter by status' + end + + field :extension, ExtensionType, null: true, description: 'Find an extension by name' do + argument :name, String, required: true, description: 'Extension gem name' + end + + # ── tasks ───────────────────────────────────────────────────────────── + + field :tasks, [TaskType], null: false, description: 'List task records' do + argument :status, String, required: false, description: 'Filter by status' + argument :limit, Integer, required: false, description: 'Maximum results' + end + + # ── node ────────────────────────────────────────────────────────────── + + field :node, NodeType, null: true, description: 'Current node information' + + # ── resolvers ──────────────────────────────────────────────────────── + + def workers(status: nil, risk_tier: nil, limit: nil) + Resolvers::Workers.resolve(status: status, risk_tier: risk_tier, limit: limit) + end + + def worker(id:) + Resolvers::Workers.find(id: id) + end + + def extensions(status: nil) + Resolvers::Extensions.resolve(status: status) + end + + def extension(name:) + Resolvers::Extensions.find(name: name) + end + + def tasks(status: nil, limit: nil) + Resolvers::Tasks.resolve(status: status, limit: limit) + end + + def node + Resolvers::Node.resolve + end + end + end + end + end +end diff --git a/lib/legion/api/graphql/types/task_type.rb b/lib/legion/api/graphql/types/task_type.rb new file mode 100644 index 00000000..f8e2c50a --- /dev/null +++ b/lib/legion/api/graphql/types/task_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +return unless defined?(GraphQL) + +module Legion + class API < Sinatra::Base + module GraphQL + module Types + class TaskType < BaseObject + graphql_name 'Task' + description 'A LegionIO task execution record' + + field :id, Integer, null: true, description: 'Task database ID' + field :status, String, null: true, description: 'Task status' + field :extension, String, null: true, description: 'Extension name' + field :runner, String, null: true, description: 'Runner namespace' + field :function, String, null: true, description: 'Function name' + field :created_at, String, null: true, description: 'Creation timestamp' + field :completed_at, String, null: true, description: 'Completion timestamp' + end + end + end + end +end diff --git a/lib/legion/api/graphql/types/worker_type.rb b/lib/legion/api/graphql/types/worker_type.rb new file mode 100644 index 00000000..de7e68a2 --- /dev/null +++ b/lib/legion/api/graphql/types/worker_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +return unless defined?(GraphQL) + +module Legion + class API < Sinatra::Base + module GraphQL + module Types + class WorkerType < BaseObject + graphql_name 'Worker' + description 'A LegionIO digital worker' + + field :id, Integer, null: true, description: 'Worker database ID' + field :name, String, null: true, description: 'Worker name' + field :status, String, null: true, description: 'Lifecycle state' + field :risk_tier, String, null: true, description: 'AIRB risk tier' + field :team, String, null: true, description: 'Team name' + field :extension, String, null: true, description: 'Extension name' + field :created_at, String, null: true, description: 'Creation timestamp' + end + end + end + end +end diff --git a/lib/legion/api/helpers.rb b/lib/legion/api/helpers.rb new file mode 100644 index 00000000..6fff24cc --- /dev/null +++ b/lib/legion/api/helpers.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Helpers + def json_response(data, status_code: 200) + content_type :json + status status_code + Legion::JSON.dump({ + data: data, + meta: response_meta + }) + end + + def json_collection(dataset, status_code: 200) + content_type :json + status status_code + + paginated = paginate(dataset) + items = paginated.respond_to?(:all) ? paginated.all : Array(paginated) + total = collection_total(dataset, items) + meta = response_meta.merge( + count: items.length, + limit: page_limit, + offset: page_offset + ) + meta[:total] = total unless total.nil? + meta[:has_more] = collection_has_more?(items, total) + + Legion::JSON.dump({ + data: items.map { |r| r.respond_to?(:values) ? r.values : r }, + meta: meta + }) + end + + def json_error(code, message, status_code: 400) + content_type :json + status status_code + Legion::JSON.dump({ + error: { code: code, message: message }, + meta: response_meta + }) + end + + def require_data! + return if Legion::Settings[:data][:connected] + + halt 503, json_error('data_unavailable', 'legion-data is not connected', status_code: 503) + end + + def require_scheduler! + require_data! + return if defined?(Legion::Extensions::Scheduler) + + halt 503, json_error('scheduler_unavailable', 'lex-scheduler is not loaded', status_code: 503) + end + + def require_knowledge_query! + return if defined?(Legion::Extensions::Knowledge::Runners::Query) + + halt 503, json_error('knowledge_unavailable', 'lex-knowledge is not loaded', status_code: 503) + end + + def require_knowledge_ingest! + return if defined?(Legion::Extensions::Knowledge::Runners::Ingest) + + halt 503, json_error('knowledge_unavailable', 'lex-knowledge is not loaded', status_code: 503) + end + + def require_knowledge_maintenance! + return if defined?(Legion::Extensions::Knowledge::Runners::Maintenance) + + halt 503, json_error('knowledge_unavailable', 'lex-knowledge is not loaded', status_code: 503) + end + + def require_knowledge_monitor! + return if defined?(Legion::Extensions::Knowledge::Runners::Monitor) + + halt 503, json_error('knowledge_unavailable', 'lex-knowledge is not loaded', status_code: 503) + end + + def require_mesh! + return if defined?(Legion::Extensions::Mesh) + + halt 503, json_error('mesh_unavailable', 'lex-mesh is not loaded', status_code: 503) + end + + def require_trace_search! + return if defined?(Legion::TraceSearch) && defined?(Legion::LLM) + + halt 503, json_error('trace_search_unavailable', 'TraceSearch requires LLM subsystem', status_code: 503) + end + + def parse_request_body + body = request.body.read + return {} if body.nil? || body.empty? + + Legion::JSON.load(body).transform_keys(&:to_sym) + rescue StandardError => e + Legion::Logging.warn "API#parse_request_body failed to parse JSON: #{e.message}" if defined?(Legion::Logging) + halt 400, json_error('invalid_json', 'request body is not valid JSON', status_code: 400) + end + + def find_extension_module(lex_name) + short = lex_name.delete_prefix('lex-') + short_no_sep = short.tr('-', '_').delete('_') + Legion::Extensions.loaded_extension_modules.find do |mod| + parts = mod.name&.split('::') + mod_short = parts&.last&.downcase + mod_short == short.tr('-', '_') || + mod_short == short.delete('-') || + mod_short == short_no_sep + end + end + + def find_runner_info(ext_mod, runner_name) + return nil unless ext_mod.respond_to?(:runners) + + ext_mod.runners.values.find do |r| + r[:runner_name].to_s.downcase == runner_name.downcase + end + end + + def runner_summaries(ext_mod) + return [] unless ext_mod.respond_to?(:runners) + + ext_mod.runners.values.map do |r| + functions = r[:runner_module]&.instance_methods(false)&.map(&:to_s) || [] + { name: r[:runner_name], runner_class: r[:runner_class], functions: functions } + end + end + + def halt_not_found(message) + halt 404, json_error('not_found', message, status_code: 404) + end + + def find_or_halt(model_class, id) + record = model_class[id.to_i] + halt 404, json_error('not_found', "#{model_class.name.split('::').last} #{id} not found", status_code: 404) if record.nil? + record + end + + def redact_hash(hash, sensitive_keys: %i[password secret token key cert private_key api_key]) + return hash unless hash.is_a?(Hash) + + hash.each_with_object({}) do |(k, v), result| + key_sym = k.to_sym + result[k] = if v.is_a?(Hash) + redact_hash(v, sensitive_keys: sensitive_keys) + elsif sensitive_keys.any? { |s| key_sym.to_s.include?(s.to_s) } + '[REDACTED]' + else + v + end + end + end + + def transport_subclasses(base_class) + ObjectSpace.each_object(Class) + .select { |klass| klass < base_class } + .map { |klass| { name: klass.name } } + .sort_by { |h| h[:name].to_s } + rescue NameError => e + Legion::Logging.debug "API#transport_subclasses failed for #{base_class}: #{e.message}" if defined?(Legion::Logging) + [] + end + + def build_schedule_attrs(body) + attrs = { function_id: body[:function_id].to_i, active: body.fetch(:active, true), last_run: Time.at(0) } + attrs[:cron] = body[:cron] if body[:cron] + attrs[:interval] = body[:interval].to_i if body[:interval] + attrs[:task_ttl] = body[:task_ttl].to_i if body[:task_ttl] + attrs[:payload] = Legion::JSON.dump(body[:payload] || {}) + attrs[:transformation] = body[:transformation] if body[:transformation] + attrs + end + + def build_schedule_updates(body) + updates = {} + updates[:cron] = body[:cron] if body.key?(:cron) + updates[:interval] = body[:interval].to_i if body.key?(:interval) + updates[:active] = body[:active] if body.key?(:active) + updates[:task_ttl] = body[:task_ttl].to_i if body.key?(:task_ttl) + updates[:function_id] = body[:function_id].to_i if body.key?(:function_id) + updates[:payload] = Legion::JSON.dump(body[:payload]) if body.key?(:payload) + updates[:transformation] = body[:transformation] if body.key?(:transformation) + updates + end + + def current_claims + env['legion.auth'] + end + + def current_worker_id + env['legion.worker_id'] + end + + def current_owner_msid + env['legion.owner_msid'] + end + + def authenticated? + !current_claims.nil? + end + + private + + def response_meta + meta = { + timestamp: Time.now.utc.iso8601, + node: Legion::Settings[:client][:name] + } + + if authenticated? && defined?(Legion::Identity::Request) + req = env['legion.principal'] + if req + meta[:caller] = { + canonical_name: req.canonical_name, + kind: req.kind, + source: req.source + } + end + end + + meta + end + + def page_limit + limit = (params[:limit] || 25).to_i + limit = 25 if limit < 1 + limit = 100 if limit > 100 + limit + end + + def page_offset + offset = (params[:offset] || 0).to_i + offset = 0 if offset.negative? + offset + end + + def paginate(dataset) + if dataset.respond_to?(:limit) + dataset.limit(page_limit, page_offset) + elsif dataset.is_a?(Array) + dataset.slice(page_offset, page_limit) || [] + else + dataset + end + end + + def include_total_count? + params[:include_total].to_s == 'true' + end + + def collection_total(dataset, items) + return dataset.count if include_total_count? && dataset.respond_to?(:count) + return dataset.length if dataset.respond_to?(:length) && !dataset.respond_to?(:limit) + + return page_offset + items.length if items.length < page_limit + + nil + end + + def collection_has_more?(items, total) + return (page_offset + items.length) < total if total + + items.length == page_limit + end + end + end +end diff --git a/lib/legion/api/identity_audit.rb b/lib/legion/api/identity_audit.rb new file mode 100644 index 00000000..d5a8aa5d --- /dev/null +++ b/lib/legion/api/identity_audit.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module IdentityAudit + def self.registered(app) + app.helpers IdentityAuditHelpers + + app.get '/api/identity' do + identity = defined?(Legion::Identity::Process) ? Legion::Identity::Process.identity_hash : {} + + registered_providers = if defined?(Legion::Identity::Resolver) + Legion::Identity::Resolver.providers.map do |p| + { + name: p.provider_name, + type: p.provider_type, + trust_level: p.trust_level, + priority: p.respond_to?(:priority) ? p.priority : nil, + capabilities: p.respond_to?(:capabilities) ? p.capabilities : [] + } + end + else + [] + end + + json_response(identity.merge(registered_providers: registered_providers)) + end + + app.get '/api/identity/audit' do + require_data! + halt 503, json_error('unavailable', 'identity audit log not available') unless defined?(Legion::Data::Model::Identity::AuditLog) + + dataset = Legion::Data::Model::Identity::AuditLog.dataset + + principal = params[:principal] + if principal && defined?(Legion::Data::Model::Identity::Principal) + principal_record = Legion::Data::Model::Identity::Principal.where(canonical_name: principal).first + halt 404, json_error('not_found', "principal '#{principal}' not found") unless principal_record + dataset = dataset.where(principal_id: principal_record.id) + end + + provider = params[:provider] + dataset = dataset.where(provider_name: provider) if provider + + event_type = params[:event_type] + dataset = dataset.where(event_type: event_type) if event_type + + since = params[:since] + if since + duration = parse_since_duration(since) + dataset = dataset.where { created_at >= Time.now - duration } if duration + end + + records = dataset.order(Sequel.desc(:created_at)).limit(100).all + json_collection(records.map do |r| + { id: r.id, event_type: r.event_type, provider_name: r.provider_name, + trust_level: r.trust_level, detail_payload: r.detail_payload, + node_ref: r.node_ref, session_ref: r.session_ref, created_at: r.created_at } + end) + end + end + + module IdentityAuditHelpers + def parse_since_duration(value) + return nil unless value.is_a?(String) + + case value + when /\A(\d+)h\z/ then Regexp.last_match(1).to_i * 3600 + when /\A(\d+)m\z/ then Regexp.last_match(1).to_i * 60 + when /\A(\d+)s\z/ then Regexp.last_match(1).to_i + when /\A(\d+)d\z/ then Regexp.last_match(1).to_i * 86_400 + end + end + end + end + end + end +end diff --git a/lib/legion/api/inbound_webhooks.rb b/lib/legion/api/inbound_webhooks.rb new file mode 100644 index 00000000..b54aa43d --- /dev/null +++ b/lib/legion/api/inbound_webhooks.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module InboundWebhooks + def self.registered(app) + app.post '/api/webhooks/:source' do + require 'legion/trigger' + + source_name = params[:source] + body_raw = request.body.read + body = begin + Legion::JSON.load(body_raw) + rescue StandardError + halt 400, json_error('invalid_body', 'request body must be valid JSON', status_code: 400) + end + + headers = request.env.select { |k, _| k.start_with?('HTTP_') } + + result = Legion::Trigger.process( + source_name: source_name, + headers: headers, + body_raw: body_raw, + body: body + ) + + if result[:success] + json_response(result, status_code: 202) + elsif result[:reason] == :duplicate + json_response(result, status_code: 200) + elsif result[:reason] == :unknown_source + halt 404, json_error('unknown_source', result[:error], status_code: 404) + else + halt 500, json_error('trigger_error', result[:error] || 'processing failed', status_code: 500) + end + end + + app.get '/api/webhooks/sources' do + require 'legion/trigger' + json_response({ sources: Legion::Trigger.registered_sources }) + end + end + end + end + end +end diff --git a/lib/legion/api/knowledge.rb b/lib/legion/api/knowledge.rb new file mode 100644 index 00000000..a8b06a7a --- /dev/null +++ b/lib/legion/api/knowledge.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Knowledge + def self.registered(app) + register_query_routes(app) + register_ingest_routes(app) + register_maintenance_routes(app) + register_monitor_routes(app) + end + + def self.register_query_routes(app) + app.post '/api/knowledge/query' do + require_knowledge_query! + body = parse_request_body + result = Legion::Extensions::Knowledge::Runners::Query.query( + question: body[:question], + top_k: body[:top_k] || 5, + synthesize: body.fetch(:synthesize, true) + ) + json_response(result) + end + + app.post '/api/knowledge/retrieve' do + require_knowledge_query! + body = parse_request_body + result = Legion::Extensions::Knowledge::Runners::Query.retrieve( + question: body[:question], + top_k: body[:top_k] || 5 + ) + json_response(result) + end + end + + def self.register_ingest_routes(app) + app.post '/api/knowledge/ingest' do + require_knowledge_ingest! + body = parse_request_body + + result = if body[:content] + Legion::Extensions::Knowledge::Runners::Ingest.ingest_content( + content: body[:content], + source_type: body[:source] || :text, + metadata: { tags: body[:tags] || [] } + ) + elsif body[:path] + if File.directory?(body[:path]) + Legion::Extensions::Knowledge::Runners::Ingest.ingest_corpus( + path: body[:path], + force: body[:force] || false, + dry_run: body[:dry_run] || false + ) + else + Legion::Extensions::Knowledge::Runners::Ingest.ingest_file( + file_path: body[:path], + force: body[:force] || false + ) + end + else + halt 400, json_error('missing_param', 'content or path is required') + end + json_response(result) + end + + app.post '/api/knowledge/status' do + require_knowledge_ingest! + body = parse_request_body + path = body[:path] || + Legion::Settings.dig(:knowledge, :default_corpus_path) || + ENV.fetch('LEGION_CORPUS_PATH', nil) + + if path.nil? || path.to_s.empty? + halt 400, json_error('missing_param', + 'path is required (no knowledge.default_corpus_path configured)') + end + + result = Legion::Extensions::Knowledge::Runners::Ingest.scan_corpus(path: path) + json_response(result) + end + end + + def self.register_maintenance_routes(app) + app.post '/api/knowledge/health' do + require_knowledge_maintenance! + body = parse_request_body + result = Legion::Extensions::Knowledge::Runners::Maintenance.health(path: body[:path]) + json_response(result) + end + + app.post '/api/knowledge/maintain' do + require_knowledge_maintenance! + body = parse_request_body + result = Legion::Extensions::Knowledge::Runners::Maintenance.cleanup_orphans( + path: body[:path], + dry_run: body.fetch(:dry_run, true) + ) + json_response(result) + end + + app.post '/api/knowledge/quality' do + require_knowledge_maintenance! + body = parse_request_body + result = Legion::Extensions::Knowledge::Runners::Maintenance.quality_report( + limit: body[:limit] || 10 + ) + json_response(result) + end + end + + def self.register_monitor_routes(app) + monitor_list = lambda do + require_knowledge_monitor! + monitors = Legion::Extensions::Knowledge::Runners::Monitor.list_monitors + json_response(monitors) + end + + monitor_add = lambda do + require_knowledge_monitor! + body = parse_request_body + result = Legion::Extensions::Knowledge::Runners::Monitor.add_monitor( + path: body[:path], + extensions: body[:extensions], + label: body[:label] + ) + json_response(result, status_code: 201) + end + + monitor_remove = lambda do + require_knowledge_monitor! + body = parse_request_body + result = Legion::Extensions::Knowledge::Runners::Monitor.remove_monitor( + identifier: body[:identifier] + ) + json_response(result) + end + + # Primary routes + app.get('/api/knowledge/monitors', &monitor_list) + app.post('/api/knowledge/monitors', &monitor_add) + app.delete('/api/knowledge/monitors', &monitor_remove) + + # Interlink v3 aliases + app.get('/api/extensions/knowledge/runners/monitors/list', &monitor_list) + app.post('/api/extensions/knowledge/runners/monitors/create', &monitor_add) + app.delete('/api/extensions/knowledge/runners/monitors/delete', &monitor_remove) + + # Interlink v2 aliases + app.get('/api/lex/knowledge/monitors', &monitor_list) + app.post('/api/lex/knowledge/monitors', &monitor_add) + app.delete('/api/lex/knowledge/monitors', &monitor_remove) + + app.get '/api/knowledge/monitors/status' do + require_knowledge_monitor! + result = Legion::Extensions::Knowledge::Runners::Monitor.monitor_status + json_response(result) + end + end + + class << self + private :register_query_routes, :register_ingest_routes, + :register_maintenance_routes, :register_monitor_routes + end + end + end + end +end diff --git a/lib/legion/api/lex_dispatch.rb b/lib/legion/api/lex_dispatch.rb new file mode 100644 index 00000000..5aa5ff37 --- /dev/null +++ b/lib/legion/api/lex_dispatch.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + class API < Sinatra::Base + module Routes + module LexDispatch + def self.registered(app) + register_discovery(app) + register_dispatch(app) + end + + # Discovery endpoints (GET) + def self.register_discovery(app) + # GET /api/extensions/index — list all extensions + app.get '/api/extensions/index' do + content_type :json + names = Legion::API.router.extension_names + Legion::JSON.dump({ extensions: names }) + end + + # GET /api/extensions/:lex_name/:component_type/:component_name/:method_name — full contract + app.get '/api/extensions/:lex_name/:component_type/:component_name/:method_name' do + content_type :json + entry = Legion::API.router.find_extension_route( + params[:lex_name], params[:component_type], + params[:component_name], params[:method_name] + ) + unless entry + halt 404, Legion::JSON.dump({ + task_id: nil, + conversation_id: nil, + status: 'failed', + error: { code: 404, message: 'route not found' } + }) + end + + amqp_pfx = entry[:amqp_prefix].to_s.then { |p| p.empty? ? "lex.#{params[:lex_name]}" : p } + response = { + extension: params[:lex_name], + component_type: params[:component_type], + component: params[:component_name], + method: params[:method_name], + definition: entry[:definition], + amqp: { + exchange: amqp_pfx, + routing_key: "#{amqp_pfx}.#{params[:component_type]}.#{params[:component_name]}.#{params[:method_name]}" + } + } + if params[:component_type] == 'hooks' + response[:hook_endpoint] = + "/api/extensions/#{params[:lex_name]}/hooks/#{params[:component_name]}/#{params[:method_name]}" + end + Legion::JSON.dump(response) + end + end + + # Dispatch endpoint (POST) + def self.register_dispatch(app) + dispatcher = method(:dispatch_request) + app.post '/api/extensions/:lex_name/:component_type/:component_name/:method_name' do + dispatcher.call(self, request, params) + end + end + + def self.dispatch_request(context, request, params) # rubocop:disable Metrics/MethodLength + content_type = 'application/json' + context.content_type content_type + + entry = Legion::API.router.find_extension_route( + params[:lex_name], params[:component_type], + params[:component_name], params[:method_name] + ) + + unless entry + route_key = "#{params[:lex_name]}/#{params[:component_type]}/#{params[:component_name]}/#{params[:method_name]}" + context.halt 404, Legion::JSON.dump({ + task_id: nil, + conversation_id: nil, + status: 'failed', + error: { code: 404, message: "no route registered for '#{route_key}'" } + }) + end + + envelope = build_envelope(request) + + payload = begin + body = request.body.read + body.nil? || body.empty? ? {} : Legion::JSON.load(body) + rescue StandardError => e + Legion::Logging.warn "[LexDispatch] invalid JSON body: #{e.message}" if defined?(Legion::Logging) + context.halt 400, Legion::JSON.dump({ + task_id: nil, + conversation_id: nil, + status: 'failed', + error: { code: 400, message: 'request body is not valid JSON' } + }) + end + + # Remote dispatch: when the runner class is not loaded locally, forward via AMQP + unless extension_loaded_locally?(entry) + if definition_blocks_remote?(entry) + context.halt 403, Legion::JSON.dump({ + task_id: nil, + conversation_id: nil, + status: 'failed', + error: { code: 403, message: 'Method not remotely invocable' } + }) + end + + exchange_name = entry[:amqp_prefix].to_s.then { |p| p.empty? ? "lex.#{entry[:lex_name]}" : p } + routing_key = "#{exchange_name}.#{entry[:component_type]}.#{entry[:component_name]}.#{entry[:method_name]}" + + if request.env['HTTP_X_LEGION_SYNC'] == 'true' + result = Legion::API::SyncDispatch.dispatch(exchange_name, routing_key, payload, envelope) + return Legion::JSON.dump(result) + else + unless defined?(Legion::Transport) && + Legion::Transport.respond_to?(:connected?) && + Legion::Transport.connected? + context.halt 503, Legion::JSON.dump({ + task_id: nil, + conversation_id: nil, + status: 'failed', + error: { code: 503, message: 'Transport not available' } + }) + end + + dispatch_async_amqp(exchange_name, routing_key, payload, envelope) + context.status 202 + return Legion::JSON.dump(envelope.merge(status: 'queued')) + end + end + + # Hook-aware dispatch: when component_type is 'hooks' and the runner class + # is a Hooks::Base subclass, apply verify -> route -> transform -> Ingress. + return dispatch_hook(context, request, entry, payload, envelope) if entry[:component_type] == 'hooks' && hook_base_subclass?(entry[:runner_class]) + + result = Legion::Ingress.run( + payload: payload.merge(envelope.slice(:task_id, :conversation_id, :parent_id, :master_id, :chain_id)), + runner_class: entry[:runner_class], + function: entry[:method_name].to_sym, + source: 'lex_dispatch', + generate_task: true + ) + + response_body = envelope.merge( + status: result[:status], + result: result[:result] + ).compact + + Legion::JSON.dump(response_body) + rescue StandardError => e + route_key = "#{params[:lex_name]}/#{params[:component_type]}/#{params[:component_name]}/#{params[:method_name]}" + Legion::Logging.log_exception(e, payload_summary: "LexDispatch POST #{route_key}", component_type: :api) + context.status 500 + Legion::JSON.dump({ + task_id: nil, + conversation_id: nil, + status: 'failed', + error: { code: 500, message: e.message } + }) + end + + def self.parse_header_integer(value) + return nil if value.nil? + + Integer(value) + rescue ArgumentError, TypeError + nil + end + + def self.build_envelope(request) + task_id = parse_header_integer(request.env['HTTP_X_LEGION_TASK_ID']) + conversation_id = request.env['HTTP_X_LEGION_CONVERSATION_ID'] || ::SecureRandom.uuid + parent_id = parse_header_integer(request.env['HTTP_X_LEGION_PARENT_ID']) + master_id = parse_header_integer(request.env['HTTP_X_LEGION_MASTER_ID']) + chain_id = parse_header_integer(request.env['HTTP_X_LEGION_CHAIN_ID']) + debug = request.env['HTTP_X_LEGION_DEBUG'] == 'true' + + { + task_id: task_id, + conversation_id: conversation_id, + parent_id: parent_id, + master_id: master_id || task_id, + chain_id: chain_id, + debug: debug + }.compact + end + + # Returns true when the runner class referenced by the route entry is + # available in the current process (i.e. the extension is loaded locally). + def self.extension_loaded_locally?(entry) + runner_class = entry[:runner_class] + return false if runner_class.nil? || runner_class.to_s.empty? + + # Try constant lookup — safe because runner_class is from the route registry, + # not from user input. + parts = runner_class.to_s.split('::').reject(&:empty?) + parts.reduce(Object) { |mod, name| mod.const_get(name, false) } + true + rescue NameError, TypeError + false + end + + # Returns true when the definition-level flag explicitly disables remote dispatch. + # Extension-level gate (entry[:lex_name] module) takes precedence over definition flag. + def self.definition_blocks_remote?(entry) + defn = entry[:definition] + return false if defn.nil? + + defn[:remote_invocable] == false + end + + # Publish an async AMQP message for remote dispatch (fire-and-forget). + def self.dispatch_async_amqp(exchange_name, routing_key, payload, envelope) + return unless defined?(Legion::Transport) && + Legion::Transport.respond_to?(:connected?) && + Legion::Transport.connected? + + channel = Legion::Transport.channel + exchange = channel.exchange(exchange_name, type: :topic, durable: true, passive: true) + message = Legion::JSON.dump(payload.merge(envelope)) + exchange.publish(message, routing_key: routing_key, content_type: 'application/json', persistent: true) + rescue StandardError => e + Legion::Logging.warn "[LexDispatch] async AMQP publish failed: #{e.message}" if defined?(Legion::Logging) + raise + end + + def self.hook_base_subclass?(runner_class) + return false unless defined?(Legion::Extensions::Hooks::Base) + return false if runner_class.nil? + + klass = runner_class.is_a?(Class) ? runner_class : Kernel.const_get(runner_class.to_s) + klass < Legion::Extensions::Hooks::Base + rescue NameError, TypeError + false + end + + def self.dispatch_hook(context, request, entry, payload, envelope) + hook = entry[:runner_class].new + + # Re-read body for verification (request body was already read for payload parsing) + request.body.rewind + body_for_verify = request.body.read + request.body.rewind + + unless hook.verify(request.env, body_for_verify) + context.halt 401, Legion::JSON.dump({ + task_id: nil, conversation_id: nil, status: 'failed', + error: { code: 401, message: 'hook verification failed' } + }) + end + + function = hook.route(request.env, payload) + unless function + context.halt 422, Legion::JSON.dump({ + task_id: nil, conversation_id: nil, status: 'failed', + error: { code: 422, message: 'hook could not route this event' } + }) + end + + # If the hook defines the routed function as an instance method, call it to transform + if hook.class.method_defined?(function) && hook.class.instance_method(function).owner != Legion::Extensions::Hooks::Base + transformed = hook.send(function, payload) + payload = transformed if transformed + end + + runner = hook.runner_class || entry[:runner_class] + + result = Legion::Ingress.run( + payload: payload.merge(envelope.slice(:task_id, :conversation_id, :parent_id, :master_id, :chain_id)), + runner_class: runner, + function: function, + source: 'hook', + check_subtask: true, + generate_task: true + ) + + response_body = envelope.merge( + status: result[:status], + result: result[:result] + ).compact + + Legion::JSON.dump(response_body) + end + + class << self + private :register_discovery, :register_dispatch, :dispatch_request, :parse_header_integer, + :build_envelope, :extension_loaded_locally?, :definition_blocks_remote?, :dispatch_async_amqp, + :hook_base_subclass?, :dispatch_hook + end + end + end + end +end diff --git a/lib/legion/api/library_routes.rb b/lib/legion/api/library_routes.rb new file mode 100644 index 00000000..daafb4a6 --- /dev/null +++ b/lib/legion/api/library_routes.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + # Register a library gem's route module with the tier-aware router and mount it + # on this Sinatra app. + # + # Call from the library gem's boot/start method: + # Legion::API.register_library_routes('llm', Legion::LLM::Routes) if defined?(Legion::API) + # + # @param gem_name [String] short name for the library (e.g. 'llm', 'apollo') + # @param routes_module [Module] a Sinatra::Extension module to register + def self.register_library_routes(gem_name, routes_module) + existing = router.library_routes[gem_name.to_s] + return routes_module if existing == routes_module + + router.register_library(gem_name, routes_module) + register routes_module + end + end +end diff --git a/lib/legion/api/logs.rb b/lib/legion/api/logs.rb new file mode 100644 index 00000000..b560c223 --- /dev/null +++ b/lib/legion/api/logs.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'legion/transport/exchanges/logging' + +module Legion + class API < Sinatra::Base + module Routes + module Logs + VALID_LEVELS = %w[error warn].freeze + + def self.registered(app) + register_ingest(app) + end + + def self.register_ingest(app) + app.post '/api/logs' do + body = parse_request_body + Legion::API::Routes::Logs.validate_log_request!(self, body) + + level = body[:level].to_s + source = body[:source].to_s.then { |s| s.empty? ? 'unknown' : s } + payload = Legion::API::Routes::Logs.build_log_payload(body, level, source) + key = Legion::API::Routes::Logs.routing_key_for(body, level, source) + + exchange = Legion::Transport::Exchanges::Logging.cached_instance || Legion::Transport::Exchanges::Logging.new + exchange.publish( + Legion::JSON.dump(payload), + routing_key: key, + content_type: 'application/json', + content_encoding: 'identity', + type: 'log', + persistent: true, + app_id: 'legion', + headers: { 'legion_protocol_version' => '2.0' } + ) + + json_response({ published: true, routing_key: key }, status_code: 201) + rescue StandardError => e + Legion::Logging.error "API POST /api/logs: #{e.class} - #{e.message}" if defined?(Legion::Logging) + halt 500, json_error('publish_error', e.message, status_code: 500) + end + end + + def self.validate_log_request!(ctx, body) + unless VALID_LEVELS.include?(body[:level].to_s) + Legion::Logging.warn 'API POST /api/logs returned 422: level must be error or warn' if defined?(Legion::Logging) + ctx.halt 422, ctx.json_error('invalid_level', 'level must be "error" or "warn"', status_code: 422) + end + + return unless body[:message].to_s.strip.empty? + + Legion::Logging.warn 'API POST /api/logs returned 422: message is required' if defined?(Legion::Logging) + ctx.halt 422, ctx.json_error('missing_field', 'message is required', status_code: 422) + end + + def self.build_log_payload(body, level, source) + payload = { + level: level, + message: body[:message].to_s, + timestamp: Time.now.utc.iso8601(3), + node: Legion::Settings[:client][:name], + legion_versions: Legion::Logging::EventBuilder.send(:legion_versions), + ruby_version: "#{RUBY_VERSION} #{RUBY_PLATFORM}", + pid: ::Process.pid, + component_type: body[:component_type].to_s.then { |t| t.empty? ? 'cli' : t }, + source: source + } + payload[:exception_class] = body[:exception_class] if body[:exception_class] + payload[:backtrace] = body[:backtrace] if body[:backtrace] + payload[:command] = body[:command] if body[:command] + payload[:error_fingerprint] = fingerprint_for(body, payload) if body[:exception_class] + payload + end + + def self.fingerprint_for(body, payload) + Legion::Logging::EventBuilder.fingerprint( + exception_class: body[:exception_class].to_s, + message: body[:message].to_s, + caller_file: '', + caller_line: 0, + caller_function: '', + gem_name: '', + component_type: payload[:component_type], + backtrace: Array(body[:backtrace]) + ) + end + + def self.routing_key_for(body, level, source) + kind = body[:exception_class] ? 'exception' : 'log' + "legion.logging.#{kind}.#{level}.cli.#{source}" + end + + class << self + private :register_ingest, :fingerprint_for + end + end + end + end +end diff --git a/lib/legion/api/marketplace.rb b/lib/legion/api/marketplace.rb new file mode 100644 index 00000000..51a20665 --- /dev/null +++ b/lib/legion/api/marketplace.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'date' +require 'legion/registry' + +module Legion + class API < Sinatra::Base + module Routes + module Marketplace + module Helpers + def parse_sunset_date(date_str) + return nil if date_str.nil? || date_str.empty? + + Date.parse(date_str.to_s) + rescue ArgumentError => e + Legion::Logging.debug "Marketplace#parse_sunset_date invalid date '#{date_str}': #{e.message}" if defined?(Legion::Logging) + nil + end + end + + def self.registered(app) + app.helpers Helpers + register_collection(app) + register_member(app) + register_review_actions(app) + register_stats(app) + end + + def self.register_collection(app) + app.get '/api/marketplace' do + query = params[:q] || params[:query] + entries = query ? Legion::Registry.search(query) : Legion::Registry.all + entries = entries.select { |e| e.status.to_s == params[:status] } if params[:status] + entries = entries.select { |e| e.risk_tier == params[:tier] } if params[:tier] + + paginated = entries.slice((page_offset)..(page_offset + page_limit - 1)) || [] + content_type :json + status 200 + Legion::JSON.dump({ + data: paginated.map(&:to_h), + meta: response_meta.merge(total: entries.size, limit: page_limit, offset: page_offset) + }) + end + end + + def self.register_member(app) + app.get '/api/marketplace/:name' do + entry = Legion::Registry.lookup(params[:name]) + unless entry + halt 404, { 'Content-Type' => 'application/json' }, + Legion::JSON.dump({ error: { code: 'not_found', message: "Extension #{params[:name]} not found" } }) + end + + json_response(entry.to_h.merge(stats: Legion::Registry.usage_stats(params[:name]))) + end + end + + def self.register_review_actions(app) # rubocop:disable Metrics/AbcSize + app.post '/api/marketplace/:name/submit' do + begin + Legion::Registry.submit_for_review(params[:name]) + rescue ArgumentError => e + Legion::Logging.warn "API POST /api/marketplace/#{params[:name]}/submit: #{e.message}" if defined?(Legion::Logging) + halt 404, { 'Content-Type' => 'application/json' }, + Legion::JSON.dump({ error: { code: 'not_found', message: e.message } }) + end + json_response({ name: params[:name], status: 'pending_review' }, status_code: 202) + end + + app.post '/api/marketplace/:name/approve' do + body = parse_request_body + begin + Legion::Registry.approve(params[:name], notes: body[:notes]) + rescue ArgumentError => e + Legion::Logging.warn "API POST /api/marketplace/#{params[:name]}/approve: #{e.message}" if defined?(Legion::Logging) + halt 404, { 'Content-Type' => 'application/json' }, + Legion::JSON.dump({ error: { code: 'not_found', message: e.message } }) + end + entry = Legion::Registry.lookup(params[:name]) + json_response({ name: params[:name], status: 'approved', entry: entry.to_h }) + end + + app.post '/api/marketplace/:name/reject' do + body = parse_request_body + begin + Legion::Registry.reject(params[:name], reason: body[:reason]) + rescue ArgumentError => e + Legion::Logging.warn "API POST /api/marketplace/#{params[:name]}/reject: #{e.message}" if defined?(Legion::Logging) + halt 404, { 'Content-Type' => 'application/json' }, + Legion::JSON.dump({ error: { code: 'not_found', message: e.message } }) + end + entry = Legion::Registry.lookup(params[:name]) + json_response({ name: params[:name], status: 'rejected', entry: entry.to_h }) + end + + app.post '/api/marketplace/:name/deprecate' do + body = parse_request_body + sunset = begin + body[:sunset_date] ? Date.parse(body[:sunset_date].to_s) : nil + rescue ArgumentError => e + Legion::Logging.debug "Marketplace#deprecate invalid sunset_date '#{body[:sunset_date]}': #{e.message}" if defined?(Legion::Logging) + nil + end + begin + Legion::Registry.deprecate(params[:name], successor: body[:successor], sunset_date: sunset) + rescue ArgumentError => e + Legion::Logging.warn "API POST /api/marketplace/#{params[:name]}/deprecate: #{e.message}" if defined?(Legion::Logging) + halt 404, { 'Content-Type' => 'application/json' }, + Legion::JSON.dump({ error: { code: 'not_found', message: e.message } }) + end + entry = Legion::Registry.lookup(params[:name]) + json_response({ name: params[:name], status: 'deprecated', entry: entry.to_h }) + end + end + + def self.register_stats(app) + app.get '/api/marketplace/:name/stats' do + data = Legion::Registry.usage_stats(params[:name]) + unless data + halt 404, { 'Content-Type' => 'application/json' }, + Legion::JSON.dump({ error: { code: 'not_found', message: "Extension #{params[:name]} not found" } }) + end + + json_response(data) + end + end + end + end + end +end diff --git a/lib/legion/api/mesh.rb b/lib/legion/api/mesh.rb new file mode 100644 index 00000000..992b4d6c --- /dev/null +++ b/lib/legion/api/mesh.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Mesh + @cache = {} + @cache_mutex = Mutex.new + MESH_CACHE_TTL = 10 + + def self.cached_fetch(key) + @cache_mutex.synchronize do + entry = @cache[key] + return entry[:data] if entry && (Time.now - entry[:at]) < MESH_CACHE_TTL + end + + data = yield + @cache_mutex.synchronize { @cache[key] = { data: data, at: Time.now } } + data + end + + def self.registered(app) + app.get '/api/mesh/status' do + require_mesh! + result = Mesh.cached_fetch(:status) do + Legion::Ingress.run( + runner_class: 'Legion::Extensions::Mesh::Runners::Mesh', + function: 'mesh_status', + source: :api, + payload: {} + ) + end + json_response(result) + rescue StandardError => e + Legion::Logging.log_exception(e, payload_summary: 'GET /api/mesh/status', component_type: :api) + json_error('mesh_error', e.message, status_code: 500) + end + + app.get '/api/mesh/peers' do + require_mesh! + result = Mesh.cached_fetch(:peers) do + Legion::Ingress.run( + runner_class: 'Legion::Extensions::Mesh::Runners::Mesh', + function: 'find_agents', + source: :api, + payload: { capability: nil } + ) + end + json_response(result) + rescue StandardError => e + Legion::Logging.log_exception(e, payload_summary: 'GET /api/mesh/peers', component_type: :api) + json_error('mesh_error', e.message, status_code: 500) + end + end + end + end + end +end diff --git a/lib/legion/api/metering.rb b/lib/legion/api/metering.rb new file mode 100644 index 00000000..8f9396b2 --- /dev/null +++ b/lib/legion/api/metering.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Metering + def self.registered(app) + register_helpers(app) + register_summary_route(app) + register_rollup_route(app) + register_by_model_route(app) + end + + def self.register_helpers(app) + app.helpers do + define_method(:require_metering!) do + return if defined?(Legion::Extensions::Metering::Runners::Metering) + + halt 503, json_error('metering_unavailable', 'lex-metering is not loaded', status_code: 503) + end + + define_method(:metering_table?) do + defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && + Legion::Data.connected? && Legion::Data.connection.table_exists?(:metering_records) + end + end + end + + def self.register_summary_route(app) + app.get '/api/metering' do + require_metering! + unless metering_table? + return json_response({ total_cost_usd: 0.0, total_tokens: 0, total_requests: 0, + note: 'metering_records table not available' }) + end + + ds = Legion::Data.connection[:metering_records] + json_response({ + total_cost_usd: (ds.sum(:cost_usd) || 0).to_f, + total_tokens: (ds.sum(:total_tokens) || 0).to_i, + total_requests: ds.count + }) + rescue StandardError => e + Legion::Logging.log_exception(e, payload_summary: 'GET /api/metering', component_type: :api) + json_response({ total_cost_usd: 0.0, total_tokens: 0, total_requests: 0, error: e.message }) + end + end + + def self.register_rollup_route(app) + app.get '/api/metering/rollup' do + require_metering! + return json_response({ rollup: [], period: 'hourly', note: 'metering_records table not available' }) unless metering_table? + + return json_response({ rollup: [], period: 'hourly' }) unless defined?(Legion::Extensions::Metering::Runners::Rollup) + + result = Legion::Extensions::Metering::Runners::Rollup.rollup_hour + json_response(result) + rescue StandardError => e + Legion::Logging.log_exception(e, payload_summary: 'GET /api/metering/rollup', component_type: :api) + json_response({ rollup: [], period: 'hourly', error: e.message }) + end + end + + def self.register_by_model_route(app) + app.get '/api/metering/by_model' do + require_metering! + return json_response({ models: [], note: 'metering_records table not available' }) unless metering_table? + + ds = Legion::Data.connection[:metering_records] + models = ds.group(:model_id).select_append do + [count.as(:call_count), + sum(total_tokens).as(:total_tokens), + sum(cost_usd).as(:total_cost), + avg(latency_ms).as(:avg_latency_ms)] + end.all + + json_response({ models: models }) + rescue StandardError => e + Legion::Logging.log_exception(e, payload_summary: 'GET /api/metering/by_model', component_type: :api) + json_response({ models: [], error: e.message }) + end + end + + private_class_method :register_helpers, :register_summary_route, :register_rollup_route, :register_by_model_route + end + end + end +end diff --git a/lib/legion/api/metrics.rb b/lib/legion/api/metrics.rb new file mode 100644 index 00000000..62e276f1 --- /dev/null +++ b/lib/legion/api/metrics.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Metrics + def self.registered(app) + app.get '/metrics' do + unless defined?(Legion::Metrics) && Legion::Metrics.available? + content_type 'text/plain' + halt 404, 'prometheus-client gem not available' + end + + Legion::Metrics.refresh_gauges + content_type 'text/plain; version=0.0.4; charset=utf-8' + Legion::Metrics.render + end + end + end + end + end +end diff --git a/lib/legion/api/middleware/api_version.rb b/lib/legion/api/middleware/api_version.rb new file mode 100644 index 00000000..63b8b7f8 --- /dev/null +++ b/lib/legion/api/middleware/api_version.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Legion + class API < Sinatra::Base + module Middleware + class ApiVersion + SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics].freeze + + def initialize(app) + @app = app + end + + def call(env) + path = env['PATH_INFO'] + + if path.start_with?('/api/v1/') + env['PATH_INFO'] = path.sub('/api/v1/', '/api/') + env['HTTP_X_API_VERSION'] = '1' + @app.call(env) + elsif path.start_with?('/api/') && !skip_path?(path) + status, headers, body = @app.call(env) + headers['Deprecation'] = 'true' + headers['Sunset'] = (Time.now + (180 * 86_400)).httpdate + successor = path.sub('/api/', '/api/v1/') + headers['Link'] = "<#{successor}>; rel=\"successor-version\"" + [status, headers, body] + else + @app.call(env) + end + end + + private + + def skip_path?(path) + SKIP_PATHS.any? { |skip| path.start_with?(skip) } + end + end + end + end +end diff --git a/lib/legion/api/middleware/auth.rb b/lib/legion/api/middleware/auth.rb new file mode 100644 index 00000000..6f19b64c --- /dev/null +++ b/lib/legion/api/middleware/auth.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Middleware + class Auth + SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics /api/auth/token /api/auth/worker-token + /api/auth/authorize /api/auth/callback /api/auth/negotiate].freeze + AUTH_HEADER = 'HTTP_AUTHORIZATION' + BEARER_PATTERN = /\ABearer\s+(.+)\z/i + NEGOTIATE_PATTERN = /\ANegotiate\s+(.+)\z/i + API_KEY_HEADER = 'HTTP_X_API_KEY' + + def initialize(app, opts = {}) + @app = app + @enabled = opts.fetch(:enabled, false) + @signing_key = opts[:signing_key] + @api_keys = opts.fetch(:api_keys, {}) + end + + def call(env) + return @app.call(env) unless @enabled + return @app.call(env) if skip_path?(env['PATH_INFO']) + + # Try Negotiate/SPNEGO first (Kerberos) + result = try_negotiate(env) + return result if result + + # Try Bearer JWT first + token = extract_token(env) + if token + claims = verify_token(token) + if claims + env['legion.auth'] = claims + env['legion.auth_method'] = 'jwt' + env['legion.worker_id'] = claims[:worker_id] + env['legion.owner_msid'] = claims[:sub] || claims[:owner_msid] + return @app.call(env) + end + Legion::Logging.warn "API auth failure: invalid or expired JWT token for #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" if defined?(Legion::Logging) + return unauthorized('invalid or expired token') + end + + # Try API key + api_key = extract_api_key(env) + if api_key + key_meta = verify_api_key(api_key) + if key_meta + env['legion.auth'] = key_meta + env['legion.auth_method'] = 'api_key' + env['legion.worker_id'] = key_meta[:worker_id] + env['legion.owner_msid'] = key_meta[:owner_msid] + return @app.call(env) + end + Legion::Logging.warn "API auth failure: invalid API key for #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" if defined?(Legion::Logging) + return unauthorized('invalid API key') + end + + Legion::Logging.warn "API auth failure: missing Authorization header for #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" if defined?(Legion::Logging) + unauthorized('missing Authorization header') + end + + private + + def try_negotiate(env) + negotiate_token = extract_negotiate_token(env) + return nil unless negotiate_token + + negotiate_result = verify_negotiate(negotiate_token) + unless negotiate_result + return kerberos_available? ? unauthorized('Kerberos authentication failed') : nil + end + + env['legion.auth'] = negotiate_result[:claims] + env['legion.auth_method'] = 'kerberos' + env['legion.owner_msid'] = negotiate_result[:claims][:sub] + status, app_headers, body = @app.call(env) + response_headers = app_headers.dup + response_headers['WWW-Authenticate'] = "Negotiate #{negotiate_result[:output_token]}" if negotiate_result[:output_token] + [status, response_headers, body] + end + + def skip_path?(path) + SKIP_PATHS.any? { |p| path.start_with?(p) } + end + + def extract_negotiate_token(env) + header = env[AUTH_HEADER] + return nil unless header + + match = header.match(NEGOTIATE_PATTERN) + match&.captures&.first + end + + def verify_negotiate(token) + return nil unless kerberos_available? + + client = Legion::Extensions::Kerberos::Client.new + auth_result = client.authenticate(token: token) + return nil unless auth_result[:success] + + claims = Legion::Rbac::KerberosClaimsMapper.map_with_fallback( + principal: auth_result[:principal], + groups: auth_result[:groups] || [], + role_map: kerberos_role_map, + fallback: kerberos_fallback + ) + + { claims: claims, output_token: auth_result[:output_token] } + rescue StandardError => e + Legion::Logging.warn "Auth#verify_negotiate failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def kerberos_available? + defined?(Legion::Extensions::Kerberos::Client) && + defined?(Legion::Rbac::KerberosClaimsMapper) + end + + def kerberos_role_map + return {} unless defined?(Legion::Settings) + + Legion::Settings.dig(:kerberos, :role_map) || {} + rescue StandardError => e + Legion::Logging.debug "Auth#kerberos_role_map failed: #{e.message}" if defined?(Legion::Logging) + {} + end + + def kerberos_fallback + return :entra unless defined?(Legion::Settings) + + Legion::Settings.dig(:kerberos, :fallback) || :entra + rescue StandardError => e + Legion::Logging.debug "Auth#kerberos_fallback failed: #{e.message}" if defined?(Legion::Logging) + :entra + end + + def extract_api_key(env) + env[API_KEY_HEADER] + end + + def verify_api_key(key) + return nil unless @api_keys.is_a?(Hash) + + @api_keys[key] + end + + def extract_token(env) + header = env[AUTH_HEADER] + return nil unless header + + match = header.match(BEARER_PATTERN) + match&.captures&.first + end + + def verify_token(token) + key = @signing_key || default_signing_key + return nil unless key + + Legion::Crypt::JWT.verify(token, verification_key: key) + rescue Legion::Crypt::JWT::Error => e + Legion::Logging.debug "Auth#verify_token failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def default_signing_key + return Legion::Crypt.cluster_secret if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:cluster_secret) + + nil + end + + def unauthorized(message) + body = Legion::JSON.dump({ error: { code: 401, message: message }, meta: { timestamp: Time.now.utc.iso8601 } }) + [401, { 'content-type' => 'application/json' }, [body]] + rescue StandardError => e + Legion::Logging.warn "Auth#unauthorized JSON serialization failed: #{e.message}" if defined?(Legion::Logging) + [401, { 'content-type' => 'application/json' }, ["{\"error\":{\"code\":401,\"message\":\"#{message}\"}}"]] + end + end + end + end +end diff --git a/lib/legion/api/middleware/body_limit.rb b/lib/legion/api/middleware/body_limit.rb new file mode 100644 index 00000000..61886b73 --- /dev/null +++ b/lib/legion/api/middleware/body_limit.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module Legion + class API < Sinatra::Base + module Middleware + class BodyLimit + MAX_BODY_SIZE = 1_048_576 # 1MB + + def initialize(app, max_size: MAX_BODY_SIZE) + @app = app + @max_size = max_size + end + + def call(env) + content_length = env['CONTENT_LENGTH'].to_i + if content_length > @max_size + if defined?(Legion::Logging) + Legion::Logging.warn "API body limit exceeded: #{content_length} bytes > #{@max_size} for #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" + end + body = Legion::JSON.dump({ + error: { code: 'payload_too_large', + message: "request body exceeds #{@max_size} bytes" }, + meta: { timestamp: Time.now.utc.iso8601 } + }) + return [413, { 'content-type' => 'application/json' }, [body]] + end + @app.call(env) + end + end + end + end +end diff --git a/lib/legion/api/middleware/rate_limit.rb b/lib/legion/api/middleware/rate_limit.rb new file mode 100644 index 00000000..cbe9b457 --- /dev/null +++ b/lib/legion/api/middleware/rate_limit.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'sinatra/base' +require 'concurrent-ruby' + +module Legion + class API < Sinatra::Base + module Middleware + class RateLimit + SKIP_PATHS = %w[/api/health /api/ready /api/metrics /api/openapi.json].freeze + WINDOW_SIZE = 60 + + class MemoryStore + def initialize + @counters = Concurrent::Hash.new + end + + def increment(key, window) + composite = "#{key}:#{window}" + @counters[composite] = (@counters[composite] || 0) + 1 + end + + def count(key, window) + @counters["#{key}:#{window}"] || 0 + end + + def reap! + cutoff = (Time.now.to_i / WINDOW_SIZE * WINDOW_SIZE) - (WINDOW_SIZE * 2) + @counters.each_key do |k| + window = k.split(':').last.to_i + @counters.delete(k) if window < cutoff + end + end + end + + class CacheStore + def increment(key, window) + cache_key = "legion:ratelimit:#{key}:#{window}" + current = Legion::Cache.get(cache_key).to_i + Legion::Cache.set(cache_key, current + 1, ttl: 120) + current + 1 + end + + def count(key, window) + Legion::Cache.get("legion:ratelimit:#{key}:#{window}").to_i + end + + def reap!; end + end + + def initialize(app, **opts) + @app = app + @enabled = opts.fetch(:enabled, true) + @limits = { + per_ip: opts.fetch(:per_ip, 60), + per_agent: opts.fetch(:per_agent, 300), + per_tenant: opts.fetch(:per_tenant, 3000) + } + @store = select_store + @reap_counter = 0 + end + + def call(env) + return @app.call(env) unless @enabled + return @app.call(env) if skip_path?(env['PATH_INFO']) + + result = check_limits(env) + if result[:limited] + rate_limit_response(result) + else + status, headers, body = @app.call(env) + [status, headers.merge(rate_limit_headers(result)), body] + end + rescue StandardError => e + Legion::Logging.warn "RateLimit#call failed, passing through: #{e.message}" if defined?(Legion::Logging) + @app.call(env) + end + + private + + def select_store + if defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected? + CacheStore.new + else + MemoryStore.new + end + end + + def skip_path?(path) + SKIP_PATHS.any? { |p| path.start_with?(p) } + end + + def current_window + Time.now.to_i / WINDOW_SIZE * WINDOW_SIZE + end + + def check_limits(env) + window = current_window + reset_at = window + WINDOW_SIZE + most_restrictive = { limited: false, limit: 0, remaining: 0, reset: reset_at } + + ip = env['REMOTE_ADDR'] || 'unknown' + ip_count = @store.increment("ip:#{ip}", window) + update_most_restrictive(most_restrictive, ip_count, @limits[:per_ip], reset_at) + + worker_id = env['legion.worker_id'] + if worker_id + agent_count = @store.increment("agent:#{worker_id}", window) + update_most_restrictive(most_restrictive, agent_count, @limits[:per_agent], reset_at) + end + + owner_msid = env['legion.owner_msid'] + if owner_msid + tenant_count = @store.increment("tenant:#{owner_msid}", window) + update_most_restrictive(most_restrictive, tenant_count, @limits[:per_tenant], reset_at) + end + + lazy_reap! + most_restrictive + end + + def update_most_restrictive(result, count, limit, reset_at) + remaining = [limit - count, 0].max + if count > limit + result[:limited] = true + result[:limit] = limit + result[:remaining] = 0 + result[:reset] = reset_at + elsif result[:limit].zero? || remaining < result[:remaining] + result[:limit] = limit + result[:remaining] = remaining + result[:reset] = reset_at + end + end + + def lazy_reap! + @reap_counter += 1 + return unless @reap_counter >= 100 + + @reap_counter = 0 + @store.reap! + end + + def rate_limit_headers(result) + { + 'X-RateLimit-Limit' => result[:limit].to_s, + 'X-RateLimit-Remaining' => result[:remaining].to_s, + 'X-RateLimit-Reset' => result[:reset].to_s + } + end + + def rate_limit_response(result) + retry_after = [result[:reset] - Time.now.to_i, 1].max + Legion::Logging.warn "API rate limit exceeded: limit=#{result[:limit]} retry_after=#{retry_after}s" if defined?(Legion::Logging) + body = Legion::JSON.dump({ + error: { code: 'rate_limit_exceeded', + message: "Rate limit exceeded. Try again after #{retry_after} seconds." }, + meta: { timestamp: Time.now.utc.iso8601 } + }) + headers = rate_limit_headers(result).merge( + 'content-type' => 'application/json', + 'Retry-After' => retry_after.to_s + ) + [429, headers, [body]] + end + end + end + end +end diff --git a/lib/legion/api/middleware/request_logger.rb b/lib/legion/api/middleware/request_logger.rb new file mode 100644 index 00000000..f7ebdc02 --- /dev/null +++ b/lib/legion/api/middleware/request_logger.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Middleware + class RequestLogger + def initialize(app) + @app = app + end + + def call(env) + method_path = "#{env['REQUEST_METHOD']} #{env['PATH_INFO']}" + client_info = build_client_info(env) + Legion::Logging.info "[api][request-start] #{method_path} #{client_info}" + start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + status, headers, body = @app.call(env) + duration = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) * 1000).round(2) + + level = duration > 5000 ? :warn : :info + Legion::Logging.send(level, "[api] #{method_path} #{status} #{duration}ms #{client_info}") + [status, headers, body] + rescue StandardError => e + duration = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start) * 1000).round(2) + Legion::Logging.error "[api] #{method_path} 500 #{duration}ms #{client_info} - #{e.message}" + raise + end + + private + + def build_client_info(env) + ip = env['HTTP_X_FORWARDED_FOR'] || env['REMOTE_ADDR'] || '-' + ua = env['HTTP_USER_AGENT'] || '-' + origin = env['HTTP_ORIGIN'] || '-' + referer = env['HTTP_REFERER'] || '-' + auth = env['HTTP_AUTHORIZATION'] ? 'Bearer(present)' : 'none' + content_type = env['CONTENT_TYPE'] || '-' + content_length = env['CONTENT_LENGTH'] || '-' + query = env['QUERY_STRING'] && env['QUERY_STRING'].empty? ? nil : env['QUERY_STRING'] + + parts = [ + "ip=#{ip}", + "ua=#{ua}", + "origin=#{origin}", + "referer=#{referer}", + "auth=#{auth}", + "content_type=#{content_type}", + "content_length=#{content_length}" + ] + parts << "query=#{query}" if query + parts.join(' ') + end + + def peek_body(env) + input = env['rack.input'] + return '-' unless input.respond_to?(:read) && input.respond_to?(:rewind) + + begin + input.rewind + raw = input.read(1024) + raw.to_s.gsub(/\s+/, ' ')[0, 512] + rescue StandardError + '-' + ensure + begin + input.rewind + rescue StandardError + nil + end + end + end + end + end + end +end diff --git a/lib/legion/api/middleware/tenant.rb b/lib/legion/api/middleware/tenant.rb new file mode 100644 index 00000000..5f5308f9 --- /dev/null +++ b/lib/legion/api/middleware/tenant.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Middleware + class Tenant + SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics].freeze + + def initialize(app, opts = {}) + @app = app + @opts = opts + end + + def call(env) + return @app.call(env) if skip_path?(env['PATH_INFO']) + + tenant_id = extract_tenant(env) + if tenant_id + Legion::Logging.debug "API tenant: resolved tenant_id=#{tenant_id} for #{env['REQUEST_METHOD']} #{env['PATH_INFO']}" if defined?(Legion::Logging) + Legion::TenantContext.set(tenant_id) + end + @app.call(env) + ensure + Legion::TenantContext.clear + end + + private + + def skip_path?(path) + SKIP_PATHS.any? { |sp| path.start_with?(sp) } + end + + def extract_tenant(env) + env['legion.tenant_id'] || env['HTTP_X_TENANT_ID'] + end + end + end + end +end diff --git a/lib/legion/api/nodes.rb b/lib/legion/api/nodes.rb new file mode 100644 index 00000000..bc35b057 --- /dev/null +++ b/lib/legion/api/nodes.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Nodes + def self.registered(app) + app.get '/api/nodes' do + require_data! + dataset = Legion::Data::Model::Node.order(:id) + dataset = dataset.where(active: true) if params[:active] == 'true' + dataset = dataset.where(status: params[:status]) if params[:status] + json_collection(dataset) + end + + app.get '/api/nodes/:id' do + require_data! + node = find_or_halt(Legion::Data::Model::Node, params[:id]) + json_response(node.values) + end + end + end + end + end +end diff --git a/lib/legion/api/openapi.rb b/lib/legion/api/openapi.rb new file mode 100644 index 00000000..a8b5ae1f --- /dev/null +++ b/lib/legion/api/openapi.rb @@ -0,0 +1,1721 @@ +# frozen_string_literal: true + +require 'legion/json' +require 'legion/api/events' + +module Legion + class API < Sinatra::Base + module OpenAPI + META_SCHEMA = { + type: 'object', + properties: { + timestamp: { type: 'string', format: 'date-time' }, + node: { type: 'string' } + }, + required: %w[timestamp node] + }.freeze + + META_COLLECTION_SCHEMA = { + type: 'object', + properties: { + timestamp: { type: 'string', format: 'date-time' }, + node: { type: 'string' }, + total: { type: 'integer' }, + limit: { type: 'integer' }, + offset: { type: 'integer' } + }, + required: %w[timestamp node total limit offset] + }.freeze + + ERROR_SCHEMA = { + type: 'object', + properties: { + error: { + type: 'object', + properties: { + code: { type: 'string' }, + message: { type: 'string' } + }, + required: %w[code message] + }, + meta: META_SCHEMA + }, + required: %w[error meta] + }.freeze + + PAGINATION_PARAMS = [ + { + name: 'limit', + in: 'query', + description: 'Maximum number of records to return (1-100, default 25)', + required: false, + schema: { type: 'integer', minimum: 1, maximum: 100, default: 25 } + }, + { + name: 'offset', + in: 'query', + description: 'Number of records to skip', + required: false, + schema: { type: 'integer', minimum: 0, default: 0 } + } + ].freeze + + NOT_FOUND_RESPONSE = { + description: 'Not found', + content: { 'application/json' => { schema: { '$ref' => '#/components/schemas/ErrorResponse' } } } + }.freeze + + UNAUTH_RESPONSE = { + description: 'Unauthorized', + content: { 'application/json' => { schema: { '$ref' => '#/components/schemas/ErrorResponse' } } } + }.freeze + + UNPROCESSABLE_RESPONSE = { + description: 'Unprocessable entity', + content: { 'application/json' => { schema: { '$ref' => '#/components/schemas/ErrorResponse' } } } + }.freeze + + NOT_IMPL_RESPONSE = { + description: 'Not implemented', + content: { 'application/json' => { schema: { '$ref' => '#/components/schemas/ErrorResponse' } } } + }.freeze + + def self.spec + { + openapi: '3.1.0', + info: info_block, + servers: [{ url: 'http://localhost:4567', description: 'Local Legion daemon' }], + security: [{ BearerAuth: [] }, { ApiKeyAuth: [] }], + tags: tags, + paths: paths, + components: components + } + end + + def self.to_json + require 'json' + ::JSON.generate(spec) + end + + # --- private helpers --- + + def self.info_block + { + title: 'LegionIO REST API', + description: 'Async job engine and digital worker platform REST API. ' \ + 'All routes are under the /api/ prefix. ' \ + 'Success responses wrap data in { data: ..., meta: { timestamp:, node: } }. ' \ + 'Error responses use { error: { code:, message: }, meta: ... }.', + version: Legion::VERSION, + contact: { name: 'LegionIO', url: 'https://github.com/LegionIO/LegionIO' }, + license: { name: 'Apache-2.0', url: 'https://www.apache.org/licenses/LICENSE-2.0' } + } + end + private_class_method :info_block + + def self.tags + [ + { name: 'Health', description: 'Health and readiness probes' }, + { name: 'Tasks', description: 'Task management and execution' }, + { name: 'Extensions', description: 'Extension, runner, and function discovery' }, + { name: 'Nodes', description: 'Node registry' }, + { name: 'Schedules', description: 'Cron/interval schedule management (requires lex-scheduler)' }, + { name: 'Relationships', description: 'Task relationships (stub, 501)' }, + { name: 'Chains', description: 'Task chains (stub, 501)' }, + { name: 'Settings', description: 'Runtime configuration' }, + { name: 'Events', description: 'SSE event stream and recent event buffer' }, + { name: 'Transport', description: 'RabbitMQ transport status and publish' }, + { name: 'Hooks', description: 'Extension webhook endpoints' }, + { name: 'Lex', description: 'Auto-registered LEX runner routes' }, + { name: 'Workers', description: 'Digital worker lifecycle management' }, + { name: 'Teams', description: 'Team-level worker and cost views' }, + { name: 'Coldstart', description: 'Cold-start memory ingestion (requires lex-coldstart + lex-agentic-memory)' }, + { name: 'Gaia', description: 'Gaia cognitive layer status' }, + { name: 'Apollo', description: 'Apollo knowledge graph (requires lex-apollo + legion-data)' }, + { name: 'OpenAPI', description: 'OpenAPI spec endpoint' } + ] + end + private_class_method :tags + + def self.paths + {}.merge(health_paths) + .merge(task_paths) + .merge(extension_paths) + .merge(node_paths) + .merge(schedule_paths) + .merge(relationship_paths) + .merge(chain_paths) + .merge(settings_paths) + .merge(event_paths) + .merge(transport_paths) + .merge(hook_paths) + .merge(lex_paths) + .merge(worker_paths) + .merge(team_paths) + .merge(coldstart_paths) + .merge(gaia_paths) + .merge(apollo_paths) + .merge(openapi_paths) + .merge(stats_paths) + end + private_class_method :paths + + def self.components + { + securitySchemes: { + BearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'Legion-issued JWT token (worker or human scope)' + }, + ApiKeyAuth: { + type: 'apiKey', + in: 'header', + name: 'X-API-Key', + description: 'Pre-shared API key' + } + }, + schemas: { + Meta: META_SCHEMA, + MetaCollection: META_COLLECTION_SCHEMA, + ErrorResponse: ERROR_SCHEMA, + DeletedResponse: deleted_response_schema, + TaskObject: task_object_schema, + TaskInput: task_input_schema, + ExtensionObject: extension_object_schema, + RunnerObject: runner_object_schema, + FunctionObject: function_object_schema, + AvailableExtensionObject: available_extension_object_schema, + NodeObject: node_object_schema, + ScheduleObject: schedule_object_schema, + ScheduleInput: schedule_input_schema, + RelationshipObject: stub_object_schema('Relationship'), + ChainObject: stub_object_schema('Chain'), + WorkerObject: worker_object_schema, + WorkerInput: worker_input_schema + } + } + end + private_class_method :components + + # --- schema helpers --- + + def self.deleted_response_schema + { type: 'object', properties: { data: { type: 'object', properties: { deleted: { type: 'boolean' } } }, meta: META_SCHEMA } } + end + private_class_method :deleted_response_schema + + def self.task_object_schema + { + type: 'object', + properties: { + id: { type: 'integer' }, + function_id: { type: 'integer' }, + status: { type: 'string' }, + payload: { type: 'object' }, + worker_id: { type: 'string', nullable: true }, + created_at: { type: 'string', format: 'date-time' }, + updated_at: { type: 'string', format: 'date-time' } + } + } + end + private_class_method :task_object_schema + + def self.task_input_schema + { + type: 'object', + required: %w[runner_class function], + properties: { + runner_class: { type: 'string', description: 'Fully qualified runner class name' }, + function: { type: 'string', description: 'Runner function name' }, + check_subtask: { type: 'boolean', default: true }, + generate_task: { type: 'boolean', default: true } + }, + additionalProperties: true + } + end + private_class_method :task_input_schema + + def self.extension_object_schema + { + type: 'object', + properties: { + name: { type: 'string' }, + state: { type: 'string' }, + version: { type: 'string', nullable: true }, + registered_at: { type: 'string', format: 'date-time', nullable: true }, + started_at: { type: 'string', format: 'date-time', nullable: true }, + runners: { type: 'array', items: { '$ref' => '#/components/schemas/RunnerObject' } } + } + } + end + private_class_method :extension_object_schema + + def self.runner_object_schema + { + type: 'object', + properties: { + name: { type: 'string' }, + runner_class: { type: 'string' }, + functions: { type: 'array', items: { type: 'string' } } + } + } + end + private_class_method :runner_object_schema + + def self.function_object_schema + { + type: 'object', + properties: { + name: { type: 'string' }, + runner: { type: 'string' }, + args: { type: 'object', nullable: true } + } + } + end + private_class_method :function_object_schema + + def self.available_extension_object_schema + { + type: 'object', + properties: { + name: { type: 'string' }, + category: { type: 'string' }, + description: { type: 'string' } + } + } + end + private_class_method :available_extension_object_schema + + def self.node_object_schema + { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + status: { type: 'string' }, + active: { type: 'boolean' }, + created_at: { type: 'string', format: 'date-time' } + } + } + end + private_class_method :node_object_schema + + def self.schedule_object_schema + { + type: 'object', + properties: { + id: { type: 'integer' }, + function_id: { type: 'integer' }, + cron: { type: 'string', nullable: true }, + interval: { type: 'integer', nullable: true }, + active: { type: 'boolean' }, + last_run: { type: 'string', format: 'date-time' }, + task_ttl: { type: 'integer', nullable: true }, + payload: { type: 'string', description: 'JSON-encoded payload' }, + transformation: { type: 'string', nullable: true } + } + } + end + private_class_method :schedule_object_schema + + def self.schedule_input_schema + { + type: 'object', + required: %w[function_id], + properties: { + function_id: { type: 'integer' }, + cron: { type: 'string', description: 'Cron expression (required if interval not given)' }, + interval: { type: 'integer', description: 'Interval in seconds (required if cron not given)' }, + active: { type: 'boolean', default: true }, + task_ttl: { type: 'integer', nullable: true }, + payload: { type: 'object' }, + transformation: { type: 'string', nullable: true } + } + } + end + private_class_method :schedule_input_schema + + def self.stub_object_schema(name) + { type: 'object', description: "#{name} record (schema not yet finalized)", additionalProperties: true } + end + private_class_method :stub_object_schema + + def self.worker_object_schema + { + type: 'object', + properties: { + worker_id: { type: 'string' }, + name: { type: 'string' }, + extension_name: { type: 'string' }, + entra_app_id: { type: 'string' }, + owner_msid: { type: 'string' }, + owner_name: { type: 'string', nullable: true }, + business_role: { type: 'string', nullable: true }, + risk_tier: { type: 'string', nullable: true }, + team: { type: 'string', nullable: true }, + lifecycle_state: { type: 'string' }, + manager_msid: { type: 'string', nullable: true } + } + } + end + private_class_method :worker_object_schema + + def self.worker_input_schema + { + type: 'object', + required: %w[name extension_name entra_app_id owner_msid], + properties: { + name: { type: 'string' }, + extension_name: { type: 'string' }, + entra_app_id: { type: 'string' }, + owner_msid: { type: 'string' }, + owner_name: { type: 'string' }, + business_role: { type: 'string' }, + risk_tier: { type: 'string' }, + team: { type: 'string' }, + manager_msid: { type: 'string' } + } + } + end + private_class_method :worker_input_schema + + # --- route path builders --- + + def self.wrap_array(schema_ref) + { + type: 'object', + properties: { + data: { type: 'array', items: { '$ref' => "#/components/schemas/#{schema_ref}" } }, + meta: { '$ref' => '#/components/schemas/Meta' } + } + } + end + private_class_method :wrap_array + + def self.wrap_data(schema_ref) + { + type: 'object', + properties: { + data: { '$ref' => "#/components/schemas/#{schema_ref}" }, + meta: { '$ref' => '#/components/schemas/Meta' } + } + } + end + private_class_method :wrap_data + + def self.wrap_collection(schema_ref) + { + type: 'object', + properties: { + data: { type: 'array', items: { '$ref' => "#/components/schemas/#{schema_ref}" } }, + meta: { '$ref' => '#/components/schemas/MetaCollection' } + } + } + end + private_class_method :wrap_collection + + def self.json_content(schema) + { 'application/json' => { schema: schema } } + end + private_class_method :json_content + + def self.ok_response(description, schema) + { description: description, content: json_content(schema) } + end + private_class_method :ok_response + + def self.health_paths + { + '/api/health' => { + get: { + tags: ['Health'], + summary: 'Health check', + description: 'Returns ok status and version. Skips auth middleware.', + operationId: 'getHealth', + security: [], + responses: { + '200' => ok_response('Healthy', wrap_data('TaskObject').merge( + properties: { + data: { + type: 'object', + properties: { status: { type: 'string', example: 'ok' }, version: { type: 'string' } } + }, + meta: { '$ref' => '#/components/schemas/Meta' } + } + )) + } + } + }, + '/api/ready' => { + get: { + tags: ['Health'], + summary: 'Readiness check', + description: 'Returns readiness status for all components. Returns 503 if not ready. Skips auth middleware.', + operationId: 'getReady', + security: [], + responses: { + '200' => { description: 'Ready' }, + '503' => { description: 'Not ready' } + } + } + } + } + end + private_class_method :health_paths + + def self.task_paths + { + '/api/tasks' => { + get: { + tags: ['Tasks'], + summary: 'List tasks', + operationId: 'listTasks', + parameters: PAGINATION_PARAMS + [ + { name: 'status', in: 'query', description: 'Filter by task status', required: false, + schema: { type: 'string' } }, + { name: 'function_id', in: 'query', description: 'Filter by function ID', required: false, + schema: { type: 'integer' } } + ], + responses: { + '200' => ok_response('Task list', wrap_collection('TaskObject')), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'legion-data not connected' } + } + }, + post: { + tags: ['Tasks'], + summary: 'Create and dispatch a task', + operationId: 'createTask', + requestBody: { + required: true, + content: json_content({ '$ref' => '#/components/schemas/TaskInput' }) + }, + responses: { + '201' => ok_response('Task created', wrap_data('TaskObject')), + '401' => UNAUTH_RESPONSE, + '422' => UNPROCESSABLE_RESPONSE, + '500' => { description: 'Execution error' } + } + } + }, + '/api/tasks/{id}' => { + get: { + tags: ['Tasks'], + summary: 'Get task by ID', + operationId: 'getTask', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { + '200' => ok_response('Task detail', wrap_data('TaskObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE, + '503' => { description: 'legion-data not connected' } + } + }, + delete: { + tags: ['Tasks'], + summary: 'Delete task', + operationId: 'deleteTask', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { + '200' => ok_response('Deleted', wrap_data('DeletedResponse')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/tasks/{id}/logs' => { + get: { + tags: ['Tasks'], + summary: 'Get task logs', + operationId: 'getTaskLogs', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }] + PAGINATION_PARAMS, + responses: { + '200' => ok_response('Task log entries', wrap_collection('TaskObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + } + } + end + private_class_method :task_paths + + def self.extension_paths + { + '/api/extension_catalog' => { + get: { + tags: ['Extensions'], + summary: 'List loaded extensions', + operationId: 'listExtensions', + parameters: [ + { name: 'state', in: 'query', description: 'Filter by extension state (e.g. running)', required: false, + schema: { type: 'string' } } + ], + responses: { + '200' => ok_response('Extension list', wrap_array('ExtensionObject')), + '401' => UNAUTH_RESPONSE + } + } + }, + '/api/extension_catalog/available' => { + get: { + tags: ['Extensions'], + summary: 'List all available extensions in the ecosystem registry', + operationId: 'listAvailableExtensions', + parameters: [ + { name: 'category', in: 'query', description: 'Filter by category (core, ai, agentic, identity, service, other)', + required: false, schema: { type: 'string' } } + ], + responses: { + '200' => ok_response('Available extension list', wrap_array('AvailableExtensionObject')), + '401' => UNAUTH_RESPONSE + } + } + }, + '/api/extension_catalog/{name}' => { + get: { + tags: ['Extensions'], + summary: 'Get extension by name', + operationId: 'getExtension', + parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200' => ok_response('Extension detail', wrap_data('ExtensionObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/extension_catalog/{name}/runners' => { + get: { + tags: ['Extensions'], + summary: 'List runners for extension', + operationId: 'listExtensionRunners', + parameters: [{ name: 'name', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200' => ok_response('Runner list', wrap_array('RunnerObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/extension_catalog/{name}/runners/{runner_name}' => { + get: { + tags: ['Extensions'], + summary: 'Get runner by name', + operationId: 'getExtensionRunner', + parameters: [ + { name: 'name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'runner_name', in: 'path', required: true, schema: { type: 'string' } } + ], + responses: { + '200' => ok_response('Runner detail', wrap_data('RunnerObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/extension_catalog/{name}/runners/{runner_name}/functions' => { + get: { + tags: ['Extensions'], + summary: 'List functions for runner', + operationId: 'listRunnerFunctions', + parameters: [ + { name: 'name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'runner_name', in: 'path', required: true, schema: { type: 'string' } } + ], + responses: { + '200' => ok_response('Function list', wrap_array('FunctionObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/extension_catalog/{name}/runners/{runner_name}/functions/{function_name}' => { + get: { + tags: ['Extensions'], + summary: 'Get function by name', + operationId: 'getRunnerFunction', + parameters: [ + { name: 'name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'runner_name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'function_name', in: 'path', required: true, schema: { type: 'string' } } + ], + responses: { + '200' => ok_response('Function detail', wrap_data('FunctionObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/extension_catalog/{name}/runners/{runner_name}/functions/{function_name}/invoke' => { + post: { + tags: ['Extensions'], + summary: 'Invoke a function directly', + operationId: 'invokeFunction', + parameters: [ + { name: 'name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'runner_name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'function_name', in: 'path', required: true, schema: { type: 'string' } } + ], + requestBody: { + required: false, + content: json_content({ type: 'object', additionalProperties: true }) + }, + responses: { + '201' => ok_response('Task created', wrap_data('TaskObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE, + '422' => UNPROCESSABLE_RESPONSE + } + } + } + } + end + private_class_method :extension_paths + + def self.node_paths + { + '/api/nodes' => { + get: { + tags: ['Nodes'], + summary: 'List nodes', + operationId: 'listNodes', + parameters: PAGINATION_PARAMS + [ + { name: 'active', in: 'query', description: 'Filter to active nodes only', required: false, + schema: { type: 'boolean' } }, + { name: 'status', in: 'query', description: 'Filter by node status', required: false, + schema: { type: 'string' } } + ], + responses: { + '200' => ok_response('Node list', wrap_collection('NodeObject')), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'legion-data not connected' } + } + } + }, + '/api/nodes/{id}' => { + get: { + tags: ['Nodes'], + summary: 'Get node by ID', + operationId: 'getNode', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { + '200' => ok_response('Node detail', wrap_data('NodeObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + } + } + end + private_class_method :node_paths + + def self.schedule_paths + { + '/api/schedules' => { + get: { + tags: ['Schedules'], + summary: 'List schedules', + operationId: 'listSchedules', + parameters: PAGINATION_PARAMS + [ + { name: 'active', in: 'query', description: 'Filter to active schedules only', required: false, + schema: { type: 'boolean' } } + ], + responses: { + '200' => ok_response('Schedule list', wrap_collection('ScheduleObject')), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'lex-scheduler not loaded' } + } + }, + post: { + tags: ['Schedules'], + summary: 'Create schedule', + operationId: 'createSchedule', + requestBody: { + required: true, + content: json_content({ '$ref' => '#/components/schemas/ScheduleInput' }) + }, + responses: { + '201' => ok_response('Schedule created', wrap_data('ScheduleObject')), + '401' => UNAUTH_RESPONSE, + '422' => UNPROCESSABLE_RESPONSE, + '503' => { description: 'lex-scheduler not loaded' } + } + } + }, + '/api/schedules/{id}' => { + get: { + tags: ['Schedules'], + summary: 'Get schedule by ID', + operationId: 'getSchedule', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { + '200' => ok_response('Schedule detail', wrap_data('ScheduleObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + }, + put: { + tags: ['Schedules'], + summary: 'Update schedule', + operationId: 'updateSchedule', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + requestBody: { + required: true, + content: json_content({ '$ref' => '#/components/schemas/ScheduleInput' }) + }, + responses: { + '200' => ok_response('Updated schedule', wrap_data('ScheduleObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + }, + delete: { + tags: ['Schedules'], + summary: 'Delete schedule', + operationId: 'deleteSchedule', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { + '200' => ok_response('Deleted', wrap_data('DeletedResponse')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/schedules/{id}/logs' => { + get: { + tags: ['Schedules'], + summary: 'Get schedule run logs', + operationId: 'getScheduleLogs', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }] + PAGINATION_PARAMS, + responses: { + '200' => ok_response('Schedule log entries', wrap_collection('TaskObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + } + } + end + private_class_method :schedule_paths + + def self.relationship_paths + stub_crud_paths('relationships', 'Relationships', 'Relationship', 'RelationshipObject') + end + private_class_method :relationship_paths + + def self.chain_paths + stub_crud_paths('chains', 'Chains', 'Chain', 'ChainObject') + end + private_class_method :chain_paths + + def self.stub_crud_paths(resource, tag, op_prefix, schema_ref) + { + "/api/#{resource}" => { + get: { + tags: [tag], + summary: "List #{resource}", + description: 'Returns 501 — data model not yet available.', + operationId: "list#{op_prefix}s", + responses: { + '501' => NOT_IMPL_RESPONSE, + '401' => UNAUTH_RESPONSE + } + }, + post: { + tags: [tag], + summary: "Create #{resource.chop}", + description: 'Returns 501 — data model not yet available.', + operationId: "create#{op_prefix}", + requestBody: { + required: true, + content: json_content({ type: 'object', additionalProperties: true }) + }, + responses: { + '501' => NOT_IMPL_RESPONSE, + '401' => UNAUTH_RESPONSE + } + } + }, + "/api/#{resource}/{id}" => { + get: { + tags: [tag], + summary: "Get #{resource.chop} by ID", + description: 'Returns 501 — data model not yet available.', + operationId: "get#{op_prefix}", + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { + '200' => ok_response("#{op_prefix} detail", wrap_data(schema_ref)), + '501' => NOT_IMPL_RESPONSE, + '404' => NOT_FOUND_RESPONSE, + '401' => UNAUTH_RESPONSE + } + }, + put: { + tags: [tag], + summary: "Update #{resource.chop}", + description: 'Returns 501 — data model not yet available.', + operationId: "update#{op_prefix}", + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + requestBody: { + required: true, + content: json_content({ type: 'object', additionalProperties: true }) + }, + responses: { + '200' => ok_response("Updated #{resource.chop}", wrap_data(schema_ref)), + '501' => NOT_IMPL_RESPONSE, + '404' => NOT_FOUND_RESPONSE, + '401' => UNAUTH_RESPONSE + } + }, + delete: { + tags: [tag], + summary: "Delete #{resource.chop}", + description: 'Returns 501 — data model not yet available.', + operationId: "delete#{op_prefix}", + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }], + responses: { + '200' => ok_response('Deleted', wrap_data('DeletedResponse')), + '501' => NOT_IMPL_RESPONSE, + '404' => NOT_FOUND_RESPONSE, + '401' => UNAUTH_RESPONSE + } + } + } + } + end + private_class_method :stub_crud_paths + + def self.settings_paths + { + '/api/settings' => { + get: { + tags: ['Settings'], + summary: 'Get all settings (sensitive values redacted)', + operationId: 'getSettings', + responses: { + '200' => ok_response('Settings hash', { type: 'object', additionalProperties: true }), + '401' => UNAUTH_RESPONSE + } + } + }, + '/api/settings/{key}' => { + get: { + tags: ['Settings'], + summary: 'Get a single setting section', + operationId: 'getSetting', + parameters: [{ name: 'key', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200' => ok_response('Setting value', + { type: 'object', properties: { key: { type: 'string' }, value: {} } }), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + }, + put: { + tags: ['Settings'], + summary: 'Update a setting section', + description: 'transport and crypt sections are read-only and return 403.', + operationId: 'updateSetting', + parameters: [{ name: 'key', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: json_content({ type: 'object', required: ['value'], properties: { value: {} } }) + }, + responses: { + '200' => ok_response('Updated setting', + { type: 'object', properties: { key: { type: 'string' }, value: {} } }), + '401' => UNAUTH_RESPONSE, + '403' => { description: 'Forbidden — read-only section' }, + '422' => UNPROCESSABLE_RESPONSE + } + } + } + } + end + private_class_method :settings_paths + + def self.event_paths + { + '/api/events' => { + get: { + tags: ['Events'], + summary: 'Server-Sent Events stream', + description: 'Streams all Legion events as SSE. Responds with text/event-stream. ' \ + 'Each event: `event: \\ndata: \\n\\n`.', + operationId: 'streamEvents', + responses: { + '200' => { + description: 'SSE stream', + content: { 'text/event-stream' => { schema: { type: 'string' } } } + }, + '401' => UNAUTH_RESPONSE + } + } + }, + '/api/events/recent' => { + get: { + tags: ['Events'], + summary: 'Get recent events from ring buffer', + operationId: 'getRecentEvents', + parameters: [ + { name: 'count', in: 'query', description: "Number of events (max #{Legion::API::Routes::Events::BUFFER_SIZE})", + required: false, schema: { type: 'integer', default: 25 } } + ], + responses: { + '200' => ok_response('Recent events', { type: 'object', properties: { + data: { type: 'array', items: { type: 'object' } }, + meta: { '$ref' => '#/components/schemas/Meta' } + } }), + '401' => UNAUTH_RESPONSE + } + } + } + } + end + private_class_method :event_paths + + def self.transport_paths + { + '/api/transport' => { + get: { + tags: ['Transport'], + summary: 'RabbitMQ transport connection status', + operationId: 'getTransportStatus', + responses: { + '200' => ok_response('Transport status', { type: 'object', properties: { + data: { + type: 'object', + properties: { + connected: { type: 'boolean' }, + session_open: { type: 'boolean' }, + channel_open: { type: 'boolean' }, + connector: { type: 'string' } + } + }, + meta: { '$ref' => '#/components/schemas/Meta' } + } }), + '401' => UNAUTH_RESPONSE + } + } + }, + '/api/transport/exchanges' => { + get: { + tags: ['Transport'], + summary: 'List known exchange subclasses', + operationId: 'listExchanges', + responses: { + '200' => ok_response('Exchange list', + { type: 'object', properties: { + data: { type: 'array', items: { type: 'object', + properties: { name: { type: 'string' } } } }, + meta: { '$ref' => '#/components/schemas/Meta' } + } }), + '401' => UNAUTH_RESPONSE + } + } + }, + '/api/transport/queues' => { + get: { + tags: ['Transport'], + summary: 'List known queue subclasses', + operationId: 'listQueues', + responses: { + '200' => ok_response('Queue list', + { type: 'object', properties: { + data: { type: 'array', items: { type: 'object', + properties: { name: { type: 'string' } } } }, + meta: { '$ref' => '#/components/schemas/Meta' } + } }), + '401' => UNAUTH_RESPONSE + } + } + }, + '/api/transport/publish' => { + post: { + tags: ['Transport'], + summary: 'Publish a message to an exchange', + operationId: 'publishMessage', + requestBody: { + required: true, + content: json_content({ + type: 'object', + required: %w[exchange routing_key], + properties: { + exchange: { type: 'string' }, + routing_key: { type: 'string' }, + payload: { type: 'object', additionalProperties: true } + } + }) + }, + responses: { + '201' => ok_response('Published', { type: 'object', properties: { + data: { + type: 'object', + properties: { + published: { type: 'boolean' }, + exchange: { type: 'string' }, + routing_key: { type: 'string' } + } + }, + meta: { '$ref' => '#/components/schemas/Meta' } + } }), + '401' => UNAUTH_RESPONSE, + '422' => UNPROCESSABLE_RESPONSE + } + } + } + } + end + private_class_method :transport_paths + + def self.hook_paths + { + '/api/hooks' => { + get: { + tags: ['Hooks'], + summary: 'List registered webhook endpoints', + operationId: 'listHooks', + responses: { + '200' => ok_response('Hook list', { type: 'object', properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + lex_name: { type: 'string' }, + hook_name: { type: 'string' }, + hook_class: { type: 'string' }, + default_runner: { type: 'string' }, + endpoint: { type: 'string' } + } + } + }, + meta: { '$ref' => '#/components/schemas/Meta' } + } }), + '401' => UNAUTH_RESPONSE + } + } + }, + '/api/hooks/{lex_name}/{hook_name}' => { + post: { + tags: ['Hooks'], + summary: 'Trigger a registered webhook', + description: 'Verifies the webhook signature, routes the event to the configured runner, ' \ + 'and dispatches a task via Ingress.', + operationId: 'triggerHook', + parameters: [ + { name: 'lex_name', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'hook_name', in: 'path', required: false, schema: { type: 'string' } } + ], + requestBody: { + required: false, + content: { 'application/json' => { schema: { type: 'object', additionalProperties: true } } } + }, + responses: { + '200' => ok_response('Hook dispatched', { type: 'object', properties: { + data: { + type: 'object', + properties: { task_id: { type: 'integer' }, status: { type: 'string' } } + }, + meta: { '$ref' => '#/components/schemas/Meta' } + } }), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE, + '422' => UNPROCESSABLE_RESPONSE + } + } + } + } + end + private_class_method :hook_paths + + def self.lex_route_responses + { + '200' => { + description: 'Success', + content: { + 'application/json' => { + schema: { + type: 'object', + properties: { + data: { + type: 'object', + properties: { + task_id: { type: 'string' }, + status: { type: 'string' }, + result: { type: 'object' } + } + }, + meta: META_SCHEMA + } + } + } + } + }, + '401' => { description: 'Unauthorized', content: { 'application/json' => { schema: ERROR_SCHEMA } } }, + '403' => { description: 'Forbidden', content: { 'application/json' => { schema: ERROR_SCHEMA } } }, + '404' => { description: 'Not found', content: { 'application/json' => { schema: ERROR_SCHEMA } } }, + '500' => { description: 'Internal error', content: { 'application/json' => { schema: ERROR_SCHEMA } } } + } + end + private_class_method :lex_route_responses + + def self.lex_paths + { + '/api/lex' => { + get: { + tags: ['Lex'], + summary: 'List auto-registered LEX runner routes', + operationId: 'listLexRoutes', + responses: { + '200' => ok_response('Lex route list', { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + endpoint: { type: 'string' }, + extension: { type: 'string' }, + runner: { type: 'string' }, + function: { type: 'string' }, + runner_class: { type: 'string' } + } + } + }, + meta: { '$ref' => '#/components/schemas/Meta' } + } + }), + '401' => UNAUTH_RESPONSE + } + } + } + } + end + private_class_method :lex_paths + + def self.worker_paths + { + '/api/workers' => { + get: { + tags: ['Workers'], + summary: 'List digital workers', + operationId: 'listWorkers', + parameters: PAGINATION_PARAMS + [ + { name: 'team', in: 'query', required: false, schema: { type: 'string' } }, + { name: 'owner_msid', in: 'query', required: false, schema: { type: 'string' } }, + { name: 'lifecycle_state', in: 'query', required: false, schema: { type: 'string' } }, + { name: 'risk_tier', in: 'query', required: false, schema: { type: 'string' } } + ], + responses: { + '200' => ok_response('Worker list', wrap_collection('WorkerObject')), + '401' => UNAUTH_RESPONSE + } + }, + post: { + tags: ['Workers'], + summary: 'Register a new digital worker', + operationId: 'createWorker', + requestBody: { + required: true, + content: json_content({ '$ref' => '#/components/schemas/WorkerInput' }) + }, + responses: { + '201' => ok_response('Worker registered', wrap_data('WorkerObject')), + '401' => UNAUTH_RESPONSE, + '422' => UNPROCESSABLE_RESPONSE + } + } + }, + '/api/workers/{id}' => { + get: { + tags: ['Workers'], + summary: 'Get worker by ID', + operationId: 'getWorker', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200' => ok_response('Worker detail', wrap_data('WorkerObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + }, + delete: { + tags: ['Workers'], + summary: 'Retire a worker (transitions to retired state)', + operationId: 'deleteWorker', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'reason', in: 'query', required: false, schema: { type: 'string' } } + ], + responses: { + '200' => ok_response('Worker retired', wrap_data('WorkerObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE, + '422' => UNPROCESSABLE_RESPONSE + } + } + }, + '/api/workers/{id}/lifecycle' => { + patch: { + tags: ['Workers'], + summary: 'Transition worker lifecycle state', + operationId: 'transitionWorkerLifecycle', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + requestBody: { + required: true, + content: json_content({ + type: 'object', + required: ['state'], + properties: { + state: { type: 'string', enum: %w[active paused retired terminated] }, + by: { type: 'string' }, + reason: { type: 'string' }, + governance_override: { type: 'boolean', default: false }, + authority_verified: { type: 'boolean', default: false } + } + }) + }, + responses: { + '200' => ok_response('Updated worker', wrap_data('WorkerObject')), + '401' => UNAUTH_RESPONSE, + '403' => { description: 'Governance or authority required' }, + '404' => NOT_FOUND_RESPONSE, + '422' => UNPROCESSABLE_RESPONSE + } + } + }, + '/api/workers/{id}/tasks' => { + get: { + tags: ['Workers'], + summary: 'List tasks for a worker', + operationId: 'getWorkerTasks', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] + PAGINATION_PARAMS, + responses: { + '200' => ok_response('Task list', wrap_collection('TaskObject')), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/workers/{id}/events' => { + get: { + tags: ['Workers'], + summary: 'Get worker lifecycle events', + description: 'Lifecycle event persistence is not yet implemented — returns empty list.', + operationId: 'getWorkerEvents', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200' => { description: 'Worker events (stub)' }, + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/workers/{id}/costs' => { + get: { + tags: ['Workers'], + summary: 'Get worker cost summary', + description: 'Requires lex-metering. Returns stub if not available.', + operationId: 'getWorkerCosts', + parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200' => { description: 'Worker cost summary' }, + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/workers/{id}/value' => { + get: { + tags: ['Workers'], + summary: 'Get worker value metrics', + operationId: 'getWorkerValue', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'since', in: 'query', required: false, description: 'ISO8601 start timestamp', + schema: { type: 'string', format: 'date-time' } } + ], + responses: { + '200' => { description: 'Worker value summary and recent metrics' }, + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + }, + '/api/workers/{id}/roi' => { + get: { + tags: ['Workers'], + summary: 'Get worker ROI (value vs cost)', + operationId: 'getWorkerRoi', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'period', in: 'query', required: false, schema: { type: 'string', default: 'monthly' } } + ], + responses: { + '200' => { description: 'Worker ROI summary' }, + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE + } + } + } + } + end + private_class_method :worker_paths + + def self.team_paths + { + '/api/teams/{team}/workers' => { + get: { + tags: ['Teams'], + summary: 'List workers on a team', + operationId: 'getTeamWorkers', + parameters: [{ name: 'team', in: 'path', required: true, schema: { type: 'string' } }] + PAGINATION_PARAMS, + responses: { + '200' => ok_response('Team worker list', wrap_collection('WorkerObject')), + '401' => UNAUTH_RESPONSE + } + } + }, + '/api/teams/{team}/costs' => { + get: { + tags: ['Teams'], + summary: 'Get team cost summary', + description: 'Requires lex-metering. Returns stub if not available.', + operationId: 'getTeamCosts', + parameters: [{ name: 'team', in: 'path', required: true, schema: { type: 'string' } }], + responses: { + '200' => { description: 'Team cost summary' }, + '401' => UNAUTH_RESPONSE + } + } + } + } + end + private_class_method :team_paths + + def self.coldstart_paths + { + '/api/coldstart/ingest' => { + post: { + tags: ['Coldstart'], + summary: 'Ingest a file or directory into agentic memory', + description: 'Requires lex-coldstart and lex-agentic-memory to be loaded.', + operationId: 'coldstartIngest', + requestBody: { + required: true, + content: json_content({ + type: 'object', + required: ['path'], + properties: { + path: { type: 'string', description: 'File or directory path to ingest' }, + pattern: { type: 'string', description: 'Glob pattern (directory only)', + default: '**/{CLAUDE,MEMORY}.md' } + } + }) + }, + responses: { + '201' => ok_response('Ingest result', { type: 'object', additionalProperties: true }), + '401' => UNAUTH_RESPONSE, + '404' => NOT_FOUND_RESPONSE, + '422' => UNPROCESSABLE_RESPONSE, + '503' => { description: 'lex-coldstart or lex-agentic-memory not loaded' } + } + } + } + } + end + private_class_method :coldstart_paths + + def self.gaia_paths + { + '/api/gaia/status' => { + get: { + tags: ['Gaia'], + summary: 'Get Gaia cognitive layer status', + operationId: 'getGaiaStatus', + responses: { + '200' => ok_response('Gaia status', { type: 'object', additionalProperties: true }), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Gaia not started' } + } + } + }, + '/api/gaia/channels' => { + get: { + tags: ['Gaia'], + summary: 'List registered communication channels', + operationId: 'getGaiaChannels', + responses: { + '200' => ok_response('Channel list', gaia_channels_schema), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Gaia not started' } + } + } + }, + '/api/gaia/buffer' => { + get: { + tags: ['Gaia'], + summary: 'Get sensory buffer status', + operationId: 'getGaiaBuffer', + responses: { + '200' => ok_response('Buffer status', gaia_buffer_schema), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Gaia not started' } + } + } + }, + '/api/gaia/sessions' => { + get: { + tags: ['Gaia'], + summary: 'Get active session count', + operationId: 'getGaiaSessions', + responses: { + '200' => ok_response('Session info', gaia_sessions_schema), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Gaia not started' } + } + } + } + } + end + private_class_method :gaia_paths + + def self.gaia_channels_schema + { + type: 'object', + properties: { + channels: { type: 'array', items: { + type: 'object', properties: { + id: { type: 'string' }, type: { type: 'string' }, + started: { type: 'boolean' }, capabilities: { type: 'array', items: { type: 'string' } } + } + } }, + count: { type: 'integer' } + } + } + end + private_class_method :gaia_channels_schema + + def self.gaia_buffer_schema + { + type: 'object', + properties: { + depth: { type: 'integer' }, + empty: { type: 'boolean' }, + max_size: { type: 'integer', nullable: true } + } + } + end + private_class_method :gaia_buffer_schema + + def self.gaia_sessions_schema + { + type: 'object', + properties: { + count: { type: 'integer' }, + active: { type: 'boolean' } + } + } + end + private_class_method :gaia_sessions_schema + + def self.apollo_paths + { + '/api/apollo/status' => { + get: { + tags: ['Apollo'], + summary: 'Apollo knowledge graph availability', + operationId: 'getApolloStatus', + responses: { + '200' => ok_response('Apollo available', { type: 'object', properties: { + available: { type: 'boolean' }, + data_connected: { type: 'boolean' } + } }), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Apollo not available' } + } + } + }, + '/api/apollo/stats' => { + get: { + tags: ['Apollo'], + summary: 'Knowledge graph statistics', + operationId: 'getApolloStats', + responses: { + '200' => ok_response('Apollo stats', { type: 'object', properties: { + total_entries: { type: 'integer' }, + by_status: { type: 'object', additionalProperties: { type: 'integer' } }, + by_content_type: { type: 'object', additionalProperties: { type: 'integer' } }, + recent_24h: { type: 'integer' }, + avg_confidence: { type: 'number' } + } }), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Apollo not available' } + } + } + }, + '/api/apollo/query' => { + post: { + tags: ['Apollo'], + summary: 'Query the knowledge graph', + operationId: 'apolloQuery', + requestBody: { + required: true, + content: json_content({ + type: 'object', + required: ['query'], + properties: { + query: { type: 'string', description: 'Semantic search query' }, + limit: { type: 'integer', default: 10 }, + min_confidence: { type: 'number', default: 0.3 }, + status: { type: 'array', items: { type: 'string' } }, + tags: { type: 'array', items: { type: 'string' } }, + domain: { type: 'string' }, + agent_id: { type: 'string', default: 'api' } + } + }) + }, + responses: { + '200' => ok_response('Query results', { type: 'object', additionalProperties: true }), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Apollo not available' } + } + } + }, + '/api/apollo/ingest' => { + post: { + tags: ['Apollo'], + summary: 'Ingest knowledge into the graph', + operationId: 'apolloIngest', + requestBody: { + required: true, + content: json_content({ + type: 'object', + required: ['content'], + properties: { + content: { type: 'string' }, + content_type: { type: 'string', enum: %w[fact concept procedure association observation] }, + tags: { type: 'array', items: { type: 'string' } }, + source_agent: { type: 'string', default: 'api' }, + source_provider: { type: 'string' }, + source_channel: { type: 'string', default: 'rest_api' }, + knowledge_domain: { type: 'string' }, + context: { type: 'object', additionalProperties: true } + } + }) + }, + responses: { + '201' => ok_response('Ingested', { type: 'object', additionalProperties: true }), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Apollo not available' } + } + } + }, + '/api/apollo/entries/{id}/related' => { + get: { + tags: ['Apollo'], + summary: 'Get related knowledge entries', + operationId: 'getApolloRelated', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'integer' } }, + { name: 'relation_types', in: 'query', schema: { type: 'string' }, + description: 'Comma-separated relation types' }, + { name: 'depth', in: 'query', schema: { type: 'integer', default: 2 } } + ], + responses: { + '200' => ok_response('Related entries', { type: 'object', additionalProperties: true }), + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Apollo not available' } + } + } + }, + '/api/apollo/maintenance' => { + post: { + tags: ['Apollo'], + summary: 'Trigger knowledge graph maintenance', + operationId: 'apolloMaintenance', + requestBody: { + required: true, + content: json_content({ + type: 'object', + required: ['action'], + properties: { + action: { type: 'string', enum: %w[decay_cycle corroboration] } + } + }) + }, + responses: { + '200' => ok_response('Maintenance result', { type: 'object', additionalProperties: true }), + '400' => { description: 'Invalid action' }, + '401' => UNAUTH_RESPONSE, + '503' => { description: 'Apollo not available' } + } + } + } + } + end + private_class_method :apollo_paths + + def self.openapi_paths + { + '/api/openapi.json' => { + get: { + tags: ['OpenAPI'], + summary: 'OpenAPI 3.1.0 spec for this API', + description: 'Returns this document. Skips auth middleware.', + operationId: 'getOpenApiSpec', + security: [], + responses: { + '200' => { + description: 'OpenAPI spec', + content: { 'application/json' => { schema: { type: 'object', additionalProperties: true } } } + } + } + } + } + } + end + private_class_method :openapi_paths + + def self.stats_paths + { + '/api/stats' => { + get: { + tags: ['Stats'], + summary: 'Comprehensive daemon runtime stats', + description: 'Returns runtime statistics for all subsystems: extensions, gaia, transport, cache, llm, data, and api. ' \ + 'Each section collects independently — one subsystem failure does not affect others.', + operationId: 'getStats', + responses: { + '200' => ok_response('Stats', wrap_data('StatsObject').merge( + properties: { + data: { + type: 'object', + properties: { + extensions: { type: 'object' }, + gaia: { type: 'object' }, + transport: { type: 'object' }, + cache: { type: 'object' }, + cache_local: { type: 'object' }, + llm: { type: 'object' }, + data: { type: 'object' }, + data_local: { type: 'object' }, + api: { type: 'object' } + } + }, + meta: { '$ref' => '#/components/schemas/Meta' } + } + )) + } + } + } + } + end + private_class_method :stats_paths + end + end +end diff --git a/lib/legion/api/org_chart.rb b/lib/legion/api/org_chart.rb new file mode 100644 index 00000000..8f39a632 --- /dev/null +++ b/lib/legion/api/org_chart.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module OrgChart + def self.registered(app) + app.helpers OrgChartHelpers + app.get '/api/org-chart' do + require_data! + departments = build_org_chart + json_response({ departments: departments }) + end + end + + module OrgChartHelpers + def build_org_chart + extensions = Legion::Data::Model::Extension.all + workers = Legion::Data::Model::DigitalWorker.all + + extensions.map do |ext| + functions = Legion::Data::Model::Function.where(extension_id: ext.id).all + { + name: ext.name, + roles: functions.map do |func| + ext_workers = workers.select { |w| w.extension_name == ext.name } + { + name: func.name, + workers: ext_workers.map { |w| { id: w.id, name: w.name, status: w.lifecycle_state } } + } + end + } + end + rescue StandardError => e + Legion::Logging.warn "OrgChart#build_org_chart failed: #{e.message}" if defined?(Legion::Logging) + [] + end + end + end + end + end +end diff --git a/lib/legion/api/prompts.rb b/lib/legion/api/prompts.rb new file mode 100644 index 00000000..a0a42bb7 --- /dev/null +++ b/lib/legion/api/prompts.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Prompts + def self.registered(app) + app.helpers do + define_method(:require_llm!) do + return if defined?(Legion::LLM) && + Legion::LLM.respond_to?(:started?) && + Legion::LLM.started? + + halt 503, json_error('llm_unavailable', 'LLM subsystem is not available', status_code: 503) + end + + define_method(:prompt_client) do + require 'legion/extensions/prompt/client' + db = Legion::Data.connection + unless db.table_exists?(:prompts) + halt 503, json_error('prompt_unavailable', 'prompts table does not exist — run lex-prompt migrations', status_code: 503) + end + Legion::Extensions::Prompt::Client.new(db: db) + rescue LoadError => e + Legion::Logging.warn "Prompts#prompt_client failed to load lex-prompt: #{e.message}" if defined?(Legion::Logging) + halt 503, json_error('prompt_unavailable', 'lex-prompt is not loaded', status_code: 503) + end + end + + register_list(app) + register_show(app) + register_run(app) + end + + def self.register_list(app) + app.get '/api/prompts' do + client = prompt_client + result = client.list_prompts + json_response(result) + rescue StandardError => e + Legion::Logging.log_exception(e, payload_summary: 'API GET /api/prompts', component_type: :api) + json_error('execution_error', e.message, status_code: 500) + end + end + + def self.register_show(app) + app.get '/api/prompts/:name' do + name = params[:name] + client = prompt_client + result = client.get_prompt(name: name) + + if result[:error] + Legion::Logging.warn "API GET /api/prompts/#{name} returned 404: prompt not found" + halt 404, json_error('not_found', "prompt '#{name}' not found", status_code: 404) + end + + json_response(result) + rescue StandardError => e + Legion::Logging.log_exception(e, payload_summary: "API GET /api/prompts/#{params[:name]}", component_type: :api) + json_error('execution_error', e.message, status_code: 500) + end + end + + def self.register_run(app) + app.post '/api/prompts/:name/run' do + Legion::Logging.debug "API: POST /api/prompts/#{params[:name]}/run params=#{params.keys}" + require_llm! + + name = params[:name] + body = parse_request_body + variables = body[:variables] || {} + version = body[:version] + model = body[:model] + provider = body[:provider] + + client = prompt_client + rendered = client.render_prompt(name: name, variables: variables, version: version) + + if rendered[:error] + code = rendered[:error] == 'not_found' ? 404 : 422 + halt code, json_error(rendered[:error], "prompt '#{name}' #{rendered[:error].tr('_', ' ')}", status_code: code) + end + + session = Legion::LLM.chat(model: model, provider: provider, + caller: { source: 'api', endpoint: 'prompts' }) + response = session.ask(rendered[:rendered]) + + prompt_version = rendered[:prompt_version] + model_used = session.model.to_s + + usage = { + input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil, + output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil + } + + Legion::Logging.info "API: ran prompt #{name} version=#{prompt_version} model=#{model_used}" + json_response({ + name: name, + version: prompt_version, + rendered_prompt: rendered[:rendered], + response: response.content, + usage: usage, + model: model_used, + provider: provider + }) + rescue StandardError => e + Legion::Logging.log_exception(e, payload_summary: "API POST /api/prompts/#{params[:name]}/run", component_type: :api) + json_error('execution_error', e.message, status_code: 500) + end + end + + class << self + private :register_list, :register_show, :register_run + end + end + end + end +end diff --git a/lib/legion/api/rbac.rb b/lib/legion/api/rbac.rb new file mode 100644 index 00000000..7cbdf77e --- /dev/null +++ b/lib/legion/api/rbac.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Rbac + def self.registered(app) + register_roles(app) + register_check(app) + register_assignments(app) + register_grants(app) + register_cross_team_grants(app) + end + + def self.register_roles(app) + app.get '/api/rbac/roles' do + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + + roles = Legion::Rbac.role_index.transform_values do |role| + { name: role.name, description: role.description, cross_team: role.cross_team? } + end + json_response(roles) + end + + app.get '/api/rbac/roles/:name' do + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + + role = Legion::Rbac.role_index[params[:name].to_sym] + halt 404, json_error('not_found', "Role #{params[:name]} not found", status_code: 404) unless role + + json_response({ + name: role.name, + description: role.description, + cross_team: role.cross_team?, + permissions: role.permissions.map { |p| { resource: p.resource_pattern, actions: p.actions } }, + deny_rules: role.deny_rules.map { |d| { resource: d.resource_pattern, above_level: d.above_level } } + }) + end + end + + def self.register_check(app) + app.post '/api/rbac/check' do + Legion::Logging.debug "API: POST /api/rbac/check params=#{params.keys}" + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + + body = parse_request_body + principal = Legion::Rbac::Principal.new( + id: body[:principal] || 'anonymous', + roles: body[:roles] || [], + team: body[:team] + ) + result = Legion::Rbac::PolicyEngine.evaluate( + principal: principal, + action: body[:action] || 'read', + resource: body[:resource] || '*', + enforce: false + ) + json_response(result) + rescue StandardError => e + Legion::Logging.error "API POST /api/rbac/check: #{e.class} — #{e.message}" + json_error('rbac_error', e.message, status_code: 500) + end + end + + def self.register_assignments(app) + app.get '/api/rbac/assignments' do + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available? + + dataset = Legion::Data::Model::RbacRoleAssignment.order(:id) + dataset = dataset.where(team: params[:team]) if params[:team] + dataset = dataset.where(role: params[:role]) if params[:role] + dataset = dataset.where(principal_id: params[:principal]) if params[:principal] + json_collection(dataset) + end + + app.post '/api/rbac/assignments' do + Legion::Logging.debug "API: POST /api/rbac/assignments params=#{params.keys}" + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available? + + body = parse_request_body + record = Legion::Data::Model::RbacRoleAssignment.create( + principal_type: body[:principal_type] || 'human', + principal_id: body[:principal_id], + role: body[:role], + team: body[:team], + granted_by: current_owner_msid || 'api', + expires_at: body[:expires_at] ? Time.parse(body[:expires_at]) : nil + ) + Legion::Logging.info "API: created RBAC assignment #{record.id} role=#{body[:role]} principal=#{body[:principal_id]}" + json_response(record.values, status_code: 201) + rescue Sequel::ValidationFailed => e + Legion::Logging.warn "API POST /api/rbac/assignments returned 422: #{e.message}" + json_error('validation_error', e.message, status_code: 422) + end + + app.delete '/api/rbac/assignments/:id' do + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available? + + record = Legion::Data::Model::RbacRoleAssignment[params[:id].to_i] + halt 404, json_error('not_found', 'Assignment not found', status_code: 404) unless record + + record.destroy + Legion::Logging.info "API: deleted RBAC assignment #{params[:id]}" + json_response({ deleted: true }) + end + end + + def self.register_grants(app) + app.get '/api/rbac/grants' do + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available? + + dataset = Legion::Data::Model::RbacRunnerGrant.order(:id) + dataset = dataset.where(team: params[:team]) if params[:team] + json_collection(dataset) + end + + app.post '/api/rbac/grants' do + Legion::Logging.debug "API: POST /api/rbac/grants params=#{params.keys}" + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available? + + body = parse_request_body + record = Legion::Data::Model::RbacRunnerGrant.create( + team: body[:team], + runner_pattern: body[:runner_pattern], + actions: Array(body[:actions]).join(','), + granted_by: current_owner_msid || 'api' + ) + Legion::Logging.info "API: created RBAC grant #{record.id} team=#{body[:team]} pattern=#{body[:runner_pattern]}" + json_response(record.values, status_code: 201) + rescue Sequel::ValidationFailed => e + Legion::Logging.warn "API POST /api/rbac/grants returned 422: #{e.message}" + json_error('validation_error', e.message, status_code: 422) + end + + app.delete '/api/rbac/grants/:id' do + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available? + + record = Legion::Data::Model::RbacRunnerGrant[params[:id].to_i] + halt 404, json_error('not_found', 'Grant not found', status_code: 404) unless record + + record.destroy + Legion::Logging.info "API: deleted RBAC grant #{params[:id]}" + json_response({ deleted: true }) + end + end + + def self.register_cross_team_grants(app) + app.get '/api/rbac/grants/cross-team' do + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available? + + dataset = Legion::Data::Model::RbacCrossTeamGrant.order(:id) + json_collection(dataset) + end + + app.post '/api/rbac/grants/cross-team' do + Legion::Logging.debug "API: POST /api/rbac/grants/cross-team params=#{params.keys}" + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available? + + body = parse_request_body + record = Legion::Data::Model::RbacCrossTeamGrant.create( + source_team: body[:source_team], + target_team: body[:target_team], + runner_pattern: body[:runner_pattern], + actions: Array(body[:actions]).join(','), + granted_by: current_owner_msid || 'api', + expires_at: body[:expires_at] ? Time.parse(body[:expires_at]) : nil + ) + Legion::Logging.info "API: created cross-team RBAC grant #{record.id} #{body[:source_team]}->#{body[:target_team]}" + json_response(record.values, status_code: 201) + rescue Sequel::ValidationFailed => e + Legion::Logging.warn "API POST /api/rbac/grants/cross-team returned 422: #{e.message}" + json_error('validation_error', e.message, status_code: 422) + end + + app.delete '/api/rbac/grants/cross-team/:id' do + return json_error('rbac_unavailable', 'legion-rbac not installed', status_code: 501) unless defined?(Legion::Rbac) + return json_error('db_unavailable', 'legion-data not connected', status_code: 503) unless Legion::Rbac::Store.db_available? + + record = Legion::Data::Model::RbacCrossTeamGrant[params[:id].to_i] + halt 404, json_error('not_found', 'Grant not found', status_code: 404) unless record + + record.destroy + Legion::Logging.info "API: deleted cross-team RBAC grant #{params[:id]}" + json_response({ deleted: true }) + end + end + + class << self + private :register_roles, :register_check, :register_assignments, :register_grants, :register_cross_team_grants + end + end + end + end +end diff --git a/lib/legion/api/relationships.rb b/lib/legion/api/relationships.rb new file mode 100644 index 00000000..7149dee1 --- /dev/null +++ b/lib/legion/api/relationships.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Relationships + def self.registered(app) + app.get '/api/relationships' do + require_data! + json_collection(Legion::Data::Model::Relationship.order(:id)) + end + + app.post '/api/relationships' do + Legion::Logging.debug "API: POST /api/relationships params=#{params.keys}" + require_data! + body = parse_request_body + id = Legion::Data::Model::Relationship.insert(body) + record = Legion::Data::Model::Relationship[id] + Legion::Logging.info "API: created relationship #{id}" + json_response(record.values, status_code: 201) + end + + app.get '/api/relationships/:id' do + require_data! + record = find_or_halt(Legion::Data::Model::Relationship, params[:id]) + json_response(record.values) + end + + app.put '/api/relationships/:id' do + Legion::Logging.debug "API: PUT /api/relationships/#{params[:id]} params=#{params.keys}" + require_data! + record = find_or_halt(Legion::Data::Model::Relationship, params[:id]) + body = parse_request_body + record.update(body) + record.refresh + Legion::Logging.info "API: updated relationship #{params[:id]}" + json_response(record.values) + end + + app.delete '/api/relationships/:id' do + require_data! + record = find_or_halt(Legion::Data::Model::Relationship, params[:id]) + record.delete + Legion::Logging.info "API: deleted relationship #{params[:id]}" + json_response({ deleted: true }) + end + end + end + end + end +end diff --git a/lib/legion/api/router.rb b/lib/legion/api/router.rb new file mode 100644 index 00000000..2976a235 --- /dev/null +++ b/lib/legion/api/router.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + class Router + def initialize + @infrastructure_routes = [] + @library_routes = {} + @extension_routes = {} + end + + # --- Infrastructure tier --- + + def register_infrastructure(path, method: :get, summary: nil) + @infrastructure_routes << { path: path, method: method, summary: summary } + end + + def infrastructure_routes + @infrastructure_routes.dup + end + + # --- Library gem tier --- + + def register_library(gem_name, routes_module) + @library_routes[gem_name.to_s] = routes_module + end + + def library_routes + @library_routes.dup + end + + def library_names + @library_routes.keys + end + + # --- Extension tier --- + + def register_extension_route(**opts) + lex_name = opts[:lex_name] + component_type = opts[:component_type] + component_name = opts[:component_name] + method_name = opts[:method_name] + key = "#{lex_name}/#{component_type}/#{component_name}/#{method_name}" + @extension_routes[key] = { + lex_name: lex_name.to_s, + amqp_prefix: opts[:amqp_prefix].to_s, + component_type: component_type.to_s, + component_name: component_name.to_s, + method_name: method_name.to_s, + runner_class: opts[:runner_class], + definition: opts[:definition] + } + end + + def find_extension_route(lex_name, component_type, component_name, method_name) + key = "#{lex_name}/#{component_type}/#{component_name}/#{method_name}" + @extension_routes[key] + end + + def extension_routes + @extension_routes.dup + end + + def extension_names + @extension_routes.values.map { |r| r[:lex_name] }.uniq + end + + def components_for(lex_name) + @extension_routes.values + .select { |r| r[:lex_name] == lex_name.to_s } + .group_by { |r| r[:component_type] } + end + + def methods_for(lex_name, component_type, component_name) + @extension_routes.values.select do |r| + r[:lex_name] == lex_name.to_s && + r[:component_type] == component_type.to_s && + r[:component_name] == component_name.to_s + end + end + + def discovery_extension(lex_name) + comps = components_for(lex_name) + return nil if comps.empty? + + comps.transform_values do |routes| + routes.map { |r| { name: r[:component_name], method: r[:method_name], definition: r[:definition] } } + end + end + + def clear! + @infrastructure_routes.clear + @library_routes.clear + @extension_routes.clear + end + end + end +end diff --git a/lib/legion/api/schedules.rb b/lib/legion/api/schedules.rb new file mode 100644 index 00000000..3081bac9 --- /dev/null +++ b/lib/legion/api/schedules.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Schedules + def self.registered(app) + register_list_and_create(app) + register_show_update_delete(app) + register_logs(app) + end + + def self.register_list_and_create(app) + app.get '/api/schedules' do + require_scheduler! + dataset = Legion::Extensions::Scheduler::Data::Model::Schedule.order(:id) + dataset = dataset.where(active: true) if params[:active] == 'true' + json_collection(dataset) + end + + app.post '/api/schedules' do + Legion::Logging.debug "API: POST /api/schedules params=#{params.keys}" + require_scheduler! + body = parse_request_body + + unless body[:function_id] + Legion::Logging.warn 'API POST /api/schedules returned 422: function_id is required' + halt 422, json_error('missing_field', 'function_id is required', status_code: 422) + end + unless body[:cron] || body[:interval] + Legion::Logging.warn 'API POST /api/schedules returned 422: cron or interval is required' + halt 422, json_error('missing_field', 'cron or interval is required', status_code: 422) + end + + attrs = build_schedule_attrs(body) + id = Legion::Extensions::Scheduler::Data::Model::Schedule.insert(attrs) + schedule = Legion::Extensions::Scheduler::Data::Model::Schedule[id] + Legion::Logging.info "API: created schedule #{id}" + json_response(schedule.values, status_code: 201) + end + end + + def self.register_show_update_delete(app) + app.get '/api/schedules/:id' do + require_scheduler! + schedule = find_or_halt(Legion::Extensions::Scheduler::Data::Model::Schedule, params[:id]) + json_response(schedule.values) + end + + app.put '/api/schedules/:id' do + require_scheduler! + schedule = find_or_halt(Legion::Extensions::Scheduler::Data::Model::Schedule, params[:id]) + body = parse_request_body + + updates = build_schedule_updates(body) + schedule.update(updates) unless updates.empty? + schedule.refresh + Legion::Logging.info "API: updated schedule #{params[:id]}" + json_response(schedule.values) + end + + app.delete '/api/schedules/:id' do + require_scheduler! + schedule = find_or_halt(Legion::Extensions::Scheduler::Data::Model::Schedule, params[:id]) + schedule.delete + Legion::Logging.info "API: deleted schedule #{params[:id]}" + json_response({ deleted: true }) + end + end + + def self.register_logs(app) + app.get '/api/schedules/:id/logs' do + require_scheduler! + find_or_halt(Legion::Extensions::Scheduler::Data::Model::Schedule, params[:id]) + logs = Legion::Extensions::Scheduler::Data::Model::ScheduleLog + .where(schedule_id: params[:id].to_i) + .order(Sequel.desc(:id)) + json_collection(logs) + end + end + + class << self + private :register_list_and_create, :register_show_update_delete, :register_logs + end + end + end + end +end diff --git a/lib/legion/api/settings.rb b/lib/legion/api/settings.rb new file mode 100644 index 00000000..c14ef175 --- /dev/null +++ b/lib/legion/api/settings.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Settings + SENSITIVE_KEYS = %i[password secret token key cert private_key api_key].freeze + READONLY_SECTIONS = %i[crypt transport identity rbac api].freeze + + def self.registered(app) + app.get '/api/settings' do + redacted = redact_hash(Legion::Settings.loader.to_hash) + json_response(redacted) + end + + app.get '/api/settings/:key' do + key = params[:key].to_sym + settings_hash = Legion::Settings.loader.to_hash + unless settings_hash.key?(key) + Legion::Logging.warn "API GET /api/settings/#{key} returned 404: setting not found" + halt 404, json_error('not_found', "setting '#{key}' not found", status_code: 404) + end + + value = Legion::Settings[key] + value = redact_hash(value) if value.is_a?(Hash) + json_response({ key: key, value: value }) + end + + app.put '/api/settings/:key' do + Legion::Logging.debug "API: PUT /api/settings/#{params[:key]} params=#{params.keys}" + key = params[:key].to_sym + + if READONLY_SECTIONS.include?(key) + Legion::Logging.warn "API PUT /api/settings/#{key} returned 403: read-only section" + halt 403, json_error('forbidden', "setting '#{key}' is read-only via API", status_code: 403) + end + + body = parse_request_body + unless body.key?(:value) + Legion::Logging.warn "API PUT /api/settings/#{key} returned 422: value is required" + halt 422, json_error('missing_field', 'value is required', status_code: 422) + end + + Legion::Settings.loader[key] = body[:value] + Legion::Logging.info "API: updated setting #{key}" + json_response({ key: key, value: body[:value] }) + end + end + end + end + end +end diff --git a/lib/legion/api/skills.rb b/lib/legion/api/skills.rb new file mode 100644 index 00000000..0e69849a --- /dev/null +++ b/lib/legion/api/skills.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + class API < Sinatra::Base + module Routes + module Skills + def self.registered(app) + app.helpers do + define_method(:skills_registry_available?) do + defined?(Legion::LLM::Skills::Registry) + end + + define_method(:skill_descriptor) do |skill| + { + name: skill.skill_name, + namespace: skill.namespace, + description: skill.description, + trigger: skill.trigger, + follows: skill.follows_skill + } + end + end + + register_list(app) + register_show(app) + register_invoke(app) + register_cancel(app) + end + + def self.register_list(app) + app.get '/api/skills' do + return json_error('skills_unavailable', 'Skills unavailable', status_code: 503) unless skills_registry_available? + + skills = Legion::LLM::Skills::Registry.all.map { |s| skill_descriptor(s) } + json_response(skills) + end + end + + def self.register_show(app) + app.get '/api/skills/:namespace/:name' do + return json_error('skills_unavailable', 'Skills unavailable', status_code: 503) unless skills_registry_available? + + key = "#{params[:namespace]}:#{params[:name]}" + skill = Legion::LLM::Skills::Registry.find(key) + return json_error('not_found', "Skill #{key} not found", status_code: 404) unless skill + + json_response(skill_descriptor(skill).merge(steps: skill.steps)) + end + end + + def self.register_invoke(app) + app.post '/api/skills/invoke' do + return json_error('skills_unavailable', 'Skills unavailable', status_code: 503) unless skills_registry_available? + + body = parse_request_body + skill_name = body[:skill_name] + return json_error('unprocessable', 'skill_name required', status_code: 422) if skill_name.nil? || skill_name.empty? + + skill_class = Legion::LLM::Skills::Registry.find(skill_name) + return json_error('not_found', "Skill #{skill_name} not found", status_code: 404) unless skill_class + + conv_id = body[:conversation_id] || "conv_#{SecureRandom.hex(8)}" + begin + Legion::LLM::ConversationStore.set_skill_state(conv_id, skill_key: skill_name, resume_at: 0) + require 'legion/llm/inference' unless defined?(Legion::LLM::Inference::Request) && + defined?(Legion::LLM::Inference::Executor) + + req = Legion::LLM::Inference::Request.build( + messages: [{ role: :user, content: body[:initial_message] || 'start skill' }], + conversation_id: conv_id, + metadata: (body[:metadata].is_a?(Hash) ? body[:metadata] : {}).merge(skill_invoke: true), + stream: false + ) + result = Legion::LLM::Inference::Executor.new(req).call + json_response({ conversation_id: conv_id, content: result.message[:content], + skill_name: skill_name }) + rescue StandardError => e + Legion::LLM::ConversationStore.clear_skill_state(conv_id) + json_error('internal_error', e.message, status_code: 500) + end + end + end + + def self.register_cancel(app) + app.delete '/api/skills/active/:conversation_id' do + conv_id = params[:conversation_id] + if defined?(Legion::LLM::ConversationStore) + state = Legion::LLM::ConversationStore.cancel_skill!(conv_id) + if state && defined?(Legion::Events) + Legion::Events.emit('skill.cancelled', conversation_id: conv_id, + skill_name: state[:skill_key]) + end + end + status 204 + end + end + + private_class_method :register_list, :register_show, :register_invoke, :register_cancel + end + end + end +end diff --git a/lib/legion/api/stats.rb b/lib/legion/api/stats.rb new file mode 100644 index 00000000..ac1ddee1 --- /dev/null +++ b/lib/legion/api/stats.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Stats + def self.registered(app) + app.get '/api/stats' do + result = {} + result[:extensions] = Routes::Stats.collect_extensions + result[:gaia] = Routes::Stats.collect_gaia + result[:transport] = Routes::Stats.collect_transport + result[:cache] = Routes::Stats.collect_cache + result[:cache_local] = Routes::Stats.collect_cache_local + result[:llm] = Routes::Stats.collect_llm + result[:data] = Routes::Stats.collect_data + result[:data_local] = Routes::Stats.collect_data_local + result[:api] = Routes::Stats.collect_api + json_response(result) + end + end + + EXTENSION_TASK_IVARS = { + discovered: :@extensions, + subscription: :@subscription_tasks, + every: :@timer_tasks, + poll: :@poll_tasks, + once: :@once_tasks, + loop: :@loop_tasks, + actors: :@running_instances + }.freeze + + class << self + def collect_extensions + ext = Legion::Extensions + { + loaded: ext.extension_handle_registry.loaded.count, + running: ext.extension_handle_registry.running.count + }.merge(EXTENSION_TASK_IVARS.transform_values { |ivar| ext.instance_variable_get(ivar)&.count || 0 }) + rescue StandardError => e + { error: e.message } + end + + def collect_gaia + return { started: false } unless defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:started?) && Legion::Gaia.started? + + Legion::Gaia.status + rescue StandardError => e + { error: e.message } + end + + def collect_transport + conn = Legion::Transport::Connection + connected = begin + Legion::Settings[:transport][:connected] + rescue StandardError + false + end + connector = defined?(Legion::Transport::TYPE) ? Legion::Transport::TYPE.to_s : 'unknown' + + info = { connected: connected, connector: connector } + + session = conn.session + if session.respond_to?(:open?) && session.open? + info[:session_open] = true + info[:channel_max] = session.channel_max if session.respond_to?(:channel_max) + # Bunny tracks open channels in @channels hash + channels = session.instance_variable_get(:@channels) + info[:channels_open] = channels.is_a?(Hash) ? channels.count : nil + else + info[:session_open] = false + end + + info[:build_session_open] = conn.build_session_open? + info[:lite_mode] = conn.lite_mode? + info + rescue StandardError => e + { error: e.message } + end + + def collect_cache + return { connected: false } unless defined?(Legion::Cache) + + info = { connected: Legion::Cache.connected? } + info[:using_local] = Legion::Cache.using_local? if Legion::Cache.respond_to?(:using_local?) + info[:using_memory] = Legion::Cache.instance_variable_get(:@using_memory) == true + info[:driver] = begin + Legion::Settings[:cache][:driver] + rescue StandardError + nil + end + + if Legion::Cache.connected? && Legion::Cache.respond_to?(:size) + info[:pool_size] = begin + Legion::Cache.size + rescue StandardError + nil + end + info[:pool_available] = begin + Legion::Cache.available + rescue StandardError + nil + end + end + info + rescue StandardError => e + { error: e.message } + end + + def collect_cache_local + return { connected: false } unless defined?(Legion::Cache::Local) + + info = { connected: Legion::Cache::Local.connected? } + if Legion::Cache::Local.connected? + info[:pool_size] = begin + Legion::Cache::Local.size + rescue StandardError + nil + end + info[:pool_available] = begin + Legion::Cache::Local.available + rescue StandardError + nil + end + end + info + rescue StandardError => e + { error: e.message } + end + + def collect_llm + return { started: false } unless defined?(Legion::LLM) && Legion::LLM.started? + + info = { started: true } + s = Legion::LLM.settings + info[:default_model] = s[:default_model] + info[:default_provider] = s[:default_provider] + info[:pipeline_enabled] = s[:pipeline_enabled] == true + + if defined?(Legion::LLM::Router) && Legion::LLM::Router.routing_enabled? + info[:routing_enabled] = true + tracker = Legion::LLM::Router.health_tracker + if tracker + providers = s[:providers] || {} + info[:provider_health] = providers.each_with_object({}) do |(name, _cfg), h| + h[name] = { circuit: tracker.circuit_state(name)&.to_s } + rescue StandardError + nil + end + end + else + info[:routing_enabled] = false + end + + if defined?(Legion::LLM::ConversationStore) + store = Legion::LLM::ConversationStore + info[:conversations] = store.respond_to?(:size) ? store.size : nil + end + info + rescue StandardError => e + { error: e.message } + end + + def collect_data + return { connected: false } unless defined?(Legion::Data) && Legion::Settings[:data][:connected] + + if Legion::Data.respond_to?(:stats) + stats = Legion::Data.stats + stats[:shared] || stats + else + { connected: true, adapter: begin + Legion::Data::Connection.adapter + rescue StandardError + nil + end } + end + rescue StandardError => e + { error: e.message } + end + + def collect_data_local + return { connected: false } unless defined?(Legion::Data::Local) && Legion::Data::Local.connected? + + if Legion::Data::Local.respond_to?(:stats) + Legion::Data::Local.stats + else + { connected: true } + end + rescue StandardError => e + { error: e.message } + end + + def collect_api + port = Legion::Settings.dig(:api, :port) || Legion::Settings.dig(:http, :port) || 4567 + info = { port: port } + + # Puma thread pool stats if available + puma_server = Puma::Server.current if defined?(Puma::Server) && Puma::Server.respond_to?(:current) + if puma_server.respond_to?(:pool_capacity) + info[:puma] = { + pool_capacity: puma_server.pool_capacity, + max_threads: puma_server.max_threads, + running: puma_server.running, + backlog: puma_server.backlog + } + end + + info[:routes] = begin + Legion::API.routes.values.flatten.count + rescue StandardError + nil + end + info + rescue StandardError => e + { error: e.message } + end + end + end + end + end +end diff --git a/lib/legion/api/sync_dispatch.rb b/lib/legion/api/sync_dispatch.rb new file mode 100644 index 00000000..cfc54791 --- /dev/null +++ b/lib/legion/api/sync_dispatch.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + class API < Sinatra::Base + module SyncDispatch + # Dispatch a message synchronously via AMQP using a temporary reply_to queue. + # Blocks until a response arrives or the timeout expires. + # + # @param exchange_name [String] target exchange (e.g. "lex.github") + # @param routing_key [String] routing key (e.g. "lex.github.runners.pull_request.create") + # @param payload [Hash] message payload + # @param envelope [Hash] task envelope (task_id, conversation_id, etc.) + # @param timeout [Integer] seconds to wait (default 30) + # @return [Hash] + def self.dispatch(exchange_name, routing_key, payload, envelope, timeout: 30) + unless defined?(Legion::Transport) && + Legion::Transport.respond_to?(:connected?) && + Legion::Transport.connected? + return envelope.merge( + status: 'failed', + error: { code: 503, message: 'Transport not available for sync dispatch' } + ) + end + + perform_dispatch(exchange_name, routing_key, payload, envelope, timeout) + rescue StandardError => e + Legion::Logging.error "[SyncDispatch] #{e.class}: #{e.message}" if defined?(Legion::Logging) + envelope.merge( + status: 'failed', + error: { code: 500, message: e.message } + ) + end + + # @api private + def self.perform_dispatch(exchange_name, routing_key, payload, envelope, timeout) + response = nil + mutex = Mutex.new + condition = ConditionVariable.new + reply_queue_name = "sync.reply.#{::SecureRandom.uuid}" + + begin + channel = Legion::Transport.channel + reply_queue = channel.queue(reply_queue_name, exclusive: true, auto_delete: true) + subscribe_reply(reply_queue, mutex, condition) { |r| response = r } + publish_sync(channel, exchange_name, routing_key, payload, envelope, reply_queue_name) + wait_for_response(mutex, condition, timeout) { response } + response || envelope.merge( + status: 'timeout', + error: { code: 504, message: "Sync dispatch timed out after #{timeout}s" } + ) + ensure + reply_queue&.delete rescue nil # rubocop:disable Style/RescueModifier + end + end + + # @api private + def self.subscribe_reply(reply_queue, mutex, condition) + reply_queue.subscribe(block: false) do |_delivery_info, _metadata, body| + parsed = begin + Legion::JSON.load(body) + rescue StandardError + { raw: body } + end + mutex.synchronize do + yield parsed + condition.signal + end + end + end + + # @api private + def self.wait_for_response(mutex, condition, timeout) + mutex.synchronize do + deadline = Time.now + timeout + loop do + remaining = deadline - Time.now + break if yield || remaining <= 0 + + condition.wait(mutex, remaining) + end + end + end + + # @api private + def self.publish_sync(channel, exchange_name, routing_key, payload, envelope, reply_queue_name) # rubocop:disable Metrics/ParameterLists + exchange = channel.exchange(exchange_name, type: :topic, durable: true, passive: true) + message = Legion::JSON.dump(payload.merge(envelope)) + exchange.publish( + message, + routing_key: routing_key, + reply_to: reply_queue_name, + content_type: 'application/json', + persistent: false + ) + end + + private_class_method :perform_dispatch, :subscribe_reply, :wait_for_response, :publish_sync + end + end +end diff --git a/lib/legion/api/tasks.rb b/lib/legion/api/tasks.rb new file mode 100644 index 00000000..38082b8a --- /dev/null +++ b/lib/legion/api/tasks.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Tasks + def self.registered(app) + register_collection(app) + register_member(app) + end + + def self.register_collection(app) + app.get '/api/tasks' do + require_data! + dataset = Legion::Data::Model::Task.order(Sequel.desc(:id)) + dataset = dataset.where(status: params[:status]) if params[:status] + dataset = dataset.where(function_id: params[:function_id].to_i) if params[:function_id] + json_collection(dataset) + end + + app.post '/api/tasks' do + Legion::Logging.debug "API: POST /api/tasks params=#{params.keys}" + body = parse_request_body + runner_class = body.delete(:runner_class) + function = body.delete(:function) + + if runner_class.nil? + Legion::Logging.warn 'API POST /api/tasks returned 422: runner_class is required' + halt 422, json_error('missing_field', 'runner_class is required', status_code: 422) + end + if function.nil? + Legion::Logging.warn 'API POST /api/tasks returned 422: function is required' + halt 422, json_error('missing_field', 'function is required', status_code: 422) + end + + result = Legion::Ingress.run( + payload: body, runner_class: runner_class, function: function.to_sym, + source: 'api', check_subtask: body.fetch(:check_subtask, true), + generate_task: body.fetch(:generate_task, true) + ) + Legion::Logging.info "API: created task #{result[:task_id]} via #{runner_class}##{function}" + json_response(result, status_code: 201) + rescue NameError => e + Legion::Logging.warn "API POST /api/tasks returned 422: #{e.message}" + json_error('invalid_runner', e.message, status_code: 422) + rescue StandardError => e + Legion::Logging.error "API POST /api/tasks: #{e.class} — #{e.message}" + json_error('execution_error', e.message, status_code: 500) + end + end + + def self.register_member(app) + app.get '/api/tasks/:id' do + require_data! + task = find_or_halt(Legion::Data::Model::Task, params[:id]) + data = task.values + + if defined?(Legion::Data) && Legion::Data.connection.table_exists?(:metering_records) + metering = Legion::Data.connection[:metering_records].where(task_id: params[:id].to_i) + if metering.any? + data[:metering] = { + total_tokens: metering.sum(:total_tokens) || 0, + input_tokens: metering.sum(:input_tokens) || 0, + output_tokens: metering.sum(:output_tokens) || 0, + thinking_tokens: metering.sum(:thinking_tokens) || 0, + total_calls: metering.count, + avg_latency_ms: metering.avg(:latency_ms)&.round(1) || 0, + provider: metering.select_map(:provider).uniq, + model: metering.select_map(:model_id).uniq + } + end + end + + json_response(data) + end + + app.delete '/api/tasks/:id' do + require_data! + task = find_or_halt(Legion::Data::Model::Task, params[:id]) + task.delete + Legion::Logging.info "API: deleted task #{params[:id]}" + json_response({ deleted: true }) + end + + app.get '/api/tasks/:id/logs' do + require_data! + find_or_halt(Legion::Data::Model::Task, params[:id]) + logs = Legion::Data::Model::TaskLog.where(task_id: params[:id].to_i).order(Sequel.desc(:id)) + json_collection(logs) + end + end + + class << self + private :register_collection, :register_member + end + end + end + end +end diff --git a/lib/legion/api/tbi_patterns.rb b/lib/legion/api/tbi_patterns.rb new file mode 100644 index 00000000..c0064a85 --- /dev/null +++ b/lib/legion/api/tbi_patterns.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require 'digest' + +module Legion + class API < Sinatra::Base + module Routes + module TbiPatterns + MAX_DESCRIPTION_BYTES = 1024 + MAX_PAYLOAD_SHAPE_BYTES = 65_536 + VALID_TIERS = %w[tier1 tier2 tier3 tier4 tier5].freeze + + def self.registered(app) + register_export(app) + register_discover(app) + register_all(app) + register_score(app) + register_fetch(app) + end + + # POST /api/tbi/patterns/export — anonymously export a learned behavioral pattern + def self.register_export(app) + app.post '/api/tbi/patterns/export' do + require_data! + body = parse_request_body + + if body[:pattern_type].to_s.strip.empty? + Legion::Logging.warn 'API POST /api/tbi/patterns/export returned 422: pattern_type is required' if defined?(Legion::Logging) + halt 422, json_error('missing_field', 'pattern_type is required', status_code: 422) + end + if body[:description].to_s.strip.empty? + Legion::Logging.warn 'API POST /api/tbi/patterns/export returned 422: description is required' if defined?(Legion::Logging) + halt 422, json_error('missing_field', 'description is required', status_code: 422) + end + if body[:pattern_data].to_s.strip.empty? + Legion::Logging.warn 'API POST /api/tbi/patterns/export returned 422: pattern_data is required' if defined?(Legion::Logging) + halt 422, json_error('missing_field', 'pattern_data is required', status_code: 422) + end + if body[:tier].to_s.strip.empty? + Legion::Logging.warn 'API POST /api/tbi/patterns/export returned 422: tier is required' if defined?(Legion::Logging) + halt 422, json_error('missing_field', 'tier is required', status_code: 422) + end + + if body[:description].to_s.bytesize > MAX_DESCRIPTION_BYTES + halt 422, json_error('field_too_large', "description exceeds #{MAX_DESCRIPTION_BYTES} bytes", status_code: 422) + end + + pattern_data_str = Routes::TbiPatterns.serialize_pattern_data(body[:pattern_data]) + if pattern_data_str.bytesize > MAX_PAYLOAD_SHAPE_BYTES + halt 422, json_error('field_too_large', "pattern_data exceeds #{MAX_PAYLOAD_SHAPE_BYTES} bytes", status_code: 422) + end + + unless VALID_TIERS.include?(body[:tier].to_s) + halt 422, json_error('invalid_field', "tier must be one of: #{VALID_TIERS.join(', ')}", status_code: 422) + end + + # Anonymize: strip any identifying keys before persisting + anonymous_data = Routes::TbiPatterns.anonymize(body) + + invocation_count = Routes::TbiPatterns.parse_integer(body[:invocation_count], 0) + success_rate = Routes::TbiPatterns.parse_float(body[:success_rate], 0.0) + quality_score = Routes::TbiPatterns.compute_quality( + invocation_count: invocation_count, + success_rate: success_rate, + tier: body[:tier].to_s + ) + + record = Legion::Data::Model::TbiPattern.create( + pattern_type: body[:pattern_type].to_s, + description: body[:description].to_s, + tier: body[:tier].to_s, + pattern_data: pattern_data_str, + quality_score: quality_score, + invocation_count: invocation_count, + success_rate: success_rate, + source_hash: anonymous_data[:source_hash] + ) + Legion::Logging.info "API: exported TBI pattern id=#{record.id} tier=#{record.tier}" if defined?(Legion::Logging) + json_response(record.values, status_code: 201) + rescue StandardError => e + Legion::Logging.error "API POST /api/tbi/patterns/export: #{e.class} — #{e.message}" if defined?(Legion::Logging) + json_error('export_error', e.message, status_code: 500) + end + end + + # GET /api/tbi/patterns/:id — fetch a single pattern by integer ID + def self.register_fetch(app) + app.get '/api/tbi/patterns/:id' do + require_data! + id_val = params[:id].to_i + halt 422, json_error('invalid_id', 'id must be a positive integer', status_code: 422) if id_val <= 0 + + record = Legion::Data::Model::TbiPattern.first(id: id_val) + halt 404, json_error('not_found', "TBI pattern #{params[:id]} not found", status_code: 404) unless record + + json_response(record.values) + rescue StandardError => e + Legion::Logging.error "API GET /api/tbi/patterns/#{params[:id]}: #{e.class} — #{e.message}" if defined?(Legion::Logging) + json_error('fetch_error', e.message, status_code: 500) + end + end + + # GET /api/tbi/patterns — list patterns with optional tier/type filter + def self.register_all(app) + app.get '/api/tbi/patterns' do + require_data! + dataset = Legion::Data::Model::TbiPattern.order(Sequel.desc(:quality_score)) + dataset = dataset.where(tier: params[:tier]) if params[:tier] + dataset = dataset.where(pattern_type: params[:type]) if params[:type] + json_collection(dataset) + rescue StandardError => e + Legion::Logging.error "API GET /api/tbi/patterns: #{e.class} — #{e.message}" if defined?(Legion::Logging) + json_error('list_error', e.message, status_code: 500) + end + end + + # PATCH /api/tbi/patterns/:id/score — update quality score with new usage metadata + def self.register_score(app) + app.patch '/api/tbi/patterns/:id/score' do + require_data! + id_val = params[:id].to_i + halt 422, json_error('invalid_id', 'id must be a positive integer', status_code: 422) if id_val <= 0 + + record = Legion::Data::Model::TbiPattern.first(id: id_val) + halt 404, json_error('not_found', "TBI pattern #{params[:id]} not found", status_code: 404) unless record + + body = parse_request_body + invocation_count = Routes::TbiPatterns.parse_integer(body[:invocation_count], record.invocation_count) + success_rate = Routes::TbiPatterns.parse_float(body[:success_rate], record.success_rate) + quality_score = Routes::TbiPatterns.compute_quality( + invocation_count: invocation_count, + success_rate: success_rate, + tier: record.tier + ) + + record.update( + invocation_count: invocation_count, + success_rate: success_rate, + quality_score: quality_score + ) + Legion::Logging.info "API: rescored TBI pattern id=#{record.id} quality=#{quality_score}" if defined?(Legion::Logging) + json_response(record.values) + rescue StandardError => e + Legion::Logging.error "API PATCH /api/tbi/patterns/#{params[:id]}/score: #{e.class} — #{e.message}" if defined?(Legion::Logging) + json_error('score_error', e.message, status_code: 500) + end + end + + # GET /api/tbi/patterns/discover — cross-instance pattern discovery (P3/TBI Phase 6) + # TODO: implement cross-instance discovery + def self.register_discover(app) + app.get '/api/tbi/patterns/discover' do + halt 501, json_error('not_implemented', 'cross-instance pattern discovery is not yet available', status_code: 501) + end + end + + # --- helpers --- + + # Anonymize pattern export: remove instance-identifying fields, compute a + # one-way hash for deduplication without fingerprinting. + def self.anonymize(body) + identifying_keys = %i[node_id instance_id hostname ip_address worker_id] + sanitized = body.reject { |k, _v| identifying_keys.include?(k.to_sym) } + # Remove both string and symbol variants + sanitized = sanitized.reject { |k, _v| identifying_keys.map(&:to_s).include?(k.to_s) } + + salt_source = "#{body[:pattern_type]}:#{body[:tier]}:#{body[:description]}" + source_hash = Digest::SHA256.hexdigest(salt_source)[0, 16] + + sanitized.merge(source_hash: source_hash) + end + + def self.serialize_pattern_data(pattern_data) + return pattern_data.to_s if pattern_data.is_a?(String) + + Legion::JSON.dump(pattern_data) + rescue StandardError + Legion::JSON.dump(pattern_data.to_s) + end + + def self.compute_quality(invocation_count:, success_rate:, tier:) + # tier weight: higher tiers (closer to tier5) earn a modest bonus + tier_num = tier.to_s.gsub(/[^0-9]/, '').to_i.clamp(1, 5) + tier_weight = tier_num / 5.0 + + count_score = [invocation_count.to_f / 100.0, 1.0].min + success_score = success_rate.to_f.clamp(0.0, 1.0) + + ((count_score * 0.4) + (success_score * 0.5) + (tier_weight * 0.1)).round(4) + end + + # Parse an integer from user input; return default if blank or invalid. + def self.parse_integer(value, default) + return default if value.nil? + return default if value.to_s.strip.empty? + raise ArgumentError, 'not numeric' unless value.to_s =~ /\A-?\d+\z/ + + [value.to_i, 0].max + rescue ArgumentError + default + end + + # Parse a float from user input; return default if blank or invalid. + def self.parse_float(value, default) + return default if value.nil? + return default if value.to_s.strip.empty? + raise ArgumentError, 'not numeric' unless value.to_s =~ /\A-?\d+(\.\d+)?\z/ + + value.to_f.clamp(0.0, 1.0) + rescue ArgumentError + default + end + + private_class_method :register_export, :register_fetch, :register_all, + :register_score, :register_discover + end + end + end +end diff --git a/lib/legion/api/tenants.rb b/lib/legion/api/tenants.rb new file mode 100644 index 00000000..bbdcd516 --- /dev/null +++ b/lib/legion/api/tenants.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative '../tenants' + +module Legion + class API < Sinatra::Base + module Routes + module Tenants + def self.registered(app) + app.get '/api/tenants' do + tenants = Legion::Tenants.list + json_response(tenants) + end + + app.post '/api/tenants' do + body = parse_request_body + result = Legion::Tenants.create( + tenant_id: body[:tenant_id], + name: body[:name], + max_workers: body[:max_workers] || 10 + ) + json_response(result, status_code: result[:error] ? 409 : 201) + end + + app.get '/api/tenants/:tenant_id' do + tenant = Legion::Tenants.find(params[:tenant_id]) + halt 404, json_error('not_found', 'Tenant not found', status_code: 404) unless tenant + json_response(tenant) + end + + app.post '/api/tenants/:tenant_id/suspend' do + result = Legion::Tenants.suspend(tenant_id: params[:tenant_id]) + json_response(result) + end + + app.get '/api/tenants/:tenant_id/quota/:resource' do + result = Legion::Tenants.check_quota( + tenant_id: params[:tenant_id], + resource: params[:resource].to_sym + ) + json_response(result) + end + end + end + end + end +end diff --git a/lib/legion/api/token.rb b/lib/legion/api/token.rb new file mode 100644 index 00000000..0b946874 --- /dev/null +++ b/lib/legion/api/token.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Token + def self.issue_worker_token(worker_id:, owner_msid:, ttl: 3600) + Legion::Crypt::JWT.issue( + { worker_id: worker_id, sub: owner_msid, scope: 'worker' }, + signing_key: signing_key, + ttl: ttl, + issuer: 'legion' + ) + end + + def self.issue_human_token(msid:, name: nil, roles: [], ttl: 28_800) + Legion::Crypt::JWT.issue( + { sub: msid, name: name, roles: roles, scope: 'human' }, + signing_key: signing_key, + ttl: ttl, + issuer: 'legion' + ) + end + + def self.signing_key + return Legion::Crypt.cluster_secret if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:cluster_secret) + + raise 'no signing key available - Legion::Crypt not initialized' + end + end + end +end diff --git a/lib/legion/api/traces.rb b/lib/legion/api/traces.rb new file mode 100644 index 00000000..a109a768 --- /dev/null +++ b/lib/legion/api/traces.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Traces + def self.registered(app) + register_search(app) + register_summary(app) + register_anomalies(app) + register_trend(app) + end + + def self.register_search(app) + app.post '/api/traces/search' do + require_trace_search! + body = parse_request_body + halt 422, json_error('missing_field', 'query is required', status_code: 422) unless body[:query] + + result = Legion::TraceSearch.search(body[:query], limit: body[:limit] || 50) + json_response(result) + end + end + + def self.register_summary(app) + app.post '/api/traces/summary' do + require_trace_search! + body = parse_request_body + halt 422, json_error('missing_field', 'query is required', status_code: 422) unless body[:query] + + result = Legion::TraceSearch.summarize(body[:query]) + json_response(result) + end + end + + def self.register_anomalies(app) + app.get '/api/traces/anomalies' do + require_trace_search! + threshold = (params[:threshold] || 2.0).to_f + result = Legion::TraceSearch.detect_anomalies(threshold: threshold) + json_response(result) + end + end + + def self.register_trend(app) + app.get '/api/traces/trend' do + require_trace_search! + hours = (params[:hours] || 24).to_i.clamp(1, 168) + buckets = (params[:buckets] || 12).to_i.clamp(2, 48) + result = Legion::TraceSearch.trend(hours: hours, buckets: buckets) + json_response(result) + end + end + + class << self + private :register_search, :register_summary, :register_anomalies, :register_trend + end + end + end + end +end diff --git a/lib/legion/api/transport.rb b/lib/legion/api/transport.rb new file mode 100644 index 00000000..8d1982cc --- /dev/null +++ b/lib/legion/api/transport.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Transport + def self.registered(app) + register_status(app) + register_discovery(app) + register_publish(app) + end + + def self.register_status(app) + app.get '/api/transport' do + connected = begin + Legion::Settings[:transport][:connected] + rescue StandardError => e + Legion::Logging.debug "Transport#status failed to read connected setting: #{e.message}" if defined?(Legion::Logging) + false + end + session_open = begin + Legion::Transport::Connection.session_open? + rescue StandardError => e + Legion::Logging.debug "Transport#status failed to check session_open: #{e.message}" if defined?(Legion::Logging) + false + end + channel_open = begin + Legion::Transport::Connection.channel_open? + rescue StandardError => e + Legion::Logging.debug "Transport#status failed to check channel_open: #{e.message}" if defined?(Legion::Logging) + false + end + connector = defined?(Legion::Transport::TYPE) ? Legion::Transport::TYPE.to_s : 'unknown' + + json_response({ connected: connected, session_open: session_open, + channel_open: channel_open, connector: connector }) + end + end + + def self.register_discovery(app) + app.get '/api/transport/exchanges' do + klass = defined?(Legion::Transport::Exchange) ? Legion::Transport::Exchange : nil + json_response(klass ? transport_subclasses(klass) : []) + end + + app.get '/api/transport/queues' do + klass = defined?(Legion::Transport::Queue) ? Legion::Transport::Queue : nil + json_response(klass ? transport_subclasses(klass) : []) + end + end + + def self.register_publish(app) + app.post '/api/transport/publish' do + Legion::Logging.debug "API: POST /api/transport/publish params=#{params.keys}" + body = parse_request_body + unless body[:exchange] + Legion::Logging.warn 'API POST /api/transport/publish returned 422: exchange is required' + halt 422, json_error('missing_field', 'exchange is required', status_code: 422) + end + unless body[:routing_key] + Legion::Logging.warn 'API POST /api/transport/publish returned 422: routing_key is required' + halt 422, json_error('missing_field', 'routing_key is required', status_code: 422) + end + + message = Legion::Transport::Messages::Dynamic.new( + exchange: body[:exchange], routing_key: body[:routing_key], **(body[:payload] || {}) + ) + message.publish + Legion::Logging.info "API: published message to exchange=#{body[:exchange]} routing_key=#{body[:routing_key]}" + json_response({ published: true, exchange: body[:exchange], routing_key: body[:routing_key] }, status_code: 201) + rescue StandardError => e + Legion::Logging.error "API POST /api/transport/publish: #{e.class} — #{e.message}" + json_error('publish_error', e.message, status_code: 500) + end + end + + class << self + private :register_status, :register_discovery, :register_publish + end + end + end + end +end diff --git a/lib/legion/api/validators.rb b/lib/legion/api/validators.rb new file mode 100644 index 00000000..182b4470 --- /dev/null +++ b/lib/legion/api/validators.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Validators + UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i + + def validate_required!(body, *keys) + missing = keys.select { |k| body[k].nil? || (body[k].respond_to?(:empty?) && body[k].empty?) } + return if missing.empty? + + halt 400, json_error('missing_fields', "required: #{missing.join(', ')}", status_code: 400) + end + + def validate_string_length!(value, field:, max: 255) + return unless value.is_a?(String) && value.length > max + + halt 400, json_error('field_too_long', "#{field} exceeds #{max} characters", status_code: 400) + end + + def validate_enum!(value, field:, allowed:) + return if value.nil? + return if allowed.include?(value.to_s) + + halt 400, json_error('invalid_value', "#{field} must be one of: #{allowed.join(', ')}", status_code: 400) + end + + def validate_uuid!(value, field:) + return if value.nil? + return if value.to_s.match?(UUID_PATTERN) + + halt 400, json_error('invalid_format', "#{field} must be a valid UUID", status_code: 400) + end + + def validate_integer!(value, field:, min: nil, max: nil) + return if value.nil? + + int_val = value.to_i + halt 400, json_error('out_of_range', "#{field} must be >= #{min}", status_code: 400) if min && int_val < min + halt 400, json_error('out_of_range', "#{field} must be <= #{max}", status_code: 400) if max && int_val > max + end + end + end +end diff --git a/lib/legion/api/webhooks.rb b/lib/legion/api/webhooks.rb new file mode 100644 index 00000000..bf694e90 --- /dev/null +++ b/lib/legion/api/webhooks.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative '../webhooks' + +module Legion + class API < Sinatra::Base + module Routes + module Webhooks + def self.registered(app) + app.get '/api/webhooks' do + json_response(Legion::Webhooks.list) + end + + app.post '/api/webhooks' do + Legion::Logging.debug "API: POST /api/webhooks params=#{params.keys}" + body = parse_request_body + result = Legion::Webhooks.register( + url: body[:url], secret: body[:secret], + event_types: body[:event_types] || ['*'], + max_retries: body[:max_retries] || 5 + ) + Legion::Logging.info "API: registered webhook for url=#{body[:url]} events=#{(body[:event_types] || ['*']).join(',')}" + json_response(result, status_code: 201) + end + + app.delete '/api/webhooks/:id' do + result = Legion::Webhooks.unregister(id: params[:id].to_i) + Legion::Logging.info "API: deleted webhook #{params[:id]}" + json_response(result) + end + end + end + end + end +end diff --git a/lib/legion/api/workers.rb b/lib/legion/api/workers.rb new file mode 100644 index 00000000..008ddb87 --- /dev/null +++ b/lib/legion/api/workers.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Workers + def self.registered(app) + register_collection(app) + register_member(app) + register_sub_resources(app) + register_approvals(app) + register_teams(app) + end + + def self.register_collection(app) + app.get '/api/workers' do + require_data! + dataset = Legion::Data::Model::DigitalWorker.order(:id) + dataset = dataset.where(team: params[:team]) if params[:team] + dataset = dataset.where(owner_msid: params[:owner_msid]) if params[:owner_msid] + dataset = dataset.where(lifecycle_state: params[:lifecycle_state]) if params[:lifecycle_state] + dataset = dataset.where(risk_tier: params[:risk_tier]) if params[:risk_tier] + dataset = dataset.where(health_status: params[:health_status]) if params[:health_status] + json_collection(dataset) + end + + app.post '/api/workers' do + Legion::Logging.debug "API: POST /api/workers params=#{params.keys}" + require_data! + body = parse_request_body + + halt 422, json_error('missing_field', 'name is required', status_code: 422) unless body[:name] + halt 422, json_error('missing_field', 'extension_name is required', status_code: 422) unless body[:extension_name] + halt 422, json_error('missing_field', 'entra_app_id is required', status_code: 422) unless body[:entra_app_id] + halt 422, json_error('missing_field', 'owner_msid is required', status_code: 422) unless body[:owner_msid] + + worker = Legion::DigitalWorker.register( + name: body[:name], + extension_name: body[:extension_name], + entra_app_id: body[:entra_app_id], + owner_msid: body[:owner_msid], + owner_name: body[:owner_name], + business_role: body[:business_role], + risk_tier: body[:risk_tier], + team: body[:team], + manager_msid: body[:manager_msid] + ) + Legion::Logging.info "API: created worker #{worker.worker_id} (#{body[:name]})" + json_response(worker.values, status_code: 201) + rescue StandardError => e + Legion::Logging.error "API POST /api/workers: #{e.class} — #{e.message}" + json_error('creation_error', e.message, status_code: 500) + end + end + + def self.register_member(app) # rubocop:disable Metrics/AbcSize + app.get '/api/workers/:id' do + require_data! + worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id]) + halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil? + json_response(worker.values) + end + + app.patch '/api/workers/:id/lifecycle' do + Legion::Logging.debug "API: PATCH /api/workers/#{params[:id]}/lifecycle params=#{params.keys}" + require_data! + worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id]) + halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil? + + body = parse_request_body + to_state = body[:state] + by = body[:by] || current_owner_msid || 'api' + reason = body[:reason] + governance_override = body[:governance_override] == true + authority_verified = body[:authority_verified] == true + + halt 422, json_error('missing_field', 'state is required', status_code: 422) unless to_state + + updated = Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: to_state, + by: by, + reason: reason, + governance_override: governance_override, + authority_verified: authority_verified + ) + Legion::Logging.info "API: worker #{params[:id]} lifecycle transitioned to #{to_state} by #{by}" + json_response(updated.values) + rescue Legion::DigitalWorker::Lifecycle::GovernanceRequired => e + Legion::Logging.warn "API PATCH /api/workers/#{params[:id]}/lifecycle returned 403: #{e.message}" + json_error('governance_required', e.message, status_code: 403) + rescue Legion::DigitalWorker::Lifecycle::AuthorityRequired => e + Legion::Logging.warn "API PATCH /api/workers/#{params[:id]}/lifecycle returned 403: #{e.message}" + json_error('authority_required', e.message, status_code: 403) + rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e + Legion::Logging.warn "API PATCH /api/workers/#{params[:id]}/lifecycle returned 422: #{e.message}" + json_error('invalid_transition', e.message, status_code: 422) + rescue StandardError => e + Legion::Logging.error "API PATCH /api/workers/#{params[:id]}/lifecycle: #{e.class} — #{e.message}" + json_error('transition_error', e.message, status_code: 500) + end + + app.delete '/api/workers/:id' do + require_data! + worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id]) + halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil? + + by = current_owner_msid || 'api' + reason = params[:reason] || 'retired via API' + + updated = Legion::DigitalWorker::Lifecycle.transition!(worker, to_state: 'retired', by: by, reason: reason) + Legion::Logging.info "API: retired worker #{params[:id]} by #{by}" + json_response(updated.values) + rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e + Legion::Logging.warn "API DELETE /api/workers/#{params[:id]} returned 422: #{e.message}" + json_error('invalid_transition', e.message, status_code: 422) + rescue StandardError => e + Legion::Logging.error "API DELETE /api/workers/#{params[:id]}: #{e.class} — #{e.message}" + json_error('transition_error', e.message, status_code: 500) + end + end + + def self.register_sub_resources(app) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + app.get '/api/workers/:id/health' do + require_data! + worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id]) + halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil? + + node_metrics = nil + if worker.health_node + node = Legion::Data::Model::Node[name: worker.health_node] + node_metrics = node&.parsed_metrics + end + + json_response({ + worker_id: worker.worker_id, + health_status: worker.health_status, + last_heartbeat_at: worker.last_heartbeat_at, + health_node: worker.health_node, + node_metrics: node_metrics + }) + end + + app.get '/api/workers/:id/tasks' do + require_data! + worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id]) + halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil? + + dataset = Legion::Data::Model::Task.where(worker_id: params[:id]).order(Sequel.desc(:id)) + json_collection(dataset) + end + + app.get '/api/workers/:id/events' do + require_data! + worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id]) + halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil? + + if params[:stream] == 'true' && defined?(Legion::Events) + content_type 'text/event-stream' + headers 'Cache-Control' => 'no-cache', + 'Connection' => 'keep-alive', + 'X-Accel-Buffering' => 'no' + + queue = Queue.new + listener = Legion::Events.on('*') do |event| + queue.push(event) if event[:worker_id] == params[:id] + end + + stream do |out| + Routes::Events.stream_queue(out: out, queue: queue, listener: listener) + end + else + count = (params[:count] || 25).to_i + all_events = Routes::Events.recent_events([count * 4, 100].min) + filtered = all_events.select { |e| e['worker_id'] == params[:id] || e[:worker_id] == params[:id] } + json_response({ worker_id: params[:id], events: filtered.last(count) }) + end + end + + app.get '/api/workers/:id/costs' do + require_data! + worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id]) + halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil? + + json_response({ + worker_id: params[:id], + total_cost: nil, + currency: 'USD', + metering_period: nil, + note: 'cost metering requires lex-metering' + }) + end + + app.get '/api/workers/:id/value' do + require_data! + worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id]) + halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil? + + summary = Legion::DigitalWorker::ValueMetrics.summary(worker_id: params[:id]) + recent = Legion::DigitalWorker::ValueMetrics.for_worker( + worker_id: params[:id], + since: params[:since] ? Time.parse(params[:since]) : (Time.now.utc - (86_400 * 7)) + ) + + json_response({ + worker_id: params[:id], + summary: summary, + recent: recent.last(50) + }) + rescue StandardError => e + Legion::Logging.error "API worker value error: #{e.message}" + json_error('value_error', e.message, status_code: 500) + end + + app.get '/api/workers/:id/roi' do + require_data! + worker = Legion::Data::Model::DigitalWorker.first(worker_id: params[:id]) + halt 404, json_error('not_found', "Worker #{params[:id]} not found", status_code: 404) if worker.nil? + + value_summary = Legion::DigitalWorker::ValueMetrics.summary(worker_id: params[:id]) + + cost_summary = if defined?(Legion::Extensions::Metering::Runners::Metering) + runner = Object.new.extend(Legion::Extensions::Metering::Runners::Metering) + runner.worker_costs(worker_id: params[:id], period: params[:period] || 'monthly') + else + { total_tokens: 0, total_calls: 0, note: 'lex-metering not available' } + end + + json_response({ + worker_id: params[:id], + value: value_summary, + cost: cost_summary + }) + rescue StandardError => e + Legion::Logging.error "API worker ROI error: #{e.message}" + json_error('roi_error', e.message, status_code: 500) + end + end + + def self.register_approvals(app) # rubocop:disable Metrics/AbcSize + require 'legion/digital_worker/registration' + + app.get '/api/workers/approvals' do + require_data! + workers = Legion::DigitalWorker::Registration.pending_approvals + json_response({ data: workers.map(&:values), count: workers.size }) + end + + app.post '/api/workers/:id/approve' do + Legion::Logging.debug "API: POST /api/workers/#{params[:id]}/approve params=#{params.keys}" + require_data! + body = parse_request_body + approver = body[:approver] || current_owner_msid || 'api' + notes = body[:notes] + + worker = Legion::DigitalWorker::Registration.approve(params[:id], approver: approver, notes: notes) + Legion::Logging.info "API: approved worker #{params[:id]} by #{approver}" + json_response(worker.values) + rescue ArgumentError => e + Legion::Logging.warn "API POST /api/workers/#{params[:id]}/approve returned 422: #{e.message}" + json_error('invalid_request', e.message, status_code: 422) + rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e + Legion::Logging.warn "API POST /api/workers/#{params[:id]}/approve returned 422: #{e.message}" + json_error('invalid_transition', e.message, status_code: 422) + rescue StandardError => e + Legion::Logging.error "API POST /api/workers/#{params[:id]}/approve: #{e.class} — #{e.message}" + json_error('approve_error', e.message, status_code: 500) + end + + app.post '/api/workers/:id/reject' do + Legion::Logging.debug "API: POST /api/workers/#{params[:id]}/reject params=#{params.keys}" + require_data! + body = parse_request_body + approver = body[:approver] || current_owner_msid || 'api' + reason = body[:reason] + + unless reason + Legion::Logging.warn "API POST /api/workers/#{params[:id]}/reject returned 422: reason is required" + halt 422, json_error('missing_field', 'reason is required', status_code: 422) + end + + worker = Legion::DigitalWorker::Registration.reject(params[:id], approver: approver, reason: reason) + Legion::Logging.info "API: rejected worker #{params[:id]} by #{approver}" + json_response(worker.values) + rescue ArgumentError => e + Legion::Logging.warn "API POST /api/workers/#{params[:id]}/reject returned 422: #{e.message}" + json_error('invalid_request', e.message, status_code: 422) + rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e + Legion::Logging.warn "API POST /api/workers/#{params[:id]}/reject returned 422: #{e.message}" + json_error('invalid_transition', e.message, status_code: 422) + rescue StandardError => e + Legion::Logging.error "API POST /api/workers/#{params[:id]}/reject: #{e.class} — #{e.message}" + json_error('reject_error', e.message, status_code: 500) + end + end + + def self.register_teams(app) + app.get '/api/teams/:team/workers' do + require_data! + dataset = Legion::Data::Model::DigitalWorker.where(team: params[:team]).order(:id) + json_collection(dataset) + end + + app.get '/api/teams/:team/costs' do + require_data! + json_response({ + team: params[:team], + total_cost: nil, + currency: 'USD', + metering_period: nil, + note: 'cost metering requires lex-metering' + }) + end + end + + class << self + private :register_collection, :register_member, :register_sub_resources, :register_approvals, :register_teams + end + end + end + end +end diff --git a/lib/legion/api/workflow.rb b/lib/legion/api/workflow.rb new file mode 100644 index 00000000..b0181723 --- /dev/null +++ b/lib/legion/api/workflow.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Legion + class API < Sinatra::Base + module Routes + module Workflow + def self.registered(app) + app.helpers WorkflowHelpers + app.get '/api/relationships/graph' do + require_data! + graph = build_relationship_graph( + chain_id: params[:chain_id]&.to_i, + extension: params[:extension] + ) + json_response(graph) + end + end + + module WorkflowHelpers + def build_relationship_graph(chain_id: nil, extension: nil) + raw = Legion::Graph::Builder.build(chain_id: chain_id) + + nodes = raw[:nodes].map do |id, meta| + ext = id.to_s.split('.').first + { id: id, label: meta[:label], type: meta[:type], extension: ext } + end + + edges = raw[:edges].map do |edge| + { source: edge[:from], target: edge[:to], label: edge[:label] } + end + + if extension + node_ids = nodes.select { |n| n[:extension] == extension }.map { |n| n[:id] } + nodes = nodes.select { |n| node_ids.include?(n[:id]) } + edges = edges.select { |e| node_ids.include?(e[:source]) || node_ids.include?(e[:target]) } + end + + { nodes: nodes, edges: edges } + rescue StandardError => e + Legion::Logging.warn "Workflow#build_relationship_graph failed: #{e.message}" if defined?(Legion::Logging) + { nodes: [], edges: [] } + end + end + end + end + end +end diff --git a/lib/legion/audit.rb b/lib/legion/audit.rb new file mode 100644 index 00000000..854761f6 --- /dev/null +++ b/lib/legion/audit.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Legion + module Audit + class << self + def record(event_type:, principal_id:, action:, resource:, **opts) + return unless transport_available? + + Legion::Extensions::Audit::Transport::Messages::Audit.new( + event_type: event_type, + principal_id: principal_id, + principal_type: opts[:principal_type] || 'system', + action: action, + resource: resource, + source: opts[:source] || 'unknown', + node: node_name, + status: opts[:status] || 'success', + duration_ms: opts[:duration_ms], + detail: opts[:detail], + created_at: Time.now.utc.iso8601 + ).publish + rescue StandardError => e + Legion::Logging.error "[Audit] publish failed event_type=#{event_type} resource=#{resource}: #{e.message}" if defined?(Legion::Logging) + end + + def recent_for(principal_id:, window: 3600, event_type: nil, status: nil) + return [] unless defined?(Legion::Data::Model::AuditLog) + + ds = Legion::Data::Model::AuditLog + .where(principal_id: principal_id) + .where { created_at >= Time.now.utc - window } + ds = ds.where(event_type: event_type) unless event_type.nil? + ds = ds.where(status: status) unless status.nil? + ds.all + end + + def count_for(principal_id:, window: 3600, event_type: nil, status: nil) + return 0 unless defined?(Legion::Data::Model::AuditLog) + + ds = Legion::Data::Model::AuditLog + .where(principal_id: principal_id) + .where { created_at >= Time.now.utc - window } + ds = ds.where(event_type: event_type) unless event_type.nil? + ds = ds.where(status: status) unless status.nil? + ds.count + end + + def failure_count_for(principal_id:, window: 3600) + count_for(principal_id: principal_id, window: window, status: 'failure') + end + + def success_count_for(principal_id:, window: 3600) + count_for(principal_id: principal_id, window: window, status: 'success') + end + + def resources_for(principal_id:, window: 3600) + return [] unless defined?(Legion::Data::Model::AuditLog) + + Legion::Data::Model::AuditLog + .where(principal_id: principal_id) + .where { created_at >= Time.now.utc - window } + .select_map(:resource) + .uniq + end + + def recent(limit: 50, **filters) + return [] unless defined?(Legion::Data::Model::AuditLog) + + ds = Legion::Data::Model::AuditLog.order(Sequel.desc(:created_at)).limit(limit) + filters.each { |col, val| ds = ds.where(col => val) } + ds.all + end + + private + + def transport_available? + defined?(Legion::Transport) && + Legion::Settings[:transport][:connected] == true && + defined?(Legion::Extensions::Audit::Transport::Messages::Audit) + end + + def node_name + Legion::Settings[:client][:hostname] + rescue StandardError => e + Legion::Logging.debug "Audit#node_name failed to read hostname: #{e.message}" if defined?(Legion::Logging) + 'unknown' + end + end + end +end diff --git a/lib/legion/audit/archiver.rb b/lib/legion/audit/archiver.rb new file mode 100644 index 00000000..b5a88733 --- /dev/null +++ b/lib/legion/audit/archiver.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'zlib' +require 'stringio' +require_relative 'hash_chain' +require_relative 'siem_export' +require_relative 'cold_storage' + +module Legion + module Audit + module Archiver + module_function + + def enabled? + Legion::Settings[:audit]&.dig(:retention, :enabled) == true + end + + def hot_days + Legion::Settings[:audit]&.dig(:retention, :hot_days) || 90 + end + + def warm_days + Legion::Settings[:audit]&.dig(:retention, :warm_days) || 365 + end + + def verify_on_archive? + Legion::Settings[:audit]&.dig(:retention, :verify_on_archive) != false + end + + # hot -> warm: move audit_log rows older than hot_days to audit_log_archive + def archive_to_warm(cutoff_days: hot_days) + return { moved: 0, skipped: true } unless enabled? + + result = Legion::Data::Retention.archive_old_records( + table: :audit_log, + archive_after_days: cutoff_days + ) + { moved: result[:archived], from: :hot, to: :warm } + end + + # warm -> cold: export audit_log_archive rows older than warm_days to compressed JSONL, + # upload to cold storage, record manifest, delete from warm after checksum verification + def archive_to_cold(cutoff_days: warm_days) + return { moved: 0, skipped: true } unless enabled? + + db = Legion::Data.connection + return { moved: 0, error: 'no_db' } unless db&.table_exists?(:audit_log_archive) + + cutoff = Time.now - (cutoff_days * 86_400) + dataset = db[:audit_log_archive].where(::Sequel.lit('created_at < ?', cutoff)) + count = dataset.count + return { moved: 0 } if count.zero? + + records = dataset.order(:id).all + ndjson = Legion::Audit::SiemExport.to_ndjson(records.map { |r| r.is_a?(Hash) ? r : r.values }) + gz_data = compress(ndjson) + checksum = ::Digest::SHA256.hexdigest(gz_data) + + path = cold_path(records) + Legion::Audit::ColdStorage.upload(data: gz_data, path: path) + + write_manifest( + tier: 'cold', + storage_url: path, + start_date: records.first[:created_at], + end_date: records.last[:created_at], + entry_count: count, + checksum: checksum, + first_hash: records.first[:record_hash].to_s, + last_hash: records.last[:record_hash].to_s + ) + + dataset.delete + log_info "Archived #{count} warm audit records to cold: #{path}" + { moved: count, path: path, checksum: checksum } + end + + # verify hash chain integrity for a given tier across an optional date range + def verify_chain(tier: :hot, start_date: nil, end_date: nil) + records = load_records_for_tier(tier: tier, start_date: start_date, end_date: end_date) + Legion::Audit::HashChain.verify_chain(records) + end + + def cold_storage_url + Legion::Settings[:audit]&.dig(:retention, :cold_storage) || '/var/lib/legion/audit-archive/' + end + + def cold_path(records) + ts = records.first[:created_at] + stamp = ts.respond_to?(:strftime) ? ts.strftime('%Y%m%d') : ts.to_s[0, 8].tr('-', '') + ::File.join(cold_storage_url, "audit_cold_#{stamp}_#{records.last[:id]}.jsonl.gz") + end + + def compress(text) + sio = ::StringIO.new + gz = ::Zlib::GzipWriter.new(sio) + gz.write(text) + gz.close + sio.string + end + + def write_manifest(tier:, storage_url:, start_date:, end_date:, entry_count:, checksum:, first_hash:, last_hash:) # rubocop:disable Metrics/ParameterLists + db = Legion::Data.connection + return unless db&.table_exists?(:audit_archive_manifests) + + db[:audit_archive_manifests].insert( + tier: tier, + storage_url: storage_url, + start_date: start_date, + end_date: end_date, + entry_count: entry_count, + checksum: checksum, + first_hash: first_hash, + last_hash: last_hash, + archived_at: Time.now.utc + ) + end + + def load_records_for_tier(tier:, start_date: nil, end_date: nil) + db = Legion::Data.connection + table = tier.to_sym == :hot ? :audit_log : :audit_log_archive + return [] unless db&.table_exists?(table) + + ds = db[table].order(:id) + ds = ds.where(::Sequel.lit('created_at >= ?', start_date)) if start_date + ds = ds.where(::Sequel.lit('created_at <= ?', end_date)) if end_date + ds.all + end + + def log_info(msg) + Legion::Logging.info("[Audit::Archiver] #{msg}") if defined?(Legion::Logging) + end + end + end +end diff --git a/lib/legion/audit/archiver_actor.rb b/lib/legion/audit/archiver_actor.rb new file mode 100644 index 00000000..a8c09ed0 --- /dev/null +++ b/lib/legion/audit/archiver_actor.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative 'archiver' + +module Legion + module Audit + class ArchiverActor + INTERVAL_SECONDS = 3600 # check every hour; day-of-week guard applies + + class << self + def enabled? + Legion::Audit::Archiver.enabled? + end + + def schedule_setting + Legion::Settings[:audit]&.dig(:retention, :archive_schedule) || '0 2 * * 0' + end + + # Parse cron day-of-week (field 5) — returns integer 0..6, 0=Sunday + def scheduled_day_of_week + schedule_setting.split[4].to_i + end + + # Parse cron hour (field 2) + def scheduled_hour + schedule_setting.split[1].to_i + end + end + + def run_archival + return unless self.class.enabled? + + now = Time.now.utc + return unless now.wday == self.class.scheduled_day_of_week + return unless now.hour == self.class.scheduled_hour + + Legion::Logging.info '[Audit::ArchiverActor] starting weekly archival' if defined?(Legion::Logging) + + warm_result = Legion::Audit::Archiver.archive_to_warm + cold_result = Legion::Audit::Archiver.archive_to_cold + + if Legion::Audit::Archiver.verify_on_archive? + verify_result = Legion::Audit::Archiver.verify_chain(tier: :warm) + if !verify_result[:valid] && defined?(Legion::Logging) + Legion::Logging.error "[Audit::ArchiverActor] chain broken after archival: #{verify_result[:broken_links].count} links" + end + end + + return unless defined?(Legion::Logging) + + Legion::Logging.info "[Audit::ArchiverActor] complete warm=#{warm_result[:moved]} cold=#{cold_result[:moved]}" + end + end + end +end diff --git a/lib/legion/audit/cold_storage.rb b/lib/legion/audit/cold_storage.rb new file mode 100644 index 00000000..f99c268b --- /dev/null +++ b/lib/legion/audit/cold_storage.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Legion + module Audit + module ColdStorage + class BackendNotAvailableError < StandardError; end + + module_function + + def backend + raw = Legion::Settings[:audit]&.dig(:retention, :cold_backend) || 'local' + raw.to_sym + end + + def upload(data:, path:) + case backend + when :local then local_upload(data: data, path: path) + when :s3 then s3_upload(data: data, path: path) + else raise BackendNotAvailableError, "unknown cold_backend: #{backend}" + end + end + + def download(path:) + case backend + when :local then local_download(path: path) + when :s3 then s3_download(path: path) + else raise BackendNotAvailableError, "unknown cold_backend: #{backend}" + end + end + + def local_upload(data:, path:) + ::FileUtils.mkdir_p(::File.dirname(path)) + ::File.binwrite(path, data) + { path: path, bytes: data.bytesize } + end + + def local_download(path:) + ::File.binread(path) + end + + def s3_client + raise BackendNotAvailableError, 'aws-sdk-s3 gem is required for :s3 cold_backend' \ + unless defined?(Aws::S3::Client) + + @s3_client ||= Aws::S3::Client.new + end + + def s3_bucket + Legion::Settings[:audit]&.dig(:retention, :s3_bucket) || + raise(BackendNotAvailableError, 'audit.retention.s3_bucket must be set for :s3 backend') + end + + def s3_upload(data:, path:) + s3_client.put_object(bucket: s3_bucket, key: path, body: data, + content_type: 'application/gzip', + server_side_encryption: 'AES256') + { path: path, bytes: data.bytesize } + end + + def s3_download(path:) + resp = s3_client.get_object(bucket: s3_bucket, key: path) + resp.body.read + end + end + end +end diff --git a/lib/legion/audit/hash_chain.rb b/lib/legion/audit/hash_chain.rb new file mode 100644 index 00000000..415f99b4 --- /dev/null +++ b/lib/legion/audit/hash_chain.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'openssl' + +module Legion + module Audit + module HashChain + ALGORITHM = 'SHA256' + GENESIS_HASH = ('0' * 64).freeze + CANONICAL_FIELDS = %i[seq principal_id action resource source status detail created_at previous_hash].freeze + + module_function + + def compute_hash(record) + payload = canonical_payload(record) + OpenSSL::Digest.new(ALGORITHM).hexdigest(payload) + end + + def canonical_payload(record) + CANONICAL_FIELDS.map { |f| "#{f}:#{record[f]}" }.join('|') + end + + def verify_chain(records) + broken = [] + records.each_cons(2) do |prev, curr| + unless curr[:previous_hash] == prev[:record_hash] + broken << { id: curr[:id], type: :broken_link, expected: prev[:record_hash], got: curr[:previous_hash] } + end + broken << { id: curr[:id], type: :gap, expected_seq: prev[:seq] + 1, got_seq: curr[:seq] } if prev[:seq] && curr[:seq] && curr[:seq] != prev[:seq] + 1 + end + { valid: broken.empty?, broken_links: broken, records_checked: records.size } + end + end + end +end diff --git a/lib/legion/audit/siem_export.rb b/lib/legion/audit/siem_export.rb new file mode 100644 index 00000000..bf0041e7 --- /dev/null +++ b/lib/legion/audit/siem_export.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Legion + module Audit + module SiemExport + module_function + + def export_batch(records) + records.map do |r| + { + timestamp: r[:created_at], + source: 'legion', + event_type: r[:event_type] || 'audit', + principal: r[:principal_id], + action: r[:action], + resource: r[:resource], + status: r[:status], + detail: r[:detail], + integrity: { + record_hash: r[:record_hash], + previous_hash: r[:previous_hash], + algorithm: 'SHA256' + } + } + end + end + + def to_ndjson(records) + export_batch(records).map { |r| Legion::JSON.generate(r) }.join("\n") + end + end + end +end diff --git a/lib/legion/auth/oauth_callback.rb b/lib/legion/auth/oauth_callback.rb new file mode 100644 index 00000000..c64e53c4 --- /dev/null +++ b/lib/legion/auth/oauth_callback.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'socket' +require 'timeout' +require 'uri' + +module Legion + module Auth + class OauthCallback + DEFAULT_TIMEOUT = 120 + LOCALHOST = '127.0.0.1' + + attr_reader :port, :redirect_uri + + def initialize(timeout: DEFAULT_TIMEOUT) + @timeout = timeout + @server = TCPServer.new(LOCALHOST, 0) + @port = @server.addr[1] + @redirect_uri = "http://#{LOCALHOST}:#{@port}/callback" + end + + def wait_for_callback + Timeout.timeout(@timeout) do + client = @server.accept + request_line = client.gets + parse_callback(request_line, client) + end + ensure + @server.close rescue nil # rubocop:disable Style/RescueModifier + end + + def close + @server.close rescue nil # rubocop:disable Style/RescueModifier + end + + private + + def parse_callback(request_line, client) + send_response(client) + return {} unless request_line&.start_with?('GET') + + path = request_line.split[1] || '' + query_string = path.split('?', 2)[1] || '' + params = URI.decode_www_form(query_string).to_h + params.transform_keys(&:to_sym) + end + + def send_response(client) + body = '

Authorization complete.

You may close this window.

' + client.puts 'HTTP/1.1 200 OK' + client.puts 'Content-Type: text/html' + client.puts "Content-Length: #{body.bytesize}" + client.puts 'Connection: close' + client.puts + client.puts body + rescue Errno::ECONNRESET, Errno::EPIPE, IOError + nil + ensure + client.close rescue nil # rubocop:disable Style/RescueModifier + end + end + end +end diff --git a/lib/legion/auth/token_manager.rb b/lib/legion/auth/token_manager.rb new file mode 100644 index 00000000..13d91a38 --- /dev/null +++ b/lib/legion/auth/token_manager.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'time' + +module Legion + module Auth + class TokenManager + class TokenExpiredError < StandardError + end + + def initialize(provider:) + @provider = provider + end + + def token_valid? + access_token = secret[:"#{@provider}_access_token"] + return false unless access_token + + expires_at_str = secret[:"#{@provider}_token_expires_at"] + return false unless expires_at_str + + expires_at = Time.parse(expires_at_str) + ttl = secret[:"#{@provider}_token_ttl"] + + expires_at > if ttl + Time.now + (ttl * 0.25) + else + Time.now + 300 + end + end + + def store_tokens(access_token:, expires_in:, refresh_token: nil, scope: nil) + secret[:"#{@provider}_access_token"] = access_token + secret[:"#{@provider}_refresh_token"] = refresh_token if refresh_token + secret[:"#{@provider}_token_ttl"] = expires_in + secret[:"#{@provider}_token_scope"] = scope if scope + secret[:"#{@provider}_token_expires_at"] = (Time.now + expires_in).iso8601 + end + + def ensure_valid_token + return secret[:"#{@provider}_access_token"] if token_valid? + + refresh_access_token + end + + def revoked? + secret[:"#{@provider}_token_revoked"] == true + end + + def mark_revoked! + secret[:"#{@provider}_token_revoked"] = true + end + + private + + def secret + @secret ||= begin + if defined?(Legion::Extensions::Helpers::SecretAccessor) + Legion::Extensions::Helpers::SecretAccessor.new(lex_name: 'auth') + else + {} + end + rescue StandardError + {} + end + end + + def refresh_access_token + # Will be implemented when OAuth2 callback server is wired in Task 2.2 + nil + end + end + end +end diff --git a/lib/legion/capacity/model.rb b/lib/legion/capacity/model.rb new file mode 100644 index 00000000..438b7785 --- /dev/null +++ b/lib/legion/capacity/model.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Legion + module Capacity + class Model + DEFAULTS = { + tasks_per_second: 10, + utilization_target: 0.7, + availability_hours: 24, + overhead_factor: 0.15 + }.freeze + + def initialize(workers:, config: {}) + @workers = Array(workers) + @config = DEFAULTS.merge(config) + end + + def aggregate + active = @workers.select { |w| active_worker?(w) } + tps = @config[:tasks_per_second] + result = { + total_workers: @workers.size, + active_workers: active.size, + max_throughput_tps: active.size * tps, + effective_throughput_tps: (active.size * tps * @config[:utilization_target]).round, + utilization_target: @config[:utilization_target], + availability_hours: @config[:availability_hours] + } + if defined?(Legion::Logging) + Legion::Logging.debug "[Capacity] aggregate: total=#{result[:total_workers]} " \ + "active=#{result[:active_workers]} effective_tps=#{result[:effective_throughput_tps]}" + end + result + end + + def forecast(days: 30, growth_rate: 0.0) + current = aggregate + projected = (current[:active_workers] * (1 + (growth_rate * days / 30.0))).ceil + tps = @config[:tasks_per_second] + result = { + period_days: days, + growth_rate: growth_rate, + current_workers: current[:active_workers], + projected_workers: projected, + projected_max_tps: projected * tps, + projected_effective_tps: (projected * tps * @config[:utilization_target]).round + } + if defined?(Legion::Logging) + Legion::Logging.debug "[Capacity] forecast: days=#{days} projected_workers=#{projected} projected_effective_tps=#{result[:projected_effective_tps]}" + end + result + end + + def per_worker_stats + @workers.map do |w| + id = w[:worker_id] || w[:id] || 'unknown' + { + worker_id: id, + status: w[:status] || w[:lifecycle_state] || 'unknown', + capacity_tps: active_worker?(w) ? @config[:tasks_per_second] : 0 + } + end + end + + private + + def active_worker?(worker) + status = (worker[:status] || worker[:lifecycle_state]).to_s + %w[active running].include?(status) + end + end + end +end diff --git a/lib/legion/catalog.rb b/lib/legion/catalog.rb new file mode 100644 index 00000000..bea95d2d --- /dev/null +++ b/lib/legion/catalog.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' + +module Legion + module Catalog + class << self + def register_tools(catalog_url:, api_key:) + tools = collect_mcp_tools + Legion::Logging.info "[Catalog] registering #{tools.size} tools to #{catalog_url}" if defined?(Legion::Logging) + post_json("#{catalog_url}/api/tools", { tools: tools }, api_key) + end + + def register_workers(catalog_url:, api_key:, workers:) + entries = workers.map do |w| + { id: w[:worker_id], status: w[:status], capabilities: w[:capabilities] || [] } + end + Legion::Logging.info "[Catalog] registering #{entries.size} workers to #{catalog_url}" if defined?(Legion::Logging) + post_json("#{catalog_url}/api/workers", { workers: entries }, api_key) + end + + def collect_mcp_tools + return [] unless defined?(Legion::MCP) && Legion::MCP.respond_to?(:tools) + + Legion::MCP.tools.map { |t| { name: t[:name], description: t[:description] } } + rescue StandardError => e + Legion::Logging.warn "Catalog#collect_mcp_tools failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + private + + def post_json(url, body, api_key) + uri = URI(url) + req = Net::HTTP::Post.new(uri) + req['Authorization'] = "Bearer #{api_key}" + req['Content-Type'] = 'application/json' + req.body = Legion::JSON.dump(body) + + response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http| + http.request(req) + end + { status: response.code.to_i, body: response.body } + rescue StandardError => e + Legion::Logging.warn "Catalog#post_json failed for #{url}: #{e.message}" if defined?(Legion::Logging) + { error: e.message } + end + end + end +end diff --git a/lib/legion/chat/notification_bridge.rb b/lib/legion/chat/notification_bridge.rb new file mode 100644 index 00000000..d65a84cb --- /dev/null +++ b/lib/legion/chat/notification_bridge.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative 'notification_queue' + +module Legion + module Chat + class NotificationBridge + DEFAULT_PATTERNS = { + 'alert.fired' => :critical, + 'extinction.*' => :critical, + 'governance.consent_violation' => :critical, + 'runner.failure' => :info, + 'worker.lifecycle' => :info, + 'scheduler.mode_changed' => :info + }.freeze + + attr_reader :queue + + def initialize(queue: NotificationQueue.new) + @queue = queue + @patterns = load_patterns + end + + def start + return unless defined?(Legion::Events) + + Legion::Events.on('*') do |event_name, payload| + priority = match_priority(event_name) + next unless priority + + message = format_notification(event_name, payload) + @queue.push(message: message, priority: priority, source: event_name) + end + end + + def pending_notifications(max_priority: :info) + @queue.pop_all(max_priority: max_priority) + end + + def has_urgent? # rubocop:disable Naming/PredicatePrefix + @queue.has_critical? + end + + private + + def match_priority(event_name) + @patterns.each do |pattern, priority| + return priority if File.fnmatch?(pattern, event_name) + end + nil + end + + def format_notification(event_name, payload) + payload ||= {} + case event_name + when /\Aalert\./ + "[ALERT] #{payload[:rule] || event_name}: #{payload[:severity] || 'unknown'}" + when /\Aextinction\./ + "[EXTINCTION] #{event_name} triggered" + when /\Arunner\.failure/ + "[FAIL] #{payload[:extension]}.#{payload[:function]} failed" + when /\Aworker\.lifecycle/ + "[WORKER] #{payload[:worker_id]} -> #{payload[:to]}" + else + "[#{event_name}]" + end + end + + def load_patterns + custom = begin + Legion::Settings.dig(:chat, :notifications, :patterns) + rescue StandardError => e + Legion::Logging.debug "NotificationBridge#load_patterns failed to read settings: #{e.message}" if defined?(Legion::Logging) + nil + end + return DEFAULT_PATTERNS unless custom + + custom.to_h { |p| [p, :info] } + .merge(DEFAULT_PATTERNS.select { |_, v| v == :critical }) + end + end + end +end diff --git a/lib/legion/chat/notification_queue.rb b/lib/legion/chat/notification_queue.rb new file mode 100644 index 00000000..0ee99cb6 --- /dev/null +++ b/lib/legion/chat/notification_queue.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Legion + module Chat + class NotificationQueue + PRIORITIES = { critical: 0, info: 1, debug: 2 }.freeze + + def initialize(max_size: 50) + @queue = [] + @mutex = Mutex.new + @max_size = max_size + end + + def push(message:, priority: :info, source: nil) + @mutex.synchronize do + @queue << { message: message, priority: priority, source: source, at: Time.now } + @queue.shift if @queue.size > @max_size + end + end + + def pop_all(max_priority: :info) + @mutex.synchronize do + threshold = PRIORITIES[max_priority] || 1 + pending = @queue.select { |n| PRIORITIES[n[:priority]] <= threshold } + @queue -= pending + pending.sort_by { |n| PRIORITIES[n[:priority]] } + end + end + + def has_critical? # rubocop:disable Naming/PredicatePrefix + @mutex.synchronize { @queue.any? { |n| n[:priority] == :critical } } + end + + def size + @mutex.synchronize { @queue.size } + end + + def clear + @mutex.synchronize { @queue.clear } + end + end + end +end diff --git a/lib/legion/chat/skills.rb b/lib/legion/chat/skills.rb new file mode 100644 index 00000000..446fe040 --- /dev/null +++ b/lib/legion/chat/skills.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Legion + module Chat + module Skills + class << self + def discover + return file_discover unless llm_skills_available? + + Legion::LLM::Skills::Registry.all.map { |s| registry_descriptor(s) } + end + + def find(name) + return file_find(name) unless llm_skills_available? + + skill = Legion::LLM::Skills::Registry.find(name) + skill ? registry_descriptor(skill) : nil + end + + # execute: REMOVED — all skill execution routes through the daemon API. + # `legion skill run` / `legion chat` are thin HTTP clients; no local LLM boot. + + private + + def llm_skills_available? + defined?(Legion::LLM::Skills) && + Legion::LLM.respond_to?(:started?) && + Legion::LLM.started? + end + + def registry_descriptor(skill) + { name: skill.skill_name, namespace: skill.namespace, prompt: nil, + description: skill.description, source: :registry } + end + + def file_discover + dirs = skill_directories + dirs.flat_map { |dir| ::Dir.glob(::File.join(dir, '*.{md,rb,yml,yaml}')) } + .map { |f| { name: ::File.basename(f, '.*'), path: f, source: :file } } + end + + def file_find(name) + dirs = skill_directories + dirs.each do |dir| + %w[.md .rb .yml .yaml].each do |ext| + path = ::File.join(dir, "#{name}#{ext}") + next unless ::File.exist?(path) + + return { name: name, path: path, prompt: ::File.read(path), source: :file } + end + end + nil + end + + def skill_directories + [ + ::File.expand_path('.legion/skills'), + ::File.expand_path('~/.legionio/skills') + ].select { |d| ::File.directory?(d) } + end + end + end + end +end diff --git a/lib/legion/cli.rb b/lib/legion/cli.rb index cf6ef3b9..a3b469fc 100755 --- a/lib/legion/cli.rb +++ b/lib/legion/cli.rb @@ -1,56 +1,472 @@ -# require 'legion/cli/version' -require 'thor' -require 'legion' -require 'legion/service' - -require 'legion/lex' -require 'legion/cli/cohort' +# frozen_string_literal: true -require 'legion/cli/relationship' -require 'legion/cli/task' -require 'legion/cli/chain' -require 'legion/cli/trigger' -require 'legion/cli/function' +require 'thor' +require 'legion/version' +require 'legion/cli/error' +require 'legion/cli/error_handler' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/cli/error_forwarder' module Legion - class CLI < Thor - include Thor::Actions - check_unknown_options! + module CLI + autoload :Start, 'legion/cli/start' + autoload :Status, 'legion/cli/status' + autoload :Lex, 'legion/cli/lex_command' + autoload :Task, 'legion/cli/task_command' + autoload :Chain, 'legion/cli/chain_command' + autoload :Config, 'legion/cli/config_command' + autoload :Generate, 'legion/cli/generate_command' + autoload :Check, 'legion/cli/check_command' + autoload :Mcp, 'legion/cli/mcp_command' + autoload :Worker, 'legion/cli/worker_command' + autoload :Coldstart, 'legion/cli/coldstart_command' + autoload :Chat, 'legion/cli/chat_command' + autoload :Commit, 'legion/cli/commit_command' + autoload :Pr, 'legion/cli/pr_command' + autoload :Review, 'legion/cli/review_command' + autoload :Memory, 'legion/cli/memory_command' + autoload :MindGrowth, 'legion/cli/mind_growth_command' + autoload :Plan, 'legion/cli/plan_command' + autoload :Swarm, 'legion/cli/swarm_command' + autoload :Gaia, 'legion/cli/gaia_command' + autoload :Schedule, 'legion/cli/schedule_command' + autoload :Completion, 'legion/cli/completion_command' + autoload :Openapi, 'legion/cli/openapi_command' + autoload :Doctor, 'legion/cli/doctor_command' + autoload :Telemetry, 'legion/cli/telemetry_command' + autoload :Auth, 'legion/cli/auth_command' + autoload :Rbac, 'legion/cli/rbac_command' + autoload :Acp, 'legion/cli/acp_command' + autoload :Audit, 'legion/cli/audit_command' + autoload :Detect, 'legion/cli/detect_command' + autoload :Eval, 'legion/cli/eval_command' + autoload :Update, 'legion/cli/update_command' + autoload :Init, 'legion/cli/init_command' + autoload :Knowledge, 'legion/cli/knowledge_command' + autoload :Setup, 'legion/cli/setup_command' + autoload :Skill, 'legion/cli/skill_command' + autoload :Prompt, 'legion/cli/prompt_command' + autoload :Image, 'legion/cli/image_command' + autoload :Dataset, 'legion/cli/dataset_command' + autoload :Cost, 'legion/cli/cost_command' + autoload :Team, 'legion/cli/team_command' + autoload :Marketplace, 'legion/cli/marketplace_command' + autoload :Notebook, 'legion/cli/notebook_command' + autoload :Llm, 'legion/cli/llm_command' + autoload :Tty, 'legion/cli/tty_command' + autoload :ObserveCommand, 'legion/cli/observe_command' + autoload :Payroll, 'legion/cli/payroll_command' + autoload :DoCommand, 'legion/cli/do_command' + autoload :Interactive, 'legion/cli/interactive' + autoload :Docs, 'legion/cli/docs_command' + autoload :Failover, 'legion/cli/failover_command' + autoload :AbsorbCommand, 'legion/cli/absorb_command' + autoload :ConnectCommand, 'legion/cli/connect_command' + autoload :Apollo, 'legion/cli/apollo_command' + autoload :TraceCommand, 'legion/cli/trace_command' + autoload :Features, 'legion/cli/features_command' + autoload :Debug, 'legion/cli/debug_command' + autoload :CodegenCommand, 'legion/cli/codegen_command' + autoload :Bootstrap, 'legion/cli/bootstrap_command' + autoload :ServiceCommand, 'legion/cli/service_command' + autoload :Broker, 'legion/cli/broker_command' + autoload :AdminCommand, 'legion/cli/admin_command' + autoload :Workflow, 'legion/cli/workflow_command' + autoload :FleetCommand, 'legion/cli/fleet_command' + autoload :Mode, 'legion/cli/mode_command' - def self.exit_on_failure? - true + module Groups + autoload :Ai, 'legion/cli/groups/ai_group' + autoload :Git, 'legion/cli/groups/git_group' + autoload :Pipeline, 'legion/cli/groups/pipeline_group' + autoload :Ops, 'legion/cli/groups/ops_group' + autoload :Serve, 'legion/cli/groups/serve_group' + autoload :Admin, 'legion/cli/groups/admin_group' + autoload :Dev, 'legion/cli/groups/dev_group' end - def self.source_root - File.dirname(__FILE__) - end + class Main < Thor + def self.exit_on_failure? + true + end - desc 'version', 'Display MyGem version' - map %w[-v --version] => :version + def self.start(given_args = ARGV, config = {}) + super(normalize_help_args(given_args), config) + rescue Legion::CLI::Error => e + Legion::Logging.error("CLI::Main.start CLI error: #{e.message}") if defined?(Legion::Logging) + formatter = Output::Formatter.new(json: given_args.include?('--json'), color: !given_args.include?('--no-color')) + ErrorHandler.format_error(e, formatter) + ErrorForwarder.forward_error(e, command: given_args.join(' ')) + exit(1) + rescue StandardError => e + Legion::Logging.error("CLI::Main.start unexpected error: #{e.message}") if defined?(Legion::Logging) + wrapped = ErrorHandler.wrap(e) + formatter = Output::Formatter.new(json: given_args.include?('--json'), color: !given_args.include?('--no-color')) + ErrorHandler.format_error(wrapped, formatter) + ErrorForwarder.forward_error(e, command: given_args.join(' ')) + exit(1) + end - def version - say "Legion::CLI #{VERSION}" - end + def self.normalize_help_args(given_args) + args = Array(given_args).dup + return args unless args.length == 2 + return args unless %w[--help -h].include?(args.last) + + command = args.first + return args if command.start_with?('-') || command == 'help' + + ['help', command] + end + + LEGION_GEMS = %w[ + legion-transport legion-cache legion-crypt legion-data + legion-json legion-logging legion-settings + legion-llm legion-gaia legion-mcp legion-rbac + legion-tty legion-ffi + ].freeze + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'version', 'Show version information' + map %w[-v --version] => :version + option :full, type: :boolean, default: false, desc: 'Include all installed lex-* extension versions' + def version + out = formatter + lexs = discovered_lexs + if options[:json] + payload = { version: Legion::VERSION, ruby: RUBY_VERSION, platform: RUBY_PLATFORM, + components: installed_components, extensions: lexs.size } + payload[:extension_versions] = lex_versions(lexs) if options[:full] + out.json(payload) + else + out.banner(version: Legion::VERSION) + out.spacer + out.detail({ ruby: RUBY_VERSION, platform: RUBY_PLATFORM }) + out.spacer + + installed = installed_components + out.header('Components') + installed.each do |name, ver| + puts " #{out.colorize(name.to_s.ljust(20), :label)} #{ver}" + end + + out.spacer + puts " #{out.colorize("#{lexs.size} extension(s)", :accent)} installed" + + if options[:full] && lexs.any? + out.spacer + out.header('Extensions') + lex_versions(lexs).each do |name, ver| + puts " #{out.colorize(name.ljust(20), :label)} #{ver}" + end + end + end + end + + desc 'start', 'Start the Legion daemon' + long_desc <<~DESC + Starts the full Legion service including transport, data, extensions, + and the HTTP API. Supports daemonization and PID management. + DESC + option :daemonize, type: :boolean, default: false, aliases: ['-d'], desc: 'Run as background daemon' + option :pidfile, type: :string, aliases: ['-p'], desc: 'PID file path' + option :logfile, type: :string, aliases: ['-l'], desc: 'Log file path' + option :time_limit, type: :numeric, aliases: ['-t'], desc: 'Run for N seconds then exit' + option :log_level, type: :string, desc: 'Log level (debug, info, warn, error)' + option :api, type: :boolean, default: true, desc: 'Start the HTTP API server' + option :http_port, type: :numeric, desc: 'HTTP API port (overrides settings)' + option :lite, type: :boolean, default: false, desc: 'Start in lite mode (no external services)' + def start + Legion::CLI::Start.run(options) + end + + desc 'stop', 'Stop a running Legion daemon' + option :pidfile, type: :string, aliases: ['-p'], desc: 'PID file path' + option :signal, type: :string, default: 'INT', desc: 'Signal to send (INT, TERM)' + def stop + out = formatter + pidfile = options[:pidfile] || find_pidfile + unless pidfile && File.exist?(pidfile) + out.error('No PID file found. Is Legion running?') + raise SystemExit, 1 + end + + pid = File.read(pidfile).to_i + sig = options[:signal].upcase + Process.kill(sig, pid) + out.success("Sent #{sig} to Legion process #{pid}") + rescue Errno::ESRCH + out.warn("Process #{pid} not found (already stopped?)") + FileUtils.rm_f(pidfile) + rescue Errno::EPERM + out.error("Permission denied sending signal to process #{pid}") + raise SystemExit, 1 + end + + desc 'status', 'Show running service status' + def status + Legion::CLI::Status.run(formatter, options) + end + + desc 'check', 'Verify Legion can start successfully' + long_desc <<~DESC + Smoke-test Legion subsystem connectivity. Tries each subsystem, + reports pass/fail, then shuts down. + + Default: check settings, crypt, transport, cache, data connections. + --extensions: also load and wire up all LEX gems. + --full: full boot cycle including API server. + DESC + option :extensions, type: :boolean, default: false, desc: 'Also load extensions' + option :full, type: :boolean, default: false, desc: 'Full boot cycle (extensions + API)' + option :privacy, type: :boolean, default: false, desc: 'Verify enterprise privacy mode' + def check + exit_code = if options[:privacy] + Legion::CLI::Check.run_privacy(formatter, options) + else + Legion::CLI::Check.run(formatter, options) + end + exit(exit_code) if exit_code != 0 + end + + # --- Core framework --- + desc 'lex SUBCOMMAND', 'Manage Legion extensions (LEXs)' + subcommand 'lex', Legion::CLI::Lex + + desc 'task SUBCOMMAND', 'Manage tasks' + subcommand 'task', Legion::CLI::Task + + desc 'chain SUBCOMMAND', 'Manage task chains' + subcommand 'chain', Legion::CLI::Chain + + desc 'config SUBCOMMAND', 'View and validate configuration' + subcommand 'config', Legion::CLI::Config + + desc 'schedule SUBCOMMAND', 'Manage schedules' + subcommand 'schedule', Legion::CLI::Schedule + + desc 'coldstart SUBCOMMAND', 'Cold start bootstrap and Claude memory ingestion' + subcommand 'coldstart', Legion::CLI::Coldstart + + # --- Health & maintenance --- + desc 'doctor', 'Diagnose environment and suggest fixes' + subcommand 'doctor', Legion::CLI::Doctor + + desc 'setup SUBCOMMAND', 'Install feature packs and configure IDE integrations' + subcommand 'setup', Legion::CLI::Setup + + desc 'service SUBCOMMAND', 'Manage the Legion launchd background service' + subcommand 'service', Legion::CLI::ServiceCommand + + desc 'bootstrap SOURCE', 'One-command setup: fetch config, scaffold, and install packs' + subcommand 'bootstrap', Legion::CLI::Bootstrap + + desc 'update', 'Update Legion gems to latest versions' + subcommand 'update', Legion::CLI::Update + + desc 'init', 'Initialize a new Legion workspace' + subcommand 'init', Legion::CLI::Init - desc 'lex', 'used to build LEXs' - subcommand 'lex', Legion::Cli::LexBuilder + desc 'detect SUBCOMMAND', 'Scan environment and recommend extensions' + subcommand 'detect', Legion::CLI::Detect - desc 'cohort', '' - subcommand 'cohort', Legion::Cli::Cohort + # --- Interactive & shortcuts --- + desc 'knowledge SUBCOMMAND', 'Search and manage the document knowledge base' + subcommand 'knowledge', Legion::CLI::Knowledge - desc 'function', 'deal with functions' - subcommand 'function', Legion::Cli::Function + desc 'codegen SUBCOMMAND', 'Manage self-generating functions' + subcommand 'codegen', CodegenCommand - desc 'relationship', 'creates and manages relationships' - subcommand 'relationship', Legion::Cli::Relationship + desc 'tty', 'Rich terminal UI (onboarding, AI chat, dashboard)' + subcommand 'tty', Legion::CLI::Tty - desc 'task', 'creates and manages tasks' - subcommand 'task', Legion::Cli::Task + # --- Command groups --- + desc 'ai SUBCOMMAND', 'AI, cognitive, and knowledge commands' + subcommand 'ai', Legion::CLI::Groups::Ai - desc 'chain', 'creates and manages chains' - subcommand 'chain', Legion::Cli::Chain + desc 'git SUBCOMMAND', 'AI-assisted git workflow (commit, pr, review)' + subcommand 'git', Legion::CLI::Groups::Git - desc 'trigger', 'sends a task to a worker' - subcommand 'trigger', Legion::Cli::Trigger + desc 'pipeline SUBCOMMAND', 'LLM pipeline tools (prompts, evals, datasets, skills)' + subcommand 'pipeline', Legion::CLI::Groups::Pipeline + + desc 'ops SUBCOMMAND', 'Observability, cost, audit, and operations' + subcommand 'ops', Legion::CLI::Groups::Ops + + desc 'serve SUBCOMMAND', 'Protocol servers (MCP, ACP)' + subcommand 'serve', Legion::CLI::Groups::Serve + + desc 'admin SUBCOMMAND', 'Auth, RBAC, workers, and teams' + subcommand 'admin', Legion::CLI::Groups::Admin + + desc 'dev SUBCOMMAND', 'Generators, docs, marketplace, and shell completion' + subcommand 'dev', Legion::CLI::Groups::Dev + + desc 'absorb SUBCOMMAND', 'Absorb content from external sources' + subcommand 'absorb', AbsorbCommand + + desc 'auth SUBCOMMAND', 'Authenticate with external services (Teams, Kerberos)' + subcommand 'auth', Auth + + desc 'connect PROVIDER', 'Connect external accounts via OAuth2' + subcommand 'connect', ConnectCommand + + desc 'broker SUBCOMMAND', 'RabbitMQ broker management (stats, cleanup)' + subcommand 'broker', Legion::CLI::Broker + + desc 'workflow SUBCOMMAND', 'Manage workflow bundles' + subcommand 'workflow', Legion::CLI::Workflow + + desc 'fleet SUBCOMMAND', 'Fleet pipeline operations (status, pending, approve, add, config)' + subcommand 'fleet', Legion::CLI::FleetCommand + + desc 'mode SUBCOMMAND', 'View and switch extension profiles and process roles' + subcommand 'mode', Legion::CLI::Mode + + desc 'tree', 'Print a tree of all available commands' + def tree + legion_print_command_tree(self.class, ::File.basename($PROGRAM_NAME), '') + end + + desc 'ask TEXT', 'Quick AI prompt (shortcut for chat prompt)' + map %w[-p --prompt] => :ask + def ask(*text) + Legion::CLI::Chat.start(['prompt', text.join(' ')] + ARGV.select { |a| a.start_with?('--') }) + end + + desc 'do TEXT', 'Route a natural language intent to the right extension' + long_desc <<~DESC + Describe what you want in plain English. Legion routes to the best + matching extension and runner automatically. + + Examples: + legion do "check consul health" + legion do "list running tasks" + legion do "review the latest PR" + DESC + def do_action(*text) + Legion::CLI::DoCommand.run(text.join(' '), formatter, options) + end + map 'do' => :do_action + + desc 'dream', 'Trigger a dream cycle on the running daemon' + option :wait, type: :boolean, default: false, desc: 'Wait for dream cycle to complete' + def dream + out = formatter + require 'net/http' + require 'json' + port = api_port + uri = URI("http://localhost:#{port}/api/tasks") + body = ::JSON.generate({ + runner_class: 'Legion::Extensions::Dream::Runners::DreamCycle', + function: 'execute_dream_cycle', + async: !options[:wait], + check_subtask: false, + generate_task: false + }) + + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 5 + http.read_timeout = options[:wait] ? 300 : 5 + request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + request.body = body + + response = http.request(request) + parsed = ::JSON.parse(response.body, symbolize_names: true) + + if options[:json] + out.json(parsed) + elsif response.is_a?(Net::HTTPSuccess) + out.success('Dream cycle triggered on daemon') + out.detail(parsed[:data] || parsed) if parsed[:data] + else + out.error("Dream cycle failed: #{parsed.dig(:error, :message) || response.code}") + end + rescue Net::ReadTimeout => e + Legion::Logging.debug("CLI#dream read timeout (expected for background tasks): #{e.message}") if defined?(Legion::Logging) + out.success('Dream cycle triggered on daemon (running in background)') + rescue Errno::ECONNREFUSED => e + Legion::Logging.warn("CLI#dream daemon not running: #{e.message}") if defined?(Legion::Logging) + out.error(format('Daemon not running (connection refused on port %d)', port)) + raise SystemExit, 1 + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def setup_connection + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + end + + private + + def installed_components + components = { legionio: Legion::VERSION } + LEGION_GEMS.each do |gem_name| + short = gem_name.sub('legion-', '') + spec = Gem::Specification.find_by_name(gem_name) + components[short.to_sym] = spec.version.to_s + rescue Gem::MissingSpecError => e + Legion::Logging.debug("CLI#installed_components gem #{gem_name} not installed: #{e.message}") if defined?(Legion::Logging) + components[short.to_sym] = '(not installed)' + end + components + end + + def discovered_lexs + Gem::Specification.select { |s| s.name.start_with?('lex-') } + .group_by(&:name) + .transform_values { |specs| specs.max_by(&:version) } + end + + def lex_versions(lexs) + lexs.sort_by { |name, _| name }.to_h { |name, spec| [name, spec.version.to_s] } + end + + def find_pidfile + %w[/var/run/legion.pid /tmp/legion.pid].find { |f| File.exist?(f) } + end + + def api_port + require 'legion/settings' + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + api_settings = Legion::Settings[:api] + (api_settings.is_a?(Hash) && api_settings[:port]) || 4567 + rescue StandardError => e + Legion::Logging.debug("CLI#api_port failed: #{e.message}") if defined?(Legion::Logging) + 4567 + end + + def legion_print_command_tree(klass, label, indent) + say "#{indent}#{label}", :blue + + child_indent = "#{indent} " + visible_commands = klass.commands.reject { |_, cmd| cmd.hidden? || cmd.name == 'help' || cmd.name == 'tree' } + last_command_idx = visible_commands.count - 1 + has_subcommands = klass.subcommand_classes.any? + visible_commands.sort.each_with_index do |(command_name, command), i| + description = command.description.split("\n").first || '' + icon = i == last_command_idx && !has_subcommands ? "\u2514\u2500" : "\u251c\u2500" + say "#{child_indent}#{icon} ", nil, false + say command_name, :green, false + say " (#{description})" unless description.empty? + end + + klass.subcommand_classes.each do |subcommand_name, subclass| + legion_print_command_tree(subclass, "#{label} #{subcommand_name}", child_indent) + end + end + end + end end end diff --git a/lib/legion/cli/absorb_command.rb b/lib/legion/cli/absorb_command.rb new file mode 100644 index 00000000..9676cabd --- /dev/null +++ b/lib/legion/cli/absorb_command.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative 'api_client' + +module Legion + module CLI + class AbsorbCommand < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'url URL', 'Absorb content from a URL' + option :scope, type: :string, default: 'global', desc: 'Knowledge scope (global/local/all)' + def url(input_url) + out = formatter + result = api_post('/api/absorbers/dispatch', url: input_url, context: { scope: options[:scope] }) + + if options[:json] + out.json(result) + elsif result[:success] + out.success("Dispatched: #{input_url}") + puts " absorber: #{result[:absorber]}" + puts " job_id: #{result[:job_id]}" + puts ' Processing in background. Check daemon logs for progress.' + else + out.warn("Failed: #{result[:error]}") + end + end + + desc 'list', 'List registered absorber patterns' + def list + out = formatter + patterns = fetch_absorbers + + if options[:json] + out.json(patterns.map { |p| { type: p[:type], value: p[:value], description: p[:description] } }) + elsif patterns.empty? + out.warn('No absorbers registered') + else + headers = %w[Type Pattern Description] + rows = patterns.map do |p| + [p[:type].to_s, p[:value], p[:description] || ''] + end + out.header('Registered Absorbers') + out.table(headers, rows) + end + end + + desc 'resolve URL', 'Show which absorber would handle a URL (dry run)' + def resolve(input_url) + out = formatter + result = fetch_resolve(input_url) + + if options[:json] + out.json(result) + elsif result[:match] + out.success("#{input_url} -> #{result[:absorber]}") + else + out.warn("No absorber registered for: #{input_url}") + end + end + + no_commands do + include ApiClient + + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + + def fetch_absorbers + api_get('/api/absorbers') + end + + def fetch_resolve(input_url) + api_get("/api/absorbers/resolve?url=#{URI.encode_www_form_component(input_url)}") + end + end + end + end +end diff --git a/lib/legion/cli/acp_command.rb b/lib/legion/cli/acp_command.rb new file mode 100644 index 00000000..39d2a2df --- /dev/null +++ b/lib/legion/cli/acp_command.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Acp < Thor + def self.exit_on_failure? + true + end + + desc 'stdio', 'Start ACP agent with stdio transport (default)' + def stdio + require 'legion/extensions/acp' + + transport = Legion::Extensions::Acp::Transport::Stdio.new + agent = Legion::Extensions::Acp::Runners::Agent.new(transport: transport) + + transport.log('LegionIO ACP agent started (stdio)') + + setup_llm if llm_available? + + transport.run { |msg| agent.dispatch(msg) } + end + + default_command :stdio + + no_commands do + private + + def llm_available? + require 'legion/llm' + true + rescue LoadError => e + Legion::Logging.debug("AcpCommand#llm_available? legion-llm not available: #{e.message}") if defined?(Legion::Logging) + false + end + + def setup_llm + require 'legion/cli/connection' + Connection.ensure_settings + Connection.ensure_llm + rescue StandardError => e + Legion::Logging.warn("AcpCommand#setup_llm failed: #{e.message}") if defined?(Legion::Logging) + warn("[lex-acp] LLM setup failed: #{e.message} — running without prompt support") + end + end + end + end +end diff --git a/lib/legion/cli/admin/purge_topology.rb b/lib/legion/cli/admin/purge_topology.rb new file mode 100644 index 00000000..e833373a --- /dev/null +++ b/lib/legion/cli/admin/purge_topology.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'erb' + +module Legion + module CLI + module Admin + class PurgeTopology < Thor + namespace 'admin:purge_topology' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host' + class_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port' + class_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management username' + class_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password' + class_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost' + class_option :execute, type: :boolean, default: false, desc: 'Actually delete (default: dry-run)' + + desc 'purge', 'Enumerate and optionally delete legacy v2.0 topology (legion.{lex} exchanges/queues)' + def purge + out = formatter + out.header('Legion AMQP Topology Migration: v2.0 → v3.0') + out.spacer + + legacy = find_legacy_topology + if legacy[:exchanges].empty? && legacy[:queues].empty? + out.success('No legacy topology found. Already on v3.0 or never had v2.0 topology.') + return + end + + if options[:json] + perform_deletion(legacy) if options[:execute] + out.json({ legacy: legacy, deleted: options[:execute] }) + return + end + + report_legacy(out, legacy) + + if options[:execute] + perform_deletion(legacy) + out.success("Deleted #{legacy[:exchanges].size} exchange(s) and #{legacy[:queues].size} queue(s)") + else + out.warn('Dry-run mode — pass --execute to delete legacy topology') + end + rescue Legion::CLI::Error => e + formatter.error(e.message) + exit(1) + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def vhost_encoded + ERB::Util.url_encode(options[:vhost]) + end + + def management_api(path) + uri = URI("http://#{options[:host]}:#{options[:port]}/api#{path}") + req = Net::HTTP::Get.new(uri) + req.basic_auth(options[:user], options[:password]) + response = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http| + http.request(req) + end + raise Legion::CLI::Error, "Management API #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess) + + ::JSON.parse(response.body, symbolize_names: true) + rescue Errno::ECONNREFUSED + raise Legion::CLI::Error, "Cannot connect to RabbitMQ management API at #{options[:host]}:#{options[:port]}" + rescue Net::OpenTimeout, Net::ReadTimeout + raise Legion::CLI::Error, 'Timed out connecting to RabbitMQ management API' + end + + def management_delete(path) + uri = URI("http://#{options[:host]}:#{options[:port]}/api#{path}") + req = Net::HTTP::Delete.new(uri) + req.basic_auth(options[:user], options[:password]) + response = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http| + http.request(req) + end + raise Legion::CLI::Error, "Management API #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess) + + response + rescue Errno::ECONNREFUSED + raise Legion::CLI::Error, "Cannot connect to RabbitMQ management API at #{options[:host]}:#{options[:port]}" + rescue Net::OpenTimeout, Net::ReadTimeout + raise Legion::CLI::Error, 'Timed out connecting to RabbitMQ management API' + end + + # Find exchanges and queues matching legacy v2.0 pattern: legion.{lex_name}.* + # but NOT matching v3.0 pattern (lex.{lex_name}.*) or infrastructure (task, node, etc.) + def find_legacy_topology + all_exchanges = management_api("/exchanges/#{vhost_encoded}") + all_queues = management_api("/queues/#{vhost_encoded}") + + legacy_exchanges = all_exchanges + .map { |e| e[:name].to_s } + .select do |name| + name.match?(/\Alegion\.[a-z]/) && !name.start_with?('legion.task', 'legion.node', 'legion.crypt', 'legion.extensions', + 'legion.logging') + end + + legacy_queues = all_queues + .map { |q| q[:name].to_s } + .select { |name| name.match?(/\Alegion\.[a-z]/) && !name.match?(/\Alegion\.(task|node|crypt|extensions|logging)/) } + + { exchanges: legacy_exchanges, queues: legacy_queues } + end + + def report_legacy(out, legacy) + unless legacy[:exchanges].empty? + out.detail_header("Legacy Exchanges (#{legacy[:exchanges].size})") + legacy[:exchanges].each { |name| out.detail({ name: name }) } + out.spacer + end + + unless legacy[:queues].empty? # rubocop:disable Style/GuardClause + out.detail_header("Legacy Queues (#{legacy[:queues].size})") + legacy[:queues].each { |name| out.detail({ name: name }) } + out.spacer + end + end + + def perform_deletion(legacy) + legacy[:queues].each do |name| + management_delete("/queues/#{vhost_encoded}/#{ERB::Util.url_encode(name)}") + end + legacy[:exchanges].each do |name| + management_delete("/exchanges/#{vhost_encoded}/#{ERB::Util.url_encode(name)}") + end + end + end + end + end + end +end diff --git a/lib/legion/cli/admin_command.rb b/lib/legion/cli/admin_command.rb new file mode 100644 index 00000000..ffd28425 --- /dev/null +++ b/lib/legion/cli/admin_command.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' + +module Legion + module CLI + class AdminCommand < Thor + namespace :admin + + desc 'purge-topology', 'Remove old v2.0 AMQP exchanges (legion.* that have lex.* counterparts)' + method_option :dry_run, type: :boolean, default: true, desc: 'List without deleting' + method_option :execute, type: :boolean, default: false, desc: 'Actually delete exchanges' + method_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host' + method_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port' + method_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management user' + method_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password' + method_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost' + method_option :open_timeout, type: :numeric, default: 5, desc: 'HTTP open timeout in seconds' + method_option :read_timeout, type: :numeric, default: 30, desc: 'HTTP read timeout in seconds' + def purge_topology + exchanges = fetch_exchanges + candidates = self.class.detect_old_exchanges(exchanges) + + if candidates.empty? + say 'No old v2.0 topology exchanges found.', :green + return + end + + say "Found #{candidates.size} old v2.0 exchange(s):", :yellow + candidates.each { |e| say " #{e[:name]} (#{e[:type]})" } + + if options[:execute] && !options[:dry_run] + candidates.each do |exchange| + delete_exchange(exchange[:name]) + say " Deleted: #{exchange[:name]}", :red + end + say "Purged #{candidates.size} exchange(s).", :green + else + say "\nDry run. Use --execute --no-dry-run to delete.", :cyan + end + end + + def self.detect_old_exchanges(exchanges) + lex_names = exchanges.select { |e| e[:name].to_s.start_with?('lex.') } + .to_set { |e| e[:name].to_s.sub('lex.', '') } + + exchanges.select do |e| + next false unless e[:name].to_s.start_with?('legion.') + + suffix = e[:name].to_s.sub('legion.', '') + lex_names.include?(suffix) + end + end + + private + + def management_uri(path) + vhost = URI.encode_www_form_component(options[:vhost]) + URI("http://#{options[:host]}:#{options[:port]}/api#{path}?vhost=#{vhost}") + end + + def fetch_exchanges + uri = management_uri('/exchanges') + response = management_get(uri) + Legion::JSON.load(response.body).map { |e| { name: e[:name], type: e[:type] } } + end + + def delete_exchange(name) + vhost = URI.encode_www_form_component(options[:vhost]) + encoded_name = URI.encode_www_form_component(name) + uri = URI("http://#{options[:host]}:#{options[:port]}/api/exchanges/#{vhost}/#{encoded_name}") + management_request(uri, Net::HTTP::Delete) + end + + def management_get(uri) + management_request(uri, Net::HTTP::Get) + end + + def management_request(uri, method_class) + Net::HTTP.start(uri.host, uri.port, + open_timeout: options[:open_timeout], + read_timeout: options[:read_timeout]) do |http| + req = method_class.new(uri) + req.basic_auth(options[:user], options[:password]) + http.request(req) + end + end + end + end +end diff --git a/lib/legion/cli/api_client.rb b/lib/legion/cli/api_client.rb new file mode 100644 index 00000000..6744452b --- /dev/null +++ b/lib/legion/cli/api_client.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' +require 'json' + +module Legion + module CLI + # Shared HTTP client for CLI commands that talk to the running daemon API. + # Include this module inside a Thor command's `no_commands` block, or + # extend it at the class level, to get api_get / api_post / api_put / + # api_delete helpers that target http://127.0.0.1:/api/*. + module ApiClient + def api_port + Connection.ensure_settings + api_settings = Legion::Settings[:api] + (api_settings.is_a?(Hash) && api_settings[:port]) || 4567 + rescue StandardError + 4567 + end + + def api_get(path) + uri = URI("http://127.0.0.1:#{api_port}#{path}") + http = build_http(uri) + response = http.get(uri.request_uri) + handle_response(response, path) + rescue Errno::ECONNREFUSED + daemon_not_running! + rescue SystemExit + raise + rescue StandardError => e + api_error!(e, path) + end + + def api_post(path, **payload) + uri = URI("http://127.0.0.1:#{api_port}#{path}") + http = build_http(uri, read_timeout: 300) + request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + request.body = ::JSON.generate(payload) + response = http.request(request) + handle_response(response, path) + rescue Errno::ECONNREFUSED + daemon_not_running! + rescue SystemExit + raise + rescue StandardError => e + api_error!(e, path) + end + + def api_put(path, **payload) + uri = URI("http://127.0.0.1:#{api_port}#{path}") + http = build_http(uri) + request = Net::HTTP::Put.new(uri.path, 'Content-Type' => 'application/json') + request.body = ::JSON.generate(payload) + response = http.request(request) + handle_response(response, path) + rescue Errno::ECONNREFUSED + daemon_not_running! + rescue SystemExit + raise + rescue StandardError => e + api_error!(e, path) + end + + def api_delete(path) + uri = URI("http://127.0.0.1:#{api_port}#{path}") + http = build_http(uri) + response = http.delete(uri.path) + handle_response(response, path) + rescue Errno::ECONNREFUSED + daemon_not_running! + rescue SystemExit + raise + rescue StandardError => e + api_error!(e, path) + end + + private + + def build_http(uri, read_timeout: 10) + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = read_timeout + http + end + + def handle_response(response, path) + unless response.is_a?(Net::HTTPSuccess) + formatter.error("API returned #{response.code} for #{path}") + raise SystemExit, 1 + end + body = ::JSON.parse(response.body, symbolize_names: true) + body[:data] + end + + def daemon_not_running! + formatter.error('Daemon not running. Start with: legionio start') + raise SystemExit, 1 + end + + def api_error!(err, path) + formatter.error("API request failed (#{path}): #{err.message}") + raise SystemExit, 1 + end + end + end +end diff --git a/lib/legion/cli/apollo_command.rb b/lib/legion/cli/apollo_command.rb new file mode 100644 index 00000000..4fec98e5 --- /dev/null +++ b/lib/legion/cli/apollo_command.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +module Legion + module CLI + class Apollo < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :port, type: :numeric, default: 4567, desc: 'API port' + class_option :host, type: :string, default: '127.0.0.1', desc: 'API host' + + desc 'status', 'Check Apollo knowledge graph availability' + def status + data = api_get('/api/apollo/status') + if options[:json] + formatter.json(data) + else + formatter.header('Apollo Status') + formatter.detail({ + 'Available' => (data[:available] || false).to_s, + 'Data Connected' => (data[:data_connected] || false).to_s + }) + end + end + default_task :status + + desc 'stats', 'Show knowledge graph statistics' + def stats + data = api_get('/api/apollo/stats') + if options[:json] + formatter.json(data) + else + formatter.header('Apollo Knowledge Graph') + formatter.detail({ + 'Total Entries' => (data[:total_entries] || 0).to_s, + 'Recent (24h)' => (data[:recent_24h] || 0).to_s, + 'Avg Confidence' => (data[:avg_confidence] || 0.0).to_s + }) + + show_breakdown('By Status', data[:by_status]) if data[:by_status] + show_breakdown('By Content Type', data[:by_content_type]) if data[:by_content_type] + end + end + + desc 'query QUERY', 'Search the knowledge graph' + option :limit, type: :numeric, default: 10, desc: 'Max results' + option :domain, type: :string, desc: 'Filter by knowledge domain' + def query(search_query) + body = { query: search_query, limit: options[:limit], domain: options[:domain] } + data = api_post('/api/apollo/query', body) + if options[:json] + formatter.json(data) + else + entries = data[:entries] || [] + formatter.header("Apollo Query (#{entries.size} results)") + entries.each_with_index do |entry, idx| + puts " #{idx + 1}. [#{entry[:content_type]}] #{truncate(entry[:content].to_s, 120)}" + puts " confidence: #{entry[:confidence]} | status: #{entry[:status]}" + end + puts ' No results found.' if entries.empty? + end + end + + desc 'ingest CONTENT', 'Ingest knowledge into the graph' + option :content_type, type: :string, default: 'observation', desc: 'Content type (fact/concept/procedure/association/observation)' + option :tags, type: :string, desc: 'Comma-separated tags' + option :domain, type: :string, desc: 'Knowledge domain' + def ingest(content) + body = { + content: content, + content_type: options[:content_type], + tags: options[:tags]&.split(',') || [], + source_agent: 'cli', + source_channel: 'cli', + knowledge_domain: options[:domain] + } + data = api_post('/api/apollo/ingest', body) + if options[:json] + formatter.json(data) + else + formatter.header('Apollo Ingest') + if data[:success] + formatter.success("Entry created (id: #{data[:id]})") + else + formatter.warn("Ingest failed: #{data[:error]}") + end + end + end + + desc 'maintain ACTION', 'Run maintenance (decay_cycle or corroboration)' + def maintain(action) + data = api_post('/api/apollo/maintenance', { action: action }) + if options[:json] + formatter.json(data) + else + formatter.header("Apollo Maintenance: #{action}") + formatter.detail(data.transform_values(&:to_s)) + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + + private + + def api_get(path) + uri = URI("http://#{options[:host]}:#{options[:port]}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 10 + response = http.get(uri.path) + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] || parsed + rescue StandardError => e + { error: e.message } + end + + def api_post(path, body) + uri = URI("http://#{options[:host]}:#{options[:port]}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 30 + req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + req.body = ::JSON.dump(body) + response = http.request(req) + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] || parsed + rescue StandardError => e + { error: e.message } + end + + def show_breakdown(title, hash) + return if hash.nil? || hash.empty? + + formatter.spacer + formatter.header(title) + hash.each { |key, count| puts " #{key}: #{count}" } + end + + def truncate(text, max) + text.length > max ? "#{text[0..(max - 3)]}..." : text + end + end + end + end +end diff --git a/lib/legion/cli/audit_command.rb b/lib/legion/cli/audit_command.rb new file mode 100644 index 00000000..809d6122 --- /dev/null +++ b/lib/legion/cli/audit_command.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require_relative '../audit/archiver' +require_relative '../audit/cold_storage' + +module Legion + module CLI + class Audit < Thor + namespace 'audit' + + desc 'list', 'List audit log records' + option :event_type, type: :string, desc: 'Filter by event type' + option :principal, type: :string, desc: 'Filter by principal_id' + option :source, type: :string, desc: 'Filter by source' + option :status, type: :string, desc: 'Filter by status' + option :since, type: :string, desc: 'Records after this ISO8601 timestamp' + option :until, type: :string, desc: 'Records before this ISO8601 timestamp' + option :limit, type: :numeric, default: 20, desc: 'Number of records' + option :json, type: :boolean, default: false, desc: 'Output as JSON' + def list + Connection.ensure_settings + Connection.ensure_data + + dataset = Legion::Data::Model::AuditLog.order(Sequel.desc(:id)) + dataset = dataset.where(event_type: options[:event_type]) if options[:event_type] + dataset = dataset.where(principal_id: options[:principal]) if options[:principal] + dataset = dataset.where(source: options[:source]) if options[:source] + dataset = dataset.where(status: options[:status]) if options[:status] + dataset = dataset.where { created_at >= Time.parse(options[:since]) } if options[:since] + dataset = dataset.where { created_at <= Time.parse(options[:until]) } if options[:until] + records = dataset.limit(options[:limit]).all + + if options[:json] + puts Legion::JSON.dump(records.map(&:values)) + else + records.each do |r| + puts "#{r.created_at} #{r.event_type.ljust(22)} #{r.principal_id.ljust(20)} " \ + "#{r.action.ljust(12)} #{r.resource.ljust(40)} #{r.status}" + end + puts "#{records.count} records shown" + end + end + + desc 'verify', 'Verify audit log hash chain integrity (lex-audit runner path)' + option :json, type: :boolean, default: false, desc: 'Output as JSON' + def verify + Connection.ensure_settings + Connection.ensure_data + + unless defined?(Legion::Extensions::Audit::Runners::Audit) + puts 'lex-audit is not loaded' + exit 1 + end + + runner = Object.new.extend(Legion::Extensions::Audit::Runners::Audit) + result = runner.verify + + if options[:json] + puts Legion::JSON.dump(result) + elsif result[:valid] + puts "Audit chain valid: #{result[:records_checked]} records verified" + else + puts "CHAIN BROKEN at record ##{result[:break_at]} (#{result[:records_checked]} records checked before break)" + exit 1 + end + end + + desc 'archive', 'Archive audit records across tiers (hot -> warm -> cold)' + option :dry_run, type: :boolean, default: false, aliases: '--dry-run', desc: 'Preview without executing' + option :execute, type: :boolean, default: false, desc: 'Run archival now' + option :json, type: :boolean, default: false, desc: 'Output as JSON' + def archive + Connection.ensure_settings + Connection.ensure_data + + unless Legion::Audit::Archiver.enabled? + puts 'Audit retention is disabled. Set audit.retention.enabled = true to activate.' + return + end + + if options[:dry_run] + status = Legion::Data::Retention.retention_status(table: :audit_log) + output = { + mode: 'DRY RUN', + hot_records: status[:active_count], + warm_records: status[:archived_count], + oldest_hot: status[:oldest_active]&.to_s, + oldest_warm: status[:oldest_archived]&.to_s, + hot_days: Legion::Audit::Archiver.hot_days, + warm_days: Legion::Audit::Archiver.warm_days + } + if options[:json] + puts Legion::JSON.dump(output) + else + puts 'DRY RUN — no records will be moved' + output.each { |k, v| puts " #{k}: #{v}" } + end + return + end + + unless options[:execute] + puts 'Pass --execute to run archival, or --dry-run to preview.' + return + end + + warm_result = Legion::Audit::Archiver.archive_to_warm + puts "Archived #{warm_result[:moved]} records to warm" unless options[:json] + + cold_result = Legion::Audit::Archiver.archive_to_cold + puts "Archived #{cold_result[:moved]} records to cold: #{cold_result[:path]}" unless options[:json] + + if Legion::Audit::Archiver.verify_on_archive? + verify_result = Legion::Audit::Archiver.verify_chain(tier: :warm) + unless options[:json] + if verify_result[:valid] + puts "Chain integrity verified: #{verify_result[:records_checked]} warm records" + else + puts "WARNING: chain broken in warm tier after archival (#{verify_result[:broken_links].count} links)" + end + end + end + + puts Legion::JSON.dump({ warm: warm_result, cold: cold_result }) if options[:json] + end + + desc 'verify_chain', 'Verify hash chain integrity for a specific tier and date range' + option :tier, type: :string, default: 'hot', desc: 'Tier to verify: hot, warm' + option :start, type: :string, desc: 'ISO8601 start date (inclusive)' + option :end, type: :string, desc: 'ISO8601 end date (inclusive)' + option :json, type: :boolean, default: false, desc: 'Output as JSON' + def verify_chain + Connection.ensure_settings + Connection.ensure_data + + tier = options[:tier].to_sym + start_date = options[:start] ? Time.parse(options[:start]) : nil + end_date = options[:end] ? Time.parse(options[:end]) : nil + + result = Legion::Audit::Archiver.verify_chain( + tier: tier, + start_date: start_date, + end_date: end_date + ) + + if options[:json] + puts Legion::JSON.dump(result) + elsif result[:valid] + puts "Chain valid (#{tier}): #{result[:records_checked]} records verified" + else + puts "CHAIN BROKEN in #{tier} tier — #{result[:broken_links].count} broken link(s)" + result[:broken_links].each { |l| puts " record ##{l[:id]}: expected #{l[:expected]}, got #{l[:got]}" } + exit 1 + end + end + + desc 'restore', 'Restore cold-archived records to warm tier for querying' + option :date, type: :string, required: true, desc: 'Date stamp of archive to restore (YYYYMMDD or ISO8601)' + option :json, type: :boolean, default: false, desc: 'Output as JSON' + def restore + Connection.ensure_settings + Connection.ensure_data + + unless Legion::Audit::Archiver.enabled? + puts 'Audit retention is disabled.' + return + end + + db = Legion::Data.connection + unless db&.table_exists?(:audit_archive_manifests) + puts 'No archive manifests table found. Has migration 039 been run?' + exit 1 + end + + date_str = options[:date].tr('-', '')[0, 8] + manifests = db[:audit_archive_manifests] + .where(tier: 'cold') + .where(::Sequel.like(:storage_url, "%#{date_str}%")) + .all + + if manifests.empty? + puts "No cold archives found for date: #{options[:date]}" + exit 1 + end + + restored = 0 + manifests.each do |manifest| + gz_data = Legion::Audit::ColdStorage.download(path: manifest[:storage_url]) + ndjson = ::Zlib::GzipReader.new(::StringIO.new(gz_data)).read + records = ndjson.split("\n").map { |line| Legion::JSON.load(line) } + + db.transaction do + records.each { |r| db[:audit_log_archive].insert(r.transform_keys(&:to_sym)) } + end + restored += records.size + end + + result = { restored: restored, manifests: manifests.count } + if options[:json] + puts Legion::JSON.dump(result) + else + puts "Restored #{restored} records from #{manifests.count} cold archive(s) to warm tier" + end + end + end + end +end diff --git a/lib/legion/cli/auth_command.rb b/lib/legion/cli/auth_command.rb new file mode 100644 index 00000000..89b7bf55 --- /dev/null +++ b/lib/legion/cli/auth_command.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'thor' +require 'uri' +require 'fileutils' + +module Legion + module CLI + class Auth < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'teams', 'Authenticate with Microsoft Teams using your browser' + method_option :tenant_id, type: :string, desc: 'Azure AD tenant ID' + method_option :client_id, type: :string, desc: 'Entra application client ID' + method_option :scopes, type: :string, desc: 'OAuth scopes to request' + def teams + out = formatter + Connection.ensure_settings(resolve_secrets: false) + + port = begin + Legion::Settings.dig(:api, :port) || 4567 + rescue StandardError + 4567 + end + + out.header('Microsoft Teams Authentication') + + require 'net/http' + require 'legion/json' + + # Ask the daemon for the authorize URL + uri = ::URI.parse("http://127.0.0.1:#{port}/api/auth/teams/authorize") + params = {} + params[:scopes] = options[:scopes] if options[:scopes] + response = ::Net::HTTP.post(uri, Legion::JSON.dump(params), 'Content-Type' => 'application/json') + parsed = Legion::JSON.load(response.body) + + unless response.code.to_i == 200 && parsed.dig(:data, :authorize_url) + error_msg = parsed.dig(:error, :message) || "HTTP #{response.code}" + out.error("Daemon returned: #{error_msg}") + raise SystemExit, 1 + end + + url = parsed[:data][:authorize_url] + out.info('Opening browser for Microsoft login...') + system('open', url) || out.warn("Open this URL manually:\n #{url}") + out.info('Waiting for callback on daemon...') + + # Poll daemon for auth result + poll_uri = ::URI.parse("http://127.0.0.1:#{port}/api/auth/teams/status?state=#{parsed.dig(:data, :state)}") + 30.times do + sleep 2 + poll_response = ::Net::HTTP.get_response(poll_uri) + poll_data = Legion::JSON.load(poll_response.body) + + if poll_data.dig(:data, :authenticated) + out.success('Authentication successful! Token stored by daemon.') + return + end + + next unless poll_data.dig(:data, :error) + + out.error("Authentication failed: #{poll_data[:data][:error]}") + raise SystemExit, 1 + end + + out.error('Timed out waiting for authentication (60s)') + raise SystemExit, 1 + rescue Errno::ECONNREFUSED + out = formatter + out.error('Daemon not running. Start it first: legionio start') + raise SystemExit, 1 + end + + desc 'kerberos', 'Authenticate using Kerberos TGT from your workstation' + method_option :api_url, type: :string, desc: 'Legion API base URL' + method_option :realm, type: :string, desc: 'Kerberos realm override' + def kerberos + klist_output = `klist 2>&1` + unless $CHILD_STATUS&.success? + say 'No Kerberos ticket found. Run kinit first or check your domain connection.', :red + return + end + + principal_match = klist_output.match(/Principal:\s+(\S+)/) + unless principal_match + say 'Could not detect Kerberos principal from klist output.', :red + return + end + + principal = principal_match[1] + realm = options[:realm] || principal.split('@', 2).last + say 'Detected Kerberos ticket:', :green + say " Principal: #{principal}" + say " Realm: #{realm}" + + api_url = resolve_api_url + say "Authenticating to #{api_url}..." + + token = build_spnego_token(api_url) + response = send_negotiate_request(api_url, token) + handle_negotiate_response(response) + rescue StandardError => e + Legion::Logging.error("Auth#kerberos failed: #{e.message}") if defined?(Legion::Logging) + say "Kerberos auth error: #{e.message}", :red + end + + default_task :teams + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def resolve_api_url + url = options[:api_url] + url ||= Legion::Settings.dig(:api, :url) if defined?(Legion::Settings) + url || 'http://127.0.0.1:4567' + end + + def build_spnego_token(api_url) + require 'gssapi' + require 'base64' + host = ::URI.parse(api_url).host + spnego = GSSAPI::Simple.new(host, 'HTTP') + ::Base64.strict_encode64(spnego.init_context) + end + + def send_negotiate_request(api_url, token) + require 'net/http' + uri = ::URI.parse("#{api_url}/api/auth/negotiate") + http = ::Net::HTTP.new(uri.host, uri.port) + request = ::Net::HTTP::Get.new(uri.request_uri) + request['Authorization'] = "Negotiate #{token}" + http.request(request) + end + + def handle_negotiate_response(response) + if response.code.to_i == 200 + body = begin + ::JSON.parse(response.body) + rescue ::JSON::ParserError => e + Legion::Logging.debug("Auth#handle_negotiate_response JSON parse failed: #{e.message}") if defined?(Legion::Logging) + {} + end + data = body.is_a?(Hash) ? (body['data'] || body) : {} + token_val = data['token'] + if token_val + save_credentials(token_val) + display_negotiate_identity(data) + say 'Login successful (kerberos)', :green + else + say 'Authentication succeeded but no token in response', :yellow + end + else + say "Authentication failed: HTTP #{response.code}", :red + say response.body.to_s, :red + end + end + + def display_negotiate_identity(data) + name = data['display_name'] || [data['first_name'], data['last_name']].compact.join(' ') + say " Name: #{name}", :green unless name.empty? + say " Email: #{data['email']}", :green if data['email'] + say " Roles: #{Array(data['roles']).join(', ')}", :green + say ' Token saved to ~/.legionio/credentials', :green + end + + def save_credentials(token_val) + credentials_dir = ::File.join(::Dir.home, '.legionio') + ::FileUtils.mkdir_p(credentials_dir) + cred_path = ::File.join(credentials_dir, 'credentials') + ::File.write(cred_path, token_val) + ::File.chmod(0o600, cred_path) + end + end + end + end +end diff --git a/lib/legion/cli/bootstrap_command.rb b/lib/legion/cli/bootstrap_command.rb new file mode 100644 index 00000000..50c5aab2 --- /dev/null +++ b/lib/legion/cli/bootstrap_command.rb @@ -0,0 +1,439 @@ +# frozen_string_literal: true + +require 'English' +require 'json' +require 'fileutils' +require 'open3' +require 'rbconfig' +require 'thor' +require 'legion/cli/output' + +module Legion + module CLI + class Bootstrap < Thor + namespace 'bootstrap' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Machine-readable output' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :skip_packs, type: :boolean, default: false, desc: 'Skip gem pack installation (config only)' + class_option :start, type: :boolean, default: false, desc: 'Start redis + legionio via brew services after bootstrap' + class_option :force, type: :boolean, default: false, desc: 'Overwrite existing config files' + class_option :clean, type: :boolean, default: false, desc: 'Remove all existing config files before import' + + desc 'SOURCE', 'Bootstrap Legion from a URL or local config file (fetch config, scaffold, install packs)' + long_desc <<~DESC + Combines three manual steps into one: + + legionio config import SOURCE (fetch + write config) + legionio config scaffold (fill gaps with env-detected defaults) + legionio setup agentic (install cognitive gem packs) + + SOURCE may be an HTTPS URL or a local file path to a bootstrap JSON file. + The JSON may include a "packs" array (e.g. ["agentic"]) which controls which + gem packs are installed. That key is removed before the config is written. + + Options: + --skip-packs Skip gem pack installation entirely + --start After bootstrap, run: brew services start redis && brew services start legionio + --force Overwrite existing config files + --json Machine-readable JSON output + DESC + def execute(source) + require_relative 'config_import' + require_relative 'config_scaffold' + require_relative 'setup_command' + + out = formatter + results = {} + warns = [] + + # 1. Pre-flight checks + print_step(out, 'Pre-flight checks') + results[:preflight] = run_preflight_checks(out, warns) + + # 2. Clean existing config (--clean) + results[:cleaned] = clean_settings(out) if options[:clean] + + # 3. Fetch + parse config + print_step(out, "Fetching config from #{source}") + body = ConfigImport.fetch_source(source) + config = ConfigImport.parse_payload(body) + + # 4. Extract packs before writing (bootstrap-only directive) + pack_names = Array(config.delete(:packs)).map(&:to_s).reject(&:empty?) + results[:packs_requested] = pack_names + + # 5. Write config + paths = ConfigImport.write_config(config, force: options[:force]) + results[:config_written] = paths + unless options[:json] + if paths.empty? + out.warn('No config files were written (config was empty after removing packs).') + else + paths.each { |p| out.success("Written: #{p}") } + end + end + + # 6. Scaffold missing subsystem files (skipped when source provided) + results[:scaffold] = :skipped + + # 7. Install packs (unless --skip-packs) + results[:packs_installed] = install_packs_step(pack_names, out) + + # 8. Post-bootstrap summary + summary = build_summary(config, results, warns) + results[:summary] = summary + print_summary(out, summary) + + # 9. Optional --start + if options[:start] + print_step(out, 'Starting services') + results[:services_started] = start_services(out) + end + + out.json(results) if options[:json] + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + end + + default_task :execute + + no_commands do # rubocop:disable Metrics/BlockLength + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def print_step(out, message) + return if options[:json] + + out.spacer + out.header(message) + end + + # Wraps backtick execution, returning [output, success_bool]. + # Extracted as a method so specs can stub it cleanly. + def shell_capture(cmd) + output = `#{cmd} 2>&1` + [output, $CHILD_STATUS.success?] + end + + # ----------------------------------------------------------------------- + # Pre-flight checks + # ----------------------------------------------------------------------- + + def run_preflight_checks(out, warns) + { + klist: check_klist(out, warns), + brew: check_brew(out, warns), + legionio: check_legionio_binary(out, warns) + } + end + + def check_klist(out, warns) + output, success = shell_capture('klist') + if success && output.match?(/principal|Credentials/i) + out.success('Kerberos ticket valid') unless options[:json] + { status: :ok } + else + msg = 'No valid Kerberos ticket found. Run `kinit` before bootstrapping.' + warns << msg + out.warn(msg) unless options[:json] + { status: :warn, message: msg } + end + rescue StandardError => e + msg = "klist check failed: #{e.message}" + warns << msg + out.warn(msg) unless options[:json] + { status: :warn, message: msg } + end + + def check_brew(out, warns) + _, success = shell_capture('brew --version') + if success + out.success('Homebrew available') unless options[:json] + { status: :ok } + else + msg = 'Homebrew not found. Install from https://brew.sh' + warns << msg + out.warn(msg) unless options[:json] + { status: :warn, message: msg } + end + rescue StandardError => e + msg = "brew check failed: #{e.message}" + warns << msg + out.warn(msg) unless options[:json] + { status: :warn, message: msg } + end + + def check_legionio_binary(out, warns) + _, success = shell_capture('legionio version') + if success + out.success('legionio binary works') unless options[:json] + { status: :ok } + else + msg = 'legionio binary not responding. Try reinstalling: brew reinstall legionio' + warns << msg + out.warn(msg) unless options[:json] + { status: :warn, message: msg } + end + rescue StandardError => e + msg = "legionio binary check failed: #{e.message}" + warns << msg + out.warn(msg) unless options[:json] + { status: :warn, message: msg } + end + + def run_scaffold(out) + print_step(out, 'Scaffolding missing subsystem files') + silent_out = Output::Formatter.new(json: false, color: false) + scaffold_opts = build_scaffold_opts + scaffold_opts[:json] = false if options[:json] + ConfigScaffold.run(options[:json] ? silent_out : out, scaffold_opts) + :done + end + + def install_packs_step(pack_names, out) + if options[:skip_packs] + out.warn('Skipping pack installation (--skip-packs)') unless options[:json] + [] + else + print_step(out, "Installing packs: #{pack_names.join(', ')}") unless pack_names.empty? + install_packs(pack_names, out) + end + end + + # ----------------------------------------------------------------------- + # Clean settings (--clean) + # ----------------------------------------------------------------------- + + def clean_settings(out) + dir = ConfigImport::SETTINGS_DIR + files = Dir.glob(File.join(dir, '*.json')) + if files.empty? + out.warn("No existing config files to clean in #{dir}") unless options[:json] + return [] + end + + print_step(out, "Cleaning #{files.size} config file(s) from #{dir}") + files.each { |f| FileUtils.rm_f(f) } + files.each { |f| out.success("Removed: #{File.basename(f)}") } unless options[:json] + files + end + + # ----------------------------------------------------------------------- + # Scaffold options + # ----------------------------------------------------------------------- + + def build_scaffold_opts + { + force: options[:force], + json: options[:json], + only: options[:only], + full: options[:full], + dir: options[:dir] + } + end + + # ----------------------------------------------------------------------- + # Pack installation + # ----------------------------------------------------------------------- + + def install_packs(pack_names, out) + return [] if pack_names.empty? + + gem_bin = File.join(RbConfig::CONFIG['bindir'], 'gem') + results = [] + + pack_names.each do |pack_name| + pack_sym = pack_name.to_sym + pack = Setup::PACKS[pack_sym] + unless pack + out.warn("Unknown pack: #{pack_name} (valid: #{Setup::PACKS.keys.join(', ')})") unless options[:json] + next + end + + out.header("Installing pack: #{pack_name}") unless options[:json] + gem_results = install_pack_gems(pack[:gems], gem_bin, out) + Gem::Specification.reset + results << { pack: pack_name, results: gem_results } + end + + results + end + + def install_pack_gems(gem_names, gem_bin, out) + already_installed = [] + to_install = [] + + gem_names.each do |name| + Gem::Specification.find_by_name(name) + already_installed << name + rescue Gem::MissingSpecError + to_install << name + end + + gem_results = to_install.map { |g| install_single_gem(g, gem_bin, out) } + + already_installed.each do |g| + out.success(" #{g} already installed") unless options[:json] + end + + gem_results + end + + def install_single_gem(name, gem_bin, out) + puts " Installing #{name}..." unless options[:json] + output, success = shell_capture("#{gem_bin} install #{name} --no-document --source https://rubygems.org/") + if success + out.success(" #{name} installed") unless options[:json] + { name: name, status: 'installed' } + else + out.error(" #{name} failed") unless options[:json] + { name: name, status: 'failed', error: output.strip.lines.last&.strip } + end + end + + # ----------------------------------------------------------------------- + # Summary + # ----------------------------------------------------------------------- + + def build_summary(config, results, warns) + settings_dir = ConfigImport::SETTINGS_DIR + subsystem_files = ConfigScaffold::SUBSYSTEMS.to_h do |s| + path = File.join(settings_dir, "#{s}.json") + [s, File.exist?(path)] + end + + { + config_sections: config.keys.map(&:to_s), + packs_requested: results[:packs_requested] || [], + packs_installed: results[:packs_installed] || [], + subsystem_files: subsystem_files, + warnings: warns, + preflight: results[:preflight] || {} + } + end + + def print_summary(out, summary) + return if options[:json] + + out.spacer + out.header('Bootstrap Summary') + out.spacer + + print_config_sections(summary) + print_subsystem_files(summary) + print_packs_summary(out, summary) + print_warnings_section(out, summary) + print_next_steps(out) + end + + def print_config_sections(summary) + puts " Config sections: #{summary[:config_sections].join(', ')}" if summary[:config_sections].any? + end + + def print_subsystem_files(summary) + present = summary[:subsystem_files].select { |_, v| v }.keys + absent = summary[:subsystem_files].reject { |_, v| v }.keys + puts " Subsystem files present: #{present.join(', ')}" if present.any? + puts " Subsystem files missing: #{absent.join(', ')}" if absent.any? + end + + def print_packs_summary(out, summary) + summary[:packs_installed].each do |pack_result| + successes = (pack_result[:results] || []).count { |r| r[:status] == 'installed' } + failures = (pack_result[:results] || []).count { |r| r[:status] == 'failed' } + if failures.zero? + out.success("Pack #{pack_result[:pack]}: #{successes} gem(s) installed") + else + out.warn("Pack #{pack_result[:pack]}: #{successes} installed, #{failures} failed") + end + end + out.warn('Pack installation skipped') if options[:skip_packs] + end + + def print_warnings_section(out, summary) + return unless summary[:warnings].any? + + out.spacer + out.header('Attention') + summary[:warnings].each { |w| out.warn(w) } + end + + def print_next_steps(out) + return if options[:start] + + out.spacer + puts ' Next steps:' + puts ' brew services start redis && brew services start legionio' + puts ' legion' + end + + # ----------------------------------------------------------------------- + # Service startup (--start) + # ----------------------------------------------------------------------- + + def start_services(out) + redis_ok = run_brew_service('redis', out) + legion_ok = run_brew_service('legionio', out) + poll_daemon_ready(out) if redis_ok && legion_ok + { redis: redis_ok, legionio: legion_ok } + end + + def run_brew_service(service, out) + output, success = shell_capture("brew services start #{service}") + unless success + out.warn("#{service} failed to start: #{output.strip.lines.last&.strip}") unless options[:json] + return false + end + + out.success("#{service} started") unless options[:json] + kickstart_launchd_service("homebrew.mxcl.#{service}", out) + rescue StandardError => e + out.warn("brew services start #{service} raised: #{e.message}") unless options[:json] + false + end + + def kickstart_launchd_service(label, out) + return true unless RbConfig::CONFIG['host_os'] =~ /darwin/ + + uid = ::Process.uid + _, status = Open3.capture2e('launchctl', 'kickstart', "gui/#{uid}/#{label}") + return true if status.success? + + out.warn("launchctl kickstart #{label} failed (service may already be running)") unless options[:json] + false + end + + def poll_daemon_ready(out, port: 4567, timeout: 30) + require 'net/http' + deadline = ::Time.now + timeout + until ::Time.now > deadline + begin + resp = Net::HTTP.get_response(URI("http://localhost:#{port}/api/ready")) + if resp.is_a?(Net::HTTPSuccess) + out.success("Daemon ready on port #{port}") unless options[:json] + return true + end + rescue StandardError + # not ready yet — keep polling + end + sleep 1 + end + out.warn("Daemon did not become ready within #{timeout}s") unless options[:json] + false + end + end + end + end +end diff --git a/lib/legion/cli/broker_command.rb b/lib/legion/cli/broker_command.rb new file mode 100644 index 00000000..05210e66 --- /dev/null +++ b/lib/legion/cli/broker_command.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require 'net/http' +require 'erb' +require 'json' + +module Legion + module CLI + class Broker < Thor + namespace 'broker' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host' + class_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port' + class_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management username' + class_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password' + class_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost' + + desc 'stats', 'Show RabbitMQ broker statistics (queues, exchanges, consumers, DLX)' + def stats + out = formatter + data = fetch_stats + + if options[:json] + out.json(data) + else + out.header('RabbitMQ Broker Stats') + out.spacer + out.detail({ + queues: data[:queues], + exchanges: data[:exchanges], + consumers: data[:consumers], + dlx: data[:dlx] + }) + end + rescue Legion::CLI::Error => e + formatter.error(e.message) + exit(1) + end + + desc 'purge-topology', 'Remove old v2.0 AMQP exchanges (legion.* that have lex.* counterparts)' + option :execute, type: :boolean, default: false, desc: 'Actually delete exchanges (default: dry-run)' + def purge_topology + require 'legion/cli/admin_command' + out = formatter + exchanges = management_api("/exchanges/#{vhost_encoded}").map { |e| { name: e[:name], type: e[:type] } } + candidates = Legion::CLI::AdminCommand.detect_old_exchanges(exchanges) + + if candidates.empty? + out.success('No old v2.0 topology exchanges found.') + return + end + + if options[:json] + out.json({ candidates: candidates, deleted: options[:execute] }) + candidates.each { |e| management_delete("/exchanges/#{vhost_encoded}/#{ERB::Util.url_encode(e[:name])}") } if options[:execute] + return + end + + out.header("Old v2.0 Exchanges (#{candidates.size})") + candidates.each { |e| out.warn("#{e[:name]} (#{e[:type]})") } + out.spacer + + if options[:execute] + candidates.each { |e| management_delete("/exchanges/#{vhost_encoded}/#{ERB::Util.url_encode(e[:name])}") } + out.success("Purged #{candidates.size} exchange(s).") + else + out.warn('Dry-run mode — pass --execute to delete') + end + rescue Legion::CLI::Error => e + formatter.error(e.message) + exit(1) + end + + desc 'cleanup', 'Find (and optionally delete) orphaned queues with 0 consumers and 0 messages' + option :execute, type: :boolean, default: false, desc: 'Actually delete orphaned queues (default: dry-run)' + def cleanup + out = formatter + orphans = find_orphans + + if orphans.empty? + out.success('No orphaned queues found') + return + end + + if options[:json] + out.json({ orphaned_queues: orphans, deleted: options[:execute] }) + delete_orphans(orphans) if options[:execute] + return + end + + out.header("Orphaned Queues (#{orphans.size})") + orphans.each { |q| out.warn(q) } + out.spacer + + if options[:execute] + delete_orphans(orphans) + out.success("Deleted #{orphans.size} orphaned queue(s)") + else + out.warn('Dry-run mode — pass --execute to delete') + end + rescue Legion::CLI::Error => e + formatter.error(e.message) + exit(1) + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def vhost_encoded + ERB::Util.url_encode(options[:vhost]) + end + + def management_api(path) + uri = URI("http://#{options[:host]}:#{options[:port]}/api#{path}") + req = Net::HTTP::Get.new(uri) + req.basic_auth(options[:user], options[:password]) + + response = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http| + http.request(req) + end + + raise Legion::CLI::Error, "Management API error #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess) + + ::JSON.parse(response.body, symbolize_names: true) + rescue Errno::ECONNREFUSED + raise Legion::CLI::Error, "Cannot connect to RabbitMQ management API at #{options[:host]}:#{options[:port]}" + rescue Net::OpenTimeout, Net::ReadTimeout + raise Legion::CLI::Error, "Timed out connecting to RabbitMQ management API at #{options[:host]}:#{options[:port]}" + end + + def management_delete(path) + uri = URI("http://#{options[:host]}:#{options[:port]}/api#{path}") + req = Net::HTTP::Delete.new(uri) + req.basic_auth(options[:user], options[:password]) + + Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http| + http.request(req) + end + rescue Errno::ECONNREFUSED + raise Legion::CLI::Error, "Cannot connect to RabbitMQ management API at #{options[:host]}:#{options[:port]}" + end + + def fetch_stats + queues = management_api("/queues/#{vhost_encoded}") + exchanges = management_api("/exchanges/#{vhost_encoded}") + + total_consumers = queues.sum { |q| q[:consumers].to_i } + dlx_count = queues.count { |q| q.dig(:arguments, :'x-dead-letter-exchange') } + + { + queues: queues.size, + exchanges: exchanges.size, + consumers: total_consumers, + dlx: dlx_count + } + end + + def find_orphans + queues = management_api("/queues/#{vhost_encoded}") + queues + .select { |q| q[:consumers].to_i.zero? && q[:messages].to_i.zero? } + .map { |q| q[:name].to_s } + end + + def delete_orphans(orphans) + orphans.each do |name| + management_delete("/queues/#{vhost_encoded}/#{ERB::Util.url_encode(name)}") + end + end + end + end + end +end diff --git a/lib/legion/cli/chain.rb b/lib/legion/cli/chain.rb index ca09e411..a7229092 100755 --- a/lib/legion/cli/chain.rb +++ b/lib/legion/cli/chain.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli class Chain < Thor diff --git a/lib/legion/cli/chain_command.rb b/lib/legion/cli/chain_command.rb new file mode 100644 index 00000000..9f770f83 --- /dev/null +++ b/lib/legion/cli/chain_command.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chain < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'list', 'List task chains' + option :limit, type: :numeric, default: 20, aliases: ['-n'], desc: 'Number of chains to show' + def list + out = formatter + with_data do + rows = Legion::Data::Model::Chain + .order(Sequel.desc(:id)) + .limit(options[:limit]) + .map do |row| + v = row.values + active_str = v[:active] ? out.status('enabled') : out.status('disabled') + [v[:id].to_s, v[:name].to_s, active_str] + end + + out.table(%w[id name active], rows) + end + end + default_task :list + + desc 'create NAME', 'Create a new task chain' + def create(name) + out = formatter + with_data do + id = Legion::Data::Model::Chain.insert(name: name) + + if options[:json] + out.json(id: id, name: name) + else + out.success("Chain created: ##{id} (#{name})") + end + end + end + + desc 'delete ID', 'Delete a chain and its relationships' + option :confirm, type: :boolean, default: false, aliases: ['-y'], desc: 'Skip confirmation' + def delete(id) + out = formatter + with_data do + chain = Legion::Data::Model::Chain[id.to_i] + unless chain + out.error("Chain #{id} not found") + raise SystemExit, 1 + end + + unless options[:confirm] + out.warn("This will delete chain '#{chain.values[:name]}' and all dependent relationships") + print ' Continue? [y/N] ' + response = $stdin.gets&.chomp + unless response&.downcase == 'y' + out.warn('Aborted') + return + end + end + + chain.delete + out.success("Chain ##{id} deleted") + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def with_data + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = 'error' + Connection.ensure_data + yield + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + end + end + end +end diff --git a/lib/legion/cli/chat/agent_delegator.rb b/lib/legion/cli/chat/agent_delegator.rb new file mode 100644 index 00000000..508c3f85 --- /dev/null +++ b/lib/legion/cli/chat/agent_delegator.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module AgentDelegator + module_function + + def delegate?(input) + return :at_mention if input.match?(/\A@\w+\s/) + return :slash if input.match?(%r{\A/agent\s+\w+\s}) + + false + end + + def parse(input) + case delegate?(input) + when :at_mention + match = input.match(/\A@(\w+)\s+(.+)/m) + return nil unless match + + { agent_name: match[1], task: match[2].strip } + when :slash + match = input.match(%r{\A/agent\s+(\w+)\s+(.+)}m) + return nil unless match + + { agent_name: match[1], task: match[2].strip } + end + end + + def dispatch(agent_name:, task:, session:, out:, chat_log: nil) + require 'legion/cli/chat/agent_registry' + agent = AgentRegistry.find(agent_name) + unless agent + out.error("Unknown agent: @#{agent_name}. Available: #{AgentRegistry.names.join(', ')}") + return + end + + chat_log&.info("agent_delegate name=#{agent_name} task_length=#{task.length}") + + require 'legion/cli/chat/subagent' + prompt = build_agent_prompt(agent, task) + + result = Subagent.spawn( + task: prompt, + model: agent[:model], + on_complete: lambda { |_id, res| + output = res[:output] || res[:error] || 'No output' + session.chat.add_message( + role: :user, + content: "@#{agent_name} result:\n\n#{output}" + ) + puts out.dim("\n [@#{agent_name}] Complete. Results added to context.") + } + ) + + if result[:error] + out.error(result[:error]) + else + out.success("Delegated to @#{agent_name} (#{result[:id]})") + end + end + + def build_agent_prompt(agent, task) + parts = [] + parts << agent[:system_prompt] if agent[:system_prompt] + parts << "Task: #{task}" + parts.join("\n\n") + end + end + end + end +end diff --git a/lib/legion/cli/chat/agent_registry.rb b/lib/legion/cli/chat/agent_registry.rb new file mode 100644 index 00000000..6521c9fb --- /dev/null +++ b/lib/legion/cli/chat/agent_registry.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module AgentRegistry + AGENT_DIR = '.legion/agents' + SUPPORTED_EXTENSIONS = %w[.json .yml .yaml].freeze + + @agents = {} + + class << self + attr_reader :agents + + def load_agents(base_dir = Dir.pwd) + @agents = {} + dir = File.join(base_dir, AGENT_DIR) + return @agents unless Dir.exist?(dir) + + Dir.glob(File.join(dir, '*')).each do |path| + ext = File.extname(path) + next unless SUPPORTED_EXTENSIONS.include?(ext) + + agent = parse_file(path) + next unless agent && agent['name'] + + @agents[agent['name']] = normalize(agent, path) + end + + @agents + end + + def find(name) + @agents[name] + end + + def names + @agents.keys + end + + def list + @agents.values + end + + def match_for_task(task_description) + return nil if @agents.empty? + + @agents.values.max_by do |agent| + score = 0 + keywords = (agent[:description] || '').downcase.split(/\W+/) + task_words = task_description.downcase.split(/\W+/) + matching = (keywords & task_words).length + score += matching * 10 + score += (agent[:weight] || 1.0) * 5 + score + end + end + + private + + def parse_file(path) + content = File.read(path, encoding: 'utf-8') + case File.extname(path) + when '.json' + require 'json' + ::JSON.parse(content) + when '.yml', '.yaml' + require 'yaml' + YAML.safe_load(content, permitted_classes: [Symbol]) + end + rescue StandardError => e + Legion::Logging.debug("AgentRegistry#parse_file failed for #{path}: #{e.message}") if defined?(Legion::Logging) + nil + end + + def normalize(raw, source_path) + { + name: raw['name'], + description: raw['description'] || '', + model: raw['model'], + system_prompt: raw['system_prompt'] || raw['prompt'], + tools: raw['tools'], + weight: (raw['weight'] || 1.0).to_f, + conditions: raw['conditions'] || {}, + source: source_path + } + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/chat_logger.rb b/lib/legion/cli/chat/chat_logger.rb new file mode 100644 index 00000000..1c5aa727 --- /dev/null +++ b/lib/legion/cli/chat/chat_logger.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'logger' +require 'fileutils' +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module ChatLogger + LOG_DIR = File.expand_path('~/.legion') + LOG_FILE = File.join(LOG_DIR, 'legion-chat.log') + LEVELS = { + 'debug' => ::Logger::DEBUG, + 'info' => ::Logger::INFO, + 'warn' => ::Logger::WARN, + 'error' => ::Logger::ERROR + }.freeze + + class << self + attr_reader :logger + + def setup(level: 'info') + FileUtils.mkdir_p(LOG_DIR) + @logger = ::Logger.new(LOG_FILE, 5, 1_048_576) # 5 rotated files, 1MB each + @logger.level = parse_level(level) + @logger.formatter = method(:format_entry) + @logger + end + + def debug(msg) = logger&.debug(msg) + + def info(msg) = logger&.info(msg) + + def warn(msg) = logger&.warn(msg) + + def error(msg) = logger&.error(msg) + + private + + def parse_level(level = 'info') + normalized_level = level.to_s.strip.downcase + return ::Logger::INFO if normalized_level.empty? + + LEVELS.fetch(normalized_level, ::Logger::INFO) + end + + def format_entry(severity, datetime, _progname, msg) + "[#{datetime.strftime('%Y-%m-%d %H:%M:%S.%L')}] #{severity.ljust(5)} #{msg}\n" + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/checkpoint.rb b/lib/legion/cli/chat/checkpoint.rb new file mode 100644 index 00000000..2cfa291a --- /dev/null +++ b/lib/legion/cli/chat/checkpoint.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module Checkpoint + Entry = Struct.new(:path, :content, :existed, :timestamp) + + @entries = [] + @max_depth = 10 + @mode = :per_edit + @storage_dir = nil + + class << self + attr_accessor :max_depth, :mode + attr_reader :entries + + def configure(max_depth: 10, mode: :per_edit) + @max_depth = max_depth + @mode = mode + @entries = [] + @storage_dir = nil + end + + def save(path) + expanded = File.expand_path(path) + entry = Entry.new( + path: expanded, + content: File.exist?(expanded) ? File.read(expanded, encoding: 'utf-8') : nil, + existed: File.exist?(expanded), + timestamp: Time.now + ) + @entries.push(entry) + @entries.shift while @entries.length > @max_depth + persist_entry(entry) + entry + end + + def rewind(steps = 1) + return [] if @entries.empty? + + steps = [steps, @entries.length].min + restored = [] + steps.times do + entry = @entries.pop + restore_entry(entry) + restored << entry + end + restored + end + + def rewind_file(path) + expanded = File.expand_path(path) + idx = @entries.rindex { |e| e.path == expanded } + return nil unless idx + + entry = @entries.delete_at(idx) + restore_entry(entry) + entry + end + + def list + @entries.map do |e| + { + path: e.path, + existed: e.existed, + timestamp: e.timestamp + } + end + end + + def clear + cleanup_storage + @entries.clear + end + + def count + @entries.length + end + + private + + def restore_entry(entry) + if entry.existed + FileUtils.mkdir_p(File.dirname(entry.path)) + File.write(entry.path, entry.content, encoding: 'utf-8') + else + FileUtils.rm_f(entry.path) + end + end + + def storage_dir + @storage_dir ||= begin + dir = File.join(Dir.tmpdir, "legion-checkpoint-#{::Process.pid}") + FileUtils.mkdir_p(dir) + dir + end + end + + def persist_entry(entry) + return unless entry.existed + + safe_name = entry.path.gsub('/', '_').gsub('\\', '_') + backup_path = File.join(storage_dir, "#{@entries.length}_#{safe_name}") + File.write(backup_path, entry.content, encoding: 'utf-8') + rescue StandardError => e + Legion::Logging.warn("Checkpoint#persist_entry failed for #{entry.path}: #{e.message}") if defined?(Legion::Logging) + # In-memory fallback is always available via @entries + nil + end + + def cleanup_storage + return unless @storage_dir && Dir.exist?(@storage_dir) + + FileUtils.rm_rf(@storage_dir) + @storage_dir = nil + rescue StandardError => e + Legion::Logging.warn("Checkpoint#cleanup_storage failed: #{e.message}") if defined?(Legion::Logging) + nil + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/context.rb b/lib/legion/cli/chat/context.rb new file mode 100644 index 00000000..e0825e08 --- /dev/null +++ b/lib/legion/cli/chat/context.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' +require 'shellwords' +require 'net/http' +require 'json' + +module Legion + module CLI + class Chat + module Context + PROJECT_MARKERS = { + 'Gemfile' => :ruby, + 'package.json' => :javascript, + 'Cargo.toml' => :rust, + 'go.mod' => :go, + 'pyproject.toml' => :python, + 'requirements.txt' => :python, + 'pom.xml' => :java, + 'build.gradle' => :java, + 'main.tf' => :terraform, + 'Makefile' => :make + }.freeze + + def self.detect(directory) + dir = File.expand_path(directory) + { + directory: dir, + project_type: detect_project_type(dir), + git_branch: detect_git_branch(dir), + git_dirty: detect_git_dirty(dir), + project_file: detect_project_file(dir) + } + end + + def self.to_system_prompt(directory, extra_dirs: []) + ctx = detect(directory) + parts = [] + parts << 'You are Legion, an AI assistant powered by the LegionIO framework.' + parts << 'You have access to tools for reading files, writing files, editing files, searching, and running shell commands.' + parts << 'Be concise and helpful. Use markdown formatting for code.' + parts << '' + parts << 'IMPORTANT: You are the AI assistant. Do not generate content (code, specs, prompts, ' \ + 'instructions) specifically for users to copy/paste into other AI tools (Claude, Codex, ' \ + 'ChatGPT, Copilot, etc.). If a user wants to accomplish a task, help them do it directly. ' \ + 'If they need API documentation, point them to `legion openapi generate` or the running ' \ + 'API at /api/openapi.json. Do not act as a clipboard intermediary between the user and another AI.' + parts << '' + parts << "Working directory: #{ctx[:directory]}" + parts << "Project type: #{ctx[:project_type]}" if ctx[:project_type] + parts << "Git branch: #{ctx[:git_branch]}" if ctx[:git_branch] + parts << 'Uncommitted changes present' if ctx[:git_dirty] + + begin + require 'legion/cli/chat/extension_tool_loader' + ext_tools = Chat::ExtensionToolLoader.discover + if ext_tools.any? + ext_names = ext_tools.filter_map do |t| + next unless t.name + + t.name.split('::').last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase + end + parts << "Extension tools available: #{ext_names.join(', ')}" + end + rescue LoadError => e + Legion::Logging.debug("Context#to_system_prompt ExtensionToolLoader not available: #{e.message}") if defined?(Legion::Logging) + end + + parts << cognitive_awareness(directory) + parts << self_awareness_hint + + extra_dirs.each do |dir| + expanded = File.expand_path(dir) + next unless Dir.exist?(expanded) + + parts << "Additional directory: #{expanded}" + end + + %w[LEGION.md CLAUDE.md].each do |name| + path = File.join(ctx[:directory], name) + next unless File.exist?(path) + + content = File.read(path, encoding: 'utf-8') + parts << '' + parts << "# Project Instructions (#{name})" + parts << content + break + end + + parts.join("\n") + end + + def self.detect_project_type(dir) + PROJECT_MARKERS.each do |file, type| + return type if File.exist?(File.join(dir, file)) + end + nil + end + + def self.detect_git_branch(dir) + head = File.join(dir, '.git', 'HEAD') + return nil unless File.exist?(head) + + ref = File.read(head).strip + ref.start_with?('ref: refs/heads/') ? ref.sub('ref: refs/heads/', '') : ref[0..7] + end + + def self.detect_git_dirty(dir) + return false unless File.exist?(File.join(dir, '.git')) + + output = `cd #{Shellwords.escape(dir)} && git status --porcelain 2>/dev/null` + !output.strip.empty? + rescue StandardError => e + Legion::Logging.debug("Context#detect_git_dirty failed: #{e.message}") if defined?(Legion::Logging) + false + end + + def self.cognitive_awareness(directory) + hints = [] + hints << daemon_hint + hints << memory_hint(directory) + hints << apollo_hint + hints.compact! + return nil if hints.empty? + + "\nCognitive context:\n#{hints.join("\n")}" + rescue StandardError => e + Legion::Logging.debug("Context#cognitive_awareness failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def self.memory_hint(directory) + require 'legion/cli/chat/memory_store' + project_entries = Chat::MemoryStore.list(base_dir: directory) + global_entries = Chat::MemoryStore.list(scope: :global) + total = project_entries.size + global_entries.size + return nil if total.zero? + + " Memory: #{project_entries.size} project + #{global_entries.size} global entries (use save_memory/search_memory/consolidate_memory)" + rescue LoadError + nil + end + + def self.apollo_hint + uri = URI("http://127.0.0.1:#{apollo_port}/api/apollo/status") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 1 + http.read_timeout = 1 + response = http.get(uri.path) + data = ::JSON.parse(response.body, symbolize_names: true) + available = data.dig(:data, :available) + return nil unless available + + ' Apollo knowledge graph: online (use query_knowledge/ingest_knowledge/relate_knowledge/knowledge_stats)' + rescue StandardError + nil + end + + def self.daemon_hint + port = apollo_port + uri = URI("http://127.0.0.1:#{port}/api/health") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 1 + http.read_timeout = 1 + response = http.get(uri.path) + data = ::JSON.parse(response.body, symbolize_names: true) + return nil unless data[:status] == 'ok' + + parts = [" Legion daemon: running on port #{port}"] + parts << " (v#{data[:version]})" if data[:version] + parts.join + rescue StandardError + nil + end + + def self.apollo_port + return 4567 unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || 4567 + rescue StandardError + 4567 + end + + def self.detect_project_file(dir) + PROJECT_MARKERS.each_key do |file| + path = File.join(dir, file) + return path if File.exist?(path) + end + nil + end + + def self.self_awareness_hint + return nil unless defined?(Legion::Extensions::Agentic::Self::Metacognition::Runners::Metacognition) + + result = Legion::Extensions::Agentic::Self::Metacognition::Runners::Metacognition.self_narrative + narrative = result[:prose] if result.is_a?(Hash) && result[:prose] + narrative ? "\nCurrent self-awareness:\n#{narrative}" : nil + rescue StandardError + nil + end + end + end + end +end diff --git a/lib/legion/cli/chat/context_manager.rb b/lib/legion/cli/chat/context_manager.rb new file mode 100644 index 00000000..e09d2086 --- /dev/null +++ b/lib/legion/cli/chat/context_manager.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + # Manages conversation context window size through deduplication, + # stopword compression, and LLM-based summarization. + # Integrates with Legion::LLM::Compressor when available. + module ContextManager + COMPACT_THRESHOLD = 40 + TOKEN_ESTIMATE_RATIO = 4 # ~4 chars per token + + class << self + def compact(session, strategy: :auto) + messages = session.chat.messages.map(&:to_h) + return { compacted: false, reason: 'too_few_messages' } if messages.length < 4 + + case strategy + when :auto + auto_compact(session, messages) + when :dedup + dedup_only(session, messages) + when :summarize + summarize_compact(session, messages) + else + { compacted: false, reason: 'unknown_strategy' } + end + end + + def should_auto_compact?(session) + session.chat.messages.length >= COMPACT_THRESHOLD + end + + def stats(session) + messages = session.chat.messages.map(&:to_h) + char_count = messages.sum { |m| m[:content].to_s.length } + { + message_count: messages.length, + estimated_tokens: char_count / TOKEN_ESTIMATE_RATIO, + char_count: char_count, + by_role: messages.group_by { |m| m[:role].to_s }.transform_values(&:size) + } + end + + private + + def auto_compact(session, messages) + results = { strategy: :auto, steps: [] } + + dedup_result = try_dedup(messages) + if dedup_result && dedup_result[:removed].positive? + messages = dedup_result[:messages] + results[:steps] << { action: :dedup, removed: dedup_result[:removed] } + end + + if messages.length > COMPACT_THRESHOLD && compressor_available? + compressed = compress_messages(messages) + if compressed + messages = compressed[:messages] + results[:steps] << { action: :compress, method: :stopword } + end + end + + apply_messages(session, messages) + results[:compacted] = results[:steps].any? + results[:final_count] = messages.length + results + end + + def dedup_only(session, messages) + dedup_result = try_dedup(messages) + if dedup_result && dedup_result[:removed].positive? + apply_messages(session, dedup_result[:messages]) + { compacted: true, strategy: :dedup, removed: dedup_result[:removed], + final_count: dedup_result[:messages].length } + else + { compacted: false, reason: 'no_duplicates' } + end + end + + def summarize_compact(session, messages) + if compressor_available? + result = Legion::LLM::Compressor.summarize_messages(messages, max_tokens: 2000) + if result[:compressed] + session.chat.reset_messages! + session.chat.add_message(role: :assistant, content: result[:summary]) + return { compacted: true, strategy: :summarize, method: result[:method] || :llm, + original_count: result[:original_count], final_count: 1 } + end + end + + { compacted: false, reason: 'summarization_unavailable' } + end + + def try_dedup(messages) + return nil unless compressor_available? + + Legion::LLM::Compressor.deduplicate_messages(messages, threshold: 0.85) + rescue StandardError => e + log_debug("dedup failed: #{e.message}") + nil + end + + def compress_messages(messages) + compressed = messages.map do |msg| + content = msg[:content].to_s + next msg if content.length < 50 + + compressed_content = Legion::LLM::Compressor.compress(content, level: 2) + msg.merge(content: compressed_content) + end + { messages: compressed } + rescue StandardError => e + log_debug("compress failed: #{e.message}") + nil + end + + def apply_messages(session, messages) + session.chat.reset_messages! + messages.each { |msg| session.chat.add_message(msg) } + end + + def compressor_available? + defined?(Legion::LLM::Compressor) + end + + def log_debug(msg) + Legion::Logging.debug("ContextManager: #{msg}") if defined?(Legion::Logging) + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/daemon_chat.rb b/lib/legion/cli/chat/daemon_chat.rb new file mode 100644 index 00000000..1f2c4ac2 --- /dev/null +++ b/lib/legion/cli/chat/daemon_chat.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'legion/cli/chat_command' + +begin + require 'legion/llm/daemon_client' +rescue LoadError + # legion-llm not yet loaded; DaemonClient must be defined before DaemonChat#ask is called. +end + +module Legion + module CLI + class Chat + # Daemon-backed chat adapter. Matches the interface that Session expects + # from a chat object (ask, with_tools, with_instructions, on_tool_call, + # on_tool_result, model, add_message, reset_messages!, with_model). + # + # All LLM inference is routed through the running daemon via + # POST /api/llm/inference. Tool execution runs locally on the client + # machine — the daemon returns tool_call requests and the client + # executes them and loops. + class DaemonChat + # Minimal response-like object returned from ask. + # Responds to the same interface Session#send_message reads. + Response = Struct.new(:content, :input_tokens, :output_tokens, :model) + + # Minimal model object responding to .id (used by Session#model_id). + ModelInfo = Struct.new(:id) do + def to_s + id.to_s + end + end + + # Single shared struct class for tool result objects; avoids allocating + # an anonymous Struct class on every build_tool_result_object call. + ToolResult = Struct.new(:content, :tool_call_id, :id) + + attr_reader :model, :conversation_id, :caller_context + + def initialize(model: nil, provider: nil) + @model = ModelInfo.new(id: model) + @provider = provider + @messages = [] + @tools = [] + @instructions = nil + @on_tool_call = nil + @on_tool_result = nil + @conversation_id = SecureRandom.uuid + @caller_context = build_caller + end + + # Sets the system prompt. Returns self for chaining. + def with_instructions(prompt) + @instructions = prompt + self + end + + # Registers tool classes for local execution and schema forwarding. + # Returns self for chaining. + def with_tools(*tools) + @tools = tools.flatten + self + end + + # Switches the active model. Returns self for chaining. + def with_model(model_id) + @model = ModelInfo.new(id: model_id) + self + end + + # Stores a tool_call callback invoked before each local tool execution. + def on_tool_call(&block) + @on_tool_call = block + end + + # Stores a tool_result callback invoked after each local tool execution. + def on_tool_result(&block) + @on_tool_result = block + end + + # Appends a message to the conversation history directly (used by + # slash commands /fetch, /search, /agent, etc. that inject context). + def add_message(role:, content:) + @messages << { role: role.to_s, content: content } + end + + # Clears all conversation history (used by /clear slash command). + def reset_messages! + @messages = [] + end + + # Sends a message through the daemon inference loop. + # Executes any tool_calls locally and loops until the LLM stops. + # Yields response-like chunks for streaming display (Phase 1: single chunk). + # Returns a Response object compatible with Session#send_message. + def ask(message, &on_chunk) + @messages << { role: 'user', content: message } + + loop do + result = call_daemon_inference + + raise CLI::Error, "Daemon inference error: #{result[:error]}" if result[:status] == :error + raise CLI::Error, 'Daemon is unavailable' if result[:status] == :unavailable + + data = extract_data(result) + + if data[:tool_calls]&.any? + execute_tool_calls(data[:tool_calls], data[:content]) + else + on_chunk&.call(Response.new(content: data[:content])) + @messages << { role: 'assistant', content: data[:content] } + return build_response(data) + end + end + end + + private + + def call_daemon_inference + Legion::LLM::DaemonClient.inference( + messages: build_messages, + tools: build_tool_schemas, + model: @model.id, + provider: @provider, + caller: @caller_context, + conversation_id: @conversation_id + ) + end + + def extract_data(result) + # DaemonClient.inference returns { status:, data: { content:, tool_calls:, ... } } + data = result[:data] || result[:body] || {} + data.is_a?(Hash) ? data : {} + end + + def build_messages + msgs = [] + msgs << { role: 'system', content: @instructions } if @instructions + msgs + @messages + end + + def build_tool_schemas + @tools.map do |tool| + { + name: tool_name(tool), + description: tool_description(tool), + parameters: tool_parameters(tool) + } + end + end + + def tool_name(tool) + if tool.respond_to?(:tool_name) + tool.tool_name + else + tool.name.to_s.split('::').last.gsub(/([A-Z])/) do + "_#{::Regexp.last_match(1).downcase}" + end.delete_prefix('_') + end + end + + def tool_description(tool) + tool.respond_to?(:description) ? tool.description : '' + end + + def tool_parameters(tool) + tool.respond_to?(:parameters) ? tool.parameters : {} + end + + def execute_tool_calls(tool_calls, assistant_content) + # Record the assistant turn with tool_calls before appending results. + @messages << { role: 'assistant', content: assistant_content, tool_calls: tool_calls } + + # Normalize all tool calls upfront so threads don't mutate shared state + normalized = tool_calls.map do |tc| + tc.respond_to?(:transform_keys) ? tc.transform_keys(&:to_sym) : tc + end + + # Fire on_tool_call callbacks immediately (serial — fast, just event emission) + normalized.each do |tc| + @on_tool_call&.call(build_tool_call_object(tc)) + end + + # Execute all tools in parallel, preserving original order for message replay + results = normalized.map do |tc| + Thread.new { [tc, run_tool(tc)] } + end.map(&:value) + + # Collect results serially: fire callbacks and append messages in order + results.each do |tc, result_text| + result_obj = build_tool_result_object(result_text, tc[:id] || tc[:tool_call_id]) + @on_tool_result&.call(result_obj) + + @messages << { + role: 'tool', + tool_call_id: tc[:id] || tc[:tool_call_id], + content: result_text.to_s + } + end + end + + def build_tool_call_object(tool_call) + Struct.new(:name, :arguments, :id).new( + name: tool_call[:name].to_s, + arguments: (tool_call[:arguments] || tool_call[:input] || {}).transform_keys(&:to_sym), + id: tool_call[:id] || tool_call[:tool_call_id] + ) + end + + # Carries both the result content AND the originating tool_call_id so the + # daemon-bridge-script serializer can include it in the tool-result event, + # allowing the Interlink frontend to match results back to the correct + # tool call by ID (rather than falling back to name-based matching which + # breaks when multiple tools of the same type run in parallel). + def build_tool_result_object(text, tool_call_id = nil) + ToolResult.new(text.to_s, tool_call_id, tool_call_id) + end + + def run_tool(tool_call) + name = tool_call[:name].to_s + arguments = (tool_call[:arguments] || tool_call[:input] || {}).transform_keys(&:to_sym) + + tool_class = @tools.find { |t| tool_name(t) == name } + return "Unknown tool: #{name}" unless tool_class + + tool_class.call(**arguments) + rescue StandardError => e + "Tool error (#{name}): #{e.message}" + end + + def build_caller + identity = resolve_identity + { requested_by: { identity: identity, type: :human, credential: :local } } + end + + def resolve_identity + if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:kerberos_principal) + principal = Legion::Crypt.kerberos_principal + return principal if principal + end + + require 'etc' + Etc.getlogin || ENV.fetch('USER', 'unknown') + rescue StandardError + ENV.fetch('USER', 'unknown') + end + + def build_response(data) + Response.new( + content: data[:content], + input_tokens: data[:input_tokens], + output_tokens: data[:output_tokens], + model: ModelInfo.new(id: data[:model] || @model.id) + ) + end + end + end + end +end diff --git a/lib/legion/cli/chat/extension_tool.rb b/lib/legion/cli/chat/extension_tool.rb new file mode 100644 index 00000000..279c9531 --- /dev/null +++ b/lib/legion/cli/chat/extension_tool.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module ExtensionTool + VALID_TIERS = %i[read write shell].freeze + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def permission_tier(tier = nil) + if tier + raise ArgumentError, "Invalid permission tier: #{tier}" unless VALID_TIERS.include?(tier) + + @declared_permission_tier = tier + end + @declared_permission_tier + end + + def declared_permission_tier + @declared_permission_tier || :write + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/extension_tool_loader.rb b/lib/legion/cli/chat/extension_tool_loader.rb new file mode 100644 index 00000000..e720a191 --- /dev/null +++ b/lib/legion/cli/chat/extension_tool_loader.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'legion/cli/chat/extension_tool' + +module Legion + module CLI + class Chat + module ExtensionToolLoader + TIER_ORDER = { read: 0, write: 1, shell: 2 }.freeze + + class << self + def discover + @discover ||= load_all_extension_tools + end + + def reset! + @discover = nil + end + + def tools_dir_for(extension_path) + "#{extension_path}/tools" + end + + def collect_tool_classes(tools_module) + tools_module.constants.filter_map do |const_name| + klass = tools_module.const_get(const_name) + klass if klass.is_a?(Class) && klass < Legion::Tools::Base + end + end + + def tool_enabled?(extension_name) + settings = extension_settings(extension_name) + return true unless settings&.dig(:tools, :enabled) == false + + false + end + + def effective_tier(tool_class, extension_name) + class_tier = if tool_class.respond_to?(:declared_permission_tier) + tool_class.declared_permission_tier + else + :write + end + override = settings_tier_for(tool_class, extension_name) + return class_tier unless override + + TIER_ORDER[override] > TIER_ORDER[class_tier] ? override : class_tier + end + + def extension_settings(extension_name) + return nil unless defined?(Legion::Settings) + + Legion::Settings[:extensions]&.[](extension_name.to_sym) + rescue StandardError => e + Legion::Logging.warn("ExtensionToolLoader#extension_settings failed for #{extension_name}: #{e.message}") if defined?(Legion::Logging) + nil + end + + private + + def load_all_extension_tools + tools = [] + loaded_extension_paths.each do |ext_name, ext_path| + next unless tool_enabled?(ext_name) + + tools_dir = tools_dir_for(ext_path) + next unless Dir.exist?(tools_dir) + + require_tool_files(tools_dir) + tools_module = resolve_tools_module(ext_name) + next unless tools_module + + found = collect_tool_classes(tools_module) + tools.concat(found) + end + tools + end + + def loaded_extension_paths + return [] unless defined?(Legion::Extensions) + + Legion::Extensions.instance_variable_get(:@extensions)&.map do |name, info| + gem_spec = Gem::Specification.find_by_name(info[:gem_name]) + ext_path = "#{gem_spec.gem_dir}/lib/legion/extensions/#{name}" + [name, ext_path] + rescue Gem::MissingSpecError => e + Legion::Logging.debug("ExtensionToolLoader#loaded_extension_paths gem not found for #{name}: #{e.message}") if defined?(Legion::Logging) + nil + end&.compact || [] + end + + def require_tool_files(tools_dir) + Dir["#{tools_dir}/*.rb"].each { |f| require f } + end + + def resolve_tools_module(ext_name) + class_name = ext_name.to_s.split('_').map(&:capitalize).join + module_path = "Legion::Extensions::#{class_name}::Tools" + Kernel.const_get(module_path) + rescue NameError + nil + end + + def settings_tier_for(tool_class, extension_name) + settings = extension_settings(extension_name) + return nil unless settings + + tool_name = tool_class.name&.split('::')&.last + return nil unless tool_name + + tool_key = tool_name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym + tier = settings.dig(:tools, tool_key, :tier) + tier&.to_sym + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/markdown_renderer.rb b/lib/legion/cli/chat/markdown_renderer.rb new file mode 100644 index 00000000..5367e355 --- /dev/null +++ b/lib/legion/cli/chat/markdown_renderer.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module MarkdownRenderer + BOLD = "\e[1m" + DIM = "\e[2m" + ITALIC = "\e[3m" + RESET = "\e[0m" + CYAN = "\e[36m" + GREEN = "\e[32m" + YELLOW = "\e[33m" + CODE_BG = "\e[48;5;236m\e[38;5;253m" + RULE = "\e[2m#{'─' * 40}\e[0m".freeze + + CODE_FENCE = /^```(\w*)\s*$/ + + class << self + def render(text, color: true) + return text unless color + + lines = text.lines + output = String.new + i = 0 + + while i < lines.length + line = lines[i] + + if line.match?(CODE_FENCE) + lang = line.match(CODE_FENCE)[1] + code_lines = [] + i += 1 + while i < lines.length && !lines[i].match?(/^```\s*$/) + code_lines << lines[i] + i += 1 + end + i += 1 # skip closing ``` + output << render_code_block(code_lines.join, lang) + else + output << render_line(line) + i += 1 + end + end + + output + end + + private + + def render_code_block(code, lang) + highlighted = highlight(code, lang) + label = lang.empty? ? '' : "#{DIM}#{lang}#{RESET}\n" + "#{label}#{highlighted}\n" + end + + def highlight(code, lang) + require 'rouge' + + lexer = Rouge::Lexer.find(lang) || Rouge::Lexers::PlainText.new + formatter = Rouge::Formatters::Terminal256.new(Rouge::Themes::Monokai.new) + formatter.format(lexer.lex(code)) + rescue LoadError => e + Legion::Logging.debug("MarkdownRenderer#highlight rouge not available: #{e.message}") if defined?(Legion::Logging) + code + end + + def render_line(line) + case line + when /^\#{3,}\s+(.*)/ + "#{BOLD}#{CYAN}#{Regexp.last_match(1)}#{RESET}\n" + when /^\#{2}\s+(.*)/ + "#{BOLD}#{GREEN}#{Regexp.last_match(1)}#{RESET}\n" + when /^\#\s+(.*)/ + "#{BOLD}#{YELLOW}#{Regexp.last_match(1)}#{RESET}\n" + when /^---\s*$/, /^\*\*\*\s*$/, /^___\s*$/ + "#{RULE}\n" + when /^(\s*)[-*+]\s+(.*)/ + "#{Regexp.last_match(1)} #{DIM}#{RESET} #{render_inline(Regexp.last_match(2))}\n" + when /^(\s*)\d+\.\s+(.*)/ + "#{Regexp.last_match(1)} #{render_inline(Regexp.last_match(2))}\n" + when /^>\s*(.*)/ + "#{DIM} #{render_inline(Regexp.last_match(1))}#{RESET}\n" + else + render_inline(line) + end + end + + def render_inline(text) + result = text.dup + result.gsub!(/\*\*(.+?)\*\*/, "#{BOLD}\\1#{RESET}") + result.gsub!(/\*(.+?)\*/, "#{ITALIC}\\1#{RESET}") + result.gsub!(/`([^`]+)`/, "#{CODE_BG}\\1#{RESET}") + result + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/memory_store.rb b/lib/legion/cli/chat/memory_store.rb new file mode 100644 index 00000000..ccdca635 --- /dev/null +++ b/lib/legion/cli/chat/memory_store.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module MemoryStore + DEFAULT_PROJECT_FILE = '.legion/memory.md' + DEFAULT_GLOBAL_DIR = File.join(Dir.home, '.legion', 'memory') + DEFAULT_GLOBAL_FILE = File.join(DEFAULT_GLOBAL_DIR, 'global.md') + + module_function + + def project_path(base_dir = Dir.pwd) + File.join(base_dir, DEFAULT_PROJECT_FILE) + end + + def global_path + DEFAULT_GLOBAL_FILE + end + + def load_all(base_dir = Dir.pwd) + memories = [] + [global_path, project_path(base_dir)].each do |path| + next unless File.exist?(path) + + memories << { source: path, content: File.read(path, encoding: 'utf-8') } + end + memories + end + + def load_context(base_dir = Dir.pwd) + parts = load_all(base_dir).map do |m| + label = m[:source].include?('global') ? 'Global Memory' : 'Project Memory' + "## #{label}\n\n#{m[:content]}" + end + return nil if parts.empty? + + parts.join("\n\n---\n\n") + end + + def add(text, scope: :project, base_dir: Dir.pwd) + path = scope == :global ? global_path : project_path(base_dir) + ensure_dir(path) + + timestamp = Time.now.strftime('%Y-%m-%d %H:%M') + entry = "\n- #{text} _(#{timestamp})_\n" + + if File.exist?(path) + File.open(path, 'a', encoding: 'utf-8') { |f| f.write(entry) } + else + header = scope == :global ? "# Global Memory\n" : "# Project Memory\n" + File.write(path, "#{header}#{entry}", encoding: 'utf-8') + end + + sync_to_team(text) + path + end + + def forget(pattern, scope: :project, base_dir: Dir.pwd) + path = scope == :global ? global_path : project_path(base_dir) + return 0 unless File.exist?(path) + + lines = File.readlines(path, encoding: 'utf-8') + original_count = lines.length + lines.reject! { |line| line.include?(pattern) } + removed = original_count - lines.length + File.write(path, lines.join, encoding: 'utf-8') + removed + end + + def list(scope: :project, base_dir: Dir.pwd) + path = scope == :global ? global_path : project_path(base_dir) + return [] unless File.exist?(path) + + File.readlines(path, encoding: 'utf-8') + .select { |line| line.start_with?('- ') } + .map { |line| line.sub(/\A- /, '').strip } + end + + def clear(scope: :project, base_dir: Dir.pwd) + path = scope == :global ? global_path : project_path(base_dir) + return false unless File.exist?(path) + + File.delete(path) + true + end + + def search(query, base_dir: Dir.pwd) + results = [] + load_all(base_dir).each do |m| + m[:content].lines.each_with_index do |line, idx| + next unless line.downcase.include?(query.downcase) + + results << { source: m[:source], line: idx + 1, text: line.strip } + end + end + results + end + + def ensure_dir(path) + FileUtils.mkdir_p(File.dirname(path)) + end + private_class_method :ensure_dir + + def sync_to_team(text) + require 'legion/cli/chat/team_memory' + Chat::TeamMemory.sync_add(text) + rescue StandardError => e + Legion::Logging.debug("MemoryStore#sync_to_team failed: #{e.message}") if defined?(Legion::Logging) + end + private_class_method :sync_to_team + end + end + end +end diff --git a/lib/legion/cli/chat/output_styles.rb b/lib/legion/cli/chat/output_styles.rb new file mode 100644 index 00000000..d306d9de --- /dev/null +++ b/lib/legion/cli/chat/output_styles.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'yaml' + +module Legion + module CLI + class Chat + module OutputStyles + STYLE_DIRS = ['.legionio/output-styles', '~/.legionio/output-styles'].freeze + + class << self + def discover + STYLE_DIRS.flat_map do |dir| + expanded = File.expand_path(dir) + next [] unless Dir.exist?(expanded) + + Dir.glob(File.join(expanded, '*.md')).filter_map { |f| parse(f) } + end + end + + def active_styles + discover.select { |s| s[:active] } + end + + def find(name) + discover.find { |s| s[:name] == name.to_s } + end + + def activate(name) + style = find(name) + return nil unless style + + path = style[:path] + content = File.read(path) + content.sub!(/^---\s*$/, "---\nactive: true") unless content.match?(/^active:\s/) + content.gsub!(/^active:\s+\w+/, 'active: true') + File.write(path, content) + style[:name] + end + + def system_prompt_injection + active = active_styles + return nil if active.empty? + + active.map { |s| s[:content] }.join("\n\n") + end + + def parse(path) + raw = File.read(path) + return nil unless raw.start_with?('---') + + parts = raw.split(/^---\s*$/, 3) + return nil if parts.size < 3 + + frontmatter = YAML.safe_load(parts[1], permitted_classes: [Symbol]) + body = parts[2]&.strip + + { + name: frontmatter['name'] || File.basename(path, '.md'), + description: frontmatter['description'] || '', + active: frontmatter['active'] == true, + content: body, + path: path + } + rescue StandardError => e + Legion::Logging.warn "OutputStyles parse error #{path}: #{e.message}" if defined?(Legion::Logging) + nil + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/permissions.rb b/lib/legion/cli/chat/permissions.rb new file mode 100644 index 00000000..31bd648e --- /dev/null +++ b/lib/legion/cli/chat/permissions.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module Permissions + TIERS = if defined?(Tools::ReadFile) + { + Tools::ReadFile => :read, + Tools::SearchFiles => :read, + Tools::SearchContent => :read, + Tools::WriteFile => :write, + Tools::EditFile => :write, + Tools::RunCommand => :shell + }.freeze + else + {}.freeze + end + + @mode = :interactive + @extension_tiers = {} + + class << self + attr_accessor :mode + + def auto_allow? + %i[headless auto_approve].include?(mode) + end + + def read_only? + mode == :read_only + end + + def confirm?(description) + return false if read_only? + return true if auto_allow? + + $stderr.print "\e[33m#{description}\e[0m\n Allow? [y/n] " + response = $stdin.gets&.strip&.downcase + %w[y yes].include?(response) + end + + def register_extension_tier(tool_class, tier) + @extension_tiers ||= {} + @extension_tiers[tool_class] = tier + end + + def clear_extension_tiers! + @extension_tiers = {} + end + + def tier_for(tool_class) + TIERS[tool_class] || @extension_tiers&.[](tool_class) || :read + end + + def apply!(tool_classes) + tool_classes.each do |klass| + tier = tier_for(klass) + klass.singleton_class.prepend(Gate) unless tier == :read + end + end + end + + module Gate + def call(**args) + desc = permission_description(args) + return error_response('Tool execution denied by user.') unless Permissions.confirm?(desc) + + super + end + + private + + def permission_description(args) + tier = Permissions.tier_for(self) + case tier + when :write + path = args[:path] || '(unknown)' + action = name.split('::').last.gsub(/([a-z])([A-Z])/, '\1 \2') + "#{action}: #{path}" + when :shell + "Run command: #{args[:command]}" + else + name + end + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/progress_bar.rb b/lib/legion/cli/chat/progress_bar.rb new file mode 100644 index 00000000..6bb6717f --- /dev/null +++ b/lib/legion/cli/chat/progress_bar.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + class ProgressBar + attr_reader :total, :current + + def initialize(total:, label: '', width: 40, output: $stdout) + @total = [total, 1].max + @current = 0 + @label = label + @width = width + @output = output + @start_time = Time.now + end + + def advance(amount = 1) + @current = [@current + amount, @total].min + render + self + end + + def finish + @current = @total + render + @output.puts + self + end + + def percentage + (@current.to_f / @total * 100).round(1) + end + + def elapsed + Time.now - @start_time + end + + def eta + return 0 if @current.zero? || @current >= @total + + (elapsed / @current * (@total - @current)).round + end + + private + + def render + filled = (@width * @current.to_f / @total).round + bar = ('#' * filled) + ('-' * [(@width - filled), 0].max) + @output.print "\r#{@label} [#{bar}] #{percentage}% (#{@current}/#{@total}) ETA: #{eta}s " + end + end + end + end +end diff --git a/lib/legion/cli/chat/session.rb b/lib/legion/cli/chat/session.rb new file mode 100644 index 00000000..7d674f54 --- /dev/null +++ b/lib/legion/cli/chat/session.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + class Session + class BudgetExceeded < StandardError; end + + # Conservative per-token rates (USD) — roughly Sonnet-class pricing. + # Used as a safety cap, not a billing system. + INPUT_RATE = 0.003 / 1000.0 # $3 per million input tokens + OUTPUT_RATE = 0.015 / 1000.0 # $15 per million output tokens + + attr_reader :chat, :stats, :cache_hits_tokens + attr_accessor :budget_usd + + def initialize(chat:, system_prompt: nil, budget_usd: nil) + @chat = chat + @chat.with_instructions(system_prompt) if system_prompt + @budget_usd = budget_usd + @stats = { + messages_sent: 0, + messages_received: 0, + started_at: Time.now + } + @model_usage = Hash.new { |h, k| h[k] = { input_tokens: 0, output_tokens: 0, requests: 0 } } + @cache_hits_tokens = 0 + @callbacks = Hash.new { |h, k| h[k] = [] } + @turn = 0 + end + + def on(event, &block) + @callbacks[event] << block + end + + def emit(event, payload = {}) + @callbacks[event].each { |cb| cb.call(payload) } + end + + def send_message(message, on_tool_call: nil, on_tool_result: nil, &block) + check_budget! + check_for_absorbable_urls(message) + + @stats[:messages_sent] += 1 + @turn += 1 + current_turn = @turn + + @chat.on_tool_call { |tc| on_tool_call&.call(tc) } + @chat.on_tool_result { |tr| on_tool_result&.call(tr) } + + emit(:llm_start, { turn: current_turn }) + + first_token_emitted = false + wrapped_block = if block + proc do |chunk| + unless first_token_emitted + first_token_emitted = true + emit(:llm_first_token, { turn: current_turn }) + end + block.call(chunk) + end + end + + response = @chat.ask(message, &wrapped_block) + @stats[:messages_received] += 1 + + if response.respond_to?(:input_tokens) + in_tok = response.input_tokens || 0 + out_tok = response.output_tokens || 0 + @stats[:input_tokens] = (@stats[:input_tokens] || 0) + in_tok + @stats[:output_tokens] = (@stats[:output_tokens] || 0) + out_tok + + resp_model = response.respond_to?(:model_id) ? response.model_id : model_id + entry = @model_usage[resp_model.to_s] + entry[:input_tokens] += in_tok + entry[:output_tokens] += out_tok + entry[:requests] += 1 + + @cache_hits_tokens += response.cache_read_input_tokens.to_i if response.respond_to?(:cache_read_input_tokens) && response.cache_read_input_tokens + end + + emit(:llm_complete, { turn: current_turn, user_message: message }) + + response + end + + def estimated_cost + if cost_estimator_available? && @model_usage.any? + @model_usage.sum do |model, usage| + Legion::LLM::CostEstimator.estimate( + model_id: model, input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens] + ) + end + else + input = (@stats[:input_tokens] || 0) * INPUT_RATE + output = (@stats[:output_tokens] || 0) * OUTPUT_RATE + input + output + end + end + + def model_usage + @model_usage.transform_values(&:dup) + end + + def cost_breakdown + @model_usage.map do |model, usage| + cost = if cost_estimator_available? + Legion::LLM::CostEstimator.estimate( + model_id: model, input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens] + ) + else + (usage[:input_tokens] * INPUT_RATE) + (usage[:output_tokens] * OUTPUT_RATE) + end + { model: model, input_tokens: usage[:input_tokens], output_tokens: usage[:output_tokens], + requests: usage[:requests], cost: cost } + end + end + + def model_id + @chat.model&.id + rescue StandardError => e + Legion::Logging.debug("Session#model_id failed: #{e.message}") if defined?(Legion::Logging) + 'unknown' + end + + def elapsed + Time.now - @stats[:started_at] + end + + private + + def cost_estimator_available? + defined?(Legion::LLM::CostEstimator) + end + + def check_budget! + return unless @budget_usd + + cost = estimated_cost + return unless cost >= @budget_usd + + raise BudgetExceeded, + format('Budget exceeded: $%.4f spent of $%.2f limit', + cost: cost, limit: @budget_usd) + end + + def check_for_absorbable_urls(text) + return unless defined?(Legion::Extensions::Absorbers::Dispatch) + return unless defined?(Legion::Extensions::Absorbers::PatternMatcher) + + urls = Legion::Extensions::Absorbers::Dispatch.extract_urls(text.to_s) + return if urls.empty? + + urls.each do |url| + absorber = Legion::Extensions::Absorbers::PatternMatcher.resolve(url) + next unless absorber + + Legion::Extensions::Absorbers::Dispatch.dispatch(url, context: { conversation_id: object_id.to_s }) + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/session_recovery.rb b/lib/legion/cli/chat/session_recovery.rb new file mode 100644 index 00000000..7ff0baec --- /dev/null +++ b/lib/legion/cli/chat/session_recovery.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module SessionRecovery + STATES = %i[none interrupted_prompt interrupted_turn].freeze + + class << self + def classify(messages) + cleaned = filter_artifacts(messages) + return :none if cleaned.empty? + + last = cleaned.last + role = msg_role(last) + + case role + when 'user' then :interrupted_prompt + when 'tool_result', 'tool' then :interrupted_turn + when 'assistant' + tool_calls = last.is_a?(Hash) ? (last[:tool_calls] || last['tool_calls']) : nil + tool_calls.is_a?(Array) && tool_calls.any? ? :interrupted_turn : :none + else :none + end + end + + def recover(messages) + cleaned = filter_artifacts(messages) + state = classify(cleaned) + + case state + when :none + { state: :none, messages: cleaned, recovery_message: nil } + when :interrupted_prompt + msg = 'Continue from where you left off. The previous session was interrupted.' + { state: :interrupted_prompt, messages: cleaned, recovery_message: msg } + when :interrupted_turn + tool_name = detect_interrupted_tool(cleaned) + msg = 'Continue from where you left off. The previous session was interrupted' + msg += " during tool execution (#{tool_name})" if tool_name + msg += '.' + repaired = repair_orphaned_tool_use(cleaned) + { state: :interrupted_turn, messages: repaired, recovery_message: msg } + end + end + + private + + def filter_artifacts(messages) + messages.reject do |msg| + role = msg_role(msg) + content = msg_content(msg) + + next true if role == 'assistant' && thinking_only?(msg) + next true if role == 'assistant' && whitespace_only?(content) + + false + end + end + + def thinking_only?(msg) + content = msg_content(msg) + return false unless content.nil? || content.to_s.strip.empty? + + tool_calls = msg.is_a?(Hash) ? (msg[:tool_calls] || msg['tool_calls']) : nil + tool_calls.nil? || (tool_calls.is_a?(Array) && tool_calls.empty?) + end + + def whitespace_only?(content) + return true if content.nil? + + content.to_s.strip.empty? + end + + def msg_role(msg) + if msg.is_a?(Hash) + (msg[:role] || msg['role']).to_s + elsif msg.respond_to?(:role) + msg.role.to_s + else + '' + end + end + + def msg_content(msg) + if msg.is_a?(Hash) + msg[:content] || msg['content'] + elsif msg.respond_to?(:content) + msg.content + end + end + + def detect_interrupted_tool(messages) + reversed = messages.reverse + reversed.each do |msg| + role = msg_role(msg) + next unless role == 'assistant' + + tool_calls = msg.is_a?(Hash) ? (msg[:tool_calls] || msg['tool_calls']) : nil + next unless tool_calls.is_a?(Array) && tool_calls.any? + + first_tool = tool_calls.first + return first_tool[:name] || first_tool['name'] if first_tool.is_a?(Hash) + end + nil + end + + def repair_orphaned_tool_use(messages) + return messages if messages.empty? + + last = messages.last + role = msg_role(last) + + return messages[0...-1] if %w[tool_result tool].include?(role) + + if role == 'assistant' + tool_calls = last.is_a?(Hash) ? (last[:tool_calls] || last['tool_calls']) : nil + return messages[0...-1] if tool_calls.is_a?(Array) && tool_calls.any? + end + + messages + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/session_store.rb b/lib/legion/cli/chat/session_store.rb new file mode 100644 index 00000000..2f57f83e --- /dev/null +++ b/lib/legion/cli/chat/session_store.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module SessionStore + SESSIONS_DIR = File.expand_path('~/.legion/sessions') + + class << self + def save(session, name) + FileUtils.mkdir_p(SESSIONS_DIR) + + messages = session.chat.messages.map(&:to_h) + data = { + name: name, + model: session.model_id, + stats: session.stats, + saved_at: Time.now.iso8601, + cwd: Dir.pwd, + message_count: messages.size, + summary: generate_summary(messages), + model_usage: session.respond_to?(:model_usage) ? session.model_usage : {}, + cache_hits_tokens: session.respond_to?(:cache_hits_tokens) ? session.cache_hits_tokens : 0, + messages: messages + } + + path = session_path(name) + File.write(path, Legion::JSON.dump(data)) + path + end + + def load(name) + path = session_path(name) + raise CLI::Error, "Session not found: #{name}" unless File.exist?(path) + + Legion::JSON.load(File.read(path)) + end + + def restore(session, data) + require 'legion/cli/chat/session_recovery' + + recovery = Chat::SessionRecovery.recover(data[:messages] || []) + session.chat.reset_messages! + recovery[:messages].each do |msg| + session.chat.add_message(msg) + end + + data[:recovery_state] = recovery[:state] + data[:recovery_message] = recovery[:recovery_message] + data + end + + def list + return [] unless Dir.exist?(SESSIONS_DIR) + + sessions = Dir.glob(File.join(SESSIONS_DIR, '*.json')).map do |path| + name = File.basename(path, '.json') + stat = File.stat(path) + meta = read_session_meta(path) + { + name: name, + size: stat.size, + modified: stat.mtime, + message_count: meta[:message_count], + summary: meta[:summary], + model: meta[:model], + cwd: meta[:cwd] + } + end + sessions.sort_by { |s| s[:modified] }.reverse + end + + def latest + sessions = list + raise CLI::Error, 'No saved sessions found.' if sessions.empty? + + sessions.first[:name] + end + + def delete(name) + path = session_path(name) + raise CLI::Error, "Session not found: #{name}" unless File.exist?(path) + + File.delete(path) + end + + def session_path(name) + File.join(SESSIONS_DIR, "#{name}.json") + end + + private + + def generate_summary(messages) + user_messages = messages.select { |m| m[:role]&.to_s == 'user' } + return nil if user_messages.empty? + + first_msg = user_messages.first[:content].to_s.strip + first_msg = "#{first_msg[0..120]}..." if first_msg.length > 120 + first_msg + rescue StandardError => e + Legion::Logging.debug("SessionStore#generate_summary failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def read_session_meta(path) + raw = File.read(path, encoding: 'utf-8') + data = Legion::JSON.load(raw) + { + message_count: data[:message_count] || data[:messages]&.size, + summary: data[:summary], + model: data[:model], + cwd: data[:cwd] + } + rescue StandardError => e + Legion::Logging.debug("SessionStore#read_session_meta failed: #{e.message}") if defined?(Legion::Logging) + { message_count: nil, summary: nil, model: nil, cwd: nil } + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/status_indicator.rb b/lib/legion/cli/chat/status_indicator.rb new file mode 100644 index 00000000..289ef08a --- /dev/null +++ b/lib/legion/cli/chat/status_indicator.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'tty-spinner' + +module Legion + module CLI + class Chat + class StatusIndicator + SPINNER_FORMAT = :dots + PURPLE = "\e[38;2;127;119;221m" + RESET = "\e[0m" + + def initialize(session) + @session = session + @active_spinner = nil + subscribe_events + end + + private + + def subscribe_events + @session.on(:llm_start) { |_payload| start_spinner('thinking...') } + @session.on(:llm_first_token) { |_payload| stop_spinner } + @session.on(:llm_complete) { |_payload| stop_spinner } + @session.on(:tool_start) { |payload| handle_tool_start(payload) } + @session.on(:tool_complete) { |_payload| stop_spinner } + end + + def handle_tool_start(payload) + stop_spinner + label = if payload[:total] && payload[:total] > 1 + "[#{payload[:index]}/#{payload[:total]}] running #{payload[:name]}..." + else + "running #{payload[:name]}..." + end + start_spinner(label) + end + + def start_spinner(label) + stop_spinner + @active_spinner = ::TTY::Spinner.new( + "#{PURPLE}:spinner#{RESET} #{label}", + format: SPINNER_FORMAT, + hide_cursor: true, + output: $stderr + ) + @active_spinner.auto_spin + end + + def stop_spinner + return unless @active_spinner + + @active_spinner.stop + @active_spinner = nil + end + end + end + end +end diff --git a/lib/legion/cli/chat/subagent.rb b/lib/legion/cli/chat/subagent.rb new file mode 100644 index 00000000..d901f762 --- /dev/null +++ b/lib/legion/cli/chat/subagent.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'open3' +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module Subagent + MAX_CONCURRENCY = 3 + TIMEOUT = 300 # 5 minutes + + @running = [] + @mutex = Mutex.new + @max_concurrency = MAX_CONCURRENCY + @timeout = TIMEOUT + + class << self + attr_accessor :max_concurrency, :timeout + + def configure(max_concurrency: MAX_CONCURRENCY, timeout: TIMEOUT) + @max_concurrency = max_concurrency + @timeout = timeout + @running = [] + end + + def configure_from_settings + mc = begin + Legion::Settings.dig(:chat, :subagent, :max_concurrency) + rescue StandardError => e + Legion::Logging.warn("Subagent#configure_from_settings max_concurrency read failed: #{e.message}") if defined?(Legion::Logging) + nil + end + to = begin + Legion::Settings.dig(:chat, :subagent, :timeout) + rescue StandardError => e + Legion::Logging.warn("Subagent#configure_from_settings timeout read failed: #{e.message}") if defined?(Legion::Logging) + nil + end + @max_concurrency = mc || MAX_CONCURRENCY + @timeout = to || TIMEOUT + end + + def spawn(task:, model: nil, provider: nil, on_complete: nil) + return { error: "Max concurrency reached (#{@max_concurrency}). Wait for a subagent to finish." } if at_capacity? + + agent_id = "agent-#{Time.now.strftime('%H%M%S')}-#{rand(1000)}" + + thread = Thread.new do + result = run_headless(task: task, model: model, provider: provider) + @mutex.synchronize { @running.delete_if { |a| a[:id] == agent_id } } + on_complete&.call(agent_id, result) + rescue StandardError => e + Legion::Logging.error("Subagent#spawn thread error for #{agent_id}: #{e.message}") if defined?(Legion::Logging) + @mutex.synchronize { @running.delete_if { |a| a[:id] == agent_id } } + on_complete&.call(agent_id, { error: e.message }) + end + + entry = { id: agent_id, task: task, thread: thread, started_at: Time.now } + @mutex.synchronize { @running << entry } + + { id: agent_id, status: 'running', task: task } + end + + def running + @mutex.synchronize { @running.map { |a| { id: a[:id], task: a[:task], elapsed: Time.now - a[:started_at] } } } + end + + def running_count + @mutex.synchronize { @running.length } + end + + def at_capacity? + @mutex.synchronize { @running.length >= @max_concurrency } + end + + def wait_all(timeout: @timeout || TIMEOUT) + deadline = Time.now + timeout + @running.each do |agent| + remaining = deadline - Time.now + break if remaining <= 0 + + agent[:thread]&.join(remaining) + end + end + + private + + def run_headless(task:, model: nil, provider: nil) + cmd = ['legion', 'chat', 'prompt', task] + cmd += ['--model', model] if model + cmd += ['--provider', provider] if provider + cmd += ['--output-format', 'json'] + + stdout, stderr, status = Open3.capture3(*cmd, chdir: Dir.pwd) + + { + exit_code: status.exitstatus, + output: stdout.strip, + error: stderr.strip.empty? ? nil : stderr.strip + } + rescue StandardError => e + Legion::Logging.error("Subagent#run_headless failed: #{e.message}") if defined?(Legion::Logging) + { exit_code: 1, output: nil, error: e.message } + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/team.rb b/lib/legion/cli/chat/team.rb new file mode 100644 index 00000000..777affb0 --- /dev/null +++ b/lib/legion/cli/chat/team.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + module Team + class UserContext + attr_reader :user_id, :team_id, :display_name + + def initialize(user_id:, team_id: nil, display_name: nil) + @user_id = user_id + @team_id = team_id + @display_name = display_name || user_id + end + + def to_h + { user_id: user_id, team_id: team_id, display_name: display_name } + end + end + + class << self + def current_user + Thread.current[:legion_chat_user] + end + + def with_user(context) + previous = Thread.current[:legion_chat_user] + Thread.current[:legion_chat_user] = context + yield + ensure + Thread.current[:legion_chat_user] = previous + end + + def detect_user + user_id = ENV.fetch('LEGION_USER', ENV.fetch('USER', 'anonymous')) + team_id = ENV.fetch('LEGION_TEAM', nil) + UserContext.new(user_id: user_id, team_id: team_id) + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/team_memory.rb b/lib/legion/cli/chat/team_memory.rb new file mode 100644 index 00000000..7a0273a6 --- /dev/null +++ b/lib/legion/cli/chat/team_memory.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + module TeamMemory + class << self + def enabled? + settings = team_sync_settings + settings[:enabled] == true + end + + def sync_add(text) + return unless enabled? + return unless apollo_available? + + repo = git_remote_url + return unless repo + + Legion::Apollo.ingest( + content: text, + tags: ['team_memory', "repo:#{repo}"], + knowledge_domain: 'team_memory', + source_agent: "user:#{current_user}", + scope: :global, + is_inference: false + ) + rescue StandardError => e + Legion::Logging.debug "[TeamMemory] sync_add failed: #{e.message}" if defined?(Legion::Logging) + end + + def retrieve + return [] unless enabled? + return [] unless apollo_available? + + repo = git_remote_url + return [] unless repo + + limit = team_sync_settings[:limit] || 20 + results = Legion::Apollo.retrieve( + '', + tags: ['team_memory', "repo:#{repo}"], + scope: :global, + limit: limit + ) + + return [] unless results.is_a?(Array) + + results.map { |r| r.is_a?(Hash) ? (r[:content] || r['content']) : r.to_s } + .compact + .reject(&:empty?) + rescue StandardError => e + Legion::Logging.debug "[TeamMemory] retrieve failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def load_context + entries = retrieve + return nil if entries.empty? + + "## Team Memory\n\n#{entries.map { |e| "- #{e}" }.join("\n")}" + end + + private + + def team_sync_settings + raw = begin + Legion::Settings.dig(:memory, :team_sync) + rescue StandardError + nil + end + raw.is_a?(Hash) ? { enabled: false, limit: 20 }.merge(raw) : { enabled: false, limit: 20 } + end + + def apollo_available? + defined?(Legion::Apollo) && + Legion::Apollo.respond_to?(:ingest) && + Legion::Apollo.respond_to?(:retrieve) + end + + def git_remote_url + url = `git remote get-url origin 2>/dev/null`.strip + url.empty? ? nil : url + rescue StandardError + nil + end + + def current_user + ENV['USER'] || 'unknown' + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tool_registry.rb b/lib/legion/cli/chat/tool_registry.rb new file mode 100644 index 00000000..035d74db --- /dev/null +++ b/lib/legion/cli/chat/tool_registry.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +require 'legion/cli/chat/tools/read_file' +require 'legion/cli/chat/tools/write_file' +require 'legion/cli/chat/tools/edit_file' +require 'legion/cli/chat/tools/search_files' +require 'legion/cli/chat/tools/search_content' +require 'legion/cli/chat/tools/run_command' +require 'legion/cli/chat/tools/save_memory' +require 'legion/cli/chat/tools/search_memory' +require 'legion/cli/chat/tools/web_search' +require 'legion/cli/chat/tools/spawn_agent' +require 'legion/cli/chat/tools/search_traces' +require 'legion/cli/chat/tools/query_knowledge' +require 'legion/cli/chat/tools/ingest_knowledge' +require 'legion/cli/chat/tools/consolidate_memory' +require 'legion/cli/chat/tools/relate_knowledge' +require 'legion/cli/chat/tools/knowledge_maintenance' +require 'legion/cli/chat/tools/knowledge_stats' +require 'legion/cli/chat/tools/summarize_traces' +require 'legion/cli/chat/tools/list_extensions' +require 'legion/cli/chat/tools/manage_tasks' +require 'legion/cli/chat/tools/system_status' +require 'legion/cli/chat/tools/view_events' +require 'legion/cli/chat/tools/cost_summary' +require 'legion/cli/chat/tools/reflect' +require 'legion/cli/chat/tools/manage_schedules' +require 'legion/cli/chat/tools/worker_status' +require 'legion/cli/chat/tools/detect_anomalies' +require 'legion/cli/chat/tools/view_trends' +require 'legion/cli/chat/tools/trigger_dream' +require 'legion/cli/chat/tools/generate_insights' +require 'legion/cli/chat/tools/budget_status' +require 'legion/cli/chat/tools/provider_health' +require 'legion/cli/chat/tools/model_comparison' +require 'legion/cli/chat/tools/shadow_eval_status' +require 'legion/cli/chat/tools/entity_extract' +require 'legion/cli/chat/tools/arbitrage_status' +require 'legion/cli/chat/tools/escalation_status' +require 'legion/cli/chat/tools/graph_explore' +require 'legion/cli/chat/tools/scheduling_status' +require 'legion/cli/chat/tools/memory_status' + +require 'legion/cli/chat/permissions' + +module Legion + module CLI + class Chat + module ToolRegistry + BUILTIN_TOOLS = [ + Tools::ReadFile, + Tools::WriteFile, + Tools::EditFile, + Tools::SearchFiles, + Tools::SearchContent, + Tools::RunCommand, + Tools::SaveMemory, + Tools::SearchMemory, + Tools::WebSearch, + Tools::SpawnAgent, + Tools::SearchTraces, + Tools::QueryKnowledge, + Tools::IngestKnowledge, + Tools::ConsolidateMemory, + Tools::RelateKnowledge, + Tools::KnowledgeMaintenance, + Tools::KnowledgeStats, + Tools::SummarizeTraces, + Tools::ListExtensions, + Tools::ManageTasks, + Tools::SystemStatus, + Tools::ViewEvents, + Tools::CostSummary, + Tools::Reflect, + Tools::ManageSchedules, + Tools::WorkerStatus, + Tools::DetectAnomalies, + Tools::ViewTrends, + Tools::TriggerDream, + Tools::GenerateInsights, + Tools::BudgetStatus, + Tools::ProviderHealth, + Tools::ModelComparison, + Tools::ShadowEvalStatus, + Tools::EntityExtract, + Tools::ArbitrageStatus, + Tools::EscalationStatus, + Tools::GraphExplore, + Tools::SchedulingStatus, + Tools::MemoryStatus + ].freeze + + Permissions.apply!(BUILTIN_TOOLS) + + def self.builtin_tools + BUILTIN_TOOLS.dup + end + + def self.all_tools + require 'legion/cli/chat/extension_tool_loader' + builtin_tools + ExtensionToolLoader.discover + rescue LoadError => e + Legion::Logging.debug("ToolRegistry#all_tools ExtensionToolLoader not available: #{e.message}") if defined?(Legion::Logging) + builtin_tools + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/arbitrage_status.rb b/lib/legion/cli/chat/tools/arbitrage_status.rb new file mode 100644 index 00000000..70c33f5d --- /dev/null +++ b/lib/legion/cli/chat/tools/arbitrage_status.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + module Tools + class ArbitrageStatus < Legion::Tools::Base + tool_name 'legion.arbitrage_status' + description 'Show LLM cost arbitrage status: model pricing table, cheapest model per capability tier' + input_schema({ + type: 'object', + properties: { + capability: { type: 'string', description: 'Capability tier to check: basic, moderate, or reasoning (default: show all)' } + }, + required: [] + }) + + TIERS = %i[basic moderate reasoning].freeze + + def self.call(capability: nil) + return 'LLM arbitrage module not available.' unless arbitrage_available? + + if capability + format_tier(capability.to_sym) + else + format_overview + end + end + + def self.arbitrage_available? + defined?(Legion::LLM::Arbitrage) + end + + def self.format_overview + arb = Legion::LLM::Arbitrage + lines = ["LLM Cost Arbitrage\n"] + lines << format(' Enabled: %s', v: arb.enabled? ? 'YES' : 'no') + lines << '' + lines << ' Cost Table (per 1M tokens):' + lines << ' Model Input Output' + lines << " #{'—' * 58}" + + arb.cost_table.sort_by { |_, v| v[:input] }.each do |model, costs| + lines << format(' %-40s %7.2f %8.2f', + m: model, i: costs[:input], o: costs[:output]) + end + + if arb.enabled? + lines << '' + lines << ' Cheapest per tier:' + TIERS.each do |tier| + pick = arb.cheapest_for(capability: tier) + lines << format(' %-12s -> %s', tier: tier, pick: pick || 'none') + end + end + + lines.join("\n") + end + + def self.format_tier(tier) + arb = Legion::LLM::Arbitrage + return format('Invalid tier: %s. Use: %s', t: tier, valid: TIERS.join(', ')) unless TIERS.include?(tier) + + pick = arb.cheapest_for(capability: tier) + cost = pick ? arb.estimated_cost(model: pick) : nil + + lines = [format("Arbitrage for tier: %s\n", t: tier)] + if pick + lines << format(' Selected model: %s', m: pick) + lines << format(' Estimated cost: $%.6f (1K in + 500 out)', c: cost) if cost + else + lines << ' No eligible model found (arbitrage may be disabled)' + end + lines.join("\n") + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/budget_status.rb b/lib/legion/cli/chat/tools/budget_status.rb new file mode 100644 index 00000000..8cccc6ad --- /dev/null +++ b/lib/legion/cli/chat/tools/budget_status.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class BudgetStatus < Legion::Tools::Base + tool_name 'legion.budget_status' + description 'Check the current LLM session cost budget status. Shows how much has been spent, ' \ + 'remaining budget, and whether the budget guard is enforcing limits. Works locally ' \ + 'without needing the Legion daemon. Use this when the user asks about spending or budget.' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', description: 'Action: "status" (default), "summary" (cost breakdown by model)' } + }, + required: [] + }) + + def self.call(action: 'status') + return 'Legion::LLM not available.' unless llm_available? + + case action.to_s + when 'summary' then format_summary + else format_status + end + rescue StandardError => e + Legion::Logging.warn("BudgetStatus#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error checking budget: #{e.message}" + end + + def self.format_status + guard = budget_guard_status + tracker = cost_summary + lines = ["Session Budget Status:\n"] + lines << format(' Enforcing: %s', val: guard[:enforcing] ? 'YES' : 'no') + lines << format(' Budget: $%.4f', val: guard[:budget_usd]) if guard[:enforcing] + lines << format(' Spent: $%.6f', val: tracker[:total_cost_usd]) + lines << format(' Remaining: $%.4f', val: guard[:remaining_usd]) if guard[:remaining_usd] + lines << format(' Usage: %.1f%%', val: guard[:ratio] * 100) if guard[:enforcing] + lines << format(' Requests: %d', val: tracker[:total_requests]) + lines << format(' Tokens In: %d', val: tracker[:total_input_tokens]) + lines << format(' Tokens Out: %d', val: tracker[:total_output_tokens]) + lines.join("\n") + end + + def self.format_summary + tracker = cost_summary + return 'No LLM requests recorded this session.' if tracker[:total_requests].zero? + + lines = ["Session Cost Summary:\n"] + lines << format(' Total: $%.6f (%d requests)', + cost: tracker[:total_cost_usd], reqs: tracker[:total_requests]) + lines << format(' Tokens: %d in / %d out', + inp: tracker[:total_input_tokens], out: tracker[:total_output_tokens]) + + append_model_breakdown(lines, tracker[:by_model]) + lines.join("\n") + end + + def self.append_model_breakdown(lines, by_model) + return unless by_model&.any? + + lines << "\n By Model:" + by_model.each do |model, data| + lines << format(' %-30s $%.6f (%d requests)', + model: model, cost: data[:cost_usd], reqs: data[:requests]) + end + end + + def self.budget_guard_status + return { enforcing: false, budget_usd: 0.0, ratio: 0.0 } unless budget_guard_available? + + Legion::LLM::Hooks::BudgetGuard.status + end + + def self.cost_summary + return empty_summary unless cost_tracker_available? + + Legion::LLM::CostTracker.summary + end + + def self.budget_guard_available? + defined?(Legion::LLM::Hooks::BudgetGuard) + end + + def self.cost_tracker_available? + defined?(Legion::LLM::CostTracker) + end + + def self.llm_available? + defined?(Legion::LLM) + end + + def self.empty_summary + { total_cost_usd: 0.0, total_requests: 0, total_input_tokens: 0, total_output_tokens: 0, by_model: {} } + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/consolidate_memory.rb b/lib/legion/cli/chat/tools/consolidate_memory.rb new file mode 100644 index 00000000..eb83161d --- /dev/null +++ b/lib/legion/cli/chat/tools/consolidate_memory.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +begin + require 'legion/cli/chat_command' + require 'legion/cli/chat/memory_store' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class ConsolidateMemory < Legion::Tools::Base + tool_name 'legion.consolidate_memory' + description 'Consolidate and organize memory entries by removing duplicates, merging related items, ' \ + 'and creating cleaner summaries. Use this when memory has grown cluttered or has redundant entries. ' \ + 'Pass scope "project" or "global" to target the right memory file.' + input_schema({ + type: 'object', + properties: { + scope: { type: 'string', description: 'Memory scope: "project" or "global" (default: project)' }, + dry_run: { type: 'string', description: 'Set to "true" to preview without writing (default: false)' } + }, + required: [] + }) + + CONSOLIDATION_PROMPT = <<~PROMPT + You are a memory consolidation engine. Given a list of memory entries, produce a cleaned-up version that: + + 1. Removes exact or near-duplicate entries (keep the most complete version) + 2. Merges entries about the same topic into a single clear statement + 3. Preserves all unique and valuable information + 4. Keeps entries concise — one line per memory + 5. Drops entries that are purely temporary or session-specific + 6. Preserves the most recent timestamp when merging + + Return ONLY the consolidated entries, one per line, each prefixed with "- ". + Do NOT add headers, explanations, or commentary. + PROMPT + + def self.call(scope: 'project', dry_run: nil) + dry_run = dry_run.to_s == 'true' + scope_sym = scope.to_s == 'global' ? :global : :project + + entries = MemoryStore.list(scope: scope_sym) + return "No memory entries found in #{scope} scope." if entries.empty? + return "Only #{entries.size} entries — no consolidation needed." if entries.size < 3 + + consolidated = consolidate_entries(entries) + return 'Consolidation failed: could not generate summary.' unless consolidated + + new_entries = parse_consolidated(consolidated) + removed = entries.size - new_entries.size + + if dry_run + preview = new_entries.map.with_index(1) { |e, i| "#{i}. #{e}" }.join("\n") + "Preview (#{entries.size} -> #{new_entries.size}, #{removed} removed):\n\n#{preview}" + else + write_consolidated(new_entries, scope_sym) + "Consolidated #{scope} memory: #{entries.size} -> #{new_entries.size} entries (#{removed} removed/merged)" + end + rescue StandardError => e + Legion::Logging.warn("ConsolidateMemory#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error consolidating memory: #{e.message}" + end + + def self.consolidate_entries(entries) + return nil unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat) + + numbered = entries.map.with_index(1) { |e, i| "#{i}. #{e}" }.join("\n") + + response = Legion::LLM.chat( + message: "#{CONSOLIDATION_PROMPT}\n\nCurrent entries:\n#{numbered}", + caller: { requested_by: { type: :system, identity: 'legion:internal:cli:consolidate_memory' } } + ) + extract_response_content(response) + end + + def self.extract_response_content(response) + if response.is_a?(Hash) + (response[:response] || response[:content] || response['response'] || response['content']).to_s + elsif response.respond_to?(:content) + response.content.to_s + else + response.to_s + end + end + + def self.parse_consolidated(text) + text.lines + .map(&:strip) + .select { |line| line.start_with?('- ') } + .map { |line| line.sub(/\A- /, '').strip } + .reject(&:empty?) + end + + def self.write_consolidated(entries, scope_sym) + path = scope_sym == :global ? MemoryStore.global_path : MemoryStore.project_path + header = scope_sym == :global ? "# Global Memory\n" : "# Project Memory\n" + timestamp = Time.now.strftime('%Y-%m-%d %H:%M') + + content = header + content += "\n_Consolidated on #{timestamp}_\n" + entries.each { |entry| content += "\n- #{entry}\n" } + + MemoryStore.send(:ensure_dir, path) + File.write(path, content, encoding: 'utf-8') + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/cost_summary.rb b/lib/legion/cli/chat/tools/cost_summary.rb new file mode 100644 index 00000000..4612f1ed --- /dev/null +++ b/lib/legion/cli/chat/tools/cost_summary.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class CostSummary < Legion::Tools::Base + tool_name 'legion.cost_summary' + description 'Get cost and token usage summary from the running Legion daemon. Shows spending ' \ + 'for today, this week, and this month, plus top cost consumers by worker. ' \ + 'Use this to monitor LLM spending and identify expensive operations.' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', description: 'Action: "summary" (default), "top" (top consumers), or "worker" (specific worker)' }, + worker_id: { type: 'string', description: 'Worker ID (required for "worker" action)' }, + limit: { type: 'integer', description: 'Number of top consumers to show (default: 5)' } + }, + required: [] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call(action: 'summary', worker_id: nil, limit: 5) + case action.to_s + when 'top' + handle_top(limit.to_i.clamp(1, 20)) + when 'worker' + return 'worker_id is required for the "worker" action.' if worker_id.nil? || worker_id.strip.empty? + + handle_worker(worker_id.strip) + else + handle_summary + end + rescue Errno::ECONNREFUSED + 'Legion daemon not running (cannot reach cost API).' + rescue StandardError => e + Legion::Logging.warn("CostSummary#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error fetching cost data: #{e.message}" + end + + def self.handle_summary + data = api_get('/api/costs/summary?period=month') + return "API error: #{data[:error]}" if data[:error] + + data = data[:data] || data + lines = ["Cost Summary:\n"] + lines << format(' Today: $%.4f', (data[:today] || 0).to_f) + lines << format(' This Week: $%.4f', (data[:week] || 0).to_f) + lines << format(' This Month: $%.4f', (data[:month] || 0).to_f) + lines << " Workers: #{data[:workers] || 0}" + lines.join("\n") + end + + def self.handle_top(limit) + data = api_get('/api/workers') + return "API error: #{data[:error]}" if data[:error] + + workers = data[:data] || data + workers = Array(workers).first(limit) + return 'No workers found.' if workers.empty? + + lines = ["Top #{workers.size} Cost Consumers:\n"] + workers.each_with_index do |w, i| + id = w[:worker_id] || w[:id] || 'unknown' + cost = fetch_worker_cost(id) + lines << format(' %d. %-20s $%.4f', rank: i + 1, id: id, cost: cost) + end + lines.join("\n") + end + + def self.handle_worker(worker_id) + data = api_get("/api/workers/#{worker_id}/value") + return "API error: #{data[:error]}" if data[:error] + + data = data[:data] || data + return "No cost data for worker #{worker_id}." if data.nil? || data.empty? + + lines = ["Worker: #{worker_id}\n"] + data.each do |key, val| + lines << " #{key}: #{val}" unless key == :worker_id + end + lines.join("\n") + end + + def self.fetch_worker_cost(worker_id) + data = api_get("/api/workers/#{worker_id}/value") + data = data[:data] || data + (data[:total_cost_usd] || 0).to_f + rescue StandardError + 0.0 + end + + def self.api_get(path) + uri = URI("http://#{DEFAULT_HOST}:#{api_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 5 + response = http.get(uri.request_uri) + ::JSON.parse(response.body, symbolize_names: true) + end + + def self.api_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/detect_anomalies.rb b/lib/legion/cli/chat/tools/detect_anomalies.rb new file mode 100644 index 00000000..2bbd354e --- /dev/null +++ b/lib/legion/cli/chat/tools/detect_anomalies.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class DetectAnomalies < Legion::Tools::Base + tool_name 'legion.detect_anomalies' + description 'Detect anomalies in recent task execution metrics by comparing the last hour against ' \ + 'the previous 23-hour baseline. Reports cost spikes, latency increases, and failure rate ' \ + 'changes. Use this proactively to check system health or when investigating issues.' + input_schema({ + type: 'object', + properties: { + threshold: { type: 'number', description: 'Anomaly detection threshold multiplier (default: 2.0, higher = less sensitive)' } + }, + required: [] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call(threshold: 2.0) + data = api_get("/api/traces/anomalies?threshold=#{threshold.to_f}") + return "API error: #{data[:error][:message]}" if data[:error] + + format_report(data[:data] || data) + rescue Errno::ECONNREFUSED + 'Legion daemon not running (cannot reach anomaly detection API).' + rescue StandardError => e + Legion::Logging.warn("DetectAnomalies#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error detecting anomalies: #{e.message}" + end + + def self.format_report(data) + anomalies = data[:anomalies] || [] + lines = ["Anomaly Report (threshold: #{data[:threshold] || '2.0'}x)\n"] + lines << " Recent period: #{data[:recent_period] || 'last 1 hour'} (#{data[:recent_count] || 0} records)" + lines << " Baseline period: #{data[:baseline_period] || 'previous 23 hours'} (#{data[:baseline_count] || 0} records)" + lines << '' + + if anomalies.empty? + lines << 'No anomalies detected. All metrics within normal range.' + else + lines << "#{anomalies.size} anomal#{anomalies.size == 1 ? 'y' : 'ies'} detected:\n" + anomalies.each_with_index do |a, i| + severity = (a[:severity] || 'warning').upcase + lines << " #{i + 1}. [#{severity}] #{a[:metric]}" + lines << " Recent: #{a[:recent]} | Baseline: #{a[:baseline]} | Ratio: #{a[:ratio]}x" + end + end + + lines.join("\n") + end + + def self.api_get(path) + uri = URI("http://#{DEFAULT_HOST}:#{api_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 5 + response = http.get(uri.request_uri) + ::JSON.parse(response.body, symbolize_names: true) + end + + def self.api_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/edit_file.rb b/lib/legion/cli/chat/tools/edit_file.rb new file mode 100644 index 00000000..e3b09b4e --- /dev/null +++ b/lib/legion/cli/chat/tools/edit_file.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module Tools + class EditFile < Legion::Tools::Base + tool_name 'legion.edit_file' + description 'Edit a file using either string replacement (old_text → new_text) or ' \ + 'line-number replacement (start_line/end_line → new_text). ' \ + 'String mode requires an exact unique match. ' \ + 'Line mode replaces lines start_line..end_line (1-based, inclusive); ' \ + 'omit end_line to replace a single line.' + input_schema({ + type: 'object', + properties: { + path: { type: 'string', description: 'Path to the file to edit' }, + new_text: { type: 'string', description: 'The replacement text' }, + old_text: { type: 'string', description: 'The exact text to find and replace (string mode)' }, + start_line: { type: 'integer', description: 'First line to replace, 1-based (line mode)' }, + end_line: { type: 'integer', description: 'Last line to replace, 1-based inclusive (line mode; defaults to start_line)' } + }, + required: %w[path new_text] + }) + + def self.call(path:, new_text:, old_text: nil, start_line: nil, end_line: nil) + expanded = File.expand_path(path) + return "Error: file not found: #{path}" unless File.exist?(expanded) + + require 'legion/cli/chat/checkpoint' + + if start_line + line_replace(expanded, new_text, start_line, end_line || start_line) + else + return 'Error: old_text is required when not using line-number mode' if old_text.nil? + + string_replace(expanded, old_text, new_text) + end + rescue StandardError => e + Legion::Logging.warn("EditFile#execute failed for #{path}: #{e.message}") if defined?(Legion::Logging) + "Error editing #{path}: #{e.message}" + end + + def self.string_replace(expanded, old_text, new_text) + content = File.read(expanded, encoding: 'utf-8') + occurrences = content.scan(old_text).length + + return "Error: old_text not found in #{expanded}" if occurrences.zero? + return "Error: old_text matches #{occurrences} locations — must be unique (provide more context)" if occurrences > 1 + + Checkpoint.save(expanded) + File.write(expanded, content.sub(old_text, new_text), encoding: 'utf-8') + "Replaced 1 occurrence in #{expanded}" + end + + def self.line_replace(expanded, new_text, start_line, end_line) + lines = File.readlines(expanded, encoding: 'utf-8') + total = lines.length + + return "Error: start_line #{start_line} out of bounds (file has #{total} lines)" if start_line < 1 || start_line > total + return "Error: end_line #{end_line} out of bounds (file has #{total} lines)" if end_line < 1 || end_line > total + return "Error: end_line #{end_line} is before start_line #{start_line}" if end_line < start_line + + Checkpoint.save(expanded) + replacement_lines = new_text.end_with?("\n") ? [new_text] : ["#{new_text}\n"] + lines[(start_line - 1)..(end_line - 1)] = replacement_lines + File.write(expanded, lines.join, encoding: 'utf-8') + "Replaced lines #{start_line}–#{end_line} in #{expanded}" + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/entity_extract.rb b/lib/legion/cli/chat/tools/entity_extract.rb new file mode 100644 index 00000000..68e3b253 --- /dev/null +++ b/lib/legion/cli/chat/tools/entity_extract.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + module Tools + class EntityExtract < Legion::Tools::Base + tool_name 'legion.entity_extract' + description 'Extract named entities (people, services, repos, concepts) from text using Apollo' + input_schema({ + type: 'object', + properties: { + text: { type: 'string', description: 'Text to extract entities from' }, + entity_types: { type: 'string', + description: 'Comma-separated entity types to extract (default: person,service,repository,concept)' }, + min_confidence: { type: 'number', description: 'Minimum confidence threshold 0.0-1.0 (default: 0.7)' } + }, + required: ['text'] + }) + + def self.call(text:, entity_types: nil, min_confidence: 0.7) + return 'Apollo entity extractor not available.' unless extractor_available? + + types = parse_types(entity_types) + result = run_extraction(text, types, min_confidence.to_f) + format_result(result) + end + + def self.extractor_available? + defined?(Legion::Extensions::Apollo::Runners::EntityExtractor) + end + + def self.parse_types(types_str) + return nil if types_str.nil? || types_str.strip.empty? + + types_str.split(',').map(&:strip) + end + + def self.run_extraction(text, types, min_confidence) + extractor = Object.new.extend(Legion::Extensions::Apollo::Runners::EntityExtractor) + extractor.extract_entities( + text: text, + entity_types: types, + min_confidence: min_confidence + ) + end + + def self.format_result(result) + return format('Entity extraction failed: %s', err: result[:error] || 'unknown error') unless result[:success] + + entities = result[:entities] + return 'No entities found in the provided text.' if entities.empty? + + lines = [format("Extracted %d entities:\n", n: entities.size)] + + grouped = entities.group_by { |e| e[:type] } + grouped.each do |type, items| + lines << format(' [%s]', type: type) + items.sort_by { |e| -(e[:confidence] || 0) }.each do |entity| + lines << format(' %s (confidence: %.0f%%)', + name: entity[:name], conf: (entity[:confidence] || 0) * 100) + end + end + + lines.join("\n") + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/escalation_status.rb b/lib/legion/cli/chat/tools/escalation_status.rb new file mode 100644 index 00000000..301845cc --- /dev/null +++ b/lib/legion/cli/chat/tools/escalation_status.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + module Tools + class EscalationStatus < Legion::Tools::Base + tool_name 'legion.escalation_status' + description 'Show model escalation history: how often cheaper models get upgraded to more capable ones' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', description: 'Action: "summary" (default) or "rate" (escalation frequency)' } + }, + required: [] + }) + + def self.call(action: 'summary') + return 'Escalation tracker not available.' unless tracker_available? + + case action.to_s + when 'rate' then format_rate + else format_summary + end + end + + def self.tracker_available? + defined?(Legion::LLM::EscalationTracker) + end + + def self.format_summary + s = Legion::LLM::EscalationTracker.summary + lines = ["Model Escalation Summary:\n"] + lines << format(' Total Escalations: %d', v: s[:total_escalations]) + + if s[:total_escalations].zero? + lines << ' No escalations recorded.' + return lines.join("\n") + end + + unless s[:by_reason].empty? + lines << '' + lines << ' By Reason:' + s[:by_reason].sort_by { |_, c| -c }.each do |reason, count| + lines << format(' %-20s %d', r: reason, c: count) + end + end + + unless s[:by_target_model].empty? + lines << '' + lines << ' Escalated To:' + s[:by_target_model].sort_by { |_, c| -c }.each do |model, count| + lines << format(' %-25s %d', m: model, c: count) + end + end + + unless s[:recent].empty? + lines << '' + lines << ' Recent:' + s[:recent].first(5).each do |entry| + lines << format(' %s -> %s (%s)', + from: entry[:from_model], to: entry[:to_model], reason: entry[:reason]) + end + end + + lines.join("\n") + end + + def self.format_rate + rate = Legion::LLM::EscalationTracker.escalation_rate + format('Escalation Rate: %d escalations in the last %d minutes', + c: rate[:count], m: rate[:window_seconds] / 60) + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/generate_insights.rb b/lib/legion/cli/chat/tools/generate_insights.rb new file mode 100644 index 00000000..8c83a368 --- /dev/null +++ b/lib/legion/cli/chat/tools/generate_insights.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class GenerateInsights < Legion::Tools::Base + tool_name 'legion.generate_insights' + description 'Generate a comprehensive system insights report by combining anomaly detection, trend analysis, ' \ + 'worker health, and knowledge stats into a single actionable summary. Use this for periodic ' \ + 'health reviews or when you want a high-level overview of system behavior.' + input_schema({ type: 'object', properties: {}, required: [] }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call + sections = gather_sections + return 'Legion daemon not running (cannot reach API).' if sections.values.all?(&:nil?) + + format_insights(sections) + rescue Errno::ECONNREFUSED + 'Legion daemon not running (cannot reach API).' + rescue StandardError => e + Legion::Logging.warn("GenerateInsights#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error generating insights: #{e.message}" + end + + def self.gather_sections + { + health: safe_fetch('/api/health'), + anomalies: safe_fetch('/api/traces/anomalies'), + trend: safe_fetch('/api/traces/trend?hours=24&buckets=6'), + apollo: safe_fetch('/api/apollo/stats'), + graph: safe_fetch('/api/apollo/graph'), + workers: safe_fetch('/api/workers'), + scheduling: scheduling_status, + llm: llm_status + } + end + + def self.safe_fetch(path) + api_get(path) + rescue StandardError + nil + end + + def self.format_insights(sections) + lines = ["System Insights Report\n"] + lines << format_health(sections[:health]) + lines << format_anomaly_section(sections[:anomalies]) + lines << format_trend_section(sections[:trend]) + lines << format_apollo_section(sections[:apollo]) + lines << format_graph_section(sections[:graph]) + lines << format_worker_section(sections[:workers]) + lines << format_scheduling_section(sections[:scheduling]) + lines << format_llm_section(sections[:llm]) + lines << recommendations(sections) + lines.compact.join("\n\n") + end + + def self.format_health(data) + return nil unless data + + d = data[:data] || data + "Health: #{d[:status] || 'unknown'} | Version: #{d[:version] || '?'}" + end + + def self.format_anomaly_section(data) + return nil unless data + + d = data[:data] || data + anomalies = d[:anomalies] || [] + if anomalies.empty? + 'Anomalies: None detected (system nominal)' + else + items = anomalies.map { |a| " - [#{(a[:severity] || 'warning').upcase}] #{a[:metric]} (#{a[:ratio]}x)" } + "Anomalies (#{anomalies.size}):\n#{items.join("\n")}" + end + end + + def self.format_trend_section(data) + return nil unless data + + d = data[:data] || data + buckets = d[:buckets] || [] + return nil if buckets.empty? + + first = buckets.first + last = buckets.last + vol_change = percent_change(first[:count], last[:count]) + cost_change = percent_change(first[:avg_cost], last[:avg_cost]) + + "Trend (24h): Volume #{vol_change} | Cost #{cost_change}" + end + + def self.format_apollo_section(data) + return nil unless data + + d = data[:data] || data + return nil if d[:error] + + "Knowledge: #{d[:total_entries] || 0} entries | 24h: #{d[:recent_24h] || 0} | " \ + "Confidence: #{d[:avg_confidence] || 0}" + end + + def self.format_worker_section(data) + return nil unless data + + workers = data[:data] || [] + workers = Array(workers) + return nil if workers.empty? + + active = workers.count { |w| w[:lifecycle_state] == 'active' } + "Workers: #{active}/#{workers.size} active" + end + + def self.format_graph_section(data) + return nil unless data + + d = data[:data] || data + return nil if d[:error] + + disputed = d[:disputed_entries] || 0 + domains = (d[:domains] || {}).size + relations = d[:total_relations] || 0 + + "Graph: #{domains} domains | #{relations} relations | #{disputed} disputed" + end + + def self.format_scheduling_section(data) + return nil unless data + + peak = data[:peak_hours] ? 'PEAK' : 'off-peak' + batch_size = data.dig(:batch, :queue_size) || 0 + + "Scheduling: #{peak} | Batch queue: #{batch_size}" + end + + def self.format_llm_section(data) + return nil unless data + + parts = [] + parts << "Escalations: #{data[:escalations]}" if data[:escalations] + parts << "Shadow evals: #{data[:shadow_evals]}" if data[:shadow_evals] + return nil if parts.empty? + + "LLM: #{parts.join(' | ')}" + end + + def self.scheduling_status + result = {} + if defined?(Legion::LLM::Scheduling) + s = Legion::LLM::Scheduling.status + result.merge!(s) + end + result[:batch] = Legion::LLM::Batch.status if defined?(Legion::LLM::Batch) + result.empty? ? nil : result + rescue StandardError => e + Legion::Logging.debug("GenerateInsights#scheduling_status failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def self.llm_status + result = {} + if defined?(Legion::LLM::EscalationTracker) + s = Legion::LLM::EscalationTracker.summary + result[:escalations] = s[:total_escalations] + end + if defined?(Legion::LLM::ShadowEval) + s = Legion::LLM::ShadowEval.summary + result[:shadow_evals] = s[:total_evaluations] + end + result.empty? ? nil : result + rescue StandardError => e + Legion::Logging.debug("GenerateInsights#llm_status failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def self.recommendations(sections) + recs = [] + add_anomaly_recs(recs, sections[:anomalies]) + add_trend_recs(recs, sections[:trend]) + return nil if recs.empty? + + "Recommendations:\n#{recs.map { |r| " * #{r}" }.join("\n")}" + end + + def self.add_anomaly_recs(recs, data) + return unless data + + anomalies = (data[:data] || data)[:anomalies] || [] + anomalies.each do |a| + case a[:metric] + when /cost/i + recs << 'Review recent high-cost operations — consider model downgrade for non-critical tasks' + when /latency/i + recs << 'Investigate latency spike — check provider health or fleet worker load' + when /failure/i + recs << 'Elevated failure rate — check extension health and transport connectivity' + end + end + end + + def self.add_trend_recs(recs, data) + return unless data + + buckets = (data[:data] || data)[:buckets] || [] + return if buckets.size < 2 + + last = buckets.last + recs << 'Failure rate above 10% in most recent period — investigate immediately' if last[:failure_rate].to_f > 0.1 + return unless last[:count].to_i.zero? && buckets.size > 2 + + recs << 'No recent activity detected — verify daemon extensions are running' + end + + def self.percent_change(first_val, last_val) + f = (first_val || 0).to_f + l = (last_val || 0).to_f + return 'stable' if f.zero? && l.zero? + return 'rising' if f.zero? + + pct = ((l - f) / f * 100).round(0) + if pct > 10 + "rising (+#{pct}%)" + elsif pct < -10 + "falling (#{pct}%)" + else + "stable (#{pct}%)" + end + end + + def self.api_get(path) + uri = URI("http://#{DEFAULT_HOST}:#{api_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 5 + response = http.get(uri.request_uri) + ::JSON.parse(response.body, symbolize_names: true) + end + + def self.api_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/graph_explore.rb b/lib/legion/cli/chat/tools/graph_explore.rb new file mode 100644 index 00000000..0f38962b --- /dev/null +++ b/lib/legion/cli/chat/tools/graph_explore.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class GraphExplore < Legion::Tools::Base + tool_name 'legion.graph_explore' + description 'Explore the Apollo knowledge graph topology: view domains, agent expertise, ' \ + 'relation types, and disputed entries. Use this to understand the structure ' \ + 'and health of the knowledge graph beyond basic stats.' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', description: 'Action: "topology" (domain/agent/relation overview), ' } + }, + required: [] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call(action: 'topology') + case action.to_s + when 'expertise' then format_expertise + when 'disputed' then format_disputed + else format_topology + end + rescue Errno::ECONNREFUSED + 'Apollo unavailable (daemon not running).' + rescue StandardError => e + Legion::Logging.warn("GraphExplore#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error exploring knowledge graph: #{e.message}" + end + + def self.format_topology + data = fetch_json('/api/apollo/graph') + return "Apollo error: #{data[:error]}" if data[:error] + + lines = ["Apollo Knowledge Graph Topology:\n"] + + lines << ' Domains:' + (data[:domains] || {}).sort_by { |_, c| -c }.each do |domain, count| + lines << format(' %-25s %d entries', d: domain, c: count) + end + + lines << '' + lines << ' Contributing Agents:' + (data[:agents] || {}).sort_by { |_, c| -c }.first(10).each do |agent, count| + lines << format(' %-25s %d entries', a: agent, c: count) + end + + lines << '' + lines << ' Relation Types:' + (data[:relation_types] || {}).sort_by { |_, c| -c }.each do |rtype, count| + lines << format(' %-20s %d', r: rtype, c: count) + end + + lines << '' + lines << format(' Total Relations: %d', v: data[:total_relations] || 0) + lines << format(' Confirmed: %d Candidates: %d Disputed: %d', + v: data[:confirmed] || 0, v2: data[:candidates] || 0, v3: data[:disputed_entries] || 0) + + lines.join("\n") + end + + def self.format_expertise + data = fetch_json('/api/apollo/expertise') + return "Apollo error: #{data[:error]}" if data[:error] + + lines = ["Apollo Agent Expertise Map:\n"] + lines << format(' Agents: %d Domains: %d', a: data[:total_agents] || 0, d: data[:total_domains] || 0) + + (data[:domains] || {}).each do |domain, agents| + lines << '' + lines << " #{domain}:" + Array(agents).each do |agent| + bar = proficiency_bar(agent[:proficiency] || 0.0) + lines << format(' %-20s %s %

5.1f%% (%d entries)', + a: agent[:agent_id], bar: bar, p: (agent[:proficiency] || 0.0) * 100, + c: agent[:entry_count] || 0) + end + end + + lines.join("\n") + end + + def self.format_disputed + data = fetch_json('/api/apollo/query', method: :post, + body: { status: ['disputed'], limit: 20, query: '*', + min_confidence: 0.0 }) + return "Apollo error: #{data[:error]}" if data[:error] + + entries = data[:entries] || [] + return 'No disputed entries in the knowledge graph.' if entries.empty? + + lines = ["Disputed Knowledge Entries (#{entries.size}):\n"] + entries.each_with_index do |entry, idx| + conf = entry[:confidence] ? format(' (conf: %.2f)', entry[:confidence]) : '' + tags = entry[:tags]&.any? ? " [#{Array(entry[:tags]).join(', ')}]" : '' + lines << " #{idx + 1}. ##{entry[:id]}#{conf}#{tags}" + lines << " #{truncate(entry[:content], 120)}" + lines << " source: #{entry[:source_agent] || 'unknown'}" + end + + lines.join("\n") + end + + def self.fetch_json(path, method: :get, body: nil) + uri = URI("http://#{DEFAULT_HOST}:#{apollo_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 10 + + response = if method == :post + req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + req.body = ::JSON.dump(body) if body + http.request(req) + else + http.get(uri.request_uri) + end + + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] || parsed + end + + def self.apollo_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + + def self.proficiency_bar(value) + filled = (value * 10).round.clamp(0, 10) + ('#' * filled) + ('-' * (10 - filled)) + end + + def self.truncate(text, max) + return '' if text.nil? + + text.length > max ? "#{text[0...max]}..." : text + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/ingest_knowledge.rb b/lib/legion/cli/chat/tools/ingest_knowledge.rb new file mode 100644 index 00000000..bc9ab52d --- /dev/null +++ b/lib/legion/cli/chat/tools/ingest_knowledge.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class IngestKnowledge < Legion::Tools::Base + tool_name 'legion.ingest_knowledge' + description 'Save a fact, observation, or concept to the Apollo knowledge graph for long-term retention. ' \ + 'Use this when the user shares important information, when you discover a project convention, ' \ + 'or when a key decision is made that should be remembered across sessions.' + input_schema({ + type: 'object', + properties: { + content: { type: 'string', description: 'The knowledge to store (a clear, concise statement)' }, + content_type: { type: 'string', description: 'Type: fact, observation, concept, procedure, decision (default: observation)' }, + tags: { type: 'string', description: 'Comma-separated tags for categorization (optional)' }, + knowledge_domain: { type: 'string', description: 'Domain category (optional)' } + }, + required: ['content'] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + VALID_TYPES = %w[fact observation concept procedure decision].freeze + + def self.call(content:, content_type: nil, tags: nil, knowledge_domain: nil) + content_type = sanitize_type(content_type) + tag_list = parse_tags(tags) + + data = apollo_ingest( + content: content, + content_type: content_type, + tags: tag_list, + knowledge_domain: knowledge_domain + ) + + return "Failed to ingest: #{data[:error]}" if data[:error] + + id = data[:id] || data[:entry_id] + "Saved to Apollo knowledge graph (id: #{id}, type: #{content_type}, tags: #{tag_list.join(', ')})" + rescue Errno::ECONNREFUSED + 'Apollo unavailable (daemon not running). Knowledge was not saved.' + rescue StandardError => e + Legion::Logging.warn("IngestKnowledge#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error saving to knowledge graph: #{e.message}" + end + + def self.sanitize_type(content_type) + type = (content_type || 'observation').to_s.downcase + VALID_TYPES.include?(type) ? type : 'observation' + end + + def self.parse_tags(tags) + return [] unless tags.is_a?(String) && !tags.empty? + + tags.split(',').map(&:strip).reject(&:empty?) + end + + def self.apollo_ingest(content:, content_type:, tags:, knowledge_domain:) + body = { + content: content, + content_type: content_type, + tags: tags, + source_agent: 'chat', + source_channel: 'chat_tool' + } + body[:knowledge_domain] = knowledge_domain if knowledge_domain + + uri = URI("http://#{DEFAULT_HOST}:#{apollo_port}/api/apollo/ingest") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 10 + req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + req.body = ::JSON.dump(body) + response = http.request(req) + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] || parsed + end + + def self.apollo_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/knowledge_maintenance.rb b/lib/legion/cli/chat/tools/knowledge_maintenance.rb new file mode 100644 index 00000000..f620534b --- /dev/null +++ b/lib/legion/cli/chat/tools/knowledge_maintenance.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class KnowledgeMaintenance < Legion::Tools::Base + tool_name 'legion.knowledge_maintenance' + description 'Run maintenance operations on the Apollo knowledge graph. ' \ + 'Use decay_cycle to reduce confidence of old or uncorroborated entries over time. ' \ + 'Use corroboration to cross-verify entries and boost confidence of mutually supporting facts.' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', + description: 'Maintenance action: "decay_cycle" (age-based confidence decay) or "corroboration" (cross-verify entries)' } + }, + required: ['action'] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + VALID_ACTIONS = %w[decay_cycle corroboration].freeze + + def self.call(action:) + action = action.to_s.strip + return "Invalid action: #{action}. Must be one of: #{VALID_ACTIONS.join(', ')}" unless VALID_ACTIONS.include?(action) + + data = run_maintenance(action) + return "Apollo error: #{data[:error]}" if data[:error] + + format_result(action, data) + rescue Errno::ECONNREFUSED + 'Apollo unavailable (daemon not running).' + rescue StandardError => e + Legion::Logging.warn("KnowledgeMaintenance#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error running maintenance: #{e.message}" + end + + def self.run_maintenance(action) + uri = URI("http://#{DEFAULT_HOST}:#{apollo_port}/api/apollo/maintenance") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 30 + req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + req.body = ::JSON.dump({ action: action }) + response = http.request(req) + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] || parsed + end + + def self.apollo_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + + def self.format_result(action, data) + case action + when 'decay_cycle' + format_decay_result(data) + when 'corroboration' + format_corroboration_result(data) + else + "Maintenance completed: #{data.inspect}" + end + end + + def self.format_decay_result(data) + decayed = data[:decayed_count] || data[:decayed] || 0 + removed = data[:removed_count] || data[:removed] || 0 + header = "Decay cycle complete:\n" + header += " Entries decayed: #{decayed}\n" + header += " Entries removed (below threshold): #{removed}\n" + header += " Duration: #{data[:duration_ms]}ms" if data[:duration_ms] + header + end + + def self.format_corroboration_result(data) + checked = data[:checked_count] || data[:checked] || 0 + boosted = data[:boosted_count] || data[:boosted] || 0 + header = "Corroboration check complete:\n" + header += " Entries checked: #{checked}\n" + header += " Entries boosted (mutually supporting): #{boosted}\n" + header += " Duration: #{data[:duration_ms]}ms" if data[:duration_ms] + header + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/knowledge_stats.rb b/lib/legion/cli/chat/tools/knowledge_stats.rb new file mode 100644 index 00000000..a1ec1ed5 --- /dev/null +++ b/lib/legion/cli/chat/tools/knowledge_stats.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class KnowledgeStats < Legion::Tools::Base + tool_name 'legion.knowledge_stats' + description 'Get statistics about the Apollo knowledge graph including total entries, ' \ + 'breakdowns by status and content type, recent activity, and average confidence. ' \ + 'Use this to understand the current state of the knowledge base.' + input_schema({ type: 'object', properties: {}, required: [] }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call + data = fetch_stats + return "Apollo error: #{data[:error]}" if data[:error] + + format_stats(data) + rescue Errno::ECONNREFUSED + 'Apollo unavailable (daemon not running).' + rescue StandardError => e + Legion::Logging.warn("KnowledgeStats#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error fetching knowledge stats: #{e.message}" + end + + def self.fetch_stats + uri = URI("http://#{DEFAULT_HOST}:#{apollo_port}/api/apollo/stats") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 10 + response = http.get(uri.request_uri) + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] || parsed + end + + def self.apollo_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + + def self.format_stats(data) + lines = ["Apollo Knowledge Graph Statistics:\n"] + lines << " Total entries: #{data[:total_entries] || 0}" + lines << " Recent (24h): #{data[:recent_24h] || 0}" + lines << " Avg confidence: #{data[:avg_confidence] || 0.0}" + + lines << format_breakdown('By Status', data[:by_status]) + lines << format_breakdown('By Content Type', data[:by_content_type]) + + lines.compact.join("\n") + end + + def self.format_breakdown(title, hash) + return nil if hash.nil? || hash.empty? + + parts = hash.map { |key, count| " #{key}: #{count}" } + "\n #{title}:\n#{parts.join("\n")}" + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/list_extensions.rb b/lib/legion/cli/chat/tools/list_extensions.rb new file mode 100644 index 00000000..66b05332 --- /dev/null +++ b/lib/legion/cli/chat/tools/list_extensions.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class ListExtensions < Legion::Tools::Base + tool_name 'legion.list_extensions' + description 'List loaded Legion extensions and their runners/functions. ' \ + 'Use this to discover what capabilities are available, what extensions are active, ' \ + 'and what tasks can be triggered through the framework.' + input_schema({ + type: 'object', + properties: { + extension_name: { type: 'string', description: 'Show runners for a specific extension by name (e.g. lex-node)' }, + state: { type: 'string', description: 'Filter by state (e.g. "running"). Default: all' } + }, + required: [] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call(extension_name: nil, state: nil) + if extension_name + fetch_extension_detail(extension_name) + else + fetch_extension_list(state) + end + rescue Errno::ECONNREFUSED + 'Legion daemon not running (cannot query extensions API).' + rescue StandardError => e + Legion::Logging.warn("ListExtensions#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error listing extensions: #{e.message}" + end + + def self.fetch_extension_list(state) + path = '/api/extension_catalog' + path += "?state=#{state}" if state + data = api_get(path) + return "API error: #{data[:error]}" if data[:error] + + extensions = data[:data] || data[:items] || data + extensions = [extensions] if extensions.is_a?(Hash) + return 'No extensions found.' if extensions.empty? + + format_list(extensions) + end + + def self.fetch_extension_detail(name) + ext_data = api_get("/api/extension_catalog/#{name}") + return "API error: #{ext_data[:error]}" if ext_data[:error] + + runners_data = api_get("/api/extension_catalog/#{name}/runners") + runners = runners_data[:data] || runners_data[:items] || runners_data + runners = [runners] if runners.is_a?(Hash) + runners = [] unless runners.is_a?(Array) + + format_detail(ext_data[:data] || ext_data, runners) + end + + def self.format_list(extensions) + lines = ["Loaded Extensions (#{extensions.size}):\n"] + extensions.each do |ext| + lines << " #{ext[:name]} (#{ext[:state]})" + end + lines.join("\n") + end + + def self.format_detail(ext, runners) + lines = ["Extension: #{ext[:name]}\n"] + lines << " State: #{ext[:state]}" + lines << " Version: #{ext[:version]}" if ext[:version] + + if runners.any? + lines << "\n Runners (#{runners.size}):" + runners.each do |r| + lines << " #{r[:name]} (#{r[:runner_class]})" + end + else + lines << "\n No runners registered." + end + + lines.join("\n") + end + + def self.api_get(path) + uri = URI("http://#{DEFAULT_HOST}:#{api_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 10 + response = http.get(uri.request_uri) + ::JSON.parse(response.body, symbolize_names: true) + end + + def self.api_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/manage_schedules.rb b/lib/legion/cli/chat/tools/manage_schedules.rb new file mode 100644 index 00000000..7e7040f8 --- /dev/null +++ b/lib/legion/cli/chat/tools/manage_schedules.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class ManageSchedules < Legion::Tools::Base + tool_name 'legion.manage_schedules' + description 'Manage scheduled tasks on the running Legion daemon. List active schedules, ' \ + 'show schedule details, view run logs, or create new cron/interval schedules. ' \ + 'Use this to automate recurring tasks.' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', description: 'Action: "list", "show", "logs", or "create"' }, + schedule_id: { type: 'string', description: 'Schedule ID (for show/logs)' }, + function_id: { type: 'string', description: 'Function ID to schedule (for create)' }, + cron: { type: 'string', description: 'Cron expression (for create, e.g. "0 * * * *")' } + }, + required: ['action'] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + VALID_ACTIONS = %w[list show logs create].freeze + + def self.call(action:, **) + action = action.to_s.strip + return "Invalid action: #{action}. Use: #{VALID_ACTIONS.join(', ')}" unless VALID_ACTIONS.include?(action) + + send(:"handle_#{action}", **) + rescue Errno::ECONNREFUSED + 'Legion daemon not running (cannot reach schedules API).' + rescue StandardError => e + Legion::Logging.warn("ManageSchedules#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error managing schedules: #{e.message}" + end + + def self.handle_list(**) + data = api_get('/api/schedules') + entries = extract_collection(data) + return 'No schedules found.' if entries.empty? + + lines = ["Schedules (#{entries.size}):\n"] + entries.each do |s| + schedule = s[:cron] || "every #{s[:interval]}s" + status = s[:active] ? 'active' : 'inactive' + lines << " ##{s[:id]} [#{status}] #{schedule} -> function #{s[:function_id] || '?'}" + lines << " #{s[:description]}" if s[:description] + end + lines.join("\n") + end + + def self.handle_show(schedule_id: nil, **) + return 'schedule_id is required for the "show" action.' unless schedule_id + + data = api_get("/api/schedules/#{schedule_id}") + s = data[:data] || data + return "Schedule ##{schedule_id} not found." if s[:error] + + lines = ["Schedule ##{schedule_id}:\n"] + s.each { |key, val| lines << " #{key}: #{val}" unless val.nil? } + lines.join("\n") + end + + def self.handle_logs(schedule_id: nil, **) + return 'schedule_id is required for the "logs" action.' unless schedule_id + + data = api_get("/api/schedules/#{schedule_id}/logs") + entries = extract_collection(data) + return "No logs for schedule ##{schedule_id}." if entries.empty? + + lines = ["Logs for Schedule ##{schedule_id} (#{entries.size}):\n"] + entries.first(10).each do |log| + lines << " [#{log[:started_at]}] #{log[:status] || '?'}: #{log[:message] || '-'}" + end + lines.join("\n") + end + + def self.handle_create(function_id: nil, cron: nil, **) + return 'function_id is required for the "create" action.' unless function_id + return 'cron expression is required for the "create" action.' unless cron + + data = api_post('/api/schedules', { function_id: function_id.to_i, cron: cron }) + s = data[:data] || data + return "Failed to create schedule: #{s[:error]}" if s[:error] + + "Schedule created (id: #{s[:id]}, cron: #{cron}, function: #{function_id})" + end + + def self.extract_collection(data) + entries = data[:data] || data + entries = [entries] if entries.is_a?(Hash) && !entries.key?(:error) + Array(entries).reject { |e| e.is_a?(Hash) && e.key?(:error) } + end + + def self.api_get(path) + uri = URI("http://#{DEFAULT_HOST}:#{api_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 5 + response = http.get(uri.request_uri) + ::JSON.parse(response.body, symbolize_names: true) + end + + def self.api_post(path, body) + uri = URI("http://#{DEFAULT_HOST}:#{api_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 5 + req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + req.body = ::JSON.dump(body) + response = http.request(req) + ::JSON.parse(response.body, symbolize_names: true) + end + + def self.api_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/manage_tasks.rb b/lib/legion/cli/chat/tools/manage_tasks.rb new file mode 100644 index 00000000..f66bb382 --- /dev/null +++ b/lib/legion/cli/chat/tools/manage_tasks.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class ManageTasks < Legion::Tools::Base + tool_name 'legion.manage_tasks' + description 'Interact with the Legion task system. List recent tasks, show task details ' \ + 'with metering data, view task logs, or trigger new tasks through the Ingress pipeline. ' \ + 'Use this to monitor job execution, check task status, and invoke extension runners.' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', description: 'Action to perform: "list", "show", "logs", or "trigger"' }, + task_id: { type: 'integer', description: 'Task ID (required for "show" and "logs")' }, + runner_class: { type: 'string', + description: 'Full runner class name for "trigger" (e.g. "Legion::Extensions::Node::Runners::Info")' }, + function: { type: 'string', description: 'Function name for "trigger" (e.g. "execute")' }, + payload: { type: 'string', description: 'JSON payload for "trigger" action (optional)' }, + status: { type: 'string', description: 'Filter tasks by status for "list" (e.g. "completed", "failed", "pending")' }, + limit: { type: 'integer', description: 'Max results for "list" (default: 10)' } + }, + required: ['action'] + }) + + VALID_ACTIONS = %w[list show logs trigger].freeze + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call(action:, **) + action = action.to_s.strip + return "Invalid action: #{action}. Use: #{VALID_ACTIONS.join(', ')}" unless VALID_ACTIONS.include?(action) + + send(:"handle_#{action}", **) + rescue Errno::ECONNREFUSED + 'Legion daemon not running (cannot reach task API).' + rescue StandardError => e + Legion::Logging.warn("ManageTasks#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error managing tasks: #{e.message}" + end + + def self.handle_list(status: nil, limit: nil, **) + path = '/api/tasks' + params = [] + params << "status=#{status}" if status + params << "per_page=#{limit || 10}" + path += "?#{params.join('&')}" unless params.empty? + + data = api_get(path) + return "API error: #{data[:error]}" if data[:error] + + tasks = data[:data] || data[:items] || data + tasks = [tasks] if tasks.is_a?(Hash) + return 'No tasks found.' if !tasks.is_a?(Array) || tasks.empty? + + format_task_list(tasks) + end + + def self.handle_show(task_id: nil, **) + return 'task_id is required for "show"' unless task_id + + data = api_get("/api/tasks/#{task_id}") + return "API error: #{data[:error]}" if data[:error] + + task = data[:data] || data + format_task_detail(task) + end + + def self.handle_logs(task_id: nil, **) + return 'task_id is required for "logs"' unless task_id + + data = api_get("/api/tasks/#{task_id}/logs") + return "API error: #{data[:error]}" if data[:error] + + logs = data[:data] || data[:items] || data + logs = [logs] if logs.is_a?(Hash) + return "No logs found for task #{task_id}." if !logs.is_a?(Array) || logs.empty? + + format_task_logs(task_id, logs) + end + + def self.handle_trigger(runner_class: nil, function: nil, payload: nil, **) + return 'runner_class is required for "trigger"' unless runner_class + return 'function is required for "trigger"' unless function + + body = { runner_class: runner_class, function: function } + body.merge!(::JSON.parse(payload, symbolize_names: true)) if payload + + data = api_post('/api/tasks', body) + return "API error: #{data[:error]}" if data[:error] + + result = data[:data] || data + "Task triggered successfully.\n Task ID: #{result[:task_id]}\n Runner: #{runner_class}\n Function: #{function}" + end + + def self.format_task_list(tasks) + lines = ["Recent Tasks (#{tasks.size}):\n"] + tasks.each do |t| + status_str = t[:status] || 'unknown' + lines << " ##{t[:id]} [#{status_str}] #{t[:runner_class]}##{t[:function]} (#{t[:created_at]})" + end + lines.join("\n") + end + + def self.format_task_detail(task) + lines = ["Task ##{task[:id]}\n"] + lines << " Status: #{task[:status]}" + lines << " Runner: #{task[:runner_class]}" + lines << " Function: #{task[:function]}" if task[:function] + lines << " Created: #{task[:created_at]}" + lines << " Updated: #{task[:updated_at]}" if task[:updated_at] + + if task[:metering] + m = task[:metering] + lines << "\n Metering:" + lines << " Total tokens: #{m[:total_tokens]}" + lines << " Input/Output: #{m[:input_tokens]}/#{m[:output_tokens]}" + lines << " Calls: #{m[:total_calls]}" + lines << " Avg latency: #{m[:avg_latency_ms]}ms" + lines << " Provider: #{Array(m[:provider]).join(', ')}" if m[:provider] + lines << " Model: #{Array(m[:model]).join(', ')}" if m[:model] + end + + lines.join("\n") + end + + def self.format_task_logs(task_id, logs) + lines = ["Logs for Task ##{task_id} (#{logs.size} entries):\n"] + logs.each do |log| + ts = log[:created_at] || log[:timestamp] + lines << " [#{ts}] #{log[:level] || 'info'}: #{log[:message]}" + end + lines.join("\n") + end + + def self.api_get(path) + uri = URI("http://#{DEFAULT_HOST}:#{api_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 10 + response = http.get(uri.request_uri) + ::JSON.parse(response.body, symbolize_names: true) + end + + def self.api_post(path, body) + uri = URI("http://#{DEFAULT_HOST}:#{api_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 15 + request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json') + request.body = ::JSON.generate(body) + response = http.request(request) + ::JSON.parse(response.body, symbolize_names: true) + end + + def self.api_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/memory_status.rb b/lib/legion/cli/chat/tools/memory_status.rb new file mode 100644 index 00000000..6030884f --- /dev/null +++ b/lib/legion/cli/chat/tools/memory_status.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + module Tools + class MemoryStatus < Legion::Tools::Base + tool_name 'legion.memory_status' + description 'Show persistent memory status: project and global memory entries, ' \ + 'Apollo knowledge store stats, and session history overview' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', description: 'Action: "overview" (default), "memories" (local memory detail), ' } + }, + required: [] + }) + + def self.call(action: 'overview') + case action.to_s + when 'memories' then format_memories + when 'apollo' then format_apollo + when 'sessions' then format_sessions + else format_overview + end + end + + def self.format_overview + lines = ["Memory & Knowledge Overview:\n"] + + mem = memory_stats + lines << format(' Local Memory: %

d project, %d global entries', p: mem[:project], g: mem[:global]) + + apollo = apollo_stats + lines << if apollo + format(' Apollo Store: %d entries (%d confirmed, %d disputed)', + t: apollo[:total] || 0, c: apollo[:confirmed] || 0, d: apollo[:disputed] || 0) + else + ' Apollo Store: not available' + end + + sessions = session_list + lines << format(' Saved Sessions: %d', c: sessions.size) + + lines.join("\n") + end + + def self.format_memories + require 'legion/cli/chat/memory_store' + lines = ["Persistent Memory Detail:\n"] + + project = Chat::MemoryStore.list(scope: :project) + lines << ' Project Memory:' + if project.empty? + lines << ' (no entries)' + else + project.each_with_index do |entry, i| + lines << format(' %d. %s', i: i + 1, e: truncate(entry, 100)) + end + end + + lines << '' + global = Chat::MemoryStore.list(scope: :global) + lines << ' Global Memory:' + if global.empty? + lines << ' (no entries)' + else + global.each_with_index do |entry, i| + lines << format(' %d. %s', i: i + 1, e: truncate(entry, 100)) + end + end + + lines.join("\n") + end + + def self.format_apollo + stats = apollo_stats + return 'Apollo knowledge store is not available.' unless stats + + lines = ["Apollo Knowledge Store:\n"] + lines << format(' Total Entries: %d', v: stats[:total] || 0) + lines << format(' Confirmed: %d', v: stats[:confirmed] || 0) + lines << format(' Candidates: %d', v: stats[:candidates] || 0) + lines << format(' Disputed: %d', v: stats[:disputed] || 0) + lines << format(' Recent (24h): %d', v: stats[:recent_24h] || 0) + lines << format(' Avg Confidence: %.2f', v: stats[:avg_confidence] || 0.0) + + if stats[:domains] + lines << '' + lines << ' Domains:' + stats[:domains].each do |domain, count| + lines << format(' %-20s %d entries', d: domain, c: count) + end + end + + lines.join("\n") + end + + def self.format_sessions + require 'legion/cli/chat/session_store' + sessions = Chat::SessionStore.list + return 'No saved sessions found.' if sessions.empty? + + lines = [format("Saved Sessions (%d):\n", c: sessions.size)] + sessions.first(10).each do |s| + age = time_ago(s[:modified]) + lines << format(' %-20s %3d msgs %s %s', + n: s[:name], m: s[:message_count] || 0, a: age, s: s[:model] || '') + lines << format(' %s', v: truncate(s[:summary].to_s, 80)) if s[:summary] + end + lines << format(' ... and %d more', n: sessions.size - 10) if sessions.size > 10 + + lines.join("\n") + end + + def self.memory_stats + require 'legion/cli/chat/memory_store' + { + project: Chat::MemoryStore.list(scope: :project).size, + global: Chat::MemoryStore.list(scope: :global).size + } + rescue StandardError + { project: 0, global: 0 } + end + + def self.apollo_stats + return nil unless apollo_available? + + data = safe_fetch('/api/apollo/stats') + return nil unless data + + data[:data] || data + rescue StandardError + nil + end + + def self.session_list + require 'legion/cli/chat/session_store' + Chat::SessionStore.list + rescue StandardError + [] + end + + def self.apollo_available? + defined?(Legion::Data) + end + + def self.safe_fetch(path) + require 'net/http' + uri = URI("http://127.0.0.1:#{api_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 5 + response = http.request(Net::HTTP::Get.new(uri)) + return nil unless response.is_a?(Net::HTTPSuccess) + + Legion::JSON.load(response.body) + rescue StandardError + nil + end + + def self.api_port + (defined?(Legion::Settings) && Legion::Settings[:api] && Legion::Settings[:api][:port]) || 4567 + end + + def self.truncate(str, max) + str.length > max ? "#{str[0, max]}..." : str + end + + def self.time_ago(time) + return '?' unless time + + seconds = Time.now - time + if seconds < 3600 + format('%dm ago', m: (seconds / 60).to_i) + elsif seconds < 86_400 + format('%dh ago', h: (seconds / 3600).to_i) + else + format('%dd ago', d: (seconds / 86_400).to_i) + end + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/model_comparison.rb b/lib/legion/cli/chat/tools/model_comparison.rb new file mode 100644 index 00000000..00ed2aee --- /dev/null +++ b/lib/legion/cli/chat/tools/model_comparison.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + module Tools + class ModelComparison < Legion::Tools::Base + tool_name 'legion.model_comparison' + description 'Compare LLM model pricing and capabilities side-by-side' + input_schema({ + type: 'object', + properties: { + models: { type: 'string', description: 'Comma-separated model names to compare (blank = show all known models)' }, + tokens: { type: 'integer', description: 'Hypothetical token count for cost projection (default: 1000)' } + }, + required: [] + }) + + def self.call(models: nil, tokens: 1000) + pricing = load_pricing + selected = filter_models(pricing, models) + return 'No matching models found.' if selected.empty? + + format_comparison(selected, tokens.to_i) + end + + def self.load_pricing + base = cost_tracker_pricing + return base unless base.empty? + + default_pricing + end + + def self.cost_tracker_pricing + return {} unless defined?(Legion::LLM::CostTracker) + + Legion::LLM::CostTracker::DEFAULT_PRICING.transform_values do |v| + { input: v[:input], output: v[:output] } + end + rescue StandardError => e + Legion::Logging.debug("ModelComparison#cost_tracker_pricing failed: #{e.message}") if defined?(Legion::Logging) + {} + end + + def self.default_pricing + { + 'claude-sonnet-4-6' => { input: 3.0, output: 15.0 }, + 'claude-haiku-4-5' => { input: 0.80, output: 4.0 }, + 'claude-opus-4-6' => { input: 15.0, output: 75.0 }, + 'gpt-4o' => { input: 2.50, output: 10.0 }, + 'gpt-4o-mini' => { input: 0.15, output: 0.60 } + } + end + + def self.filter_models(pricing, models_str) + return pricing if models_str.nil? || models_str.strip.empty? + + names = models_str.split(',').map(&:strip).map(&:downcase) + pricing.select { |k, _| names.any? { |n| k.downcase.include?(n) } } + end + + def self.format_comparison(selected, tokens) + lines = ["Model Comparison (per 1M tokens pricing):\n"] + lines << ' Model Input/$ Output/$ Est. Cost' + lines << " #{'—' * 59}" + + sorted = selected.sort_by { |_, v| v[:input] } + sorted.each do |name, price| + est = estimate_cost(price, tokens) + lines << format(' %-25s %9.2f %10.2f $%.6f', + name: name, inp: price[:input], out: price[:output], est: est) + end + + lines << '' + lines << format(' Estimate based on %d input + %d output tokens.', t: tokens) + + if sorted.size > 1 + cheapest = sorted.first + priciest = sorted.last + ratio = priciest[1][:input] / cheapest[1][:input] + lines << format(' %s is %.1fx more expensive than %s (input rate).', + exp: priciest[0], r: ratio, chp: cheapest[0]) + end + + lines.join("\n") + end + + def self.estimate_cost(price, tokens) + ((tokens * price[:input] / 1_000_000.0) + (tokens * price[:output] / 1_000_000.0)).round(6) + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/provider_health.rb b/lib/legion/cli/chat/tools/provider_health.rb new file mode 100644 index 00000000..eb3c999a --- /dev/null +++ b/lib/legion/cli/chat/tools/provider_health.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class ProviderHealth < Legion::Tools::Base + tool_name 'legion.provider_health' + description 'Check the health status of configured LLM providers. Shows circuit breaker state, ' \ + 'routing adjustments, and overall availability. Use this when the user asks about ' \ + 'provider status, LLM health, or routing problems.' + input_schema({ + type: 'object', + properties: { + provider: { type: 'string', description: 'Specific provider to check (optional)' } + }, + required: [] + }) + + def self.call(provider: nil) + return 'LLM provider inventory not available.' unless provider_stats_available? + + if provider + format_detail(provider.strip) + else + format_report + end + rescue StandardError => e + Legion::Logging.warn("ProviderHealth#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error checking provider health: #{e.message}" + end + + def self.format_report + report = provider_health_report + return "Router not available: #{report[:error]}" if report.is_a?(Hash) && report[:error] + return 'No providers configured.' if report.empty? + + summary = provider_circuit_summary(report) + lines = ["Provider Health Report:\n"] + lines << format_circuit_summary(summary) if summary.is_a?(Hash) && !summary[:error] + lines << '' + report.each { |entry| lines << format_entry(entry) } + lines.join("\n") + end + + def self.format_detail(provider) + entry = provider_detail(provider) + return "Router not available: #{entry[:error]}" if entry[:error] + return "Provider not found: #{provider}" if entry.empty? + + lines = ["Provider: #{entry[:provider]}\n"] + lines << " Circuit: #{entry[:circuit]}" + lines << " Healthy: #{entry[:healthy] ? 'YES' : 'NO'}" + lines << " Adjustment: #{entry[:adjustment]}" + lines.join("\n") + end + + def self.format_circuit_summary(summary) + format(' Circuits: %d closed, %d open, %d half-open (of %d)', + closed: summary[:closed], open: summary[:open], + half: summary[:half_open], total: summary[:total]) + end + + def self.format_entry(entry) + icon = entry[:healthy] ? '+' : '!' + suffix = +'' + suffix << " offerings=#{entry[:offerings]}" if entry.key?(:offerings) + suffix << " models=#{entry[:models].length}" if entry[:models].respond_to?(:length) + format(' [%s] %-15s circuit=%s adj=%d%s', + icon: icon, name: entry[:provider], + circuit: entry[:circuit], adj: entry[:adjustment], suffix: suffix) + end + + def self.provider_stats_available? + native_provider_stats_available? + end + + def self.native_provider_stats_available? + defined?(Legion::LLM::Inventory) && Legion::LLM::Inventory.respond_to?(:providers) + end + + def self.provider_health_report + native_provider_health_report + end + + def self.native_provider_health_report + groups = Legion::LLM::Inventory.providers + return [] unless groups.respond_to?(:map) + + groups.map do |provider, offerings| + provider_offerings = Array(offerings) + health = provider_offerings.map { |offering| offering_value(offering, :health) } + .find { |entry| entry.is_a?(Hash) } || {} + circuit = health[:circuit_state] || health['circuit_state'] || 'unknown' + { + provider: provider.to_s, + circuit: circuit, + adjustment: health[:adjustment] || health['adjustment'] || 0, + healthy: circuit.to_s != 'open', + offerings: provider_offerings.size, + models: provider_offerings.map { |offering| offering_value(offering, :model) }.compact.uniq, + types: provider_offerings.map { |offering| offering_value(offering, :type) }.compact.uniq, + instances: provider_offerings.map do |offering| + offering_value(offering, :provider_instance) || offering_value(offering, :instance_id) + end.compact.uniq + } + end + end + + def self.provider_circuit_summary(report) + circuits = report.map { |entry| entry[:circuit].to_s } + { + total: report.size, + closed: circuits.count('closed'), + open: circuits.count('open'), + half_open: circuits.count('half_open') + } + end + + def self.provider_detail(provider) + provider_name = provider.to_s + provider_health_report.find { |entry| entry[:provider] == provider_name } || {} + end + + def self.offering_value(offering, key) + return unless offering.respond_to?(:[]) + + offering[key] || offering[key.to_s] + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/query_knowledge.rb b/lib/legion/cli/chat/tools/query_knowledge.rb new file mode 100644 index 00000000..a0c856a1 --- /dev/null +++ b/lib/legion/cli/chat/tools/query_knowledge.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class QueryKnowledge < Legion::Tools::Base + tool_name 'legion.query_knowledge' + description 'Query the Apollo knowledge graph for facts, observations, concepts, and procedures. ' \ + 'Use this when the user asks about known facts, project knowledge, system behavior, ' \ + 'or anything that may have been ingested into the knowledge base.' + input_schema({ + type: 'object', + properties: { + query: { type: 'string', description: 'Natural language search query' }, + domain: { type: 'string', description: 'Filter by knowledge domain (optional)' }, + limit: { type: 'integer', description: 'Max results (default: 10)' } + }, + required: ['query'] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call(query:, domain: nil, limit: nil) + limit = (limit || 10).clamp(1, 50) + data = apollo_query(query: query, domain: domain, limit: limit) + + return "Apollo knowledge graph error: #{data[:error]}" if data[:error] + + entries = data[:entries] || [] + return 'No knowledge entries found matching that query.' if entries.empty? + + format_entries(entries) + rescue StandardError => e + Legion::Logging.warn("QueryKnowledge#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error querying knowledge graph: #{e.message}" + end + + def self.apollo_query(query:, domain:, limit:) + body = { query: query, limit: limit, status: %w[confirmed candidate] } + body[:domain] = domain if domain + + uri = URI("http://#{DEFAULT_HOST}:#{apollo_port}/api/apollo/query") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 10 + req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + req.body = ::JSON.dump(body) + response = http.request(req) + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] || parsed + end + + def self.apollo_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + + def self.format_entries(entries) + parts = entries.map.with_index(1) do |entry, idx| + confidence = entry[:confidence] ? " (confidence: #{entry[:confidence]})" : '' + tags = entry[:tags]&.any? ? " [#{entry[:tags].join(', ')}]" : '' + "#{idx}. [#{entry[:content_type] || 'unknown'}]#{confidence} #{entry[:content]}#{tags}" + end + + "Found #{entries.size} knowledge entries:\n\n#{parts.join("\n")}" + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/read_file.rb b/lib/legion/cli/chat/tools/read_file.rb new file mode 100644 index 00000000..9300426b --- /dev/null +++ b/lib/legion/cli/chat/tools/read_file.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module Tools + class ReadFile < Legion::Tools::Base + tool_name 'legion.read_file' + description 'Read the contents of a file. Returns the file content with line numbers.' + input_schema({ + type: 'object', + properties: { + path: { type: 'string', description: 'Absolute or relative path to the file' }, + offset: { type: 'integer', description: 'Line number to start reading from (1-based)' }, + limit: { type: 'integer', description: 'Maximum number of lines to read' } + }, + required: ['path'] + }) + + def self.call(path:, offset: nil, limit: nil) + expanded = File.expand_path(path) + return "Error: file not found: #{path}" unless File.exist?(expanded) + return "Error: path is a directory: #{path}" if File.directory?(expanded) + + lines = File.readlines(expanded, encoding: 'utf-8') + start_line = [(offset || 1) - 1, 0].max + count = limit || lines.length + selected = lines[start_line, count] || [] + + numbered = selected.each_with_index.map do |line, i| + "#{(start_line + i + 1).to_s.rjust(5)} | #{line}" + end + + "#{expanded} (#{lines.length} lines total)\n#{numbered.join}" + rescue StandardError => e + Legion::Logging.warn("ReadFile#execute failed for #{path}: #{e.message}") if defined?(Legion::Logging) + "Error reading #{path}: #{e.message}" + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/reflect.rb b/lib/legion/cli/chat/tools/reflect.rb new file mode 100644 index 00000000..6373766a --- /dev/null +++ b/lib/legion/cli/chat/tools/reflect.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class Reflect < Legion::Tools::Base + tool_name 'legion.reflect' + description 'Reflect on the current conversation to extract useful knowledge, patterns, or decisions ' \ + 'worth remembering. Analyzes the provided text and ingests key learnings into the Apollo ' \ + 'knowledge graph and project memory. Use after completing a task or when you notice ' \ + 'something worth preserving for future sessions.' + input_schema({ + type: 'object', + properties: { + text: { type: 'string', description: 'Text to reflect on (conversation excerpt, decision rationale, or lesson learned)' }, + domain: { type: 'string', description: 'Knowledge domain (e.g., "architecture", "debugging", "patterns")' } + }, + required: ['text'] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + EXTRACTION_PROMPT = <<~PROMPT + Extract discrete, reusable knowledge entries from the following text. + Each entry should be a standalone fact, pattern, decision, or procedure + that would be useful in future conversations. + + Rules: + - One entry per line, prefixed with "- " + - Be specific and actionable, not vague + - Include context (file paths, module names, patterns) + - Skip trivial observations + - Maximum 5 entries + + Return ONLY the entries, no headers or commentary. + PROMPT + + def self.call(text:, domain: nil) + entries = extract_entries(text) + return 'No actionable knowledge found to reflect on.' if entries.empty? + + results = ingest_entries(entries, domain) + format_results(entries, results) + rescue StandardError => e + Legion::Logging.warn("Reflect#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error during reflection: #{e.message}" + end + + def self.extract_entries(text) + return [text] unless llm_available? + + response = Legion::LLM.chat( + message: "#{EXTRACTION_PROMPT}\n\nText:\n#{text}", + caller: { requested_by: { type: :system, identity: 'legion:internal:cli:reflect' } } + ) + parse_entries(extract_response_content(response)) + rescue StandardError + [text] + end + + def self.extract_response_content(response) + if response.is_a?(Hash) + (response[:response] || response[:content] || response['response'] || response['content']).to_s + elsif response.respond_to?(:content) + response.content.to_s + else + response.to_s + end + end + + def self.parse_entries(content) + content.lines + .map(&:strip) + .select { |line| line.start_with?('- ') } + .map { |line| line.sub(/\A- /, '').strip } + .reject(&:empty?) + .first(5) + end + + def self.ingest_entries(entries, domain) + results = { apollo: 0, memory: 0 } + entries.each do |entry| + results[:apollo] += 1 if ingest_to_apollo(entry, domain) + results[:memory] += 1 if save_to_memory(entry) + end + results + end + + def self.ingest_to_apollo(content, domain) + uri = URI("http://#{DEFAULT_HOST}:#{api_port}/api/apollo/ingest") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 5 + req = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + req.body = ::JSON.dump({ + content: content, + content_type: 'observation', + tags: %w[reflection auto-learned], + source_agent: 'chat', + source_channel: 'reflection', + knowledge_domain: domain + }) + response = http.request(req) + response.is_a?(Net::HTTPSuccess) + rescue StandardError + false + end + + def self.save_to_memory(entry) + require 'legion/cli/chat/memory_store' + MemoryStore.add(entry, scope: :project) + true + rescue StandardError + false + end + + def self.format_results(entries, results) + lines = ["Reflected on #{entries.size} knowledge entries:\n"] + entries.each_with_index { |e, i| lines << " #{i + 1}. #{e}" } + lines << '' + lines << "Saved: #{results[:apollo]} to Apollo, #{results[:memory]} to memory" + lines.join("\n") + end + + def self.llm_available? + defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat) + end + + def self.api_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/relate_knowledge.rb b/lib/legion/cli/chat/tools/relate_knowledge.rb new file mode 100644 index 00000000..680d7b26 --- /dev/null +++ b/lib/legion/cli/chat/tools/relate_knowledge.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class RelateKnowledge < Legion::Tools::Base + tool_name 'legion.relate_knowledge' + description 'Find related knowledge entries in the Apollo knowledge graph. ' \ + 'Use this to discover connections between concepts, find supporting or contradicting facts, ' \ + 'or explore the knowledge neighborhood of a specific entry.' + input_schema({ + type: 'object', + properties: { + entry_id: { type: 'integer', description: 'The ID of the knowledge entry to find relations for' }, + relation_types: { type: 'string', + description: 'Comma-separated relation types to filter (supports, contradicts, related, derived_from)' }, + depth: { type: 'integer', description: 'Depth of relation traversal (1-3, default: 2)' } + }, + required: ['entry_id'] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call(entry_id:, relation_types: nil, depth: nil) + depth = (depth || 2).clamp(1, 3) + params = { depth: depth } + params[:relation_types] = relation_types if relation_types + + data = apollo_related(entry_id, params) + return "Apollo error: #{data[:error]}" if data[:error] + + entries = data[:entries] || [] + return "No related entries found for entry ##{entry_id}." if entries.empty? + + format_related(entry_id, entries, depth) + rescue Errno::ECONNREFUSED + 'Apollo unavailable (daemon not running).' + rescue StandardError => e + Legion::Logging.warn("RelateKnowledge#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error finding related entries: #{e.message}" + end + + def self.apollo_related(entry_id, params) + query_string = params.map { |k, v| "#{k}=#{v}" }.join('&') + path = "/api/apollo/entries/#{entry_id}/related" + path += "?#{query_string}" unless query_string.empty? + + uri = URI("http://#{DEFAULT_HOST}:#{apollo_port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 10 + response = http.get(uri.request_uri) + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] || parsed + end + + def self.apollo_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + + def self.format_related(entry_id, entries, depth) + header = "Related entries for ##{entry_id} (depth: #{depth}, found: #{entries.size}):\n\n" + parts = entries.map.with_index(1) do |entry, idx| + relation = entry[:relation_type] ? " [#{entry[:relation_type]}]" : '' + confidence = entry[:confidence] ? " (conf: #{entry[:confidence]})" : '' + "#{idx}.#{relation}#{confidence} #{entry[:content]}" + end + header + parts.join("\n") + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/run_command.rb b/lib/legion/cli/chat/tools/run_command.rb new file mode 100644 index 00000000..8f370264 --- /dev/null +++ b/lib/legion/cli/chat/tools/run_command.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' +require 'open3' +require 'timeout' + +module Legion + module CLI + class Chat + module Tools + class RunCommand < Legion::Tools::Base + tool_name 'legion.run_command' + description 'Execute a shell command and return its output. Use for running tests, builds, git commands, etc.' + input_schema({ + type: 'object', + properties: { + command: { type: 'string', description: 'The shell command to execute' }, + timeout: { type: 'integer', description: 'Timeout in seconds (default: 120)' }, + working_directory: { type: 'string', description: 'Working directory (default: current dir)' } + }, + required: ['command'] + }) + + def self.call(command:, timeout: 120, working_directory: nil) + dir = working_directory ? File.expand_path(working_directory) : Dir.pwd + + if sandbox_enabled? && sandbox_available? + execute_sandboxed(command: command, timeout: timeout, dir: dir) + else + execute_direct(command: command, timeout: timeout, dir: dir) + end + end + + def self.sandbox_enabled? + Legion::Settings.dig(:chat, :sandboxed_commands, :enabled) == true + rescue StandardError + false + end + + def self.sandbox_available? + defined?(Legion::Extensions::Exec::Runners::Shell) + end + + def self.execute_sandboxed(command:, timeout:, dir:) + timeout_ms = timeout * 1000 + result = Legion::Extensions::Exec::Runners::Shell.execute( + command: command, cwd: dir, timeout: timeout_ms + ) + + if result[:error] == :blocked + "Command blocked by sandbox: #{result[:reason]}" + elsif result[:error] == :timeout + "[command timed out after #{timeout}s]: #{command}" + elsif result[:success] == false && result[:error] + "Error executing command: #{result[:error]}" + else + format_output(command, result[:stdout], result[:stderr], result[:exit_code]) + end + rescue StandardError => e + "Error executing command: #{e.message}" + end + + def self.execute_direct(command:, timeout:, dir:) + stdout, stderr, status = Open3.popen3(command, chdir: dir) do |stdin, out, err, wait_thr| + stdin.close + out_reader = Thread.new { out.read } + err_reader = Thread.new { err.read } + + unless wait_thr.join(timeout) + ::Process.kill('TERM', wait_thr.pid) + wait_thr.join(5) || ::Process.kill('KILL', wait_thr.pid) + out_reader.kill + err_reader.kill + raise ::Timeout::Error, "command timed out after #{timeout}s" + end + + [out_reader.value, err_reader.value, wait_thr.value] + end + + format_output(command, stdout, stderr, status.exitstatus) + rescue ::Timeout::Error + "[command timed out after #{timeout}s]: #{command}" + rescue StandardError => e + "Error executing command: #{e.message}" + end + + def self.format_output(command, stdout, stderr, exit_code) + output = String.new + output << "$ #{command}\n" + output << stdout.to_s unless stdout.to_s.empty? + output << stderr.to_s unless stderr.to_s.empty? + output << "\n[exit code: #{exit_code}]" + output + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/save_memory.rb b/lib/legion/cli/chat/tools/save_memory.rb new file mode 100644 index 00000000..bad10ea2 --- /dev/null +++ b/lib/legion/cli/chat/tools/save_memory.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module Tools + class SaveMemory < Legion::Tools::Base + tool_name 'legion.save_memory' + description 'Save important information to persistent memory for future sessions. ' \ + 'Also ingests into the Apollo knowledge graph when available for semantic search. ' \ + 'Use this when you learn something important about the project, user preferences, ' \ + 'key decisions, or recurring patterns that should be remembered.' + input_schema({ + type: 'object', + properties: { + text: { type: 'string', description: 'The information to remember' }, + scope: { type: 'string', description: 'Memory scope: "project" (default) or "global"' } + }, + required: ['text'] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call(text:, scope: 'project') + require 'legion/cli/chat/memory_store' + sym_scope = scope.to_s == 'global' ? :global : :project + path = MemoryStore.add(text, scope: sym_scope) + apollo_status = ingest_to_apollo(text, sym_scope) + + parts = ["Saved to #{sym_scope} memory (#{path})"] + parts << apollo_status if apollo_status + parts.join("\n") + rescue StandardError => e + Legion::Logging.warn("SaveMemory#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error saving memory: #{e.message}" + end + + def self.ingest_to_apollo(text, scope) + require 'net/http' + require 'json' + + uri = URI("http://#{DEFAULT_HOST}:#{api_port}/api/apollo/ingest") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 5 + request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + request.body = ::JSON.generate({ + content: text, + source: "chat:#{scope}", + tags: ['memory', scope.to_s] + }) + response = http.request(request) + data = ::JSON.parse(response.body, symbolize_names: true) + return nil if data[:error] + + 'Also ingested into Apollo knowledge graph.' + rescue StandardError => e + Legion::Logging.debug("SaveMemory#ingest_to_apollo failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def self.api_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/scheduling_status.rb b/lib/legion/cli/chat/tools/scheduling_status.rb new file mode 100644 index 00000000..0db4f1b4 --- /dev/null +++ b/lib/legion/cli/chat/tools/scheduling_status.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + module Tools + class SchedulingStatus < Legion::Tools::Base + tool_name 'legion.scheduling_status' + description 'Show LLM scheduling and batch queue status: peak/off-peak state, ' \ + 'batch queue depth, and scheduling configuration' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', description: 'Action: "overview" (default), "scheduling" (peak/off-peak detail), "batch" (queue detail)' } + }, + required: [] + }) + + def self.call(action: 'overview') + case action.to_s + when 'scheduling' then format_scheduling + when 'batch' then format_batch + else format_overview + end + end + + def self.format_overview + lines = ["LLM Scheduling & Batch Overview:\n"] + + if scheduling_available? + s = Legion::LLM::Scheduling.status + lines << format(' Scheduling: %s', v: s[:enabled] ? 'enabled' : 'disabled') + lines << format(' Peak Hours: %s (%s UTC)', + v: s[:peak_hours] ? 'YES (peak now)' : 'no (off-peak)', + r: s[:peak_range]) + else + lines << ' Scheduling: not available' + end + + lines << '' + + if batch_available? + b = Legion::LLM::Batch.status + lines << format(' Batch Queue: %s', v: b[:enabled] ? 'enabled' : 'disabled') + lines << format(' Queue Depth: %d', v: b[:queue_size]) + else + lines << ' Batch Queue: not available' + end + + lines.join("\n") + end + + def self.format_scheduling + return 'Scheduling module not available.' unless scheduling_available? + + s = Legion::LLM::Scheduling.status + lines = ["LLM Scheduling Detail:\n"] + lines << format(' Enabled: %s', v: s[:enabled]) + lines << format(' Peak Hours Now: %s', v: s[:peak_hours]) + lines << format(' Peak Range (UTC): %s', v: s[:peak_range]) + lines << format(' Next Off-Peak: %s', v: s[:next_off_peak]) + lines << format(' Max Defer Hours: %d', v: s[:max_defer_hours]) + lines << format(' Defer Intents: %s', v: Array(s[:defer_intents]).join(', ')) + lines.join("\n") + end + + def self.format_batch + return 'Batch module not available.' unless batch_available? + + b = Legion::LLM::Batch.status + lines = ["LLM Batch Queue Detail:\n"] + lines << format(' Enabled: %s', v: b[:enabled]) + lines << format(' Queue Size: %d', v: b[:queue_size]) + lines << format(' Max Batch Size: %d', v: b[:max_batch_size]) + lines << format(' Window (sec): %d', v: b[:window_seconds]) + + lines << format(' Oldest Queued: %s', v: b[:oldest_queued]) if b[:oldest_queued] + + unless (b[:by_priority] || {}).empty? + lines << '' + lines << ' By Priority:' + b[:by_priority].each do |priority, count| + lines << format(' %

-10s %d', p: priority, c: count) + end + end + + lines.join("\n") + end + + def self.scheduling_available? + defined?(Legion::LLM::Scheduling) + end + + def self.batch_available? + defined?(Legion::LLM::Batch) + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/search_content.rb b/lib/legion/cli/chat/tools/search_content.rb new file mode 100644 index 00000000..ce0e3f92 --- /dev/null +++ b/lib/legion/cli/chat/tools/search_content.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module Tools + class SearchContent < Legion::Tools::Base + tool_name 'legion.search_content' + description 'Search file contents for a regex pattern. Returns matching lines with context.' + input_schema({ + type: 'object', + properties: { + pattern: { type: 'string', description: 'Regex pattern to search for' }, + directory: { type: 'string', description: 'Directory to search in (default: current dir)' }, + glob: { type: 'string', description: 'File glob filter (e.g., "*.rb")' } + }, + required: ['pattern'] + }) + + def self.call(pattern:, directory: nil, glob: nil) + dir = File.expand_path(directory || Dir.pwd) + return "Error: directory not found: #{dir}" unless Dir.exist?(dir) + + file_pattern = File.join(dir, glob || '**/*') + files = Dir.glob(file_pattern).select { |f| File.file?(f) } + regex = Regexp.new(pattern) + + results = [] + files.each do |file| + begin + File.readlines(file, encoding: 'utf-8').each_with_index do |line, i| + if line.match?(regex) + relative = file.sub("#{dir}/", '') + results << "#{relative}:#{i + 1}: #{line.rstrip}" + end + rescue ArgumentError => e + Legion::Logging.debug("SearchContent#execute encoding error in #{file}: #{e.message}") if defined?(Legion::Logging) + next + end + rescue StandardError => e + Legion::Logging.debug("SearchContent#execute skipping #{file}: #{e.message}") if defined?(Legion::Logging) + next + end + break if results.length >= 50 + end + + return "No matches for /#{pattern}/ in #{dir}" if results.empty? + + "#{results.length} matches:\n#{results.join("\n")}" + rescue RegexpError => e + Legion::Logging.warn("SearchContent#execute invalid regex #{pattern}: #{e.message}") if defined?(Legion::Logging) + "Error: invalid regex: #{e.message}" + rescue StandardError => e + Legion::Logging.warn("SearchContent#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error searching: #{e.message}" + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/search_files.rb b/lib/legion/cli/chat/tools/search_files.rb new file mode 100644 index 00000000..4a739790 --- /dev/null +++ b/lib/legion/cli/chat/tools/search_files.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module Tools + class SearchFiles < Legion::Tools::Base + tool_name 'legion.search_files' + description 'Find files matching a glob pattern. Returns matching file paths.' + input_schema({ + type: 'object', + properties: { + pattern: { type: 'string', description: 'Glob pattern (e.g., "**/*.rb", "src/**/*.ts")' }, + directory: { type: 'string', description: 'Directory to search in (default: current dir)' } + }, + required: ['pattern'] + }) + + def self.call(pattern:, directory: nil) + dir = File.expand_path(directory || Dir.pwd) + return "Error: directory not found: #{dir}" unless Dir.exist?(dir) + + matches = Dir.glob(File.join(dir, pattern)) + return "No files matching #{pattern} in #{dir}" if matches.empty? + + relative = matches.map { |f| f.sub("#{dir}/", '') } + "#{relative.length} files matching #{pattern}:\n#{relative.join("\n")}" + rescue StandardError => e + Legion::Logging.warn("SearchFiles#execute failed for pattern #{pattern}: #{e.message}") if defined?(Legion::Logging) + "Error searching: #{e.message}" + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/search_memory.rb b/lib/legion/cli/chat/tools/search_memory.rb new file mode 100644 index 00000000..333a8c20 --- /dev/null +++ b/lib/legion/cli/chat/tools/search_memory.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'legion/cli/chat_command' + +module Legion + module CLI + class Chat + module Tools + class SearchMemory < Legion::Tools::Base + tool_name 'legion.search_memory' + description 'Search persistent memory and the Apollo knowledge graph for previously saved information. ' \ + 'Returns matching memory entries (substring match) and related Apollo knowledge entries when available. ' \ + 'Use this to recall project conventions, user preferences, past decisions, or learned facts.' + input_schema({ + type: 'object', + properties: { + query: { type: 'string', description: 'Search text (case-insensitive substring match for memory, semantic for Apollo)' } + }, + required: ['query'] + }) + + DEFAULT_PORT = 4567 + DEFAULT_HOST = '127.0.0.1' + + def self.call(query:) + require 'legion/cli/chat/memory_store' + sections = [] + + memory_results = MemoryStore.search(query) + unless memory_results.empty? + lines = memory_results.map { |r| "- #{r[:text]}" } + sections << "Memory matches (#{memory_results.size}):\n#{lines.join("\n")}" + end + + apollo_results = search_apollo(query) + if apollo_results&.any? + lines = apollo_results.map { |r| "- [#{r[:type] || 'fact'}] #{r[:content]} (confidence: #{r[:confidence] || 'n/a'})" } + sections << "Apollo knowledge (#{apollo_results.size}):\n#{lines.join("\n")}" + end + + return 'No matching memories or knowledge found.' if sections.empty? + + sections.join("\n\n") + rescue StandardError => e + Legion::Logging.warn("SearchMemory#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error searching memory: #{e.message}" + end + + def self.search_apollo(query) + require 'net/http' + require 'json' + + uri = URI("http://#{DEFAULT_HOST}:#{api_port}/api/apollo/query") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 5 + request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + request.body = ::JSON.generate({ query: query, limit: 5 }) + response = http.request(request) + data = ::JSON.parse(response.body, symbolize_names: true) + data[:data] || data[:results] || [] + rescue StandardError + nil + end + + def self.api_port + return DEFAULT_PORT unless defined?(Legion::Settings) + + Legion::Settings[:api]&.dig(:port) || DEFAULT_PORT + rescue StandardError + DEFAULT_PORT + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/search_traces.rb b/lib/legion/cli/chat/tools/search_traces.rb new file mode 100644 index 00000000..f2905fcf --- /dev/null +++ b/lib/legion/cli/chat/tools/search_traces.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require 'json' + +begin + require 'legion/cli/chat_command' +rescue LoadError + nil +end + +module Legion + module CLI + class Chat + module Tools + class SearchTraces < Legion::Tools::Base + tool_name 'legion.search_traces' + description 'Search cognitive memory traces for information from Teams messages, conversations, ' \ + 'meetings, people, and other ingested data. Use this when the user asks about what ' \ + 'someone said, conversation topics, meeting details, or any previously observed context.' + input_schema({ + type: 'object', + properties: { + query: { type: 'string', description: 'Natural language search query (e.g., "what did Bob say about deployment")' }, + person: { type: 'string', description: 'Filter by person name (matches peer:Name domain tags)' }, + domain: { type: 'string', description: 'Filter by domain tag (e.g., "teams", "meeting", "conversation")' }, + trace_type: { type: 'string', description: 'Filter by trace type: episodic, semantic, sensory, identity' }, + limit: { type: 'integer', description: 'Max results to return (default: 20)' } + }, + required: ['query'] + }) + + STRUCTURED_FIELDS = [ + ['Person', 'displayName', :displayName, 'peer', :peer], + ['Summary', 'summary', :summary], + ['Subject', 'subject', :subject], + ['Team', 'team', :team], + ['Job', 'jobTitle', :jobTitle] + ].freeze + + def self.call(query:, person: nil, domain: nil, trace_type: nil, limit: nil, **) # rubocop:disable Metrics/ParameterLists + return 'Memory trace system not available (lex-agentic-memory not loaded).' unless trace_store_available? + + limit = (limit || 20).clamp(1, 50) + traces = collect_traces(person: person, domain: domain, trace_type: trace_type, limit: limit * 3) + return 'No memory traces found matching those filters.' if traces.empty? + + ranked = rank_by_query(traces: traces, query: query) + results = ranked.first(limit) + return 'No traces matched your query.' if results.empty? + + format_results(results) + rescue StandardError => e + Legion::Logging.warn("SearchTraces#execute failed: #{e.message}") if defined?(Legion::Logging) + "Error searching traces: #{e.message}" + end + + def self.trace_store_available? + load_trace_gem unless defined?(Legion::Extensions::Agentic::Memory::Trace) + defined?(Legion::Extensions::Agentic::Memory::Trace) && + Legion::Extensions::Agentic::Memory::Trace.respond_to?(:shared_store) + end + + def self.load_trace_gem + require 'legion/extensions/agentic/memory/trace' + rescue LoadError + nil + end + + def self.store + Legion::Extensions::Agentic::Memory::Trace.shared_store + end + + def self.collect_traces(person:, domain:, trace_type:, limit:) + if person + candidates = [] + name_variants = person_name_variants(person) + name_variants.each do |name| + %W[peer:#{name} sender:#{name}].each do |tag| + candidates += store.retrieve_by_domain(tag, min_strength: 0.01, limit: limit) + end + end + + candidates += fuzzy_person_search(person, limit: limit) if candidates.size < 5 + + candidates += store.retrieve_by_domain('teams', min_strength: 0.01, limit: limit) if candidates.size < 5 + return candidates.uniq { |t| t[:trace_id] } + end + + return store.retrieve_by_domain(domain, min_strength: 0.01, limit: limit) if domain + + if trace_type + sym = trace_type.to_sym + return store.retrieve_by_type(sym, min_strength: 0.01, limit: limit) + end + + store.all_traces(min_strength: 0.01).sort_by { |t| -t[:strength] }.first(limit) + end + + def self.rank_by_query(traces:, query:) + keywords = query.downcase.split(/\s+/).reject { |w| w.length < 3 } + return traces if keywords.empty? + + scored = traces.filter_map do |trace| + text = extract_searchable_text(trace) + next nil if text.empty? + + score = compute_score(text: text, keywords: keywords, trace: trace) + next nil if score.zero? + + { trace: trace, score: score } + end + + scored.sort_by { |s| -s[:score] }.map { |s| s[:trace] } + end + + def self.extract_searchable_text(trace) + payload = trace[:content_payload] || trace[:content] + text = case payload + when String + begin + parsed = ::JSON.parse(payload) + flatten_to_text(parsed) + rescue ::JSON::ParserError + payload + end + when Hash + flatten_to_text(payload) + else + payload.to_s + end + text.downcase + end + + def self.flatten_to_text(obj) + case obj + when Hash + obj.values.map { |v| flatten_to_text(v) }.join(' ') + when Array + obj.map { |v| flatten_to_text(v) }.join(' ') + else + obj.to_s + end + end + + def self.compute_score(text:, keywords:, trace:) + keyword_hits = keywords.count { |kw| text.include?(kw) } + return 0.0 if keyword_hits.zero? + + keyword_ratio = keyword_hits.to_f / keywords.size + strength_bonus = trace[:strength] || 0.0 + recency_bonus = recency_score(trace[:created_at]) + + (keyword_ratio * 10.0) + (strength_bonus * 2.0) + (recency_bonus * 3.0) + end + + def self.recency_score(created_at) + return 0.0 unless created_at.is_a?(Time) + + age_hours = (Time.now.utc - created_at) / 3600.0 + 1.0 / (1.0 + (age_hours / 24.0)) + end + + def self.format_results(traces) + parts = traces.map.with_index(1) do |trace, idx| + payload = trace[:content_payload] || trace[:content] + content = format_payload(payload) + tags = (trace[:domain_tags] || []).join(', ') + age = format_age(trace[:created_at]) + + "#{idx}. [#{trace[:trace_type]}] #{content}\n tags: #{tags} | strength: #{(trace[:strength] || 0).round(2)} | #{age}" + end + + "Found #{traces.size} matching traces:\n\n#{parts.join("\n\n")}" + end + + def self.format_payload(payload) + data = parse_payload(payload) + return truncate(data, 300) if data.is_a?(String) + + format_structured(data) + end + + def self.parse_payload(payload) + case payload + when String + ::JSON.parse(payload) + when Hash + payload + else + payload.to_s + end + rescue ::JSON::ParserError + payload + end + + def self.format_structured(data) + parts = STRUCTURED_FIELDS.filter_map do |label, *keys| + val = keys.lazy.filter_map { |k| data[k] }.first + "#{label}: #{val}" if val + end + + return parts.join(' | ') unless parts.empty? + + truncate(flatten_to_text(data), 300) + end + + def self.truncate(text, max) + text.length > max ? "#{text[0..(max - 3)]}..." : text + end + + def self.format_age(created_at) + return 'age unknown' unless created_at.is_a?(Time) + + seconds = Time.now.utc - created_at + if seconds < 3600 + "#{(seconds / 60).to_i}m ago" + elsif seconds < 86_400 + "#{(seconds / 3600).to_i}h ago" + else + "#{(seconds / 86_400).to_i}d ago" + end + end + + def self.person_name_variants(name) + parts = name.strip.split(/[\s,]+/).reject(&:empty?) + variants = [name] + + if parts.length == 2 + variants << "#{parts[1]}, #{parts[0]}" + variants << "#{parts[0]} #{parts[1]}" + variants << "#{parts[1]} #{parts[0]}" + elsif parts.length >= 3 + variants << "#{parts.last}, #{parts[0...-1].join(' ')}" + variants << "#{parts[0...-1].join(' ')} #{parts.last}" + end + + variants << parts.first if parts.first && parts.first.length > 2 + + variants.uniq + end + + def self.fuzzy_person_search(person, limit: 60) + needle = person.downcase + parts = needle.split(/[\s,]+/).reject(&:empty?) + + matches = store.all_traces(min_strength: 0.01).select do |trace| + tags = trace[:domain_tags] || [] + tags.any? do |tag| + next false unless tag.start_with?('peer:', 'sender:') + + tag_name = tag.sub(/\A(peer|sender):/, '').downcase + parts.all? { |p| tag_name.include?(p) } + end + end + matches.sort_by { |t| -t[:strength] }.first(limit) + end + end + end + end + end +end diff --git a/lib/legion/cli/chat/tools/shadow_eval_status.rb b/lib/legion/cli/chat/tools/shadow_eval_status.rb new file mode 100644 index 00000000..8aefda01 --- /dev/null +++ b/lib/legion/cli/chat/tools/shadow_eval_status.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Chat + module Tools + class ShadowEvalStatus < Legion::Tools::Base + tool_name 'legion.shadow_eval_status' + description 'Show shadow evaluation results comparing primary vs cheaper models' + input_schema({ + type: 'object', + properties: { + action: { type: 'string', description: 'Action: "summary" (default) or "history" (recent evaluations)' } + }, + required: [] + }) + + def self.call(action: 'summary') + return 'Shadow evaluation not available.' unless shadow_available? + + case action.to_s + when 'history' then format_history + else format_summary + end + end + + def self.shadow_available? + defined?(Legion::LLM::ShadowEval) + end + + def self.format_summary + s = Legion::LLM::ShadowEval.summary + lines = ["Shadow Evaluation Summary:\n"] + lines << format(' Evaluations: %d', v: s[:total_evaluations]) + + if s[:total_evaluations].zero? + lines << ' No evaluations recorded yet.' + lines << '' + lines << ' Enable via settings: llm.shadow.enabled = true' + return lines.join("\n") + end + + lines << format(' Avg Length Ratio: %.2f', v: s[:avg_length_ratio]) + lines << format(' Avg Cost Savings: %.1f%%', v: s[:avg_cost_savings] * 100) + lines << format(' Primary Cost: $%.6f', v: s[:total_primary_cost]) + lines << format(' Shadow Cost: $%.6f', v: s[:total_shadow_cost]) + lines << format(' Models Tested: %s', v: s[:models_evaluated].join(', ')) + + if s[:avg_cost_savings].positive? + lines << '' + lines << format(' Shadow models saved ~%.1f%% on average.', + v: s[:avg_cost_savings] * 100) + end + + lines.join("\n") + end + + def self.format_history + entries = Legion::LLM::ShadowEval.history + return 'No shadow evaluation history.' if entries.empty? + + lines = [format("Shadow Evaluation History (last %d):\n", n: entries.size)] + + entries.last(10).reverse_each do |entry| + lines << format( + ' %}mi) do |url, title| + clean_title = strip_tags(title).strip + next if clean_title.empty? + + real_url = extract_real_url(url) + next unless real_url + + results << { title: clean_title, url: real_url } + break if results.length >= max_results + end + + # Extract snippets + snippets = [] + html.scan(%r{]+class="result__snippet"[^>]*>(.*?)}mi) do |snippet| + snippets << strip_tags(snippet.first).strip + end + + results.each_with_index do |r, i| + r[:snippet] = snippets[i] || '' + end + + results + end + + def extract_real_url(ddg_url) + uri = URI.parse(ddg_url) + return ddg_url unless uri.host&.end_with?('.duckduckgo.com') || uri.host == 'duckduckgo.com' + + match = ddg_url.match(/uddg=([^&]+)/) + return nil unless match + + URI.decode_www_form_component(match[1]) + rescue StandardError => e + Legion::Logging.debug("WebSearch#extract_real_url failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def strip_tags(html) + html.gsub(/<[^>]+>/, '').gsub('&', '&').gsub('<', '<').gsub('>', '>') + .gsub('"', '"').gsub(''', "'").gsub(' ', ' ') + end + + def fetch_top_result(url) + require 'legion/cli/chat/web_fetch' + WebFetch.fetch(url) + rescue StandardError => e + Legion::Logging.debug("WebSearch#fetch_top_result failed for #{url}: #{e.message}") if defined?(Legion::Logging) + nil + end + end + end + end +end diff --git a/lib/legion/cli/chat_command.rb b/lib/legion/cli/chat_command.rb new file mode 100644 index 00000000..b8232728 --- /dev/null +++ b/lib/legion/cli/chat_command.rb @@ -0,0 +1,1489 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' + +module Legion + module CLI + class Chat < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + class_option :model, type: :string, aliases: ['-m'], desc: 'Model ID (e.g., claude-sonnet-4-6)' + class_option :provider, type: :string, desc: 'LLM provider (bedrock, anthropic, openai, gemini, ollama)' + class_option :system, type: :string, desc: 'System prompt override' + class_option :auto_approve, type: :boolean, default: false, aliases: ['-y'], + desc: 'Auto-approve all tool executions (skip confirmation prompts)' + class_option :no_markdown, type: :boolean, default: false, + desc: 'Disable markdown rendering (raw output)' + class_option :max_budget_usd, type: :numeric, desc: 'Maximum estimated cost in USD (stops when exceeded)' + class_option :incognito, type: :boolean, default: false, + desc: 'Disable automatic session history saving' + class_option :continue, type: :boolean, default: false, aliases: ['-c'], + desc: 'Resume the most recent session' + class_option :resume, type: :string, desc: 'Resume a saved session by name' + class_option :resume_latest, type: :boolean, default: false, + desc: 'Auto-resume most recent session regardless of CWD' + class_option :fork, type: :string, desc: 'Fork a saved session (load but save as new)' + class_option :add_dir, type: :array, default: [], desc: 'Additional directories to include in context' + class_option :personality, type: :string, desc: 'Communication style (concise, verbose, educational)' + class_option :worktree, type: :boolean, default: false, desc: 'Run in isolated git worktree' + + autoload :Session, 'legion/cli/chat/session' + autoload :StatusIndicator, 'legion/cli/chat/status_indicator' + + desc 'interactive', 'Start interactive AI conversation' + def interactive + out = formatter + setup_chat_logger + setup_connection + + chat_obj = create_chat + configure_permissions(:interactive) + system_prompt = build_system_prompt + @session = Chat::Session.new( + chat: chat_obj, system_prompt: system_prompt, + budget_usd: effective_budget + ) + @indicator = Chat::StatusIndicator.new(@session) unless options[:json] + + restore_session(out) if options[:continue] || options[:resume] || options[:resume_latest] || options[:fork] + load_memory_context + load_custom_agents + + setup_notification_bridge + setup_gaia_observation + setup_worktree(out) if options[:worktree] + + @last_active_at = Time.now + + chat_log.info "session started model=#{@session.model_id} incognito=#{incognito?}" + out.banner(version: Legion::VERSION) + puts + puts out.dim(" Model: #{@session.model_id}") + puts out.dim(' Type /help for commands, /quit to exit. End a line with \\ for multiline.') + puts + + send_recovery_message(out) if @recovery_message + repl_loop(out) + rescue Interrupt + Legion::Logging.debug('ChatCommand#interactive interrupted by user') if defined?(Legion::Logging) + puts + puts out.dim('Interrupted.') + show_session_stats(out) if @session + rescue CLI::Error => e + chat_log.error "cli_error: #{e.message}" + out.error(e.message) + raise SystemExit, 1 + ensure + auto_save_session(out) if @session + chat_log&.info('session ended') + Connection.shutdown + end + default_task :interactive + + desc 'prompt TEXT', 'Send a single prompt and exit (headless mode)' + option :output_format, type: :string, default: 'text', desc: 'Output format: text, json' + option :max_turns, type: :numeric, desc: 'Maximum tool-use turns (default: 10)' + def prompt(text) + out = formatter + setup_chat_logger + setup_connection + + text = combine_with_stdin(text) + raise CLI::Error, 'No prompt text provided. Pass text as argument or pipe via stdin.' if text.empty? + + chat_obj = create_chat + configure_permissions(:headless) + system_prompt = build_system_prompt + session = Chat::Session.new( + chat: chat_obj, system_prompt: system_prompt, + budget_usd: effective_budget + ) + + chat_log.info "headless prompt model=#{session.model_id} length=#{text.length}" + + turn_tool_calls = [] + tool_callbacks = { + on_tool_call: ->(tc) { turn_tool_calls << { name: tc.name, args: tc.arguments, result: nil } }, + on_tool_result: ->(tr) { turn_tool_calls.last[:result] = tr.to_s.lines.first(3).join.rstrip if turn_tool_calls.last } + } + + response = if options[:output_format] == 'json' + session.send_message(text, **tool_callbacks) + else + session.send_message(text, **tool_callbacks) { |chunk| print chunk.content if chunk.content } + end + + chat_log.info "headless complete tokens_in=#{session.stats[:input_tokens]} tokens_out=#{session.stats[:output_tokens]}" + + if options[:output_format] == 'json' + out.json({ + response: response.content, + model: session.model_id, + stats: session.stats + }) + else + puts unless response.content&.end_with?("\n") + end + rescue CLI::Error => e + chat_log&.error("cli_error: #{e.message}") + out.error(e.message) + raise SystemExit, 1 + rescue StandardError => e + chat_log&.error("prompt_error: #{e.message}") + warn "Error: #{e.message}" + raise SystemExit, 1 + ensure + Connection.shutdown + end + + no_commands do + def chat_setting(*keys) + Legion::Settings.dig(:chat, *keys) + rescue StandardError => e + Legion::Logging.debug("ChatCommand#chat_setting failed for #{keys.inspect}: #{e.message}") if defined?(Legion::Logging) + nil + end + + def incognito? + options[:incognito] || chat_setting(:incognito) == true + end + + def effective_budget + options[:max_budget_usd] || chat_setting(:max_budget_usd) + end + + def effective_max_turns + options[:max_turns] || chat_setting(:headless, :max_turns) || 10 + end + + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def setup_chat_logger + require 'legion/cli/chat/chat_logger' + ChatLogger.setup(level: options[:verbose] ? 'debug' : 'info') + end + + def chat_log + ChatLogger.logger + end + + def setup_connection + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_settings + + require 'legion/llm/daemon_client' + return if Legion::LLM::DaemonClient.available? + + raise CLI::Error, + "LegionIO daemon is not running. Start it with: legionio start\n " \ + 'All LLM requests must route through the daemon.' + end + + def setup_notification_bridge + require 'legion/chat/notification_bridge' + @notification_bridge = Legion::Chat::NotificationBridge.new + @notification_bridge.start + rescue LoadError => e + Legion::Logging.debug("ChatCommand#setup_notification_bridge notification_bridge not available: #{e.message}") if defined?(Legion::Logging) + @notification_bridge = nil + end + + def setup_gaia_observation + return unless defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:started?) + return unless Legion::Gaia.started? + return unless defined?(Legion::Gaia::InputFrame) + + identity = chat_obj_identity + @session.on(:llm_complete) do |payload| + next unless Legion::Gaia.started? + + content = payload[:user_message] || payload[:message] || '' + frame = Legion::Gaia::InputFrame.new( + content: content, + channel_id: :cli_chat, + auth_context: { identity: identity }, + metadata: { source_type: :human_direct, + direct_address: content.to_s.match?(/\bgaia\b/i) } + ) + Legion::Gaia.ingest(frame) + rescue StandardError => e + Legion::Logging.debug("GAIA observation error: #{e.message}") if defined?(Legion::Logging) + end + rescue StandardError => e + Legion::Logging.debug("setup_gaia_observation failed: #{e.message}") if defined?(Legion::Logging) + end + + def chat_obj_identity + return @session.chat.caller_context.dig(:requested_by, :identity) if @session.chat.respond_to?(:caller_context) + + require 'etc' + Etc.getlogin || ENV.fetch('USER', 'unknown') + rescue StandardError + ENV.fetch('USER', 'unknown') + end + + def away? + return false unless @last_active_at + + threshold = chat_setting(:away_summary_threshold_seconds) || 120 + Time.now - @last_active_at > threshold + end + + def show_away_summary(out) + return unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat) + + messages = @session.chat.messages.last(30).select { |m| m.respond_to?(:role) } + return if messages.length < 2 + + summary_input = messages.map { |m| "#{m.role}: #{m.content.to_s[0..500]}" }.join("\n") + idle_minutes = ((Time.now - @last_active_at) / 60).round(1) + + prompt = "You are a concise assistant. The user has been away for #{idle_minutes} minutes. " \ + 'In 1-3 sentences, summarize what happened in this conversation for a returning user. ' \ + 'Focus on: what task was in progress, what was accomplished, what needs attention next. ' \ + "Skip status reports and commit recaps.\n\nRecent conversation:\n#{summary_input}" + + response = Legion::LLM.chat( + message: prompt, + caller: { requested_by: { type: :system, identity: 'legion:internal:cli:away_summary' } } + ) + + text = if response.is_a?(Hash) + response[:response] || response[:content] + elsif response.respond_to?(:content) + response.content + else + response.to_s + end + return if text.to_s.strip.empty? + + puts + puts out.colorize(" [away #{idle_minutes}m] ", :gray) + text.strip + puts + rescue StandardError => e + Legion::Logging.debug "away_summary failed: #{e.message}" if defined?(Legion::Logging) + end + + def display_pending_notifications + return unless @notification_bridge + + notes = @notification_bridge.pending_notifications + return if notes.empty? + + notes.each do |n| + prefix = n[:priority] == :critical ? "\e[31m!\e[0m" : "\e[33m*\e[0m" + puts " #{prefix} #{n[:message]}" + end + puts + end + + def render_response(text, out) + markdown_enabled = if options[:no_markdown] + false + else + chat_setting(:markdown) != false + end + return text unless markdown_enabled && out.color_enabled + + require 'legion/cli/chat/markdown_renderer' + Chat::MarkdownRenderer.render(text, color: out.color_enabled) + rescue LoadError => e + Legion::Logging.debug("ChatCommand#render_response markdown_renderer not available: #{e.message}") if defined?(Legion::Logging) + text + end + + def combine_with_stdin(text) + return text if $stdin.tty? + + piped = $stdin.read + return piped.strip if text.strip.empty? + + "#{text}\n\n#{piped}" + end + + def configure_permissions(default) + require 'legion/cli/chat/permissions' + Chat::Permissions.mode = if options[:auto_approve] + :auto_approve + elsif (setting = chat_setting(:permissions)) + setting.to_sym + else + default + end + end + + def create_chat + require 'legion/cli/chat/daemon_chat' + require 'legion/cli/chat/tool_registry' + + chat = Chat::DaemonChat.new( + model: options[:model] || chat_setting(:model), + provider: (options[:provider] || chat_setting(:provider))&.to_sym + ) + chat.with_tools(*Chat::ToolRegistry.all_tools) + chat + end + + def build_system_prompt + return options[:system] if options[:system] + + require 'legion/cli/chat/context' + @extra_dirs = options[:add_dir] || [] + prompt = Chat::Context.to_system_prompt(Dir.pwd, extra_dirs: @extra_dirs) + + @personality = options[:personality] || chat_setting(:personality) + case @personality + when 'concise' then prompt += "\n\nBe extremely concise. Short answers, minimal explanation. Code over prose." + when 'verbose' then prompt += "\n\nBe thorough and detailed. Explain your reasoning step by step." + when 'educational' then prompt += "\n\nBe educational. Explain concepts, provide context, teach as you help." + end + + require 'legion/cli/chat/output_styles' + style_injection = Chat::OutputStyles.system_prompt_injection + prompt += "\n\n#{style_injection}" if style_injection + + prompt + end + + def repl_loop(out) + require 'reline' + + loop do + display_pending_notifications + input = read_user_input + break if input.nil? # Ctrl+D + + show_away_summary(out) if away? + @last_active_at = Time.now + + stripped = input.strip + + if ['/edit', '/e'].include?(stripped) + stripped = open_editor_prompt(out) + next unless stripped + end + + next if stripped.empty? + + if stripped.start_with?('!') + handle_bang_command(stripped[1..], out) + next + end + + if stripped.start_with?('/') + handled = handle_slash_command(stripped, out) + next if handled + end + + if stripped.start_with?('@') + handled = handle_at_mention(stripped, out) + next if handled + end + + chat_log.debug "user_message length=#{stripped.length}" + print out.colorize('legion', :title) + print out.dim(' > ') + + buffer = String.new + tool_index = 0 + tool_total = 0 + turn_tool_calls = [] + @session.send_message( + stripped, + on_tool_call: lambda { |tc| + tool_index += 1 + chat_log.debug "tool_call name=#{tc.name} args=#{tc.arguments.keys.join(',')}" + turn_tool_calls << { name: tc.name, args: tc.arguments, result: nil } + @session.emit(:tool_start, { + name: tc.name, args: tc.arguments, + index: tool_index, total: tool_total + }) + puts out.dim(" [tool] #{tc.name}(#{tc.arguments.keys.join(', ')})") + }, + on_tool_result: lambda { |tr| + result_preview = tr.to_s.lines.first(3).join.rstrip + chat_log.debug "tool_result preview=#{result_preview[0..200]}" + turn_tool_calls.last[:result] = result_preview if turn_tool_calls.last + @session.emit(:tool_complete, { + name: 'tool', result_preview: result_preview, + index: tool_index, total: tool_total + }) + puts out.dim(" [result] #{result_preview}") + worktree_auto_checkpoint + } + ) do |chunk| + buffer << chunk.content if chunk.content + end + chat_log.debug "response length=#{buffer.length} tokens_in=#{@session.stats[:input_tokens]} tokens_out=#{@session.stats[:output_tokens]}" + print render_response(buffer, out) + puts + puts + rescue Chat::Session::BudgetExceeded => e + chat_log.warn "budget_exceeded: #{e.message}" + puts + out.error(e.message) + break + rescue Interrupt + Legion::Logging.debug('ChatCommand#repl_loop interrupted mid-input by user') if defined?(Legion::Logging) + puts + next + rescue StandardError => e + chat_log.error "llm_error: #{e.class}: #{e.message}" + puts + out.error("LLM error: #{e.message}") + puts + end + + puts + puts out.dim('Goodbye.') + show_session_stats(out) + end + + def read_user_input + lines = [] + first_line = true + + loop do + prompt = first_line ? prompt_string : continuation_prompt_string + line = Reline.readline(prompt, first_line) + return nil if line.nil? # Ctrl+D + + if line.rstrip.end_with?('\\') + lines << line.rstrip.chomp('\\').rstrip + first_line = false + next + end + + lines << line + break + end + + result = lines.join("\n") + result.strip.empty? ? '' : result + rescue Interrupt + Legion::Logging.debug('ChatCommand#read_user_input interrupted during multiline input') if defined?(Legion::Logging) + raise if first_line + + puts + nil + end + + def prompt_string + label = @plan_mode ? 'plan' : 'you' + "\001\e[38;2;127;119;221m\002#{label}\001\e[0m\002 > " + end + + def continuation_prompt_string + "\001\e[2m\002 ... \001\e[0m\002 " + end + + def open_editor_prompt(out) + require 'tempfile' + editor = ENV['VISUAL'] || ENV['EDITOR'] || 'vi' + tmpfile = Tempfile.new(['legion-prompt', '.md']) + tmpfile.write("# Write your prompt below, then save and close the editor\n\n") + tmpfile.flush + + system("#{editor} #{tmpfile.path}") + content = File.read(tmpfile.path, encoding: 'utf-8') + lines = content.lines.reject { |l| l.start_with?('#') }.join.strip + + if lines.empty? + out.warn('Empty prompt — editor cancelled.') + return nil + end + + chat_log.debug "editor_prompt length=#{lines.length}" + lines + rescue StandardError => e + out.error("Editor failed: #{e.message}") + nil + ensure + tmpfile&.close + tmpfile&.unlink + end + + def handle_slash_command(input, out) + cmd, *args = input.split(' ', 2) + chat_log.debug "slash_command: #{cmd}" + case cmd.downcase + when '/quit', '/exit', '/q' + show_session_stats(out) + auto_save_session(out) + cleanup_worktree(out) if @worktree_path + raise SystemExit, 0 + when '/help', '/h' + show_help(out) + when '/cost' + show_session_stats(out) + when '/clear' + @session.chat.reset_messages! + chat_log.info 'conversation cleared' + out.success('Conversation cleared') + when '/save' + handle_save(args.first, out) + when '/load' + handle_load(args.first, out) + when '/sessions' + handle_sessions(out) + when '/compact' + handle_compact(args.first, out) + when '/context' + handle_context_stats(out) + when '/fetch' + handle_fetch(args.first, out) + when '/rewind' + handle_rewind(args.first, out) + when '/memory' + handle_memory(args.first, out) + when '/search' + handle_search(args.first, out) + when '/agent' + handle_agent(args.first, out) + when '/agents' + handle_agents_status(out) + when '/plan' + handle_plan_toggle(out) + when '/swarm' + handle_swarm(args.first, out) + when '/copy' + handle_copy(out) + when '/diff' + handle_diff(out) + when '/permissions' + handle_permissions(args.first, out) + when '/review' + handle_review_in_session(args.first, out) + when '/status' + handle_status(out) + when '/new' + handle_new_conversation(out) + when '/personality' + handle_personality(args.first, out) + when '/style' + handle_style(args, out) + when '/model' + if args.first + @session.chat.with_model(args.first) + chat_log.info "model_switch to=#{args.first}" + out.success("Switched to model: #{args.first}") + else + puts " Current model: #{@session.model_id}" + end + when '/commit' + handle_commit_in_chat(out) + when '/workers' + handle_workers_in_chat(out) + when '/dream' + handle_dream_in_chat(out) + else + require 'legion/chat/skills' + skill = Legion::Chat::Skills.find(cmd.delete_prefix('/')) + if skill + handle_skill(skill, args.first, out) + else + out.warn("Unknown command: #{cmd}. Type /help for available commands.") + end + end + true + end + + def handle_save(name, out) + require 'legion/cli/chat/session_store' + name ||= Time.now.strftime('%Y%m%d-%H%M%S') + @session_name = name + path = Chat::SessionStore.save(@session, name) + out.success("Session saved: #{name} (#{path})") + rescue StandardError => e + out.error("Save failed: #{e.message}") + end + + def handle_load(name, out) + require 'legion/cli/chat/session_store' + unless name + out.error('Usage: /load . Use /sessions to list saved sessions.') + return + end + data = Chat::SessionStore.load(name) + Chat::SessionStore.restore(@session, data) + msg_count = data[:messages]&.length || 0 + out.success("Loaded session: #{name} (#{msg_count} messages)") + rescue CLI::Error => e + out.error(e.message) + end + + def handle_sessions(_out) + require 'legion/cli/chat/session_store' + sessions = Chat::SessionStore.list + if sessions.empty? + puts ' No saved sessions.' + return + end + puts ' Recent Sessions:' + sessions.first(10).each_with_index do |s, idx| + age = Time.now - s[:modified] + ago = if age < 3600 + "#{(age / 60).round}m ago" + elsif age < 86_400 + "#{(age / 3600).round}h ago" + else + "#{(age / 86_400).round}d ago" + end + cwd = s[:cwd] ? abbreviate_path(s[:cwd]) : '?' + msgs = s[:message_count] || '?' + puts format(' %d. [%s] %-24s %s (%s messages)', + idx: idx + 1, ago: ago, name: s[:name], cwd: cwd, msgs: msgs) + end + end + + def show_help(out) + out.header('Chat Commands') + out.detail({ + '/help' => 'Show this help', + '/quit' => 'Exit chat', + '/cost' => 'Show per-model cost breakdown and session stats', + '/status' => 'Detailed session status (model, tokens, context, permissions)', + '/compact [STRATEGY]' => 'Compress history (auto, dedup, summarize)', + '/context' => 'Show context window stats', + '/clear' => 'Clear conversation history', + '/new' => 'Start new conversation (same session)', + '/copy' => 'Copy last response to clipboard', + '/diff' => 'Show git diff of working directory', + '/save NAME' => 'Save session to disk', + '/load NAME' => 'Load a saved session', + '/fetch URL' => 'Fetch a web page into context', + '/search QUERY' => 'Web search and inject results into context', + '/rewind [N|FILE]' => 'Undo file edits (last, N steps, or specific file)', + '/memory [add TEXT]' => 'View or add persistent memory', + '/agent TASK' => 'Spawn a background subagent', + '/agents' => 'Show running subagents', + '/plan' => 'Toggle plan mode (read-only)', + '/review [SCOPE]' => 'Code review (staged, uncommitted, or branch)', + '/permissions [MODE]' => 'View or switch permission mode (interactive, auto_approve, read_only)', + '/personality [STYLE]' => 'Set communication style (concise, verbose, educational)', + '/style [list|set|show]' => 'Manage output styles from .legionio/output-styles/', + '/swarm NAME|PROMPT' => 'Run a swarm workflow or auto-generate one', + '/sessions' => 'List saved sessions', + '/model X' => 'Switch model', + '/edit' => 'Open $EDITOR for long prompts', + '/commit' => 'Generate AI commit message and commit staged changes', + '/workers' => 'List digital workers from running daemon', + '/dream' => 'Trigger dream cycle on running daemon' + }) + puts + puts out.dim(' End a line with \\ for multiline input. !command runs a shell command inline.') + puts out.dim(' Sessions auto-saved on exit.') + end + + def handle_compact(strategy_arg, out) + require 'legion/cli/chat/context_manager' + strategy = (strategy_arg || 'auto').to_sym + before = Chat::ContextManager.stats(@session) + + result = Chat::ContextManager.compact(@session, strategy: strategy) + unless result[:compacted] + out.warn("Compact: #{result[:reason]}") + return + end + + after = Chat::ContextManager.stats(@session) + chat_log.info "compact strategy=#{strategy} before=#{before[:message_count]} after=#{after[:message_count]}" + + steps = result[:steps]&.map { |s| "#{s[:action]}(#{s[:removed] || s[:method]})" }&.join(', ') + detail = steps ? " [#{steps}]" : '' + out.success("Compacted #{before[:message_count]} -> #{after[:message_count]} messages#{detail}") + rescue StandardError => e + out.error("Compact failed: #{e.message}") + end + + def handle_context_stats(out) + require 'legion/cli/chat/context_manager' + stats = Chat::ContextManager.stats(@session) + out.header('Context Window') + out.detail({ + 'Messages' => stats[:message_count].to_s, + 'Estimated tokens' => format('%s', tokens: stats[:estimated_tokens].to_s.gsub(/(\d)(?=(\d{3})+$)/, '\1,')), + 'Characters' => format('%s', chars: stats[:char_count].to_s.gsub(/(\d)(?=(\d{3})+$)/, '\1,')), + 'By role' => stats[:by_role].map { |r, c| "#{r}: #{c}" }.join(', '), + 'Auto-compact at' => "#{Chat::ContextManager::COMPACT_THRESHOLD} messages", + 'Should compact?' => Chat::ContextManager.should_auto_compact?(@session) ? 'yes' : 'no' + }) + end + + def handle_fetch(url, out) + unless url && !url.strip.empty? + out.error('Usage: /fetch ') + return + end + + require 'legion/cli/chat/web_fetch' + out.header("Fetching #{url}...") + content = Chat::WebFetch.fetch(url.strip) + chat_log.info "web_fetch url=#{url} length=#{content.length}" + + @session.chat.add_message(role: :user, content: "Content from #{url}:\n\n#{content}") + out.success("Fetched #{content.length} chars into context. Ask questions about it.") + rescue Chat::WebFetch::FetchError => e + chat_log.warn "web_fetch_error url=#{url} error=#{e.message}" + out.error("Fetch failed: #{e.message}") + end + + def handle_memory(arg, out) + require 'legion/cli/chat/memory_store' + if arg&.start_with?('add ') + text = arg.sub('add ', '').strip + if text.empty? + out.error('Usage: /memory add ') + return + end + path = Chat::MemoryStore.add(text) + chat_log.info "memory_add length=#{text.length}" + out.success("Saved to project memory (#{path})") + else + entries = Chat::MemoryStore.list + global_entries = Chat::MemoryStore.list(scope: :global) + if entries.empty? && global_entries.empty? + out.warn('No memory entries. Use /memory add to save something.') + return + end + unless global_entries.empty? + puts out.dim(' Global:') + global_entries.each { |e| puts " - #{e}" } + end + unless entries.empty? + puts out.dim(' Project:') + entries.each { |e| puts " - #{e}" } + end + end + end + + def handle_search(query, out) + unless query && !query.strip.empty? + out.error('Usage: /search ') + return + end + + require 'legion/cli/chat/web_search' + out.header("Searching: #{query}...") + results = Chat::WebSearch.search(query.strip) + chat_log.info "web_search query=#{query} results=#{results[:results].length}" + + summary = results[:results].map { |r| "- [#{r[:title]}](#{r[:url]})\n #{r[:snippet]}" }.join("\n\n") + context = "Web search results for '#{query}':\n\n#{summary}" + + context += "\n\n---\n\nTop result content:\n\n#{results[:fetched_content]}" if results[:fetched_content] + + @session.chat.add_message(role: :user, content: context) + out.success("#{results[:results].length} results injected into context.") + rescue Chat::WebSearch::SearchError => e + chat_log.warn "web_search_error query=#{query} error=#{e.message}" + out.error("Search failed: #{e.message}") + end + + def handle_swarm(arg, out) + unless arg && !arg.strip.empty? + out.error('Usage: /swarm or /swarm ') + return + end + + workflow_path = File.join(Dir.pwd, '.legion/swarms', "#{arg.strip}.json") + if File.exist?(workflow_path) + chat_log.info "swarm_start workflow=#{arg.strip}" + out.header("Starting swarm: #{arg.strip}") + Thread.new do + Legion::CLI::Swarm.new.invoke(:start, [arg.strip]) + rescue StandardError => e + puts out.dim("\n [swarm] Error: #{e.message}") + end + out.success('Swarm running in background. Results will appear when done.') + else + chat_log.info "swarm_generate prompt_length=#{arg.length}" + out.warn("No workflow file found for '#{arg.strip}'. Auto-generation from prompt is planned but not yet implemented.") + out.dim(" Create a workflow file at: #{workflow_path}") + end + end + + def handle_plan_toggle(out) + @plan_mode = !@plan_mode + if @plan_mode + # Keep only read-tier tools (both builtin and extension) + read_only_tools = @session.chat.instance_variable_get(:@tools)&.select do |t| + t.is_a?(Class) && Chat::Permissions.tier_for(t) == :read + end + @saved_tools = @session.chat.instance_variable_get(:@tools) + @session.chat.instance_variable_set(:@tools, read_only_tools || []) + chat_log.info 'plan_mode enabled' + out.success('Plan mode ON — read-only (no writes, edits, or commands)') + else + @session.chat.instance_variable_set(:@tools, @saved_tools) if @saved_tools + @saved_tools = nil + chat_log.info 'plan_mode disabled' + out.success('Plan mode OFF — all tools available') + end + end + + def handle_agent(task, out) + unless task && !task.strip.empty? + out.error('Usage: /agent ') + return + end + + require 'legion/cli/chat/subagent' + result = Chat::Subagent.spawn( + task: task.strip, + model: @session.model_id, + on_complete: lambda { |id, res| + output = res[:output] || res[:error] || 'No output' + @session.chat.add_message( + role: :user, + content: "Subagent #{id} result:\n\n#{output}" + ) + puts out.dim("\n [subagent #{id}] Complete. Results added to context.") + print prompt_string + } + ) + + if result[:error] + out.error(result[:error]) + else + chat_log.info "subagent_spawn id=#{result[:id]} task_length=#{task.length}" + out.success("Subagent #{result[:id]} started. Results will appear when done.") + end + end + + def handle_agents_status(out) + require 'legion/cli/chat/subagent' + agents = Chat::Subagent.running + if agents.empty? + out.warn('No subagents running.') + return + end + + out.header("Running Subagents (#{agents.length})") + agents.each do |a| + elapsed = a[:elapsed].round(1) + puts " #{a[:id]} #{elapsed}s #{a[:task][0..60]}" + end + end + + def handle_at_mention(input, out) + require 'legion/cli/chat/agent_delegator' + parsed = Chat::AgentDelegator.parse(input) + return false unless parsed + + Chat::AgentDelegator.dispatch( + agent_name: parsed[:agent_name], + task: parsed[:task], + session: @session, + out: out, + chat_log: chat_log + ) + true + end + + def load_custom_agents + require 'legion/cli/chat/agent_registry' + agents = Chat::AgentRegistry.load_agents + return if agents.empty? + + names = agents.keys.join(', ') + @session.chat.add_message( + role: :user, + content: "Available custom agents: #{names}. Use @name to delegate tasks to them." + ) + end + + def load_memory_context + require 'legion/cli/chat/memory_store' + parts = [] + + context = Chat::MemoryStore.load_context + parts << context if context + + require 'legion/cli/chat/team_memory' + team_context = Chat::TeamMemory.load_context + parts << team_context if team_context + + return if parts.empty? + + @session.chat.add_message( + role: :user, + content: "The following is persistent memory from previous sessions:\n\n#{parts.join("\n\n---\n\n")}\n\nUse this context as needed." + ) + end + + def handle_rewind(arg, out) + if @worktree_path + handle_worktree_rewind(arg, out) + return + end + + require 'legion/cli/chat/checkpoint' + if Chat::Checkpoint.entries.none? + out.warn('No checkpoints available to rewind.') + return + end + + if arg.nil? || arg.strip.empty? + restored = Chat::Checkpoint.rewind(1) + elsif arg.strip.match?(/\A\d+\z/) + restored = Chat::Checkpoint.rewind(arg.strip.to_i) + else + entry = Chat::Checkpoint.rewind_file(arg.strip) + restored = entry ? [entry] : [] + end + + if restored.empty? + out.warn('Nothing to rewind.') + else + restored.each do |e| + label = e.existed ? 'restored' : 'deleted (was new)' + puts out.dim(" #{File.basename(e.path)}: #{label}") + end + chat_log.info "rewind count=#{restored.length}" + out.success("Rewound #{restored.length} edit(s)") + end + end + + def handle_bang_command(command, out) + command = command.strip + if command.empty? + out.error('Usage: ! (e.g., !ls, !git status)') + return + end + + chat_log.debug "bang_command: #{command}" + puts out.dim(" $ #{command}") + output = `#{command} 2>&1` + status = $CHILD_STATUS&.exitstatus || 0 + puts output unless output.empty? + puts out.dim(" [exit #{status}]") + + @session.chat.add_message( + role: :user, + content: "Shell command: #{command}\nExit code: #{status}\n\n#{output}" + ) + rescue StandardError => e + out.error("Command failed: #{e.message}") + end + + def handle_copy(out) + messages = @session.chat.messages + last_assistant = messages.reverse.find do |m| + m[:role] == :assistant || m.role == :assistant + rescue StandardError => e + Legion::Logging.debug("ChatCommand#handle_copy message role check failed: #{e.message}") if defined?(Legion::Logging) + false + end + unless last_assistant + out.warn('No assistant response to copy.') + return + end + + content = last_assistant.respond_to?(:content) ? last_assistant.content : last_assistant[:content] + IO.popen('pbcopy', 'w') { |io| io.write(content) } + chat_log.info "copy length=#{content.length}" + out.success("Copied #{content.length} chars to clipboard") + rescue Errno::ENOENT => e + Legion::Logging.debug("ChatCommand#handle_copy pbcopy not available: #{e.message}") if defined?(Legion::Logging) + out.error('pbcopy not available (macOS only). Use terminal selection instead.') + rescue StandardError => e + out.error("Copy failed: #{e.message}") + end + + def handle_diff(out) + diff = `git diff 2>/dev/null` + untracked = `git ls-files --others --exclude-standard 2>/dev/null`.strip + + if diff.empty? && untracked.empty? + out.warn('No changes detected.') + return + end + + puts render_response("```diff\n#{diff}```", out) unless diff.empty? + + return if untracked.empty? + + puts out.dim("\n Untracked files:") + untracked.each_line { |f| puts out.dim(" #{f.strip}") } + end + + def handle_permissions(mode, out) + require 'legion/cli/chat/permissions' + unless mode + current = Chat::Permissions.mode + puts " Current mode: #{current}" + puts out.dim(' Available: interactive, auto_approve, read_only') + return + end + + sym = mode.strip.to_sym + valid = %i[interactive auto_approve read_only] + unless valid.include?(sym) + out.error("Invalid mode: #{mode}. Choose: #{valid.join(', ')}") + return + end + + Chat::Permissions.mode = sym + chat_log.info "permissions_switch to=#{sym}" + out.success("Permission mode: #{sym}") + end + + def handle_review_in_session(scope, out) + scope = (scope || '').strip + diff = case scope + when 'staged' then `git diff --staged 2>/dev/null` + when 'branch' then `git diff main...HEAD 2>/dev/null` + when '', 'uncommitted' then `git diff 2>/dev/null` + else + out.error('Usage: /review [staged|uncommitted|branch]') + return + end + + if diff.empty? + out.warn("No #{scope.empty? ? 'uncommitted' : scope} changes to review.") + return + end + + diff = diff[0..12_000] if diff.length > 12_000 + + chat_log.info "review_in_session scope=#{scope.empty? ? 'uncommitted' : scope} diff_length=#{diff.length}" + out.header('Reviewing changes...') + + prompt = <<~PROMPT + Review the following code diff. For each finding, prefix with severity: + CRITICAL: bugs, security vulnerabilities, data loss risks + WARNING: logic errors, performance issues, bad practices + SUGGESTION: style improvements, refactoring opportunities + NOTE: observations, questions + + End with a one-line SUMMARY. + + ```diff + #{diff} + ``` + PROMPT + + print out.colorize('legion', :title) + print out.dim(' > ') + buffer = String.new + @session.send_message(prompt) { |chunk| buffer << chunk.content if chunk.content } + print render_response(buffer, out) + puts + puts + rescue StandardError => e + out.error("Review failed: #{e.message}") + end + + def handle_status(out) + require 'legion/cli/chat/permissions' + s = @session.stats + elapsed = @session.elapsed.round(1) + msgs = @session.chat.messages + + details = { + 'Model' => @session.model_id, + 'Duration' => "#{elapsed}s", + 'Messages' => "#{s[:messages_sent]} sent, #{s[:messages_received]} received (#{msgs.length} in context)", + 'Permissions' => Chat::Permissions.mode.to_s, + 'Plan mode' => @plan_mode ? 'ON' : 'OFF' + } + details['Input tokens'] = s[:input_tokens].to_s if s[:input_tokens] + details['Output tokens'] = s[:output_tokens].to_s if s[:output_tokens] + cost = @session.estimated_cost + details['Est. cost'] = format('$%.4f', cost) if cost.positive? + details['Personality'] = @personality || 'default' + details['Directories'] = ([@work_dir || Dir.pwd] + (@extra_dirs || [])).join(', ') + + out.header('Session Status') + out.detail(details) + end + + def handle_new_conversation(out) + auto_save_session(out) + @session.chat.reset_messages! + @auto_saved = false + @session_name = nil + @session.stats[:messages_sent] = 0 + @session.stats[:messages_received] = 0 + @session.stats[:started_at] = Time.now + @session.stats[:input_tokens] = 0 + @session.stats[:output_tokens] = 0 + + system_prompt = build_system_prompt + @session.chat.with_instructions(system_prompt) + load_memory_context + + chat_log.info 'new_conversation' + out.success('New conversation started (previous session saved)') + end + + def handle_personality(style, out) + unless style + puts " Current: #{@personality || 'default'}" + puts out.dim(' Available: concise, verbose, educational, default') + return + end + + valid = %w[concise verbose educational default] + style = style.strip.downcase + unless valid.include?(style) + out.error("Invalid style: #{style}. Choose: #{valid.join(', ')}") + return + end + + @personality = style == 'default' ? nil : style + instructions = { + 'concise' => 'Be extremely concise. Short answers, minimal explanation. Code over prose.', + 'verbose' => 'Be thorough and detailed. Explain your reasoning step by step.', + 'educational' => 'Be educational. Explain concepts, provide context, teach as you help.' + } + instruction = instructions[@personality] + + @session.chat.add_message(role: :user, content: "Style instruction: #{instruction}") if instruction + + chat_log.info "personality_switch to=#{style}" + out.success("Personality: #{style}") + end + + def handle_style(args, out) + require 'legion/cli/chat/output_styles' + subcmd = args.first + + case subcmd + when 'list', nil + styles = Chat::OutputStyles.discover + if styles.empty? + puts ' No output styles found. Create .md files in .legionio/output-styles/ or ~/.legionio/output-styles/' + return + end + styles.each do |s| + active = s[:active] ? '*' : ' ' + puts " #{active} #{s[:name]} — #{s[:description]}" + end + when 'show' + name = args[1] + unless name + out.error('Usage: /style show ') + return + end + style = Chat::OutputStyles.find(name) + if style + puts " Name: #{style[:name]}" + puts " Description: #{style[:description]}" + puts " Active: #{style[:active]}" + puts " Path: #{style[:path]}" + puts "\n#{style[:content]}" + else + out.error("Style '#{name}' not found") + end + when 'set' + name = args[1] + unless name + out.error('Usage: /style set ') + return + end + if Chat::OutputStyles.activate(name) + instruction = Chat::OutputStyles.find(name)&.dig(:content) + @session.chat.add_message(role: :user, content: "Style instruction: #{instruction}") if instruction + out.success("Output style set to: #{name}") + else + out.error("Style '#{name}' not found") + end + else + out.error("Unknown /style subcommand: #{subcmd}. Use: list, show, set") + end + end + + def show_session_stats(out) + s = @session.stats + elapsed = @session.elapsed.round(1) + breakdown = @session.cost_breakdown + + if breakdown.any? + out.header('Session Cost Summary') + rows = breakdown.map do |entry| + [entry[:model], + format_number(entry[:input_tokens]), + format_number(entry[:output_tokens]), + format('$%.4f', entry[:cost])] + end + total_cost = @session.estimated_cost + rows << ['Total', + format_number(s[:input_tokens] || 0), + format_number(s[:output_tokens] || 0), + format('$%.4f', total_cost)] + out.table(['Model', 'Input Tokens', 'Output Tokens', 'Cost'], rows) + end + + cache = @session.cache_hits_tokens + puts " Cache hits: #{format_number(cache)} tokens saved" if cache.positive? + + duration_min = (elapsed / 60.0).round(1) + label = duration_min >= 1.0 ? "#{duration_min} minutes" : "#{elapsed}s" + puts " Session duration: #{label}" + puts " Messages: #{s[:messages_sent]} sent, #{s[:messages_received]} received" + end + + def format_number(num) + num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse + end + + def restore_session(out) + require 'legion/cli/chat/session_store' + if options[:continue] || options[:resume_latest] + name = Chat::SessionStore.latest + @session_name = name + elsif options[:resume] + name = options[:resume] + @session_name = name + elsif options[:fork] + name = options[:fork] + @session_name = nil # fork: save as new on exit + end + + data = Chat::SessionStore.load(name) + Chat::SessionStore.restore(@session, data) + msg_count = data[:messages]&.length || 0 + cwd_info = data[:cwd] ? " from #{abbreviate_path(data[:cwd])}" : '' + label = options[:fork] ? 'Forked from' : 'Resumed' + out.success("#{label} session: #{name}#{cwd_info} (#{msg_count} messages)") + + if data[:recovery_state] && data[:recovery_state] != :none + out.warn("Session was interrupted (#{data[:recovery_state]}). Auto-recovering.") + @recovery_message = data[:recovery_message] + end + + chat_log.info "session_restore name=#{name} messages=#{msg_count} recovery=#{data[:recovery_state]} mode=#{options[:fork] ? 'fork' : 'resume'}" + end + + def send_recovery_message(out) + return unless @recovery_message + + chat_log.info "sending recovery message: #{@recovery_message}" + buffer = String.new + @session.send_message(@recovery_message) { |chunk| buffer << chunk.content if chunk.content } + print render_response(buffer, out) + puts + puts + @recovery_message = nil + rescue StandardError => e + chat_log.warn "recovery message failed: #{e.message}" + end + + def abbreviate_path(path) + home = Dir.home + path.start_with?(home) ? path.sub(home, '~') : path + end + + def auto_save_session(out) + return if @auto_saved + return if incognito? + return unless @session + return if @session.stats[:messages_sent].zero? + + @auto_saved = true + require 'legion/cli/chat/session_store' + name = @session_name || "auto-#{Time.now.strftime('%Y%m%d-%H%M%S')}" + path = Chat::SessionStore.save(@session, name) + chat_log.info "auto_save name=#{name} path=#{path}" + out&.dim(" Session saved: #{name}")&.then { |msg| puts msg } + rescue StandardError => e + chat_log&.error("auto_save_failed: #{e.message}") + end + + def handle_commit_in_chat(out) + require 'open3' + stdout, _stderr, _status = Open3.capture3('git', 'diff', '--cached', '--stat') + if stdout.strip.empty? + out.warn('No staged changes. Stage files with `git add` first.') + return + end + out.header('Staged Changes') + puts stdout + out.info('Generating commit message...') + diff_output, = Open3.capture3('git', 'diff', '--cached') + prompt = 'Generate a concise git commit message (lowercase, imperative mood, 1-2 sentences) ' \ + "for these staged changes:\n\n```diff\n#{diff_output[0..4000]}\n```\n\n" \ + 'Respond with ONLY the commit message, nothing else.' + response = @session.send_message(prompt) + msg = response.content.strip.gsub(/\A["'`]+|["'`]+\z/, '') + out.spacer + puts " #{msg}" + out.spacer + print ' Commit with this message? [y/N] ' + if $stdin.gets&.strip&.downcase == 'y' + system('git', 'commit', '-m', msg) + out.success('Committed!') + else + out.info('Cancelled.') + end + rescue StandardError => e + out.error("Commit failed: #{e.message}") + end + + def handle_workers_in_chat(out) + require 'net/http' + require 'json' + port = api_port_for_chat + uri = URI("http://localhost:#{port}/api/workers") + response = Net::HTTP.get_response(uri) + parsed = ::JSON.parse(response.body, symbolize_names: true) + workers = parsed[:data] || [] + if workers.empty? + out.info('No digital workers registered.') + return + end + out.header("Digital Workers (#{workers.size})") + rows = workers.map do |w| + [w[:worker_id].to_s[0..7], w[:name], w[:lifecycle_state], w[:consent_tier], w[:team] || '-'] + end + out.table(%w[ID Name State Consent Team], rows) + rescue Errno::ECONNREFUSED => e + Legion::Logging.debug("ChatCommand#handle_workers_in_chat daemon not running: #{e.message}") if defined?(Legion::Logging) + out.warn('Daemon not running. Use `legion worker list` from another terminal.') + rescue StandardError => e + Legion::Logging.warn("ChatCommand#handle_workers_in_chat failed: #{e.message}") if defined?(Legion::Logging) + out.error("Failed to fetch workers: #{e.message}") + end + + def handle_dream_in_chat(out) + require 'net/http' + require 'json' + port = api_port_for_chat + uri = URI("http://localhost:#{port}/api/tasks") + body = ::JSON.generate({ + runner_class: 'Legion::Extensions::Dream::Runners::DreamCycle', + function: 'execute_dream_cycle', + async: true, + check_subtask: false, + generate_task: false + }) + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 5 + http.read_timeout = 5 + request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + request.body = body + response = http.request(request) + if response.is_a?(Net::HTTPSuccess) + out.success('Dream cycle triggered on daemon') + else + out.error("Dream cycle failed: #{response.code}") + end + rescue Errno::ECONNREFUSED => e + Legion::Logging.debug("ChatCommand#handle_dream_in_chat daemon not running: #{e.message}") if defined?(Legion::Logging) + out.warn('Daemon not running. Use `legion dream` from another terminal.') + rescue StandardError => e + Legion::Logging.warn("ChatCommand#handle_dream_in_chat failed: #{e.message}") if defined?(Legion::Logging) + out.error("Dream failed: #{e.message}") + end + + def handle_skill(skill, args_text, out) + out.info("Running skill: #{skill[:name]}") + user_input = args_text || '' + system_prompt = skill[:prompt] + + @session.chat.ask(user_input) do |msg| + msg.system_prompt = system_prompt if system_prompt && msg.respond_to?(:system_prompt=) + end + rescue StandardError => e + out.error("Skill error: #{e.message}") + end + + def api_port_for_chat + 4567 + end + + def setup_worktree(out) + require 'legion/extensions/exec/helpers/worktree' + @worktree_task_id = "chat-#{SecureRandom.hex(4)}" + @checkpoint_count = 0 + wt = Legion::Extensions::Exec::Helpers::Worktree.create(task_id: @worktree_task_id) + if wt[:success] + @worktree_path = wt[:path] + Dir.chdir(@worktree_path) + out.success("Worktree created: #{@worktree_path} (branch: #{wt[:branch]})") + chat_log.info "worktree created path=#{@worktree_path} branch=#{wt[:branch]}" + else + out.warn("Worktree creation failed: #{wt[:reason]}. Continuing without worktree.") + chat_log.warn "worktree creation failed: #{wt.inspect}" + end + rescue LoadError => e + Legion::Logging.debug("ChatCommand#setup_worktree lex-exec not available: #{e.message}") if defined?(Legion::Logging) + out.warn('lex-exec not available. --worktree requires lex-exec. Continuing without worktree.') + end + + def worktree_auto_checkpoint + return unless @worktree_path + + require 'legion/extensions/exec/helpers/checkpoint' + @checkpoint_count += 1 + Legion::Extensions::Exec::Helpers::Checkpoint.save( + worktree_path: @worktree_path, + label: "step-#{@checkpoint_count}", + task_id: @worktree_task_id + ) + rescue StandardError => e + chat_log.debug "worktree checkpoint failed: #{e.message}" + end + + def handle_worktree_rewind(arg, out) + require 'legion/extensions/exec/helpers/checkpoint' + list = Legion::Extensions::Exec::Helpers::Checkpoint.list_checkpoints(task_id: @worktree_task_id) + if list[:checkpoints].empty? + out.warn('No worktree checkpoints available.') + return + end + + label = if arg.nil? || arg.strip.empty? + list[:checkpoints].last[:label] + elsif arg.strip.match?(/\A\d+\z/) + target = [list[:checkpoints].size - arg.strip.to_i, 0].max + list[:checkpoints][target][:label] + else + arg.strip + end + + result = Legion::Extensions::Exec::Helpers::Checkpoint.restore( + worktree_path: @worktree_path, label: label, task_id: @worktree_task_id + ) + if result[:success] + chat_log.info "worktree rewind to #{label}" + out.success("Restored to checkpoint: #{label}") + else + out.warn("Rewind failed: #{result[:message]}") + end + end + + def cleanup_worktree(out) + require 'legion/extensions/exec/helpers/worktree' + require 'legion/extensions/exec/helpers/checkpoint' + + checkpoints = Legion::Extensions::Exec::Helpers::Checkpoint.list_checkpoints(task_id: @worktree_task_id) + if checkpoints[:checkpoints].any? + out.info("Worktree has #{checkpoints[:checkpoints].size} checkpoint(s). Branch: legion/#{@worktree_task_id}") + out.info('Worktree preserved. Merge manually or run: git worktree remove ') + else + Legion::Extensions::Exec::Helpers::Worktree.remove(task_id: @worktree_task_id) + out.info('Worktree removed (no checkpoints).') + end + rescue StandardError => e + chat_log.warn "worktree cleanup error: #{e.message}" + end + end + end + end +end diff --git a/lib/legion/cli/check/privacy_check.rb b/lib/legion/cli/check/privacy_check.rb new file mode 100644 index 00000000..c3eb41a9 --- /dev/null +++ b/lib/legion/cli/check/privacy_check.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Legion + module CLI + module Check + class PrivacyCheck + CLOUD_PROVIDERS = %i[bedrock anthropic openai gemini azure].freeze + + def run + @results = {} + @results[:flag_set] = check_flag_set + @results[:no_cloud_keys] = check_no_cloud_keys + @results[:no_external_endpoints] = check_no_external_endpoints + @results + end + + def overall_pass? + run.values.all? { |v| v == :pass } + end + + private + + def check_flag_set + if settings_loaded? && Legion::Settings.enterprise_privacy? + :pass + else + :fail + end + end + + def check_no_cloud_keys + llm = Legion::Settings[:llm] + return :pass unless llm.is_a?(Hash) + + providers = (llm[:providers] || llm['providers'] || {}).transform_keys(&:to_sym) + CLOUD_PROVIDERS.each do |provider| + cfg = providers[provider] + return :fail if raw_credential?(cfg) + end + + :pass + rescue StandardError => e + Legion::Logging.warn("PrivacyCheck#check_no_cloud_keys failed: #{e.message}") if defined?(Legion::Logging) + :skip + end + + def raw_credential?(cfg) + return false unless cfg.is_a?(Hash) + + key = cfg[:api_key] || cfg['api_key'] || + cfg[:bearer_token] || cfg['bearer_token'] || + cfg[:secret_key] || cfg['secret_key'] + + key.is_a?(String) && !key.empty? && !key.start_with?('env://', 'vault://') + end + + def check_no_external_endpoints + endpoints = [ + ['api.anthropic.com', 443], + ['api.openai.com', 443], + ['generativelanguage.googleapis.com', 443] + ] + endpoints.each do |host, port| + return :fail if tcp_reachable?(host, port) + end + :pass + rescue StandardError => e + Legion::Logging.warn("PrivacyCheck#check_no_external_endpoints failed: #{e.message}") if defined?(Legion::Logging) + :skip + end + + def tcp_reachable?(host, port) + socket = ::TCPSocket.new(host, port) + socket.close + true + rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, Errno::ENETUNREACH + false + end + + def settings_loaded? + defined?(Legion::Settings) && Legion::Settings.respond_to?(:enterprise_privacy?) + rescue StandardError => e + Legion::Logging.debug("PrivacyCheck#settings_loaded? failed: #{e.message}") if defined?(Legion::Logging) + false + end + end + end + end +end diff --git a/lib/legion/cli/check_command.rb b/lib/legion/cli/check_command.rb new file mode 100644 index 00000000..9fa0f0b8 --- /dev/null +++ b/lib/legion/cli/check_command.rb @@ -0,0 +1,382 @@ +# frozen_string_literal: true + +module Legion + module CLI + module Check + CHECKS = %i[settings crypt transport cache cache_local data data_local].freeze + EXTENSION_CHECKS = %i[extensions].freeze + FULL_CHECKS = %i[api].freeze + + CHECK_LABELS = { + settings: 'Legion::Settings', + crypt: 'Legion::Crypt', + transport: 'Legion::Transport', + cache: 'Legion::Cache', + cache_local: 'Legion::Cache::Local', + data: 'Legion::Data', + data_local: 'Legion::Data::Local', + extensions: 'Legion::Extensions', + api: 'Legion::API' + }.freeze + + # Dependencies: if a check fails, these dependents are skipped + DEPENDS_ON = { + crypt: :settings, + transport: :settings, + cache: :settings, + cache_local: :cache, + data: :settings, + data_local: :data, + extensions: :transport, + api: :transport + }.freeze + + autoload :PrivacyCheck, 'legion/cli/check/privacy_check' + + PROBE_LABELS = { + flag_set: 'Privacy flag set', + no_cloud_keys: 'No cloud API keys configured', + no_external_endpoints: 'External endpoints unreachable' + }.freeze + + class << self + def run_privacy(formatter, options) + require 'legion/settings' + dir = Connection.send(:resolve_config_dir) + Legion::Settings.load(config_dir: dir) + + checker = PrivacyCheck.new + results = checker.run + + if options[:json] + formatter.json({ results: results, overall: checker.overall_pass? ? 'pass' : 'fail' }) + return checker.overall_pass? ? 0 : 1 + end + + formatter.header('Enterprise Privacy Mode Check') + formatter.spacer + + results.each do |probe, status| + label = PROBE_LABELS.fetch(probe, probe.to_s).ljust(36) + case status + when :pass + puts " #{label}#{formatter.colorize('pass', :green)}" + when :fail + puts " #{label}#{formatter.colorize('FAIL', :red)}" + when :skip + puts " #{label}#{formatter.colorize('skip', :yellow)}" + end + end + + formatter.spacer + if checker.overall_pass? + formatter.success('Privacy mode fully engaged') + else + formatter.error('Privacy mode check failed — see items above') + end + + checker.overall_pass? ? 0 : 1 + end + + def run(formatter, options) + level = if options[:full] + :full + elsif options[:extensions] + :extensions + else + :connections + end + + checks = CHECKS.dup + checks.concat(EXTENSION_CHECKS) if %i[extensions full].include?(level) + checks.concat(FULL_CHECKS) if level == :full + + results = {} + started = [] + + log_level = options[:verbose] ? 'debug' : 'error' + setup_logging(log_level) + + checks.each do |name| + dep = DEPENDS_ON[name] + if dep && results[dep] && %w[fail skip].include?(results[dep][:status]) + results[name] = { status: 'skip', error: "#{dep} failed" } + print_result(formatter, name, results[name], options) unless options[:json] + next + end + + results[name] = run_check(name, options) + started << name if results[name][:status] == 'pass' + resolve_secrets_after_crypt(name, results[name]) + print_result(formatter, name, results[name], options) unless options[:json] + end + + shutdown(started) + print_summary(formatter, results, level, options) + + results.values.any? { |r| r[:status] == 'fail' } ? 1 : 0 + end + + private + + def setup_logging(log_level) + require 'legion/logging' + Legion::Logging.setup(log_level: log_level, level: log_level, trace: false) + end + + def resolve_secrets_after_crypt(name, result) + return unless name == :crypt && result[:status] == 'pass' + return unless Legion::Settings.respond_to?(:resolve_secrets!) + + Legion::Settings.resolve_secrets! + rescue StandardError => e + Legion::Logging.warn("Check#run secret resolution failed: #{e.message}") if defined?(Legion::Logging) + end + + def run_check(name, options) + start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + detail = send(:"check_#{name}", options) + elapsed = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start).round(2) + { status: 'pass', time: elapsed, detail: detail } + rescue StandardError, LoadError => e + elapsed = (::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start).round(2) + { status: 'fail', error: e.message, time: elapsed } + end + + def check_settings(_options) + require 'legion/settings' + dir = Connection.send(:resolve_config_dir) + Legion::Settings.load(config_dir: dir) + dir || Legion::Settings.instance_variable_get(:@config_dir) || '(default)' + end + + def check_crypt(_options) + require 'legion/crypt' + Legion::Crypt.start + vault_addr = ENV.fetch('VAULT_ADDR', nil) + connected = defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:vault_connected?) && Legion::Crypt.vault_connected? + connected ? "Vault #{vault_addr || 'connected'}" : 'no Vault' + end + + def check_transport(_options) + require 'legion/transport' + Legion::Settings.merge_settings('transport', Legion::Transport::Settings.default) + conn = Legion::Settings[:transport][:connection] || {} + user = conn[:user].to_s + pass = conn[:password].to_s + if user.start_with?('lease://', 'vault://') || pass.start_with?('lease://', 'vault://') + scheme = user[%r{\A[^:]+://}] + redacted = scheme ? "#{scheme}..." : '(unresolved)' + raise "credentials not resolved (Vault lease pending) — user: #{redacted}" + end + + Legion::Transport::Connection.setup + if Legion::Transport::Connection.lite_mode? + 'InProcess (lite mode)' + else + ts = Legion::Settings[:transport] || {} + host = ts.dig(:connection, :host) || '127.0.0.1' + port = ts.dig(:connection, :port) || 5672 + vhost = ts.dig(:connection, :vhost) || '/' + user = ts.dig(:connection, :user) || 'guest' + "amqp://#{user}@#{host}:#{port}#{vhost}" + end + end + + def check_cache(_options) + require 'legion/cache' + if defined?(Legion::Cache) && Legion::Cache.respond_to?(:using_memory?) && Legion::Cache.using_memory? + 'Memory (lite mode)' + else + cs = Legion::Settings[:cache] || {} + driver = cs[:driver] || 'dalli' + servers = Array(cs[:servers] || cs[:server] || ['127.0.0.1']) + "#{driver} -> #{servers.join(', ')}" + end + end + + def check_cache_local(_options) + raise 'Legion::Cache::Local not available' unless defined?(Legion::Cache::Local) && Legion::Cache::Local.respond_to?(:setup) + + Legion::Cache::Local.setup + cs = Legion::Settings[:cache_local] || (Legion::Cache::Settings.respond_to?(:local) ? Legion::Cache::Settings.local : {}) + driver = cs[:driver] || 'dalli' + servers = Array(cs[:servers] || cs[:server] || ['127.0.0.1']) + "#{driver} -> #{servers.join(', ')}" + end + + def check_data(_options) + require 'legion/data' + Legion::Settings.merge_settings(:data, Legion::Data::Settings.default) + creds = Legion::Settings[:data][:creds] || Legion::Settings[:data] || {} + db_user = (creds[:user] || creds[:username]).to_s + db_pass = creds[:password].to_s + raise_if_unresolved_data_creds(db_user, db_pass) + + Legion::Data.setup + ds = Legion::Settings[:data] || {} + adapter = ds[:adapter] || 'sqlite' + if adapter == 'sqlite' + db_path = ds[:database] || 'legion.db' + "sqlite -> #{db_path}" + else + host = ds[:host] || '127.0.0.1' + port = ds[:port] + database = ds[:database] || 'legion' + "#{adapter} -> #{host}#{":#{port}" if port}/#{database}" + end + end + + def raise_if_unresolved_data_creds(db_user, db_pass) + return unless db_user.start_with?('lease://', 'vault://') || db_pass.start_with?('lease://', 'vault://') + + unresolved_fields = [] + unresolved_fields << 'user' if db_user.start_with?('lease://', 'vault://') + unresolved_fields << 'password' if db_pass.start_with?('lease://', 'vault://') + scheme_hints = [] + scheme_hints << 'lease://...' if db_user.start_with?('lease://') || db_pass.start_with?('lease://') + scheme_hints << 'vault://...' if db_user.start_with?('vault://') || db_pass.start_with?('vault://') + details = "unresolved fields: #{unresolved_fields.join(', ')}" + details += " (#{scheme_hints.join(', ')})" unless scheme_hints.empty? + raise "credentials not resolved (Vault lease pending) — #{details}" + end + + def check_data_local(_options) + if defined?(Legion::Data::Local) && Legion::Data::Local.respond_to?(:setup) + Legion::Data::Local.setup unless Legion::Data::Local.respond_to?(:connected?) && Legion::Data::Local.connected? + db_path = Legion::Data::Local.respond_to?(:db_path) ? Legion::Data::Local.db_path : '~/.legionio/local.db' + "sqlite -> #{db_path}" + elsif defined?(Legion::Data) + 'not configured' + else + raise 'Legion::Data not available' + end + end + + def check_extensions(_options) + require 'legion/runner' + Legion::Extensions.hook_extensions + end + + def check_api(_options) + require 'legion/api' + api_settings = Legion::Settings[:api] + port = api_settings[:port] + configured_bind = api_settings[:bind] + bind = %w[127.0.0.1 localhost ::1].include?(configured_bind) ? configured_bind : '127.0.0.1' + + Legion::API.set :port, port + Legion::API.set :bind, bind + Legion::API.set :server, :puma + Legion::API.set :environment, :production + + thread = Thread.new { Legion::API.run! } + + deadline = Time.now + 5 + loop do + break if api_running? + break if Time.now > deadline + + sleep(0.1) + end + + raise 'API server did not start within 5 seconds' unless api_running? + ensure + if defined?(thread) && thread + Legion::API.quit! if defined?(Legion::API) && api_running? + thread.kill + end + end + + def api_running? + defined?(Legion::API) && Legion::API.running? + rescue StandardError => e + Legion::Logging.debug("Check#api_running? failed: #{e.message}") if defined?(Legion::Logging) + false + end + + def shutdown(started) + started.reverse_each do |name| + send(:"shutdown_#{name}") + rescue StandardError => e + Legion::Logging.warn("Check#shutdown failed for #{name}: #{e.message}") if defined?(Legion::Logging) + end + end + + def shutdown_settings; end + + def shutdown_crypt + Legion::Crypt.shutdown + end + + def shutdown_transport + Legion::Transport::Connection.shutdown + end + + def shutdown_cache + Legion::Cache.shutdown + end + + def shutdown_cache_local + Legion::Cache::Local.shutdown if defined?(Legion::Cache::Local) && Legion::Cache::Local.respond_to?(:shutdown) + end + + def shutdown_data + Legion::Data.shutdown + end + + def shutdown_data_local + Legion::Data::Local.shutdown if defined?(Legion::Data::Local) && Legion::Data::Local.respond_to?(:shutdown) + end + + def shutdown_extensions + Legion::Extensions.shutdown + end + + def shutdown_api; end + + def print_result(formatter, name, result, options) + label = CHECK_LABELS.fetch(name, name.to_s).ljust(22) + case result[:status] + when 'pass' + detail = result[:detail] ? " #{formatter.colorize(result[:detail].to_s, :muted)}" : '' + line = " #{label} #{formatter.colorize('pass', :green)}#{detail}" + line += " (#{result[:time]}s)" if options[:verbose] + when 'fail' + line = " #{label} #{formatter.colorize('FAIL', :red)} #{result[:error]}" + line += " (#{result[:time]}s)" if options[:verbose] + when 'skip' + line = " #{label} #{formatter.colorize('skip', :yellow)} #{result[:error]}" + end + puts line + end + + def print_summary(formatter, results, level, options) + passed = results.values.count { |r| r[:status] == 'pass' } + failed = results.values.count { |r| r[:status] == 'fail' } + skipped = results.values.count { |r| r[:status] == 'skip' } + total = results.size + + if options[:json] + formatter.json({ + results: results.transform_values(&:compact), + summary: { passed: passed, failed: failed, skipped: skipped, level: level.to_s } + }) + else + formatter.spacer + failed_names = results.select { |_, v| v[:status] == 'fail' }.keys.join(', ') + msg = "#{passed}/#{total} passed" + msg += " (#{failed_names} failed)" if failed.positive? + msg += " (#{skipped} skipped)" if skipped.positive? + + if failed.positive? + formatter.error(msg) + else + formatter.success(msg) + end + end + end + end + end + end +end diff --git a/lib/legion/cli/codegen_command.rb b/lib/legion/cli/codegen_command.rb new file mode 100644 index 00000000..eeb1d4ac --- /dev/null +++ b/lib/legion/cli/codegen_command.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'api_client' + +module Legion + module CLI + class CodegenCommand < Thor + namespace :codegen + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'status', 'Show codegen cycle stats, pending gaps, registry counts' + def status + data = api_get('/api/codegen/status') + formatter.json(data) + end + + desc 'list', 'List generated functions' + method_option :status, type: :string, desc: 'Filter by status' + def list + path = '/api/codegen/generated' + path += "?status=#{options[:status]}" if options[:status] + data = api_get(path) + formatter.json(data) + end + + desc 'show ID', 'Show details of a generated function' + def show(id) + data = api_get("/api/codegen/generated/#{id}") + formatter.json(data) + end + + desc 'approve ID', 'Manually approve a parked generated function' + def approve(id) + data = api_post("/api/codegen/generated/#{id}/approve") + formatter.json(data) + end + + desc 'reject ID', 'Manually reject a generated function' + def reject(id) + data = api_post("/api/codegen/generated/#{id}/reject") + formatter.json(data) + end + + desc 'retry ID', 'Re-queue a generated function for regeneration' + def retry_generation(id) + data = api_post("/api/codegen/generated/#{id}/retry") + formatter.json(data) + end + map 'retry' => :retry_generation + + desc 'gaps', 'List detected capability gaps with priorities' + def gaps + data = api_get('/api/codegen/gaps') + formatter.json(data) + end + + desc 'cycle', 'Manually trigger a generation cycle (bypass cooldown)' + def cycle + data = api_post('/api/codegen/cycle') + formatter.json(data) + end + + no_commands do + include ApiClient + + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + end + end + end +end diff --git a/lib/legion/cli/cohort.rb b/lib/legion/cli/cohort.rb index 60ca5119..0693c8b0 100755 --- a/lib/legion/cli/cohort.rb +++ b/lib/legion/cli/cohort.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli class Cohort < Thor diff --git a/lib/legion/cli/coldstart_command.rb b/lib/legion/cli/coldstart_command.rb new file mode 100644 index 00000000..4782c27c --- /dev/null +++ b/lib/legion/cli/coldstart_command.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Coldstart < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + + desc 'ingest [PATH...]', 'Ingest Claude memory/CLAUDE.md files into agentic memory traces' + long_desc <<~DESC + Parse Claude Code MEMORY.md or CLAUDE.md files and convert them into + agentic memory traces for cold start bootstrapping. + + Accepts any number of file or directory paths. When given a directory, + all CLAUDE.md and MEMORY.md files are discovered recursively. + When no path is given, defaults to the current working directory. + + Use --dry-run to preview traces without storing them. + DESC + option :dry_run, type: :boolean, default: false, desc: 'Preview traces without storing' + option :pattern, type: :string, default: '**/{CLAUDE,MEMORY}.md', desc: 'Glob pattern for directory mode' + def ingest(*paths) + out = formatter + paths = [Dir.pwd] if paths.empty? + + paths.each do |path| + unless File.exist?(path) + out.error("Path not found: #{path}") + next + end + + if options[:dry_run] + require_coldstart! + run_local_ingest(out, path, dry_run: true) + next + end + + result = try_api_ingest(path) + if result + out.success('Ingested via running daemon (traces stored in live memory)') + File.directory?(path) ? render_directory_result(out, result) : render_file_result(out, result) + else + out.warn('Daemon not running, ingesting locally (traces stored in-process only)') + require_coldstart! + run_local_ingest(out, path, dry_run: false) + end + end + end + default_task :ingest + + desc 'preview [PATH...]', 'Preview what traces would be created (alias for ingest --dry-run)' + def preview(*paths) + out = formatter + require_coldstart! + paths = [Dir.pwd] if paths.empty? + + runner = build_runner(Legion::Extensions::Coldstart::Runners::Ingest) + + paths.each do |path| + if File.file?(path) + result = runner.preview_ingest(file_path: File.expand_path(path)) + render_file_result(out, result) + elsif File.directory?(path) + result = runner.ingest_directory( + dir_path: File.expand_path(path), + pattern: '**/{CLAUDE,MEMORY}.md', + store_traces: false + ) + render_directory_result(out, result) + else + out.error("Path not found: #{path}") + end + end + end + + desc 'status', 'Show cold start progress' + def status + out = formatter + require_coldstart! + + runner = build_runner(Legion::Extensions::Coldstart::Runners::Coldstart) + progress = runner.coldstart_progress + + if options[:json] + out.json(progress) + else + out.header('Cold Start Status') + out.spacer + out.detail({ + 'Firmware Loaded' => progress[:firmware_loaded], + 'Imprint Active' => progress[:imprint_active], + 'Imprint Progress' => "#{(progress[:imprint_progress] * 100).round(1)}%", + 'Observation Count' => progress[:observation_count], + 'Calibration State' => progress[:calibration_state], + 'Current Layer' => progress[:current_layer] + }) + end + end + + no_commands do # rubocop:disable Metrics/BlockLength + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def run_local_ingest(out, path, dry_run:) + runner = build_runner(Legion::Extensions::Coldstart::Runners::Ingest) + + if File.file?(path) + result = dry_run ? runner.preview_ingest(file_path: File.expand_path(path)) : runner.ingest_file(file_path: File.expand_path(path)) + render_file_result(out, result) + elsif File.directory?(path) + result = runner.ingest_directory( + dir_path: File.expand_path(path), + pattern: options[:pattern] || '**/{CLAUDE,MEMORY}.md', + store_traces: !dry_run + ) + render_directory_result(out, result) + end + end + + def try_api_ingest(path) + require 'net/http' + require 'json' + api_port = api_port_from_settings + uri = URI("http://localhost:#{api_port}/api/coldstart/ingest") + body = ::JSON.generate({ path: File.expand_path(path) }) + response = Net::HTTP.post(uri, body, 'Content-Type' => 'application/json') + return nil unless response.is_a?(Net::HTTPSuccess) + + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] + rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e + Legion::Logging.debug("Coldstart#try_api_ingest daemon not reachable: #{e.message}") if defined?(Legion::Logging) + nil + end + + def api_port_from_settings + require 'legion/settings' + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + api_settings = Legion::Settings[:api] + (api_settings.is_a?(Hash) && api_settings[:port]) || 4567 + rescue StandardError => e + Legion::Logging.warn("Coldstart#api_port_from_settings failed: #{e.message}") if defined?(Legion::Logging) + 4567 + end + + def build_runner(mod) + obj = Object.new + obj.extend(mod) + obj.define_singleton_method(:log) { Legion::Logging } unless obj.respond_to?(:log) + obj + end + + def require_coldstart! + require 'legion/logging' + Legion::Logging.setup(level: options[:verbose] ? 'debug' : 'warn') unless Legion::Logging.instance_variable_get(:@log) + + begin + require 'legion/extensions/agentic/memory/trace' + rescue LoadError + Legion::Logging.debug('lex-agentic-memory not available, traces will be parsed but not stored') if defined?(Legion::Logging) + end + + require 'legion/extensions/coldstart' + rescue LoadError => e + formatter.error("lex-coldstart not available: #{e.message}") + raise SystemExit, 1 + end + + def render_file_result(out, result) + if result[:error] + out.error(result[:error]) + raise SystemExit, 1 + end + + if options[:json] + out.json(result) + return + end + + out.header("Ingested: #{File.basename(result[:file] || result[:file_path] || 'unknown')}") + out.spacer + out.detail({ + 'File' => result[:file], + 'Type' => result[:file_type], + 'Traces Parsed' => result[:traces_parsed] || result[:traces]&.size || 0, + 'Traces Stored' => result[:traces_stored] || 0 + }) + + traces = result[:traces] || [] + return if traces.empty? + + out.spacer + type_counts = traces.group_by { |t| t[:trace_type] }.transform_values(&:size) + out.header('Trace Types') + type_counts.sort_by { |_, v| -v }.each do |type, count| + puts " #{out.colorize(type.to_s.ljust(15), :cyan)} #{count}" + end + end + + def render_directory_result(out, result) + if result[:error] + out.error(result[:error]) + raise SystemExit, 1 + end + + if options[:json] + out.json(result) + return + end + + out.header("Directory Ingest: #{result[:directory]}") + out.spacer + out.detail({ + 'Directory' => result[:directory], + 'Files Found' => result[:files_found], + 'Total Parsed' => result[:total_parsed], + 'Total Stored' => result[:total_stored] + }) + + files = result[:files] || [] + return if files.empty? + + out.spacer + out.header('Files Processed') + files.each { |f| puts " #{f}" } + end + end + end + end +end diff --git a/lib/legion/cli/commit_command.rb b/lib/legion/cli/commit_command.rb new file mode 100644 index 00000000..c15d9338 --- /dev/null +++ b/lib/legion/cli/commit_command.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/cli/error' +require 'open3' + +module Legion + module CLI + class Commit < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + class_option :model, type: :string, aliases: ['-m'], desc: 'Model ID' + class_option :provider, type: :string, desc: 'LLM provider' + + desc 'generate', 'Generate a commit message from staged changes' + option :all, type: :boolean, default: false, aliases: ['-a'], desc: 'Stage all modified files first' + option :amend, type: :boolean, default: false, desc: 'Amend the last commit' + option :yes, type: :boolean, default: false, aliases: ['-y'], desc: 'Auto-approve (skip confirmation)' + def generate + out = formatter + + stage_all if options[:all] + diff = staged_diff + if diff.strip.empty? + out.error('Nothing staged to commit. Use -a to stage all changes, or git add files first.') + raise SystemExit, 1 + end + + stat = staged_stat + log = recent_commits + setup_connection + + out.header('Generating commit message...') + message = generate_message(diff, stat, log) + + if options[:json] + out.json({ message: message, stat: stat }) + return + end + + puts + puts out.colorize(message, :green) + puts + puts out.dim(stat) + puts + + unless options[:yes] + $stderr.print "#{out.colorize('Commit with this message?', :yellow)} [Y/n/e(dit)] " + response = $stdin.gets&.strip&.downcase + case response + when 'n', 'no' + out.warn('Commit aborted.') + return + when 'e', 'edit' + message = edit_message(message) + return out.warn('Commit aborted (empty message).') if message.strip.empty? + end + end + + run_commit(message, amend: options[:amend]) + out.success(options[:amend] ? 'Commit amended.' : 'Committed.') + rescue CLI::Error => e + out.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + default_task :generate + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def setup_connection + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_llm + end + + def stage_all + stdout, stderr, status = Open3.capture3('git', 'add', '-u') + return if status.success? + + raise CLI::Error, "git add -u failed: #{stderr.strip.empty? ? stdout : stderr}" + end + + def staged_diff + stdout, _stderr, _status = Open3.capture3('git', 'diff', '--staged') + stdout + end + + def staged_stat + stdout, _stderr, _status = Open3.capture3('git', 'diff', '--staged', '--stat') + stdout.strip + end + + def recent_commits + stdout, _stderr, _status = Open3.capture3('git', 'log', '--oneline', '-10', '--no-decorate') + stdout.strip + end + + def generate_message(diff, stat, log) + opts = {} + opts[:model] = options[:model] if options[:model] + opts[:provider] = options[:provider]&.to_sym if options[:provider] + + chat = Legion::LLM.chat(**opts, caller: { source: 'cli', command: 'commit' }) + prompt = build_prompt(diff, stat, log) + response = chat.ask(prompt) + response.content.strip + end + + def build_prompt(diff, stat, log) + <<~PROMPT + Generate a concise git commit message for the following staged changes. + + Rules: + - Use lowercase, imperative mood (e.g., "add feature", "fix bug", not "Added" or "Fixes") + - First line: summary under 72 characters + - If the changes are complex, add a blank line then bullet points explaining key changes + - No emojis + - Match the style of recent commits shown below + - Output ONLY the commit message, nothing else + + Recent commits (for style reference): + #{log} + + Diffstat: + #{stat} + + Full diff: + #{diff[0, 8000]} + PROMPT + end + + def edit_message(message) + require 'tempfile' + file = Tempfile.new(['legion-commit', '.txt']) + file.write(message) + file.close + + editor = ENV.fetch('EDITOR', ENV.fetch('VISUAL', 'vi')) + system(editor, file.path) + + result = File.read(file.path) + file.unlink + result.strip + end + + def run_commit(message, amend: false) + cmd = %w[git commit] + cmd << '--amend' if amend + cmd.push('-m', message) + + _stdout, stderr, status = Open3.capture3(*cmd) + return if status.success? + + raise CLI::Error, "git commit failed: #{stderr.strip}" + end + end + end + end +end diff --git a/lib/legion/cli/completion_command.rb b/lib/legion/cli/completion_command.rb new file mode 100644 index 00000000..459e389d --- /dev/null +++ b/lib/legion/cli/completion_command.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Completion < Thor + def self.exit_on_failure? + true + end + + COMPLETION_DIR = File.expand_path('../../../completions', __dir__) + + desc 'bash', 'Output bash completion script' + long_desc <<~DESC + Outputs the bash completion script for the legion CLI. + + Add to your shell permanently: + echo 'source <(legion completion bash)' >> ~/.bashrc + + Or copy to the bash completions directory: + legion completion bash > /etc/bash_completion.d/legion + DESC + def bash + puts File.read(File.join(COMPLETION_DIR, 'legion.bash')) + end + + desc 'zsh', 'Output zsh completion script' + long_desc <<~DESC + Outputs the zsh completion script for the legion CLI. + + Add to a directory in your $fpath: + legion completion zsh > "${fpath[1]}/_legion" + + Or with oh-my-zsh: + legion completion zsh > ~/.oh-my-zsh/completions/_legion + exec zsh + DESC + def zsh + puts File.read(File.join(COMPLETION_DIR, '_legion')) + end + + desc 'install', 'Print shell completion installation instructions' + def install + shell = detect_shell + out = Output::Formatter.new(color: true, json: false) + + out.header('Legion Shell Completion') + out.spacer + + case shell + when 'zsh' + print_zsh_instructions(out) + when 'bash' + print_bash_instructions(out) + else + print_bash_instructions(out) + out.spacer + print_zsh_instructions(out) + end + end + + no_commands do + private + + def detect_shell + shell = ENV.fetch('SHELL', '') + return 'zsh' if shell.end_with?('zsh') + return 'bash' if shell.end_with?('bash') + + nil + end + + def print_bash_instructions(out) + out.header('Bash') + puts ' # One-time (current session):' + puts ' source <(legion completion bash)' + out.spacer + puts ' # Permanent (add to ~/.bashrc):' + puts " echo 'source <(legion completion bash)' >> ~/.bashrc" + out.spacer + puts ' # Or copy to completions directory:' + puts ' legion completion bash > /etc/bash_completion.d/legion' + end + + def print_zsh_instructions(out) + out.header('Zsh') + puts ' # One-time (current session):' + puts ' source <(legion completion zsh)' + out.spacer + puts ' # Permanent — add to a directory in your $fpath:' + puts ' legion completion zsh > "${fpath[1]}/_legion"' + out.spacer + puts ' # Or with oh-my-zsh:' + puts ' legion completion zsh > ~/.oh-my-zsh/completions/_legion' + puts ' exec zsh' + end + end + end + end +end diff --git a/lib/legion/cli/config_command.rb b/lib/legion/cli/config_command.rb new file mode 100644 index 00000000..d41d34ee --- /dev/null +++ b/lib/legion/cli/config_command.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +require_relative 'config_scaffold' + +module Legion + module CLI + class Config < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'show', 'Show resolved configuration' + option :section, type: :string, aliases: ['-s'], desc: 'Show only a specific section (e.g. transport, data, extensions)' + def show + out = formatter + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.ensure_settings(resolve_secrets: false) + + settings = if Legion::Settings.respond_to?(:to_hash) + Legion::Settings.to_hash + elsif Legion::Settings.respond_to?(:to_h) + Legion::Settings.to_h + else + # Settings uses [] accessor, enumerate known sections + %i[client transport data cache crypt extensions api].to_h do |key| + [key, Legion::Settings[key]] + rescue StandardError => e + Legion::Logging.warn("ConfigCommand#show settings key #{key} read failed: #{e.message}") if defined?(Legion::Logging) + [key, nil] + end.compact + end + + if options[:section] + key = options[:section].to_sym + unless settings.key?(key) + out.error("Section '#{options[:section]}' not found. Available: #{settings.keys.join(', ')}") + raise SystemExit, 1 + end + settings = { key => settings[key] } + end + + # Redact sensitive values + redacted = deep_redact(settings) + + if options[:json] + out.json(redacted) + else + print_nested(out, redacted) + end + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + default_task :show + + desc 'path', 'Show configuration file search paths' + def path + out = formatter + Connection.config_dir = options[:config_dir] if options[:config_dir] + paths = config_search_paths + + if options[:json] + out.json(paths.map { |p| { path: p[:path], exists: p[:exists], active: p[:active] } }) + return + end + + out.header('Configuration Search Paths') + out.spacer + paths.each do |p| + if p[:active] + puts " #{out.colorize('>>', :green)} #{p[:path]} #{out.colorize('(active)', :green)}" + elsif p[:exists] + puts " #{out.colorize(' *', :yellow)} #{p[:path]} #{out.colorize('(exists)', :yellow)}" + else + puts " #{out.colorize(' ', :gray)} #{out.colorize(p[:path], :gray)}" + end + end + + out.spacer + out.header('Environment Variables') + env_vars = %w[LEGION_ENV LEGION_CONFIG_DIR LEGION_LOG_LEVEL] + env_vars.each do |var| + val = ENV.fetch(var, nil) + if val + puts " #{out.colorize(var, :cyan)} = #{val}" + else + puts " #{out.colorize(var, :gray)} (not set)" + end + end + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + + desc 'validate', 'Validate current configuration' + def validate + out = formatter + Connection.config_dir = options[:config_dir] if options[:config_dir] + + issues = [] + warnings = [] + + # Check settings load + begin + Connection.ensure_settings(resolve_secrets: false) + out.success('Settings loaded successfully') unless options[:json] + rescue StandardError => e + issues << "Settings failed to load: #{e.message}" + end + + # Check transport config + if Connection.settings? + transport = Legion::Settings[:transport] || {} + transport_host = transport.dig(:connection, :host) + warnings << 'Transport host not configured (RabbitMQ will use default localhost)' if transport_host.nil? || transport_host.to_s.empty? + + # Check data config + data = Legion::Settings[:data] || {} + warnings << 'Database adapter not configured' if data[:adapter].nil? + + # Check extensions config + extensions = Legion::Settings[:extensions] || {} + warnings << 'No extensions configured in settings' if extensions.empty? + end + + # Check LLM config + validate_llm(warnings) if Connection.settings? + + if options[:json] + out.json(valid: issues.empty?, issues: issues, warnings: warnings) + return + end + + if issues.any? + out.spacer + out.header('Issues') + issues.each { |i| out.error(i) } + end + + if warnings.any? + out.spacer + out.header('Warnings') + warnings.each { |w| out.warn(w) } + end + + if issues.empty? && warnings.empty? + out.success('Configuration looks good') + elsif issues.empty? + out.warn("Configuration valid with #{warnings.size} warning(s)") + else + out.error("Configuration has #{issues.size} issue(s)") + raise SystemExit, 1 + end + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + + desc 'scaffold', 'Generate starter config files for each subsystem' + long_desc <<~DESC + Generates JSON config files for LegionIO subsystems (transport, data, cache, + crypt, logging, llm). Files are written to --dir (default: ~/.legionio/settings/). + + By default, generates minimal starter files with only the most commonly + changed fields. Use --full for the complete schema with all defaults. + DESC + option :dir, type: :string, desc: 'Output directory (default: ~/.legionio/settings)' + option :only, type: :string, desc: 'Comma-separated subsystems (transport,data,cache,crypt,logging,llm)' + option :full, type: :boolean, default: false, desc: 'Include all fields with defaults' + option :force, type: :boolean, default: false, desc: 'Overwrite existing files' + def scaffold + out = formatter + exit_code = ConfigScaffold.run(out, options) + raise SystemExit, exit_code if exit_code != 0 + end + + desc 'reset', 'Remove all JSON config files from the settings directory' + long_desc <<~DESC + Removes all *.json files from the settings directory (~/.legionio/settings/). + Prompts for confirmation unless --force is passed. + DESC + option :force, type: :boolean, default: false, desc: 'Skip confirmation prompt' + def reset + require_relative 'config_import' + out = formatter + dir = options[:config_dir] || ConfigImport::SETTINGS_DIR + + files = Dir.glob(File.join(dir, '*.json')) + if files.empty? + out.warn("No JSON files found in #{dir}") + return + end + + unless options[:force] + out.warn("This will remove #{files.size} JSON file(s) from #{dir}:") + files.each { |f| puts " #{File.basename(f)}" } + print ' Continue? [y/N] ' + answer = $stdin.gets&.strip + unless answer&.match?(/\Ay(es)?\z/i) + out.warn('Aborted.') + return + end + end + + files.each { |f| FileUtils.rm_f(f) } + + if options[:json] + out.json(removed: files, directory: dir) + else + out.success("Removed #{files.size} JSON file(s) from #{dir}") + end + end + + desc 'import SOURCE', 'Import configuration from a URL or local file' + option :force, type: :boolean, default: false, desc: 'Overwrite existing imported config' + def import(source) + out = formatter + require_relative 'config_import' + + out.info("Fetching config from #{source}...") + body = ConfigImport.fetch_source(source) + config = ConfigImport.parse_payload(body) + paths = ConfigImport.write_config(config, force: options[:force]) + summary = ConfigImport.summary(config) + + if paths.empty? + out.warn('No config files were written (empty configuration).') + else + paths.each { |p| out.success("Written: #{p}") } + end + out.info("Sections: #{summary[:sections].join(', ')}") + if summary[:vault_clusters].any? + out.info("Vault clusters: #{summary[:vault_clusters].join(', ')}") + out.info("Run 'legion' to authenticate via LDAP during onboarding") + end + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def config_search_paths + active_found = false + [ + '/etc/legionio', + File.expand_path('~/.legionio/settings'), + File.expand_path('~/legionio'), + File.expand_path('./settings') + ].map do |path| + exists = Dir.exist?(path) + active = exists && !active_found + active_found = true if active + { path: path, exists: exists, active: active } + end + end + + def deep_redact(obj, depth: 0) + case obj + when Hash + obj.to_h do |k, v| + if sensitive_key?(k) + [k, '***REDACTED***'] + else + [k, deep_redact(v, depth: depth + 1)] + end + end + when Array + obj.map { |v| deep_redact(v, depth: depth + 1) } + else + obj + end + end + + def validate_llm(warnings) + llm = Legion::Settings[:llm] || {} + return unless llm[:enabled] + + warnings << 'LLM enabled but no default provider configured' if llm[:default_provider].nil? || llm[:default_provider].to_s.empty? + + keyless_providers = %i[bedrock ollama] + (llm[:providers] || {}).each do |name, config| + next unless config.is_a?(Hash) && config[:enabled] + next if keyless_providers.include?(name.to_sym) + next if config[:api_key] && !config[:api_key].to_s.empty? + + warnings << "LLM provider '#{name}' enabled but no api_key configured" + end + end + + def sensitive_key?(key) + name = key.to_s.downcase + name.match?(/(?:\A|_)(?:password|secret|token|key|credential|auth)\z/) + end + + def print_nested(out, hash, indent: 0) + hash.each do |key, value| + pad = ' ' * (indent + 1) + case value + when Hash + puts "#{pad}#{out.colorize("#{key}:", :cyan)}" + print_nested(out, value, indent: indent + 1) + when Array + puts "#{pad}#{out.colorize("#{key}:", :cyan)} [#{value.join(', ')}]" + else + puts "#{pad}#{out.colorize("#{key}:", :cyan)} #{value}" + end + end + end + end + end + end +end diff --git a/lib/legion/cli/config_import.rb b/lib/legion/cli/config_import.rb new file mode 100644 index 00000000..8de56d18 --- /dev/null +++ b/lib/legion/cli/config_import.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'base64' +require 'net/http' +require 'uri' +require 'fileutils' +require 'json' + +module Legion + module CLI + module ConfigImport + SETTINGS_DIR = File.expand_path('~/.legionio/settings') + BOOTSTRAPPED_FILE = 'bootstrapped_settings.json' + + SUBSYSTEM_KEYS = %i[ + microsoft_teams rbac api logging gaia extensions + llm data cache_local cache transport crypt role + ].freeze + + module_function + + def fetch_source(source) + if source.match?(%r{\Ahttps?://}) + fetch_http(source) + else + raise CLI::Error, "File not found: #{source}" unless File.exist?(source) + + File.read(source) + end + end + + def fetch_http(url) + uri = URI.parse(url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.open_timeout = 10 + http.read_timeout = 10 + request = Net::HTTP::Get.new(uri) + response = http.request(request) + raise CLI::Error, "HTTP #{response.code}: #{response.message}" unless response.is_a?(Net::HTTPSuccess) + + response.body + end + + def parse_payload(body) + parsed = ::JSON.parse(body, symbolize_names: true) + raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash) + + parsed + rescue ::JSON::ParserError + begin + decoded = Base64.decode64(body) + parsed = ::JSON.parse(decoded, symbolize_names: true) + raise CLI::Error, 'Config must be a JSON object' unless parsed.is_a?(Hash) + + parsed + rescue ::JSON::ParserError + raise CLI::Error, 'Source is not valid JSON or base64-encoded JSON' + end + end + + def write_config(config, force: false) + FileUtils.mkdir_p(SETTINGS_DIR) + written = [] + remainder = config.dup + + SUBSYSTEM_KEYS.each do |key| + next unless remainder.key?(key) + + subsystem_data = remainder.delete(key) + path = File.join(SETTINGS_DIR, "#{key}.json") + to_write = { key => subsystem_data } + if File.exist?(path) && !force + existing = ::JSON.parse(File.read(path), symbolize_names: true) + existing_subsystem = existing[key] + to_write = { key => deep_merge(existing_subsystem, subsystem_data) } if existing_subsystem.is_a?(Hash) && subsystem_data.is_a?(Hash) + end + File.write(path, "#{::JSON.pretty_generate(to_write)}\n") + written << path + end + + unless remainder.empty? + path = File.join(SETTINGS_DIR, BOOTSTRAPPED_FILE) + if File.exist?(path) && !force + existing = ::JSON.parse(File.read(path), symbolize_names: true) + remainder = deep_merge(existing, remainder) + end + File.write(path, "#{::JSON.pretty_generate(remainder)}\n") + written << path + end + + written + end + + def deep_merge(base, overlay) + base.merge(overlay) do |_key, old_val, new_val| + if old_val.is_a?(Hash) && new_val.is_a?(Hash) + deep_merge(old_val, new_val) + else + new_val + end + end + end + + def summary(config) + sections = config.keys.map(&:to_s) + vault_clusters = config.dig(:crypt, :vault, :clusters)&.keys&.map(&:to_s) || [] + { sections: sections, vault_clusters: vault_clusters } + end + end + end +end diff --git a/lib/legion/cli/config_scaffold.rb b/lib/legion/cli/config_scaffold.rb new file mode 100644 index 00000000..6e2bb4c4 --- /dev/null +++ b/lib/legion/cli/config_scaffold.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +require 'json' +require 'fileutils' +require 'net/http' + +module Legion + module CLI + module ConfigScaffold + SUBSYSTEMS = %w[transport data cache crypt logging llm chat].freeze + + ENV_DETECTIONS = { + 'AWS_BEARER_TOKEN_BEDROCK' => { subsystem: 'llm', provider: :bedrock, field: :bearer_token }, + 'ANTHROPIC_API_KEY' => { subsystem: 'llm', provider: :anthropic, field: :api_key }, + 'OPENAI_API_KEY' => { subsystem: 'llm', provider: :openai, field: :api_key }, + 'GEMINI_API_KEY' => { subsystem: 'llm', provider: :gemini, field: :api_key }, + 'VAULT_TOKEN' => { subsystem: 'crypt', field: :token }, + 'RABBITMQ_USER' => { subsystem: 'transport', field: :user }, + 'RABBITMQ_PASSWORD' => { subsystem: 'transport', field: :password } + }.freeze + + module_function + + def run(formatter, options) + dir = options[:dir] || "#{Dir.home}/.legionio/settings" + only = options[:only] ? options[:only].split(',').map(&:strip) : SUBSYSTEMS + full_mode = options[:full] + force = options[:force] + + invalid = only - SUBSYSTEMS + if invalid.any? + formatter.error("Unknown subsystem(s): #{invalid.join(', ')}. Valid: #{SUBSYSTEMS.join(', ')}") + return 1 + end + + FileUtils.mkdir_p(dir) + + detected = detect_environment + created = [] + skipped = [] + + only.each do |name| + path = File.join(dir, "#{name}.json") + + if File.exist?(path) && !force + skipped << path + next + end + + content = full_mode ? full_template(name) : minimal_template(name) + apply_detections!(content, name, detected) + File.write(path, "#{::JSON.pretty_generate(content)}\n") + created << path + end + + if options[:json] + formatter.json(created: created, skipped: skipped, detected: detected.map { |d| d[:label] }) + else + if created.any? + formatter.success("Created #{created.size} config file(s) in #{dir}/") + created.each { |f| puts " #{f}" } + end + if skipped.any? + formatter.warn("Skipped #{skipped.size} existing file(s) (use --force to overwrite)") + skipped.each { |f| puts " #{f}" } + end + if detected.any? && created.any? + formatter.spacer + puts ' Auto-detected:' + detected.each { |d| puts " #{d[:label]}" } + end + formatter.spacer + formatter.success('Edit these files then run: legion config validate') if created.any? + end + + 0 + end + + def detect_environment + detected = [] + + ENV_DETECTIONS.each do |env_var, meta| + next unless ENV[env_var] && !ENV[env_var].empty? + + label = meta[:provider] ? "#{meta[:provider]} enabled (#{env_var} found)" : "#{meta[:field]} set (#{env_var} found)" + detected << { env_var: env_var, label: label, **meta } + end + + if ollama_running? + detected << { subsystem: 'llm', provider: :ollama, field: :enabled, env_var: nil, + label: 'ollama enabled (responding on localhost:11434)' } + end + + detected + end + + def ollama_running? + uri = URI('http://localhost:11434/') + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 1 + http.read_timeout = 1 + response = http.get(uri.path) + response.is_a?(Net::HTTPSuccess) + rescue StandardError => e + Legion::Logging.debug("ConfigScaffold#ollama_running? ollama not reachable: #{e.message}") if defined?(Legion::Logging) + false + end + + def apply_detections!(content, subsystem, detected) + relevant = detected.select { |d| d[:subsystem] == subsystem } + return if relevant.empty? + + case subsystem + when 'llm' then apply_llm_detections!(content[:llm], relevant) + when 'crypt' then apply_crypt_detections!(content[:crypt], relevant) + when 'transport' then apply_transport_detections!(content[:transport][:connection], relevant) + end + end + + def apply_llm_detections!(llm, detections) + first_provider = nil + detections.each do |det| + provider = det[:provider] + next unless provider && llm[:providers][provider] + + llm[:providers][provider][:enabled] = true + llm[:providers][provider][det[:field]] = "env://#{det[:env_var]}" if det[:env_var] + first_provider ||= provider + end + return unless first_provider + + llm[:enabled] = true + llm[:default_provider] = first_provider.to_s + end + + def apply_crypt_detections!(crypt, detections) + vault_det = detections.find { |d| d[:field] == :token } + return unless vault_det + + crypt[:vault][:enabled] = true + crypt[:vault][:token] = "env://#{vault_det[:env_var]}" + end + + def apply_transport_detections!(connection, detections) + detections.each do |det| + connection[det[:field]] = "env://#{det[:env_var]}" + end + end + + def minimal_template(name) + case name # rubocop:disable Style/HashLikeCase + when 'transport' + { transport: { + connection: { + host: '127.0.0.1', + port: 5672, + user: 'guest', + password: 'guest', + vhost: '/' + } + } } + when 'data' + { data: { + adapter: 'sqlite', + creds: { database: 'legionio.db' } + } } + when 'cache' + { cache: { + driver: 'dalli', + servers: ['127.0.0.1:11211'], + enabled: true + } } + when 'crypt' + { crypt: { + vault: { + enabled: false, + address: 'localhost', + port: 8200, + token: nil + }, + jwt: { + enabled: true, + default_algorithm: 'HS256', + default_ttl: 3600 + } + } } + when 'logging' + { logging: { + level: 'info', + location: 'stdout', + trace: true + } } + when 'llm' + { llm: { + enabled: false, + default_provider: nil, + default_model: nil, + providers: { + anthropic: { enabled: false, api_key: nil }, + openai: { enabled: false, api_key: nil }, + gemini: { enabled: false, api_key: nil }, + bedrock: { enabled: false, region: 'us-east-2' }, + ollama: { enabled: false, base_url: 'http://localhost:11434' } + } + } } + when 'chat' + { chat: { + permissions: 'interactive', + model: nil, + provider: nil, + personality: nil, + markdown: true, + incognito: false, + max_budget_usd: nil, + subagent: { max_concurrency: 3, timeout: 300 }, + headless: { max_turns: 10 }, + notifications: { patterns: [] } + } } + end + end + + def full_template(name) # rubocop:disable Metrics/MethodLength + case name # rubocop:disable Style/HashLikeCase + when 'transport' + { transport: { + type: 'rabbitmq', + logger_level: 'info', + prefetch: 0, + messages: { + encrypt: false, + ttl: nil, + priority: 0, + persistent: false + }, + exchanges: { + type: 'topic', + arguments: {}, + auto_delete: false, + durable: true, + internal: false + }, + queues: { + manual_ack: true, + durable: true, + block: false, + auto_delete: false, + arguments: { 'x-queue-type': 'quorum' } + }, + connection: { + host: '127.0.0.1', + port: 5672, + user: 'guest', + password: 'guest', + vhost: '/', + read_timeout: 1, + heartbeat: 30, + automatically_recover: true, + continuation_timeout: 4000, + network_recovery_interval: 1, + connection_timeout: 1, + frame_max: 65_536, + recovery_attempts: 100, + logger_level: 'info' + }, + channel: { + default_worker_pool_size: 1, + session_worker_pool_size: 8 + } + } } + when 'data' + { data: { + adapter: 'sqlite', + connect_on_start: true, + cache: { + auto_enable: false, + ttl: 60 + }, + connection: { + log: false, + log_connection_info: false, + log_warn_duration: 1, + sql_log_level: 'debug', + max_connections: 10, + preconnect: false + }, + creds: { + database: 'legionio.db' + }, + migrations: { + continue_on_fail: false, + auto_migrate: true + }, + models: { + continue_on_load_fail: false, + autoload: true + } + } } + when 'cache' + { cache: { + driver: 'dalli', + servers: ['127.0.0.1:11211'], + enabled: true, + namespace: 'legion', + compress: false, + failover: true, + threadsafe: true, + expires_in: 0, + cache_nils: false, + pool_size: 10, + timeout: 5 + } } + when 'crypt' + { crypt: { + cluster_secret: nil, + cluster_secret_timeout: 5, + dynamic_keys: true, + save_private_key: true, + read_private_key: true, + jwt: { + enabled: true, + default_algorithm: 'HS256', + default_ttl: 3600, + issuer: 'legion', + verify_expiration: true, + verify_issuer: true + }, + vault: { + enabled: false, + protocol: 'http', + address: 'localhost', + port: 8200, + token: nil, + renewer_time: 5, + renewer: true, + push_cluster_secret: true, + read_cluster_secret: true, + kv_path: 'legion' + } + } } + when 'logging' + { logging: { + level: 'info', + location: 'stdout', + trace: true, + backtrace_logging: true + } } + when 'llm' + { llm: { + enabled: false, + default_provider: nil, + default_model: nil, + providers: { + bedrock: { enabled: false, api_key: nil, secret_key: nil, session_token: nil, + region: 'us-east-2', vault_path: nil }, + anthropic: { enabled: false, api_key: nil, vault_path: nil }, + openai: { enabled: false, api_key: nil, vault_path: nil }, + gemini: { enabled: false, api_key: nil, vault_path: nil }, + ollama: { enabled: false, base_url: 'http://localhost:11434' } + } + } } + when 'chat' + { chat: { + permissions: 'interactive', + model: nil, + provider: nil, + personality: nil, + markdown: true, + incognito: false, + max_budget_usd: nil, + subagent: { max_concurrency: 3, timeout: 300 }, + headless: { max_turns: 10 }, + notifications: { patterns: [] } + } } + end + end + end + end +end diff --git a/lib/legion/cli/connect_command.rb b/lib/legion/cli/connect_command.rb new file mode 100644 index 00000000..99d9f0db --- /dev/null +++ b/lib/legion/cli/connect_command.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class ConnectCommand < Thor + namespace :connect + + PROVIDERS = %w[microsoft github google].freeze + + desc 'microsoft', 'Connect a Microsoft account (OAuth2 delegated auth)' + method_option :tenant_id, type: :string, desc: 'Azure tenant ID' + method_option :client_id, type: :string, desc: 'Application client ID' + method_option :scope, type: :string, default: 'Calendars.Read OnlineMeetings.Read', + desc: 'OAuth2 scopes (space-separated)' + method_option :no_browser, type: :boolean, default: false, desc: 'Print URL instead of launching browser' + def microsoft + say 'Delegating to Teams OAuth2 browser auth...', :blue + Legion::CLI::Auth.start(['teams'] + ARGV.select { |a| a.start_with?('--') }) + end + + desc 'github', 'Connect a GitHub account (OAuth2 device flow)' + method_option :client_id, type: :string, desc: 'GitHub OAuth App client ID' + def github + say 'GitHub connection not yet implemented.', :yellow + end + + desc 'status', 'Show connection status for all providers' + def status + require 'legion/auth/token_manager' + + PROVIDERS.each do |provider| + manager = Legion::Auth::TokenManager.new(provider: provider.to_sym) + if manager.token_valid? + say " #{provider}: connected", :green + elsif manager.revoked? + say " #{provider}: revoked", :red + else + say " #{provider}: not connected", :yellow + end + end + end + + desc 'disconnect PROVIDER', 'Disconnect a provider account' + def disconnect(provider) + unless PROVIDERS.include?(provider) + say "Unknown provider: #{provider}. Valid: #{PROVIDERS.join(', ')}", :red + return + end + + say "Disconnected #{provider} account.", :green + end + end + end +end diff --git a/lib/legion/cli/connection.rb b/lib/legion/cli/connection.rb new file mode 100644 index 00000000..10a42f69 --- /dev/null +++ b/lib/legion/cli/connection.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +module Legion + module CLI + # Lazy connection manager for CLI commands. + # Only connects to the subsystems a command actually needs, + # instead of booting the entire Legion::Service. + module Connection + class << self + attr_accessor :config_dir + + attr_writer :log_level + + def log_level + @log_level || 'error' + end + + def ensure_logging + return if @logging_ready + + require 'legion/logging' + Legion::Logging.setup(log_level: log_level, level: log_level, trace: false) + @logging_ready = true + end + + def ensure_settings(resolve_secrets: true) + return if @settings_ready + + ensure_logging + require 'legion/settings' + + dir = resolve_config_dir + Legion::Settings.load(config_dir: dir) + Legion::Settings.resolve_secrets! if resolve_secrets && Legion::Settings.respond_to?(:resolve_secrets!) + @settings_ready = true + end + + def ensure_data + return if @data_ready + + ensure_settings + require 'legion/data' + Legion::Settings.merge_settings(:data, Legion::Data::Settings.default) + Legion::Data.setup + @data_ready = true + rescue LoadError + raise CLI::Error, 'legion-data gem is not installed (gem install legion-data)' + rescue StandardError => e + raise CLI::Error, "database connection failed: #{e.message}" + end + + def ensure_transport + return if @transport_ready + + ensure_settings + require 'legion/transport' + Legion::Settings.merge_settings('transport', Legion::Transport::Settings.default) + Legion::Transport::Connection.setup + @transport_ready = true + rescue LoadError + raise CLI::Error, 'legion-transport gem is not installed (gem install legion-transport)' + rescue StandardError => e + raise CLI::Error, "transport connection failed: #{e.message}" + end + + def ensure_crypt + return if @crypt_ready + + ensure_settings + require 'legion/crypt' + Legion::Crypt.start + # Re-resolve now that LeaseManager is available for lease:// URIs + Legion::Settings.resolve_secrets! if Legion::Settings.respond_to?(:resolve_secrets!) + @crypt_ready = true + rescue LoadError + raise CLI::Error, 'legion-crypt gem is not installed (gem install legion-crypt)' + rescue StandardError => e + raise CLI::Error, "crypt initialization failed: #{e.message}" + end + + def ensure_cache + return if @cache_ready + + ensure_settings + require 'legion/cache' + @cache_ready = true + rescue LoadError + raise CLI::Error, 'legion-cache gem is not installed (gem install legion-cache)' + end + + def ensure_knowledge + return if @knowledge_ready + + ensure_settings + spec = Gem::Specification.find_by_name('lex-knowledge') + require "#{spec.gem_dir}/lib/legion/extensions/knowledge" + @knowledge_ready = true + rescue Gem::MissingSpecError + raise CLI::Error, 'lex-knowledge gem is not installed (gem install lex-knowledge)' + end + + def ensure_llm + return if @llm_ready + + ensure_settings + require 'legion/llm' + Legion::Settings.merge_settings(:llm, Legion::LLM::Settings.default) + Legion::LLM.start + @llm_ready = true + rescue LoadError + raise CLI::Error, 'legion-llm gem is not installed (gem install legion-llm)' + rescue StandardError => e + raise CLI::Error, "LLM initialization failed: #{e.message}" + end + + def settings? + @settings_ready == true + end + + def data? + @data_ready == true + end + + def transport? + @transport_ready == true + end + + def llm? + @llm_ready == true + end + + def shutdown + Legion::LLM.shutdown if @llm_ready + Legion::Transport::Connection.shutdown if @transport_ready + Legion::Data.shutdown if @data_ready + Legion::Cache.shutdown if @cache_ready + Legion::Crypt.shutdown if @crypt_ready + rescue StandardError => e + Legion::Logging.warn("Connection#shutdown cleanup failed: #{e.message}") if defined?(Legion::Logging) + end + + private + + def resolve_config_dir + if @config_dir.is_a?(String) + stripped = @config_dir.strip + unless stripped.empty? + expanded = File.expand_path(stripped) + return expanded if Dir.exist?(expanded) + end + end + + require 'legion/settings/loader' unless defined?(Legion::Settings::Loader) + Legion::Settings::Loader.default_directories.each do |path| + return path if Dir.exist?(path) + end + + nil + end + end + end + end +end diff --git a/lib/legion/cli/cost/data_client.rb b/lib/legion/cli/cost/data_client.rb new file mode 100644 index 00000000..95da551a --- /dev/null +++ b/lib/legion/cli/cost/data_client.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'net/http' + +module Legion + module CLI + module CostData + class Client + def initialize(base_url: 'http://localhost:4567') + @base_url = base_url + end + + def summary(period: 'month') + fetch("/api/costs/summary?period=#{period}") || default_summary + end + + def worker_cost(worker_id) + fetch("/api/workers/#{worker_id}/value") || {} + end + + def top_consumers(limit: 10) + workers = fetch('/api/workers') || [] + workers = workers[:data] if workers.is_a?(Hash) && workers.key?(:data) + results = Array(workers).map do |w| + id = w[:worker_id] || w[:id] + cost = worker_cost(id) + { worker_id: id, cost: cost } + end + results.sort_by { |w| -(w.dig(:cost, :total_cost_usd) || 0) }.first(limit) + end + + private + + def fetch(path) + uri = URI("#{@base_url}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 5 + http.read_timeout = 5 + response = http.request(Net::HTTP::Get.new(uri)) + return nil unless response.is_a?(Net::HTTPSuccess) + + Legion::JSON.load(response.body) + rescue StandardError => e + Legion::Logging.warn("CostData::Client#fetch failed for #{path}: #{e.message}") if defined?(Legion::Logging) + nil + end + + def default_summary + { today: 0.0, week: 0.0, month: 0.0, workers: 0 } + end + end + end + end +end diff --git a/lib/legion/cli/cost_command.rb b/lib/legion/cli/cost_command.rb new file mode 100644 index 00000000..a9586744 --- /dev/null +++ b/lib/legion/cli/cost_command.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Cost < Thor + def self.exit_on_failure? + true + end + + class_option :url, type: :string, default: 'http://localhost:4567', desc: 'API base URL' + + desc 'summary', 'Overall cost summary' + option :period, type: :string, default: 'month', desc: 'Time period (day, week, month)' + def summary + data = build_client.summary(period: options[:period]) + say 'Cost Summary', :green + say '-' * 30 + say format(' Today: $%.2f', data[:today] || 0) + say format(' This Week: $%.2f', data[:week] || 0) + say format(' This Month: $%.2f', data[:month] || 0) + say " Workers: #{data[:workers] || 0}" + end + + desc 'worker ID', 'Per-worker cost breakdown' + def worker(id) + data = build_client.worker_cost(id) + if data.empty? + say "No cost data for worker #{id}", :yellow + return + end + say "Worker: #{id}", :green + say '-' * 30 + data.each { |k, v| say " #{k}: #{v}" } + end + + desc 'top', 'Top cost consumers' + option :limit, type: :numeric, default: 10 + def top + consumers = build_client.top_consumers(limit: options[:limit]) + if consumers.empty? + say 'No cost data available', :yellow + return + end + say 'Top Cost Consumers', :green + say '-' * 40 + consumers.each_with_index do |c, i| + cost = c.dig(:cost, :total_cost_usd) || 0 + say format(' %d. %-25s $%.2f', rank: i + 1, name: c[:worker_id], cost: cost) + end + end + + desc 'export', 'Export cost data' + option :format, type: :string, default: 'json', enum: %w[json csv] + option :period, type: :string, default: 'month' + def export + data = build_client.summary(period: options[:period]) + case options[:format] + when 'json' + say Legion::JSON.dump(data) + when 'csv' + say 'period,today,week,month,workers' + say "#{options[:period]},#{data[:today]},#{data[:week]},#{data[:month]},#{data[:workers]}" + end + end + + private + + def build_client + require 'legion/cli/cost/data_client' + CostData::Client.new(base_url: options[:url]) + end + end + end +end diff --git a/lib/legion/cli/dashboard/data_fetcher.rb b/lib/legion/cli/dashboard/data_fetcher.rb new file mode 100644 index 00000000..a020c870 --- /dev/null +++ b/lib/legion/cli/dashboard/data_fetcher.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'net/http' + +module Legion + module CLI + module Dashboard + class DataFetcher + def initialize(base_url: 'http://localhost:4567') + @base_url = base_url + end + + def workers + fetch('/api/workers') || [] + end + + def health + fetch('/api/health') || {} + end + + def recent_events(limit: 10) + fetch("/api/events/recent?limit=#{limit}") || [] + end + + def summary + { + workers: workers, + health: health, + events: recent_events, + fetched_at: Time.now + } + end + + private + + def fetch(path) + uri = URI("#{@base_url}#{path}") + response = Net::HTTP.get_response(uri) + return nil unless response.is_a?(Net::HTTPSuccess) + + Legion::JSON.load(response.body) + rescue StandardError => e + Legion::Logging.warn("Dashboard::DataFetcher#fetch failed for #{path}: #{e.message}") if defined?(Legion::Logging) + nil + end + end + end + end +end diff --git a/lib/legion/cli/dashboard/renderer.rb b/lib/legion/cli/dashboard/renderer.rb new file mode 100644 index 00000000..aa9da25c --- /dev/null +++ b/lib/legion/cli/dashboard/renderer.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Legion + module CLI + module Dashboard + class Renderer + def initialize(width: nil) + @width = width || default_width + end + + def render(data) + lines = [] + lines << header_line(data) + lines << separator + lines << workers_section(data[:workers] || []) + lines << separator + lines << events_section(data[:events] || []) + lines << separator + lines << health_section(data[:health] || {}) + lines << separator + lines << org_chart_section(data[:departments] || []) + lines << footer_line(data[:fetched_at]) + lines.flatten.join("\n") + end + + private + + def default_width + defined?(TTY::Screen) ? TTY::Screen.width : 80 + end + + def header_line(data) + workers = data[:workers]&.size || 0 + "Legion Dashboard | Workers: #{workers} | #{Time.now.strftime('%H:%M:%S')}" + end + + def separator + '-' * @width + end + + def workers_section(workers) + lines = ['Active Workers:'] + workers.first(5).each do |w| + id = w[:worker_id] || w[:id] || 'unknown' + status = w[:status] || w[:lifecycle_state] || 'unknown' + lines << " #{id.to_s.ljust(20)} #{status.to_s.ljust(10)}" + end + lines << ' (none)' if workers.empty? + lines + end + + def events_section(events) + lines = ['Recent Events:'] + events.first(5).each do |e| + time = e[:timestamp] || e[:created_at] || '' + name = e[:event_name] || e[:name] || '' + lines << " #{time.to_s[11..18]} #{name}" + end + lines << ' (none)' if events.empty? + lines + end + + def health_section(health) + components = health.map { |k, v| "#{k}: #{v}" }.join(' | ') + "Health: #{components.empty? ? 'unknown' : components}" + end + + def org_chart_section(departments) + lines = ['Org Chart:'] + if departments.empty? + lines << ' (no departments)' + else + departments.each do |dept| + lines << " #{dept[:name]}" + (dept[:roles] || []).each do |role| + lines << " +-- #{role[:name]}" + (role[:workers] || []).each do |w| + lines << " | +-- #{w[:name]} (#{w[:status]})" + end + end + end + end + lines + end + + def footer_line(fetched_at) + "Last updated: #{fetched_at&.strftime('%H:%M:%S') || 'never'} | Press q to quit, r to refresh" + end + end + end + end +end diff --git a/lib/legion/cli/dashboard_command.rb b/lib/legion/cli/dashboard_command.rb new file mode 100644 index 00000000..0c0fea11 --- /dev/null +++ b/lib/legion/cli/dashboard_command.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class DashboardCommand < Thor + namespace 'dashboard' + + desc 'start', 'Launch the TUI dashboard' + option :url, type: :string, default: 'http://localhost:4567', desc: 'API base URL' + option :refresh, type: :numeric, default: 2, desc: 'Refresh interval in seconds' + def start + require 'legion/cli/dashboard/data_fetcher' + require 'legion/cli/dashboard/renderer' + + fetcher = Dashboard::DataFetcher.new(base_url: options[:url]) + renderer = Dashboard::Renderer.new + + puts 'Starting dashboard... (press q to quit)' + loop do + system('clear') || system('cls') + data = fetcher.summary + puts renderer.render(data) + + ready = $stdin.wait_readable(options[:refresh]) + if ready + input = $stdin.getc + break if input == 'q' + end + rescue Interrupt + Legion::Logging.debug('DashboardCommand#start interrupted by user') if defined?(Legion::Logging) + break + end + puts 'Dashboard stopped.' + end + + default_task :start + end + end +end diff --git a/lib/legion/cli/dataset_command.rb b/lib/legion/cli/dataset_command.rb new file mode 100644 index 00000000..5c24f5d1 --- /dev/null +++ b/lib/legion/cli/dataset_command.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Dataset < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'list', 'List all datasets' + def list + out = formatter + with_dataset_client do |client| + datasets = client.list_datasets + if options[:json] + out.json(datasets) + elsif datasets.empty? + out.warn('No datasets found') + else + rows = datasets.map do |d| + [d[:name].to_s, (d[:description] || '').to_s, + (d[:latest_version] || '-').to_s, (d[:row_count] || 0).to_s] + end + out.table(%w[name description version row_count], rows) + end + end + end + default_task :list + + desc 'show NAME', 'Show dataset info and first 10 rows' + option :version, type: :numeric, desc: 'Specific version number' + def show(name) + out = formatter + with_dataset_client do |client| + kwargs = { name: name } + kwargs[:version] = options[:version] if options[:version] + result = client.get_dataset(**kwargs) + if result[:error] + out.error("Dataset '#{name}': #{result[:error]}") + raise SystemExit, 1 + end + + if options[:json] + out.json(result) + else + out.header("Dataset: #{result[:name]}") + out.spacer + out.detail({ version: result[:version], row_count: result[:row_count] }) + out.spacer + preview = (result[:rows] || []).first(10) + if preview.empty? + out.warn('No rows in this dataset version') + else + rows = preview.map do |r| + [r[:row_index].to_s, r[:input].to_s.slice(0, 60), (r[:expected_output] || '').to_s.slice(0, 60)] + end + out.table(%w[index input expected_output], rows) + remaining = result[:row_count].to_i - preview.size + out.warn("... #{remaining} more rows not shown") if remaining.positive? + end + end + end + end + + desc 'import NAME PATH', 'Import a dataset from a file' + option :format, type: :string, default: 'json', enum: %w[json csv jsonl], desc: 'File format' + option :description, type: :string, desc: 'Dataset description' + def import(name, path) + out = formatter + with_dataset_client do |client| + unless File.exist?(path) + out.error("File not found: #{path}") + raise SystemExit, 1 + end + + result = client.import_dataset( + name: name, + path: path, + format: options[:format], + description: options[:description] + ) + if options[:json] + out.json(result) + else + out.success("Imported '#{result[:name]}' v#{result[:version]} (#{result[:row_count]} rows)") + end + end + end + + desc 'export NAME PATH', 'Export a dataset to a file' + option :format, type: :string, default: 'json', enum: %w[json csv jsonl], desc: 'File format' + option :version, type: :numeric, desc: 'Version to export' + def export(name, path) + out = formatter + with_dataset_client do |client| + kwargs = { name: name, path: path, format: options[:format] } + kwargs[:version] = options[:version] if options[:version] + result = client.export_dataset(**kwargs) + if options[:json] + out.json(result) + else + out.success("Exported #{result[:row_count]} rows to #{result[:path]}") + end + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def with_dataset_client + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_data + + begin + require 'legion/extensions/dataset' + require 'legion/extensions/dataset/runners/dataset' + require 'legion/extensions/dataset/client' + rescue LoadError + formatter.error('lex-dataset gem is not installed (gem install lex-dataset)') + raise SystemExit, 1 + end + + db = Legion::Data.db + client = Legion::Extensions::Dataset::Client.new(db: db) + yield client + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + end + end + end +end diff --git a/lib/legion/cli/debug_command.rb b/lib/legion/cli/debug_command.rb new file mode 100644 index 00000000..7b8d3229 --- /dev/null +++ b/lib/legion/cli/debug_command.rb @@ -0,0 +1,453 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'net/http' +require 'json' +require 'socket' +require 'thor' + +module Legion + module CLI + class Debug < Thor + namespace 'debug' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :port, type: :numeric, default: 4567, desc: 'API port' + class_option :host, type: :string, default: '127.0.0.1', desc: 'API host' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'dump', 'Full diagnostic dump (markdown, suitable for piping to LLM)' + default_task :dump + def dump + sections = collect_all_sections + + output = if options[:json] + ::JSON.pretty_generate(sections) + else + build_markdown(sections) + end + + puts output + + path = write_dump_file(output) + warn "Saved to #{path}" if path + end + + DEBUG_DIR = File.expand_path('~/.legionio/debug') + + no_commands do # rubocop:disable Metrics/BlockLength + private + + def collect_all_sections + sections = {} + sections[:versions] = section_versions + sections[:doctor] = section_doctor + sections[:config] = section_config + sections[:gems] = section_gems + sections[:extensions] = section_extensions + sections[:rbac] = section_rbac + sections[:llm] = section_llm + sections[:gaia] = section_gaia + sections[:transport] = section_transport + sections[:events] = section_events + sections[:apollo] = section_apollo + sections[:remote_redis] = section_remote_redis + sections[:local_redis] = section_local_redis + sections[:postgresql] = section_postgresql + sections[:rabbitmq] = section_rabbitmq + sections[:api_health] = section_api_health + sections + end + + def write_dump_file(output) + FileUtils.mkdir_p(DEBUG_DIR) + ext = options[:json] ? 'json' : 'md' + filename = "#{Time.now.utc.strftime('%Y-%m-%d_%H%M%S')}.#{ext}" + path = File.join(DEBUG_DIR, filename) + File.write(path, output) + path + rescue StandardError => e + warn "Warning: could not write debug file: #{e.message}" + nil + end + + def api_host + options[:host] || '127.0.0.1' + end + + def api_port_number + options[:port] || 4567 + end + + def api_get(path) + uri = URI("http://#{api_host}:#{api_port_number}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 5 + response = http.get(uri.request_uri) + ::JSON.parse(response.body, symbolize_names: true) + rescue StandardError => e + { error: e.message } + end + + def load_settings + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = 'error' + Connection.ensure_settings(resolve_secrets: false) + rescue StandardError + nil + end + + def section_versions + components = {} + components[:legionio] = defined?(Legion::VERSION) ? Legion::VERSION : 'unknown' + components[:ruby] = RUBY_VERSION + components[:platform] = RUBY_PLATFORM + components[:yjit] = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? + + %w[legion-transport legion-cache legion-crypt legion-data + legion-json legion-logging legion-settings + legion-llm legion-gaia legion-mcp legion-rbac legion-tty].each do |gem_name| + spec = Gem::Specification.find_by_name(gem_name) + components[gem_name.to_sym] = spec.version.to_s + rescue Gem::MissingSpecError + components[gem_name.to_sym] = 'not installed' + end + + components + rescue StandardError => e + { error: e.message } + end + + def section_doctor + load_settings + require 'legion/cli/doctor_command' + Doctor::CHECKS.map do |name| + check = Doctor.const_get(name).new + result = check.run + { name: result.name, status: result.status, message: result.message } + rescue StandardError => e + { name: name.to_s, status: :error, message: e.message } + end + rescue StandardError => e + { error: e.message } + end + + def section_config + load_settings + settings_hash = Legion::Settings.loader.to_hash + redact_deep(settings_hash) + rescue StandardError => e + { error: e.message } + end + + def section_gems + gems = {} + duplicates = [] + Gem::Specification.each do |spec| + next unless spec.name.start_with?('legion-', 'lex-', 'legionio') + + gems[spec.name] ||= [] + gems[spec.name] << spec.version.to_s + end + + gems.each do |name, versions| + duplicates << { name: name, versions: versions } if versions.size > 1 + end + + { total: gems.size, duplicates: duplicates, + versions: gems.transform_values { |v| v.max_by { |ver| Gem::Version.new(ver) } } } + rescue StandardError => e + { error: e.message } + end + + def section_extensions + data = api_get('/api/extensions') + return data if data[:error] + + exts = data[:data] || data[:extensions] || data + { count: exts.is_a?(Array) ? exts.size : nil, extensions: exts } + end + + def section_rbac + api_get('/api/rbac/roles') + end + + def section_llm + load_settings + require 'legion/llm' + Legion::Settings.merge_settings(:llm, Legion::LLM::Settings.default) + settings = Legion::LLM.settings + providers = settings[:providers] || {} + { + started: defined?(Legion::LLM) && Legion::LLM.started?, + default_provider: settings[:default_provider], + default_model: settings[:default_model], + providers: providers.map { |name, cfg| { name: name, enabled: cfg[:enabled] } } + } + rescue StandardError => e + { error: e.message } + end + + def section_gaia + status = api_get('/api/gaia/status') + channels = api_get('/api/gaia/channels') + buffer = api_get('/api/gaia/buffer') + sessions = api_get('/api/gaia/sessions') + { status: status[:data] || status, channels: channels[:data] || channels, + buffer: buffer[:data] || buffer, sessions: sessions[:data] || sessions } + end + + def section_transport + api_get('/api/transport/status') + end + + def section_events + api_get('/api/events/recent?count=20') + end + + def section_apollo + api_get('/api/apollo/stats') + end + + def section_remote_redis + load_settings + cache_cfg = Legion::Settings[:cache] + return { error: 'no cache config' } unless cache_cfg.is_a?(Hash) && cache_cfg[:servers] + + server = cache_cfg[:servers].first + host, port = server.to_s.split(':') + password = cache_cfg[:password] + + redis_info(host, port.to_i, password) + rescue StandardError => e + { error: e.message } + end + + def section_local_redis + load_settings + local_cfg = Legion::Settings[:cache_local] + return { error: 'no cache_local config' } unless local_cfg.is_a?(Hash) && local_cfg[:servers] + + server = local_cfg[:servers].first + host, port = server.to_s.split(':') + password = local_cfg[:password] + + redis_info(host, port.to_i, password) + rescue StandardError => e + { error: e.message } + end + + def section_postgresql + load_settings + data_cfg = Legion::Settings[:data] + return { error: 'no data config' } unless data_cfg.is_a?(Hash) && data_cfg[:creds] + + creds = data_cfg[:creds] + require 'pg' + conn = PG.connect( + host: creds[:host], port: creds[:port] || 5432, + dbname: creds[:database], user: creds[:user], password: creds[:password], + connect_timeout: 5 + ) + + db_size = conn.exec_params( + 'SELECT pg_size_pretty(pg_database_size(current_database())) AS size' + ).first['size'] + migration = conn.exec_params( + 'SELECT version FROM schema_info ORDER BY version DESC LIMIT 1' + ).first + migration_version = migration ? migration['version'] : 'unknown' + + tables = conn.exec_params(<<~SQL).to_a + SELECT tablename AS name, + pg_size_pretty(pg_total_relation_size(quote_ident(tablename))) AS size, + (SELECT n_live_tup FROM pg_stat_user_tables WHERE relname = tablename) AS rows + FROM pg_tables WHERE schemaname = 'public' + ORDER BY pg_total_relation_size(quote_ident(tablename)) DESC LIMIT 20 + SQL + + conn.close + { db_size: db_size, migration_version: migration_version, tables: tables } + rescue LoadError + { error: 'pg gem not available' } + rescue StandardError => e + { error: e.message } + end + + def section_rabbitmq + load_settings + transport_cfg = Legion::Settings[:transport] || {} + host = transport_cfg[:host] || 'localhost' + mgmt_port = transport_cfg[:management_port] || 15_672 + user = transport_cfg[:user] || 'guest' + pass = transport_cfg[:password] || 'guest' + vhost = transport_cfg[:vhost] || '/' + + uri = URI("http://#{host}:#{mgmt_port}/api/overview") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 5 + req = Net::HTTP::Get.new(uri) + req.basic_auth(user, pass) + resp = http.request(req) + overview = ::JSON.parse(resp.body, symbolize_names: true) + + encoded_vhost = URI.encode_www_form_component(vhost) + queues_uri = URI("http://#{host}:#{mgmt_port}/api/queues/#{encoded_vhost}") + req2 = Net::HTTP::Get.new("#{queues_uri.path}?page=1&page_size=15&sort=messages&sort_reverse=true") + req2.basic_auth(user, pass) + resp2 = http.request(req2) + queues = ::JSON.parse(resp2.body, symbolize_names: true) + + queue_list = queues.is_a?(Array) ? queues : (queues[:items] || []) + + { + cluster_name: overview[:cluster_name], + rabbitmq_version: overview[:rabbitmq_version], + erlang_version: overview[:erlang_version], + message_stats: overview[:message_stats], + queue_totals: overview[:queue_totals], + object_totals: overview[:object_totals], + top_queues: queue_list.first(15).map do |q| + { name: q[:name], messages: q[:messages], consumers: q[:consumers] } + end + } + rescue StandardError => e + { error: e.message } + end + + def section_api_health + ready = api_get('/api/ready') + health = api_get('/api/health') + capacity = api_get('/api/capacity') + cost = api_get('/api/cost/summary') + { ready: ready, health: health, capacity: capacity, cost: cost } + end + + def redis_info(host, port, password) + socket = TCPSocket.new(host, port) + socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) + + if password && !password.empty? + socket.write("AUTH #{password}\r\n") + auth_resp = socket.gets + return { error: "AUTH failed: #{auth_resp&.strip}" } unless auth_resp&.start_with?('+OK') + end + + info = redis_command(socket, 'INFO memory') + dbsize_raw = redis_command(socket, 'DBSIZE') + + socket.close + + memory_lines = info.lines.select { |l| l.include?(':') }.to_h { |l| l.strip.split(':', 2) } + dbsize = dbsize_raw.to_s.scan(/\d+/).first + + { + used_memory_human: memory_lines['used_memory_human'], + used_memory_peak_human: memory_lines['used_memory_peak_human'], + maxmemory_human: memory_lines['maxmemory_human'], + mem_fragmentation_ratio: memory_lines['mem_fragmentation_ratio'], + dbsize: dbsize + } + rescue StandardError => e + { error: e.message } + end + + def redis_command(socket, cmd) + parts = cmd.split + socket.write("*#{parts.size}\r\n") + parts.each { |p| socket.write("$#{p.bytesize}\r\n#{p}\r\n") } + + first = socket.gets + return '' unless first + + case first[0] + when '+', ':' then first[1..].strip + when '-' then "ERROR: #{first[1..].strip}" + when '$' + len = first[1..].to_i + return '' if len.negative? + + data = socket.read(len + 2) + data&.strip || '' + when '*' + count = first[1..].to_i + return '' if count.negative? + + count.times.map { redis_read_bulk(socket) }.join("\n") + else + first.strip + end + end + + def redis_read_bulk(socket) + header = socket.gets + return '' unless header&.start_with?('$') + + len = header[1..].to_i + return '' if len.negative? + + data = socket.read(len + 2) + data&.strip || '' + end + + def redact_deep(obj) + case obj + when Hash + obj.each_with_object({}) do |(k, v), h| + h[k] = if k.to_s.match?(/password|secret|token|key|credential/i) && v.is_a?(String) + '[REDACTED]' + else + redact_deep(v) + end + end + when Array + obj.map { |v| redact_deep(v) } + else + obj + end + end + + def build_markdown(sections) + lines = [] + lines << '# LegionIO Diagnostic Dump' + lines << '' + lines << "Generated: #{Time.now.utc.iso8601}" + lines << '' + + { 'Versions' => :versions, + 'Doctor Checks' => :doctor, + 'Configuration (redacted)' => :config, + 'Installed Gems' => :gems, + 'Loaded Extensions' => :extensions, + 'RBAC Roles' => :rbac, + 'LLM Status' => :llm, + 'GAIA Status' => :gaia, + 'Transport Status' => :transport, + 'Recent Events (last 20)' => :events, + 'Apollo Stats' => :apollo, + 'Remote Redis' => :remote_redis, + 'Local Redis' => :local_redis, + 'PostgreSQL' => :postgresql, + 'RabbitMQ' => :rabbitmq, + 'API Health' => :api_health }.each do |title, key| + lines << "## #{title}" + lines << '' + lines << '```json' + lines << ::JSON.pretty_generate(sections[key]) + lines << '```' + lines << '' + end + + lines.join("\n") + end + end + end + end +end diff --git a/lib/legion/cli/detect_command.rb b/lib/legion/cli/detect_command.rb new file mode 100644 index 00000000..b4101ef5 --- /dev/null +++ b/lib/legion/cli/detect_command.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require 'json' +require 'thor' +require 'legion/cli/output' + +module Legion + module CLI + class Detect < Thor + namespace 'detect' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + CORE_RECOMMENDATIONS = { + 'legion-gaia' => 'Cognitive coordination (GAIA + agentic extensions)', + 'legion-llm' => 'LLM routing and provider integration' + }.freeze + + default_task :scan + + desc 'scan', 'Scan environment and recommend extensions (default)' + option :install, type: :boolean, default: false, desc: 'Interactive install of missing extensions after scan' + option :install_all, type: :boolean, default: false, desc: 'Install all missing extensions without prompting' + option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed without installing' + option :format, type: :string, enum: %w[sarif markdown json], desc: 'Output format (sarif, markdown, json)' + def scan + out = formatter + require_detect_gem + + results = Legion::Extensions::Detect.scan + + if options[:format] + output = Legion::Extensions::Detect.format_results(format: options[:format], detections: results) + puts output.is_a?(String) ? output : ::JSON.pretty_generate(output) + elsif options[:json] + out.json(detections: results) + else + display_detections(out, results) + if options[:install] + interactive_install(out, results) + elsif options[:install_all] + install_missing(out) + end + end + end + + desc 'catalog', 'Show the full detection catalog' + def catalog + out = formatter + require_detect_gem + + catalog = Legion::Extensions::Detect.catalog + + if options[:json] + catalog_data = catalog.map do |rule| + { name: rule[:name], extensions: rule[:extensions], + signals: rule[:signals].map { |s| "#{s[:type]}:#{s[:match]}" } } + end + out.json(catalog: catalog_data) + else + out.header('Detection Catalog') + out.spacer + catalog.each do |rule| + signals = rule[:signals].map { |s| "#{s[:type]}:#{s[:match]}" }.join(', ') + extensions = rule[:extensions].join(', ') + puts " #{out.colorize(rule[:name].ljust(20), :label)} #{extensions.ljust(30)} #{signals}" + end + out.spacer + puts " #{catalog.size} detection rules" + end + end + + desc 'missing', 'List extensions that should be installed but are not' + def missing + out = formatter + require_detect_gem + + missing_gems = Legion::Extensions::Detect.missing + + if options[:json] + out.json(missing: missing_gems) + elsif missing_gems.empty? + out.success('All detected extensions are installed') + else + out.header('Missing Extensions') + missing_gems.each { |name| puts " gem install #{name}" } + out.spacer + puts " #{missing_gems.size} extension(s) recommended" + puts " Run 'legionio detect --install' to install them" + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def require_detect_gem + require 'legion/extensions/detect' + rescue LoadError => e + formatter.error("lex-detect gem not installed: #{e.message}") + puts ' Install with: gem install lex-detect' + raise SystemExit, 1 + end + + def display_detections(out, results) + display_pack_recommendations(out) + + if results.empty? + out.detail('No software detected that maps to Legion extensions.') + return + end + + out.header('Environment Detection') + out.spacer + + installed_count = 0 + total_count = 0 + + results.each do |detection| + signals = detection[:matched_signals].join(', ') + detection[:extensions].each do |ext| + total_count += 1 + is_installed = detection[:installed][ext] + installed_count += 1 if is_installed + status = is_installed ? out.colorize('installed', :success) : out.colorize('missing', :error) + puts " #{out.colorize(detection[:name].ljust(20), :label)} #{signals.ljust(35)} #{ext.ljust(25)} #{status}" + end + end + + out.spacer + puts " #{installed_count} of #{total_count} extension(s) installed" + end + + def display_pack_recommendations(out) + missing = CORE_RECOMMENDATIONS.reject { |gem_name, _| gem_installed?(gem_name) } + return if missing.empty? + + out.header('Recommended Feature Packs') + out.spacer + missing.each do |gem_name, desc| + puts " #{out.colorize(gem_name.ljust(20), :label)} #{desc}" + end + out.spacer + puts " Install with: #{out.colorize('legion setup agentic', :accent)}" + out.spacer + end + + def gem_installed?(name) + Gem::Specification.find_by_name(name) + true + rescue Gem::MissingSpecError + false + end + + def interactive_install(out, results) + missing_gems = Legion::Extensions::Detect.missing + return out.success('All detected extensions are installed') if missing_gems.empty? + + signal_map = build_signal_map(results) + selected = pick_extensions(out, missing_gems, signal_map) + if selected.empty? + puts ' No extensions selected' + return + end + + if options[:dry_run] + out.header('Would install') + selected.each { |name| puts " #{name}" } + return + end + + install_selected(out, selected) + end + + def pick_extensions(out, missing_gems, signal_map) + if tty_prompt_available? + pick_with_tty_prompt(missing_gems, signal_map) + else + pick_with_numbers(out, missing_gems, signal_map) + end + end + + def pick_with_tty_prompt(missing_gems, signal_map) + require 'tty-prompt' + prompt = ::TTY::Prompt.new + + choices = missing_gems.map do |name| + label = signal_map[name] ? "#{name} (#{signal_map[name]})" : name + { name: label, value: name } + end + + prompt.multi_select('Select extensions to install:', choices, per_page: 20, echo: false) + end + + def pick_with_numbers(out, missing_gems, signal_map) + out.spacer + out.header('Missing Extensions') + missing_gems.each_with_index do |name, idx| + reason = signal_map[name] ? " (#{signal_map[name]})" : '' + puts " #{out.colorize((idx + 1).to_s.rjust(3), :label)} #{name}#{reason}" + end + out.spacer + puts ' Enter numbers to install (comma-separated), "all", or "none":' + print ' > ' + input = $stdin.gets&.strip || 'none' + + return missing_gems.dup if input.downcase == 'all' + return [] if input.empty? || input.downcase == 'none' + + indices = input.split(/[,\s]+/).filter_map { |s| s.to_i - 1 if s.match?(/\A\d+\z/) } + indices.filter_map { |i| missing_gems[i] if i >= 0 && i < missing_gems.size }.uniq + end + + def build_signal_map(results) + map = {} + results.each do |detection| + signals = detection[:matched_signals].join(', ') + detection[:installed].each do |gem_name, installed| + map[gem_name] = signals unless installed + end + end + map + end + + def install_selected(out, selected) + out.header("Installing #{selected.size} extension(s)") + result = Legion::Extensions::Detect::Installer.install(selected) + + result[:installed].each { |name| out.success(" Installed #{name}") } + result[:failed].each { |f| out.error(" Failed: #{f[:name]} — #{f[:error]}") } + + out.spacer + if result[:failed].empty? + out.success("#{result[:installed].size} extension(s) installed") + else + out.warn("#{result[:installed].size} installed, #{result[:failed].size} failed") + end + end + + def tty_prompt_available? + require 'tty-prompt' + true + rescue LoadError => e + Legion::Logging.debug("DetectCommand#tty_prompt_available? tty-prompt not available: #{e.message}") if defined?(Legion::Logging) + false + end + + def install_missing(out) + missing_gems = Legion::Extensions::Detect.missing + return if missing_gems.empty? + + out.spacer + if options[:dry_run] + out.header('Would install') + missing_gems.each { |name| puts " #{name}" } + return + end + + out.header('Installing missing extensions') + result = Legion::Extensions::Detect.install_missing! + + result[:installed].each { |name| out.success(" Installed #{name}") } + result[:failed].each { |f| out.error(" Failed: #{f[:name]} — #{f[:error]}") } + + out.spacer + if result[:failed].empty? + out.success("#{result[:installed].size} extension(s) installed") + else + out.warn("#{result[:installed].size} installed, #{result[:failed].size} failed") + end + end + end + end + end +end diff --git a/lib/legion/cli/do_command.rb b/lib/legion/cli/do_command.rb new file mode 100644 index 00000000..0e548f99 --- /dev/null +++ b/lib/legion/cli/do_command.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +module Legion + module CLI + module DoCommand + class << self + def run(intent, formatter, options) + if intent.strip.empty? + formatter.error('Usage: legion do "describe what you want"') + raise SystemExit, 1 + end + + formatter.detail("Routing intent: #{intent}") + + result = try_daemon(intent, options) || try_in_process(intent) || try_llm_classify(intent) + + if result.nil? + formatter.error('No matching tool found') + formatter.detail('Try: legion lex list (to see available extensions)') + raise SystemExit, 1 + end + + display_result(result, formatter, options) + end + + private + + def try_daemon(intent, options) + require 'net/http' + require 'json' + + port = daemon_port(options) + uri = URI("http://localhost:#{port}/api/tasks") + body = ::JSON.generate({ + runner_class: resolve_runner_class(intent) || return, + function: resolve_function(intent) || return, + payload: { intent: intent }, + source: 'cli:do', + check_subtask: false, + generate_task: true + }) + + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 30 + request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + request.body = body + + response = http.request(request) + ::JSON.parse(response.body, symbolize_names: true) + rescue Errno::ECONNREFUSED, Net::OpenTimeout + nil + end + + def try_in_process(intent) + return nil unless defined?(Legion::Tools::Registry) + + matched = Legion::Tools::Registry.all_tools.find do |t| + t.tool_name.include?(intent.downcase.tr(' ', '_')) || + t.description.downcase.include?(intent.downcase) + end + return nil unless matched + + begin + result = matched.call + normalize_in_process_result(result, matched.tool_name) + rescue ArgumentError + { matched: matched.tool_name, status: 'requires_daemon', + note: 'Tool requires arguments; start the daemon and retry: legion start' } + end + end + + def normalize_in_process_result(result, tool_name) + return { matched: tool_name, result: result } unless result.is_a?(Hash) + + normalized = result.dup + normalized[:matched] = tool_name + extracted = extract_tool_text(normalized) + + if normalized[:error] == true + normalized[:error] = extracted.empty? ? 'Tool execution failed' : extracted + elsif !normalized.key?(:result) && !extracted.empty? + normalized[:result] = extracted + end + + normalized + end + + def extract_tool_text(value) + case value + when Hash + error_val = value[:error] || value['error'] + return error_val.to_s unless error_val == true || error_val.nil? || error_val.to_s.empty? + + %i[message result response detail content].each do |key| + extracted = extract_tool_text(value[key] || value[key.to_s]) + return extracted unless extracted.empty? + end + + '' + when Array + value.filter_map do |item| + text = extract_tool_text(item) + text unless text.empty? + end.join("\n") + when String + value.strip + else + value.nil? ? '' : value.to_s + end + end + + def try_llm_classify(intent) + return nil unless defined?(Legion::Tools::Registry) && defined?(Legion::LLM) + + tools = Legion::Tools::Registry.all_tools + return nil if tools.empty? + + catalog = tools.map { |t| "#{t.tool_name}: #{t.description}" } + prompt = "Given these tools:\n#{catalog.join("\n")}\n\n" \ + "Which tool best matches this intent: \"#{intent}\"?\n" \ + 'Reply with ONLY the tool name (e.g., legion.do). ' \ + 'If none match, reply NONE.' + + response = Legion::LLM.ask( + message: prompt, + caller: { extension: 'legionio', tool: 'do_command', tier: 'cli' } + ) + chosen = response.is_a?(Hash) ? response[:response].to_s.strip : response.to_s.strip + return nil if chosen.empty? || chosen.upcase == 'NONE' + + tool = Legion::Tools::Registry.find(chosen) + return nil unless tool + + { matched: tool.tool_name, status: 'resolved', source: 'llm', + note: 'Daemon not running; cannot execute. Start with: legion start' } + rescue StandardError => e + Legion::Logging.debug("DoCommand#try_llm_classify failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def resolve_runner_class(intent) + return nil unless defined?(Legion::Tools::Registry) + + matched = Legion::Tools::Registry.all_tools.find do |t| + t.description.downcase.include?(intent.downcase) + end + return nil unless matched.respond_to?(:extension) && matched.respond_to?(:runner) + + build_runner_class(matched.extension, matched.runner) + end + + def resolve_function(intent) + return nil unless defined?(Legion::Tools::Registry) + + matched = Legion::Tools::Registry.all_tools.find do |t| + t.description.downcase.include?(intent.downcase) + end + return nil unless matched + + matched.tool_name.split(/[-.]/).last + end + + def build_runner_class(extension, runner) + ext_part = extension.to_s.delete_prefix('lex-').split(/[-_]/).map(&:capitalize).join + runner_part = runner.to_s.split('_').map(&:capitalize).join + "Legion::Extensions::#{ext_part}::Runners::#{runner_part}" + end + + def daemon_port(options) + options[:http_port] || begin + require 'legion/settings' + Legion::Settings.load unless Legion::Settings.loaded? + Legion::Settings.dig(:api, :port) || 4567 + rescue StandardError + 4567 + end + end + + def display_result(result, formatter, options) + if options[:json] + formatter.json(result) + elsif result.is_a?(Hash) && result[:error] + formatter.error(result.dig(:error, :message) || result[:error].to_s) + elsif result.is_a?(Hash) && result[:data] + formatter.success('Task dispatched') + formatter.detail(result[:data]) + elsif result.is_a?(Hash) && result[:matched] + formatter.success("Matched: #{result[:matched]}") + formatter.detail(result.except(:matched)) + else + formatter.success('Done') + formatter.detail(result) + end + end + end + end + end +end diff --git a/lib/legion/cli/docs_command.rb b/lib/legion/cli/docs_command.rb new file mode 100644 index 00000000..0d048298 --- /dev/null +++ b/lib/legion/cli/docs_command.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' +require 'legion/python' + +module Legion + module CLI + class Docs < Thor + namespace :docs + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'generate', 'Generate static documentation site' + option :output, type: :string, default: 'docs/site', desc: 'Output directory' + def generate + out = formatter + require 'legion/docs/site_generator' + + out.header('Generating documentation site...') unless options[:json] + stats = Legion::Docs::SiteGenerator.new(output_dir: options[:output]).generate + + if options[:json] + out.json(stats) + else + out.success("Documentation generated in #{stats[:output]}") + puts " #{out.colorize("#{stats[:pages]} pages", :accent)} written" + puts " #{out.colorize("#{stats[:sections]} guide sections", :label)} converted" + end + end + + desc 'serve', 'Preview documentation site locally' + option :port, type: :numeric, default: 4000, desc: 'Port to listen on' + option :dir, type: :string, default: 'docs/site', desc: 'Directory to serve' + def serve + out = formatter + dir = options[:dir] + port = options[:port] + + unless Dir.exist?(dir) + out.warn("Directory #{dir} does not exist. Run 'legion docs generate' first.") + return + end + + out.header('Documentation preview') + puts " Open http://localhost:#{port}/ in your browser" + puts " Serving files from: #{File.expand_path(dir)}" + puts '' + puts " To start: #{Legion::Python.interpreter} -m http.server #{port} --directory #{dir}" + puts ' Press Ctrl+C to stop' + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + end + end + end +end diff --git a/lib/legion/cli/doctor.rb b/lib/legion/cli/doctor.rb new file mode 100644 index 00000000..d503cff1 --- /dev/null +++ b/lib/legion/cli/doctor.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +require 'legion/cli/doctor_command' diff --git a/lib/legion/cli/doctor/api_bind_check.rb b/lib/legion/cli/doctor/api_bind_check.rb new file mode 100644 index 00000000..277ef4f8 --- /dev/null +++ b/lib/legion/cli/doctor/api_bind_check.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Doctor + class ApiBindCheck + LOOPBACK_BINDS = %w[127.0.0.1 ::1 localhost].freeze + + def name + 'API bind address' + end + + def run + return skip_result unless defined?(Legion::Settings) + + api_settings = Legion::Settings[:api] + return skip_result unless api_settings.is_a?(Hash) + + bind = api_settings[:bind] + return skip_result if bind.nil? + + if LOOPBACK_BINDS.include?(bind) + Result.new( + name: name, + status: :pass, + message: "API bound to loopback (#{bind})" + ) + elsif api_settings.dig(:auth, :enabled) == true + Result.new( + name: name, + status: :pass, + message: "API bound to #{bind} with auth enabled" + ) + else + Result.new( + name: name, + status: :warn, + message: "API bound to non-loopback address (#{bind}) without explicit auth configuration", + prescription: "Set api.auth.enabled: true or change api.bind to '127.0.0.1'" + ) + end + end + + private + + def skip_result + Result.new( + name: name, + status: :pass, + message: 'API settings not loaded' + ) + end + end + end + end +end diff --git a/lib/legion/cli/doctor/bundle_check.rb b/lib/legion/cli/doctor/bundle_check.rb new file mode 100644 index 00000000..1a652987 --- /dev/null +++ b/lib/legion/cli/doctor/bundle_check.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'open3' + +module Legion + module CLI + class Doctor + class BundleCheck + def name + 'Bundle status' + end + + def run + gemfile = find_gemfile + return Result.new(name: name, status: :skip, message: 'No Gemfile found') unless gemfile + + stdout, stderr, status = Open3.capture3('bundle check') + if status.success? + Result.new(name: name, status: :pass, message: 'All gems installed') + else + detail = (stdout + stderr).strip + Result.new( + name: name, + status: :fail, + message: "Gems missing or outdated: #{detail.lines.first&.strip}", + prescription: 'Run `bundle install`', + auto_fixable: true + ) + end + rescue Errno::ENOENT => e + Legion::Logging.warn("BundleCheck#run bundler not found: #{e.message}") if defined?(Legion::Logging) + Result.new( + name: name, + status: :fail, + message: 'bundler not found', + prescription: 'Install bundler: `gem install bundler`' + ) + end + + def fix + system('bundle install') + end + + private + + def find_gemfile + %w[Gemfile].map { |f| File.expand_path(f) }.find { |f| File.exist?(f) } + end + end + end + end +end diff --git a/lib/legion/cli/doctor/cache_check.rb b/lib/legion/cli/doctor/cache_check.rb new file mode 100644 index 00000000..f771c631 --- /dev/null +++ b/lib/legion/cli/doctor/cache_check.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'socket' + +module Legion + module CLI + class Doctor + class CacheCheck + def name + 'Cache backend' + end + + def run + backend, host, port = read_cache_config + return Result.new(name: name, status: :skip, message: 'No cache backend configured') if backend.nil? + + check_connection(backend, host, port) + end + + private + + def read_cache_config + return [nil, nil, nil] unless defined?(Legion::Settings) + + cache = Legion::Settings[:cache] + return [nil, nil, nil] unless cache.is_a?(Hash) + + backend = (cache[:backend] || cache[:driver])&.to_s + return [nil, nil, nil] if backend.nil? || backend.empty? + + host = cache[:host] || 'localhost' + port = cache_port(backend, cache) + [backend, host.to_s, port] + rescue StandardError => e + Legion::Logging.warn("CacheCheck#read_cache_config failed: #{e.message}") if defined?(Legion::Logging) + [nil, nil, nil] + end + + def cache_port(backend, cache) + return cache[:port].to_i if cache[:port] + + case backend + when 'redis' then 6379 + when 'memcached' then 11_211 + end + end + + def check_connection(backend, host, port) + return Result.new(name: name, status: :skip, message: "#{backend}: no port configured") if port.nil? + + Socket.tcp(host, port, connect_timeout: 3, &:close) + Result.new(name: name, status: :pass, message: "#{backend} #{host}:#{port} reachable") + rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError + Result.new( + name: name, + status: :fail, + message: "#{backend} not reachable at #{host}:#{port}", + prescription: "Check #{backend} configuration or start the service" + ) + end + end + end + end +end diff --git a/lib/legion/cli/doctor/config_check.rb b/lib/legion/cli/doctor/config_check.rb new file mode 100644 index 00000000..51df7143 --- /dev/null +++ b/lib/legion/cli/doctor/config_check.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'json' + +module Legion + module CLI + class Doctor + class ConfigCheck + CONFIG_PATHS = [ + File.expand_path('~/.legionio/settings'), + '/etc/legionio', + File.expand_path('./settings') + ].freeze + + def name + 'Config files' + end + + def run + found_dirs = CONFIG_PATHS.select { |p| Dir.exist?(p) } + + if found_dirs.empty? + return Result.new( + name: name, + status: :warn, + message: "No config directory found (checked: #{CONFIG_PATHS.join(', ')})", + prescription: 'Run `legion config scaffold` to generate starter config', + auto_fixable: true + ) + end + + invalid_files = find_invalid_json_files(found_dirs) + if invalid_files.any? + messages = invalid_files.map { |f, err| "#{f}: #{err}" } + return Result.new( + name: name, + status: :fail, + message: "Invalid JSON in config files: #{messages.join('; ')}", + prescription: messages.map { |m| "Fix JSON syntax error in #{m}" }.join('; ') + ) + end + + Result.new( + name: name, + status: :pass, + message: "Config found in: #{found_dirs.join(', ')}" + ) + end + + def fix + system('legion config scaffold') + end + + private + + def find_invalid_json_files(dirs) + errors = {} + dirs.each do |dir| + Dir.glob("#{dir}/*.json").each do |file| + content = File.read(file) + ::JSON.parse(content) + rescue ::JSON::ParserError => e + errors[file] = e.message.split("\n").first + rescue Errno::EACCES + errors[file] = 'permission denied' + end + end + errors + end + end + end + end +end diff --git a/lib/legion/cli/doctor/database_check.rb b/lib/legion/cli/doctor/database_check.rb new file mode 100644 index 00000000..f5ac8bc1 --- /dev/null +++ b/lib/legion/cli/doctor/database_check.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Doctor + class DatabaseCheck + def name + 'Database' + end + + def run + adapter, database = read_db_config + return Result.new(name: name, status: :skip, message: 'No database configured') if adapter.nil? + + check_database(adapter, database) + rescue StandardError => e + Result.new( + name: name, + status: :fail, + message: "Database check error: #{e.message}", + prescription: 'Check database configuration in settings' + ) + end + + private + + def read_db_config + return [nil, nil] unless defined?(Legion::Settings) + + data = Legion::Settings[:data] + return [nil, nil] unless data.is_a?(Hash) && data[:adapter] + + [data[:adapter].to_s, data[:database].to_s] + rescue StandardError => e + Legion::Logging.warn("DatabaseCheck#read_db_config failed: #{e.message}") if defined?(Legion::Logging) + [nil, nil] + end + + def check_database(adapter, database) + case adapter + when 'sqlite', 'sqlite3' + check_sqlite(database) + when 'postgresql', 'postgres', 'mysql2', 'mysql' + check_network_db(adapter, database) + else + Result.new(name: name, status: :skip, message: "Unknown adapter: #{adapter}") + end + end + + def check_sqlite(database) + if database.nil? || database.empty? + return Result.new( + name: name, + status: :warn, + message: 'SQLite database path not configured', + prescription: 'Set data.database in settings' + ) + end + + db_path = File.expand_path(database) + dir = File.dirname(db_path) + if File.exist?(db_path) + Result.new(name: name, status: :pass, message: "SQLite file exists: #{db_path}") + elsif Dir.exist?(dir) + Result.new(name: name, status: :pass, message: "SQLite dir writable: #{dir}") + else + Result.new( + name: name, + status: :fail, + message: "SQLite database directory missing: #{dir}", + prescription: "Create directory: `mkdir -p #{dir}`" + ) + end + end + + def check_network_db(adapter, _database) + require 'legion/data' + Legion::Data.setup + Result.new(name: name, status: :pass, message: "#{adapter} connection ok") + rescue LoadError + Result.new(name: name, status: :skip, message: 'legion-data not installed') + rescue StandardError => e + Result.new( + name: name, + status: :fail, + message: "#{adapter} connection failed: #{e.message}", + prescription: 'Check database configuration in settings' + ) + end + end + end + end +end diff --git a/lib/legion/cli/doctor/extensions_check.rb b/lib/legion/cli/doctor/extensions_check.rb new file mode 100644 index 00000000..7f1e26cd --- /dev/null +++ b/lib/legion/cli/doctor/extensions_check.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Doctor + class ExtensionsCheck + LOADER_CONFIG_KEYS = %w[ + agentic ai auto_install blocked categories core gaia identity + parallel_pool_size reserved_prefixes reserved_words + ].freeze + + def name + 'Extensions' + end + + def run + configured = configured_extensions + return Result.new(name: name, status: :skip, message: 'No extensions configured') if configured.empty? + + missing = [] + load_errors = [] + + configured.each do |ext_name| + gem_name = ext_name.start_with?('lex-') ? ext_name : "lex-#{ext_name}" + Gem::Specification.find_by_name(gem_name) + begin + require gem_name.tr('-', '/') + rescue LoadError => e + load_errors << "#{gem_name}: #{e.message}" + end + rescue Gem::MissingSpecError + missing << gem_name + end + + build_result(configured, missing, load_errors) + end + + private + + def configured_extensions + return [] unless defined?(Legion::Settings) + + exts = Legion::Settings[:extensions] + return [] unless exts.is_a?(Hash) || exts.is_a?(Array) + + if exts.is_a?(Array) + exts.map(&:to_s) + else + exts.keys.map(&:to_s).reject { |key| LOADER_CONFIG_KEYS.include?(key) } + end + rescue StandardError => e + Legion::Logging.warn("ExtensionsCheck#configured_extensions failed: #{e.message}") if defined?(Legion::Logging) + [] + end + + def build_result(configured, missing, load_errors) + issues = [] + prescriptions = [] + + missing.each do |gem_name| + issues << "#{gem_name} not installed" + prescriptions << "Install with `gem install #{gem_name}`" + end + + load_errors.each do |err| + issues << "Load error: #{err}" + end + + if issues.empty? + Result.new( + name: name, + status: :pass, + message: "#{configured.size} extension(s) installed and loadable" + ) + else + Result.new( + name: name, + status: :fail, + message: issues.join('; '), + prescription: prescriptions.join('; ') + ) + end + end + end + end + end +end diff --git a/lib/legion/cli/doctor/mode_check.rb b/lib/legion/cli/doctor/mode_check.rb new file mode 100644 index 00000000..f6a32756 --- /dev/null +++ b/lib/legion/cli/doctor/mode_check.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Doctor + class ModeCheck + def name + 'Process mode' + end + + def run + unless defined?(Legion::Settings) + return Result.new( + name: name, + status: :pass, + message: 'Settings not loaded' + ) + end + + explicit_mode = Legion::Settings.dig(:process, :mode) || Legion::Settings[:mode] + + if explicit_mode + Result.new( + name: name, + status: :pass, + message: "Explicit process mode configured: #{explicit_mode}" + ) + else + Result.new( + name: name, + status: :warn, + message: 'No explicit process.mode configured (defaulting to agent)', + prescription: 'Set {"process": {"mode": "agent"}} in settings to prepare for Phase 9 default change to worker' + ) + end + end + end + end + end +end diff --git a/lib/legion/cli/doctor/permissions_check.rb b/lib/legion/cli/doctor/permissions_check.rb new file mode 100644 index 00000000..d155a318 --- /dev/null +++ b/lib/legion/cli/doctor/permissions_check.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Doctor + class PermissionsCheck + DIRECTORIES = [ + File.expand_path('~/.legionio'), + File.expand_path('~/.legionio/settings'), + File.expand_path('~/.legionio/logs'), + '/tmp' + ].freeze + + def name + 'Permissions' + end + + def run + denied = unwritable_directories + + if denied.empty? + Result.new(name: name, status: :pass, message: 'Directory permissions ok') + else + prescriptions = denied.map { |d| "Fix permissions: `chmod 755 #{d}`" } + Result.new( + name: name, + status: :warn, + message: "Cannot write to: #{denied.join(', ')}", + prescription: prescriptions.join('; ') + ) + end + end + + private + + def unwritable_directories + DIRECTORIES.select do |dir| + Dir.exist?(dir) && !File.writable?(dir) + end + end + end + end + end +end diff --git a/lib/legion/cli/doctor/pid_check.rb b/lib/legion/cli/doctor/pid_check.rb new file mode 100644 index 00000000..4caba104 --- /dev/null +++ b/lib/legion/cli/doctor/pid_check.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Doctor + class PidCheck + PID_PATHS = ['/var/run/legion.pid', '/tmp/legion.pid'].freeze + + def name + 'PID files' + end + + def run + stale = stale_pid_files + if stale.empty? + Result.new(name: name, status: :pass, message: 'No stale PID files') + else + rm_cmds = stale.map { |f| "rm #{f}" }.join('; ') + Result.new( + name: name, + status: :warn, + message: "Stale PID file(s): #{stale.join(', ')}", + prescription: "Remove with: #{rm_cmds}", + auto_fixable: true + ) + end + end + + def fix + stale_pid_files.each { |f| File.delete(f) } + end + + private + + def stale_pid_files + PID_PATHS.select do |path| + next false unless File.exist?(path) + + pid = File.read(path).strip.to_i + !process_running?(pid) + rescue StandardError => e + Legion::Logging.warn("PidCheck#stale_pid_files error checking #{path}: #{e.message}") if defined?(Legion::Logging) + false + end + end + + def process_running?(pid) + return false if pid <= 0 + + ::Process.kill(0, pid) + true + rescue Errno::ESRCH + false + rescue Errno::EPERM + true + end + end + end + end +end diff --git a/lib/legion/cli/doctor/python_env_check.rb b/lib/legion/cli/doctor/python_env_check.rb new file mode 100644 index 00000000..33690f10 --- /dev/null +++ b/lib/legion/cli/doctor/python_env_check.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'json' +require 'open3' +require 'legion/python' + +module Legion + module CLI + class Doctor + class PythonEnvCheck + def name + 'Python env' + end + + def run + return skip_result('python3 not found') unless Legion::Python.find_system_python3 + unless Legion::Python.venv_exists? + return warn_result( + 'Python venv missing', + 'Run: legionio setup python', + auto_fixable: true + ) + end + + unless Legion::Python.venv_pip_exists? + return warn_result( + 'pip not found in venv — venv may be corrupt', + 'Run: legionio setup python --rebuild', + auto_fixable: true + ) + end + + unless Legion::Python.venv_python_exists? + return warn_result( + 'python3 not found in venv — venv may be corrupt', + 'Run: legionio setup python --rebuild', + auto_fixable: true + ) + end + + missing = missing_packages + if missing.any? + return warn_result( + "Missing packages: #{missing.join(', ')}", + 'Run: legionio setup python', + auto_fixable: true + ) + end + + pass_result(venv_summary) + rescue StandardError => e + Legion::Logging.error("PythonEnvCheck#run: #{e.message}") if defined?(Legion::Logging) + Result.new( + name: name, + status: :fail, + message: "Python env check error: #{e.message}", + prescription: 'Run: legionio setup python' + ) + end + + def fix + system('legionio', 'setup', 'python', '--rebuild') + end + + private + + def missing_packages + pip = Legion::Python.venv_pip + output, status = Open3.capture2e(pip, 'list', '--format=json') + return Legion::Python::PACKAGES.dup unless status.success? + + installed_names = ::JSON.parse(output).map { |p| p['name'].downcase.tr('-', '_') } + + Legion::Python::PACKAGES.reject do |pkg| + installed_names.include?(pkg.downcase.tr('-', '_')) + end + rescue StandardError + Legion::Python::PACKAGES.dup + end + + def venv_summary + python_bin = Legion::Python.venv_python + if File.executable?(python_bin) + version = `"#{python_bin}" --version 2>&1`.strip + "#{version} at #{Legion::Python::VENV_DIR}" + else + Legion::Python::VENV_DIR + end + rescue StandardError + Legion::Python::VENV_DIR + end + + def pass_result(message) + Result.new(name: name, status: :pass, message: message) + end + + def warn_result(message, prescription, auto_fixable: false) + Result.new( + name: name, + status: :warn, + message: message, + prescription: prescription, + auto_fixable: auto_fixable + ) + end + + def skip_result(message) + Result.new(name: name, status: :skip, message: message) + end + end + end + end +end diff --git a/lib/legion/cli/doctor/rabbitmq_check.rb b/lib/legion/cli/doctor/rabbitmq_check.rb new file mode 100644 index 00000000..181226b9 --- /dev/null +++ b/lib/legion/cli/doctor/rabbitmq_check.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'socket' + +module Legion + module CLI + class Doctor + class RabbitmqCheck + DEFAULT_HOST = 'localhost' + DEFAULT_PORT = 5672 + + def name + 'RabbitMQ connection' + end + + def run + host = settings_host || DEFAULT_HOST + port = settings_port || DEFAULT_PORT + + Socket.tcp(host, port, connect_timeout: 3, &:close) + Result.new(name: name, status: :pass, message: "#{host}:#{port} reachable") + rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError + Result.new( + name: name, + status: :fail, + message: "Cannot connect to #{host}:#{port}", + prescription: 'Start RabbitMQ: `brew services start rabbitmq` or `systemctl start rabbitmq-server`' + ) + rescue LoadError + Result.new(name: name, status: :skip, message: 'socket not available') + end + + private + + def settings_host + return unless defined?(Legion::Settings) + + Legion::Settings[:transport]&.dig(:host) + rescue StandardError => e + Legion::Logging.debug("RabbitmqCheck#settings_host failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def settings_port + return unless defined?(Legion::Settings) + + Legion::Settings[:transport]&.dig(:port) + rescue StandardError => e + Legion::Logging.debug("RabbitmqCheck#settings_port failed: #{e.message}") if defined?(Legion::Logging) + nil + end + end + end + end +end diff --git a/lib/legion/cli/doctor/result.rb b/lib/legion/cli/doctor/result.rb new file mode 100644 index 00000000..b2d1d390 --- /dev/null +++ b/lib/legion/cli/doctor/result.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Doctor + class Result + SCORE_MAP = { pass: 1.0, warn: 0.5, fail: 0.0, skip: nil }.freeze + + attr_reader :name, :status, :message, :prescription, :auto_fixable, :weight + + def initialize(name:, status:, message: nil, prescription: nil, auto_fixable: false, weight: 1.0) # rubocop:disable Metrics/ParameterLists + @name = name + @status = status + @message = message + @prescription = prescription + @auto_fixable = auto_fixable + @weight = weight + end + + def score + SCORE_MAP[status] + end + + def pass? + status == :pass + end + + def fail? + status == :fail + end + + def warn? + status == :warn + end + + def skip? + status == :skip + end + + def to_h + { + name: name, + status: status, + score: score, + weight: weight, + message: message, + prescription: prescription, + auto_fixable: auto_fixable + }.compact + end + end + end + end +end diff --git a/lib/legion/cli/doctor/ruby_version_check.rb b/lib/legion/cli/doctor/ruby_version_check.rb new file mode 100644 index 00000000..bc6c4c18 --- /dev/null +++ b/lib/legion/cli/doctor/ruby_version_check.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Doctor + class RubyVersionCheck + MINIMUM_VERSION = '3.4' + + def name + 'Ruby version' + end + + def run + current = RUBY_VERSION + if Gem::Version.new(current) >= Gem::Version.new(MINIMUM_VERSION) + Result.new(name: name, status: :pass, message: "Ruby #{current}") + else + Result.new( + name: name, + status: :fail, + message: "Ruby #{current} is below minimum #{MINIMUM_VERSION}", + prescription: "Upgrade Ruby to >= #{MINIMUM_VERSION} (current: #{current})" + ) + end + end + end + end + end +end diff --git a/lib/legion/cli/doctor/tls_check.rb b/lib/legion/cli/doctor/tls_check.rb new file mode 100644 index 00000000..2425f716 --- /dev/null +++ b/lib/legion/cli/doctor/tls_check.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Doctor + class TlsCheck + def name + 'TLS' + end + + def run + return Result.new(name: name, status: :skip, message: 'Legion::Settings not available') unless defined?(Legion::Settings) + + issues = [] + any_tls = false + + check_transport_tls(issues) && (any_tls = true) + check_data_tls(issues) && (any_tls = true) + check_api_tls(issues) && (any_tls = true) + + build_result(issues, any_tls) + rescue StandardError => e + Result.new( + name: name, + status: :fail, + message: "TLS check error: #{e.message}", + prescription: 'Review TLS settings configuration' + ) + end + + private + + def check_transport_tls(issues) + tls = safe_tls_settings(:transport) + return false unless tls[:enabled] + + issues << 'transport.tls: verify is none — peer verification disabled' if tls[:verify].to_s == 'none' + + check_cert_file(tls[:cert], 'transport.tls.cert', issues) + check_cert_file(tls[:key], 'transport.tls.key', issues) + check_cert_file(tls[:ca], 'transport.tls.ca', issues) + true + end + + def check_data_tls(issues) + tls = safe_tls_settings(:data) + return false unless tls[:enabled] + + sslmode = tls[:sslmode].to_s + issues << "data.tls: sslmode is '#{sslmode}' — use 'verify-full' to prevent MITM" unless sslmode.empty? || sslmode == 'verify-full' + + true + end + + def check_api_tls(issues) + tls = safe_tls_settings(:api) + return false unless tls[:enabled] + + cert = tls[:cert] + key = tls[:key] + + if cert.nil? || cert.to_s.empty? + issues << 'api.tls: enabled but api.tls.cert is not set' + return true + end + + if key.nil? || key.to_s.empty? + issues << 'api.tls: enabled but api.tls.key is not set' + return true + end + + check_cert_file(cert, 'api.tls.cert', issues) + check_cert_file(key, 'api.tls.key', issues) + true + end + + def build_result(issues, any_tls) + return Result.new(name: name, status: :pass, message: 'TLS not enabled on any component') unless any_tls + + if issues.any? { |i| i.include?('not set') } + return Result.new( + name: name, + status: :fail, + message: issues.first, + prescription: 'Set the missing TLS cert/key paths in settings' + ) + end + + if issues.any? + return Result.new( + name: name, + status: :warn, + message: issues.first, + prescription: 'Review TLS configuration — see api.tls / transport.tls / data.tls in settings' + ) + end + + Result.new(name: name, status: :pass, message: 'TLS configured correctly on enabled components') + end + + def safe_tls_settings(component) + raw = Legion::Settings[component] || {} + tls = raw[:tls] || raw['tls'] || {} + symbolize_keys(tls) + rescue StandardError + {} + end + + def check_cert_file(path, label, issues) + return if path.nil? || path.to_s.empty? + return if path.to_s.start_with?('vault://', 'env://', 'lease://') + return if ::File.exist?(path.to_s) + + issues << "#{label}: '#{path}' does not exist" + end + + def symbolize_keys(hash) + return {} unless hash.is_a?(Hash) + + hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } + end + end + end + end +end diff --git a/lib/legion/cli/doctor/vault_check.rb b/lib/legion/cli/doctor/vault_check.rb new file mode 100644 index 00000000..7dea7148 --- /dev/null +++ b/lib/legion/cli/doctor/vault_check.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'socket' +require 'net/http' +require 'uri' +require 'json' + +module Legion + module CLI + class Doctor + class VaultCheck + DEFAULT_HOST = 'localhost' + DEFAULT_PORT = 8200 + + def name + 'Vault' + end + + def run + host, port = read_vault_config + return Result.new(name: name, status: :skip, message: 'Vault not configured') if host.nil? + + check_vault(host, port) + end + + private + + def read_vault_config + return [nil, nil] unless defined?(Legion::Settings) + + crypt = Legion::Settings[:crypt] + return [nil, nil] unless crypt.is_a?(Hash) && crypt[:vault_enabled] + + addr = crypt[:vault_address] || crypt[:vault_addr] || "http://#{DEFAULT_HOST}:#{DEFAULT_PORT}" + uri = URI.parse(addr) + [uri.host || DEFAULT_HOST, uri.port || DEFAULT_PORT] + rescue StandardError => e + Legion::Logging.warn("VaultCheck#read_vault_config failed: #{e.message}") if defined?(Legion::Logging) + [nil, nil] + end + + def check_vault(host, port) + uri = URI("http://#{host}:#{port}/v1/sys/health") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 3 + response = http.get(uri.path) + body = ::JSON.parse(response.body) + + if body['sealed'] + Result.new( + name: name, + status: :warn, + message: "Vault is sealed at #{host}:#{port}", + prescription: 'Unseal Vault: `vault operator unseal`' + ) + else + Result.new(name: name, status: :pass, message: "Vault #{host}:#{port} reachable and unsealed") + end + rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, Net::OpenTimeout + Result.new( + name: name, + status: :fail, + message: "Cannot connect to Vault at #{host}:#{port}", + prescription: 'Check Vault address and token in settings' + ) + rescue ::JSON::ParserError + Result.new( + name: name, + status: :warn, + message: "Vault responded but returned unexpected body at #{host}:#{port}" + ) + end + end + end + end +end diff --git a/lib/legion/cli/doctor_command.rb b/lib/legion/cli/doctor_command.rb new file mode 100644 index 00000000..ad6c7a12 --- /dev/null +++ b/lib/legion/cli/doctor_command.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Doctor < Thor + autoload :Result, 'legion/cli/doctor/result' + autoload :RubyVersionCheck, 'legion/cli/doctor/ruby_version_check' + autoload :BundleCheck, 'legion/cli/doctor/bundle_check' + autoload :ConfigCheck, 'legion/cli/doctor/config_check' + autoload :RabbitmqCheck, 'legion/cli/doctor/rabbitmq_check' + autoload :DatabaseCheck, 'legion/cli/doctor/database_check' + autoload :CacheCheck, 'legion/cli/doctor/cache_check' + autoload :VaultCheck, 'legion/cli/doctor/vault_check' + autoload :ExtensionsCheck, 'legion/cli/doctor/extensions_check' + autoload :PidCheck, 'legion/cli/doctor/pid_check' + autoload :PermissionsCheck, 'legion/cli/doctor/permissions_check' + autoload :TlsCheck, 'legion/cli/doctor/tls_check' + autoload :ApiBindCheck, 'legion/cli/doctor/api_bind_check' + autoload :ModeCheck, 'legion/cli/doctor/mode_check' + autoload :PythonEnvCheck, 'legion/cli/doctor/python_env_check' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + CHECKS = %i[ + RubyVersionCheck + BundleCheck + ConfigCheck + RabbitmqCheck + DatabaseCheck + CacheCheck + VaultCheck + ExtensionsCheck + PidCheck + PermissionsCheck + TlsCheck + ApiBindCheck + ModeCheck + PythonEnvCheck + ].freeze + + # Weights: security > connectivity > convenience + WEIGHTS = { + 'TLS' => 3.0, + 'Vault connection' => 3.0, + 'Permissions' => 2.5, + 'Ruby version' => 2.0, + 'RabbitMQ connection' => 2.0, + 'Database connection' => 2.0, + 'Cache connection' => 1.5, + 'Bundle' => 1.5, + 'Config' => 1.0, + 'Extensions' => 1.0, + 'Python env' => 1.0, + 'PID files' => 0.5 + }.freeze + + GRADE_THRESHOLDS = [ + [0.95, 'A'], + [0.85, 'B'], + [0.70, 'C'], + [0.50, 'D'] + ].freeze + + desc 'diagnose', 'Check environment health and suggest fixes' + method_option :fix, type: :boolean, default: false, desc: 'Auto-fix issues where possible' + def diagnose + out = formatter + begin + Connection.ensure_settings(resolve_secrets: false) + rescue StandardError => e + Legion::Logging.debug("Doctor#diagnose settings load failed: #{e.message}") if defined?(Legion::Logging) + end + results = run_all_checks + + if options[:json] + output_json(out, results) + else + output_text(out, results) + end + + auto_fix(results) if options[:fix] + + exit(1) if results.any?(&:fail?) + ensure + Connection.shutdown + end + + default_task :diagnose + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + end + + private + + def check_classes + CHECKS.map { |name| Doctor.const_get(name) } + end + + def run_all_checks + check_classes.map do |check_class| + result = check_class.new.run + inject_weight(result) + rescue StandardError => e + Legion::Logging.error("DoctorCommand#run_all_checks unexpected error in #{check_class}: #{e.message}") if defined?(Legion::Logging) + Doctor::Result.new( + name: check_class.new.name, + status: :fail, + message: "Unexpected error: #{e.message}" + ) + end + end + + def inject_weight(result) + weight = WEIGHTS[result.name] || 1.0 + result.instance_variable_set(:@weight, weight) + result + end + + def output_text(out, results) + out.header('Legion Environment Diagnosis') + out.spacer + + results.each { |r| print_result(out, r) } + + out.spacer + print_summary(out, results) + end + + def print_result(out, result) + label = result.name.ljust(24) + score_label = result.score ? format('%.1f', result.score) : ' - ' + case result.status + when :pass + puts " #{out.colorize('pass', :green)} #{score_label} #{label} #{out.colorize(result.message.to_s, :muted)}" + when :fail + puts " #{out.colorize('FAIL', :red)} #{score_label} #{label} #{out.colorize(result.message.to_s, :critical)}" + puts " #{out.colorize('->', :yellow)} #{result.prescription}" if result.prescription + when :warn + puts " #{out.colorize('WARN', :yellow)} #{score_label} #{label} #{out.colorize(result.message.to_s, :caution)}" + puts " #{out.colorize('->', :yellow)} #{result.prescription}" if result.prescription + when :skip + puts " #{out.colorize('skip', :muted)} #{score_label} #{label} #{out.colorize(result.message.to_s, :disabled)}" + end + end + + def print_summary(out, results) + passed = results.count(&:pass?) + failed = results.count(&:fail?) + warned = results.count(&:warn?) + skipped = results.count(&:skip?) + auto_fixable = results.count { |r| (r.fail? || r.warn?) && r.auto_fixable } + + agg = aggregate_score(results) + grade = letter_grade(agg) + + msg = build_summary_message(passed, failed, warned, skipped, auto_fixable) + + out.spacer + grade_color = grade_color_for(grade) + puts " Health Score: #{out.colorize(format('%.0f%%', agg * 100), grade_color)} Grade: #{out.colorize(grade, grade_color)}" + out.spacer + + if failed.positive? + out.error(msg) + elsif warned.positive? + out.warn(msg) + else + out.success(msg) + end + end + + def build_summary_message(passed, failed, warned, skipped, auto_fixable) + msg = "#{passed} passed" + msg += ", #{failed} failed" if failed.positive? + msg += ", #{warned} warnings" if warned.positive? + msg += ", #{skipped} skipped" if skipped.positive? + msg += " (#{auto_fixable} auto-fixable, run with --fix)" if auto_fixable.positive? && !options[:fix] + msg + end + + def aggregate_score(results) + scored = results.reject(&:skip?) + return 0.0 if scored.empty? + + weighted_sum = scored.sum { |r| r.score * r.weight } + total_weight = scored.sum(&:weight) + total_weight.zero? ? 0.0 : weighted_sum / total_weight + end + + def letter_grade(score) + GRADE_THRESHOLDS.each do |threshold, grade| + return grade if score >= threshold + end + 'F' + end + + def grade_color_for(grade) + case grade + when 'A' then :green + when 'B' then :cyan + when 'C' then :yellow + when 'D' then :caution + else :red + end + end + + def output_json(out, results) + passed = results.count(&:pass?) + failed = results.count(&:fail?) + warned = results.count(&:warn?) + skipped = results.count(&:skip?) + auto_fixable = results.count { |r| (r.fail? || r.warn?) && r.auto_fixable } + agg = aggregate_score(results) + grade = letter_grade(agg) + + out.json({ + results: results.map(&:to_h), + summary: { + passed: passed, + failed: failed, + warnings: warned, + skipped: skipped, + auto_fixable: auto_fixable, + health_score: agg.round(4), + grade: grade + } + }) + end + + def auto_fix(results) + fixable = results.select { |r| (r.fail? || r.warn?) && r.auto_fixable } + return if fixable.empty? + + out = formatter + out.spacer + out.header('Auto-fixing issues...') + + check_classes.each do |check_class| + instance = check_class.new + result = results.find { |r| r.name == instance.name } + next unless result && (result.fail? || result.warn?) && result.auto_fixable + next unless instance.respond_to?(:fix) + + out.success("Fixing: #{result.name}") + instance.fix + rescue StandardError => e + out.error("Fix failed for #{check_class}: #{e.message}") + end + end + end + end +end diff --git a/lib/legion/cli/error.rb b/lib/legion/cli/error.rb new file mode 100644 index 00000000..c0c5d193 --- /dev/null +++ b/lib/legion/cli/error.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Error < StandardError + attr_reader :suggestions, :code + + def self.actionable(code:, message:, suggestions: []) + err = new(message) + err.instance_variable_set(:@code, code) + err.instance_variable_set(:@suggestions, suggestions) + err + end + + def actionable? + !suggestions.nil? && !suggestions.empty? + end + end + end +end diff --git a/lib/legion/cli/error_forwarder.rb b/lib/legion/cli/error_forwarder.rb new file mode 100644 index 00000000..f069a080 --- /dev/null +++ b/lib/legion/cli/error_forwarder.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'net/http' + +module Legion + module CLI + module ErrorForwarder + module_function + + def forward_error(exception, command: nil) + payload = { + level: 'error', + message: exception.message.to_s, + exception_class: exception.class.name, + backtrace: Array(exception.backtrace).first(10), + component_type: 'cli', + source: ::File.basename($PROGRAM_NAME) + } + payload[:command] = command if command + post_to_daemon(payload) + rescue StandardError + # silently swallow — forwarding must never crash the CLI + end + + def forward_warning(message, command: nil) + payload = { + level: 'warn', + message: message.to_s, + component_type: 'cli', + source: ::File.basename($PROGRAM_NAME) + } + payload[:command] = command if command + post_to_daemon(payload) + rescue StandardError + # silently swallow — forwarding must never crash the CLI + end + + def post_to_daemon(payload) + port = daemon_port + uri = URI("http://localhost:#{port}/api/logs") + + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 2 + http.read_timeout = 2 + + request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json') + request.body = ::JSON.generate(payload) + + http.request(request) + rescue StandardError + nil + end + + def daemon_port + require 'legion/settings' + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + api_settings = Legion::Settings[:api] + (api_settings.is_a?(Hash) && api_settings[:port]) || 4567 + rescue StandardError + 4567 + end + end + end +end diff --git a/lib/legion/cli/error_handler.rb b/lib/legion/cli/error_handler.rb new file mode 100644 index 00000000..fc21a572 --- /dev/null +++ b/lib/legion/cli/error_handler.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'legion/logging' + +module Legion + module CLI + module ErrorHandler + extend Legion::Logging::Helper + + PATTERNS = [ + { + match: /connection refused.*5672|ECONNREFUSED.*5672|bunny.*not connected/i, + code: :transport_unavailable, + message: 'Cannot connect to RabbitMQ', + suggestions: [ + "Run 'legion doctor' to diagnose connectivity", + "Check transport settings: 'legion config show -s transport'", + 'Verify RabbitMQ is running: brew services list | grep rabbitmq' + ] + }, + { + match: /table.*not.*found|no such table|PG::UndefinedTable|Sequel::DatabaseError.*exist/i, + code: :database_missing, + message: 'Database table not found', + suggestions: [ + "Run 'legion start' to apply pending migrations", + "Check database config: 'legion config show -s data'", + "Verify database is running: 'legion doctor'" + ] + }, + { + match: /extension.*not.*found|no such extension|uninitialized constant.*Extensions/i, + code: :extension_missing, + message: 'Extension not found', + suggestions: [ + "Search available extensions: 'legion marketplace search '", + 'Install with: gem install lex-', + "List installed: 'legion lex list'" + ] + }, + { + match: /permission denied|EACCES/i, + code: :permission_denied, + message: 'Permission denied', + suggestions: [ + 'Try running with sudo for system directories', + 'Set custom config dir: LEGIONIO_CONFIG_DIR=~/.legionio', + 'Check file permissions: ls -la ~/.legionio/' + ] + }, + { + match: /legion-data.*not.*connected|data.*not.*available/i, + code: :data_unavailable, + message: 'Database not connected', + suggestions: [ + "Check database config: 'legion config show -s data'", + "Run diagnostics: 'legion doctor'", + 'Some commands work without a database — try adding --no-data flag' + ] + }, + { + match: /vault.*not.*connected|vault.*sealed|VAULT_ADDR/i, + code: :vault_unavailable, + message: 'Vault not connected', + suggestions: [ + "Check Vault config: 'legion config show -s crypt'", + 'Verify VAULT_ADDR and VAULT_TOKEN environment variables', + "Run diagnostics: 'legion doctor'" + ] + } + ].freeze + + module_function + + def wrap(error) + pattern = PATTERNS.find { |p| error.message.match?(p[:match]) } + unless pattern + handle_exception(error, level: :error, handled: true, operation: :wrap_cli_error, matched: false) if logging_available? + log.error("[CLI] unhandled error: #{error.class} - #{error.message}") if logging_available? + return error + end + + handle_exception(error, level: :warn, handled: true, operation: :wrap_cli_error, code: pattern[:code]) if logging_available? + log.warn("[CLI] matched error pattern :#{pattern[:code]} - #{error.message}") if logging_available? + Error.actionable( + code: pattern[:code], + message: "#{pattern[:message]}: #{error.message}", + suggestions: pattern[:suggestions] + ) + end + + def format_error(error, formatter) + formatter.error(error.message) + return unless error.is_a?(Error) && error.actionable? + + error.suggestions.each do |suggestion| + puts " #{formatter.colorize('>', :label)} #{suggestion}" + end + end + + def logging_available? + defined?(Legion::Logging) + end + end + end +end diff --git a/lib/legion/cli/eval_command.rb b/lib/legion/cli/eval_command.rb new file mode 100644 index 00000000..a409f0d9 --- /dev/null +++ b/lib/legion/cli/eval_command.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require 'json' + +module Legion + module CLI + class Eval < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'run', 'Run eval against a dataset and gate on a threshold' + map 'run' => :execute + option :dataset, type: :string, required: true, aliases: '-d', desc: 'Dataset name' + option :threshold, type: :numeric, default: 0.8, aliases: '-t', desc: 'Pass/fail threshold (0.0-1.0)' + option :evaluator, type: :string, default: nil, aliases: '-e', desc: 'Evaluator name' + option :exit_code, type: :boolean, default: false, desc: 'Exit 1 if gate fails (for CI use)' + def execute + setup_connection + require_eval! + require_dataset! + + rows = fetch_dataset_rows(options[:dataset]) + report = run_evaluations(rows) + + avg_score = report.dig(:summary, :avg_score) || 0.0 + passed = avg_score >= options[:threshold] + + ci_report = build_ci_report(report, avg_score, passed) + + if options[:json] + formatter.json(ci_report) + else + render_human_report(ci_report, avg_score, passed) + end + + exit(1) if options[:exit_code] && !passed + ensure + Connection.shutdown + end + + desc 'experiments', 'List all tracked experiments' + def experiments + setup_connection + require_dataset! + + client = Legion::Extensions::Dataset::Client.new + rows = client.list_experiments + out = formatter + + if rows.empty? + out.warn('no experiments found') + return + end + + if options[:json] + out.json(experiments: rows) + else + out.header('Experiments') + out.spacer + table_rows = rows.map do |r| + [r[:id].to_s, r[:name].to_s, r[:status].to_s, r[:created_at].to_s, r[:summary].to_s[0, 60]] + end + out.table(%w[id name status created summary], table_rows) + end + ensure + Connection.shutdown + end + + desc 'promote', 'Tag a prompt version from a passing experiment for production' + option :experiment, type: :string, required: true, aliases: '-e', desc: 'Experiment name' + option :tag, type: :string, required: true, aliases: '-t', desc: 'Tag to apply (e.g. production)' + def promote + setup_connection + require_dataset! + require_prompt! + + dataset_client = Legion::Extensions::Dataset::Client.new + experiment = dataset_client.get_experiment(name: options[:experiment]) + raise CLI::Error, "Experiment '#{options[:experiment]}' not found" if experiment.nil? + raise CLI::Error, "Experiment '#{options[:experiment]}' has no prompt linked" if experiment[:prompt_name].nil? + + prompt_client = Legion::Extensions::Prompt::Client.new + result = prompt_client.tag_prompt( + name: experiment[:prompt_name], + tag: options[:tag], + version: experiment[:prompt_version] + ) + + out = formatter + if options[:json] + out.json(result) + else + out.success("Tagged prompt '#{experiment[:prompt_name]}' v#{experiment[:prompt_version]} as '#{options[:tag]}'") + end + ensure + Connection.shutdown + end + + desc 'compare', 'Compare two experiment runs side by side' + option :run1, type: :string, required: true, desc: 'First experiment name' + option :run2, type: :string, required: true, desc: 'Second experiment name' + def compare + setup_connection + require_dataset! + + client = Legion::Extensions::Dataset::Client.new + diff = client.compare_experiments(exp1_name: options[:run1], exp2_name: options[:run2]) + raise CLI::Error, 'One or both experiments not found' if diff[:error] + + out = formatter + if options[:json] + out.json(diff) + else + out.header("Compare: #{diff[:exp1]} vs #{diff[:exp2]}") + out.spacer + table_rows = [ + ['Rows compared', diff[:rows_compared].to_s], + ['Regressions', diff[:regression_count].to_s], + ['Improvements', diff[:improvement_count].to_s] + ] + out.table(%w[metric value], table_rows) + end + ensure + Connection.shutdown + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def setup_connection + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_data + end + + def require_eval! + return if defined?(Legion::Extensions::Eval::Client) + + raise CLI::Error, 'lex-eval extension is not loaded. Install and enable it first.' + end + + def require_dataset! + return if defined?(Legion::Extensions::Dataset::Client) + + raise CLI::Error, 'lex-dataset extension is not loaded. Install and enable it first.' + end + + def require_prompt! + return if defined?(Legion::Extensions::Prompt::Client) + + raise CLI::Error, 'lex-prompt extension is not loaded. Install and enable it first.' + end + + def fetch_dataset_rows(name) + client = Legion::Extensions::Dataset::Client.new + result = client.get_dataset(name: name) + raise CLI::Error, "Dataset '#{name}' not found" if result[:error] + + result[:rows].map do |r| + { input: r[:input], output: r[:input], expected: r[:expected_output] } + end + end + + def run_evaluations(rows) + Legion::Extensions::Eval::Client.new.run_evaluation(inputs: rows) + end + + def build_ci_report(report, avg_score, passed) + { + dataset: options[:dataset], + evaluator: report[:evaluator], + threshold: options[:threshold], + avg_score: avg_score, + passed: passed, + summary: report[:summary], + results: report[:results], + timestamp: Time.now.utc.iso8601 + } + end + + def render_human_report(report, avg_score, passed) + out = formatter + out.header("Eval Gate: #{report[:dataset]}") + out.spacer + out.detail({ + dataset: report[:dataset], + evaluator: report[:evaluator], + total: report.dig(:summary, :total), + passed: report.dig(:summary, :passed), + failed: report.dig(:summary, :failed), + avg_score: format('%.3f', avg_score), + threshold: report[:threshold], + gate: passed ? 'PASSED' : 'FAILED' + }) + out.spacer + + if passed + out.success("Gate PASSED (avg_score=#{format('%.3f', avg_score)} >= threshold=#{report[:threshold]})") + else + out.warn("Gate FAILED (avg_score=#{format('%.3f', avg_score)} < threshold=#{report[:threshold]})") + end + end + end + end + end +end diff --git a/lib/legion/cli/failover_command.rb b/lib/legion/cli/failover_command.rb new file mode 100644 index 00000000..a7253693 --- /dev/null +++ b/lib/legion/cli/failover_command.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' +require 'legion/cli/connection' + +module Legion + module CLI + class Failover < Thor + namespace 'failover' + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'promote', 'Promote a region to primary' + option :region, type: :string, required: true, desc: 'Target region to promote' + option :dry_run, type: :boolean, default: false, desc: 'Show replication lag without promoting' + option :force, type: :boolean, default: false, desc: 'Force promotion even if lag exceeds threshold' + def promote + out = formatter + ensure_settings + + target = options[:region] + require 'legion/region/failover' + + if options[:dry_run] + run_dry_run(out, target) + else + run_promote(out, target) + end + rescue Legion::Region::Failover::UnknownRegionError => e + out.error(e.message) + raise SystemExit, 1 + rescue Legion::Region::Failover::LagTooHighError => e + if options[:force] + out.warn("#{e.message} — forcing promotion") + force_promote(out, target) + else + out.error("#{e.message}. Use --force to override.") + raise SystemExit, 1 + end + end + + desc 'status', 'Show current region configuration' + def status + out = formatter + ensure_settings + + region_config = Legion::Settings[:region] || {} + if options[:json] + out.json(region_config) + else + out.header('Region Configuration') + out.detail({ + current: region_config[:current] || '(not set)', + primary: region_config[:primary] || '(not set)', + failover: region_config[:failover] || '(not set)', + peers: (region_config[:peers] || []).join(', ').then { |s| s.empty? ? '(none)' : s }, + default_affinity: region_config[:default_affinity] || 'prefer_local' + }) + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def ensure_settings + Connection.ensure_settings(resolve_secrets: false) + end + + def run_dry_run(out, target) + Legion::Region::Failover.validate_target!(target) + lag = Legion::Region::Failover.replication_lag + + if options[:json] + out.json({ target: target, lag_seconds: lag, dry_run: true }) + else + out.header('Failover Dry Run') + lag_str = lag ? "#{lag.round(1)}s" : '(unavailable — no DB connection)' + out.detail({ target: target, replication_lag: lag_str }) + if lag && lag > Legion::Region::Failover::MAX_LAG_SECONDS + out.warn("Lag exceeds #{Legion::Region::Failover::MAX_LAG_SECONDS}s threshold") + else + out.success('Lag within acceptable range') + end + end + end + + def run_promote(out, target) + result = Legion::Region::Failover.promote!(region: target) + if options[:json] + out.json(result) + else + out.success("Region promoted: #{result[:previous]} -> #{result[:promoted]}") + lag_str = result[:lag_seconds] ? "#{result[:lag_seconds].round(1)}s" : '(unavailable)' + out.detail({ promoted: result[:promoted], previous: result[:previous], replication_lag: lag_str }) + end + end + + def force_promote(out, target) + previous = Legion::Settings.dig(:region, :primary) + lag = Legion::Region::Failover.replication_lag + Legion::Settings[:region][:primary] = target + Legion::Events.emit('region.failover', from: previous, to: target) if defined?(Legion::Events) + + result = { promoted: target, previous: previous, lag_seconds: lag, forced: true } + if options[:json] + out.json(result) + else + out.success("Region force-promoted: #{previous} -> #{target}") + end + end + end + end + end +end diff --git a/lib/legion/cli/features_command.rb b/lib/legion/cli/features_command.rb new file mode 100644 index 00000000..11d20df3 --- /dev/null +++ b/lib/legion/cli/features_command.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +require 'English' +require 'thor' +require 'rbconfig' +require 'legion/cli/output' + +module Legion + module CLI + class Features < Thor + namespace 'features' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + BUNDLES = { + tasking: { + label: 'Tasking Engine', + description: 'Task scheduling, chaining, conditioning, and metering', + gems: %w[lex-tasker lex-scheduler lex-lex lex-conditioner lex-transformer lex-health lex-metering] + }, + cognitive: { + label: 'Cognitive / Agentic', + description: 'Full GAIA cognitive stack (13 agentic domains + tick + mesh + apollo)', + gems: %w[legion-gaia] + }, + ai: { + label: 'AI / LLM', + description: 'LLM routing, provider integration, and MCP tools', + gems: %w[legion-llm legion-mcp] + }, + observability: { + label: 'Observability', + description: 'Telemetry, logging, anomaly detection, and webhooks', + gems: %w[lex-telemetry lex-log lex-webhook lex-detect] + }, + governance: { + label: 'Governance & Security', + description: 'RBAC, audit trails, FinOps, PII protection, and lifecycle governance', + gems: %w[lex-governance lex-audit lex-finops lex-privatecore] + }, + channels: { + label: 'Chat Channels', + description: 'Slack, Microsoft Teams, and GitHub chat adapters', + gems: %w[lex-slack lex-microsoft_teams lex-github] + }, + devtools: { + label: 'Development Tools', + description: 'Eval gating, datasets, prompt templates, autofix, and mind-growth', + gems: %w[lex-eval lex-dataset lex-prompt lex-autofix lex-mind-growth] + }, + swarm: { + label: 'Swarm / Multi-Agent', + description: 'Multi-agent orchestration, GitHub swarm pipeline, and ACP adapter', + gems: %w[lex-swarm lex-swarm-github lex-adapter lex-acp] + }, + services: { + label: 'Service Integrations', + description: 'HTTP, Vault, and Consul service connectors', + gems: %w[lex-http lex-vault lex-consul] + } + }.freeze + + desc 'install', 'Interactively select and install feature bundles' + option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed without installing' + option :all, type: :boolean, default: false, desc: 'Install all feature bundles' + def install + out = formatter + selected = options[:all] ? BUNDLES.keys : prompt_bundle_selection(out) + + return out.error('No bundles selected') if selected.empty? + + gems = resolve_gems(selected) + installed, missing = partition_gems(gems) + + if missing.empty? + report_all_present(out, selected, installed) + elsif options[:dry_run] + report_dry_run(out, selected, installed, missing) + else + execute_install(out, selected, installed, missing) + end + end + + desc 'list', 'Show available feature bundles and their install status' + def list + out = formatter + statuses = bundle_statuses + + if options[:json] + out.json(bundles: statuses) + else + out.header('Feature Bundles') + out.spacer + statuses.each { |s| print_bundle_status(out, s) } + out.spacer + installed_count = statuses.count { |s| s[:missing].empty? } + puts " #{installed_count} of #{statuses.size} bundle(s) fully installed" + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def prompt_bundle_selection(out) + require 'tty-prompt' + prompt = ::TTY::Prompt.new + statuses = bundle_statuses + + choices = statuses.map do |s| + icon = s[:missing].empty? ? '(installed)' : "(#{s[:missing].size} gem(s) to install)" + { name: "#{s[:label]} #{icon} - #{s[:description]}", value: s[:name] } + end + choices << { name: 'Everything - install all bundles above', value: :everything } + + out.header('Legion Feature Bundles') + out.spacer + + selected = prompt.multi_select('Select bundles to install:', choices, per_page: 12, + echo: false, + min: 1) + return BUNDLES.keys if selected.include?(:everything) + + selected + rescue ::TTY::Reader::InputInterrupt, Interrupt + out.spacer + puts ' Cancelled.' + [] + end + + def resolve_gems(bundle_keys) + bundle_keys.flat_map { |key| BUNDLES[key][:gems] }.uniq.sort + end + + def partition_gems(gem_names) + installed = [] + missing = [] + gem_names.each do |name| + Gem::Specification.find_by_name(name) + installed << name + rescue Gem::MissingSpecError + missing << name + end + [installed, missing] + end + + def gem_version(name) + Gem::Specification.find_by_name(name).version.to_s + rescue Gem::MissingSpecError + nil + end + + def bundle_statuses + BUNDLES.map do |name, bundle| + installed, missing = partition_gems(bundle[:gems]) + { + name: name, + label: bundle[:label], + description: bundle[:description], + installed: installed.map { |g| { name: g, version: gem_version(g) } }, + missing: missing + } + end + end + + def print_bundle_status(out, status) + icon = if status[:missing].empty? + out.colorize('installed', :success) + else + out.colorize("#{status[:missing].size} missing", :muted) + end + puts " #{out.colorize(status[:label].ljust(24), :label)} #{icon}" + status[:installed].each do |g| + puts " #{out.colorize(g[:name], :success)} #{g[:version]}" + end + status[:missing].each do |g| + puts " #{out.colorize(g, :muted)} (not installed)" + end + end + + def report_all_present(out, selected, installed) + labels = selected.map { |k| BUNDLES[k][:label] }.join(', ') + if options[:json] + out.json(status: 'already_installed', bundles: selected, + gems: installed.map { |g| { name: g, version: gem_version(g) } }) + else + out.success("All gems already installed for: #{labels}") + installed.each { |g| puts " #{g} #{gem_version(g)}" } + end + end + + def report_dry_run(out, selected, installed, missing) + labels = selected.map { |k| BUNDLES[k][:label] }.join(', ') + if options[:json] + out.json(status: 'dry_run', bundles: selected, to_install: missing, + already_installed: installed.map { |g| { name: g, version: gem_version(g) } }) + else + out.header("Feature install dry run: #{labels}") + out.spacer + missing.each { |g| puts " #{out.colorize('install', :accent)} #{g}" } + installed.each { |g| puts " #{out.colorize('skip', :muted)} #{g} #{gem_version(g)} (already installed)" } + end + end + + def execute_install(out, selected, installed, missing) + labels = selected.map { |k| BUNDLES[k][:label] }.join(', ') + out.header("Installing: #{labels}") unless options[:json] + out.spacer unless options[:json] + puts " #{missing.size} gem(s) to install, #{installed.size} already present" unless options[:json] + out.spacer unless options[:json] + + gem_bin = File.join(RbConfig::CONFIG['bindir'], 'gem') + results = missing.map { |g| install_gem(g, gem_bin, out) } + + Gem::Specification.reset + successes, failures = results.partition { |r| r[:status] == 'installed' } + + if options[:json] + out.json(bundles: selected, installed: successes, failed: failures, + already_present: installed.map { |g| { name: g, version: gem_version(g) } }) + else + out.spacer + if failures.empty? + out.success("#{successes.size} gem(s) installed successfully") + else + out.error("#{failures.size} gem(s) failed to install") + failures.each { |f| puts " #{f[:name]}: #{f[:error]}" } + out.spacer + out.success("#{successes.size} gem(s) installed") unless successes.empty? + end + suggest_next_steps(out, selected) + end + end + + def install_gem(name, gem_bin, out) + puts " Installing #{name}..." unless options[:json] + output = `#{gem_bin} install #{name} --no-document 2>&1` + if $CHILD_STATUS.success? + out.success(" #{name} installed") unless options[:json] + { name: name, status: 'installed' } + else + out.error(" #{name} failed") unless options[:json] + { name: name, status: 'failed', error: output.strip.lines.last&.strip } + end + end + + def suggest_next_steps(out, selected) + out.spacer + puts ' Next steps:' + if selected.include?(:cognitive) || selected.include?(:ai) + puts ' legion start # full daemon with cognitive stack' + puts ' legion start --lite # single-process, no external services' + puts ' legion chat # interactive AI conversation' + end + puts ' legion features list # verify installed bundles' + puts ' legion doctor # check environment health' + end + end + end + end +end diff --git a/lib/legion/cli/fleet_command.rb b/lib/legion/cli/fleet_command.rb new file mode 100644 index 00000000..b9bad53c --- /dev/null +++ b/lib/legion/cli/fleet_command.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require 'thor' +require_relative 'api_client' +require_relative 'output' +require_relative 'connection' + +module Legion + module CLI + class FleetCommand < Thor + def self.exit_on_failure? + true + end + + namespace 'fleet' + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'status', 'Show fleet pipeline status (queue depths, active work items, workers)' + def status + out = formatter + data = fetch_fleet_status + + if options[:json] + out.json(data) + else + out.header('Fleet Pipeline Status') + out.spacer + + puts " Active work items: #{data[:active_work_items] || 0}" + puts " Workers: #{data[:workers] || 0}" + out.spacer + + if data[:queues]&.any? + rows = data[:queues].map { |q| [q[:name], q[:depth].to_s] } + out.table(%w[Queue Depth], rows) + else + puts ' No fleet queues found' + end + end + end + default_task :status + + desc 'pending', 'List work items awaiting human approval' + option :limit, type: :numeric, default: 20, aliases: ['-n'], desc: 'Max items to show' + def pending + out = formatter + items = fetch_pending_approvals + + if options[:json] + out.json(items) + elsif items.empty? + puts ' No pending approvals' + else + out.header('Pending Approvals') + rows = items.first(options[:limit]).map do |item| + [item[:id].to_s, item[:source_ref].to_s, item[:title].to_s, + item[:source].to_s, item[:created_at].to_s] + end + out.table(['ID', 'Source Ref', 'Title', 'Source', 'Created'], rows) + end + end + + desc 'approve ID', 'Approve a pending work item and resume the pipeline' + def approve(id) + out = formatter + result = approve_work_item(id.to_i) + + if options[:json] + out.json(result) + elsif result[:success] + out.success("Approved work item #{id} (#{result[:work_item_id]})") + puts " Pipeline resumed: #{result[:resumed]}" + else + out.error("Approval failed: #{result[:error]}") + raise SystemExit, 1 + end + end + + desc 'add SOURCE', 'Add a source to the fleet pipeline (e.g., github, slack)' + option :owner, type: :string, desc: 'GitHub org/owner (for github source)' + option :repo, type: :string, desc: 'GitHub repo name (for github source)' + option :webhook_url, type: :string, desc: 'Webhook callback URL' + def add(source) + out = formatter + result = add_fleet_source(source) + + if options[:json] + out.json(result) + elsif result[:success] + out.success("Added #{source} as fleet source") + puts " Absorber: #{result[:absorber]}" if result[:absorber] + puts " Webhook: #{result[:webhook_url]}" if result[:webhook_url] + out.spacer + puts ' The fleet will now process incoming events from this source.' + else + out.error("Failed to add source: #{result[:error]}") + raise SystemExit, 1 + end + end + + desc 'config', 'Show fleet configuration' + def config + out = formatter + with_settings do + fleet_settings = Legion::Settings[:fleet] || {} + + if options[:json] + out.json(fleet_settings) + else + out.header('Fleet Configuration') + out.spacer + puts " Enabled: #{fleet_settings[:enabled] || false}" + puts " Sources: #{(fleet_settings[:sources] || []).join(', ').then { |s| s.empty? ? 'none' : s }}" + out.spacer + + puts ' Defaults:' + puts " Planning: #{fleet_settings.dig(:planning, :enabled) ? 'enabled' : 'disabled'}" + puts " Validation: #{fleet_settings.dig(:validation, :enabled) ? 'enabled' : 'disabled'}" + puts " Max iterations: #{fleet_settings.dig(:implementation, :max_iterations) || 5}" + puts " Validators: #{fleet_settings.dig(:implementation, :validators) || 3}" + puts " Isolation: #{fleet_settings.dig(:workspace, :isolation) || 'worktree'}" + end + end + end + + no_commands do + include ApiClient + + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + + private + + def fetch_fleet_status + api_get('/api/fleet/status') + rescue SystemExit + { queues: [], active_work_items: 0, workers: 0 } + end + + def fetch_pending_approvals + api_get('/api/fleet/pending') + rescue SystemExit + [] + end + + def approve_work_item(id) + api_post('/api/fleet/approve', id: id) + end + + def add_fleet_source(source) + payload = { source: source } + payload[:owner] = options[:owner] if options[:owner] + payload[:repo] = options[:repo] if options[:repo] + payload[:webhook_url] = options[:webhook_url] if options[:webhook_url] + api_post('/api/fleet/sources', **payload) + end + + def with_settings + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = 'error' + Connection.ensure_settings + yield + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + end + end + end +end diff --git a/lib/legion/cli/fleet_setup.rb b/lib/legion/cli/fleet_setup.rb new file mode 100644 index 00000000..81927b76 --- /dev/null +++ b/lib/legion/cli/fleet_setup.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require 'rbconfig' +require 'fileutils' + +module Legion + module CLI + class FleetSetup + FLEET_GEMS = %w[ + lex-assessor lex-planner lex-developer lex-validator + lex-codegen lex-eval lex-exec + lex-tasker lex-conditioner lex-transformer + lex-audit lex-governance lex-agentic-social + ].freeze + + MANIFEST_PATH = File.expand_path('../fleet/manifest.yml', __dir__) + + attr_reader :formatter, :options + + def initialize(formatter:, options:) + @formatter = formatter + @options = options + end + + def self.fleet_gems + FLEET_GEMS + end + + def self.manifest_path + MANIFEST_PATH + end + + # Phase 1: Install gems. Extensions register themselves on next LegionIO start. + def phase1_install + formatter.header('Fleet Setup - Phase 1: Install') unless options[:json] + + installed, missing = partition_gems + if missing.empty? + formatter.success('All fleet gems already installed') unless options[:json] + return { success: true, installed: installed.size, skipped: 0 } + end + + result = install_gems(missing) + if result[:failed].positive? + formatter.error("#{result[:failed]} gem(s) failed to install") unless options[:json] + return { success: false, error: :install_failed, **result } + end + + formatter.success("Phase 1 complete: #{result[:installed]} gem(s) installed") unless options[:json] + { success: true, **result } + end + + # Phase 2: Wire relationships, seed rules, register settings. + # Requires that extensions have been loaded and registered (LexRegister). + def phase2_wire + formatter.header('Fleet Setup - Phase 2: Wire') unless options[:json] + + require 'legion/workflow/manifest' + require 'legion/workflow/loader' + + manifest = Legion::Workflow::Manifest.new(path: MANIFEST_PATH) + unless manifest.valid? + formatter.error("Invalid manifest: #{manifest.errors.join(', ')}") unless options[:json] + return { success: false, error: :invalid_manifest, errors: manifest.errors } + end + + loader_result = Legion::Workflow::Loader.new.install(manifest) + unless loader_result[:success] + formatter.error("Relationship install failed: #{loader_result[:error]}") unless options[:json] + return { success: false, error: :relationship_install_failed, detail: loader_result } + end + + apply_planner_timeout_policy + rules_result = seed_conditioner_rules + settings_result = register_settings + + unless options[:json] + formatter.success( + "Phase 2 complete: chain_id=#{loader_result[:chain_id]}, " \ + "#{loader_result[:relationship_ids].size} relationships" + ) + end + + { + success: true, + chain_id: loader_result[:chain_id], + relationships: loader_result[:relationship_ids].size, + rules: rules_result, + settings: settings_result + } + end + + private + + def partition_gems + installed = [] + missing = [] + FLEET_GEMS.each do |name| + Gem::Specification.find_by_name(name) + installed << name + rescue Gem::MissingSpecError + missing << name + end + [installed, missing] + end + + def install_gems(gems = nil) + gems ||= partition_gems.last + gem_bin = File.join(RbConfig::CONFIG['bindir'], 'gem') + installed = 0 + failed = 0 + + gems.each do |name| + formatter.spacer unless options[:json] + puts " Installing #{name}..." unless options[:json] + output = `#{gem_bin} install #{name} --no-document 2>&1` + if $CHILD_STATUS&.success? + installed += 1 + else + failed += 1 + formatter.error(" #{name} failed: #{output.strip.lines.last&.strip}") unless options[:json] + end + end + + { installed: installed, failed: failed } + end + + # Apply RabbitMQ consumer timeout policy for planner queue. + # The planner queue needs a longer consumer timeout for LLM plan generation. + # Default RabbitMQ consumer timeout is 30min; planner may need up to 60min. + def apply_planner_timeout_policy + system( + 'rabbitmqctl', 'set_policy', 'fleet-timeout', + '^lex\\.planner\\.', '{"consumer-timeout": 3600000}', + '--apply-to', 'queues' + ) + formatter.success('Applied planner queue timeout policy (60min)') unless options[:json] + rescue StandardError => e + formatter.warn("Planner timeout policy skipped: #{e.message}") unless options[:json] + end + + # Register fleet settings and LLM routing overrides via load_module_settings. + # This uses the Loader's internal deep_merge and mark_dirty! automatically. + def register_settings + require 'legion/fleet/settings' + Legion::Fleet::Settings.apply! + { success: true } + rescue StandardError => e + formatter.warn("Settings registration skipped: #{e.message}") unless options[:json] + { success: false, error: e.message } + end + + def seed_conditioner_rules + require 'legion/fleet/conditioner_rules' + Legion::Fleet::ConditionerRules.seed! + rescue StandardError => e + formatter.warn("Conditioner rules seeding skipped: #{e.message}") unless options[:json] + { success: false, error: e.message } + end + end + end +end diff --git a/lib/legion/cli/function.rb b/lib/legion/cli/function.rb index 12a30d61..2ff62e21 100755 --- a/lib/legion/cli/function.rb +++ b/lib/legion/cli/function.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli class Function < Thor @@ -8,7 +10,7 @@ def find response = ask 'trigger extension?', limited_to: Legion::Data::Model::Extension.map(:name) trigger_extension = Legion::Data::Model::Extension.where(name: response).first runners = Legion::Data::Model::Runner.where(extension_id: trigger_extension.values[:id]) - if runners.count == 1 + if runners.one? trigger_runner = runners.first say "Auto selecting #{trigger_runner.values[:name]} since it is the only option" else @@ -18,7 +20,7 @@ def find functions = Legion::Data::Model::Function.where(runner_id: trigger_runner.values[:id]) - if functions.count == 1 + if functions.one? trigger_function = functions.first say "Auto selecting #{trigger_function.values[:name]} since it is the only option" else diff --git a/lib/legion/cli/gaia_command.rb b/lib/legion/cli/gaia_command.rb new file mode 100644 index 00000000..380877dc --- /dev/null +++ b/lib/legion/cli/gaia_command.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' + +module Legion + module CLI + class Gaia < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + class_option :port, type: :numeric, default: 4567, desc: 'API port' + class_option :host, type: :string, default: '127.0.0.1', desc: 'API host' + + desc 'status', 'Show GAIA cognitive coordination status' + def status + out = formatter + data = api_get('/api/gaia/status') + + if data.nil? + show_not_running(out) + elsif options[:json] + out.json(data) + else + show_status(out, data) + end + end + default_task :status + + desc 'channels', 'List registered GAIA communication channels' + def channels + out = formatter + data = api_get('/api/gaia/channels') + + if data.nil? + show_not_running(out) + return + end + + if options[:json] + out.json(data) + return + end + + channels_list = data[:channels] || [] + out.header("GAIA Channels (#{channels_list.size})") + if channels_list.empty? + puts ' No channels registered.' + else + channels_list.each do |ch| + status_str = ch[:started] ? 'active' : 'stopped' + caps = ch[:capabilities]&.any? ? " [#{ch[:capabilities].join(', ')}]" : '' + puts " #{ch[:id]} (#{ch[:type] || 'unknown'}) - #{status_str}#{caps}" + end + end + end + + desc 'buffer', 'Show sensory buffer status' + def buffer + out = formatter + data = api_get('/api/gaia/buffer') + + if data.nil? + show_not_running(out) + return + end + + if options[:json] + out.json(data) + return + end + + out.header('GAIA Sensory Buffer') + out.detail({ + 'Depth' => (data[:depth] || 0).to_s, + 'Empty' => (data[:empty] || true).to_s, + 'Max Size' => (data[:max_size] || 'unknown').to_s + }) + end + + desc 'sessions', 'Show active session count' + def sessions + out = formatter + data = api_get('/api/gaia/sessions') + + if data.nil? + show_not_running(out) + return + end + + if options[:json] + out.json(data) + return + end + + out.header('GAIA Sessions') + out.detail({ + 'Active Sessions' => (data[:count] || 0).to_s, + 'System Active' => (data[:active] || false).to_s + }) + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def api_get(path) + host = options[:host] || '127.0.0.1' + port = options[:port] || api_port + uri = URI("http://#{host}:#{port}#{path}") + http = Net::HTTP.new(uri.host, uri.port) + http.open_timeout = 3 + http.read_timeout = 5 + response = http.get(uri.path) + parsed = ::JSON.parse(response.body, symbolize_names: true) + parsed[:data] || parsed + rescue StandardError => e + Legion::Logging.warn("GaiaCommand#api_get failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def api_port + require 'legion/settings' + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + api_settings = Legion::Settings[:api] + (api_settings.is_a?(Hash) && api_settings[:port]) || 4567 + rescue StandardError => e + Legion::Logging.warn("GaiaCommand#api_port failed: #{e.message}") if defined?(Legion::Logging) + 4567 + end + + def show_not_running(out) + if options[:json] + out.json({ started: false, error: 'daemon not running' }) + else + out.header('GAIA Status') + out.warn('Legion daemon not running (connection refused)') + end + end + + def show_status(out, data) + out.header('GAIA Status') + details = { + 'Mode' => (data[:mode] || 'unknown').to_s, + 'Started' => data[:started].to_s, + 'Buffer' => (data[:buffer_depth] || 0).to_s, + 'Sessions' => (data[:sessions] || 0).to_s, + 'Extensions' => "#{data[:extensions_loaded]}/#{data[:extensions_total]} loaded", + 'Phases' => "#{data[:wired_phases]} wired" + } + out.detail(details) + + channels_list = data[:active_channels] || [] + out.spacer + out.header("Active Channels (#{channels_list.size})") + channels_list.each { |ch| puts " #{ch}" } + + phases = data[:phase_list] || [] + return if phases.empty? + + out.spacer + out.header("Wired Phases (#{phases.size})") + puts " #{phases.join(', ')}" + end + end + end + end +end diff --git a/lib/legion/cli/generate_command.rb b/lib/legion/cli/generate_command.rb new file mode 100644 index 00000000..185e7b9c --- /dev/null +++ b/lib/legion/cli/generate_command.rb @@ -0,0 +1,449 @@ +# frozen_string_literal: true + +require 'fileutils' + +module Legion + module CLI + class Generate < Thor + ACTOR_PARENTS = { + 'subscription' => 'Legion::Extensions::Actors::Subscription', + 'every' => 'Legion::Extensions::Actors::Every', + 'poll' => 'Legion::Extensions::Actors::Poll', + 'once' => 'Legion::Extensions::Actors::Once', + 'loop' => 'Legion::Extensions::Actors::Loop' + }.freeze + + def self.exit_on_failure? + true + end + + desc 'runner NAME', 'Add a runner to the current LEX' + option :functions, type: :string, desc: 'Comma-separated function names to scaffold' + def runner(name) + out = formatter + lex = detect_lex(out) + + runner_path = "lib/legion/extensions/#{lex}/runners/#{name}.rb" + spec_path = "spec/runners/#{name}_spec.rb" + + ensure_dir(File.dirname(runner_path)) + ensure_dir(File.dirname(spec_path)) + + class_name = name.split('_').map(&:capitalize).join + lex_class = lex.split('_').map(&:capitalize).join + + functions = (options[:functions] || 'execute').split(',').map(&:strip) + + File.write(runner_path, runner_template(lex, lex_class, name, class_name, functions)) + File.write(spec_path, runner_spec_template(lex, lex_class, name, class_name, functions)) + + out.success("Created #{runner_path}") + out.success("Created #{spec_path}") + + return unless functions.any? + + out.spacer + puts " Functions scaffolded: #{functions.join(', ')}" + puts " Add actors with: legion generate actor #{name} --type subscription" + end + + desc 'actor NAME', 'Add an actor to the current LEX' + option :type, type: :string, default: 'subscription', + enum: %w[subscription every poll once loop], + desc: 'Actor execution type' + option :runner, type: :string, desc: 'Associated runner name' + option :interval, type: :numeric, default: 60, desc: 'Interval in seconds (for every/poll types)' + def actor(name) + out = formatter + lex = detect_lex(out) + + actor_path = "lib/legion/extensions/#{lex}/actors/#{name}.rb" + spec_path = "spec/actors/#{name}_spec.rb" + + ensure_dir(File.dirname(actor_path)) + ensure_dir(File.dirname(spec_path)) + + class_name = name.split('_').map(&:capitalize).join + lex_class = lex.split('_').map(&:capitalize).join + actor_type = options[:type] + runner_name = options[:runner] || name + interval = options[:interval] + + actor_opts = { lex_class: lex_class, class_name: class_name, type: actor_type, + runner_name: runner_name, interval: interval } + File.write(actor_path, actor_template(**actor_opts)) + File.write(spec_path, actor_spec_template(**actor_opts)) + + out.success("Created #{actor_path}") + out.success("Created #{spec_path}") + puts " Actor type: #{actor_type}" + end + + desc 'exchange NAME', 'Add a transport exchange to the current LEX' + def exchange(name) + out = formatter + lex = detect_lex(out) + + exchange_path = "lib/legion/extensions/#{lex}/transport/exchanges/#{name}.rb" + ensure_dir(File.dirname(exchange_path)) + + class_name = name.split('_').map(&:capitalize).join + lex_class = lex.split('_').map(&:capitalize).join + + File.write(exchange_path, exchange_template(lex, lex_class, name, class_name)) + out.success("Created #{exchange_path}") + end + + desc 'queue NAME', 'Add a transport queue to the current LEX' + option :exchange, type: :string, desc: 'Exchange to bind to' + def queue(name) + out = formatter + lex = detect_lex(out) + + queue_path = "lib/legion/extensions/#{lex}/transport/queues/#{name}.rb" + ensure_dir(File.dirname(queue_path)) + + class_name = name.split('_').map(&:capitalize).join + lex_class = lex.split('_').map(&:capitalize).join + + File.write(queue_path, queue_template(lex, lex_class, name, class_name)) + out.success("Created #{queue_path}") + end + + desc 'message NAME', 'Add a transport message to the current LEX' + def message(name) + out = formatter + lex = detect_lex(out) + + message_path = "lib/legion/extensions/#{lex}/transport/messages/#{name}.rb" + ensure_dir(File.dirname(message_path)) + + class_name = name.split('_').map(&:capitalize).join + lex_class = lex.split('_').map(&:capitalize).join + + File.write(message_path, message_template(lex, lex_class, name, class_name)) + out.success("Created #{message_path}") + end + + desc 'absorber NAME', 'Add an absorber to the current LEX' + option :url_pattern, type: :string, default: 'example.com/path/*', desc: 'URL pattern to match' + def absorber(name) + out = formatter + lex = detect_lex(out) + + snake = name.downcase.gsub(/[^a-z0-9]/, '_') + class_name = snake.split('_').map(&:capitalize).join + lex_class = lex.split('_').map(&:capitalize).join + url_pat = options[:url_pattern] + + absorber_path = "lib/legion/extensions/#{lex}/absorbers/#{snake}.rb" + spec_path = "spec/absorbers/#{snake}_spec.rb" + + ensure_dir(File.dirname(absorber_path)) + ensure_dir(File.dirname(spec_path)) + + File.write(absorber_path, absorber_template(lex_class, class_name, url_pat)) + File.write(spec_path, absorber_spec_template(lex_class, class_name, url_pat)) + + out.success("Created #{absorber_path}") + out.success("Created #{spec_path}") + end + + desc 'tool NAME', 'Add a chat tool to the current LEX' + def tool(name) + out = formatter + lex = detect_lex(out) + + tool_path = "lib/legion/extensions/#{lex}/tools/#{name}.rb" + spec_path = "spec/tools/#{name}_spec.rb" + + ensure_dir(File.dirname(tool_path)) + ensure_dir(File.dirname(spec_path)) + + class_name = name.split('_').map(&:capitalize).join + lex_class = lex.split('_').map(&:capitalize).join + + File.write(tool_path, tool_template(lex, lex_class, name, class_name)) + File.write(spec_path, tool_spec_template(lex, lex_class, name, class_name)) + + out.success("Created #{tool_path}") + out.success("Created #{spec_path}") + end + + no_commands do # rubocop:disable Metrics/BlockLength + def formatter + @formatter ||= Output::Formatter.new + end + + def detect_lex(out) + pwd = Dir.pwd + dir_name = File.basename(pwd) + unless dir_name.start_with?('lex-') + out.error("Not inside a LEX directory (expected lex-* directory, got '#{dir_name}')") + out.spacer + puts ' Run this command from inside a LEX project directory:' + puts ' cd lex-myextension' + puts ' legion generate runner my_runner' + raise SystemExit, 1 + end + dir_name.sub('lex-', '') + end + + def ensure_dir(path) + FileUtils.mkdir_p(path) + end + + # --- Templates --- + + def runner_template(_lex, lex_class, _name, class_name, functions) + func_methods = functions.map do |func| + <<~RUBY.gsub(/^/, ' ') + def #{func}(**) + { success: true } + end + RUBY + end.join("\n") + + <<~RUBY + # frozen_string_literal: true + + module Legion + module Extensions + module #{lex_class} + module Runners + module #{class_name} + extend Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined? 'Helpers::Lex' + + #{func_methods} + end + end + end + end + end + RUBY + end + + def runner_spec_template(_lex, lex_class, _name, class_name, functions) + func_specs = functions.map do |func| + " it { is_expected.to respond_to(:#{func}).with_any_keywords }" + end.join("\n") + + <<~RUBY + # frozen_string_literal: true + + RSpec.describe Legion::Extensions::#{lex_class}::Runners::#{class_name} do + subject { described_class } + + it { should be_a Module } + #{func_specs} + end + RUBY + end + + def actor_template(lex_class:, class_name:, type:, runner_name:, interval:, **) # rubocop:disable Metrics/ParameterLists + parent = ACTOR_PARENTS[type] + interval_line = %w[every poll].include?(type) ? "\n INTERVAL = #{interval}\n" : '' + runner_class = runner_name.split('_').map(&:capitalize).join + + <<~RUBY + # frozen_string_literal: true + + module Legion + module Extensions + module #{lex_class} + module Actors + class #{class_name} < #{parent}#{interval_line} + include Legion::Extensions::#{lex_class}::Runners::#{runner_class} + end + end + end + end + end + RUBY + end + + def actor_spec_template(lex_class:, class_name:, type:, **) + parent = ACTOR_PARENTS[type] + + <<~RUBY + # frozen_string_literal: true + + RSpec.describe Legion::Extensions::#{lex_class}::Actors::#{class_name} do + it { expect(described_class.ancestors).to include(#{parent}) } + end + RUBY + end + + def exchange_template(_lex, lex_class, _name, class_name) + <<~RUBY + # frozen_string_literal: true + + module Legion + module Extensions + module #{lex_class} + module Transport + module Exchanges + class #{class_name} < Legion::Transport::Exchange + end + end + end + end + end + end + RUBY + end + + def queue_template(_lex, lex_class, _name, class_name) + <<~RUBY + # frozen_string_literal: true + + module Legion + module Extensions + module #{lex_class} + module Transport + module Queues + class #{class_name} < Legion::Transport::Queue + end + end + end + end + end + end + RUBY + end + + def message_template(_lex, lex_class, _name, class_name) + <<~RUBY + # frozen_string_literal: true + + module Legion + module Extensions + module #{lex_class} + module Transport + module Messages + class #{class_name} < Legion::Transport::Message + end + end + end + end + end + end + RUBY + end + + def tool_template(lex, lex_class, _name, class_name) + tool_snake = class_name.gsub(/([a-z\d])([A-Z])/, '\1_\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').downcase + <<~RUBY + # frozen_string_literal: true + + require 'legion/cli/chat/extension_tool' + + module Legion + module Extensions + module #{lex_class} + module Tools + class #{class_name} < Legion::Tools::Base + include Legion::CLI::Chat::ExtensionTool + + tool_name 'legion.#{lex}.#{tool_snake}' + description 'TODO: Describe what this tool does' + input_schema({ + type: 'object', + properties: { + example: { type: 'string', description: 'TODO: Describe this parameter' } + }, + required: ['example'] + }) + + permission_tier :write + + def self.call(example:) + settings = Legion::Settings[:extensions][:#{lex}] || {} + client = Legion::Extensions::#{lex_class}::Client.new(**settings) + # TODO: implement + text_response('Not yet implemented') + rescue StandardError => e + error_response(e.message) + end + end + end + end + end + end + RUBY + end + + def tool_spec_template(_lex, lex_class, _name, class_name) + <<~RUBY + # frozen_string_literal: true + + RSpec.describe Legion::Extensions::#{lex_class}::Tools::#{class_name} do + subject(:tool) { described_class.new } + + it 'has a description' do + expect(described_class.description).not_to include('TODO') + end + + it 'executes successfully' do + result = tool.execute(example: 'test') + expect(result).to be_a(String) + end + end + RUBY + end + + def absorber_template(lex_class, class_name, url_pat) + escaped_pat = url_pat.inspect + <<~RUBY + # frozen_string_literal: true + + module Legion + module Extensions + module #{lex_class} + module Absorbers + class #{class_name} < Legion::Extensions::Absorbers::Base + pattern :url, #{escaped_pat} + description 'TODO: describe what this absorber handles' + + def absorb(url: nil, content: nil, metadata: {}, context: {}) + report_progress(message: 'starting absorption') + + # TODO: implement content acquisition and processing + # absorb_to_knowledge(content: content, tags: ['tag']) + + report_progress(message: 'done', percent: 100) + { success: true } + end + end + end + end + end + end + RUBY + end + + def absorber_spec_template(lex_class, class_name, url_pat) + test_url = url_pat.gsub('*', 'test') + <<~RUBY + # frozen_string_literal: true + + RSpec.describe Legion::Extensions::#{lex_class}::Absorbers::#{class_name} do + describe '.patterns' do + it 'has registered patterns' do + expect(described_class.patterns).not_to be_empty + end + end + + describe '#absorb' do + it 'returns success' do + result = described_class.new.absorb(url: 'https://#{test_url}') + expect(result[:success]).to be true + end + end + end + RUBY + end + end + end + end +end diff --git a/lib/legion/cli/graph_command.rb b/lib/legion/cli/graph_command.rb new file mode 100644 index 00000000..a13cf90a --- /dev/null +++ b/lib/legion/cli/graph_command.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class GraphCommand < Thor + namespace 'graph' + + desc 'show', 'Display task relationship graph' + option :chain, type: :string, desc: 'Filter by chain ID' + option :worker, type: :string, desc: 'Filter by worker ID' + option :format, type: :string, default: 'mermaid', enum: %w[mermaid dot] + option :output, type: :string, desc: 'Write to file' + option :limit, type: :numeric, default: 100 + def show + require 'legion/graph/builder' + require 'legion/graph/exporter' + + graph = Legion::Graph::Builder.build( + chain_id: options[:chain], + worker_id: options[:worker], + limit: options[:limit] + ) + + rendered = case options[:format] + when 'dot' then Legion::Graph::Exporter.to_dot(graph) + else Legion::Graph::Exporter.to_mermaid(graph) + end + + if options[:output] + File.write(options[:output], rendered) + say "Written to #{options[:output]}", :green + else + say rendered + end + end + + default_task :show + end + end +end diff --git a/lib/legion/cli/groups/admin_group.rb b/lib/legion/cli/groups/admin_group.rb new file mode 100644 index 00000000..997193db --- /dev/null +++ b/lib/legion/cli/groups/admin_group.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + module Groups + class Admin < Thor + namespace 'admin' + + def self.exit_on_failure? + true + end + + desc 'rbac SUBCOMMAND', 'Role-based access control management' + subcommand 'rbac', Legion::CLI::Rbac + + desc 'auth SUBCOMMAND', 'Authenticate with external services' + subcommand 'auth', Legion::CLI::Auth + + desc 'worker SUBCOMMAND', 'Manage digital workers' + subcommand 'worker', Legion::CLI::Worker + + desc 'team SUBCOMMAND', 'Team and multi-user management' + subcommand 'team', Legion::CLI::Team + + desc 'purge-topology', 'Remove old v2.0 AMQP exchanges (legion.* that have lex.* counterparts)' + method_option :dry_run, type: :boolean, default: true, desc: 'List without deleting' + method_option :execute, type: :boolean, default: false, desc: 'Actually delete exchanges' + method_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host' + method_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port' + method_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management user' + method_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password' + method_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost' + method_option :open_timeout, type: :numeric, default: 5, desc: 'HTTP open timeout in seconds' + method_option :read_timeout, type: :numeric, default: 30, desc: 'HTTP read timeout in seconds' + def purge_topology + Legion::CLI::AdminCommand.new([], options).purge_topology + end + end + end + end +end diff --git a/lib/legion/cli/groups/ai_group.rb b/lib/legion/cli/groups/ai_group.rb new file mode 100644 index 00000000..cafb6472 --- /dev/null +++ b/lib/legion/cli/groups/ai_group.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + module Groups + class Ai < Thor + namespace 'ai' + + def self.exit_on_failure? + true + end + + desc 'chat SUBCOMMAND', 'Interactive AI conversation' + subcommand 'chat', Legion::CLI::Chat + + desc 'llm SUBCOMMAND', 'LLM provider diagnostics (status, ping, models)' + subcommand 'llm', Legion::CLI::Llm + + desc 'gaia SUBCOMMAND', 'GAIA cognitive coordination' + subcommand 'gaia', Legion::CLI::Gaia + + desc 'apollo SUBCOMMAND', 'Apollo knowledge graph' + subcommand 'apollo', Legion::CLI::Apollo + + desc 'knowledge SUBCOMMAND', 'Search and manage the document knowledge base' + subcommand 'knowledge', Legion::CLI::Knowledge + + desc 'memory SUBCOMMAND', 'Persistent project memory across sessions' + subcommand 'memory', Legion::CLI::Memory + + desc 'mind-growth SUBCOMMAND', 'Autonomous cognitive architecture expansion' + subcommand 'mind-growth', Legion::CLI::MindGrowth + + desc 'swarm SUBCOMMAND', 'Multi-agent swarm orchestration' + subcommand 'swarm', Legion::CLI::Swarm + + desc 'plan', 'Start plan mode (read-only exploration, no writes)' + subcommand 'plan', Legion::CLI::Plan + + desc 'trace SUBCOMMAND', 'Natural language trace search via LLM' + subcommand 'trace', Legion::CLI::TraceCommand + end + end + end +end diff --git a/lib/legion/cli/groups/dev_group.rb b/lib/legion/cli/groups/dev_group.rb new file mode 100644 index 00000000..3884293f --- /dev/null +++ b/lib/legion/cli/groups/dev_group.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + module Groups + class Dev < Thor + namespace 'dev' + + def self.exit_on_failure? + true + end + + desc 'generate SUBCOMMAND', 'Code generators for LEX components' + map 'g' => :generate + subcommand 'generate', Legion::CLI::Generate + + desc 'docs SUBCOMMAND', 'Documentation site generator' + subcommand 'docs', Legion::CLI::Docs + + desc 'openapi SUBCOMMAND', 'OpenAPI spec generation' + subcommand 'openapi', Legion::CLI::Openapi + + desc 'completion SUBCOMMAND', 'Shell tab completion scripts' + subcommand 'completion', Legion::CLI::Completion + + desc 'marketplace', 'Extension marketplace (search, info, scan)' + subcommand 'marketplace', Legion::CLI::Marketplace + + desc 'features SUBCOMMAND', 'Install feature bundles (interactive selector)' + subcommand 'features', Legion::CLI::Features + end + end + end +end diff --git a/lib/legion/cli/groups/git_group.rb b/lib/legion/cli/groups/git_group.rb new file mode 100644 index 00000000..7735b3be --- /dev/null +++ b/lib/legion/cli/groups/git_group.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + module Groups + class Git < Thor + namespace 'git' + + def self.exit_on_failure? + true + end + + desc 'commit', 'Generate AI commit message from staged changes' + subcommand 'commit', Legion::CLI::Commit + + desc 'pr', 'Create pull request with AI-generated title and description' + subcommand 'pr', Legion::CLI::Pr + + desc 'review', 'AI code review of changes' + subcommand 'review', Legion::CLI::Review + end + end + end +end diff --git a/lib/legion/cli/groups/ops_group.rb b/lib/legion/cli/groups/ops_group.rb new file mode 100644 index 00000000..0433c9ff --- /dev/null +++ b/lib/legion/cli/groups/ops_group.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + module Groups + class Ops < Thor + namespace 'ops' + + def self.exit_on_failure? + true + end + + desc 'telemetry SUBCOMMAND', 'Session log analytics and telemetry' + subcommand 'telemetry', Legion::CLI::Telemetry + + desc 'observe SUBCOMMAND', 'MCP tool observation stats' + subcommand 'observe', Legion::CLI::ObserveCommand + + desc 'detect', 'Scan environment and recommend extensions' + subcommand 'detect', Legion::CLI::Detect + + desc 'cost', 'Cost visibility and reporting' + subcommand 'cost', Legion::CLI::Cost + + desc 'payroll SUBCOMMAND', 'Workforce cost and labor economics' + subcommand 'payroll', Legion::CLI::Payroll + + desc 'audit SUBCOMMAND', 'Audit log inspection and verification' + subcommand 'audit', Legion::CLI::Audit + + desc 'debug', 'Diagnostic dump for troubleshooting (pipe to LLM for analysis)' + subcommand 'debug', Legion::CLI::Debug + + desc 'failover SUBCOMMAND', 'Region failover management' + subcommand 'failover', Legion::CLI::Failover + end + end + end +end diff --git a/lib/legion/cli/groups/pipeline_group.rb b/lib/legion/cli/groups/pipeline_group.rb new file mode 100644 index 00000000..a065d553 --- /dev/null +++ b/lib/legion/cli/groups/pipeline_group.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + module Groups + class Pipeline < Thor + namespace 'pipeline' + + def self.exit_on_failure? + true + end + + desc 'skill', 'Manage skills (.legion/skills/ markdown files)' + subcommand 'skill', Legion::CLI::Skill + + desc 'prompt SUBCOMMAND', 'Manage versioned LLM prompt templates' + subcommand 'prompt', Legion::CLI::Prompt + + desc 'eval SUBCOMMAND', 'Eval gating and experiment management' + subcommand 'eval', Legion::CLI::Eval + + desc 'dataset SUBCOMMAND', 'Manage versioned datasets' + subcommand 'dataset', Legion::CLI::Dataset + + desc 'image SUBCOMMAND', 'Multimodal image analysis and comparison' + subcommand 'image', Legion::CLI::Image + + desc 'notebook', 'Read and export Jupyter notebooks' + subcommand 'notebook', Legion::CLI::Notebook + end + end + end +end diff --git a/lib/legion/cli/groups/serve_group.rb b/lib/legion/cli/groups/serve_group.rb new file mode 100644 index 00000000..f9f74ddc --- /dev/null +++ b/lib/legion/cli/groups/serve_group.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + module Groups + class Serve < Thor + namespace 'serve' + + def self.exit_on_failure? + true + end + + desc 'mcp SUBCOMMAND', 'Start MCP server for AI agent integration' + subcommand 'mcp', Legion::CLI::Mcp + + desc 'acp SUBCOMMAND', 'Start ACP agent for editor integration' + subcommand 'acp', Legion::CLI::Acp + end + end + end +end diff --git a/lib/legion/cli/image_command.rb b/lib/legion/cli/image_command.rb new file mode 100644 index 00000000..40e4c4ee --- /dev/null +++ b/lib/legion/cli/image_command.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require 'thor' +require 'base64' + +module Legion + module CLI + class Image < Thor + def self.exit_on_failure? + true + end + + SUPPORTED_TYPES = %w[png jpg jpeg gif webp].freeze + + MIME_TYPES = { + 'png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'webp' => 'image/webp' + }.freeze + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'analyze PATH', 'Analyze an image file using an LLM' + option :prompt, type: :string, aliases: ['-p'], + desc: 'Custom question to ask about the image', + default: 'Describe this image in detail' + option :model, type: :string, aliases: ['-m'], desc: 'LLM model override' + option :provider, type: :string, desc: 'LLM provider override' + option :format, type: :string, default: 'text', desc: 'Output format: text or json' + def analyze(path) + out = formatter + setup_connection(out) + + image_data = load_image(path, out) + return unless image_data + + messages = [build_image_message([image_data], options[:prompt])] + response = call_llm(messages, out) + return unless response + + render_response(out, response, { path: path, prompt: options[:prompt] }) + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + + desc 'compare PATH1 PATH2', 'Compare two images side by side using an LLM' + option :prompt, type: :string, aliases: ['-p'], + desc: 'Custom comparison question', + default: 'Compare these two images and describe the differences' + option :model, type: :string, aliases: ['-m'], desc: 'LLM model override' + option :provider, type: :string, desc: 'LLM provider override' + option :format, type: :string, default: 'text', desc: 'Output format: text or json' + def compare(path1, path2) + out = formatter + setup_connection(out) + + image1 = load_image(path1, out) + return unless image1 + + image2 = load_image(path2, out) + return unless image2 + + messages = [build_image_message([image1, image2], options[:prompt])] + response = call_llm(messages, out) + return unless response + + render_response(out, response, { path1: path1, path2: path2, prompt: options[:prompt] }) + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def setup_connection(out) + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_llm + rescue CLI::Error => e + out.error(e.message) + raise SystemExit, 1 + end + + def load_image(path, out) + unless File.exist?(path) + out.error("File not found: #{path}") + raise SystemExit, 1 + end + + ext = File.extname(path).delete_prefix('.').downcase + unless SUPPORTED_TYPES.include?(ext) + out.error("Unsupported image type '.#{ext}'. Supported: #{SUPPORTED_TYPES.join(', ')}") + raise SystemExit, 1 + end + + { + path: path, + mime_type: MIME_TYPES[ext], + data: Base64.strict_encode64(File.binread(path)) + } + end + + def build_image_message(images, prompt_text) + content = images.map do |img| + { + type: 'image', + source: { + type: 'base64', + media_type: img[:mime_type], + data: img[:data] + } + } + end + content << { type: 'text', text: prompt_text } + { role: 'user', content: content } + end + + def call_llm(messages, out) + llm_kwargs = {} + llm_kwargs[:model] = options[:model] if options[:model] + llm_kwargs[:provider] = options[:provider].to_sym if options[:provider] + + chat = Legion::LLM.chat(**llm_kwargs) + user_msg = messages.first + response = chat.ask(user_msg[:content]) + { content: response.content, usage: extract_usage(response) } + rescue StandardError => e + out.error("LLM call failed: #{e.message}") + raise SystemExit, 1 + end + + def extract_usage(response) + return {} unless response.respond_to?(:usage) && response.usage + + { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens + } + rescue StandardError + {} + end + + def render_response(out, response, meta) + content = response[:content].to_s + usage = response[:usage] || {} + + if options[:format] == 'json' || options[:json] + out.json(meta.merge(response: content, usage: usage)) + else + out.header('Analysis') + out.spacer + puts content + return if usage.nil? || usage.empty? + + out.spacer + out.detail(usage) + end + end + end + end + end +end diff --git a/lib/legion/cli/init/config_generator.rb b/lib/legion/cli/init/config_generator.rb new file mode 100644 index 00000000..1b7857dc --- /dev/null +++ b/lib/legion/cli/init/config_generator.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'erb' +require 'fileutils' + +module Legion + module CLI + module InitHelpers + module ConfigGenerator + TEMPLATE_DIR = File.expand_path('../templates', __dir__) + CONFIG_DIR = File.expand_path('~/.legionio/settings') + + class << self + def generate(options = {}) + FileUtils.mkdir_p(CONFIG_DIR) + generated = [] + + %w[core].each do |name| + template_path = File.join(TEMPLATE_DIR, "#{name}.json.erb") + next unless File.exist?(template_path) + + output_path = File.join(CONFIG_DIR, "#{name}.json") + next if File.exist?(output_path) && !options[:force] + + content = render_template(template_path, options) + File.write(output_path, content) + generated << output_path + end + + generated + end + + def scaffold_workspace(dir = '.') + workspace_dir = File.join(dir, '.legion') + FileUtils.mkdir_p(File.join(workspace_dir, 'agents')) + FileUtils.mkdir_p(File.join(workspace_dir, 'skills')) + FileUtils.mkdir_p(File.join(workspace_dir, 'memory')) + + settings_path = File.join(workspace_dir, 'settings.json') + File.write(settings_path, "{}\n") unless File.exist?(settings_path) + + ensure_gitignore_entries(dir) + + workspace_dir + end + + GITIGNORE_ENTRIES = %w[ + .legion-context/ + .legion-worktrees/ + ].freeze + + private + + def ensure_gitignore_entries(dir) + gitignore_path = File.join(dir, '.gitignore') + existing = File.exist?(gitignore_path) ? File.read(gitignore_path) : '' + existing_lines = existing.lines.map(&:chomp) + + additions = GITIGNORE_ENTRIES.reject { |entry| existing_lines.include?(entry) } + return if additions.empty? + + content = existing + content += "\n" unless content.empty? || content.end_with?("\n") + content += "# Legion workspace\n" unless existing_lines.any? { |l| l.include?('Legion') } + content += "#{additions.join("\n")}\n" + File.write(gitignore_path, content) + end + + def render_template(path, options) + template = File.read(path) + ERB.new(template, trim_mode: '-').result_with_hash(options: options) + end + end + end + end + end +end diff --git a/lib/legion/cli/init/environment_detector.rb b/lib/legion/cli/init/environment_detector.rb new file mode 100644 index 00000000..2b6e0143 --- /dev/null +++ b/lib/legion/cli/init/environment_detector.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'socket' + +module Legion + module CLI + module InitHelpers + module EnvironmentDetector + class << self + def detect + { + rabbitmq: check_rabbitmq, + database: check_database, + vault: check_vault, + redis: check_redis, + git: check_git, + existing_config: check_config + } + end + + private + + def check_rabbitmq + return { available: true, source: 'env' } if ENV['AMQP_URL'] || ENV['RABBITMQ_URL'] + + Socket.tcp('localhost', 5672, connect_timeout: 2) { true } + { available: true, source: 'localhost' } + rescue StandardError => e + Legion::Logging.debug("EnvironmentDetector#check_rabbitmq not reachable: #{e.message}") if defined?(Legion::Logging) + { available: false } + end + + def check_database + return { available: true, adapter: 'postgresql', source: 'env' } if ENV['DATABASE_URL'] + + { available: true, adapter: 'sqlite', source: 'fallback' } + end + + def check_vault + return { available: true, source: 'env' } if ENV['VAULT_ADDR'] + + { available: false } + end + + def check_redis + return { available: true, source: 'env' } if ENV['REDIS_URL'] + + Socket.tcp('localhost', 6379, connect_timeout: 2) { true } + { available: true, source: 'localhost' } + rescue StandardError => e + Legion::Logging.debug("EnvironmentDetector#check_redis not reachable: #{e.message}") if defined?(Legion::Logging) + { available: false } + end + + def check_git + { available: Dir.exist?('.git') } + end + + def check_config + dir = File.expand_path('~/.legionio/settings') + { available: Dir.exist?(dir), path: dir } + end + end + end + end + end +end diff --git a/lib/legion/cli/init_command.rb b/lib/legion/cli/init_command.rb new file mode 100644 index 00000000..e5e9b9b2 --- /dev/null +++ b/lib/legion/cli/init_command.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Init < Thor + def self.exit_on_failure? + true + end + + desc 'workspace', 'Initialize a new Legion workspace in the current directory' + option :askid, type: :string, desc: 'ASK ID for the deployment' + option :local, type: :boolean, default: false, desc: 'Local dev mode (no external dependencies)' + option :force, type: :boolean, default: false, desc: 'Overwrite existing config files' + def workspace + detect_environment + generate_config + scaffold_workspace + verify_setup + end + default_task :workspace + + private + + def detect_environment + require 'legion/cli/init/environment_detector' + @env = InitHelpers::EnvironmentDetector.detect + say 'Environment detected:', :green + @env.each { |k, v| say " #{k}: #{v[:available] ? 'available' : 'not found'}" } + end + + def generate_config + require 'legion/cli/init/config_generator' + opts = options.to_h.transform_keys(&:to_sym) + opts[:redis] = @env[:redis][:available] + + files = InitHelpers::ConfigGenerator.generate(opts) + if files.empty? + say ' Config files already exist (use --force to overwrite)', :yellow + else + files.each { |f| say " Created: #{f}", :green } + end + end + + def scaffold_workspace + require 'legion/cli/init/config_generator' + dir = InitHelpers::ConfigGenerator.scaffold_workspace + say " Workspace scaffolded: #{dir}", :green + end + + def verify_setup + say "\nVerifying setup...", :yellow + say "Run 'legion doctor' to check environment health", :cyan + end + end + end +end diff --git a/lib/legion/cli/interactive.rb b/lib/legion/cli/interactive.rb new file mode 100644 index 00000000..3f8761ea --- /dev/null +++ b/lib/legion/cli/interactive.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/version' +require 'legion/cli/error' +require 'legion/cli/output' + +module Legion + module CLI + class Interactive < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + + desc 'version', 'Show version information' + map %w[-v --version] => :version + def version + Main.start(['version'] + ARGV.select { |a| a.start_with?('--') }) + end + + desc 'chat [SUBCOMMAND]', 'Text-based AI conversation' + subcommand 'chat', Legion::CLI::Chat + + desc 'commit', 'Generate AI commit message from staged changes' + subcommand 'commit', Legion::CLI::Commit + + desc 'pr', 'Create pull request with AI-generated title and description' + subcommand 'pr', Legion::CLI::Pr + + desc 'review', 'AI code review of changes' + subcommand 'review', Legion::CLI::Review + + desc 'memory SUBCOMMAND', 'Persistent project memory across sessions' + subcommand 'memory', Legion::CLI::Memory + + desc 'plan', 'Start plan mode (read-only exploration, no writes)' + subcommand 'plan', Legion::CLI::Plan + + desc 'init', 'Initialize a new Legion workspace' + subcommand 'init', Legion::CLI::Init + + desc 'tty', 'Launch the rich terminal UI' + subcommand 'tty', Legion::CLI::Tty + + desc 'ask TEXT', 'Quick AI prompt (shortcut for chat prompt)' + map %w[-p --prompt] => :ask + def ask(*text) + Legion::CLI::Chat.start(['prompt', text.join(' ')] + ARGV.select { |a| a.start_with?('--') }) + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + end + end + end +end diff --git a/lib/legion/cli/knowledge_command.rb b/lib/legion/cli/knowledge_command.rb new file mode 100644 index 00000000..aa0e5b60 --- /dev/null +++ b/lib/legion/cli/knowledge_command.rb @@ -0,0 +1,465 @@ +# frozen_string_literal: true + +require 'shellwords' +require_relative 'api_client' + +module Legion + module CLI + class MonitorCommand < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'add PATH', 'Add a directory to corpus monitors' + option :extensions, type: :string, desc: 'Comma-separated file extensions to watch (e.g. md,rb)' + option :label, type: :string, desc: 'Human-readable label for this monitor' + def add(path) + out = formatter + exts = options[:extensions]&.split(',')&.map(&:strip) + result = api_post('/api/knowledge/monitors', path: path, extensions: exts, label: options[:label]) + + if options[:json] + out.json(result) + elsif result[:success] + out.success("Monitor added: #{path}") + else + out.warn("Failed to add monitor: #{result[:error]}") + end + end + + desc 'list', 'List registered corpus monitors' + def list + out = formatter + monitors = api_get('/api/knowledge/monitors') + + if options[:json] + out.json(monitors) + elsif monitors.nil? || monitors.empty? + out.warn('No monitors registered') + else + out.header('Knowledge Monitors') + monitors.each do |m| + label = m[:label] ? " [#{m[:label]}]" : '' + exts = m[:extensions]&.join(', ') + puts " #{m[:path]}#{label}" + puts " Extensions: #{exts}" if exts && !exts.empty? + end + end + end + default_task :list + + desc 'remove IDENTIFIER', 'Remove a corpus monitor by path or label' + def remove(identifier) + out = formatter + result = api_delete("/api/knowledge/monitors?identifier=#{URI.encode_www_form_component(identifier)}") + + if options[:json] + out.json(result) + elsif result[:success] + out.success("Monitor removed: #{identifier}") + else + out.warn("Failed to remove monitor: #{result[:error]}") + end + end + + desc 'status', 'Show monitor status (counts)' + def status + out = formatter + result = api_get('/api/knowledge/monitors/status') + + if options[:json] + out.json(result) + else + out.header('Monitor Status') + out.detail({ + 'Total monitors' => result[:total_monitors].to_s, + 'Total files' => result[:total_files].to_s + }) + end + end + + no_commands do + include ApiClient + + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + end + end + + class CaptureCommand < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'commit', 'Capture the last git commit as knowledge' + def commit + log_line = `git log -1 --format='%H %s' 2>/dev/null`.strip + diff_stat = `git diff HEAD~1 --stat 2>/dev/null`.strip + + if log_line.empty? + formatter.warn('No git commit found') + return + end + + sha, *subject_parts = log_line.split + subject = subject_parts.join(' ') + content = "Git commit: #{sha}\nSubject: #{subject}\n\nDiff stat:\n#{diff_stat}" + tags = %w[git commit knowledge-capture] + + result = api_post('/api/knowledge/ingest', content: content, tags: tags, source: "git:#{sha}") + + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.success("Captured commit #{sha[0, 8]}: #{subject}") + else + out.warn("Capture failed: #{result[:error]}") + end + end + + desc 'session', 'Capture a session note from stdin' + def session + input = $stdin.gets(nil) if $stdin.ready? rescue nil # rubocop:disable Style/RescueModifier + input = input.to_s.strip + + if input.empty? + formatter.warn('No session input provided (pipe text to stdin)') + return + end + + repo = `git rev-parse --show-toplevel 2>/dev/null`.strip.split('/').last + content = "Session note (#{::Time.now.strftime('%Y-%m-%d')}):\n\n#{input}" + tags = ['session', 'knowledge-capture', ::Time.now.strftime('%Y-%m-%d')] + tags << "repo:#{repo}" unless repo.empty? + + result = api_post('/api/knowledge/ingest', + content: content, tags: tags, source: "session:#{::Time.now.iso8601}") + + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.success('Session captured') + else + out.warn("Capture failed: #{result[:error]}") + end + end + + desc 'transcript', 'Capture a Claude Code session transcript as knowledge' + option :session_id, type: :string, desc: 'Session ID (defaults to CLAUDE_SESSION_ID env)' + option :cwd, type: :string, desc: 'Working directory (defaults to CLAUDE_CWD env)' + option :max_chunks, type: :numeric, default: 50, desc: 'Max conversation turn chunks to ingest' + def transcript + session_id = options[:session_id] || ENV.fetch('CLAUDE_SESSION_ID', nil) + cwd = options[:cwd] || ENV.fetch('CLAUDE_CWD', nil) || ::Dir.pwd + + unless session_id + formatter.warn('No session ID provided (set CLAUDE_SESSION_ID or --session-id)') + return + end + + jsonl_path = resolve_transcript_path(session_id, cwd) + unless jsonl_path && ::File.exist?(jsonl_path) + formatter.warn("Transcript not found: #{jsonl_path || 'could not resolve path'}") + return + end + + turns = extract_turns(jsonl_path) + if turns.empty? + formatter.warn('No conversation turns found in transcript') + return + end + + repo = `git -C #{Shellwords.escape(cwd)} rev-parse --show-toplevel 2>/dev/null`.strip.split('/').last + base_tags = ['claude-code', 'transcript', "session:#{session_id}", ::Time.now.strftime('%Y-%m-%d')] + base_tags << "repo:#{repo}" unless repo.to_s.empty? + + ingested = 0 + turns.first(options[:max_chunks]).each_with_index do |turn, idx| + content = format_turn(turn, idx + 1) + next if content.strip.empty? + + result = api_post('/api/knowledge/ingest', + content: content, + tags: base_tags + ["turn:#{idx + 1}"], + source: "claude-code:#{session_id}:turn-#{idx + 1}") + ingested += 1 if result[:success] + end + + out = formatter + if options[:json] + out.json(success: true, session_id: session_id, turns: turns.size, ingested: ingested) + else + out.success("Captured #{ingested}/#{[turns.size, options[:max_chunks]].min} turns from session #{session_id[0, 8]}") + end + end + + no_commands do + include ApiClient + + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + + def resolve_transcript_path(session_id, cwd) + project_dir = cwd.gsub('/', '-') + ::File.expand_path("~/.claude/projects/#{project_dir}/#{session_id}.jsonl") + end + + def extract_turns(path) + turns = [] + current_turn = nil + + ::File.foreach(path) do |line| + entry = ::JSON.parse(line, symbolize_names: true) + type = entry[:type] + + case type + when 'user' + turns << current_turn if current_turn + current_turn = { user: extract_message_text(entry), assistant: +'', timestamp: entry[:timestamp] } + when 'assistant' + next unless current_turn + + text = extract_message_text(entry) + current_turn[:assistant] << text unless text.empty? + end + rescue ::JSON::ParserError + next + end + + turns << current_turn if current_turn + turns + end + + def extract_message_text(entry) + msg = entry[:message] + return '' unless msg + + content = msg[:content] + case content + when String then content + when Array + content.filter_map { |block| block[:text] if block[:type] == 'text' }.join("\n") + else '' + end + end + + def format_turn(turn, number) + text = "## Turn #{number}\n" + text << "Timestamp: #{turn[:timestamp]}\n\n" if turn[:timestamp] + text << "### User\n#{truncate_text(turn[:user], 4096)}\n\n" + text << "### Assistant\n#{truncate_text(turn[:assistant], 4096)}\n" + text + end + + def truncate_text(text, max_bytes) + return text if text.bytesize <= max_bytes + + "#{text.byteslice(0, max_bytes - 20)}\n\n[truncated]" + end + end + end + + class Knowledge < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'query QUESTION', 'Query the knowledge base with optional LLM synthesis' + option :top_k, type: :numeric, default: 5, desc: 'Number of source chunks' + option :synthesize, type: :boolean, default: true, desc: 'Synthesize an LLM answer' + option :verbose, type: :boolean, default: false, desc: 'Show full source metadata' + def query(question) + result = api_post('/api/knowledge/query', + question: question, top_k: options[:top_k], synthesize: options[:synthesize]) + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.header('Knowledge Query') + if result[:answer] + out.spacer + puts result[:answer] + out.spacer + end + print_sources(result[:sources] || [], out, verbose: options[:verbose]) + else + out.warn("Query failed: #{result[:error]}") + end + end + default_task :help + + desc 'retrieve QUESTION', 'Retrieve source chunks without LLM synthesis' + option :top_k, type: :numeric, default: 5, desc: 'Number of source chunks' + def retrieve(question) + result = api_post('/api/knowledge/retrieve', question: question, top_k: options[:top_k]) + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.header("Knowledge Retrieve (#{(result[:sources] || []).size} chunks)") + print_sources(result[:sources] || [], out, verbose: true) + else + out.warn("Retrieve failed: #{result[:error]}") + end + end + + desc 'ingest PATH', 'Ingest a file or directory into the knowledge base' + option :force, type: :boolean, default: false, desc: 'Re-ingest even unchanged files' + option :dry_run, type: :boolean, default: false, desc: 'Preview without writing' + def ingest(path) + payload = { path: ::File.expand_path(path), force: options[:force] } + payload[:dry_run] = options[:dry_run] if options[:dry_run] + result = api_post('/api/knowledge/ingest', **payload) + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.success('Ingest complete') + out.detail(result.except(:success)) + else + out.warn("Ingest failed: #{result[:error]}") + end + end + + desc 'status', 'Show knowledge base status' + def status + result = api_post('/api/knowledge/status', path: ::Dir.pwd) + out = formatter + if options[:json] + out.json(result) + else + out.header('Knowledge Status') + out.detail({ + 'Path' => result[:path].to_s, + 'Files' => result[:file_count].to_s, + 'Total size' => "#{result[:total_bytes]} bytes" + }) + end + end + + desc 'health', 'Show knowledge base health report (local, Apollo, sync)' + option :corpus_path, type: :string, desc: 'Path to corpus directory (falls back to settings)' + def health + result = api_post('/api/knowledge/health', path: options[:corpus_path]) + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.header('Knowledge Health') + out.spacer + out.header('Local') + out.detail(result[:local]) + out.spacer + out.header('Apollo') + out.detail(result[:apollo]) + out.spacer + out.header('Sync') + out.detail(result[:sync]) + else + out.warn("Health check failed: #{result[:error]}") + end + end + + desc 'maintain', 'Detect and clean up orphaned knowledge chunks' + option :corpus_path, type: :string, desc: 'Path to corpus directory (falls back to settings)' + option :dry_run, type: :boolean, default: true, desc: 'Preview without archiving (default: true)' + def maintain + result = api_post('/api/knowledge/maintain', + path: options[:corpus_path], dry_run: options[:dry_run]) + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.header("Knowledge Maintain#{' (dry run)' if options[:dry_run]}") + out.detail({ + 'Orphan files' => (result[:orphan_files] || []).join(', '), + 'Archived' => result[:archived].to_s, + 'Files cleaned' => result[:files_cleaned].to_s, + 'Dry run' => result[:dry_run].to_s + }) + else + out.warn("Maintenance failed: #{result[:error]}") + end + end + + desc 'quality', 'Show knowledge quality report (hot, cold, low-confidence chunks)' + option :limit, type: :numeric, default: 10, desc: 'Max entries per category' + def quality + result = api_post('/api/knowledge/quality', limit: options[:limit]) + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.header('Knowledge Quality Report') + out.spacer + print_chunk_section('Hot Chunks (most accessed)', result[:hot_chunks], out) + print_chunk_section('Cold Chunks (never accessed)', result[:cold_chunks], out) + print_chunk_section('Low Confidence', result[:low_confidence], out) + out.spacer + out.header('Summary') + out.detail(result[:summary]) + else + out.warn("Quality report failed: #{result[:error]}") + end + end + + desc 'monitor SUBCOMMAND', 'Manage knowledge corpus monitors' + subcommand 'monitor', MonitorCommand + + desc 'capture SUBCOMMAND', 'Capture knowledge from git commits or sessions' + subcommand 'capture', CaptureCommand + + no_commands do + include ApiClient + + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + + def print_sources(sources, out, verbose:) + return out.warn('No sources found') if sources.empty? + + out.header("Sources (#{sources.size})") + sources.each_with_index do |s, i| + score = format('%.2f', s[:score].to_f) + heading = s[:heading].to_s.empty? ? '' : " \u00a7 #{s[:heading]}" + puts " #{i + 1}. #{s[:source_file]}#{heading} score: #{score}" + puts " #{truncate(s[:content].to_s, 100)}" if verbose + end + end + + def print_chunk_section(title, chunks, out) + out.header(title) + if chunks.empty? + out.warn(' (none)') + else + chunks.each do |c| + puts " id=#{c[:id]} confidence=#{c[:confidence]} #{c[:source_file]}" + end + end + out.spacer + end + + def truncate(text, max) + return text if text.length <= max + return text[0, max] if max < 4 + + "#{text[0, max - 3]}..." + end + end + end + end +end diff --git a/lib/legion/cli/lex/actor.rb b/lib/legion/cli/lex/actor.rb index 05914364..09b90baf 100755 --- a/lib/legion/cli/lex/actor.rb +++ b/lib/legion/cli/lex/actor.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli module Lex diff --git a/lib/legion/cli/lex/exchange.rb b/lib/legion/cli/lex/exchange.rb index 8d4001e2..785b9123 100755 --- a/lib/legion/cli/lex/exchange.rb +++ b/lib/legion/cli/lex/exchange.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli module Lex diff --git a/lib/legion/cli/lex/message.rb b/lib/legion/cli/lex/message.rb index 2c109da0..1eea8f31 100755 --- a/lib/legion/cli/lex/message.rb +++ b/lib/legion/cli/lex/message.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli module Lex diff --git a/lib/legion/cli/lex/queue.rb b/lib/legion/cli/lex/queue.rb index 66ba96f2..9f531af1 100755 --- a/lib/legion/cli/lex/queue.rb +++ b/lib/legion/cli/lex/queue.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli module Lex @@ -29,7 +31,6 @@ def delete(name) remove_file("spec/queues/#{name}_spec.rb") remove_file("spec/transport/queues/#{name}_spec.rb") - # puts Dir.pwd # /Users/miverso2/Rubymine/lex/wip/lex-conflux if Dir.exist? "#{Dir.pwd}/lib/legion/extensions/#{lex}/transport/queues/" remove_dir("#{Dir.pwd}/lib/legion/extensions/#{lex}/transport/queues") if Dir.empty?("#{Dir.pwd}/lib/legion/extensions/#{lex}/transport/queues/") remove_dir("#{Dir.pwd}/lib/legion/extensions/#{lex}/transport") if Dir.empty?("#{Dir.pwd}/lib/legion/extensions/#{lex}/transport") diff --git a/lib/legion/cli/lex/runner.rb b/lib/legion/cli/lex/runner.rb index b908a2c8..f94de2bb 100755 --- a/lib/legion/cli/lex/runner.rb +++ b/lib/legion/cli/lex/runner.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli module Lex @@ -56,7 +58,7 @@ def #{function}(#{args}) insert_into_file("spec/runners/#{name}_spec.rb", after: "it { should be_a Module }\n") do result = " it { is_expected.to respond_to(:#{function}).with_any_keywords }\n" - result.concat " it { is_expected.to respond_to(:#{function}).with_keywords(:#{@arg_keys.join(', :')}) }\n" if @arg_keys.count.positive? + result.concat " it { is_expected.to respond_to(:#{function}).with_keywords(:#{@arg_keys.join(', :')}) }\n" if @arg_keys.any? result end diff --git a/lib/legion/cli/lex/templates/base/dockerfile.erb b/lib/legion/cli/lex/templates/base/dockerfile.erb index 80a1c2b4..13681b52 100644 --- a/lib/legion/cli/lex/templates/base/dockerfile.erb +++ b/lib/legion/cli/lex/templates/base/dockerfile.erb @@ -2,4 +2,4 @@ FROM legionio/legion:latest LABEL maintainer="Matthew Iverson " RUN gem install lex-<%= config[:lex] %> legion-data --no-document --no-prerelease -CMD ruby $(which legionio) +CMD ruby $(which legion) diff --git a/lib/legion/cli/lex/templates/data_pipeline/actors/ingest.rb.erb b/lib/legion/cli/lex/templates/data_pipeline/actors/ingest.rb.erb new file mode 100644 index 00000000..956fb75c --- /dev/null +++ b/lib/legion/cli/lex/templates/data_pipeline/actors/ingest.rb.erb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module <%= lex_class %> + module Actors + class Ingest < Legion::Extensions::Actors::Subscription + include Legion::Extensions::<%= lex_class %>::Runners::Transform + + QUEUE = 'legion.<%= lex_name %>.ingest' + EXCHANGE = 'legion.<%= lex_name %>' + + def self.queue + QUEUE + end + + def self.exchange + EXCHANGE + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/data_pipeline/runners/transform.rb.erb b/lib/legion/cli/lex/templates/data_pipeline/runners/transform.rb.erb new file mode 100644 index 00000000..b6412de1 --- /dev/null +++ b/lib/legion/cli/lex/templates/data_pipeline/runners/transform.rb.erb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module <%= lex_class %> + module Runners + module Transform + extend Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?('Helpers::Lex') + + # Main transform entry point. Receives a payload hash, returns transformed output. + def transform(payload:, options: {}, **) + validated = validate_input(payload) + return validated unless validated[:success] + + result = process(validated[:data], options) + publish_output(result) if defined?(Legion::Transport) + + { success: true, data: result } + rescue StandardError => e + handle_error(e, payload) + end + + private + + def validate_input(payload) + return { success: false, reason: 'payload is required' } if payload.nil? + return { success: false, reason: 'payload must be a Hash' } unless payload.is_a?(Hash) + + { success: true, data: payload } + end + + def process(data, _options) + # TODO: implement transformation logic + data + end + + def publish_output(result) + return unless defined?(Legion::Transport) + + Legion::Transport::Messages::<%= name_class %>Output.new(data: result).publish + rescue StandardError + nil + end + + def handle_error(error, payload) + { + success: false, + reason: error.message, + payload: payload + } + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/data_pipeline/spec/actors/ingest_spec.rb.erb b/lib/legion/cli/lex/templates/data_pipeline/spec/actors/ingest_spec.rb.erb new file mode 100644 index 00000000..1a1dbc9d --- /dev/null +++ b/lib/legion/cli/lex/templates/data_pipeline/spec/actors/ingest_spec.rb.erb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::<%= lex_class %>::Actors::Ingest do + it 'inherits from Subscription actor' do + expect(described_class.ancestors).to include(Legion::Extensions::Actors::Subscription) + end + + it 'includes the Transform runner' do + expect(described_class.ancestors).to include(Legion::Extensions::<%= lex_class %>::Runners::Transform) + end + + it 'defines a queue name' do + expect(described_class::QUEUE).to include('<%= lex_name %>') + end + + it 'defines an exchange name' do + expect(described_class::EXCHANGE).to include('<%= lex_name %>') + end +end diff --git a/lib/legion/cli/lex/templates/data_pipeline/spec/runners/transform_spec.rb.erb b/lib/legion/cli/lex/templates/data_pipeline/spec/runners/transform_spec.rb.erb new file mode 100644 index 00000000..a680f1e8 --- /dev/null +++ b/lib/legion/cli/lex/templates/data_pipeline/spec/runners/transform_spec.rb.erb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::<%= lex_class %>::Runners::Transform do + subject { described_class } + + let(:test_class) do + Class.new do + extend Legion::Extensions::<%= lex_class %>::Runners::Transform + end + end + + it { should be_a Module } + it { is_expected.to respond_to(:transform).with_any_keywords } + + describe '#transform' do + it 'returns success for a valid payload' do + result = test_class.transform(payload: { key: 'value' }) + expect(result[:success]).to be true + expect(result[:data]).to be_a(Hash) + end + + it 'returns failure when payload is nil' do + result = test_class.transform(payload: nil) + expect(result[:success]).to be false + expect(result[:reason]).to include('required') + end + + it 'returns failure when payload is not a Hash' do + result = test_class.transform(payload: 'not a hash') + expect(result[:success]).to be false + expect(result[:reason]).to include('Hash') + end + + it 'handles unexpected errors gracefully' do + allow(test_class).to receive(:process).and_raise(StandardError, 'unexpected') + result = test_class.transform(payload: { key: 'value' }) + expect(result[:success]).to be false + expect(result[:reason]).to eq('unexpected') + end + end +end diff --git a/lib/legion/cli/lex/templates/data_pipeline/transport/exchanges/%name%.rb.erb b/lib/legion/cli/lex/templates/data_pipeline/transport/exchanges/%name%.rb.erb new file mode 100644 index 00000000..d4283640 --- /dev/null +++ b/lib/legion/cli/lex/templates/data_pipeline/transport/exchanges/%name%.rb.erb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module <%= lex_class %> + module Transport + module Exchanges + class <%= name_class %> < Legion::Transport::Exchange + EXCHANGE_NAME = 'legion.<%= lex_name %>' + EXCHANGE_TYPE = :direct + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/data_pipeline/transport/messages/%name%_output.rb.erb b/lib/legion/cli/lex/templates/data_pipeline/transport/messages/%name%_output.rb.erb new file mode 100644 index 00000000..b2455067 --- /dev/null +++ b/lib/legion/cli/lex/templates/data_pipeline/transport/messages/%name%_output.rb.erb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module <%= lex_class %> + module Transport + module Messages + class <%= name_class %>Output < Legion::Transport::Message + EXCHANGE_NAME = 'legion.<%= lex_name %>' + ROUTING_KEY = 'output' + + attr_accessor :data + + def initialize(data:) + @data = data + super() + end + + def to_payload + { data: @data } + end + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/data_pipeline/transport/queues/ingest.rb.erb b/lib/legion/cli/lex/templates/data_pipeline/transport/queues/ingest.rb.erb new file mode 100644 index 00000000..bab4cd50 --- /dev/null +++ b/lib/legion/cli/lex/templates/data_pipeline/transport/queues/ingest.rb.erb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module <%= lex_class %> + module Transport + module Queues + class Ingest < Legion::Transport::Queue + QUEUE_NAME = 'legion.<%= lex_name %>.ingest' + EXCHANGE_NAME = 'legion.<%= lex_name %>' + ROUTING_KEY = 'ingest' + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/llm_agent/helpers/client.rb.erb b/lib/legion/cli/lex/templates/llm_agent/helpers/client.rb.erb new file mode 100644 index 00000000..70e56b00 --- /dev/null +++ b/lib/legion/cli/lex/templates/llm_agent/helpers/client.rb.erb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module <%= lex_class %> + module Helpers + class Client + attr_reader :model, :temperature, :max_tokens + + def initialize(model: nil, temperature: 0.7, max_tokens: 1024, **) + @model = model + @temperature = temperature + @max_tokens = max_tokens + end + + def chat(prompt:, **override) + return { success: false, reason: 'legion-llm not available' } unless defined?(Legion::LLM) + + opts = { prompt: prompt, temperature: @temperature, max_tokens: @max_tokens } + opts[:model] = @model if @model + opts.merge!(override) + + Legion::LLM.chat(**opts) + rescue StandardError => e + { success: false, reason: e.message } + end + + def structured(prompt:, schema:, **override) + return { success: false, reason: 'legion-llm not available' } unless defined?(Legion::LLM) + + opts = { prompt: prompt, schema: schema } + opts[:model] = @model if @model + opts.merge!(override) + + Legion::LLM.structured(**opts) + rescue StandardError => e + { success: false, reason: e.message } + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/llm_agent/prompts/default.yml.erb b/lib/legion/cli/lex/templates/llm_agent/prompts/default.yml.erb new file mode 100644 index 00000000..80b4aa5c --- /dev/null +++ b/lib/legion/cli/lex/templates/llm_agent/prompts/default.yml.erb @@ -0,0 +1,14 @@ +--- +# Default prompt template for <%= gem_name %> +# Customize system and user prompts for your use case. + +system: | + You are a helpful assistant for <%= lex_class %>. + Respond concisely and accurately. + +user: | + <%= '{{input}}' %> + +examples: + - input: "What can you help me with?" + expected: "I can help you with <%= lex_class.downcase %>-related tasks." diff --git a/lib/legion/cli/lex/templates/llm_agent/runners/%name%.rb.erb b/lib/legion/cli/lex/templates/llm_agent/runners/%name%.rb.erb new file mode 100644 index 00000000..44b0f87f --- /dev/null +++ b/lib/legion/cli/lex/templates/llm_agent/runners/%name%.rb.erb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module <%= lex_class %> + module Runners + module <%= name_class %> + extend Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?('Helpers::Lex') + + def run(prompt:, model: nil, temperature: nil, structured: false, schema: nil, **) + llm_opts = { prompt: prompt } + llm_opts[:model] = model if model + llm_opts[:temperature] = temperature if temperature + + if structured && defined?(Legion::LLM) + response = Legion::LLM.structured(prompt: prompt, schema: schema || default_schema) + elsif defined?(Legion::LLM) + response = Legion::LLM.chat(**llm_opts) + else + return { success: false, reason: 'legion-llm not available' } + end + + { success: true, response: response } + rescue StandardError => e + { success: false, reason: e.message } + end + + private + + def default_schema + { type: 'object', properties: { result: { type: 'string' } } } + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/llm_agent/spec/runners/%name%_spec.rb.erb b/lib/legion/cli/lex/templates/llm_agent/spec/runners/%name%_spec.rb.erb new file mode 100644 index 00000000..b597a1b7 --- /dev/null +++ b/lib/legion/cli/lex/templates/llm_agent/spec/runners/%name%_spec.rb.erb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +RSpec.describe Legion::Extensions::<%= lex_class %>::Runners::<%= name_class %> do + subject { described_class } + + let(:test_class) do + Class.new do + extend Legion::Extensions::<%= lex_class %>::Runners::<%= name_class %> + end + end + + before do + stub_const('Legion::LLM', Module.new do + def self.chat(**_opts) + { success: true, content: 'mock response' } + end + + def self.structured(**_opts) + { success: true, result: 'mock result' } + end + end) + end + + it { should be_a Module } + it { is_expected.to respond_to(:run).with_any_keywords } + + describe '#run' do + it 'returns success with a response' do + result = test_class.run(prompt: 'hello') + expect(result[:success]).to be true + end + + it 'returns failure when legion-llm is unavailable' do + hide_const('Legion::LLM') + result = test_class.run(prompt: 'hello') + expect(result[:success]).to be false + expect(result[:reason]).to include('legion-llm') + end + end +end diff --git a/lib/legion/cli/lex/templates/service_integration/helpers/auth.rb.erb b/lib/legion/cli/lex/templates/service_integration/helpers/auth.rb.erb new file mode 100644 index 00000000..6e88f7b1 --- /dev/null +++ b/lib/legion/cli/lex/templates/service_integration/helpers/auth.rb.erb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module <%= lex_class %> + module Helpers + module Auth + # Build an auth hash from settings or explicit kwargs. + # Supports three methods: + # api_key — { method: :api_key, key: '...', header: 'X-API-Key' } + # bearer — { method: :bearer, token: '...' } + # basic — { method: :basic, username: '...', password: '...' } + def self.from_settings(settings = {}) + return {} if settings.nil? || settings.empty? + + auth = settings[:auth] || settings['auth'] || {} + return {} if auth.empty? + + auth + end + + def self.api_key(key, header: 'X-API-Key') + { method: :api_key, key: key, header: header } + end + + def self.bearer(token) + { method: :bearer, token: token } + end + + def self.basic(username, password) + { method: :basic, username: username, password: password } + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/service_integration/helpers/client.rb.erb b/lib/legion/cli/lex/templates/service_integration/helpers/client.rb.erb new file mode 100644 index 00000000..66462868 --- /dev/null +++ b/lib/legion/cli/lex/templates/service_integration/helpers/client.rb.erb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'faraday' +require 'json' + +module Legion + module Extensions + module <%= lex_class %> + module Helpers + class Client + DEFAULT_TIMEOUT = 30 + + class << self + def connection(base_url: nil, timeout: DEFAULT_TIMEOUT, auth: {}, **) + raise ArgumentError, 'base_url is required' if base_url.nil? || base_url.empty? + + Faraday.new(url: base_url) do |conn| + conn.options.timeout = timeout + conn.options.open_timeout = timeout + conn.headers['Content-Type'] = 'application/json' + conn.headers['Accept'] = 'application/json' + + apply_auth(conn, auth) + + conn.response :json, content_type: /\bjson$/ + conn.adapter Faraday.default_adapter + end + end + + private + + def apply_auth(conn, auth) + method = auth[:method] || auth['method'] + + case method&.to_sym + when :api_key + header = auth[:header] || auth['header'] || 'X-API-Key' + conn.headers[header] = auth[:key] || auth['key'] + when :bearer + token = auth[:token] || auth['token'] + conn.headers['Authorization'] = "Bearer #{token}" + when :basic + conn.request :authorization, :basic, + auth[:username] || auth['username'], + auth[:password] || auth['password'] + end + end + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/service_integration/runners/%name%.rb.erb b/lib/legion/cli/lex/templates/service_integration/runners/%name%.rb.erb new file mode 100644 index 00000000..513014a9 --- /dev/null +++ b/lib/legion/cli/lex/templates/service_integration/runners/%name%.rb.erb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module <%= lex_class %> + module Runners + module <%= name_class %> + extend Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?('Helpers::Lex') + + def list(**opts) + client = Helpers::Client.connection(**settings.merge(opts)) + response = client.get('/') + { success: true, data: response.body } + rescue StandardError => e + { success: false, reason: e.message } + end + + def get(id:, **) + client = Helpers::Client.connection(**settings) + response = client.get("/#{id}") + { success: true, data: response.body } + rescue StandardError => e + { success: false, reason: e.message } + end + + def create(**payload) + client = Helpers::Client.connection(**settings) + response = client.post('/') { |req| req.body = payload.to_json } + { success: true, data: response.body } + rescue StandardError => e + { success: false, reason: e.message } + end + + def update(id:, **payload) + client = Helpers::Client.connection(**settings) + response = client.put("/#{id}") { |req| req.body = payload.to_json } + { success: true, data: response.body } + rescue StandardError => e + { success: false, reason: e.message } + end + + def delete(id:, **) + client = Helpers::Client.connection(**settings) + response = client.delete("/#{id}") + { success: true, status: response.status } + rescue StandardError => e + { success: false, reason: e.message } + end + + private + + def settings + return {} unless defined?(Legion::Settings) + + Legion::Settings.dig(:extensions, :<%= lex_name %>) || {} + end + end + end + end + end +end diff --git a/lib/legion/cli/lex/templates/service_integration/spec/helpers/client_spec.rb.erb b/lib/legion/cli/lex/templates/service_integration/spec/helpers/client_spec.rb.erb new file mode 100644 index 00000000..24b78f23 --- /dev/null +++ b/lib/legion/cli/lex/templates/service_integration/spec/helpers/client_spec.rb.erb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'webmock/rspec' + +RSpec.describe Legion::Extensions::<%= lex_class %>::Helpers::Client do + let(:base_url) { 'https://api.example.com' } + + before { WebMock.enable! } + after { WebMock.disable! } + + describe '.connection' do + it 'raises ArgumentError when base_url is missing' do + expect { described_class.connection }.to raise_error(ArgumentError, /base_url/) + end + + it 'returns a Faraday connection' do + conn = described_class.connection(base_url: base_url) + expect(conn).to be_a(Faraday::Connection) + end + + it 'sets Content-Type header to application/json' do + conn = described_class.connection(base_url: base_url) + expect(conn.headers['Content-Type']).to eq('application/json') + end + + context 'with api_key auth' do + it 'sets the API key header' do + conn = described_class.connection( + base_url: base_url, + auth: { method: :api_key, key: 'secret', header: 'X-API-Key' } + ) + expect(conn.headers['X-API-Key']).to eq('secret') + end + end + + context 'with bearer auth' do + it 'sets Authorization header' do + conn = described_class.connection( + base_url: base_url, + auth: { method: :bearer, token: 'mytoken' } + ) + expect(conn.headers['Authorization']).to eq('Bearer mytoken') + end + end + end +end diff --git a/lib/legion/cli/lex/templates/service_integration/spec/runners/%name%_spec.rb.erb b/lib/legion/cli/lex/templates/service_integration/spec/runners/%name%_spec.rb.erb new file mode 100644 index 00000000..88cfd866 --- /dev/null +++ b/lib/legion/cli/lex/templates/service_integration/spec/runners/%name%_spec.rb.erb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'webmock/rspec' + +RSpec.describe Legion::Extensions::<%= lex_class %>::Runners::<%= name_class %> do + subject { described_class } + + let(:base_url) { 'https://api.example.com' } + let(:test_class) do + Class.new do + extend Legion::Extensions::<%= lex_class %>::Runners::<%= name_class %> + + def self.settings + { base_url: 'https://api.example.com' } + end + end + end + + before { WebMock.enable! } + after { WebMock.disable! } + + it { should be_a Module } + it { is_expected.to respond_to(:list).with_any_keywords } + it { is_expected.to respond_to(:get).with_any_keywords } + it { is_expected.to respond_to(:create).with_any_keywords } + it { is_expected.to respond_to(:update).with_any_keywords } + it { is_expected.to respond_to(:delete).with_any_keywords } + + describe '#list' do + before do + stub_request(:get, "#{base_url}/") + .to_return(status: 200, body: '[]', headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns success' do + result = test_class.list + expect(result[:success]).to be true + end + end + + describe '#get' do + before do + stub_request(:get, "#{base_url}/42") + .to_return(status: 200, body: '{"id":42}', headers: { 'Content-Type' => 'application/json' }) + end + + it 'returns success with data' do + result = test_class.get(id: 42) + expect(result[:success]).to be true + end + end +end diff --git a/lib/legion/cli/lex_cli_manifest.rb b/lib/legion/cli/lex_cli_manifest.rb new file mode 100644 index 00000000..bcf4b6e4 --- /dev/null +++ b/lib/legion/cli/lex_cli_manifest.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'json' +require 'fileutils' + +module Legion + module CLI + class LexCliManifest + attr_reader :cache_dir + + def initialize(cache_dir: File.expand_path('~/.legionio/cache/cli')) + @cache_dir = cache_dir + FileUtils.mkdir_p(@cache_dir) + end + + def write_manifest(gem_name:, gem_version:, alias_name:, commands:) + data = { 'gem' => gem_name, 'version' => gem_version, 'alias' => alias_name, + 'commands' => serialize_commands(commands) } + File.write(manifest_path(gem_name), ::JSON.pretty_generate(data)) + end + + def read_manifest(gem_name) + path = manifest_path(gem_name) + return nil unless File.exist?(path) + + ::JSON.parse(File.read(path)) + end + + def resolve_alias(name) + all_manifests.each do |m| + return m['gem'] if m['alias'] == name + end + nil + end + + def all_manifests + Dir.glob(File.join(@cache_dir, 'lex-*.json')).map do |path| + ::JSON.parse(File.read(path)) + rescue StandardError => e + Legion::Logging.warn("LexCliManifest#all_manifests failed to parse #{path}: #{e.message}") if defined?(Legion::Logging) + nil + end.compact + end + + def stale?(gem_name, current_version) + m = read_manifest(gem_name) + return true unless m + + m['version'] != current_version + end + + private + + def manifest_path(gem_name) + File.join(@cache_dir, "#{gem_name}.json") + end + + def serialize_commands(commands) + commands.transform_values do |cmd| + { + 'class' => cmd[:class_name], + 'methods' => cmd[:methods].transform_values { |m| { 'desc' => m[:desc], 'args' => m[:args] } } + } + end + end + end + end +end diff --git a/lib/legion/cli/lex_command.rb b/lib/legion/cli/lex_command.rb new file mode 100644 index 00000000..94c08dac --- /dev/null +++ b/lib/legion/cli/lex_command.rb @@ -0,0 +1,833 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'legion/extensions/helpers/segments' +require 'legion/cli/lex_cli_manifest' +require 'legion/cli/lex_templates' + +module Legion + module CLI + class Lex < Thor + DEFAULT_CATEGORIES = { + core: { type: :list, tier: 1 }, + ai: { type: :list, tier: 2 }, + gaia: { type: :list, tier: 3 }, + agentic: { type: :prefix, tier: 4 } + }.freeze + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'list [CATEGORY]', 'List all installed extensions, optionally filtered by category' + option :all, type: :boolean, default: false, aliases: ['-a'], desc: 'Include disabled extensions' + option :flat, type: :boolean, default: false, desc: 'Show all extensions in a flat list without category grouping' + def list(category = nil) + out = formatter + lexs = discover_all + + rows = options[:all] ? lexs : lexs.reject { |l| l[:status] == 'disabled' } + rows = rows.select { |l| l[:category] == category } if category + + if options[:flat] || category + render_flat_table(out, rows) + else + render_grouped_table(out, rows) + end + end + default_task :list + + desc 'info NAME', 'Show detailed extension information' + def info(name) + out = formatter + lex = find_lex(name) + + unless lex + out.error("Extension '#{name}' not found. Run `legion lex list` to see installed extensions.") + raise SystemExit, 1 + end + + if options[:json] + out.json(lex) + return + end + + out.header("lex-#{lex[:name]} v#{lex[:version]}") + out.spacer + out.detail({ + name: lex[:name], + version: lex[:version], + status: lex[:status], + gem_dir: lex[:gem_dir], + class: lex[:extension_class] + }) + + if lex[:runners].is_a?(Array) && lex[:runners].any? + out.spacer + out.header('Runners') + lex[:runners].each do |runner| + puts " #{out.colorize(runner, :cyan)}" + end + end + + if lex[:actors].is_a?(Array) && lex[:actors].any? + out.spacer + out.header('Actors') + lex[:actors].each do |actor| + puts " #{out.colorize(actor[:name], :cyan)} #{out.colorize(actor[:type], :gray)}" + end + end + + return unless lex[:dependencies].is_a?(Array) && lex[:dependencies].any? + + out.spacer + out.header('Dependencies') + lex[:dependencies].each do |dep| + puts " #{dep}" + end + end + + desc 'create NAME', 'Scaffold a new Legion extension' + method_option :rspec, type: :boolean, default: true, desc: 'Include RSpec setup' + method_option :github_ci, type: :boolean, default: true, desc: 'Include GitHub Actions CI' + method_option :git_init, type: :boolean, default: true, desc: 'Initialize git repository' + method_option :bundle_install, type: :boolean, default: true, desc: 'Run bundle install' + method_option :category, type: :string, default: nil, + desc: 'Extension category (agentic, ai, gaia). Determines namespace nesting and gem prefix.' + method_option :template, type: :string, default: 'basic', + desc: 'Scaffold template: basic, llm-agent, service-integration, data-pipeline' + method_option :list_templates, type: :boolean, default: false, + desc: 'List available scaffold templates and exit' + def create(name = nil) + out = formatter + + if options[:list_templates] + render_template_list(out) + return + end + + unless name + out.error('NAME is required. Usage: legion lex create NAME [--template TEMPLATE]') + return + end + + if options[:category] && options[:category] !~ /\A[a-z][a-z0-9_-]*\z/ + out.error('--category must be lowercase letters, numbers, underscores, or hyphens') + return + end + + template_name = options[:template] || 'basic' + unless LexTemplates.valid?(template_name) + out.warn("Unknown template '#{template_name}', falling back to 'basic'. Run `legion lex create --list-templates` to see available templates.") + template_name = 'basic' + end + + gem_name = options[:category] ? "lex-#{options[:category]}-#{name}" : "lex-#{name}" + target_dir = gem_name + + if Dir.exist?(target_dir) + out.error("Directory #{target_dir} already exists") + raise SystemExit, 1 + end + + if Dir.pwd.include?('lex-') + out.error('Already inside a LEX directory. Move to a parent directory first.') + raise SystemExit, 1 + end + + Legion::Extensions.check_reserved_words(gem_name, known_org: false) + + out.success("Creating #{gem_name} (template: #{template_name})...") + + vars = { filename: target_dir, class_name: name.split('_').map(&:capitalize).join, lex: name } + + generator = LexGenerator.new(name, vars, options, gem_name: gem_name, template: template_name) + generator.generate(out) + + out.spacer + out.success("Extension #{gem_name} created in ./#{target_dir}") + out.spacer + puts ' Next steps:' + puts " cd #{target_dir}" + puts ' bundle install' unless options[:bundle_install] + puts ' # Add runners: legion generate runner my_runner' + puts ' # Add actors: legion generate actor my_actor' + end + + desc 'enable NAME', 'Enable an extension in settings' + def enable(name) + out = formatter + Connection.ensure_settings(resolve_secrets: false) + + extensions = Legion::Settings[:extensions] || {} + if extensions.key?(name.to_sym) + extensions[name.to_sym][:enabled] = true + else + extensions[name.to_sym] = { enabled: true } + end + + out.success("Extension '#{name}' enabled") + out.warn('Restart Legion for changes to take effect') unless options[:json] + end + + desc 'disable NAME', 'Disable an extension in settings' + def disable(name) + out = formatter + Connection.ensure_settings(resolve_secrets: false) + + extensions = Legion::Settings[:extensions] || {} + if extensions.key?(name.to_sym) + extensions[name.to_sym][:enabled] = false + out.success("Extension '#{name}' disabled") + else + out.warn("Extension '#{name}' not found in settings (may not be configured)") + end + out.warn('Restart Legion for changes to take effect') unless options[:json] + end + + desc 'invoke_ext EXTENSION COMMAND [METHOD]', 'Run a LEX CLI command' + map 'exec' => :invoke_ext + def invoke_ext(ext_name, command = nil, method_name = nil) + out = formatter + manifest = LexCliManifest.new + gem_name = manifest.resolve_alias(ext_name) || "lex-#{ext_name}" + gem_manifest = manifest.read_manifest(gem_name) + + unless gem_manifest + out.error("Unknown extension: #{ext_name}. Run `legion lex list` to see installed extensions.") + return + end + + unless command && gem_manifest.dig('commands', command) + out.header("Available commands for #{ext_name}:") + gem_manifest['commands'].each do |cmd, info| + methods = info['methods'].map { |m, d| "#{m} - #{d['desc']}" }.join(', ') + puts " #{out.colorize(cmd, :cyan)}: #{methods}" + end + return + end + + unless method_name && gem_manifest.dig('commands', command, 'methods', method_name) + methods = gem_manifest.dig('commands', command, 'methods') + out.header("Available methods for #{command}:") + methods.each do |m, d| + puts " #{out.colorize(m, :cyan)} - #{d['desc']}" + end + return + end + + require gem_name.tr('-', '/').tr('_', '/') + klass = Object.const_get(gem_manifest.dig('commands', command, 'class')) + instance = klass.new + instance.public_send(method_name.to_sym) + rescue LoadError => e + out.error("Failed to load #{gem_name}: #{e.message}") + rescue StandardError => e + out.error("Error running #{ext_name} #{command} #{method_name}: #{e.message}") + end + + desc 'fixes', 'List pending auto-fix patches' + option :status, type: :string, desc: 'Filter by status: pending, approved, rejected' + def fixes + out = formatter + with_data do + require 'legion/extensions/codegen/runners/auto_fix' + result = Legion::Extensions::Codegen::Runners::AutoFix.list_fixes(status: options[:status]) + if options[:json] + out.json(result) + elsif result[:fixes].empty? + out.warn('No fixes found') + else + rows = result[:fixes].map do |f| + [f[:fix_id][0..7], f[:gem_name], f[:status], f[:specs_passed] ? 'PASS' : 'FAIL', + f[:branch], f[:created_at]] + end + out.table(%w[ID Gem Status Specs Branch Created], rows) + end + end + end + + desc 'approve-fix FIX_ID', 'Approve an auto-generated fix' + def approve_fix(fix_id) + out = formatter + with_data do + require 'legion/extensions/codegen/runners/auto_fix' + result = Legion::Extensions::Codegen::Runners::AutoFix.approve_fix(fix_id: fix_id) + if result[:success] + out.success("Fix #{fix_id} approved. Merge the branch manually.") + else + out.error("Failed to approve: #{result[:reason]}") + end + end + end + map 'approve_fix' => :approve_fix + + desc 'reject-fix FIX_ID', 'Reject an auto-generated fix' + def reject_fix(fix_id) + out = formatter + with_data do + require 'legion/extensions/codegen/runners/auto_fix' + result = Legion::Extensions::Codegen::Runners::AutoFix.reject_fix(fix_id: fix_id) + if result[:success] + out.success("Fix #{fix_id} rejected.") + else + out.error("Failed to reject: #{result[:reason]}") + end + end + end + map 'reject_fix' => :reject_fix + + no_commands do # rubocop:disable Metrics/BlockLength + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def render_template_list(out) + templates = LexTemplates.list + if options[:json] + out.json(templates) + else + out.header('Available scaffold templates') + rows = templates.map { |t| [t[:name], t[:description]] } + out.table(%w[template description], rows) + end + end + + def format_runners(runners) + return '-' unless runners.is_a?(Array) && runners.any? + + runners.length <= 3 ? runners.join(', ') : "#{runners.length} runners" + end + + def format_actors(actors) + return '-' unless actors.is_a?(Array) && actors.any? + + names = actors.map { |a| a.is_a?(Hash) ? a[:name] : a.to_s } + names.length <= 3 ? names.join(', ') : "#{names.length} actors" + end + + def render_flat_table(out, rows) + if options[:json] + out.json(rows) + return + end + + table_rows = rows.sort_by { |l| l[:name] }.map do |l| + [l[:name], l[:version], l[:category].to_s, out.status(l[:status]), + format_runners(l[:runners]), format_actors(l[:actors])] + end + out.table(%w[name version category status runners actors], table_rows) + end + + def render_grouped_table(out, rows) + if options[:json] + out.json(rows) + return + end + + grouped = rows.group_by { |l| [l[:tier], l[:category]] } + grouped.keys.sort_by { |tier, cat| [tier, cat.to_s] }.each do |key| + tier, cat = key + out.spacer + out.header("#{cat} (tier #{tier})") + group_rows = grouped[key].sort_by { |l| l[:name] }.map do |l| + [l[:name], l[:version], out.status(l[:status]), + format_runners(l[:runners]), format_actors(l[:actors])] + end + out.table(%w[name version status runners actors], group_rows) + end + end + + def discover_all + installed = Gem::Specification.select { |s| s.name.start_with?('lex-') } + + # Load settings to check enabled/disabled state + begin + Connection.ensure_settings(resolve_secrets: false) + ext_settings = Legion::Settings[:extensions] || {} + rescue StandardError => e + Legion::Logging.warn("LexCommand#discover_all settings load failed: #{e.message}") if defined?(Legion::Logging) + ext_settings = {} + end + + categories = resolve_categories + cat_lists = resolve_cat_lists + + result = installed.map do |spec| + short_name = spec.name.sub('lex-', '') + extension_class = Legion::Extensions::Helpers::Segments.derive_const_path(spec.name) + + setting = ext_settings[short_name.to_sym] || {} + status = setting[:enabled] == false ? 'disabled' : 'installed' + + runner_info = extract_runners(spec) + actor_info = extract_actors(spec) + cat_info = Legion::Extensions::Helpers::Segments.categorize_gem(spec.name, categories: categories, lists: cat_lists) + + { + name: short_name, + version: spec.version.to_s, + status: status, + gem_dir: spec.gem_dir, + extension_class: extension_class, + runners: runner_info, + actors: actor_info, + dependencies: spec.runtime_dependencies.map(&:to_s), + category: cat_info[:category].to_s, + tier: cat_info[:tier] + } + end + result.sort_by { |l| [l[:tier], l[:name]] } + end + + def resolve_categories + raw = Legion::Settings.dig(:extensions, :categories) + raw.nil? || raw.empty? ? DEFAULT_CATEGORIES : raw + end + + def resolve_cat_lists + { + core: Array(Legion::Settings.dig(:extensions, :core)), + ai: Array(Legion::Settings.dig(:extensions, :ai)), + gaia: Array(Legion::Settings.dig(:extensions, :gaia)) + } + end + + def find_lex(name) + name = name.sub(/^lex-/, '') + discover_all.find { |l| l[:name] == name } + end + + def extract_runners(spec) + runner_dir = File.join(spec.gem_dir, 'lib', 'legion', 'extensions', + Legion::Extensions::Helpers::Segments.derive_segments(spec.name).join('/'), 'runners') + return [] unless Dir.exist?(runner_dir) + + Dir.glob("#{runner_dir}/*.rb").map { |f| File.basename(f, '.rb') } + rescue StandardError => e + Legion::Logging.warn("LexCommand#extract_runners failed for #{spec.name}: #{e.message}") if defined?(Legion::Logging) + [] + end + + def extract_actors(spec) + actor_dir = File.join(spec.gem_dir, 'lib', 'legion', 'extensions', + Legion::Extensions::Helpers::Segments.derive_segments(spec.name).join('/'), 'actors') + return [] unless Dir.exist?(actor_dir) + + Dir.glob("#{actor_dir}/*.rb").map do |f| + basename = File.basename(f, '.rb') + { name: basename, type: guess_actor_type(f) } + end + rescue StandardError => e + Legion::Logging.warn("LexCommand#extract_actors failed for #{spec.name}: #{e.message}") if defined?(Legion::Logging) + [] + end + + def guess_actor_type(file_path) + content = File.read(file_path, encoding: 'utf-8') + if content.include?('Subscription') + 'subscription' + elsif content.include?('Every') + 'interval' + elsif content.include?('Poll') + 'poll' + elsif content.include?('Once') + 'once' + elsif content.include?('Loop') + 'loop' + else + 'unknown' + end + rescue StandardError => e + Legion::Logging.debug("LexCommand#guess_actor_type failed for #{file_path}: #{e.message}") if defined?(Legion::Logging) + 'unknown' + end + + def with_data + Connection.ensure_data + yield + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + end + end + end + + # Thin generator class that wraps the template logic + class LexGenerator + def initialize(name, vars, options, gem_name: nil, template: 'basic') + @name = name + @vars = vars + @options = options + @gem_name = gem_name || "lex-#{name}" + @target = @gem_name + @template = template || 'basic' + end + + def generate(out) + create_structure(out) + apply_template_overlay(out) unless @template == 'basic' + init_git(out) if @options[:git_init] + run_bundle(out) if @options[:bundle_install] + end + + private + + attr_reader :gem_name + + def target_dir + @target + end + + def namespace_segments + @namespace_segments ||= Legion::Extensions::Helpers::Segments.derive_namespace(@gem_name) + end + + def const_path + @const_path ||= Legion::Extensions::Helpers::Segments.derive_const_path(@gem_name) + end + + def require_path + @require_path ||= Legion::Extensions::Helpers::Segments.derive_require_path(@gem_name) + end + + def extension_dirs + base = "#{@target}/lib/legion/extensions" + segs = Legion::Extensions::Helpers::Segments.derive_segments(@gem_name) + dirs = [ + @target, + "#{@target}/lib", + "#{@target}/lib/legion", + base + ] + segs.each_with_index do |_, i| + dirs << "#{base}/#{segs[0..i].join('/')}" + end + dirs += [ + "#{base}/#{segs.join('/')}/runners", + "#{base}/#{segs.join('/')}/actors", + "#{base}/#{segs.join('/')}/tools", + "#{@target}/spec", + "#{@target}/spec/legion" + ] + dirs + end + + def module_open_lines + indent = ' ' + lines = ["module Legion\n", "#{indent}module Extensions\n"] + namespace_segments.each_with_index do |seg, i| + lines << "#{indent * (i + 2)}module #{seg}\n" + end + lines + end + + def module_close_lines + depth = namespace_segments.length + 2 + (1..depth).map { |i| "#{' ' * (depth - i)}end\n" } + end + + def nested_module_wrap(inner_lines) + opens = module_open_lines + closes = module_close_lines + (opens + inner_lines + closes).join + end + + def create_structure(out) + dirs = extension_dirs + dirs << "#{@target}/.github/workflows" if @options[:github_ci] + + dirs.each { |d| FileUtils.mkdir_p(d) } + + ext_base = "lib/legion/extensions/#{Legion::Extensions::Helpers::Segments.derive_segments(@gem_name).join('/')}" + FileUtils.touch("#{@target}/#{ext_base}/tools/.gitkeep") + + entry_file = "lib/legion/extensions/#{require_path.split('legion/extensions/').last}" + + write_template("#{@target}/#{@target}.gemspec", gemspec_content) + write_template("#{@target}/Gemfile", gemfile_content) + write_template("#{@target}/.gitignore", gitignore_content) + write_template("#{@target}/.rubocop.yml", rubocop_content) + write_template("#{@target}/LICENSE", license_content) + write_template("#{@target}/README.md", readme_content) + write_template("#{@target}/lib/#{entry_file}.rb", extension_entry_content) + write_template("#{@target}/#{ext_base}/version.rb", version_content) + write_template("#{@target}/#{ext_base}/client.rb", client_content) + + if @options[:rspec] + spec_relative = Legion::Extensions::Helpers::Segments.derive_segments(@gem_name).join('/') + FileUtils.mkdir_p("#{@target}/spec/legion/extensions/#{File.dirname(spec_relative)}") + write_template("#{@target}/spec/spec_helper.rb", spec_helper_content) + write_template("#{@target}/spec/legion/extensions/#{spec_relative}_spec.rb", spec_content) + end + + if @options[:github_ci] + write_template("#{@target}/.github/workflows/rspec.yml", github_rspec_content) + write_template("#{@target}/.github/workflows/rubocop.yml", github_rubocop_content) + end + + out.success('Files generated') + end + + def write_template(path, content) + File.write(path, content) + end + + def apply_template_overlay(out) + segs = Legion::Extensions::Helpers::Segments.derive_segments(@gem_name) + lex_class = segs.map(&:capitalize).join('::') + lex_name = @name + name_class = @name.split(/[_-]/).map(&:capitalize).join + gem_name = @gem_name + + vars = { lex_class: lex_class, lex_name: lex_name, name_class: name_class, gem_name: gem_name } + overlay = LexTemplates::TemplateOverlay.new(@template, @target, vars) + overlay.apply(out) + end + + def init_git(out) + Dir.chdir(@target) do + system('git init -q') + system('git add .') + system("git commit -q -m 'initial commit'") + end + out.success('Git initialized') + end + + def run_bundle(out) + Dir.chdir(@target) do + system('bundle install --quiet') + end + out.success('Bundle installed') + end + + def gemspec_content + <<~RUBY + # frozen_string_literal: true + + require_relative 'lib/#{require_path}/version' + + Gem::Specification.new do |spec| + spec.name = '#{@gem_name}' + spec.version = #{const_path}::VERSION + spec.authors = ['Esity'] + spec.email = ['matthewdiverson@gmail.com'] + spec.summary = 'A LegionIO Extension for #{namespace_segments.last}' + spec.description = 'A LegionIO Extension (LEX) for #{namespace_segments.last}' + spec.homepage = 'https://github.com/LegionIO/#{@gem_name}' + spec.license = 'MIT' + spec.required_ruby_version = '>= 3.4' + + spec.metadata = { + 'homepage_uri' => spec.homepage, + 'source_code_uri' => spec.homepage, + 'rubygems_mfa_required' => 'true' + } + + spec.files = Dir['lib/**/*', 'LICENSE', 'README.md'] + spec.require_paths = ['lib'] + + spec.add_dependency 'legionio', '>= 1.2' + end + RUBY + end + + def gemfile_content + <<~RUBY + # frozen_string_literal: true + + source 'https://rubygems.org' + gemspec + + group :development, :test do + gem 'rspec', '~> 3.12' + gem 'rubocop', '~> 1.50' + gem 'rubocop-rspec', '~> 2.20' + end + RUBY + end + + def gitignore_content + <<~TEXT + /.bundle/ + /.yardoc + /_yardoc/ + /coverage/ + /doc/ + /pkg/ + /spec/reports/ + /tmp/ + *.gem + Gemfile.lock + TEXT + end + + def rubocop_content + <<~YAML + inherit_gem: + rubocop: config/default.yml + + AllCops: + NewCops: enable + TargetRubyVersion: 3.4 + YAML + end + + def license_content + <<~TEXT + MIT License + + Copyright (c) #{Time.now.year} LegionIO + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + TEXT + end + + def readme_content + <<~MD + # #{@gem_name} + + A [LegionIO](https://github.com/LegionIO) extension for #{namespace_segments.last}. + + ## Installation + + ```ruby + gem '#{@gem_name}' + ``` + + ## Usage + + This extension is auto-discovered by LegionIO when installed. + + ## Development + + ```bash + bundle install + bundle exec rspec + bundle exec rubocop + ``` + + ## License + + MIT + MD + end + + def extension_entry_content + segs = Legion::Extensions::Helpers::Segments.derive_segments(@gem_name) + last_seg = segs.last + inner = [" require_relative '#{last_seg}/version'\n", + " require_relative '#{last_seg}/client'\n", + "\n"] + "# frozen_string_literal: true\n\n#{nested_module_wrap(inner)}" + end + + def version_content + depth = namespace_segments.length + 2 + inner = ["#{' ' * depth}VERSION = '0.1.0'\n"] + "# frozen_string_literal: true\n\n#{nested_module_wrap(inner)}" + end + + def client_content + depth = namespace_segments.length + 2 + pad = ' ' * depth + inner = [ + "#{pad}class Client\n", + "#{pad} attr_reader :opts\n", + "\n", + "#{pad} def initialize(**kwargs)\n", + "#{pad} @opts = kwargs\n", + "#{pad} end\n", + "\n", + "#{pad} def connection(**override)\n", + "#{pad} Helpers::Client.connection(**@opts, **override)\n", + "#{pad} end\n", + "#{pad}end\n" + ] + "# frozen_string_literal: true\n\n#{nested_module_wrap(inner)}" + end + + def spec_helper_content + <<~RUBY + # frozen_string_literal: true + + require '#{require_path}' + + RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + end + RUBY + end + + def spec_content + <<~RUBY + # frozen_string_literal: true + + RSpec.describe #{const_path} do + it 'has a version number' do + expect(#{const_path}::VERSION).not_to be_nil + end + end + RUBY + end + + def github_rspec_content + <<~YAML + name: RSpec + on: [push, pull_request] + jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: bundle exec rspec + YAML + end + + def github_rubocop_content + <<~YAML + name: RuboCop + on: [push, pull_request] + jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.4' + bundler-cache: true + - run: bundle exec rubocop + YAML + end + end + end +end diff --git a/lib/legion/cli/lex_templates.rb b/lib/legion/cli/lex_templates.rb new file mode 100644 index 00000000..32bed7c7 --- /dev/null +++ b/lib/legion/cli/lex_templates.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'erb' +require 'fileutils' + +module Legion + module CLI + module LexTemplates + TEMPLATES_DIR = File.join(File.dirname(__FILE__), 'lex', 'templates').freeze + + REGISTRY = { + 'basic' => { + runners: ['default'], + actors: ['subscription'], + tools: [], + client: false, + dependencies: [], + description: 'Basic extension with subscription actor' + }, + 'llm-agent' => { + runners: %w[processor analyzer], + actors: %w[subscription polling], + tools: %w[process analyze], + client: true, + dependencies: ['legion-llm'], + description: 'LLM-powered agent extension', + template_dir: 'llm_agent' + }, + 'service-integration' => { + runners: ['operations'], + actors: ['subscription'], + tools: [], + client: true, + dependencies: [], + description: 'External service integration with standalone client', + template_dir: 'service_integration' + }, + 'data-pipeline' => { + runners: ['transform'], + actors: ['ingest'], + tools: [], + client: false, + dependencies: [], + description: 'Event-driven data processing pipeline', + template_dir: 'data_pipeline' + }, + 'scheduled-task' => { + runners: ['executor'], + actors: ['interval'], + tools: [], + client: false, + dependencies: [], + description: 'Scheduled task with interval actor' + }, + 'webhook-handler' => { + runners: %w[handler validator], + actors: ['subscription'], + tools: [], + client: false, + dependencies: [], + description: 'Inbound webhook processing' + } + }.freeze + + class << self + def list + REGISTRY.map { |name, config| { name: name, description: config[:description] } } + end + + def get(name) + REGISTRY[name.to_s] + end + + def valid?(name) + REGISTRY.key?(name.to_s) + end + + def template_dir(name) + config = REGISTRY[name.to_s] + return nil unless config + + dir_key = config[:template_dir] + return nil unless dir_key + + File.join(TEMPLATES_DIR, dir_key) + end + end + + # Renders and writes template-specific overlay files into the target extension directory. + class TemplateOverlay + PLACEHOLDER = '%name%' + + # vars: { gem_name:, lex_name:, lex_class:, name_class: } + def initialize(template_name, target_dir, vars) + @template_name = template_name + @target_dir = target_dir + @vars = vars + end + + def apply(out = nil) + src = LexTemplates.template_dir(@template_name) + return unless src && Dir.exist?(src) + + each_template_file(src) do |abs_src, rel_path| + dest_rel = rel_path.gsub(PLACEHOLDER, @vars[:lex_name]) + dest_rel = dest_rel.sub(/\.erb$/, '') + dest_abs = File.join(@target_dir, dest_rel) + + FileUtils.mkdir_p(File.dirname(dest_abs)) + rendered = render_erb(File.read(abs_src)) + File.write(dest_abs, rendered) + out&.success(" [#{@template_name}] #{dest_rel}") + end + end + + private + + def each_template_file(src_dir, &block) + Dir.glob("#{src_dir}/**/*.erb").each do |abs_src| + rel_path = abs_src.sub("#{src_dir}/", '') + block.call(abs_src, rel_path) + end + end + + def render_erb(template_text) + lex_class = @vars[:lex_class] + lex_name = @vars[:lex_name] + name_class = @vars[:name_class] + gem_name = @vars[:gem_name] + + ERB.new(template_text, trim_mode: '-').result(binding) + end + end + end + end +end diff --git a/lib/legion/cli/llm_command.rb b/lib/legion/cli/llm_command.rb new file mode 100644 index 00000000..41e1c9a8 --- /dev/null +++ b/lib/legion/cli/llm_command.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Llm < Thor + namespace 'llm' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'status', 'Show LLM subsystem status and provider health' + default_task :status + def status + out = formatter + boot_llm_settings + + data = collect_status + if options[:json] + out.json(data) + else + show_status(out, data) + end + end + + desc 'providers', 'List configured LLM providers' + def providers + out = formatter + boot_llm_settings + + data = collect_providers + if options[:json] + out.json(providers: data) + else + show_providers(out, data) + end + end + + desc 'models', 'List available models per provider' + def models + out = formatter + boot_llm_settings + + data = collect_models + if options[:json] + out.json(models: data) + else + show_models(out, data) + end + end + + desc 'ping', 'Test connectivity to each enabled provider' + option :timeout, type: :numeric, default: 15, desc: 'Timeout per provider in seconds' + def ping + out = formatter + boot_llm(out) + + results = ping_all_providers(out) + if options[:json] + out.json(results: results) + else + show_ping_results(out, results) + end + end + + no_commands do # rubocop:disable Metrics/BlockLength + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def boot_llm_settings + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_settings + Legion::Settings.resolve_secrets! if Legion::Settings.respond_to?(:resolve_secrets!) + require 'legion/llm' + Legion::Settings.merge_settings(:llm, Legion::LLM::Settings.default) + end + + def boot_llm(out) + boot_llm_settings + out.header('Starting LLM subsystem...') unless options[:json] + Legion::LLM.start + rescue StandardError => e + Legion::Logging.error("LlmCommand#boot_llm failed: #{e.message}") if defined?(Legion::Logging) + out.error("LLM start failed: #{e.message}") unless options[:json] + end + + def llm_settings + Legion::LLM.settings + end + + def collect_status + providers_cfg = llm_settings[:providers] || {} + enabled = providers_cfg.select { |_, c| c[:enabled] } + started = defined?(Legion::LLM) && Legion::LLM.started? + + { + started: started, + default_model: llm_settings[:default_model], + default_provider: llm_settings[:default_provider], + enabled_count: enabled.size, + total_count: providers_cfg.size, + providers: collect_providers, + routing: collect_routing, + system: collect_system + } + end + + def collect_providers + providers_cfg = llm_settings[:providers] || {} + providers_cfg.map do |name, cfg| + enabled = cfg[:enabled] == true + { + name: name, + enabled: enabled, + deferred: !enabled && unresolved_credentials?(cfg), + default_model: cfg[:default_model], + reachable: check_reachable(name, cfg) + } + end + end + + def unresolved_credentials?(cfg) + %i[api_key secret_key bearer_token password].any? do |key| + val = cfg[key].to_s + val.start_with?('vault://', 'lease://', 'env://') + end + end + + def check_reachable(name, cfg) + case name + when :ollama + return false unless cfg[:enabled] + + base = cfg[:base_url] || 'http://localhost:11434' + uri = URI(base) + Socket.tcp(uri.host, uri.port, connect_timeout: 2) { true } + when :bedrock + return nil unless cfg[:enabled] + + cfg[:bearer_token] || (cfg[:api_key] && cfg[:secret_key]) ? :credentials_present : false + else + return nil unless cfg[:enabled] + + cfg[:api_key] ? :credentials_present : false + end + rescue StandardError => e + Legion::Logging.warn("LlmCommand#check_provider_credentials failed: #{e.message}") if defined?(Legion::Logging) + false + end + + def collect_routing + return { enabled: false } unless defined?(Legion::LLM::Router) + + { + enabled: Legion::LLM::Router.routing_enabled?, + local_tier: Legion::LLM::Router.tier_available?(:local), + fleet_tier: Legion::LLM::Router.tier_available?(:fleet), + cloud_tier: Legion::LLM::Router.tier_available?(:cloud) + } + rescue StandardError => e + Legion::Logging.warn("LlmCommand#collect_routing failed: #{e.message}") if defined?(Legion::Logging) + { enabled: false } + end + + def collect_system + return {} unless defined?(Legion::LLM::Discovery::System) + + Legion::LLM::Discovery::System.refresh! if Legion::LLM::Discovery::System.stale? + { + platform: Legion::LLM::Discovery::System.platform, + total_memory_mb: Legion::LLM::Discovery::System.total_memory_mb, + avail_memory_mb: Legion::LLM::Discovery::System.available_memory_mb, + memory_pressure: Legion::LLM::Discovery::System.memory_pressure? + } + rescue StandardError => e + Legion::Logging.warn("LlmCommand#collect_system failed: #{e.message}") if defined?(Legion::Logging) + {} + end + + def collect_models + providers_cfg = llm_settings[:providers] || {} + result = {} + + providers_cfg.each do |name, cfg| + next unless cfg[:enabled] + + models = [cfg[:default_model]].compact + if name == :ollama && defined?(Legion::LLM::Discovery::Ollama) + begin + Legion::LLM::Discovery::Ollama.refresh! if Legion::LLM::Discovery::Ollama.stale? + discovered = Legion::LLM::Discovery::Ollama.model_names + models = discovered unless discovered.empty? + rescue StandardError => e + Legion::Logging.debug("LlmCommand#collect_models ollama discovery failed: #{e.message}") if defined?(Legion::Logging) + end + end + result[name] = models + end + result + end + + def ping_all_providers(out) + providers_cfg = llm_settings[:providers] || {} + enabled = providers_cfg.select { |_, c| c[:enabled] } + + if enabled.empty? + out.warn('No providers enabled') unless options[:json] + return [] + end + + enabled.map do |name, cfg| + ping_one_provider(out, name, cfg) + end + end + + def ping_one_provider(out, name, cfg) + model = cfg[:default_model] + return { provider: name, status: 'skip', message: 'no default model configured', latency_ms: nil } unless model + + out.header(" Pinging #{name} (#{model})...") unless options[:json] + t0 = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + + response = Legion::LLM.ask_direct( + message: 'Respond with only the word: pong', + model: model, + provider: name, + caller: { source: 'cli', command: 'llm ping' } + ) + elapsed = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - t0) * 1000).round + + content = response_content(response) + success = content.downcase.include?('pong') + + if success + out.success(" #{name}: pong (#{elapsed}ms)") unless options[:json] + else + out.warn(" #{name}: unexpected response (#{elapsed}ms): #{content[0..80]}") unless options[:json] + end + + { provider: name, status: success ? 'ok' : 'unexpected', response: content[0..80], + model: model, latency_ms: elapsed } + rescue StandardError => e + elapsed = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - t0) * 1000).round if t0 + + out.error(" #{name}: #{e.message}") unless options[:json] + { provider: name, status: 'error', message: e.message, model: model, latency_ms: elapsed } + end + + def response_content(response) + if response.respond_to?(:content) + response.content.to_s.strip + elsif response.is_a?(Hash) + (response[:content] || response['content'] || response[:response] || response['response']).to_s.strip + else + response.to_s.strip + end + end + + def show_status(out, data) + out.header('LLM Status') + out.detail({ + 'Started' => data[:started].to_s, + 'Default Provider' => (data[:default_provider] || '(none)').to_s, + 'Default Model' => (data[:default_model] || '(none)').to_s, + 'Providers Enabled' => "#{data[:enabled_count]}/#{data[:total_count]}" + }) + + out.spacer + show_providers(out, data[:providers]) + + routing = data[:routing] || {} + if routing[:enabled] + out.spacer + out.header('Routing') + out.detail({ + 'Enabled' => routing[:enabled].to_s, + 'Local Tier' => routing[:local_tier].to_s, + 'Fleet Tier' => routing[:fleet_tier].to_s, + 'Cloud Tier' => routing[:cloud_tier].to_s + }) + end + + sys = data[:system] || {} + return if sys.empty? + + out.spacer + out.header('System') + out.detail({ + 'Platform' => (sys[:platform] || 'unknown').to_s, + 'Total Memory' => sys[:total_memory_mb] ? "#{sys[:total_memory_mb]} MB" : 'unknown', + 'Available Memory' => sys[:avail_memory_mb] ? "#{sys[:avail_memory_mb]} MB" : 'unknown', + 'Memory Pressure' => sys[:memory_pressure].to_s + }) + end + + def show_providers(out, providers_data) + out.header('Providers') + providers_data.each do |p| + status = if p[:enabled] + reach = p[:reachable] + case reach + when true then 'enabled, reachable' + when :credentials_present then 'enabled, credentials present' + when false then 'enabled, unreachable' + else 'enabled' + end + elsif p[:deferred] + 'deferred (credentials pending Vault)' + else + 'disabled' + end + + color = if p[:enabled] + :green + elsif p[:deferred] + :yellow + else + :muted + end + name_str = p[:name].to_s.ljust(12) + model_str = p[:default_model] ? " (#{p[:default_model]})" : '' + puts " #{out.colorize(name_str, :label)}#{out.colorize(status, color)}#{model_str}" + end + end + + def show_models(out, models_data) + out.header('Available Models') + if models_data.empty? + out.warn('No providers enabled') + return + end + + models_data.each do |provider, model_list| + out.spacer + puts " #{out.colorize(provider.to_s, :accent)} (#{model_list.size} model#{'s' unless model_list.size == 1})" + model_list.each { |m| puts " #{m}" } + end + end + + def show_ping_results(out, results) + return if results.empty? + + out.spacer + out.header('Ping Results') + passed = 0 + failed = 0 + + results.each do |r| + case r[:status] + when 'ok' + passed += 1 + when 'skip' + puts " #{out.colorize(r[:provider].to_s.ljust(12), :label)}#{out.colorize('skipped', :muted)} #{r[:message]}" + else + failed += 1 + end + end + + out.spacer + if failed.zero? + out.success("#{passed} provider(s) responding") + else + out.error("#{failed} provider(s) failed, #{passed} responding") + end + end + end + end + end +end diff --git a/lib/legion/cli/marketplace_command.rb b/lib/legion/cli/marketplace_command.rb new file mode 100644 index 00000000..5fad10de --- /dev/null +++ b/lib/legion/cli/marketplace_command.rb @@ -0,0 +1,392 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Marketplace < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + # ────────────────────────────────────────────────────────── + # search + # ────────────────────────────────────────────────────────── + + desc 'search QUERY', 'Search extension registry' + def search(query) + require 'legion/registry' + out = formatter + results = Legion::Registry.search(query) + + if results.empty? + out.warn("No extensions found matching '#{query}'") + return + end + + if options[:json] + out.json(results.map(&:to_h)) + else + rows = results.map do |e| + status_label = e.approved? ? 'approved' : (e.status || e.airb_status).to_s + [e.name, e.version.to_s, status_label, (e.description || '')[0..60]] + end + out.table(%w[Name Version Status Description], rows) + end + end + + # ────────────────────────────────────────────────────────── + # info + # ────────────────────────────────────────────────────────── + + desc 'info NAME', 'Show extension details' + def info(name) + require 'legion/registry' + out = formatter + entry = Legion::Registry.lookup(name) + + unless entry + out.error("Extension '#{name}' not found") + return + end + + if options[:json] + out.json(entry.to_h) + else + out.header("Extension: #{entry.name}") + out.spacer + out.detail(entry.to_h.compact) + end + end + + # ────────────────────────────────────────────────────────── + # list + # ────────────────────────────────────────────────────────── + + desc 'list', 'List all registered extensions' + option :approved, type: :boolean, desc: 'Show only approved extensions' + option :tier, type: :string, desc: 'Filter by risk tier' + option :status, type: :string, desc: 'Filter by review status' + def list + require 'legion/registry' + out = formatter + extensions = build_extension_list + + if extensions.empty? + out.warn('No extensions registered') + return + end + + if options[:json] + out.json(extensions.map(&:to_h)) + else + rows = extensions.map { |e| [e.name, e.version.to_s, e.status.to_s, e.risk_tier] } + out.table(%w[Name Version Status Tier], rows) + puts " #{extensions.size} extension(s)" + end + end + + # ────────────────────────────────────────────────────────── + # scan + # ────────────────────────────────────────────────────────── + + desc 'scan NAME', 'Run security scan on extension' + def scan(name) + require 'legion/registry/security_scanner' + out = formatter + scanner = Legion::Registry::SecurityScanner.new + result = scanner.scan(name: name) + + if options[:json] + out.json(result) + else + result[:checks].each do |check| + color = check[:status] == :fail ? :critical : :nominal + puts " #{out.colorize(check[:check].to_s.ljust(25), color)} #{check[:status]} - #{check[:details]}" + end + if result[:passed] + out.success('Scan PASSED') + else + out.error('Scan FAILED') + end + end + end + + # ────────────────────────────────────────────────────────── + # submit + # ────────────────────────────────────────────────────────── + + desc 'submit NAME', 'Submit extension for review' + def submit(name) + require 'legion/registry' + out = formatter + + Legion::Registry.submit_for_review(name) + + if options[:json] + out.json(success: true, name: name, status: 'pending_review') + else + out.success("'#{name}' submitted for review") + end + rescue ArgumentError => e + out.error(e.message) + end + + # ────────────────────────────────────────────────────────── + # review + # ────────────────────────────────────────────────────────── + + desc 'review', 'List extensions pending review' + def review + require 'legion/registry' + out = formatter + pending = Legion::Registry.pending_reviews + + if pending.empty? + out.warn('No extensions pending review') + return + end + + if options[:json] + out.json(pending.map(&:to_h)) + else + rows = pending.map { |e| [e.name, e.version.to_s, e.author.to_s, e.submitted_at.to_s] } + out.table(%w[Name Version Author Submitted], rows) + puts " #{pending.size} pending review(s)" + end + end + + # ────────────────────────────────────────────────────────── + # approve + # ────────────────────────────────────────────────────────── + + desc 'approve NAME', 'Approve an extension' + option :notes, type: :string, desc: 'Reviewer notes' + def approve(name) + require 'legion/registry' + out = formatter + + Legion::Registry.approve(name, notes: options[:notes]) + + if options[:json] + out.json(success: true, name: name, status: 'approved') + else + out.success("'#{name}' approved") + out.detail({ 'Notes' => options[:notes] }) if options[:notes] + end + rescue ArgumentError => e + out.error(e.message) + end + + # ────────────────────────────────────────────────────────── + # reject + # ────────────────────────────────────────────────────────── + + desc 'reject NAME', 'Reject an extension' + option :reason, type: :string, desc: 'Rejection reason' + def reject(name) + require 'legion/registry' + out = formatter + + Legion::Registry.reject(name, reason: options[:reason]) + + if options[:json] + out.json(success: true, name: name, status: 'rejected') + else + out.success("'#{name}' rejected") + out.detail({ 'Reason' => options[:reason] }) if options[:reason] + end + rescue ArgumentError => e + out.error(e.message) + end + + # ────────────────────────────────────────────────────────── + # deprecate + # ────────────────────────────────────────────────────────── + + desc 'deprecate NAME', 'Mark an extension as deprecated' + option :successor, type: :string, desc: 'Replacement extension name' + option :sunset_date, type: :string, desc: 'Sunset date (YYYY-MM-DD)' + def deprecate(name) + require 'legion/registry' + out = formatter + + sunset = parse_sunset_date(options[:sunset_date]) + Legion::Registry.deprecate(name, successor: options[:successor], sunset_date: sunset) + + if options[:json] + out.json(success: true, name: name, status: 'deprecated', + successor: options[:successor], sunset_date: options[:sunset_date]) + else + out.success("'#{name}' marked as deprecated") + detail_hash = {} + detail_hash['Successor'] = options[:successor] if options[:successor] + detail_hash['Sunset Date'] = options[:sunset_date] if options[:sunset_date] + out.detail(detail_hash) unless detail_hash.empty? + end + rescue ArgumentError => e + out.error(e.message) + end + + # ────────────────────────────────────────────────────────── + # install + # ────────────────────────────────────────────────────────── + + desc 'install NAME', 'Install a lex extension gem' + option :source, type: :string, desc: 'Gem source URL (overrides configured sources)' + def install(name) + require 'legion/registry' + require 'legion/extensions/gem_source' + out = formatter + + unless name.start_with?('lex-') + out.error("Extension name must start with 'lex-'") + return + end + + begin + Connection.ensure_settings(resolve_secrets: false) + Legion::Extensions::GemSource.setup! + rescue StandardError => e + Legion::Logging.debug("marketplace install: settings not available: #{e.message}") if defined?(Legion::Logging) + end + + result = if options[:source] + Legion::Extensions::GemSource.install_gem(name, source_override: options[:source]) + else + Legion::Extensions::GemSource.install_gem(name) + end + + if result[:success] + entry = Legion::Registry::Entry.new(name: name, status: :active, airb_status: 'pending') + Legion::Registry.register(entry) + out.success("'#{name}' installed successfully") + else + out.error("Failed to install '#{name}'") + puts result[:output] if result[:output] + end + end + + # ────────────────────────────────────────────────────────── + # publish + # ────────────────────────────────────────────────────────── + + desc 'publish', 'Publish current extension to rubygems' + def publish + require 'legion/registry' + require 'legion/registry/security_scanner' + out = formatter + + gemspec_files = Dir.glob('*.gemspec') + if gemspec_files.empty? + out.error('No gemspec found — publish aborted') + return + end + + gemspec_path = gemspec_files.first + gem_name = File.basename(gemspec_path, '.gemspec') + + unless Kernel.system('bundle', 'exec', 'rspec') + out.error('Specs failed — publish aborted') + return + end + + unless Kernel.system('bundle', 'exec', 'rubocop') + out.error('Rubocop failed — publish aborted') + return + end + + unless Kernel.system('gem', 'build', gemspec_path) + out.error("Failed to build gem '#{gem_name}'") + return + end + + gem_files = Dir.glob("#{gem_name}-*.gem") + if gem_files.empty? + out.error('No built gem file found after build') + return + end + + gem_file = gem_files.max_by { |f| File.mtime(f) } + + unless Kernel.system('gem', 'push', gem_file) + out.error("Failed to push '#{gem_file}'") + return + end + + scanner = Legion::Registry::SecurityScanner.new + scan_result = scanner.scan(name: gem_file) + + version = gem_file.sub("#{gem_name}-", '').sub('.gem', '') + entry = Legion::Registry::Entry.new(name: gem_name, version: version, + status: :active, airb_status: 'pending') + Legion::Registry.register(entry) + + out.success("'#{gem_name}' v#{version} published — security: #{scan_result[:passed] ? 'passed' : 'failed'}") + end + + # ────────────────────────────────────────────────────────── + # stats + # ────────────────────────────────────────────────────────── + + desc 'stats NAME', 'Show usage statistics for an extension' + def stats(name) + require 'legion/registry' + out = formatter + data = Legion::Registry.usage_stats(name) + + unless data + out.error("Extension '#{name}' not found") + return + end + + if options[:json] + out.json(data) + else + out.header("Usage Stats: #{name}") + out.spacer + out.detail({ + 'Install Count' => data[:install_count].to_s, + 'Active Instances' => data[:active_instances].to_s, + 'Downloads (7d)' => data[:downloads_7d].to_s, + 'Downloads (30d)' => data[:downloads_30d].to_s, + 'Last Updated' => data[:last_updated].to_s + }) + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def build_extension_list + if options[:approved] + Legion::Registry.approved + elsif options[:tier] + Legion::Registry.by_risk_tier(options[:tier]) + elsif options[:status] + Legion::Registry.all.select { |e| e.status.to_s == options[:status] } + else + Legion::Registry.all + end + end + + def parse_sunset_date(date_str) + return nil if date_str.nil? || date_str.empty? + + Date.parse(date_str) + rescue ArgumentError => e + Legion::Logging.debug("MarketplaceCommand#parse_sunset_date failed to parse '#{date_str}': #{e.message}") if defined?(Legion::Logging) + nil + end + end + end + end +end diff --git a/lib/legion/cli/mcp_command.rb b/lib/legion/cli/mcp_command.rb new file mode 100644 index 00000000..85a4fb71 --- /dev/null +++ b/lib/legion/cli/mcp_command.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Mcp < Thor + def self.exit_on_failure? + true + end + + desc 'stdio', 'Start MCP server with stdio transport (default)' + def stdio + require 'legion/mcp' + + server = Legion::MCP.server + transport = ::MCP::Server::Transports::StdioTransport.new(server) + transport.open + end + + desc 'http', 'Start MCP server with streamable HTTP transport' + option :port, type: :numeric, default: 9393, desc: 'Port to listen on' + option :host, type: :string, default: 'localhost', desc: 'Host to bind to' + def http + require 'legion/mcp' + require 'rackup' + + server = Legion::MCP.server + transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(server) + server.transport = transport + + app = build_rack_app(transport) + + warn "Legion MCP server listening on http://#{options[:host]}:#{options[:port]}" + Rackup::Handler.get('puma').run(app, Port: options[:port], Host: options[:host]) + end + + default_command :stdio + + no_commands do + private + + def build_rack_app(transport) + Rack::Builder.new do + run lambda { |env| + req = Rack::Request.new(env) + if Legion::MCP::Auth.auth_enabled? + token = req.get_header('HTTP_AUTHORIZATION')&.sub(/\ABearer /i, '') + auth = Legion::MCP::Auth.authenticate(token) + unless auth[:authenticated] + next [401, { 'content-type' => 'application/json' }, + [Legion::JSON.dump({ error: auth[:error] })]] + end + end + transport.handle_request(req) + } + end + end + end + end + end +end diff --git a/lib/legion/cli/memory_command.rb b/lib/legion/cli/memory_command.rb new file mode 100644 index 00000000..1c1173bb --- /dev/null +++ b/lib/legion/cli/memory_command.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' + +module Legion + module CLI + class Memory < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :global, type: :boolean, default: false, aliases: ['-g'], + desc: 'Use global memory instead of project memory' + + desc 'list', 'List all memory entries' + def list + out = formatter + require 'legion/cli/chat/memory_store' + scope = options[:global] ? :global : :project + entries = Chat::MemoryStore.list(scope: scope) + + if entries.empty? + out.warn('No memory entries found.') + return + end + + if options[:json] + out.json({ entries: entries, scope: scope.to_s }) + else + out.header("#{scope.to_s.capitalize} Memory (#{entries.length} entries)") + entries.each { |e| puts " - #{e}" } + end + end + default_task :list + + desc 'add TEXT', 'Add a memory entry' + def add(text) + out = formatter + require 'legion/cli/chat/memory_store' + scope = options[:global] ? :global : :project + path = Chat::MemoryStore.add(text, scope: scope) + out.success("Added to #{scope} memory (#{path})") + end + + desc 'forget PATTERN', 'Remove memory entries matching pattern' + def forget(pattern) + out = formatter + require 'legion/cli/chat/memory_store' + scope = options[:global] ? :global : :project + removed = Chat::MemoryStore.forget(pattern, scope: scope) + + if removed.zero? + out.warn("No entries matching '#{pattern}' found.") + else + out.success("Removed #{removed} entry/entries matching '#{pattern}'") + end + end + + desc 'search QUERY', 'Search memory entries' + def search(query) + out = formatter + require 'legion/cli/chat/memory_store' + results = Chat::MemoryStore.search(query) + + if results.empty? + out.warn("No results for '#{query}'") + return + end + + if options[:json] + out.json({ results: results, query: query }) + else + results.each do |r| + source = File.basename(File.dirname(r[:source])) + puts " #{source}:#{r[:line]} #{r[:text]}" + end + end + end + + desc 'clear', 'Clear all memory entries' + option :yes, type: :boolean, default: false, aliases: ['-y'], desc: 'Skip confirmation' + def clear + out = formatter + scope = options[:global] ? :global : :project + + unless options[:yes] + $stderr.print "Clear all #{scope} memory? [y/n] " + response = $stdin.gets&.strip&.downcase + return unless %w[y yes].include?(response) + end + + require 'legion/cli/chat/memory_store' + if Chat::MemoryStore.clear(scope: scope) + out.success("#{scope.to_s.capitalize} memory cleared.") + else + out.warn('No memory file to clear.') + end + end + + desc 'consolidate', 'Consolidate cross-session learnings into global memory' + option :force, type: :boolean, default: false, aliases: ['-f'], + desc: 'Skip gate checks (time, sessions, lock)' + def consolidate + out = formatter + require 'legion/memory/consolidator' + + out.header('Cross-Session Memory Consolidation') + + unless Legion::Memory::Consolidator.enabled? + out.warn('Consolidation is disabled. Enable with memory.consolidation.enabled: true') + return + end + + unless options[:force] + gates = Legion::Memory::Consolidator.gate_status + out.detail({ + 'Time gate' => gates[:time_gate] ? 'pass' : 'fail', + 'Session gate' => gates[:session_gate] ? 'pass' : 'fail', + 'Lock gate' => gates[:lock_gate] ? 'pass' : 'fail' + }) + end + + result = Legion::Memory::Consolidator.run(force: options[:force]) + + if options[:json] + out.json(result) + return + end + + if result[:success] + out.success("Consolidated #{result[:insights_count]} insights from #{result[:transcripts_scanned]} sessions") + else + out.warn("Consolidation skipped: #{result[:reason]}") + end + end + + desc 'status', 'Show consolidation gate status' + def status + out = formatter + require 'legion/memory/consolidator' + + gates = Legion::Memory::Consolidator.gate_status + settings = Legion::Memory::Consolidator.consolidation_settings + + if options[:json] + out.json({ gates: gates, settings: settings, enabled: Legion::Memory::Consolidator.enabled? }) + return + end + + out.header('Consolidation Status') + out.detail({ + 'Enabled' => Legion::Memory::Consolidator.enabled?.to_s, + 'Time gate' => gates[:time_gate] ? 'pass' : "fail (< #{settings[:min_hours]}h since last run)", + 'Session gate' => gates[:session_gate] ? 'pass' : "fail (< #{settings[:min_sessions]} new sessions)", + 'Lock gate' => gates[:lock_gate] ? 'pass' : 'fail (consolidation in progress)' + }) + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + end + end + end +end diff --git a/lib/legion/cli/mind_growth_command.rb b/lib/legion/cli/mind_growth_command.rb new file mode 100644 index 00000000..edba3816 --- /dev/null +++ b/lib/legion/cli/mind_growth_command.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require 'json' +require 'thor' + +module Legion + module CLI + class MindGrowth < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + + desc 'status', 'Show mind-growth cycle status' + def status + require_mind_growth! + result = mind_growth_client.growth_status + out = formatter + if options[:json] + out.json(result) + else + out.header('Mind-Growth Status') + out.spacer + out.detail(result) + end + end + + desc 'propose', 'Propose a new cognitive concept' + option :category, type: :string, desc: 'Cognitive category' + option :description, type: :string, desc: 'Concept description' + option :name, type: :string, desc: 'Concept name' + def propose + require_mind_growth! + result = mind_growth_client.propose_concept( + category: options[:category]&.to_sym, + description: options[:description], + name: options[:name] + ) + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.success("Proposal created: #{result.dig(:proposal, :id)}") + else + out.warn("Proposal failed: #{result[:error]}") + end + end + + desc 'approve ID', 'Approve a proposal' + def approve(proposal_id) + require_mind_growth! + result = mind_growth_client.evaluate_proposal(proposal_id: proposal_id) + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + status_label = result[:approved] ? 'approved' : 'rejected' + out.success("Proposal #{proposal_id[0, 8]} #{status_label}") + else + out.warn("Evaluation failed: #{result[:error]}") + end + end + + desc 'reject ID', 'Reject a proposal' + map 'reject' => :reject_proposal + option :reason, type: :string, desc: 'Rejection reason' + def reject_proposal(proposal_id) + require_mind_growth! + proposal = Legion::Extensions::MindGrowth::Runners::Proposer.get_proposal_object(proposal_id) + out = formatter + if proposal.nil? + out.warn("Proposal #{proposal_id[0, 8]} not found") + return + end + proposal.transition!(:rejected) + if options[:json] + out.json({ success: true, proposal_id: proposal_id, status: 'rejected', + reason: options[:reason] }) + else + out.success("Proposal #{proposal_id[0, 8]} rejected") + end + rescue ArgumentError => e + formatter.warn("Cannot reject: #{e.message}") + end + + desc 'build ID', 'Force-build an approved proposal' + def build(proposal_id) + require_mind_growth! + result = mind_growth_client.build_extension(proposal_id: proposal_id) + out = formatter + if options[:json] + out.json(result) + elsif result[:success] + out.success("Build pipeline started for #{proposal_id[0, 8]}") + out.detail(result[:pipeline]) if result[:pipeline] + else + out.warn("Build failed: #{result[:error]}") + end + end + + desc 'proposals', 'List proposals' + option :status, type: :string, desc: 'Filter by status' + option :limit, type: :numeric, default: 20, desc: 'Max results' + def proposals + require_mind_growth! + result = mind_growth_client.list_proposals( + status: options[:status]&.to_sym, + limit: options[:limit] + ) + out = formatter + if options[:json] + out.json(result) + else + rows = (result[:proposals] || []).map do |p| + [p[:id].to_s[0, 8], p[:name].to_s, p[:category].to_s, + p[:status].to_s, p[:created_at].to_s] + end + if rows.empty? + out.warn('No proposals found') + else + out.table(%w[id name category status created_at], rows) + end + end + end + + desc 'profile', 'Show cognitive architecture profile' + def profile + require_mind_growth! + result = mind_growth_client.cognitive_profile + out = formatter + if options[:json] + out.json(result) + else + out.header('Cognitive Architecture Profile') + out.spacer + out.detail({ total_extensions: result[:total_extensions], + overall_coverage: result[:overall_coverage] }) + out.spacer + coverage = result[:model_coverage] || {} + rows = coverage.map { |model, data| [model.to_s, data[:coverage].to_s, data[:missing].to_s] } + out.table(%w[model coverage missing], rows) unless rows.empty? + end + end + + desc 'health', 'Show extension health and fitness scores' + def health + require_mind_growth! + result = mind_growth_client.validate_fitness(extensions: []) + out = formatter + if options[:json] + out.json(result) + else + out.header('Extension Fitness') + out.spacer + ranked = result[:ranked] || [] + if ranked.empty? + out.warn('No extensions to score') + else + rows = ranked.map { |e| [e[:name].to_s, format('%.3f', e[:fitness].to_f)] } + out.table(%w[extension fitness], rows) + end + end + end + + desc 'report', 'Generate retrospective report' + def report + require_mind_growth! + result = mind_growth_client.session_report + out = formatter + if options[:json] + out.json(result) + else + out.header('Mind-Growth Report') + out.spacer + out.detail(result) + end + end + + desc 'wire ID', 'Wire a built extension into the cognitive tick cycle' + option :phase, type: :string, desc: 'Override phase (auto-detected if omitted)' + def wire(proposal_id) + require_mind_growth! + result = Legion::Extensions::MindGrowth::Runners::Orchestrator.post_build_pipeline( + proposal_id: proposal_id + ) + + if result[:skipped] + say_status :skipped, result[:reason], :yellow + elsif result[:activated] + say_status :activated, "#{proposal_id} wired and activated", :green + elsif result[:error] + say_status :error, result[:error], :red + else + say_status :partial, "Wire: #{result[:wire]}, Test: #{result[:integration_test]}", :yellow + end + rescue StandardError => e + Legion::Logging.error(e.message) if defined?(Legion::Logging) + say_status :error, e.message, :red + end + + desc 'history', 'Show recent proposal history' + option :limit, type: :numeric, default: 50, desc: 'Max results' + def history + require_mind_growth! + result = mind_growth_client.list_proposals(limit: options[:limit]) + out = formatter + if options[:json] + out.json(result) + else + rows = (result[:proposals] || []).map do |p| + [p[:id].to_s[0, 8], p[:name].to_s, p[:category].to_s, + p[:status].to_s, p[:created_at].to_s] + end + if rows.empty? + out.warn('No proposals found') + else + out.table(%w[id name category status created_at], rows) + end + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def require_mind_growth! + return if defined?(Legion::Extensions::MindGrowth::Client) + + raise CLI::Error, 'lex-mind-growth extension is not loaded. Install and enable it first.' + end + + def mind_growth_client + @mind_growth_client ||= Legion::Extensions::MindGrowth::Client.new + end + end + end + end +end diff --git a/lib/legion/cli/mode_command.rb b/lib/legion/cli/mode_command.rb new file mode 100644 index 00000000..85c582ba --- /dev/null +++ b/lib/legion/cli/mode_command.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require 'thor' +require 'fileutils' +require 'legion/cli/output' +require 'legion/cli/connection' + +module Legion + module CLI + class Mode < Thor + SETTINGS_DIR = File.expand_path('~/.legionio/settings') + ROLE_FILE = File.join(SETTINGS_DIR, 'role.json') + + VALID_PROFILES = %i[core cognitive service dev custom].freeze + + PROFILE_DESCRIPTIONS = { + core: '14 core operational extensions only', + cognitive: 'core + all agentic extensions', + service: 'core + service + other integrations', + dev: 'core + AI + essential agentic (~20 extensions)', + custom: 'only extensions listed in role.extensions' + }.freeze + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'show', 'Show current process role and extension profile' + def show + out = formatter + Connection.ensure_settings(resolve_secrets: false) + + process_role = Legion::ProcessRole.current + profile = Legion::Settings.dig(:role, :profile)&.to_s || '(none — all extensions load)' + custom_exts = Array(Legion::Settings.dig(:role, :extensions)) + + if options[:json] + out.json({ process_role: process_role, extension_profile: profile, + custom_extensions: custom_exts }) + return + end + + out.header('Current Mode') + details = { + 'Process Role' => process_role.to_s, + 'Extension Profile' => profile + } + details['Custom Extensions'] = custom_exts.join(', ') if profile.to_s == 'custom' && custom_exts.any? + out.detail(details) + end + + desc 'list', 'List available extension profiles and process roles' + def list + out = formatter + Connection.ensure_settings(resolve_secrets: false) + + if options[:json] + out.json({ profiles: PROFILE_DESCRIPTIONS, process_roles: Legion::ProcessRole::ROLES.keys }) + return + end + + out.header('Extension Profiles') + profile_rows = PROFILE_DESCRIPTIONS.map do |name, desc| + count = count_extensions_for_profile(name) + [name.to_s, desc, count.to_s] + end + out.table(%w[profile description extensions], profile_rows) + + out.spacer + out.header('Process Roles') + role_rows = Legion::ProcessRole::ROLES.map do |name, subsystems| + enabled = subsystems.select { |_, v| v }.keys.join(', ') + [name.to_s, enabled] + end + out.table(%w[role enabled_subsystems], role_rows) + end + + desc 'set PROFILE', 'Set extension profile and/or process role' + long_desc <<~DESC + Set the extension profile (core, cognitive, service, dev, custom) and + optionally the process role (full, api, worker, router, lite). + + Examples: + legionio mode set dev + legionio mode set custom --extensions tick,react,knowledge + legionio mode set --process-role worker + legionio mode set cognitive --process-role worker + DESC + option :process_role, type: :string, desc: 'Process role (full, api, worker, router, lite)' + option :extensions, type: :string, desc: 'Comma-separated extension list (for custom profile)' + option :dry_run, type: :boolean, default: false, desc: 'Preview changes without writing config' + option :reload, type: :boolean, default: false, desc: 'Trigger daemon reload after writing config' + def set(profile = nil) + out = formatter + Connection.ensure_settings(resolve_secrets: false) + + validate_inputs!(out, profile) + + new_config = build_config(profile) + existing = read_existing_config + + if options[:dry_run] + show_dry_run(out, existing, new_config) + return + end + + write_config(new_config) + out.success("Mode updated: #{ROLE_FILE}") + show_written_config(out, new_config) + + trigger_reload(out) if options[:reload] + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + + def validate_inputs!(out, profile) + if profile + sym = profile.to_sym + unless VALID_PROFILES.include?(sym) + out.error("Unknown profile: '#{profile}'. Valid profiles: #{VALID_PROFILES.join(', ')}") + raise SystemExit, 1 + end + + if sym == :custom && !options[:extensions] + out.error('Custom profile requires --extensions (comma-separated list)') + raise SystemExit, 1 + end + end + + return unless options[:process_role] + + role_sym = options[:process_role].to_sym + return if Legion::ProcessRole::ROLES.key?(role_sym) + + out.error("Unknown process role: '#{options[:process_role]}'. Valid roles: #{Legion::ProcessRole::ROLES.keys.join(', ')}") + raise SystemExit, 1 + end + + def build_config(profile) + config = read_existing_config + + if profile + config[:role] ||= {} + config[:role][:profile] = profile.to_s + if profile.to_sym == :custom && options[:extensions] + config[:role][:extensions] = options[:extensions].split(',').map(&:strip) + elsif profile.to_sym != :custom + config[:role].delete(:extensions) + end + end + + if options[:process_role] + config[:process] ||= {} + config[:process][:role] = options[:process_role] + end + + config + end + + def read_existing_config + return {} unless File.exist?(ROLE_FILE) + + Legion::JSON.load(File.read(ROLE_FILE)) + rescue StandardError + {} + end + + def write_config(config) + FileUtils.mkdir_p(SETTINGS_DIR) + File.write(ROLE_FILE, ::JSON.pretty_generate(config)) + end + + def show_dry_run(out, existing, new_config) + out.header('Dry Run — changes that would be written') + out.detail({ + 'File' => ROLE_FILE, + 'Before' => existing.empty? ? '(no file)' : existing.to_s, + 'After' => new_config.to_s + }) + + profile = new_config.dig(:role, :profile) || new_config.dig('role', 'profile') + return unless profile + + count = count_extensions_for_profile(profile.to_sym) + out.spacer + puts " Extensions that would load: #{count}" + end + + def show_written_config(out, config) + profile = config.dig(:role, :profile) + role = config.dig(:process, :role) + parts = [] + parts << "profile=#{profile}" if profile + parts << "process_role=#{role}" if role + exts = config.dig(:role, :extensions) + parts << "extensions=#{exts.join(',')}" if exts.is_a?(Array) && exts.any? + out.dim(" #{parts.join(' ')}")&.then { |msg| puts msg } + end + + def trigger_reload(out) + require 'net/http' + uri = URI('http://127.0.0.1:4567/api/reload') + response = Net::HTTP.post(uri, '', 'Content-Type' => 'application/json') + if response.is_a?(Net::HTTPSuccess) + out.success('Daemon reload triggered') + else + out.warn("Daemon reload returned #{response.code}: #{response.body}") + end + rescue StandardError => e + out.warn("Could not reach daemon for reload: #{e.message}") + out.dim(' Changes will take effect on next `legionio start`')&.then { |msg| puts msg } + end + + def count_extensions_for_profile(profile) + Legion::Extensions.find_extensions if Legion::Extensions.instance_variable_get(:@extensions).nil? + + all_extensions = Legion::Extensions.instance_variable_get(:@extensions) || [] + all_names = all_extensions.map { |e| e[:gem_name] } + + allowed = Legion::Extensions.allowed_gem_names_for_profile(profile, { extensions: [] }) + return all_names.count unless allowed + + (all_names & allowed).count + rescue StandardError + '?' + end + end + end + end +end diff --git a/lib/legion/cli/notebook_command.rb b/lib/legion/cli/notebook_command.rb new file mode 100644 index 00000000..80dfd55d --- /dev/null +++ b/lib/legion/cli/notebook_command.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require 'thor' +require 'json' +require 'legion/cli/output' +require 'legion/cli/error' +require 'legion/cli/connection' + +module Legion + module CLI + class Notebook < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'read PATH', 'Parse and display a Jupyter notebook with syntax highlighting' + def read(path) + out = formatter + load_notebook(path, out) + color = !options[:no_color] + + require 'legion/notebook/parser' + require 'legion/notebook/renderer' + + parsed = Legion::Notebook::Parser.parse(path) + rendered = Legion::Notebook::Renderer.render_notebook(parsed, color: color) + + if options[:json] + out.json(cells: parsed[:cells].length, kernel: parsed[:kernel], path: path) + else + puts rendered + out.spacer + count = parsed[:cells].length + puts "#{count} cell#{'s' unless count == 1} total" + end + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + end + + desc 'cells PATH', 'List all cells with index numbers and types' + def cells(path) + out = formatter + load_notebook(path, out) + + require 'legion/notebook/parser' + + parsed = Legion::Notebook::Parser.parse(path) + color = !options[:no_color] + + if options[:json] + cell_list = parsed[:cells].each_with_index.map do |cell, i| + { index: i + 1, type: cell[:type], lines: cell[:source].lines.count } + end + out.json(cells: cell_list, total: parsed[:cells].length) + else + parsed[:cells].each_with_index do |cell, i| + lines = cell[:source].lines.count + plural = lines == 1 ? '' : 's' + label = " [#{(i + 1).to_s.rjust(2)}] #{cell[:type].to_s.ljust(8)} #{lines} line#{plural}" + if color + type_color = cell[:type] == 'code' ? "\e[36m" : "\e[33m" + puts "#{type_color}#{label}\e[0m" + else + puts label + end + end + out.spacer + puts "Total: #{parsed[:cells].length} cell#{'s' unless parsed[:cells].length == 1}" + end + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + end + + desc 'export PATH', 'Export notebook to another format' + option :format, type: :string, default: 'md', enum: %w[md markdown script], desc: 'Export format: md or script' + option :output, type: :string, aliases: ['-o'], desc: 'Write to file instead of stdout' + def export(path) + out = formatter + load_notebook(path, out) + + require 'legion/notebook/parser' + + parsed = Legion::Notebook::Parser.parse(path) + lang = parsed[:language] + + content = case options[:format] + when 'script' + export_as_script(parsed[:cells], lang) + else + export_as_markdown(parsed[:cells], lang) + end + + if options[:output] + File.write(options[:output], content) + out.success("Exported to #{options[:output]}") + elsif options[:json] + out.json(content: content, format: options[:format], path: path) + else + puts content + end + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + end + + desc 'create PATH', 'Generate a Jupyter notebook from a natural language description (requires legion-llm)' + option :description, type: :string, aliases: ['-d'], desc: 'What the notebook should do' + option :kernel, type: :string, default: 'python3', desc: 'Kernel name (default: python3)' + option :model, type: :string, aliases: ['-m'], desc: 'LLM model override' + option :provider, type: :string, desc: 'LLM provider override' + def create(path) + out = formatter + setup_llm_connection(out) + + require 'legion/notebook/generator' + + description = options[:description] + if description.nil? || description.strip.empty? + out.error('--description is required for notebook creation') + raise SystemExit, 1 + end + + out.success("Generating notebook: #{description}") unless options[:json] + + notebook_data = Legion::Notebook::Generator.generate( + description: description, + kernel: options[:kernel], + model: options[:model], + provider: options[:provider] + ) + + Legion::Notebook::Generator.write(path, notebook_data) + cell_count = Array(notebook_data['cells']).length + + if options[:json] + out.json(path: path, cells: cell_count, kernel: options[:kernel]) + else + out.success("Created #{path} (#{cell_count} cells)") + end + rescue ArgumentError, CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def setup_llm_connection(out) + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_llm + rescue CLI::Error => e + out.error(e.message) + raise SystemExit, 1 + end + + def load_notebook(path, out) + unless File.exist?(path) + out.error("File not found: #{path}") + raise SystemExit, 1 + end + + unless path.end_with?('.ipynb') + out.error("Expected a .ipynb file, got: #{File.basename(path)}") + raise SystemExit, 1 + end + + ::JSON.parse(File.read(path)) + rescue ::JSON::ParserError => e + out.error("Invalid notebook JSON: #{e.message}") + raise SystemExit, 1 + end + + def export_as_markdown(cells, lang) + lines = [] + cells.each do |cell| + if cell[:type] == 'code' + lines << "```#{lang}" + lines << cell[:source] + lines << '```' + else + lines << cell[:source] + end + lines << '' + end + lines.join("\n") + end + + def export_as_script(cells, _lang) + lines = [] + cells.each do |cell| + if cell[:type] == 'code' + lines << cell[:source] + else + cell[:source].each_line do |line| + lines << "# #{line.chomp}" + end + end + lines << '' + end + lines.join("\n") + end + end + end + end +end diff --git a/lib/legion/cli/observe_command.rb b/lib/legion/cli/observe_command.rb new file mode 100644 index 00000000..1c35d281 --- /dev/null +++ b/lib/legion/cli/observe_command.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/mcp/observer' + +module Legion + module CLI + class ObserveCommand < Thor + namespace :observe + + desc 'stats', 'Show MCP tool usage statistics' + def stats + data = Legion::MCP::Observer.stats + + if options['json'] + puts ::JSON.pretty_generate(serialize_stats(data)) + return + end + + puts 'MCP Tool Observation Stats' + puts '=' * 40 + puts "Total Calls: #{data[:total_calls]}" + puts "Tools Used: #{data[:tool_count]}" + puts "Failure Rate: #{(data[:failure_rate] * 100).round(1)}%" + puts "Since: #{data[:since]&.strftime('%Y-%m-%d %H:%M:%S')}" + puts + + return if data[:top_tools].empty? + + puts 'Top Tools:' + puts '-' * 60 + puts 'Tool Calls Avg(ms) Fails' + puts '-' * 60 + data[:top_tools].each do |tool| + puts format('%-30s %6d %8d %6d', + name: tool[:name], calls: tool[:call_count], + avg: tool[:avg_latency_ms], fails: tool[:failure_count]) + end + end + + desc 'recent', 'Show recent MCP tool calls' + method_option :limit, type: :numeric, default: 20, aliases: '-n' + def recent + calls = Legion::MCP::Observer.recent(options['limit'] || 20) + + if options['json'] + puts ::JSON.pretty_generate(calls.map { |c| serialize_call(c) }) + return + end + + if calls.empty? + puts 'No recent tool calls recorded.' + return + end + + puts 'Tool Duration Status Time' + puts '-' * 70 + calls.reverse_each do |call| + status = call[:success] ? 'OK' : 'FAIL' + time = call[:timestamp]&.strftime('%H:%M:%S') + puts format('%-30s %6dms %7s %s', + tool: call[:tool_name], dur: call[:duration_ms], st: status, tm: time) + end + end + + desc 'reset', 'Clear all observation data' + def reset + print 'Clear all observation data? (yes/no): ' + return unless $stdin.gets&.strip&.downcase == 'yes' + + Legion::MCP::Observer.reset! + puts 'Observation data cleared.' + end + + desc 'embeddings', 'Show MCP tool embedding index status' + def embeddings + require 'legion/mcp/embedding_index' + data = { + index_size: Legion::MCP::EmbeddingIndex.size, + coverage: Legion::MCP::EmbeddingIndex.coverage, + populated: Legion::MCP::EmbeddingIndex.populated? + } + + if options['json'] + puts ::JSON.pretty_generate(data.transform_keys(&:to_s)) + return + end + + puts 'MCP Embedding Index' + puts '=' * 40 + puts "Index Size: #{data[:index_size]}" + puts "Coverage: #{(data[:coverage] * 100).round(1)}%" + puts "Populated: #{data[:populated]}" + end + + private + + def serialize_stats(data) + { + total_calls: data[:total_calls], + tool_count: data[:tool_count], + failure_rate: data[:failure_rate], + since: data[:since]&.iso8601, + top_tools: data[:top_tools].map { |t| t.transform_keys(&:to_s) } + } + end + + def serialize_call(call) + call.transform_keys(&:to_s).tap do |c| + c['timestamp'] = c['timestamp']&.iso8601 if c['timestamp'] + end + end + end + end +end diff --git a/lib/legion/cli/openapi_command.rb b/lib/legion/cli/openapi_command.rb new file mode 100644 index 00000000..9f558e79 --- /dev/null +++ b/lib/legion/cli/openapi_command.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Openapi < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'generate', 'Generate OpenAPI spec JSON' + method_option :output, aliases: '-o', type: :string, desc: 'Output file path' + def generate + require 'sinatra/base' + require 'legion/version' + require 'legion/settings' + require 'legion/api/openapi' + + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + loader = Legion::Settings.loader + loader.settings[:client] ||= { name: 'legion' } + + spec = Legion::API::OpenAPI.to_json + + if options[:output] + File.write(options[:output], spec) + say "OpenAPI spec written to #{options[:output]}" + else + puts spec + end + end + + desc 'routes', 'List all API routes' + def routes + require 'sinatra/base' + require 'legion/version' + require 'legion/settings' + require 'legion/api/openapi' + + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + loader = Legion::Settings.loader + loader.settings[:client] ||= { name: 'legion' } + + Legion::API::OpenAPI.spec[:paths].each do |path, methods| + methods.each do |method, details| + summary = details.is_a?(Hash) ? (details[:summary] || '') : '' + puts "#{method.to_s.upcase.ljust(7)} #{path} # #{summary}" + end + end + end + end + end +end diff --git a/lib/legion/cli/output.rb b/lib/legion/cli/output.rb new file mode 100644 index 00000000..cf361fa4 --- /dev/null +++ b/lib/legion/cli/output.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require 'json' +require 'legion/cli/theme' + +module Legion + module CLI + module Output + # Use Legion::JSON if available, fall back to stdlib + def self.encode_json(data) + if defined?(Legion::JSON) && Legion::JSON.respond_to?(:dump) + Legion::JSON.dump(data) + else + JSON.pretty_generate(data) + end + end + + # Purple-only palette mapped to semantic names. + # Legacy ANSI names (red, green, etc.) remap to purple intensity shades + # so all existing code works but renders on-brand. + COLORS = { + reset: "\e[0m", + bold: "\e[1m", + dim: "\e[2m", + + # Legacy names → purple intensity equivalents + red: Theme.c(:self_point), # errors: brightest + green: Theme.c(:cardinal), # success: calm/nominal + yellow: Theme.c(:innermost), # warnings: medium-bright + blue: Theme.c(:mid_nodes), + magenta: Theme.c(:inner_nodes), + cyan: Theme.c(:mid_nodes), + white: Theme.c(:near_white), + gray: Theme.c(:mid_arcs), + + # Semantic theme names + title: Theme.c(:self_point), + heading: Theme.c(:near_white), + body: Theme.c(:inner_nodes), + label: Theme.c(:cardinal), + accent: Theme.c(:mid_nodes), + muted: Theme.c(:diagonal_nodes), + disabled: Theme.c(:skip), + border: Theme.c(:inner_tier), + node: Theme.c(:cardinal), + + # Status intensity (no traffic lights) + nominal: Theme.c(:cardinal), + caution: Theme.c(:innermost), + critical: Theme.c(:self_point) + }.freeze + + # Status → intensity mapping. Brightness communicates urgency. + STATUS_ICONS = { + ok: 'nominal', + ready: 'nominal', + running: 'nominal', + enabled: 'nominal', + loaded: 'nominal', + completed: 'nominal', + warning: 'caution', + pending: 'caution', + disabled: 'muted', + error: 'critical', + failed: 'critical', + dead: 'critical', + unknown: 'disabled' + }.freeze + + class Formatter + attr_reader :json_mode, :color_enabled + + def initialize(json: false, color: true) + @json_mode = json + @color_enabled = color && $stdout.tty? && !json + end + + def colorize(text, color) + return text.to_s unless @color_enabled + + "#{COLORS[color]}#{text}#{COLORS[:reset]}" + end + + def bold(text) + return text.to_s unless @color_enabled + + "#{COLORS[:bold]}#{COLORS[:heading]}#{text}#{COLORS[:reset]}" + end + + def dim(text) + return text.to_s unless @color_enabled + + "#{COLORS[:gray]}#{text}#{COLORS[:reset]}" + end + + def status_color(status) + key = status.to_s.downcase.tr('.', '_').to_sym + color_name = STATUS_ICONS[key] || 'disabled' + color_name.to_sym + end + + def status(text) + colorize(text, status_color(text)) + end + + def banner(version: nil) + puts Theme.render_banner(version: version, color: @color_enabled) + end + + def header(text) + return if @json_mode + + if @color_enabled + puts "#{COLORS[:bold]}#{COLORS[:heading]}#{text}#{COLORS[:reset]}" + else + puts text + end + end + + def detail(hash, indent: 0) + if @json_mode + puts Output.encode_json(hash) + return + end + + pad = ' ' * indent + max_key = hash.keys.map { |k| k.to_s.length }.max || 0 + + hash.each do |key, value| + label = colorize("#{key.to_s.ljust(max_key)}:", :label) + val = case value + when true then colorize('yes', :accent) + when false then colorize('no', :muted) + when nil then colorize('(none)', :disabled) + else value.to_s + end + puts "#{pad} #{label} #{val}" + end + end + + def table(headers, rows, title: nil) + if @json_mode + json_rows = rows.map { |row| headers.zip(row).to_h } + puts Output.encode_json(title ? { title: title, data: json_rows } : json_rows) + return + end + + return puts dim(' (no results)') if rows.empty? + + all_rows = [headers] + rows + widths = headers.each_index.map do |i| + all_rows.map { |r| strip_ansi(r[i].to_s).length }.max + end + + puts if title + header_line = headers.each_with_index.map { |h, i| colorize(h.to_s.upcase.ljust(widths[i]), :heading) }.join(' ') + puts " #{header_line}" + puts " #{widths.map { |w| colorize('─' * w, :border) }.join(' ')}" + + rows.each do |row| + line = row.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(' ') + puts " #{line}" + end + end + + def success(message) + if @json_mode + puts Output.encode_json(success: true, message: message) + else + puts " #{colorize('»', :accent)} #{message}" + end + end + + def warn(message) + if @json_mode + puts Output.encode_json(warning: true, message: message) + else + puts " #{colorize('»', :caution)} #{message}" + end + end + + def info(message) + if @json_mode + puts Output.encode_json(info: true, message: message) + else + puts " #{colorize('»', :accent)} #{message}" + end + end + + def error(message) + if @json_mode + puts Output.encode_json(error: true, message: message) + else + warn " #{colorize('»', :critical)} #{colorize(message, :critical)}" + end + end + + def json(data) + puts Output.encode_json(data) + end + + def spacer + puts unless @json_mode + end + + private + + def strip_ansi(str) + str.gsub(/\e\[[0-9;]*m/, '') + end + end + end + end +end diff --git a/lib/legion/cli/payroll_command.rb b/lib/legion/cli/payroll_command.rb new file mode 100644 index 00000000..4b9365d5 --- /dev/null +++ b/lib/legion/cli/payroll_command.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Payroll < Thor + def self.exit_on_failure? = true + + desc 'summary', 'Show workforce payroll summary' + option :period, type: :string, default: 'daily', desc: 'Period: daily, weekly, monthly' + option :json, type: :boolean, default: false, desc: 'Output as JSON' + def summary + require 'legion/extensions/metering/helpers/economics' + economics = Object.new.extend(Legion::Extensions::Metering::Helpers::Economics) + result = economics.payroll_summary(period: options[:period].to_sym) + + if options[:json] + say ::JSON.dump(result) + else + say 'Payroll Summary', :green + say '-' * 40 + say " Period: #{result[:period]}" + say format(' Total Cost: $%.4f', result[:total_cost]) + say format(' Avg Productivity: %.1f tasks', result[:avg_productivity]) + if result[:workers].any? + say '' + say ' Worker Tasks Cost Autonomy' + say " #{'-' * 52}" + result[:workers].each do |w| + cost_str = format('$%.4f', w[:cost]) + say format(' %-20s %8d %10s %10s', + worker: w[:worker_id], tasks: w[:task_count], + cost: cost_str, autonomy: w[:autonomy]) + end + else + say ' No worker data found for this period.', :yellow + end + end + rescue LoadError => e + Legion::Logging.warn("PayrollCommand#summary lex-metering not available: #{e.message}") if defined?(Legion::Logging) + say "Error: lex-metering not available (#{e.message})", :red + end + default_task :summary + + desc 'report WORKER_ID', 'Detailed worker cost report' + option :period, type: :string, default: 'daily' + option :json, type: :boolean, default: false + def report(worker_id) + require 'legion/extensions/metering/helpers/economics' + economics = Object.new.extend(Legion::Extensions::Metering::Helpers::Economics) + result = economics.worker_report(worker_id: worker_id, period: options[:period].to_sym) + + if options[:json] + say ::JSON.dump(result) + else + say "Worker Report: #{worker_id}", :green + say '-' * 40 + result.each { |k, v| say " #{k}: #{v}" } + end + rescue LoadError => e + Legion::Logging.warn("PayrollCommand#report lex-metering not available: #{e.message}") if defined?(Legion::Logging) + say "Error: lex-metering not available (#{e.message})", :red + end + + desc 'forecast', 'Project costs for upcoming period' + option :days, type: :numeric, default: 30, desc: 'Number of days to project' + option :json, type: :boolean, default: false + def forecast + require 'legion/extensions/metering/helpers/economics' + economics = Object.new.extend(Legion::Extensions::Metering::Helpers::Economics) + result = economics.budget_forecast(days: options[:days]) + + if options[:json] + say ::JSON.dump(result) + else + say 'Cost Forecast', :green + say '-' * 40 + say format(" Projected Cost (#{result[:days]}d): $%.4f", result[:projected_cost]) + say format(' Daily Average: $%.4f', result[:daily_average]) + say " Trend: #{result[:trend]}" + end + rescue LoadError => e + Legion::Logging.warn("PayrollCommand#forecast lex-metering not available: #{e.message}") if defined?(Legion::Logging) + say "Error: lex-metering not available (#{e.message})", :red + end + + desc 'budget', 'Show or set daily budget threshold' + option :set, type: :numeric, desc: 'Set daily budget threshold' + def budget + if options[:set] + say "Daily budget set to $#{options[:set]}", :green + say 'Budget enforcement requires alert rules (see legion alerts)', :yellow + else + say 'Budget', :green + say '-' * 40 + say ' No budget threshold configured. Use --set to configure.', :yellow + end + end + end + end +end diff --git a/lib/legion/cli/plan_command.rb b/lib/legion/cli/plan_command.rb new file mode 100644 index 00000000..bba6a809 --- /dev/null +++ b/lib/legion/cli/plan_command.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' + +module Legion + module CLI + class Plan < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + class_option :model, type: :string, aliases: ['-m'], desc: 'Model ID' + class_option :provider, type: :string, desc: 'LLM provider' + class_option :no_markdown, type: :boolean, default: false, desc: 'Disable markdown rendering' + + desc 'interactive', 'Start plan mode (read-only exploration)' + def interactive + out = formatter + setup_connection + + chat_obj = create_plan_chat + system_prompt = build_plan_prompt + + require 'legion/cli/chat/session' + @session = Chat::Session.new(chat: chat_obj, system_prompt: system_prompt) + + out.header("Legion Plan Mode (#{@session.model_id})") + puts out.dim(' Read-only exploration. No file writes or shell commands.') + puts out.dim(' Type /save to save plan, /quit to exit') + puts + + plan_repl(out) + rescue Interrupt + puts + puts out.dim('Interrupted.') + ensure + Connection.shutdown + end + default_task :interactive + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def setup_connection + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_llm + end + + def create_plan_chat + opts = {} + opts[:model] = options[:model] if options[:model] + opts[:provider] = options[:provider]&.to_sym if options[:provider] + + require 'legion/cli/chat/tools/read_file' + require 'legion/cli/chat/tools/search_files' + require 'legion/cli/chat/tools/search_content' + + chat = Legion::LLM.chat(**opts) + chat.with_tools( + Chat::Tools::ReadFile, + Chat::Tools::SearchFiles, + Chat::Tools::SearchContent + ) + chat + end + + def build_plan_prompt + require 'legion/cli/chat/context' + base = Chat::Context.to_system_prompt(Dir.pwd) + <<~PROMPT + #{base} + + You are in PLAN MODE. You can ONLY read files and search the codebase. + You CANNOT write files, edit files, or run shell commands. + + Your job is to: + 1. Explore the codebase to understand the current state + 2. Ask clarifying questions about what the user wants to build + 3. Produce a structured implementation plan as a markdown document + + When the user is satisfied with the plan, they will use /save to save it. + Output the final plan in markdown format with clear task breakdowns. + PROMPT + end + + def render_response(text, out) + return text if options[:no_markdown] || options[:no_color] + + require 'legion/cli/chat/markdown_renderer' + Chat::MarkdownRenderer.render(text, color: out.color_enabled) + rescue LoadError => e + Legion::Logging.debug("PlanCommand#render_response markdown_renderer not available: #{e.message}") if defined?(Legion::Logging) + text + end + + def plan_repl(out) + require 'reline' + @plan_buffer = String.new + + loop do + line = Reline.readline("\001\e[38;2;100;200;100m\002plan\001\e[0m\002 > ", true) + break if line.nil? + + stripped = line.strip + next if stripped.empty? + + case stripped.downcase + when '/quit', '/exit', '/q' + break + when '/save' + save_plan(out) + next + when '/help' + show_plan_help(out) + next + end + + print out.colorize('legion', :title) + print out.dim(' > ') + + buffer = String.new + @session.send_message(stripped) { |chunk| buffer << chunk.content if chunk.content } + @plan_buffer << "\n\n#{buffer}" unless buffer.empty? + print render_response(buffer, out) + puts + puts + rescue Interrupt + puts + next + rescue StandardError => e + puts + out.error("Error: #{e.message}") + puts + end + + puts + puts out.dim('Goodbye.') + end + + def save_plan(out) + if @plan_buffer.strip.empty? + out.warn('No plan content to save. Have a conversation first.') + return + end + + require 'fileutils' + dir = File.join(Dir.pwd, 'docs', 'plans') + FileUtils.mkdir_p(dir) + filename = "#{Time.now.strftime('%Y-%m-%d')}-plan.md" + path = File.join(dir, filename) + + # Avoid overwriting + counter = 1 + while File.exist?(path) + filename = "#{Time.now.strftime('%Y-%m-%d')}-plan-#{counter}.md" + path = File.join(dir, filename) + counter += 1 + end + + File.write(path, @plan_buffer.strip, encoding: 'utf-8') + out.success("Plan saved to #{path}") + end + + def show_plan_help(out) + out.header('Plan Mode Commands') + out.detail({ + '/save' => 'Save the plan to docs/plans/', + '/help' => 'Show this help', + '/quit' => 'Exit plan mode' + }) + puts + puts out.dim(' Read-only: file reads and searches only. No writes or commands.') + end + end + end + end +end diff --git a/lib/legion/cli/pr_command.rb b/lib/legion/cli/pr_command.rb new file mode 100644 index 00000000..9c8e3670 --- /dev/null +++ b/lib/legion/cli/pr_command.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/cli/error' +require 'open3' + +module Legion + module CLI + class Pr < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + class_option :model, type: :string, aliases: ['-m'], desc: 'Model ID' + class_option :provider, type: :string, desc: 'LLM provider' + + desc 'create', 'Create a pull request with AI-generated title and description' + option :base, type: :string, default: 'main', aliases: ['-b'], desc: 'Base branch' + option :draft, type: :boolean, default: false, desc: 'Create as draft PR' + option :yes, type: :boolean, default: false, aliases: ['-y'], desc: 'Auto-approve (skip confirmation)' + option :push, type: :boolean, default: true, desc: 'Push branch before creating PR' + option :token, type: :string, desc: 'GitHub token (default: GITHUB_TOKEN env var)' + def create + out = formatter + validate_branch!(out) + + diff, stat, log = gather_changes(options[:base]) + validate_diff!(diff, out) + setup_connection + + out.header('Generating PR title and description...') + title, body = generate_pr_content(diff, stat, log, current_branch) + + return out.json(pr_json(title, body)) if options[:json] + + display_pr_preview(out, title, body) + title, body = confirm_or_edit(out, title, body) unless options[:yes] + return unless title + + push_branch(current_branch) if options[:push] + pr_url = submit_pull_request(title, body) + out.success("PR created: #{pr_url}") + rescue CLI::Error => e + out.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + default_task :create + + no_commands do # rubocop:disable Metrics/BlockLength + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def setup_connection + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_llm + end + + def validate_branch!(out) + return unless current_branch == options[:base] + + out.error("Already on #{options[:base]}. Switch to a feature branch first.") + raise SystemExit, 1 + end + + def validate_diff!(diff, out) + return unless diff.strip.empty? + + out.error("No changes between #{current_branch} and #{options[:base]}.") + raise SystemExit, 1 + end + + def gather_changes(base) + [branch_diff(base), branch_stat(base), branch_log(base)] + end + + def display_pr_preview(out, title, body) + puts + puts out.colorize(title, :green) + puts + puts body + puts + end + + def confirm_or_edit(out, title, body) + $stderr.print "#{out.colorize('Create PR with this content?', :yellow)} [Y/n/e(dit)] " + response = $stdin.gets&.strip&.downcase + case response + when 'n', 'no' + out.warn('PR creation aborted.') + return [nil, nil] + when 'e', 'edit' + title, body = edit_pr_content(title, body) + if title.strip.empty? + out.warn('PR creation aborted (empty title).') + return [nil, nil] + end + end + [title, body] + end + + def pr_json(title, body) + { title: title, body: body, branch: current_branch, base: options[:base] } + end + + def current_branch + stdout, _stderr, _status = Open3.capture3('git', 'rev-parse', '--abbrev-ref', 'HEAD') + stdout.strip + end + + def branch_diff(base) + stdout, _stderr, _status = Open3.capture3('git', 'diff', "#{base}...HEAD") + stdout + end + + def branch_stat(base) + stdout, _stderr, _status = Open3.capture3('git', 'diff', "#{base}...HEAD", '--stat') + stdout.strip + end + + def branch_log(base) + stdout, _stderr, _status = Open3.capture3('git', 'log', "#{base}..HEAD", '--oneline', '--no-decorate') + stdout.strip + end + + def push_branch(branch) + _stdout, stderr, status = Open3.capture3('git', 'push', '-u', 'origin', branch) + return if status.success? + + raise CLI::Error, "git push failed: #{stderr.strip}" + end + + def detect_remote + stdout, _stderr, _status = Open3.capture3('git', 'remote', 'get-url', 'origin') + url = stdout.strip + match = url.match(%r{[:/]([^/]+)/([^/.]+?)(?:\.git)?$}) + raise CLI::Error, "Cannot parse GitHub owner/repo from remote: #{url}" unless match + + [match[1], match[2]] + end + + def resolve_token + token = options[:token] || ENV.fetch('GITHUB_TOKEN', nil) || ENV.fetch('GH_TOKEN', nil) + raise CLI::Error, 'No GitHub token found. Set GITHUB_TOKEN env var or pass --token.' unless token + + token + end + + def generate_pr_content(diff, stat, log, branch) + opts = {} + opts[:model] = options[:model] if options[:model] + opts[:provider] = options[:provider]&.to_sym if options[:provider] + + chat = Legion::LLM.chat(**opts, caller: { source: 'cli', command: 'pr' }) + prompt = build_prompt(diff, stat, log, branch) + response = chat.ask(prompt) + parse_pr_response(response.content) + end + + def build_prompt(diff, stat, log, branch) + <<~PROMPT + Generate a pull request title and description for the following changes. + + Rules: + - Title: concise, under 70 characters, describes the change + - Description: use markdown with ## Summary section (2-4 bullet points) and ## Changes section + - Be specific about what changed and why + - Output format: first line is the title, then a blank line, then the description body + - Output ONLY the title and description, nothing else + + Branch: #{branch} + Commits: + #{log} + + Diffstat: + #{stat} + + Full diff (truncated): + #{diff[0, 8000]} + PROMPT + end + + def parse_pr_response(content) + lines = content.strip.lines + title = lines.first&.strip || 'Update' + body = lines.length > 2 ? lines[2..].join.strip : '' + [title, body] + end + + def edit_pr_content(title, body) + require 'tempfile' + file = Tempfile.new(['legion-pr', '.md']) + file.write("#{title}\n\n#{body}") + file.close + + editor = ENV.fetch('EDITOR', ENV.fetch('VISUAL', 'vi')) + system(editor, file.path) + + content = File.read(file.path) + file.unlink + parse_pr_response(content) + end + + def submit_pull_request(title, body) + owner, repo = detect_remote + token = resolve_token + + require 'legion/extensions/github/client' + client = Legion::Extensions::Github::Client.new(token: token) + result = client.create_pull_request( + owner: owner, repo: repo, title: title, + head: current_branch, base: options[:base], + body: body, draft: options[:draft] + ) + + pr_data = result[:result] + pr_data['html_url'] || pr_data['url'] || "#{owner}/#{repo}##{pr_data['number']}" + end + end + end + end +end diff --git a/lib/legion/cli/prompt_command.rb b/lib/legion/cli/prompt_command.rb new file mode 100644 index 00000000..e73c53e2 --- /dev/null +++ b/lib/legion/cli/prompt_command.rb @@ -0,0 +1,336 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Prompt < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'list', 'List all prompts' + def list + out = formatter + with_prompt_client do |client| + prompts = client.list_prompts + if options[:json] + out.json(prompts) + elsif prompts.empty? + out.warn('No prompts found') + else + rows = prompts.map do |p| + [p[:name].to_s, (p[:description] || '').to_s, + (p[:latest_version] || '-').to_s, (p[:updated_at] || '-').to_s] + end + out.table(%w[name description version updated_at], rows) + end + end + end + default_task :list + + desc 'show NAME', 'Show a prompt template and parameters' + option :version, type: :numeric, desc: 'Specific version number' + option :tag, type: :string, desc: 'Tag name to resolve' + def show(name) + out = formatter + with_prompt_client do |client| + kwargs = { name: name } + kwargs[:version] = options[:version] if options[:version] + kwargs[:tag] = options[:tag] if options[:tag] + result = client.get_prompt(**kwargs) + if result[:error] + out.error("Prompt '#{name}': #{result[:error]}") + raise SystemExit, 1 + end + + if options[:json] + out.json(result) + else + out.header("Prompt: #{result[:name]}") + out.spacer + out.detail({ version: result[:version], content_hash: result[:content_hash], + created_at: result[:created_at] }) + unless result[:model_params].nil? || result[:model_params].empty? + out.spacer + out.header('Model Params') + out.detail(result[:model_params]) + end + out.spacer + puts result[:template] + end + end + end + + desc 'create NAME', 'Create a new prompt' + option :template, type: :string, required: true, desc: 'Prompt template text' + option :description, type: :string, desc: 'Short description' + option :model_params, type: :string, desc: 'Model parameters as JSON' + def create(name) + out = formatter + with_prompt_client do |client| + params = parse_model_params(options[:model_params], out) + return if params.nil? + + result = client.create_prompt( + name: name, + template: options[:template], + description: options[:description], + model_params: params + ) + if options[:json] + out.json(result) + else + out.success("Created prompt '#{result[:name]}' (version #{result[:version]})") + end + end + end + + desc 'tag NAME TAG', 'Tag a prompt version' + option :version, type: :numeric, desc: 'Version to tag (defaults to latest)' + def tag(name, tag_name) + out = formatter + with_prompt_client do |client| + kwargs = { name: name, tag: tag_name } + kwargs[:version] = options[:version] if options[:version] + result = client.tag_prompt(**kwargs) + if result[:error] + out.error("Prompt '#{name}': #{result[:error]}") + raise SystemExit, 1 + end + + if options[:json] + out.json(result) + else + out.success("Tagged '#{result[:name]}' v#{result[:version]} as '#{result[:tag]}'") + end + end + end + + desc 'diff NAME V1 V2', 'Show text diff between two versions of a prompt' + def diff(name, ver1, ver2) + out = formatter + with_prompt_client do |client| + r1 = client.get_prompt(name: name, version: ver1.to_i) + r2 = client.get_prompt(name: name, version: ver2.to_i) + + if r1[:error] + out.error("Version #{ver1}: #{r1[:error]}") + raise SystemExit, 1 + end + if r2[:error] + out.error("Version #{ver2}: #{r2[:error]}") + raise SystemExit, 1 + end + + if options[:json] + out.json({ name: name, v1: ver1.to_i, v2: ver2.to_i, + template_v1: r1[:template], template_v2: r2[:template] }) + else + require 'diff/lcs' if defined?(Diff::LCS) + puts "--- v#{ver1}" + puts "+++ v#{ver2}" + puts diff_lines(r1[:template].to_s, r2[:template].to_s) + end + end + end + + desc 'play NAME', 'Run a prompt through an LLM and display the response' + option :variables, type: :string, desc: 'Template variables as JSON' + option :version, type: :numeric, desc: 'Prompt version' + option :model, type: :string, desc: 'LLM model override' + option :provider, type: :string, desc: 'LLM provider override' + option :compare, type: :numeric, desc: 'Compare with this version' + def play(name) + out = formatter + with_prompt_client do |client| + unless defined?(Legion::LLM) && Legion::LLM.started? + out.error('legion-llm is not available. Install legion-llm and configure a provider.') + raise SystemExit, 1 + end + + vars = parse_variables(options[:variables], out) + return if vars.nil? + + llm_kwargs = {} + llm_kwargs[:model] = options[:model] if options[:model] + llm_kwargs[:provider] = options[:provider] if options[:provider] + + base_ctx = { name: name, vars: vars, llm_kwargs: llm_kwargs, client: client, out: out } + if options[:compare] + run_compare(base_ctx.merge(ver_a: options[:version], ver_b: options[:compare])) + else + run_single(base_ctx.merge(version: options[:version])) + end + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def with_prompt_client + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_data + Connection.ensure_llm + + begin + require 'legion/extensions/prompt' + require 'legion/extensions/prompt/runners/prompt' + require 'legion/extensions/prompt/client' + rescue LoadError + formatter.error('lex-prompt gem is not installed (gem install lex-prompt)') + raise SystemExit, 1 + end + + db = Legion::Data.db + client = Legion::Extensions::Prompt::Client.new(db: db) + yield client + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + + def parse_model_params(raw, out) + return {} if raw.nil? || raw.empty? + + ::JSON.parse(raw) + rescue ::JSON::ParserError => e + out.error("Invalid JSON for --model-params: #{e.message}") + nil + end + + def parse_variables(raw, out) + return {} if raw.nil? || raw.empty? + + ::JSON.parse(raw) + rescue ::JSON::ParserError => e + out.error("Invalid JSON for --variables: #{e.message}") + nil + end + + def diff_lines(old_text, new_text) + old_lines = old_text.split("\n") + new_lines = new_text.split("\n") + result = [] + old_set = old_lines.to_set + new_set = new_lines.to_set + old_lines.each { |l| result << "- #{l}" unless new_set.include?(l) } + new_lines.each { |l| result << "+ #{l}" unless old_set.include?(l) } + result.join("\n") + end + + def run_single(ctx) + name, version, vars, llm_kwargs, client, out = ctx.values_at(:name, :version, :vars, :llm_kwargs, :client, :out) + prompt = fetch_prompt(name, version, client, out) + return if prompt.nil? + + rendered = render_prompt(name, version, vars, client, out) + return if rendered.nil? + + response = Legion::LLM.chat( + messages: [{ role: 'user', content: rendered }], + caller: { source: 'cli', command: 'prompt' }, + **llm_kwargs + ) + + if options[:json] + out.json({ name: name, version: prompt[:version], rendered: rendered, + response: response[:content], usage: response[:usage] }) + else + out.header("Prompt: #{name} (v#{prompt[:version]})") + out.spacer + out.header('Rendered Template') + puts rendered + out.spacer + out.header('LLM Response') + puts response[:content] + display_usage(response[:usage], out) + end + end + + def run_compare(ctx) + name, ver_a, ver_b, vars, llm_kwargs, client, out = + ctx.values_at(:name, :ver_a, :ver_b, :vars, :llm_kwargs, :client, :out) + prompt_a = fetch_prompt(name, ver_a, client, out) + return if prompt_a.nil? + + prompt_b = fetch_prompt(name, ver_b, client, out) + return if prompt_b.nil? + + rendered_a = render_prompt(name, prompt_a[:version], vars, client, out) + return if rendered_a.nil? + + rendered_b = render_prompt(name, prompt_b[:version], vars, client, out) + return if rendered_b.nil? + + response_a = Legion::LLM.chat(messages: [{ role: 'user', content: rendered_a }], + caller: { source: 'cli', command: 'prompt' }, **llm_kwargs) + response_b = Legion::LLM.chat(messages: [{ role: 'user', content: rendered_b }], + caller: { source: 'cli', command: 'prompt' }, **llm_kwargs) + + if options[:json] + out.json({ name: name, version_a: prompt_a[:version], version_b: prompt_b[:version], + rendered_a: rendered_a, rendered_b: rendered_b, + response_a: response_a[:content], response_b: response_b[:content], + usage_a: response_a[:usage], usage_b: response_b[:usage] }) + else + out.header("Version A (v#{prompt_a[:version]})") + puts response_a[:content] + out.spacer + out.header("Version B (v#{prompt_b[:version]})") + puts response_b[:content] + content_a = response_a[:content].to_s + content_b = response_b[:content].to_s + if content_a != content_b + out.spacer + out.header('Diff (A vs B)') + puts diff_lines(content_a, content_b) + end + end + end + + def fetch_prompt(name, version, client, out) + kwargs = { name: name } + kwargs[:version] = version if version + result = client.get_prompt(**kwargs) + if result[:error] + out.error("Prompt '#{name}': #{result[:error]}") + raise SystemExit, 1 + end + result + end + + def render_prompt(name, version, vars, client, out) + kwargs = { name: name, variables: vars } + kwargs[:version] = version if version + result = client.render_prompt(**kwargs) + if result.is_a?(Hash) && result[:error] + out.error("Render error for '#{name}': #{result[:error]}") + raise SystemExit, 1 + end + result.is_a?(Hash) ? result[:rendered] : result + end + + def display_usage(usage, out) + return unless usage && !usage.empty? + + out.spacer + out.detail(usage) + end + end + end + end +end diff --git a/lib/legion/cli/rbac_command.rb b/lib/legion/cli/rbac_command.rb new file mode 100644 index 00000000..90370567 --- /dev/null +++ b/lib/legion/cli/rbac_command.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Rbac < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'roles', 'List role definitions from config' + def roles + out = formatter + with_rbac do + index = Legion::Rbac.role_index + if options[:json] + out.json(index.transform_values { |r| { description: r.description, cross_team: r.cross_team? } }) + else + rows = index.map { |name, r| [name.to_s, r.description, r.cross_team? ? 'yes' : 'no'] } + out.table(%w[Role Description CrossTeam], rows) + end + end + end + default_task :roles + + desc 'show ROLE', 'Show permissions for a role' + def show(role_name) + out = formatter + with_rbac do + role = Legion::Rbac.role_index[role_name.to_sym] + unless role + out.error("Role not found: #{role_name}") + return + end + + if options[:json] + out.json({ + name: role.name, + description: role.description, + cross_team: role.cross_team?, + permissions: role.permissions.map { |p| { resource: p.resource_pattern, actions: p.actions } }, + deny_rules: role.deny_rules.map { |d| { resource: d.resource_pattern, above_level: d.above_level } } + }) + else + out.header("Role: #{role.name}") + puts " #{role.description}" + puts " Cross-team: #{role.cross_team? ? 'yes' : 'no'}" + puts "\n Permissions:" + role.permissions.each { |p| puts " #{p.resource_pattern} -> #{p.actions.join(', ')}" } + puts "\n Deny rules:" + role.deny_rules.each { |d| puts " #{d.resource_pattern}#{" (above level #{d.above_level})" if d.above_level}" } + end + end + end + + desc 'assignments', 'List role assignments from DB' + option :team, type: :string, desc: 'Filter by team' + option :role, type: :string, desc: 'Filter by role' + option :principal, type: :string, desc: 'Filter by principal ID' + def assignments + out = formatter + with_data do + ds = Legion::Data::Model::RbacRoleAssignment.dataset + ds = ds.where(team: options[:team]) if options[:team] + ds = ds.where(role: options[:role]) if options[:role] + ds = ds.where(principal_id: options[:principal]) if options[:principal] + + records = ds.all + if options[:json] + out.json(records.map(&:values)) + else + rows = records.map { |r| [r.id, r.principal_id, r.principal_type, r.role, r.team || '-', r.active? ? 'active' : 'expired'] } + out.table(%w[ID Principal Type Role Team Status], rows) + end + end + end + + desc 'assign PRINCIPAL ROLE', 'Assign a role to a principal' + option :type, type: :string, default: 'human', desc: 'Principal type (human/worker)' + option :team, type: :string, desc: 'Team scope' + option :expires, type: :string, desc: 'Expiry (ISO 8601)' + def assign(principal, role) + out = formatter + with_data do + record = Legion::Data::Model::RbacRoleAssignment.create( + principal_type: options[:type], + principal_id: principal, + role: role, + team: options[:team], + granted_by: 'cli', + expires_at: options[:expires] ? Time.parse(options[:expires]) : nil + ) + out.success("Assigned #{role} to #{principal} (id: #{record.id})") + end + end + + desc 'revoke PRINCIPAL ROLE', 'Remove a role assignment' + def revoke(principal, role) + out = formatter + with_data do + ds = Legion::Data::Model::RbacRoleAssignment.where(principal_id: principal, role: role) + count = ds.count + ds.destroy + out.success("Revoked #{count} assignment(s) of #{role} from #{principal}") + end + end + + desc 'grants', 'List runner grants' + option :team, type: :string, desc: 'Filter by team' + def grants + out = formatter + with_data do + ds = Legion::Data::Model::RbacRunnerGrant.dataset + ds = ds.where(team: options[:team]) if options[:team] + + records = ds.all + if options[:json] + out.json(records.map(&:values)) + else + rows = records.map { |r| [r.id, r.team, r.runner_pattern, r.actions] } + out.table(%w[ID Team Pattern Actions], rows) + end + end + end + + desc 'grant TEAM PATTERN', 'Grant runner access to a team' + option :actions, type: :string, default: 'execute', desc: 'Comma-separated actions' + def grant(team, pattern) + out = formatter + with_data do + record = Legion::Data::Model::RbacRunnerGrant.create( + team: team, + runner_pattern: pattern, + actions: options[:actions], + granted_by: 'cli' + ) + out.success("Granted #{pattern} to team #{team} (id: #{record.id})") + end + end + + desc 'check PRINCIPAL RESOURCE', 'Dry-run authorization check' + option :action, type: :string, default: 'read', desc: 'Action to check' + option :roles, type: :array, default: [], desc: 'Roles to check (comma-separated)' + option :team, type: :string, desc: 'Team scope' + def check(principal_id, resource) + out = formatter + with_rbac do + principal = Legion::Rbac::Principal.new( + id: principal_id, + roles: options[:roles], + team: options[:team] + ) + result = Legion::Rbac::PolicyEngine.evaluate( + principal: principal, + action: options[:action], + resource: resource, + enforce: false + ) + if options[:json] + out.json(result) + else + status = result[:allowed] ? 'ALLOWED' : 'DENIED' + puts " #{status}: #{principal_id} -> #{options[:action]} #{resource}" + puts " Reason: #{result[:reason]}" if result[:reason] + puts " Would deny: #{result[:would_deny]}" if result[:would_deny] + end + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + + private + + def with_rbac + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_settings + require 'legion/rbac' + Legion::Rbac.setup + yield + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + end + + def with_data + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_data + require 'legion/rbac' + Legion::Rbac.setup + yield + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + end + end + end +end diff --git a/lib/legion/cli/relationship.rb b/lib/legion/cli/relationship.rb index 0c5e247c..79338b83 100755 --- a/lib/legion/cli/relationship.rb +++ b/lib/legion/cli/relationship.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli class Relationship < Thor diff --git a/lib/legion/cli/review_command.rb b/lib/legion/cli/review_command.rb new file mode 100644 index 00000000..c171341e --- /dev/null +++ b/lib/legion/cli/review_command.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/cli/error' +require 'open3' + +module Legion + module CLI + class Review < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + class_option :model, type: :string, aliases: ['-m'], desc: 'Model ID' + class_option :provider, type: :string, desc: 'LLM provider' + + desc 'diff', 'Review code changes via LLM' + option :staged, type: :boolean, default: false, desc: 'Review only staged changes' + option :base, type: :string, desc: 'Base branch for comparison (e.g., main)' + option :pr, type: :numeric, desc: 'Review a GitHub PR by number' + option :fix, type: :boolean, default: false, desc: 'Generate and apply fixes' + option :yes, type: :boolean, default: false, aliases: ['-y'], desc: 'Auto-approve fixes' + option :token, type: :string, desc: 'GitHub token (for --pr mode)' + def diff + out = formatter + setup_connection + + diff_text, context = fetch_diff(out) + if diff_text.strip.empty? + out.error('No changes to review.') + raise SystemExit, 1 + end + + out.header('Reviewing code changes...') + review = run_review(diff_text, context) + + if options[:json] + out.json(review) + return + end + + display_review(out, review) + + apply_fixes(out, review[:fixes]) if options[:fix] && review[:fixes]&.any? + + exit(1) if review[:findings].any? { |f| f[:severity] == 'critical' } + rescue CLI::Error => e + out.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown if Connection.respond_to?(:shutdown) + end + default_task :diff + + no_commands do # rubocop:disable Metrics/BlockLength + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def setup_connection + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_llm + end + + def fetch_diff(out) + if options[:pr] + fetch_pr_diff(out) + elsif options[:base] + fetch_branch_diff + elsif options[:staged] + fetch_staged_diff + else + fetch_working_diff + end + end + + def fetch_staged_diff + diff = git_capture('git', 'diff', '--staged') + stat = git_capture('git', 'diff', '--staged', '--stat') + [diff, { mode: 'staged', stat: stat }] + end + + def fetch_working_diff + diff = git_capture('git', 'diff') + stat = git_capture('git', 'diff', '--stat') + [diff, { mode: 'working', stat: stat }] + end + + def fetch_branch_diff + base = options[:base] + diff = git_capture('git', 'diff', "#{base}...HEAD") + stat = git_capture('git', 'diff', "#{base}...HEAD", '--stat') + log = git_capture('git', 'log', "#{base}..HEAD", '--oneline', '--no-decorate') + [diff, { mode: 'branch', base: base, stat: stat, log: log }] + end + + def fetch_pr_diff(out) + owner, repo = detect_remote + token = resolve_token + out.header("Fetching PR ##{options[:pr]}...") + + require 'legion/extensions/github/client' + client = Legion::Extensions::Github::Client.new(token: token) + pr = client.get_pull_request(owner: owner, repo: repo, pull_number: options[:pr]) + files = client.list_pull_request_files(owner: owner, repo: repo, pull_number: options[:pr]) + + pr_data = pr[:result] + patches = files[:result].map { |f| "--- a/#{f['filename']}\n+++ b/#{f['filename']}\n#{f['patch']}" } + diff = patches.join("\n\n") + + context = { + mode: 'pr', + pr: options[:pr], + title: pr_data['title'], + body: pr_data['body'], + stat: files[:result].map { |f| "#{f['filename']} (+#{f['additions']}/-#{f['deletions']})" }.join("\n") + } + + [diff, context] + end + + def run_review(diff_text, context) + opts = {} + opts[:model] = options[:model] if options[:model] + opts[:provider] = options[:provider]&.to_sym if options[:provider] + + chat = Legion::LLM.chat(**opts, caller: { source: 'cli', command: 'review' }) + prompt = build_review_prompt(diff_text, context) + response = chat.ask(prompt) + parse_review(response.content, context) + end + + def build_review_prompt(diff_text, context) + fix_instruction = options[:fix] ? fix_prompt_section : '' + context_section = build_context_section(context) + + <<~PROMPT + You are a senior code reviewer. Review the following code changes and provide structured feedback. + + #{context_section} + + For each finding, output exactly this format (one per finding): + [SEVERITY] file:line - description + + Severity levels: + - CRITICAL: bugs, security vulnerabilities, data loss risks + - WARNING: logic errors, performance issues, bad practices + - SUGGESTION: style improvements, refactoring opportunities + - NOTE: observations, questions, documentation needs + + After all findings, output a single line: + SUMMARY: one-sentence overall assessment + #{fix_instruction} + + Diff: + #{diff_text[0, 12_000]} + PROMPT + end + + def fix_prompt_section + <<~FIX + + Additionally, for each CRITICAL and WARNING finding, output a fix in unified diff format: + FIX file:line + ```diff + (unified diff patch) + ``` + FIX + end + + def build_context_section(context) + case context[:mode] + when 'pr' + "PR ##{context[:pr]}: #{context[:title]}\n#{context[:body]}\n\nChanged files:\n#{context[:stat]}" + when 'branch' + "Branch diff against #{context[:base]}\nCommits:\n#{context[:log]}\n\nDiffstat:\n#{context[:stat]}" + else + "#{context[:mode].capitalize} changes\n\nDiffstat:\n#{context[:stat]}" + end + end + + def parse_review(content, context) + findings = [] + fixes = [] + summary = nil + + content.each_line do |line| + stripped = line.strip + case stripped + when /^\[(CRITICAL|WARNING|SUGGESTION|NOTE)\]\s+(.+)/ + findings << { severity: Regexp.last_match(1).downcase, detail: Regexp.last_match(2) } + when /^SUMMARY:\s+(.+)/ + summary = Regexp.last_match(1) + when /^FIX\s+(.+)/ + fixes << { target: Regexp.last_match(1) } + end + end + + # Extract fix patches from code blocks + content.scan(/FIX\s+(.+?)\n```diff\n(.*?)```/m).each_with_index do |(target, patch), i| + fixes[i] = { target: target.strip, patch: patch } if fixes[i] + end + + { + findings: findings, + fixes: fixes.select { |f| f[:patch] }, + summary: summary || 'No summary provided.', + mode: context[:mode] + } + end + + def display_review(out, review) + puts + + severity_colors = { + 'critical' => :red, + 'warning' => :yellow, + 'suggestion' => :cyan, + 'note' => :white + } + + review[:findings].each do |finding| + color = severity_colors[finding[:severity]] || :white + label = finding[:severity].upcase.ljust(10) + puts " #{out.colorize(label, color)} #{finding[:detail]}" + end + + puts out.colorize(' No issues found.', :green) if review[:findings].empty? + + puts + counts = review[:findings].group_by { |f| f[:severity] }.transform_values(&:count) + parts = %w[critical warning suggestion note].filter_map do |sev| + "#{counts[sev]} #{sev}" if counts[sev] + end + puts " #{parts.any? ? parts.join(', ') : 'Clean'}" + puts " #{out.dim(review[:summary])}" + puts + end + + def apply_fixes(out, fixes) + out.header("#{fixes.length} fix(es) available") + + fixes.each do |fix| + puts out.dim(" #{fix[:target]}") + end + puts + + unless options[:yes] + $stderr.print "#{out.colorize('Apply fixes?', :yellow)} [Y/n] " + response = $stdin.gets&.strip&.downcase + return out.warn('Fixes skipped.') if %w[n no].include?(response) + end + + fixes.each do |fix| + apply_patch(fix[:patch], out) + end + end + + def apply_patch(patch, out) + require 'tempfile' + file = Tempfile.new(['legion-fix', '.patch']) + file.write(patch) + file.close + + _stdout, stderr, status = Open3.capture3('git', 'apply', '--check', file.path) + if status.success? + Open3.capture3('git', 'apply', file.path) + out.success('Patch applied.') + else + out.warn("Patch skipped (would not apply cleanly): #{stderr.strip}") + end + ensure + file&.unlink + end + + def git_capture(*cmd) + stdout, _stderr, _status = Open3.capture3(*cmd) + stdout.strip + end + + def detect_remote + stdout, _stderr, _status = Open3.capture3('git', 'remote', 'get-url', 'origin') + url = stdout.strip + match = url.match(%r{[:/]([^/]+)/([^/.]+?)(?:\.git)?$}) + raise CLI::Error, "Cannot parse GitHub owner/repo from remote: #{url}" unless match + + [match[1], match[2]] + end + + def resolve_token + token = options[:token] || ENV.fetch('GITHUB_TOKEN', nil) || ENV.fetch('GH_TOKEN', nil) + raise CLI::Error, 'No GitHub token found. Set GITHUB_TOKEN env var or pass --token.' unless token + + token + end + end + end + end +end diff --git a/lib/legion/cli/schedule_command.rb b/lib/legion/cli/schedule_command.rb new file mode 100644 index 00000000..1e12e432 --- /dev/null +++ b/lib/legion/cli/schedule_command.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require_relative 'api_client' + +module Legion + module CLI + class Schedule < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'list', 'List schedules' + option :active, type: :boolean, default: false, desc: 'Show only active schedules' + option :limit, type: :numeric, default: 20, desc: 'Max results' + def list + out = formatter + query = "/api/schedules?limit=#{options[:limit]}" + query += '&active=true' if options[:active] + schedules = api_get(query) + schedules = [] if schedules.nil? + + if options[:json] + out.json(schedules) + else + rows = Array(schedules).map do |s| + [s[:id], s[:function_id] || '-', s[:cron] || s[:interval] || '-', + out.status(s[:active] ? 'active' : 'inactive'), s[:description] || '-'] + end + out.table(%w[ID Function Schedule Status Description], rows) + puts " #{rows.size} schedule(s)" + end + end + default_task :list + + desc 'show ID', 'Show schedule details' + def show(id) + out = formatter + schedule = api_get("/api/schedules/#{id}") + + if options[:json] + out.json(schedule) + else + out.header("Schedule ##{id}") + out.spacer + out.detail(schedule.transform_keys(&:to_s)) + end + end + + desc 'add', 'Create a new schedule' + option :function_id, type: :numeric, required: true, desc: 'Function ID to schedule' + option :cron, type: :string, desc: 'Cron expression (e.g., "0 * * * *")' + option :interval, type: :numeric, desc: 'Interval in seconds' + option :description, type: :string, desc: 'Schedule description' + def add + out = formatter + + unless options[:cron] || options[:interval] + out.error('Either --cron or --interval is required') + return + end + + payload = { function_id: options[:function_id], active: true } + payload[:cron] = options[:cron] if options[:cron] + payload[:interval] = options[:interval] if options[:interval] + payload[:description] = options[:description] if options[:description] + + result = api_post('/api/schedules', **payload) + if options[:json] + out.json(result) + else + out.success("Schedule ##{result[:id]} created") + end + end + + desc 'remove ID', 'Delete a schedule' + option :yes, type: :boolean, default: false, aliases: '-y', desc: 'Skip confirmation' + def remove(id) + out = formatter + + unless options[:yes] + print "Delete schedule ##{id}? [y/N] " + return unless $stdin.gets&.strip&.downcase == 'y' + end + + result = api_delete("/api/schedules/#{id}") + if options[:json] + out.json({ id: id.to_i, deleted: true }.merge(result || {})) + else + out.success("Schedule ##{id} deleted") + end + end + + desc 'logs ID', 'Show schedule run logs' + option :limit, type: :numeric, default: 20, desc: 'Max results' + def logs(id) + out = formatter + log_entries = api_get("/api/schedules/#{id}/logs?limit=#{options[:limit]}") + log_entries = [] if log_entries.nil? + + if options[:json] + out.json(log_entries) + else + out.header("Logs for Schedule ##{id}") + if Array(log_entries).empty? + puts ' No logs found.' + else + rows = Array(log_entries).map do |l| + [l[:id], l[:status] || '-', l[:started_at]&.to_s || '-', l[:message] || '-'] + end + out.table(%w[ID Status Started Message], rows) + end + end + end + + no_commands do + include ApiClient + + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + end + end + end +end diff --git a/lib/legion/cli/service_command.rb b/lib/legion/cli/service_command.rb new file mode 100644 index 00000000..bb3ce9d2 --- /dev/null +++ b/lib/legion/cli/service_command.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'open3' +require 'rbconfig' +require 'thor' +require 'legion/cli/output' + +module Legion + module CLI + class ServiceCommand < Thor + namespace 'service' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + SERVICE_LABEL = 'homebrew.mxcl.legionio' + + desc 'start', 'Start the Legion launchd service' + long_desc <<~DESC + Starts the Legion background service via launchd. On macOS 26+ (Tahoe), + uses launchctl kickstart to ensure immediate process spawn after bootstrap. + DESC + def start + out = Output::Formatter.new(json: options[:json], color: !options[:no_color]) + ensure_macos!(out) + + plist = plist_path + unless File.exist?(plist) + out.error("Service plist not found at #{plist}") + out.info('Run: brew install legionio') + raise SystemExit, 1 + end + + uid = ::Process.uid + target = "gui/#{uid}" + + if service_loaded?(target) + out.info('Service already loaded, kicking...') + else + _, status = Open3.capture2e('launchctl', 'bootstrap', target, plist) + out.warn('bootstrap failed (may already be loaded), attempting kickstart anyway') unless status.success? + end + + _, status = Open3.capture2e('launchctl', 'kickstart', '-k', "#{target}/#{SERVICE_LABEL}") + if status.success? + out.success('Legion service started') + else + out.error('Failed to kickstart Legion service') + raise SystemExit, 1 + end + + poll_ready(out) + end + + desc 'stop', 'Stop the Legion launchd service' + def stop + out = Output::Formatter.new(json: options[:json], color: !options[:no_color]) + ensure_macos!(out) + + uid = ::Process.uid + target = "gui/#{uid}" + + _, status = Open3.capture2e('launchctl', 'bootout', "#{target}/#{SERVICE_LABEL}") + if status.success? + out.success('Legion service stopped') + else + out.warn('Service was not loaded (already stopped?)') + end + end + + desc 'restart', 'Restart the Legion launchd service' + def restart + out = Output::Formatter.new(json: options[:json], color: !options[:no_color]) + ensure_macos!(out) + + uid = ::Process.uid + target = "gui/#{uid}" + + Open3.capture2e('launchctl', 'bootout', "#{target}/#{SERVICE_LABEL}") + sleep 1 + + plist = plist_path + Open3.capture2e('launchctl', 'bootstrap', target, plist) if File.exist?(plist) + + _, status = Open3.capture2e('launchctl', 'kickstart', '-k', "#{target}/#{SERVICE_LABEL}") + if status.success? + out.success('Legion service restarted') + else + out.error('Failed to restart Legion service') + raise SystemExit, 1 + end + + poll_ready(out) + end + + desc 'status', 'Show Legion launchd service status' + def status + out = Output::Formatter.new(json: options[:json], color: !options[:no_color]) + ensure_macos!(out) + + uid = ::Process.uid + target = "gui/#{uid}" + output, status = Open3.capture2e('launchctl', 'print', "#{target}/#{SERVICE_LABEL}") + + unless status.success? + out.info('Service is not loaded') + return + end + + state = output[/state = (.+)/, 1] || 'unknown' + pid = output[/pid = (\d+)/, 1] + runs = output[/runs = (\d+)/, 1] + + if options[:json] + puts Legion::JSON.dump({ state: state, pid: pid&.to_i, runs: runs&.to_i }) + else + out.info("State: #{state}") + out.info("PID: #{pid}") if pid + out.info("Runs: #{runs}") if runs + end + end + + private + + def ensure_macos!(out) + return if RbConfig::CONFIG['host_os'] =~ /darwin/ + + out.error('The service command is only available on macOS (uses launchd)') + raise SystemExit, 1 + end + + def plist_path + File.expand_path("~/Library/LaunchAgents/#{SERVICE_LABEL}.plist") + end + + def service_loaded?(target) + _, status = Open3.capture2e('launchctl', 'print', "#{target}/#{SERVICE_LABEL}") + status.success? + end + + def poll_ready(out, port: 4567, timeout: 15) + require 'net/http' + deadline = ::Time.now + timeout + until ::Time.now > deadline + begin + resp = Net::HTTP.get_response(URI("http://localhost:#{port}/api/ready")) + if resp.is_a?(Net::HTTPSuccess) + out.success("Daemon ready on port #{port}") + return + end + rescue StandardError + # not ready yet + end + sleep 1 + end + out.info('Service started but not yet ready (boot in progress)') + end + end + end +end diff --git a/lib/legion/cli/setup_command.rb b/lib/legion/cli/setup_command.rb new file mode 100644 index 00000000..7177efa0 --- /dev/null +++ b/lib/legion/cli/setup_command.rb @@ -0,0 +1,953 @@ +# frozen_string_literal: true + +require 'English' +require 'json' +require 'fileutils' +require 'open3' +require 'thor' +require 'rbconfig' +require 'legion/cli/output' +require 'legion/python' + +module Legion + module CLI + class Setup < Thor + namespace 'setup' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :force, type: :boolean, default: false, desc: 'Overwrite existing config' + + LEGION_MCP_ENTRY = { + 'command' => 'legionio', + 'args' => %w[mcp stdio] + }.freeze + + PACKS = { + agentic: { + description: 'Cognitive stack: agentic domains, AI providers, and coordination', + gems: %w[ + legion-apollo legion-gaia legion-llm legion-mcp legion-rbac + lex-acp lex-adapter lex-agentic-affect lex-agentic-attention + lex-agentic-defense lex-agentic-executive lex-agentic-homeostasis + lex-agentic-imagination lex-agentic-inference lex-agentic-integration + lex-agentic-language lex-agentic-learning lex-agentic-memory + lex-agentic-self lex-agentic-social lex-apollo lex-coldstart + lex-conditioner lex-detect lex-extinction lex-kerberos lex-knowledge + lex-llm lex-llm-anthropic lex-llm-azure-foundry lex-llm-bedrock + lex-llm-gemini lex-llm-mlx lex-llm-ollama lex-llm-openai + lex-llm-vertex lex-llm-vllm lex-metering lex-mesh + lex-microsoft_teams lex-mind-growth lex-node lex-privatecore + lex-synapse lex-telemetry lex-tick + ] + }, + llm: { + description: 'LLM routing and provider integration (no cognitive stack)', + gems: %w[ + legion-llm legion-mcp lex-llm lex-llm-anthropic lex-llm-azure-foundry + lex-llm-bedrock lex-llm-gemini lex-llm-mlx + lex-llm-ollama lex-llm-openai lex-llm-vertex lex-llm-vllm + ] + }, + gaia: { + description: 'Cognitive coordination engine + agentic extensions (GAIA stack)', + gems: %w[ + legion-gaia + lex-agentic-affect lex-agentic-attention lex-agentic-defense + lex-agentic-executive lex-agentic-homeostasis lex-agentic-inference + lex-agentic-integration lex-agentic-language lex-agentic-learning + lex-agentic-memory lex-agentic-self lex-agentic-social + lex-synapse lex-mind-growth lex-tick + ] + }, + identity: { + description: 'Identity and access management (RBAC + identity providers)', + gems: %w[ + legion-rbac lex-identity-entra lex-identity-kerberos lex-identity-system lex-kerberos + ] + }, + developer: { + description: 'Developer tooling and CI/CD integrations', + gems: %w[ + lex-developer lex-dynatrace lex-eval lex-exec lex-github + lex-http lex-jfrog lex-skill-superpowers lex-ssh + ] + }, + channels: { + description: 'Channel adapters for chat platforms', + gems: %w[lex-slack lex-microsoft_teams] + } + }.freeze + + PYTHON_PACKAGES = Legion::Python::PACKAGES + PYTHON_VENV_DIR = Legion::Python::VENV_DIR + PYTHON_MARKER = Legion::Python::MARKER + + SKILL_CONTENT = <<~MARKDOWN + --- + name: legion + description: Orchestrate LegionIO extensions and agents + --- + + You have access to LegionIO MCP tools. When the user asks you to work with Legion: + + 1. Use `legion.discover_tools` to find relevant capabilities + 2. Use `legion.do_action` for natural language task routing + 3. Use `legion.run_task` to execute specific extension functions + 4. Use `legion.list_peers` and `legion.ask_peer` for agent coordination + 5. Present results as a consolidated summary + MARKDOWN + + desc 'claude-code', 'Install Legion MCP server and slash command skill for Claude Code' + def claude_code + out = formatter + installed = [] + + install_claude_mcp(installed) + install_claude_skill(installed) + install_claude_hooks(installed) + + if options[:json] + out.json(platform: 'claude-code', installed: installed) + else + out.spacer + out.success("Legion configured for Claude Code (#{installed.size} item(s))") + out.spacer + puts " Run '/legion' in Claude Code to use your LegionIO tools." + end + end + + desc 'cursor', 'Install Legion MCP server config for Cursor' + def cursor + out = formatter + path = File.join(Dir.pwd, '.cursor', 'mcp.json') + installed = [] + + write_mcp_servers_json(nil, path, installed) + + if options[:json] + out.json(platform: 'cursor', installed: installed) + else + out.spacer + out.success("Legion configured for Cursor (#{installed.size} item(s))") + out.spacer + puts " MCP config written to: #{path}" + end + end + + desc 'vscode', 'Install Legion MCP server config for VS Code' + def vscode + out = formatter + path = File.join(Dir.pwd, '.vscode', 'mcp.json') + installed = [] + + write_vscode_mcp_json(nil, path, installed) + + if options[:json] + out.json(platform: 'vscode', installed: installed) + else + out.spacer + out.success("Legion configured for VS Code (#{installed.size} item(s))") + out.spacer + puts " MCP config written to: #{path}" + end + end + + desc 'proxy-mode', 'Configure Codex CLI and Claude Code to use LegionIO as a local API proxy' + option :port, type: :numeric, default: 4567, desc: 'LegionIO API port' + option :host, type: :string, default: 'localhost', desc: 'LegionIO API host' + def proxy_mode + out = formatter + base_url = "http://#{options[:host]}:#{options[:port]}/v1" + written = [] + skipped = [] + + llm_installed, = partition_gems(PACKS[:llm][:gems]) + out.warn('LLM pack not installed. Run: legionio setup llm') if llm_installed.empty? && !options[:json] + + write_codex_config(base_url, written, skipped) + # write_claude_code_proxy_config(base_url, written, skipped) # too destructive for enterprise users + write_zsh_legionio(base_url, written, skipped) + write_pack_marker(:'proxy-mode') + + if options[:json] + out.json(written: written, skipped: skipped, base_url: base_url, profile: 'legionio') + else + out.spacer + out.success("LegionIO proxy mode configured (#{written.size} written, #{skipped.size} skipped)") + written.each { |f| puts " Written: #{f}" } + skipped.each { |f| puts " Skipped (already exists, use --force to overwrite): #{f}" } + out.spacer + puts " LegionIO API: #{base_url.sub('/v1', '')}" + puts ' Codex CLI: codex --profile legionio' + puts ' Claude Code: set ANTHROPIC_BASE_URL in your shell or ~/.claude/settings.json' + if written.any? { |f| f.end_with?('.zsh_legionio') } + out.spacer + puts ' To activate shell functions in this session, run:' + puts ' source ~/.zsh_legionio' + end + out.spacer + end + end + map 'proxy' => :proxy_mode + + desc 'agentic', 'Install full cognitive stack (GAIA + LLM + Apollo + all agentic extensions)' + option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed without installing' + def agentic + install_pack(:agentic) + end + map 'give-me-all-the-brains' => :agentic + map 'brains' => :agentic + + desc 'llm', 'Install LLM routing and provider integration' + option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed without installing' + def llm + install_pack(:llm) + end + + desc 'gaia', 'Install cognitive coordination engine and agentic extensions (GAIA stack)' + option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed without installing' + def gaia + install_pack(:gaia) + end + + desc 'identity', 'Install identity and access management (RBAC + identity providers)' + option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed without installing' + def identity + install_pack(:identity) + end + + desc 'channels', 'Install channel adapters (Slack, Teams)' + option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed without installing' + def channels + install_pack(:channels) + end + + desc 'fleet', 'Install and wire the Fleet Pipeline (two-phase: install gems + seed relationships)' + option :phase, type: :numeric, desc: 'Run only phase 1 (install) or 2 (wire)' + option :dry_run, type: :boolean, default: false, desc: 'Show what would be installed' + def fleet + require 'legion/cli/fleet_setup' + setup = Legion::CLI::FleetSetup.new(formatter: formatter, options: options) + + if options[:dry_run] + gems = Legion::CLI::FleetSetup.fleet_gems + installed, missing = gems.partition { |g| Gem::Specification.find_by_name(g) rescue nil } # rubocop:disable Style/RescueModifier + if options[:json] + formatter.json(to_install: missing, already_installed: installed) + else + formatter.header('Fleet Setup (dry run)') + missing.each { |g| puts " install #{g}" } + installed.each { |g| puts " skip #{g} (already installed)" } + end + return + end + + case options[:phase] + when 1 + result = setup.phase1_install + when 2 + Connection.ensure_data + result = setup.phase2_wire + Connection.shutdown + else + result = setup.phase1_install + if result[:success] + formatter.spacer unless options[:json] + formatter.warn('Phase 2 requires LegionIO restart to register extensions.') unless options[:json] + formatter.warn('Run: legionio start && legionio setup fleet --phase 2') unless options[:json] + end + end + + formatter.json(result) if options[:json] + rescue SystemExit + raise + rescue StandardError => e + formatter.error("Fleet setup failed: #{e.message}") + raise SystemExit, 1 + end + + desc 'python', 'Set up Legion Python environment (venv + document/data packages)' + option :packages, type: :array, default: [], banner: 'PKG [PKG...]', desc: 'Additional pip packages to install' + option :rebuild, type: :boolean, default: false, desc: 'Destroy and recreate the venv from scratch' + def python + out = formatter + results = [] + + python3 = find_python3 + unless python3 + out.error('python3 not found. Install it with: brew install python') + exit 1 + end + + if options[:rebuild] && Dir.exist?(PYTHON_VENV_DIR) + out.header("Rebuilding Python venv at #{PYTHON_VENV_DIR}") unless options[:json] + FileUtils.rm_rf(PYTHON_VENV_DIR) + end + + unless File.exist?("#{PYTHON_VENV_DIR}/pyvenv.cfg") + out.header("Creating Python venv at #{PYTHON_VENV_DIR}") unless options[:json] + FileUtils.mkdir_p(File.dirname(PYTHON_VENV_DIR)) + unless system(python3, '-m', 'venv', PYTHON_VENV_DIR) + out.error('Failed to create Python venv') + exit 1 + end + results << { action: 'created_venv', path: PYTHON_VENV_DIR } + end + + pip = "#{PYTHON_VENV_DIR}/bin/pip" + unless File.executable?(pip) + out.error("pip not found at #{pip} — try: legionio setup python --rebuild") + exit 1 + end + + packages = PYTHON_PACKAGES + Array(options[:packages]) + packages.uniq! + + failed = false + packages.each do |pkg| + puts " Installing #{pkg}..." unless options[:json] + output, status = Open3.capture2e(pip, 'install', '--quiet', '--upgrade', pkg) + if status.success? + out.success(" #{pkg}") unless options[:json] + results << { package: pkg, status: 'installed' } + else + failed = true + out.error(" #{pkg} failed") unless options[:json] + results << { package: pkg, status: 'failed', error: output.strip.lines.last&.strip } + end + end + + write_python_marker(python3, packages) + write_pack_marker(:python) + + if options[:json] + out.json(venv: PYTHON_VENV_DIR, python: python_version(python3), results: results) + else + out.spacer + out.success("Python environment ready: #{PYTHON_VENV_DIR}/bin/python3") + out.spacer + puts " Interpreter: #{PYTHON_VENV_DIR}/bin/python3" + puts ' Env var: $LEGION_PYTHON' + puts ' Add packages: legionio setup python --packages [...]' + puts ' Rebuild venv: legionio setup python --rebuild' + end + + exit 1 if failed + end + + desc 'packs', 'Show installed feature packs and available gems' + def packs + out = formatter + pack_statuses = PACKS.map do |name, pack| + installed, missing = partition_gems(pack[:gems]) + { name: name, description: pack[:description], + installed: installed.map { |g| { name: g, version: gem_version(g) } }, + missing: missing } + end + + python_status = { name: :python, description: 'Python venv + document/data packages', + installed: File.exist?(PYTHON_MARKER), missing: [] } + proxy_status = { name: :'proxy-mode', description: 'Codex CLI + shell helper functions for LegionIO proxy', + installed: File.exist?(File.expand_path('~/.legionio/.packs/proxy-mode')), missing: [] } + + if options[:json] + out.json(packs: pack_statuses, + python: python_status.slice(:name, :description, :installed), + proxy_mode: proxy_status.slice(:name, :description, :installed)) + else + out.header('Feature Packs') + out.spacer + pack_statuses.each do |ps| + all_installed = ps[:missing].empty? + icon = all_installed ? out.colorize('installed', :success) : out.colorize('not installed', :muted) + puts " #{out.colorize(ps[:name].to_s.ljust(12), :label)} #{icon} #{ps[:description]}" + ps[:installed].each do |g| + puts " #{out.colorize(g[:name], :success)} #{g[:version]}" + end + ps[:missing].each do |g| + puts " #{out.colorize(g, :muted)} (missing)" + end + end + [python_status, proxy_status].each do |ps| + icon = ps[:installed] ? out.colorize('installed', :success) : out.colorize('not installed', :muted) + puts " #{out.colorize(ps[:name].to_s.ljust(12), :label)} #{icon} #{ps[:description]}" + end + out.spacer + end + end + + desc 'status', 'Show which platforms have Legion MCP configured' + def status + out = formatter + platforms = check_all_platforms + + if options[:json] + out.json(platforms: platforms) + else + out.header('Legion MCP Setup Status') + out.spacer + platforms.each do |p| + icon = p[:configured] ? out.colorize('configured', :success) : out.colorize('not configured', :muted) + puts " #{out.colorize(p[:name].ljust(16), :label)} #{icon}" + puts " #{out.colorize(p[:path], :muted)}" if p[:path] + end + out.spacer + configured_count = platforms.count { |p| p[:configured] } + puts " #{configured_count} of #{platforms.size} platform(s) configured" + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + # ----------------------------------------------------------------------- + # Python helpers + # ----------------------------------------------------------------------- + + def find_python3 + Legion::Python.find_system_python3 + end + + def python_version(python3) + `"#{python3}" --version 2>&1`.strip + rescue StandardError + 'unknown' + end + + def write_python_marker(python3, packages) + FileUtils.mkdir_p(File.dirname(PYTHON_MARKER)) + File.write(PYTHON_MARKER, ::JSON.pretty_generate( + venv: PYTHON_VENV_DIR, + python: python_version(python3), + packages: packages, + updated_at: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ') + )) + rescue Errno::EPERM, Errno::EACCES, Errno::ENOENT => e + Legion::Logging.warn("SetupCommand#write_python_marker: #{e.message}") if defined?(Legion::Logging) + end + + # ----------------------------------------------------------------------- + # Pack helpers + # ----------------------------------------------------------------------- + + def install_pack(pack_name) + pack = PACKS[pack_name] + installed, missing = partition_gems(pack[:gems]) + + if missing.empty? + write_pack_marker(pack_name) + return report_already_installed(pack_name, installed) + end + return report_dry_run(pack_name, installed, missing) if options[:dry_run] + + execute_pack_install(pack_name, installed, missing) + end + + def report_already_installed(pack_name, installed) + out = formatter + if options[:json] + out.json(pack: pack_name, status: 'already_installed', + gems: installed.map { |g| { name: g, version: gem_version(g) } }) + else + out.success("#{pack_name} pack already installed") + installed.each { |g| puts " #{g} #{gem_version(g)}" } + end + end + + def report_dry_run(pack_name, installed, missing) + out = formatter + if options[:json] + out.json(pack: pack_name, status: 'dry_run', to_install: missing, + already_installed: installed.map { |g| { name: g, version: gem_version(g) } }) + else + out.header("#{pack_name} pack (dry run)") + missing.each { |g| puts " #{out.colorize('install', :accent)} #{g}" } + installed.each { |g| puts " #{out.colorize('skip', :muted)} #{g} #{gem_version(g)} (already installed)" } + end + end + + def execute_pack_install(pack_name, installed, missing) + out = formatter + out.header("Installing #{pack_name} pack") unless options[:json] + gem_bin = File.join(RbConfig::CONFIG['bindir'], 'gem') + results = missing.map { |g| install_gem(g, gem_bin, out) } + + Gem::Specification.reset + successes, failures = results.partition { |r| r[:status] == 'installed' } + + if options[:json] + out.json(pack: pack_name, installed: successes, failed: failures, + already_present: installed.map { |g| { name: g, version: gem_version(g) } }) + else + out.spacer + if failures.empty? + write_pack_marker(pack_name) + out.success("#{pack_name} pack installed (#{successes.size} gem(s))") + suggest_next_steps(out, pack_name) + else + out.error("#{failures.size} gem(s) failed to install") + failures.each { |f| puts " #{f[:name]}: #{f[:error]}" } + end + end + end + + def partition_gems(gem_names) + installed = [] + missing = [] + gem_names.each do |name| + Gem::Specification.find_by_name(name) + installed << name + rescue Gem::MissingSpecError + missing << name + end + [installed, missing] + end + + def gem_version(name) + Gem::Specification.find_by_name(name).version.to_s + rescue Gem::MissingSpecError + nil + end + + def install_gem(name, gem_bin, out) + puts " Installing #{name}..." unless options[:json] + output = `#{gem_bin} install #{name} --no-document --source https://rubygems.org/ 2>&1` + if $CHILD_STATUS.success? + out.success(" #{name} installed") unless options[:json] + { name: name, status: 'installed' } + else + out.error(" #{name} failed") unless options[:json] + { name: name, status: 'failed', error: output.strip.lines.last&.strip } + end + end + + def write_pack_marker(pack_name) + marker_dir = File.expand_path('~/.legionio/.packs') + FileUtils.mkdir_p(marker_dir) + marker = File.join(marker_dir, pack_name.to_s) + File.write(marker, '') unless File.exist?(marker) + update_packs_setting(pack_name) + rescue StandardError => e + Legion::Logging.warn("Could not write pack marker: #{e.message}") if defined?(Legion::Logging) + end + + def update_packs_setting(pack_name) + settings_file = File.expand_path('~/.legionio/settings/packs.json') + data = if File.exist?(settings_file) + ::JSON.parse(File.read(settings_file)) + else + {} + end + packs = Array(data['packs']) + packs << pack_name.to_s unless packs.include?(pack_name.to_s) + data['packs'] = packs.sort + FileUtils.mkdir_p(File.dirname(settings_file)) + File.write(settings_file, ::JSON.pretty_generate(data)) + rescue ::JSON::ParserError + data = { 'packs' => [pack_name.to_s] } + File.write(settings_file, ::JSON.pretty_generate(data)) + rescue StandardError => e + Legion::Logging.warn("Could not update packs setting: #{e.message}") if defined?(Legion::Logging) + end + + def suggest_next_steps(out, pack_name) + out.spacer + case pack_name + when :agentic + puts ' Next steps:' + puts ' legion start # full daemon with cognitive stack' + puts ' legion start --lite # single-process, no external services' + puts ' legion chat # interactive AI conversation' + when :llm + puts ' Next steps:' + puts ' legion chat # interactive AI conversation' + puts ' legion llm status # check provider connectivity' + when :gaia + puts ' Next steps:' + puts ' legion start # start daemon with cognitive stack' + puts ' legion start --lite # single-process, no external services' + when :identity + puts ' Next steps:' + puts ' Configure RBAC in settings: {"rbac": {"enabled": true}}' + puts ' legion start # start daemon with identity services' + when :channels + puts ' Next steps:' + puts ' Configure channels in settings: {"gaia": {"channels": {"slack": {"enabled": true}}}}' + end + end + + # ----------------------------------------------------------------------- + # MCP / editor platform helpers + # ----------------------------------------------------------------------- + + def install_claude_mcp(installed) + settings_path = File.expand_path('~/.claude/settings.json') + existing = load_json_file(settings_path) + servers = existing['mcpServers'] || {} + + if servers.key?('legion') && !options[:force] + puts ' Claude Code MCP entry already present (use --force to overwrite)' unless options[:json] + return + end + + servers['legion'] = LEGION_MCP_ENTRY + existing['mcpServers'] = servers + + write_json_file(settings_path, existing) + installed << settings_path + puts " Wrote MCP server entry to #{settings_path}" unless options[:json] + end + + def install_claude_skill(installed) + skill_path = File.expand_path('~/.claude/commands/legion.md') + + if File.exist?(skill_path) && !options[:force] + puts ' Claude Code skill already present (use --force to overwrite)' unless options[:json] + return + end + + FileUtils.mkdir_p(File.dirname(skill_path)) + File.write(skill_path, SKILL_CONTENT) + installed << skill_path + puts " Wrote slash command skill to #{skill_path}" unless options[:json] + end + + def install_claude_hooks(installed) + settings_path = File.expand_path('~/.claude/settings.json') + existing = load_json_file(settings_path) + + hooks = existing['hooks'] || {} + + has_commit = Array(hooks['PostToolUse']).any? { |h| hook_commands(h).any? { |c| c.include?('knowledge capture commit') } } + has_transcript = Array(hooks['Stop']).any? { |h| hook_commands(h).any? { |c| c.include?('knowledge capture transcript') } } + if has_commit && has_transcript && !options[:force] + puts ' Write-back hooks already present (use --force to overwrite)' unless options[:json] + return + end + + hooks['PostToolUse'] ||= [] + hooks['Stop'] ||= [] + + unless has_commit + hooks['PostToolUse'] << { + 'matcher' => 'Bash', + 'hooks' => [{ 'type' => 'command', 'command' => 'legionio knowledge capture commit', 'timeout' => 10_000 }] + } + end + + unless has_transcript + hooks['Stop'] << { + 'matcher' => '', + 'hooks' => [{ 'type' => 'command', 'command' => 'legionio knowledge capture transcript', 'timeout' => 30_000 }] + } + end + + existing['hooks'] = hooks + write_json_file(settings_path, existing) + installed << 'hooks' + puts ' Installed write-back hooks for knowledge capture' unless options[:json] + end + + def hook_commands(hook_entry) + # Support both old format (command at top level) and new format (hooks array) + cmds = Array(hook_entry['hooks']).filter_map { |h| h['command'] } + cmds << hook_entry['command'] if hook_entry['command'] + cmds + end + + def write_mcp_servers_json(_out, path, installed) + existing = load_json_file(path) + servers = existing['mcpServers'] || {} + + if servers.key?('legion') && !options[:force] + puts " Legion entry already present in #{path} (use --force to overwrite)" unless options[:json] + return + end + + servers['legion'] = LEGION_MCP_ENTRY + existing['mcpServers'] = servers + + write_json_file(path, existing) + installed << path + puts " Wrote MCP config to #{path}" unless options[:json] + end + + def write_vscode_mcp_json(_out, path, installed) + existing = load_json_file(path) + servers = existing['servers'] || {} + + if servers.key?('legion') && !options[:force] + puts " Legion entry already present in #{path} (use --force to overwrite)" unless options[:json] + return + end + + servers['legion'] = { + 'type' => 'stdio', + 'command' => 'legionio', + 'args' => %w[mcp stdio] + } + existing['servers'] = servers + + write_json_file(path, existing) + installed << path + puts " Wrote MCP config to #{path}" unless options[:json] + end + + def load_json_file(path) + return {} unless File.exist?(path) + + ::JSON.parse(File.read(path)) + rescue ::JSON::ParserError => e + Legion::Logging.warn("SetupCommand#load_json_file invalid JSON in #{path}: #{e.message}") if defined?(Legion::Logging) + {} + end + + def write_json_file(path, data) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, ::JSON.pretty_generate(data)) + end + + def check_all_platforms + [ + check_claude_code, + check_cursor, + check_vscode + ] + end + + def check_claude_code + path = File.expand_path('~/.claude/settings.json') + configured = begin + data = ::JSON.parse(File.read(path)) + data.dig('mcpServers', 'legion') ? true : false + rescue StandardError => e + Legion::Logging.debug("SetupCommand#check_claude_code failed: #{e.message}") if defined?(Legion::Logging) + false + end + { name: 'Claude Code', path: path, configured: configured } + end + + def check_cursor + path = File.join(Dir.pwd, '.cursor', 'mcp.json') + configured = begin + data = ::JSON.parse(File.read(path)) + data.dig('mcpServers', 'legion') ? true : false + rescue StandardError => e + Legion::Logging.debug("SetupCommand#check_cursor failed: #{e.message}") if defined?(Legion::Logging) + false + end + { name: 'Cursor', path: path, configured: configured } + end + + def check_vscode + path = File.join(Dir.pwd, '.vscode', 'mcp.json') + configured = begin + data = ::JSON.parse(File.read(path)) + data.dig('servers', 'legion') ? true : false + rescue StandardError => e + Legion::Logging.debug("SetupCommand#check_vscode failed: #{e.message}") if defined?(Legion::Logging) + false + end + { name: 'VS Code', path: path, configured: configured } + end + + def write_codex_config(base_url, written, skipped) + codex_dir = File.expand_path('~/.codex') + FileUtils.mkdir_p(codex_dir) + + write_codex_profile(codex_dir, base_url, written, skipped) + write_codex_catalog(codex_dir, written, skipped) + write_codex_main_config(codex_dir, base_url, written, skipped) + end + + def write_codex_profile(codex_dir, base_url, written, skipped) + profile_path = File.join(codex_dir, 'legionio.config.toml') + + if File.exist?(profile_path) && !options[:force] + skipped << profile_path + return + end + + catalog_path = File.join(codex_dir, 'legionio-catalog.json') + content = <<~TOML + model = "legionio" + model_provider = "legionio" + model_catalog_json = "#{catalog_path}" + + [model_providers.legionio] + name = "LegionIO" + api_key = "legion" + base_url = "#{base_url}" + wire_api = "responses" + TOML + + File.write(profile_path, content) + written << profile_path + rescue StandardError => e + raise Thor::Error, "Failed to write #{profile_path}: #{e.message}" + end + + def write_codex_catalog(codex_dir, written, skipped) + catalog_path = File.join(codex_dir, 'legionio-catalog.json') + + if File.exist?(catalog_path) && !options[:force] + skipped << catalog_path + return + end + + catalog = { + models: [ + { + slug: 'legionio', + display_name: 'LegionIO', + context_window: 262_144, + context_size: 262_144 + }, + { + slug: 'auto', + display_name: 'LegionIO (auto)', + context_window: 262_144, + context_size: 262_144 + } + ] + } + + File.write(catalog_path, ::JSON.pretty_generate(catalog)) + written << catalog_path + rescue StandardError => e + raise Thor::Error, "Failed to write #{catalog_path}: #{e.message}" + end + + def write_codex_main_config(codex_dir, base_url, written, _skipped) + config_path = File.join(codex_dir, 'config.toml') + existing = File.exist?(config_path) ? File.read(config_path) : '' + + # Upsert [model_providers.legionio] block so the provider appears in the + # model picker in both the CLI and the Codex Mac app. + # No profile = line (removed by Codex). No model_catalog_json at top level + # (Codex enforces a strict schema with required fields that breaks the app). + provider_block = <<~TOML + + [model_providers.legionio] + name = "LegionIO" + api_key = "legion" + base_url = "#{base_url}" + wire_api = "responses" + TOML + + updated = existing.dup + + # Only match uncommented [model_providers.legionio] section headers + updated = if updated.match?(/^\[model_providers\.legionio\]/) + updated.gsub( + /^\[model_providers\.legionio\].*?(?=\n\[|\z)/m, + provider_block.lstrip + ) + else + "#{updated.rstrip}\n#{provider_block}" + end + + File.write(config_path, updated) + written << config_path + rescue StandardError => e + raise Thor::Error, "Failed to write #{config_path}: #{e.message}" + end + + def write_zsh_legionio(base_url, written, _skipped) + zshrc_path = File.expand_path('~/.zshrc') + return unless File.exist?(zshrc_path) + + host_base = base_url.sub(%r{/v1$}, '') + zsh_file = File.expand_path('~/.zsh_legionio') + + content = <<~ZSH + # LegionIO shell helpers — generated by `legionio setup proxy-mode` + # Re-run to update; do not edit manually. + + claude-legionio() { + export ANTHROPIC_BASE_URL=#{host_base} + export ANTHROPIC_API_KEY=legion + export ANTHROPIC_AUTH_TOKEN= + export CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 + export CLAUDE_CODE_USE_BEDROCK= + export AWS_PROFILE= + export AWS_REGION= + unset ANTHROPIC_DEFAULT_OPUS_MODEL + unset ANTHROPIC_DEFAULT_SONNET_MODEL + unset ANTHROPIC_DEFAULT_HAIKU_MODEL + claude --model legionio "$@" + } + + codex-legionio() { + codex --profile legionio "$@" + } + ZSH + + File.write(zsh_file, content) + written << zsh_file + + source_line = '[ -f ~/.zsh_legionio ] && source ~/.zsh_legionio' + zshrc = File.read(zshrc_path) + unless zshrc.include?(source_line) + File.write(zshrc_path, "#{zshrc.rstrip}\n\n#{source_line}\n") + written << zshrc_path + end + rescue StandardError => e + raise Thor::Error, "Failed to write zsh config: #{e.message}" + end + + def write_claude_code_proxy_config(base_url, written, skipped) + claude_dir = File.expand_path('~/.claude') + claude_path = File.join(claude_dir, 'settings.json') + + existing = if File.exist?(claude_path) + begin + ::JSON.parse(File.read(claude_path)) + rescue ::JSON::ParserError + {} + end + else + {} + end + + proxy_env = { + 'ANTHROPIC_BASE_URL' => base_url.sub(%r{/v1$}, ''), + 'ANTHROPIC_API_KEY' => 'legion', + 'ANTHROPIC_AUTH_TOKEN' => 'legion', + 'ANTHROPIC_DEFAULT_OPUS_MODEL' => 'legionio', + 'ANTHROPIC_DEFAULT_SONNET_MODEL' => 'legionio', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL' => 'legionio' + } + + current_env = existing['env'] || {} + + already_set = proxy_env.all? { |k, v| current_env[k] == v } + if already_set && !options[:force] + skipped << claude_path + return + end + + merged = existing.merge('env' => current_env.merge(proxy_env)) + + FileUtils.mkdir_p(claude_dir) + File.write(claude_path, ::JSON.pretty_generate(merged)) + written << claude_path + rescue StandardError => e + raise Thor::Error, "Failed to write #{claude_path}: #{e.message}" + end + end + end + end +end diff --git a/lib/legion/cli/skill_command.rb b/lib/legion/cli/skill_command.rb new file mode 100644 index 00000000..f049f62d --- /dev/null +++ b/lib/legion/cli/skill_command.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'thor' +require 'net/http' +require 'json' +require 'uri' + +module Legion + module CLI + class Skill < Thor + def self.exit_on_failure? + true + end + + desc 'list', 'List all registered skills' + def list + response = daemon_get('/api/skills') + unless response.is_a?(::Net::HTTPSuccess) + say "Error fetching skills: #{response.code}", :red + exit 1 + end + + skills = ::JSON.parse(response.body, symbolize_names: true)[:data] || [] + if skills.empty? + say 'No skills registered. Start the daemon with legion-llm loaded.' + return + end + + skills.each do |s| + say " #{s[:namespace]}:#{s[:name]} [#{s[:trigger]}] #{s[:description]}", :green + end + end + + desc 'show NAMESPACE:NAME', 'Show skill details' + def show(name) + ns, nm = name.include?(':') ? name.split(':', 2) : ['default', name] + response = daemon_get("/api/skills/#{ns}/#{nm}") + unless response.is_a?(::Net::HTTPSuccess) + say "Skill '#{name}' not found", :red + exit 1 + end + + result = ::JSON.parse(response.body, symbolize_names: true) + data = result[:data] || {} + say "Name: #{data[:namespace]}:#{data[:name]}", :green + say "Description: #{data[:description]}" + say "Trigger: #{data[:trigger]}" + say "Steps: #{Array(data[:steps]).join(', ')}" + end + + desc 'create NAME', 'Scaffold a new skill file' + def create(name) + require 'fileutils' + dir = '.legion/skills' + FileUtils.mkdir_p(dir) + path = ::File.join(dir, "#{name}.md") + + if ::File.exist?(path) + say "Skill already exists: #{path}", :red + return + end + + content = <<~SKILL + --- + name: #{name} + namespace: local + description: Describe what this skill does + trigger: on_demand + --- + + You are a helpful assistant. Describe the skill's behavior here. + SKILL + + ::File.write(path, content) + say "Created: #{path}", :green + end + + desc 'run NAME', 'Run a skill via the daemon' + map 'run' => :run_skill + def run_skill(name) + url = "#{daemon_base_url}/api/skills/invoke" + payload = { skill_name: name }.to_json + + response = ::Net::HTTP.post( + ::URI.parse(url), + payload, + 'Content-Type' => 'application/json' + ) + + if response.is_a?(::Net::HTTPSuccess) + result = ::JSON.parse(response.body, symbolize_names: true) + say result.dig(:data, :content).to_s + else + say "Error: #{response.code} #{response.body}", :red + exit 1 + end + end + + no_commands do + def daemon_base_url + host = Legion::Settings.dig(:api, :host) || 'localhost' + port = Legion::Settings.dig(:api, :port) || 4567 + "http://#{host}:#{port}" + end + + def daemon_get(path) + uri = ::URI.parse("#{daemon_base_url}#{path}") + ::Net::HTTP.get_response(uri) + end + end + end + end +end diff --git a/lib/legion/cli/start.rb b/lib/legion/cli/start.rb new file mode 100644 index 00000000..011c320b --- /dev/null +++ b/lib/legion/cli/start.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Legion + module CLI + module Start + class << self + def run(options) + if options[:lite] + ENV['LEGION_MODE'] = 'lite' + ENV['LEGION_LOCAL'] = 'true' + end + + log_level = options[:log_level] + + # Load settings early, before any legion-* gem requires can trigger auto-load. + # This ensures DNS bootstrap and config file loading happen exactly once. + require 'legion/json' + require 'legion/settings' + directories = Legion::Settings::Loader.default_directories.select { |d| Dir.exist?(d) } + Legion::Settings.load(config_dirs: directories) + + require 'legion' + require 'legion/service' + require 'legion/process' + + clear_log_file unless options[:daemonize] + + api = options.fetch(:api, true) + service_opts = { api: api } + service_opts[:log_level] = log_level if log_level + service_opts[:http_port] = options[:http_port] if options[:http_port] + service_opts[:role] = :lite if options[:lite] + Legion.instance_variable_set(:@service, Legion::Service.new(**service_opts)) + Legion::Logging.info("Started Legion v#{Legion::VERSION}") + + process_opts = { + daemonize: options[:daemonize], + pidfile: options[:pidfile], + logfile: options[:logfile], + time_limit: options[:time_limit] + }.compact + + Legion::Process.new(process_opts).run! + end + + private + + def clear_log_file + logging = Legion::Settings[:logging] + return unless logging.is_a?(Hash) && logging[:log_file] + + path = File.expand_path(logging[:log_file]) + return unless File.exist?(path) + + File.truncate(path, 0) + rescue StandardError => e + Legion::Logging.warn("Start#clear_log_file failed: #{e.message}") if defined?(Legion::Logging) + nil + end + end + end + end +end diff --git a/lib/legion/cli/status.rb b/lib/legion/cli/status.rb new file mode 100644 index 00000000..8f504d33 --- /dev/null +++ b/lib/legion/cli/status.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' +require 'json' + +module Legion + module CLI + module Status + class << self + def run(out, options) + # Try the HTTP API first (running service) + api_status = check_api(options) + + if api_status + show_running(out, api_status, options) + else + show_static(out, options) + end + end + + private + + def check_api(options) + port = options[:port] || 4567 + host = options[:host] || '127.0.0.1' + + uri = URI("http://#{host}:#{port}/ready") + response = Net::HTTP.get_response(uri) + JSON.parse(response.body, symbolize_names: true) + rescue StandardError => e + Legion::Logging.debug("Status#check_api failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def show_running(out, api_status, options) + if options[:json] + out.json(running: true, **api_status) + return + end + + ready = api_status[:ready] + out.header('Legion Service') + puts " #{out.colorize('STATUS:', :cyan)} #{ready ? out.colorize('RUNNING', :green) : out.colorize('STARTING', :yellow)}" + out.spacer + + if api_status[:components] + out.header('Components') + api_status[:components].each do |component, is_ready| + status_str = is_ready ? out.colorize('ready', :green) : out.colorize('not ready', :yellow) + puts " #{component.to_s.ljust(15)} #{status_str}" + end + end + + # Check for PID + pidfile = find_pidfile + return unless pidfile + + pid = File.read(pidfile).to_i + out.spacer + puts " #{out.colorize('PID:', :cyan)} #{pid} (#{pidfile})" + end + + def show_static(out, options) + if options[:json] + out.json( + running: false, + extensions: discovered_lexs, + config_paths: config_paths + ) + return + end + + out.header('Legion Service') + puts " #{out.colorize('STATUS:', :cyan)} #{out.colorize('NOT RUNNING', :red)}" + out.spacer + + lexs = discovered_lexs + out.header("Installed Extensions (#{lexs.size})") + lexs.each do |name, version| + puts " #{out.colorize(name.ljust(20), :cyan)} #{version}" + end + + out.spacer + out.header('Config Search Paths') + config_paths.each do |path| + exists = Dir.exist?(path) + marker = exists ? out.colorize('*', :green) : out.colorize(' ', :gray) + path_str = exists ? path : out.colorize(path, :gray) + puts " #{marker} #{path_str}" + end + end + + def discovered_lexs + Gem::Specification.select { |s| s.name.start_with?('lex-') } + .map { |s| [s.name, s.version.to_s] } + .sort_by(&:first) + end + + def config_paths + [ + '/etc/legionio', + File.expand_path('~/legionio'), + './settings' + ] + end + + def find_pidfile + %w[/var/run/legion.pid /tmp/legion.pid].find { |f| File.exist?(f) } + end + end + end + end +end diff --git a/lib/legion/cli/swarm_command.rb b/lib/legion/cli/swarm_command.rb new file mode 100644 index 00000000..269f0336 --- /dev/null +++ b/lib/legion/cli/swarm_command.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' + +module Legion + module CLI + class Swarm < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + class_option :model, type: :string, aliases: ['-m'], desc: 'Default model for agents' + + WORKFLOW_DIR = '.legion/swarms' + + desc 'start NAME', 'Start a swarm workflow' + def start(name) + out = formatter + workflow = load_workflow(name) + + out.header("Swarm: #{workflow['name'] || name}") + puts out.dim(" Goal: #{workflow['goal']}") + puts out.dim(" Agents: #{workflow['agents']&.length || 0}") + puts out.dim(" Pipeline: #{workflow['pipeline']&.join(' -> ')}") + puts + + run_workflow(workflow, out) + end + + desc 'list', 'List available swarm workflows' + def list + out = formatter + dir = File.join(Dir.pwd, WORKFLOW_DIR) + + unless Dir.exist?(dir) + out.warn("No workflows found. Create them in #{WORKFLOW_DIR}/") + return + end + + files = Dir.glob(File.join(dir, '*.json')) + if files.empty? + out.warn("No workflow files found in #{WORKFLOW_DIR}/") + return + end + + out.header("Swarm Workflows (#{files.length})") + files.each do |f| + name = File.basename(f, '.json') + workflow = parse_workflow_file(f) + goal = workflow&.dig('goal') || '(no goal)' + puts " #{name} — #{goal}" + end + end + + desc 'show NAME', 'Show details of a swarm workflow' + def show(name) + out = formatter + workflow = load_workflow(name) + + if options[:json] + out.json(workflow) + else + out.header("Workflow: #{workflow['name'] || name}") + puts " Goal: #{workflow['goal']}" + puts + (workflow['agents'] || []).each do |agent| + puts " #{out.colorize(agent['role'], :accent)}" + puts " #{agent['description']}" + puts " Tools: #{agent['tools']&.join(', ') || 'all'}" + puts " Model: #{agent['model'] || 'default'}" + puts + end + puts " Pipeline: #{workflow['pipeline']&.join(' -> ')}" + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def load_workflow(name) + path = File.join(Dir.pwd, WORKFLOW_DIR, "#{name}.json") + raise CLI::Error, "Workflow not found: #{path}. Create it in #{WORKFLOW_DIR}/#{name}.json" unless File.exist?(path) + + parse_workflow_file(path) + end + + def parse_workflow_file(path) + require 'json' + ::JSON.parse(File.read(path, encoding: 'utf-8')) + rescue ::JSON::ParserError => e + raise CLI::Error, "Invalid workflow JSON in #{path}: #{e.message}" + end + + def run_workflow(workflow, out) + require 'legion/cli/chat/subagent' + pipeline = workflow['pipeline'] || [] + agents_map = (workflow['agents'] || []).to_h { |a| [a['role'], a] } + + previous_output = workflow['goal'] + + pipeline.each_with_index do |role, idx| + agent_def = agents_map[role] + unless agent_def + out.error("No agent defined for role: #{role}") + break + end + + step = idx + 1 + out.header("Step #{step}/#{pipeline.length}: #{role}") + puts out.dim(" #{agent_def['description']}") + + task = <<~TASK + You are a #{role} agent. Your task: + #{agent_def['description']} + + Context from previous step: + #{previous_output} + + Produce clear, structured output for the next agent in the pipeline. + TASK + + result = Chat::Subagent.send(:run_headless, + task: task, + model: agent_def['model'] || options[:model]) + + if result[:exit_code]&.zero? && result[:output] + previous_output = result[:output] + out.success("#{role} complete (#{result[:output].length} chars)") + else + out.error("#{role} failed: #{result[:error] || 'unknown error'}") + break + end + end + + puts + out.header('Swarm Complete') + puts previous_output + end + end + end + end +end diff --git a/lib/legion/cli/task.rb b/lib/legion/cli/task.rb index d00a554d..da5da0d0 100755 --- a/lib/legion/cli/task.rb +++ b/lib/legion/cli/task.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli class Task < Thor diff --git a/lib/legion/cli/task_command.rb b/lib/legion/cli/task_command.rb new file mode 100644 index 00000000..bee13b9c --- /dev/null +++ b/lib/legion/cli/task_command.rb @@ -0,0 +1,359 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Task < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'list', 'List recent tasks' + option :limit, type: :numeric, default: 20, aliases: ['-n'], desc: 'Number of tasks to return' + option :status, type: :string, aliases: ['-s'], desc: 'Filter by status (e.g. completed, failed, queued)' + option :extension, type: :string, aliases: ['-e'], desc: 'Filter by extension name' + def list + out = formatter + with_data do + dataset = Legion::Data::Model::Task.order(Sequel.desc(:id)).limit(options[:limit]) + dataset = dataset.where(Sequel.like(:status, "%#{options[:status]}%")) if options[:status] + + rows = dataset.map do |row| + v = row.values + [ + v[:id].to_s, + v[:function_id].to_s, + out.status(short_status(v[:status])), + format_time(v[:created]), + (v[:relationship_id] || '-').to_s + ] + end + + out.table(%w[id function status created relationship], rows) + end + end + default_task :list + + desc 'show ID', 'Show task details' + def show(id) + out = formatter + with_data do + task = Legion::Data::Model::Task[id.to_i] + unless task + out.error("Task #{id} not found") + raise SystemExit, 1 + end + + v = task.values + if options[:json] + out.json(v) + return + end + + out.header("Task ##{v[:id]}") + out.spacer + out.detail({ + id: v[:id], + status: v[:status], + function_id: v[:function_id], + relationship_id: v[:relationship_id], + runner_id: v[:runner_id], + created: v[:created], + updated: v[:updated], + parent_id: v[:parent_id], + master_id: v[:master_id] + }) + + if v[:args] && !v[:args].to_s.empty? + out.spacer + out.header('Arguments') + begin + args = Legion::JSON.load(v[:args]) + out.detail(args) + rescue StandardError => e + Legion::Logging.debug("TaskCommand#show args parse failed: #{e.message}") if defined?(Legion::Logging) + puts " #{v[:args]}" + end + end + end + end + + desc 'logs ID', 'Show task execution logs' + option :limit, type: :numeric, default: 50, aliases: ['-n'], desc: 'Number of log entries' + def logs(id) + out = formatter + with_data do + rows = Legion::Data::Model::TaskLog + .where(task_id: id.to_i) + .order(Sequel.desc(:id)) + .limit(options[:limit]) + .map do |row| + v = row.values + [ + v[:id].to_s, + (v[:node_id] || '-').to_s, + format_time(v[:created]), + v[:entry].to_s + ] + end + + if rows.empty? + out.warn("No logs found for task #{id}") + else + out.table(%w[id node created entry], rows) + end + end + end + + desc 'trigger FUNCTION', 'Trigger a task directly' + long_desc <<~DESC + Run a function directly by specifying it as extension.runner.function + or interactively select from available options. + + Examples: + legion task run http.request.get url:https://example.com + legion task run --extension http --runner request --function get + legion task run (interactive mode) + DESC + option :extension, type: :string, aliases: ['-e'], desc: 'Extension name' + option :runner, type: :string, aliases: ['-r'], desc: 'Runner name' + option :function, type: :string, aliases: ['-f'], desc: 'Function name' + option :delay, type: :numeric, default: 0, desc: 'Delay execution by N seconds' + map 'run' => :trigger + def trigger(function_spec = nil, *args) + out = formatter + with_data do + with_transport do + target = resolve_target(function_spec, out) + payload = parse_args(args, target[:function_args], out) + + result = execute_task(target, payload, out) + + if options[:json] + out.json(result) + else + out.spacer + out.success("Task #{result[:task_id]} #{result[:status]}") + end + end + end + end + + desc 'purge', 'Delete old tasks' + option :days, type: :numeric, default: 7, desc: 'Keep tasks newer than N days' + option :confirm, type: :boolean, default: false, aliases: ['-y'], desc: 'Skip confirmation' + def purge + out = formatter + with_data do + cutoff = DateTime.now - options[:days] + dataset = Legion::Data::Model::Task.where { created < cutoff } + count = dataset.count + + if count.zero? + out.success('No tasks to purge') + return + end + + unless options[:confirm] + out.warn("This will delete #{count} tasks older than #{options[:days]} days") + print ' Continue? [y/N] ' + response = $stdin.gets&.chomp + unless response&.downcase == 'y' + out.warn('Aborted') + return + end + end + + dataset.delete + out.success("Purged #{count} tasks") + end + end + + no_commands do # rubocop:disable Metrics/BlockLength + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def with_data + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_data + yield + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + + def with_transport + Connection.ensure_transport + yield + end + + def short_status(status) + return status unless status.is_a?(String) + + status.sub('task.', '') + end + + def format_time(time) + return '-' if time.nil? + + time.strftime('%Y-%m-%d %H:%M:%S') + rescue StandardError => e + Legion::Logging.debug("TaskCommand#format_time failed: #{e.message}") if defined?(Legion::Logging) + time.to_s + end + + def resolve_target(function_spec, out) # rubocop:disable Metrics/AbcSize,Metrics/PerceivedComplexity + # Parse dot-notation: extension.runner.function + if function_spec&.include?('.') + parts = function_spec.split('.') + ext_name = parts[0] + runner_name = parts[1] + func_name = parts[2] + else + ext_name = options[:extension] || function_spec + runner_name = options[:runner] + func_name = options[:function] + end + + # Interactive fallback for extension + if ext_name.nil? + extensions = Legion::Data::Model::Extension.map(:name) + out.header('Available Extensions') + extensions.each_with_index { |e, i| puts " #{i + 1}. #{e}" } + print ' Select extension: ' + choice = $stdin.gets&.chomp + ext_name = choice.match?(/^\d+$/) ? extensions[choice.to_i - 1] : choice + end + + extension = Legion::Data::Model::Extension.where(name: ext_name).first + raise CLI::Error, "Extension '#{ext_name}' not found in database" unless extension + + # Resolve runner + runners = Legion::Data::Model::Runner.where(extension_id: extension.values[:id]) + if runner_name + trigger_runner = runners.where(name: runner_name).first + elsif runners.one? + trigger_runner = runners.first + out.success("Auto-selected runner: #{trigger_runner.values[:name]}") unless options[:json] + else + out.header('Available Runners') + runners.each_with_index { |r, i| puts " #{i + 1}. #{r.values[:name]}" } + print ' Select runner: ' + choice = $stdin.gets&.chomp + runner_name = choice.match?(/^\d+$/) ? runners.all[choice.to_i - 1].values[:name] : choice + trigger_runner = runners.where(name: runner_name).first + end + raise CLI::Error, "Runner '#{runner_name}' not found" unless trigger_runner + + # Resolve function + functions = Legion::Data::Model::Function.where(runner_id: trigger_runner.values[:id]) + if func_name + trigger_function = functions.where(name: func_name).first + elsif functions.one? + trigger_function = functions.first + out.success("Auto-selected function: #{trigger_function.values[:name]}") unless options[:json] + else + out.header('Available Functions') + functions.each_with_index { |f, i| puts " #{i + 1}. #{f.values[:name]}" } + print ' Select function: ' + choice = $stdin.gets&.chomp + func_name = choice.match?(/^\d+$/) ? functions.all[choice.to_i - 1].values[:name] : choice + trigger_function = functions.where(name: func_name).first + end + raise CLI::Error, "Function '#{func_name}' not found" unless trigger_function + + function_args = begin + Legion::JSON.load(trigger_function.values[:args]) + rescue StandardError => e + Legion::Logging.warn("TaskCommand#resolve_target failed to parse function args: #{e.message}") if defined?(Legion::Logging) + {} + end + + { + extension: extension, + runner: trigger_runner, + function: trigger_function, + function_args: function_args + } + end + + def parse_args(cli_args, function_args, _out) + payload = {} + + # Parse key:value pairs from command line + inline = {} + cli_args.each do |arg| + key, value = arg.split(':', 2) + inline[key.to_sym] = value if key && value + end + + function_args.each do |arg_name, required| + next if %w[args payload opts options].include?(arg_name.to_s) + + if inline.key?(arg_name.to_sym) + payload[arg_name.to_sym] = inline[arg_name.to_sym] + next + end + + next if options[:json] # interactive mode + + req_label = required == 'keyreq' ? '(required)' : '(optional)' + print " #{arg_name} #{req_label}: " + response = $stdin.gets&.chomp + + if response.nil? || response.empty? + raise CLI::Error, "#{arg_name} is required" if required == 'keyreq' + + next + end + payload[arg_name.to_sym] = response + end + + payload + end + + def execute_task(target, payload, _out) + include Legion::Extensions::Helpers::Task + + ext = target[:extension] + runner = target[:runner] + func = target[:function] + + status = options[:delay].positive? ? 'task.delayed' : 'task.queued' + task = generate_task_id( + function_id: func.values[:id], + status: status, + runner_id: runner.values[:id], + args: payload, + delay: options[:delay] + ) + + return { task_id: task[:task_id], status: 'delayed', delay: options[:delay] } if options[:delay].positive? + + routing_key = "#{ext.values[:exchange]}.#{runner.values[:queue]}.#{func.values[:name]}" + message = Legion::Transport::Messages::Dynamic.new( + function: func.values[:name], + function_id: func.values[:id], + routing_key: routing_key, + args: payload + ) + message.options[:task_id] = task[:task_id] + message.publish + + { task_id: task[:task_id], status: 'queued', routing_key: routing_key } + end + end + end + end +end diff --git a/lib/legion/cli/team_command.rb b/lib/legion/cli/team_command.rb new file mode 100644 index 00000000..d335d6de --- /dev/null +++ b/lib/legion/cli/team_command.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Team < Thor + def self.exit_on_failure? + true + end + + desc 'list', 'List all teams' + def list + require 'legion/settings' + require 'legion/team' + teams = Legion::Team.list + if teams.empty? + say 'No teams configured.', :yellow + return + end + say 'Teams', :green + say '-' * 20 + teams.each { |t| say " #{t}" } + end + + desc 'show TEAM', 'Show team details and members' + def show(name) + require 'legion/settings' + require 'legion/team' + team = Legion::Team.find(name) + if team.nil? + say "Team '#{name}' not found.", :red + return + end + say "Team: #{name}", :green + say '-' * 20 + members = team[:members] || [] + if members.empty? + say ' No members.' + else + members.each { |m| say " #{m}" } + end + end + + desc 'current', 'Show the current active team' + def current + require 'legion/settings' + require 'legion/team' + say Legion::Team.current + end + + desc 'set TEAM', 'Set the active team in settings' + def set(name) + require 'legion/settings' + require 'legion/team' + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + Legion::Settings.loader.settings[:team] ||= {} + Legion::Settings.loader.settings[:team][:name] = name + say "Active team set to '#{name}'.", :green + end + + desc 'create TEAM', 'Create a new team' + def create(name) + require 'legion/settings' + require 'legion/team' + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + teams = Legion::Settings.loader.settings[:teams] || {} + if teams.key?(name.to_sym) + say "Team '#{name}' already exists.", :yellow + return + end + teams[name.to_sym] = { name: name, members: [] } + Legion::Settings.loader.settings[:teams] = teams + say "Team '#{name}' created.", :green + end + + desc 'add-member TEAM USER', 'Add a member to a team' + map 'add-member' => :add_member + def add_member(team_name, user) + require 'legion/settings' + require 'legion/team' + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + teams = Legion::Settings.loader.settings[:teams] || {} + sym = team_name.to_sym + unless teams.key?(sym) + say "Team '#{team_name}' not found.", :red + return + end + teams[sym][:members] ||= [] + if teams[sym][:members].include?(user) + say "#{user} is already a member of '#{team_name}'.", :yellow + return + end + teams[sym][:members] << user + Legion::Settings.loader.settings[:teams] = teams + say "Added #{user} to team '#{team_name}'.", :green + end + end + end +end diff --git a/lib/legion/cli/telemetry_command.rb b/lib/legion/cli/telemetry_command.rb new file mode 100644 index 00000000..760f9e8a --- /dev/null +++ b/lib/legion/cli/telemetry_command.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Legion + module CLI + class Telemetry < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'stats [SESSION_ID]', 'Show telemetry stats (aggregate or per-session)' + def stats(session_id = nil) + out = formatter + runner = telemetry_runner + + result = if session_id + runner.session_stats(session_id: session_id) + else + runner.aggregate_stats + end + + if options[:json] + out.json(result) + elsif result[:success] + out.header(session_id ? "Session: #{session_id}" : 'Aggregate Telemetry Stats') + display_stats(out, result[:stats]) + else + out.error("Error: #{result[:error]}") + end + end + default_task :stats + + desc 'ingest PATH', 'Manually ingest a session log file' + def ingest(path) + out = formatter + runner = telemetry_runner + result = runner.ingest_session(file_path: path) + + if options[:json] + out.json(result) + elsif result[:success] + out.success("Ingested #{result[:event_count]} events from #{path}") + out.detail({ session_id: result[:session_id], events: result[:event_count] }) + else + out.error("Error: #{result[:error]}") + end + end + + desc 'status', 'Show telemetry buffer health and publisher state' + def status + out = formatter + runner = telemetry_runner + result = runner.telemetry_status + + if options[:json] + out.json(result) + elsif result[:success] + out.header('Telemetry Status') + out.detail({ + 'Buffer Size' => result[:buffer_size].to_s, + 'Pending' => result[:pending_count].to_s, + 'Sessions' => result[:session_count].to_s, + 'Parsers' => result[:parsers].join(', ') + }) + else + out.error("Error: #{result[:error]}") + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def telemetry_runner + require 'legion/extensions/telemetry/runners/telemetry' + Legion::Extensions::Telemetry::Runners::Telemetry + end + + def display_stats(out, stats) + return unless stats + + stats.each do |key, value| + case value + when Hash + out.spacer + out.header(key.to_s) + value.each { |k, v| puts " #{k}: #{v}" } + else + puts " #{key}: #{value}" + end + end + end + end + end + end +end diff --git a/lib/legion/cli/templates/core.json.erb b/lib/legion/cli/templates/core.json.erb new file mode 100644 index 00000000..f01e847e --- /dev/null +++ b/lib/legion/cli/templates/core.json.erb @@ -0,0 +1,14 @@ +{ + "transport": { + "type": "<%= options[:local] ? 'local' : 'amqp' %>", + "host": "<%= options[:rabbitmq_host] || 'localhost' %>", + "port": 5672 + }, + "data": { + "adapter": "<%= options[:local] ? 'sqlite' : (options[:db_adapter] || 'sqlite') %>", + "database": "<%= options[:local] ? '~/.legionio/dev.sqlite3' : (options[:db_name] || '~/.legionio/legion.sqlite3') %>" + }, + "cache": { + "type": "<%= options[:local] ? 'local' : (options[:redis] ? 'redis' : 'local') %>" + } +} diff --git a/lib/legion/cli/theme.rb b/lib/legion/cli/theme.rb new file mode 100644 index 00000000..d5399072 --- /dev/null +++ b/lib/legion/cli/theme.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Legion + module CLI + module Theme + # LegionIO canonical palette: 17 shades, one hue, no exceptions. + # Sourced from legion_colors.html — the official color system. + PALETTE = { + void: [7, 6, 15], + background: [14, 13, 26], + deep: [18, 16, 41], + core_shell: [24, 22, 58], + glow_center: [26, 22, 64], + guide_rings: [30, 28, 58], + core_mid: [33, 30, 80], + skip: [42, 39, 96], + inner_tier: [49, 46, 128], + mid_arcs: [61, 56, 138], + diagonal_nodes: [74, 68, 168], + cardinal: [95, 87, 196], + mid_nodes: [127, 119, 221], + inner_nodes: [139, 131, 230], + innermost: [160, 154, 232], + near_white: [184, 178, 239], + self_point: [197, 194, 245] + }.freeze + + RESET = "\e[0m" + BOLD = "\e[1m" + DIM = "\e[2m" + + def self.fg(red, green, blue) + "\e[38;2;#{red};#{green};#{blue}m" + end + + def self.c(name) + rgb = PALETTE[name] + return '' unless rgb + + fg(*rgb) + end + + # ── Banner ────────────────────────────────────────── + + B = "\u2588" + LOGO = [ + "#{B} #{B * 5} #{B * 5} #{B * 2} #{B * 5} #{B} #{B}", + "#{B} #{B} #{B} #{B * 2} #{B} #{B} #{B * 2} #{B}", + "#{B} #{B * 4} #{B} #{B * 3} #{B * 2} #{B} #{B} #{B} #{B} #{B}", + "#{B} #{B} #{B} #{B} #{B * 2} #{B} #{B} #{B} #{B * 2}", + "#{B * 5} #{B * 5} #{B * 5} #{B * 2} #{B * 5} #{B} #{B}" + ].freeze + + LOGO_GRADIENT = %i[cardinal mid_nodes self_point mid_nodes cardinal].freeze + + PAD = ' ' + + def self.render_banner(version: nil, color: true) + return plain_banner(version: version) unless color + + lines = [] + lines << "#{PAD}#{c(:mid_arcs)}\u00b7 #{c(:inner_tier)}#{'─' * 43} #{c(:mid_arcs)}\u00b7#{RESET}" + lines << "#{PAD}#{c(:inner_tier)}╭#{'─' * 45}╮#{RESET}" + lines << "#{PAD}#{c(:inner_tier)}│#{c(:cardinal)} \u00b7#{' ' * 39}\u00b7 #{c(:inner_tier)}│#{RESET}" + + LOGO.each_with_index do |row, i| + lc = c(LOGO_GRADIENT[i]) + lines << "#{PAD}#{c(:inner_tier)}│#{lc} #{row} #{c(:inner_tier)}│#{RESET}" + end + + lines << "#{PAD}#{c(:inner_tier)}│#{c(:cardinal)} \u00b7#{' ' * 39}\u00b7 #{c(:inner_tier)}│#{RESET}" + lines << "#{PAD}#{c(:inner_tier)}╰#{'─' * 45}╯#{RESET}" + lines << "#{PAD}#{c(:mid_arcs)}\u00b7 #{c(:inner_tier)}#{'─' * 43} #{c(:mid_arcs)}\u00b7#{RESET}" + + if version + lines << '' + lines << "#{PAD} #{c(:mid_nodes)}Async Job Engine & Extension Ecosystem#{RESET}" + lines << "#{PAD} #{c(:diagonal_nodes)}v#{version}#{RESET}" + end + + lines.join("\n") + end + + def self.plain_banner(version: nil) + lines = [] + lines << "#{PAD}\u00b7 #{'─' * 43} \u00b7" + lines << "#{PAD}╭#{'─' * 45}╮" + lines << "#{PAD}│ \u00b7#{' ' * 39}\u00b7 │" + LOGO.each { |row| lines << "#{PAD}│ #{row} │" } + lines << "#{PAD}│ \u00b7#{' ' * 39}\u00b7 │" + lines << "#{PAD}╰#{'─' * 45}╯" + lines << "#{PAD}\u00b7 #{'─' * 43} \u00b7" + if version + lines << '' + lines << "#{PAD} Async Job Engine & Extension Ecosystem" + lines << "#{PAD} v#{version}" + end + lines.join("\n") + end + + # ── Decorative helpers ────────────────────────────── + + def self.divider(width = 50, color_enabled: true) + return "\u2500" * width unless color_enabled + + "#{c(:inner_tier)}#{"\u2500" * width}#{RESET}" + end + + def self.orbital_header(text, color_enabled: true) + return "── #{text} ──" unless color_enabled + + "#{c(:inner_tier)}── #{BOLD}#{c(:near_white)}#{text}#{RESET} #{c(:inner_tier)}──#{RESET}" + end + end + end +end diff --git a/lib/legion/cli/trace_command.rb b/lib/legion/cli/trace_command.rb new file mode 100644 index 00000000..7fb17a94 --- /dev/null +++ b/lib/legion/cli/trace_command.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' +require 'legion/cli/connection' + +module Legion + module CLI + class TraceCommand < Thor + namespace 'trace' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'search QUERY', 'Search traces with natural language' + option :limit, type: :numeric, default: 50, desc: 'Max results to return' + def search(*query_parts) + return unless setup_connection + + require 'legion/trace_search' + query = query_parts.join(' ') + out = formatter + + out.header('Trace Search') + puts " Query: #{query}" + out.spacer + + result = Legion::TraceSearch.search(query, limit: options[:limit]) + if result[:error] + out.error("Search failed: #{result[:error]}") + return + end + + if options[:json] + out.json(result) + return + end + + display_results(out, result) + ensure + Legion::CLI::Connection.shutdown + end + + desc 'summarize QUERY', 'Show aggregate statistics for matching traces' + def summarize(*query_parts) + return unless setup_connection + + require 'legion/trace_search' + query = query_parts.join(' ') + out = formatter + + out.header('Trace Summary') + puts " Query: #{query}" + out.spacer + + result = Legion::TraceSearch.summarize(query) + if result[:error] + out.error("Summary failed: #{result[:error]}") + return + end + + if options[:json] + out.json(result) + return + end + + display_summary(out, result) + ensure + Legion::CLI::Connection.shutdown + end + + default_task :search + + no_commands do + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + + def setup_connection + Legion::CLI::Connection.config_dir = options[:config_dir] if options[:config_dir] + Legion::CLI::Connection.log_level = options[:verbose] ? 'debug' : 'error' + Legion::CLI::Connection.ensure_llm + Legion::CLI::Connection.ensure_data + true + rescue CLI::Error => e + formatter.error("Setup failed: #{e.message}") + false + end + + private + + def display_results(out, result) + total = result[:total] || result[:count] || 0 + shown = result[:results]&.size || 0 + truncated = result[:truncated] ? ' (truncated)' : '' + + out.success("#{shown} of #{total} results#{truncated}") + + if result[:filter] + puts " Filter: #{result[:filter].inspect}" + out.spacer + end + + return puts(' No results found.') if result[:results].nil? || result[:results].empty? + + result[:results].each_with_index do |row, idx| + display_row(out, row, idx) + end + end + + def display_summary(out, result) + out.detail({ + 'Total Records' => result[:total_records].to_s, + 'Total Tokens In' => result[:total_tokens_in].to_s, + 'Total Tokens Out' => result[:total_tokens_out].to_s, + 'Total Cost' => format('$%.4f', result[:total_cost]), + 'Avg Latency' => "#{result[:avg_latency_ms]}ms", + 'Max Latency' => "#{result[:max_latency_ms]}ms" + }) + + if result[:time_range][:from] + out.spacer + puts " Time range: #{result[:time_range][:from]} to #{result[:time_range][:to]}" + end + + if result[:status_counts].any? + out.spacer + out.header('Status Breakdown') + result[:status_counts].each { |status, count| puts " #{status}: #{count}" } + end + + if result[:top_extensions].any? + out.spacer + out.header('Top Extensions') + result[:top_extensions].each { |e| puts " #{e[:name]}: #{e[:count]}" } + end + + return unless result[:top_workers].any? + + out.spacer + out.header('Top Workers') + result[:top_workers].each { |w| puts " #{w[:id]}: #{w[:count]}" } + end + + def display_row(out, row, idx) + ts = row[:created_at]&.strftime('%Y-%m-%d %H:%M:%S') || '?' + ext = row[:extension] || '?' + func = row[:runner_function] || '?' + status = row[:status] || '?' + cost = format('$%.4f', row[:cost_usd] || 0) + tokens = "#{row[:tokens_in] || 0}in/#{row[:tokens_out] || 0}out" + wall = row[:wall_clock_ms] ? "#{row[:wall_clock_ms]}ms" : nil + + line = " #{idx + 1}. [#{ts}] #{ext}.#{func}" + puts line + detail = " status: #{status} | cost: #{cost} | tokens: #{tokens}" + detail += " | #{wall}" if wall + detail += " | worker: #{row[:worker_id]}" if row[:worker_id] + puts out.colorize(detail, status == 'success' ? :success : :warn) + end + end + end + end +end diff --git a/lib/legion/cli/trigger.rb b/lib/legion/cli/trigger.rb index 47378246..492f3233 100755 --- a/lib/legion/cli/trigger.rb +++ b/lib/legion/cli/trigger.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion class Cli class Trigger < Thor @@ -6,9 +8,10 @@ class Trigger < Thor option :runner, type: :string, required: false, desc: 'runner short name' option :function, type: :string, required: false, desc: 'function short name' option :delay, type: :numeric, default: 0, desc: 'how long to wait before running the task' - def queue(*args) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/MethodLength + def queue(*args) # rubocop:disable Metrics/AbcSize Legion::Service.new(cache: false, crypt: false, extensions: false, log_level: 'error') include Legion::Extensions::Helpers::Task + response = if options['extension'].is_a? String options[:extension] else @@ -16,7 +19,7 @@ def queue(*args) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity, end trigger_extension = Legion::Data::Model::Extension.where(name: response).first runners = Legion::Data::Model::Runner.where(extension_id: trigger_extension.values[:id]) - if runners.count == 1 + if runners.one? trigger_runner = runners.first say "Auto selecting #{trigger_runner.values[:name]} since it is the only option for runners" else @@ -26,7 +29,7 @@ def queue(*args) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity, functions = Legion::Data::Model::Function.where(runner_id: trigger_runner.values[:id]) - if functions.count == 1 + if functions.one? trigger_function = functions.first say "Auto selecting #{trigger_function.values[:name]} since it is the only option for functions" else @@ -41,7 +44,7 @@ def queue(*args) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity, say "#{trigger_runner.values[:namespace]}.#{trigger_function.values[:name]} selected as trigger", :green, :italicized payload = {} auto_opts = {} - unless args.count.zero? + unless args.none? args.each do |arg| test = arg.split(':') auto_opts[test[0].to_sym] = test[1] diff --git a/lib/legion/cli/tty_command.rb b/lib/legion/cli/tty_command.rb new file mode 100644 index 00000000..465c0975 --- /dev/null +++ b/lib/legion/cli/tty_command.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'thor' +require 'legion/cli/output' + +module Legion + module CLI + class Tty < Thor + def self.exit_on_failure? + true + end + + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :config_dir, type: :string, desc: 'Config directory (~/.legionio/settings)' + class_option :skip_rain, type: :boolean, default: false, desc: 'Skip the digital rain intro' + + default_task :interactive + + desc 'interactive', 'Launch the rich terminal UI (default)' + long_desc <<~DESC + Launches the Legion TTY - a rich terminal interface with: + - Onboarding wizard (first run) + - AI chat shell with streaming responses + - Operational dashboard (Ctrl+D or /dashboard) + - Session persistence across runs + + Similar to tools like Claude Code (CLI) and OpenAI Codex, + but purpose-built for LegionIO's async cognition engine. + + First run: walks you through identity detection (Kerberos/GitHub), + provider selection, and API key setup. + + Subsequent runs: loads saved identity, re-scans environment, + and drops straight into the chat shell. + DESC + def interactive + require_tty_gem + config_dir = options[:config_dir] || Legion::TTY::App::CONFIG_DIR + app = Legion::TTY::App.new(config_dir: config_dir) + app.start + rescue Interrupt + Legion::Logging.debug('TtyCommand#interactive interrupted by user') if defined?(Legion::Logging) + app&.shutdown + end + + desc 'reset', 'Clear saved identity and credentials (re-run onboarding)' + option :confirm, type: :boolean, default: false, aliases: ['-y'], desc: 'Skip confirmation' + def reset + out = formatter + config_dir = options[:config_dir] || File.expand_path('~/.legionio/settings') + + identity = File.join(config_dir, 'identity.json') + credentials = File.join(config_dir, 'credentials.json') + + unless options[:confirm] + out.warn('This will delete your saved identity and credentials.') + out.warn('You will need to re-run onboarding.') + require 'tty-prompt' + prompt = ::TTY::Prompt.new + return unless prompt.yes?('Continue?') + end + + [identity, credentials].each do |path| + if File.exist?(path) + File.delete(path) + out.success("Deleted #{File.basename(path)}") + end + end + end + + desc 'sessions', 'List saved chat sessions' + def sessions + out = formatter + require_tty_gem + + store = Legion::TTY::SessionStore.new + list = store.list + + if list.empty? + out.detail('No saved sessions.') + return + end + + list.each do |session| + name = session[:name] + count = session[:message_count] + saved = session[:saved_at] || 'unknown' + puts " #{name.ljust(30)} #{count} messages #{saved}" + end + end + + desc 'version', 'Show legion-tty version' + def version + require_tty_gem + puts "legion-tty #{Legion::TTY::VERSION}" + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: false, + color: !options[:no_color] + ) + end + + private + + def require_tty_gem + require 'legion/tty' + rescue LoadError => e + formatter.error("legion-tty gem not installed: #{e.message}") + formatter.detail('Install with: gem install legion-tty') + raise SystemExit, 1 + end + end + end + end +end diff --git a/lib/legion/cli/update_command.rb b/lib/legion/cli/update_command.rb new file mode 100644 index 00000000..f29017ab --- /dev/null +++ b/lib/legion/cli/update_command.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require 'English' +require 'thor' +require 'rbconfig' +require 'rubygems/uninstaller' +require 'legion/extensions/gem_source' + +module Legion + module CLI + class Update < Thor + namespace 'update' + + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + + desc 'gems', 'Update Legion gems to latest versions (default)' + default_task :gems + option :dry_run, type: :boolean, default: false, desc: 'Show what would be updated without installing' + option :cleanup, type: :boolean, default: false, desc: 'Remove old gem versions after update' + def gems + out = formatter + gem_bin = File.join(RbConfig::CONFIG['bindir'], 'gem') + + unless File.executable?(gem_bin) + out.error("Gem binary not found at #{gem_bin}") + raise SystemExit, 1 + end + + Connection.ensure_settings(resolve_secrets: false) + Legion::Extensions::GemSource.setup! + + target_gems = discover_legion_gems + out.header('Checking for updates') unless options[:json] + + before = snapshot_versions(target_gems) + results = update_gems(target_gems, gem_bin, dry_run: options[:dry_run]) + Gem::Specification.reset unless options[:dry_run] + after = options[:dry_run] ? before : snapshot_versions(target_gems) + + if options[:json] + out.json(gems: results, dry_run: options[:dry_run]) + else + display_results(out, results, before, after) + end + + cleanup_old_gems(out, target_gems) if options[:cleanup] && !options[:dry_run] + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + private + + def discover_legion_gems + gems = ['legionio'] + Gem::Specification.each do |spec| + gems << spec.name if spec.name.start_with?('legion-') || spec.name.start_with?('lex-') + end + gems.uniq.sort + end + + def snapshot_versions(gem_names) + gem_names.each_with_object({}) do |name, hash| + specs = Gem::Specification.find_all_by_name(name) + hash[name] = if specs.empty? + nil + else + specs.map(&:version).max.to_s + end + end + end + + def update_gems(gem_names, gem_bin, dry_run: false) + local_versions = snapshot_versions(gem_names) + outdated_map = fetch_outdated(gem_bin, gem_names) + + results = gem_names.map do |name| + info = outdated_map[name] + if info + { name: name, from: local_versions[name], to: info[:remote], status: dry_run ? 'available' : 'pending' } + else + { name: name, from: local_versions[name], status: 'current' } + end + end + + return results if dry_run + + pending = results.select { |r| r[:status] == 'pending' } + return results.each { |r| r[:status] = 'current' if r[:status] == 'pending' } if pending.empty? + + install_outdated(gem_bin, pending, results) + end + + def fetch_outdated(gem_bin, gem_names) + output = `#{gem_bin} outdated 2>&1` + return {} unless $CHILD_STATUS.success? + + parse_outdated(output, gem_names) + end + + def parse_outdated(output, gem_names) + allowed = gem_names.to_set + output.each_line.with_object({}) do |line, map| + match = line.match(/^(\S+) \((\S+) < (\S+)\)/) + next unless match && allowed.include?(match[1]) + + map[match[1]] = { local: match[2], remote: match[3] } + end + end + + def install_outdated(gem_bin, pending, results) + names = pending.map { |r| r[:name] } + source_args = Legion::Extensions::GemSource.source_args_for_cli + `#{gem_bin} install #{names.join(' ')} --no-document #{source_args} 2>&1` + success = $CHILD_STATUS.success? + pending_set = names.to_set + results.each do |r| + r[:status] = if pending_set.include?(r[:name]) + success ? 'installed' : 'failed' + else + 'current' + end + end + results + end + + def display_results(out, results, before, after) + updated = [] + failed = [] + + results.each do |r| + name = r[:name] + case r[:status] + when 'available' + puts " #{name}: #{r[:from]} -> #{r[:to]}" + updated << name + when 'current' + puts " #{name}: #{r[:from] || before[name] || '?'} (already latest)" + when 'installed' + old_v = before[name] + new_v = after[name] + if old_v == new_v + out.error(" #{name}: #{old_v} (install may have failed)") + failed << name + else + out.success(" #{name}: #{old_v} -> #{new_v}") + updated << name + end + when 'failed' + out.error(" #{name}: update failed") + failed << name + end + end + + out.spacer + if updated.any? + out.success("Updated #{updated.size} gem(s)") + else + puts 'All gems are up to date' + end + out.error("#{failed.size} gem(s) failed to update") if failed.any? + + suggest_detect(out) + end + + def cleanup_old_gems(out, gem_names) + Gem::Specification.reset + cleaned = 0 + + gem_names.each do |name| + specs = Gem::Specification.find_all_by_name(name).sort_by(&:version) + next if specs.size <= 1 + + latest = specs.pop + specs.each do |old_spec| + Gem::Uninstaller.new( + old_spec.name, + version: old_spec.version, + ignore: true, + executables: false, + force: true, + abort_on_dependent: false + ).uninstall + out.success(" Cleaned #{old_spec.name}-#{old_spec.version} (keeping #{latest.version})") + cleaned += 1 + rescue StandardError => e + out.error(" Failed to clean #{old_spec.name}-#{old_spec.version}: #{e.message}") + end + end + + out.spacer + if cleaned.positive? + out.success("Cleaned #{cleaned} old gem version(s)") + else + puts 'No old gem versions to clean' + end + end + + def suggest_detect(out) + require 'legion/extensions/detect' + missing = Legion::Extensions::Detect.missing + return if missing.empty? + + out.spacer + puts " #{missing.size} new extension(s) recommended based on your environment:" + missing.each { |name| puts " gem install #{name}" } + puts " Run 'legionio detect --install' to install them" + rescue LoadError => e + Legion::Logging.debug("UpdateCommand#suggest_detect lex-detect not available: #{e.message}") if defined?(Legion::Logging) + nil + end + end + end + end +end diff --git a/lib/legion/cli/version.rb b/lib/legion/cli/version.rb index d39c0ddb..c09b751e 100755 --- a/lib/legion/cli/version.rb +++ b/lib/legion/cli/version.rb @@ -1,5 +1,8 @@ +# frozen_string_literal: true + module Legion - class Cli - VERSION = '0.2.0'.freeze + module CLI + # CLI version tracks the main gem version + VERSION = Legion::VERSION end end diff --git a/lib/legion/cli/worker_command.rb b/lib/legion/cli/worker_command.rb new file mode 100644 index 00000000..408de9ea --- /dev/null +++ b/lib/legion/cli/worker_command.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + module CLI + class Worker < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'list', 'List digital workers' + option :team, type: :string, desc: 'Filter by team' + option :owner, type: :string, desc: 'Filter by owner MSID' + option :state, type: :string, desc: 'Filter by lifecycle state' + option :limit, type: :numeric, default: 20, desc: 'Max results' + def list + out = formatter + with_data do + ds = Legion::Data::Model::DigitalWorker.dataset + + ds = ds.where(team: options[:team]) if options[:team] + ds = ds.where(owner_msid: options[:owner]) if options[:owner] + ds = ds.where(lifecycle_state: options[:state]) if options[:state] + + workers = ds.limit(options[:limit]).all + + if options[:json] + out.json(workers.map(&:to_hash)) + else + rows = workers.map do |w| + [w.worker_id[0..7], w.name, out.status(w.lifecycle_state), w.consent_tier, w.owner_msid, w.team || '-'] + end + out.table(%w[ID Name State Consent Owner Team], rows) + puts " #{workers.size} worker(s)" + end + end + end + default_task :list + + desc 'show WORKER_ID', 'Show digital worker details' + def show(worker_id) + out = formatter + with_data do + worker = find_worker(worker_id) + + unless worker + out.error("Worker not found: #{worker_id}") + return + end + + if options[:json] + out.json(worker.to_hash) + else + out.header("Worker: #{worker.name}") + out.spacer + out.detail({ + 'Worker ID' => worker.worker_id, + 'Name' => worker.name, + 'Extension' => worker.extension_name, + 'Entra App ID' => worker.entra_app_id, + 'Owner MSID' => worker.owner_msid, + 'Owner Name' => worker.owner_name || '-', + 'Lifecycle State' => worker.lifecycle_state, + 'Consent Tier' => worker.consent_tier, + 'Trust Score' => worker.trust_score.to_s, + 'Risk Tier' => worker.risk_tier || '-', + 'Team' => worker.team || '-', + 'Manager' => worker.manager_msid || '-', + 'Created' => worker.created_at.to_s, + 'Updated' => worker.updated_at&.to_s || '-' + }) + end + end + end + + desc 'pause WORKER_ID', 'Pause a digital worker' + option :reason, type: :string, desc: 'Reason for pausing' + def pause(worker_id) + with_data { transition_worker(worker_id, 'paused', options[:reason], authority_verified: true) } + end + + desc 'retire WORKER_ID', 'Retire a digital worker' + option :reason, type: :string, desc: 'Reason for retiring' + def retire(worker_id) + with_data { transition_worker(worker_id, 'retired', options[:reason], authority_verified: true) } + end + + desc 'terminate WORKER_ID', 'Terminate a digital worker (irreversible)' + option :reason, type: :string, desc: 'Reason for termination' + option :yes, type: :boolean, default: false, aliases: '-y', desc: 'Skip confirmation' + def terminate(worker_id) + out = formatter + unless options[:yes] + out.warn('This action is IRREVERSIBLE.') + print "Type 'yes' to confirm termination: " + return unless $stdin.gets&.strip == 'yes' + end + with_data { transition_worker(worker_id, 'terminated', options[:reason], governance_override: true) } + end + + desc 'activate WORKER_ID', 'Activate a worker (from bootstrap or paused)' + def activate(worker_id) + with_data { transition_worker(worker_id, 'active', nil, authority_verified: true) } + end + + desc 'create NAME', 'Register a new digital worker' + method_option :entra_app_id, type: :string, required: true, desc: 'Entra Application (client) ID' + method_option :owner_msid, type: :string, required: true, desc: 'Owner Microsoft ID (email)' + method_option :extension, type: :string, required: true, desc: 'Extension name (e.g., lex-github)' + method_option :team, type: :string, desc: 'Team assignment' + method_option :manager_msid, type: :string, desc: 'Manager Microsoft ID' + method_option :business_role, type: :string, desc: 'Business role description' + method_option :risk_tier, type: :string, default: 'low', desc: 'Risk tier (low/medium/high/critical)' + method_option :consent_tier, type: :string, default: 'supervised', desc: 'Consent tier' + method_option :client_secret, type: :string, desc: 'Entra app client secret (stored in Vault)' + def create(name) + with_data { create_worker(name) } + end + + desc 'approvals', 'List workers pending AIRB approval' + def approvals + out = formatter + with_data do + require 'legion/digital_worker/registration' + workers = Legion::DigitalWorker::Registration.pending_approvals + + if options[:json] + out.json(workers.map(&:to_hash)) + else + rows = workers.map do |w| + age = w.created_at ? "#{((Time.now.utc - w.created_at) / 3600).round(1)}h" : '-' + [w.worker_id[0..7], w.name, w.risk_tier || '-', w.owner_msid, age] + end + out.table(%w[ID Name RiskTier Owner PendingFor], rows) + puts " #{workers.size} worker(s) pending approval" + end + end + end + + desc 'approve WORKER_ID', 'Approve a worker registration' + option :notes, type: :string, desc: 'Approval notes' + def approve(worker_id) + out = formatter + with_data do + require 'legion/digital_worker/registration' + worker = Legion::DigitalWorker::Registration.approve(worker_id, approver: 'cli', notes: options[:notes]) + if options[:json] + out.json({ worker_id: worker.worker_id, lifecycle_state: worker.lifecycle_state, approved: true }) + else + out.success("Worker #{worker.name} approved and activated") + end + rescue ArgumentError => e + out.error(e.message) + rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e + out.error("Invalid transition: #{e.message}") + end + end + + desc 'reject WORKER_ID', 'Reject a worker registration' + option :reason, type: :string, required: true, desc: 'Rejection reason' + def reject(worker_id) + out = formatter + with_data do + require 'legion/digital_worker/registration' + unless options[:reason] + out.error('--reason is required to reject a worker') + return + end + worker = Legion::DigitalWorker::Registration.reject(worker_id, approver: 'cli', reason: options[:reason]) + if options[:json] + out.json({ worker_id: worker.worker_id, lifecycle_state: worker.lifecycle_state, rejected: true }) + else + out.success("Worker #{worker.name} rejected") + end + rescue ArgumentError => e + out.error(e.message) + rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e + out.error("Invalid transition: #{e.message}") + end + end + + desc 'costs WORKER_ID', 'Show cost summary for a worker' + option :period, type: :string, default: 'weekly', desc: 'Period: daily, weekly, monthly' + def costs(worker_id) + out = formatter + out.warn('Cost reporting requires lex-metering extension (coming soon)') + out.warn("Worker: #{worker_id}, Period: #{options[:period]}") + end + + no_commands do # rubocop:disable Metrics/BlockLength + def formatter + @formatter ||= Output::Formatter.new( + json: options[:json], + color: !options[:no_color] + ) + end + + def with_data + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = options[:verbose] ? 'debug' : 'error' + Connection.ensure_data + yield + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + + def find_worker(worker_id) + Legion::Data::Model::DigitalWorker.first(worker_id: worker_id) || + Legion::Data::Model::DigitalWorker.where(Sequel.like(:worker_id, "#{worker_id}%")).first + end + + def create_worker(name) + out = formatter + worker_id = SecureRandom.uuid + + attrs = { + worker_id: worker_id, + name: name, + entra_app_id: options[:entra_app_id], + owner_msid: options[:owner_msid], + extension_name: options[:extension], + lifecycle_state: 'bootstrap', + consent_tier: options[:consent_tier], + trust_score: 0.0, + created_at: Time.now.utc + } + attrs[:team] = options[:team] if options[:team] + attrs[:manager_msid] = options[:manager_msid] if options[:manager_msid] + attrs[:business_role] = options[:business_role] if options[:business_role] + attrs[:risk_tier] = options[:risk_tier] if options[:risk_tier] + + worker = Legion::Data::Model::DigitalWorker.create(attrs) + store_client_secret(out, worker_id) if options[:client_secret] + + if options[:json] + out.json(worker.to_hash) + else + out.success('Worker created successfully:') + out.spacer + out.detail({ + 'Worker ID' => worker_id, + 'Name' => name, + 'Entra App ID' => options[:entra_app_id], + 'Owner' => options[:owner_msid], + 'Extension' => options[:extension], + 'State' => 'bootstrap', + 'Consent Tier' => options[:consent_tier], + 'Risk Tier' => options[:risk_tier], + 'Team' => options[:team] || '(none)' + }) + out.spacer + out.success("Next: legion worker activate #{worker_id}") + end + rescue Sequel::UniqueConstraintViolation + out.error("A worker with entra_app_id '#{options[:entra_app_id]}' already exists.") + rescue Sequel::ValidationFailed => e + out.error(e.message) + end + + def store_client_secret(out, worker_id) + if defined?(Legion::Extensions::Identity::Helpers::VaultSecrets) && + Legion::Extensions::Identity::Helpers::VaultSecrets.send(:vault_available?) + Legion::Extensions::Identity::Helpers::VaultSecrets.store_client_secret( + worker_id: worker_id, client_secret: options[:client_secret], + entra_app_id: options[:entra_app_id] + ) + out.success('Client secret stored in Vault.') + else + out.warn('Vault not connected. Client secret was NOT stored.') + end + end + + def transition_worker(worker_id, to_state, reason, **) + out = formatter + require 'legion/digital_worker/lifecycle' + + worker = find_worker(worker_id) + + unless worker + out.error("Worker not found: #{worker_id}") + return + end + + begin + Legion::DigitalWorker::Lifecycle.transition!(worker, to_state: to_state, by: 'cli', reason: reason, **) + if options[:json] + out.json({ worker_id: worker.worker_id, lifecycle_state: to_state, transitioned: true }) + else + out.success("Worker #{worker.name} transitioned to #{to_state}") + end + rescue Legion::DigitalWorker::Lifecycle::GovernanceRequired => e + out.error("Governance approval required: #{e.message}") + rescue Legion::DigitalWorker::Lifecycle::AuthorityRequired => e + out.error("Insufficient authority/permission: #{e.message}") + rescue Legion::DigitalWorker::Lifecycle::InvalidTransition => e + out.error(e.message) + end + end + end + end + end +end diff --git a/lib/legion/cli/workflow_command.rb b/lib/legion/cli/workflow_command.rb new file mode 100644 index 00000000..f1725286 --- /dev/null +++ b/lib/legion/cli/workflow_command.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require 'thor' + +module Legion + module CLI + class Workflow < Thor + def self.exit_on_failure? + true + end + + class_option :json, type: :boolean, default: false, desc: 'Output as JSON' + class_option :no_color, type: :boolean, default: false, desc: 'Disable color output' + class_option :config_dir, type: :string, desc: 'Config directory path' + + desc 'install FILE', 'Install a workflow from a YAML manifest' + def install(file) + out = formatter + with_data do + require 'legion/workflow/manifest' + require 'legion/workflow/loader' + + unless File.exist?(file) + out.error("File not found: #{file}") + raise SystemExit, 1 + end + + manifest = Legion::Workflow::Manifest.new(path: file) + unless manifest.valid? + manifest.errors.each { |e| out.error(e) } + raise SystemExit, 1 + end + + result = Legion::Workflow::Loader.new.install(manifest) + + if result[:success] + if options[:json] + out.json(result) + else + out.success("Workflow '#{manifest.name}' installed " \ + "(chain_id=#{result[:chain_id]}, #{result[:relationship_ids].size} relationships)") + end + else + out.error("Install failed: #{result[:error]}") + raise SystemExit, 1 + end + end + end + + desc 'list', 'List installed workflows' + def list + out = formatter + with_data do + require 'legion/workflow/loader' + + workflows = Legion::Workflow::Loader.new.list + if options[:json] + out.json(workflows) + else + rows = workflows.map { |w| [w[:id].to_s, w[:name].to_s, w[:relationships].to_s] } + out.table(%w[chain_id name relationships], rows) + end + end + end + default_task :list + + desc 'uninstall NAME', 'Uninstall a workflow by name' + option :confirm, type: :boolean, default: false, aliases: ['-y'], desc: 'Skip confirmation' + def uninstall(name) + out = formatter + with_data do + require 'legion/workflow/loader' + + unless options[:confirm] + out.warn("This will delete workflow '#{name}' and all its relationships") + print ' Continue? [y/N] ' + response = $stdin.gets&.chomp + unless response&.downcase == 'y' + out.warn('Aborted') + return + end + end + + result = Legion::Workflow::Loader.new.uninstall(name) + + if result[:success] + out.success("Workflow '#{name}' uninstalled (#{result[:deleted_relationships]} relationships removed)") + else + out.error("Workflow '#{name}' not found") + raise SystemExit, 1 + end + end + end + + desc 'status NAME', 'Show workflow chain details' + def status(name) + out = formatter + with_data do + require 'legion/workflow/loader' + + result = Legion::Workflow::Loader.new.status(name) + + if result[:success] + if options[:json] + out.json(result) + else + puts "Workflow: #{result[:name]} (chain_id=#{result[:chain_id]})" + rows = result[:relationships].map do |r| + [r[:id].to_s, r[:name].to_s, r[:trigger].to_s, r[:action].to_s, + r[:conditions] ? 'yes' : 'no', r[:active] ? 'active' : 'inactive'] + end + out.table(%w[id name trigger action conditions active], rows) + end + else + out.error("Workflow '#{name}' not found") + raise SystemExit, 1 + end + end + end + + no_commands do + def formatter + @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color]) + end + + def with_data + Connection.config_dir = options[:config_dir] if options[:config_dir] + Connection.log_level = 'error' + Connection.ensure_data + yield + rescue CLI::Error => e + formatter.error(e.message) + raise SystemExit, 1 + ensure + Connection.shutdown + end + end + end + end +end diff --git a/lib/legion/cluster.rb b/lib/legion/cluster.rb new file mode 100644 index 00000000..02d61058 --- /dev/null +++ b/lib/legion/cluster.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Legion + module Cluster + autoload :Lock, 'legion/cluster/lock' + autoload :Leader, 'legion/cluster/leader' + end +end diff --git a/lib/legion/cluster/leader.rb b/lib/legion/cluster/leader.rb new file mode 100644 index 00000000..70237bfa --- /dev/null +++ b/lib/legion/cluster/leader.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Legion + module Cluster + class Leader + HEARTBEAT_INTERVAL = 10 # seconds + LOCK_NAME = 'legion_leader' + + attr_reader :node_id, :is_leader + + def initialize(node_id: SecureRandom.uuid) + @node_id = node_id + @is_leader = false + @heartbeat_thread = nil + @running = false + end + + def start + @running = true + @heartbeat_thread = Thread.new { election_loop } + end + + def stop + @running = false + @heartbeat_thread&.join(HEARTBEAT_INTERVAL + 2) + resign if @is_leader + end + + def leader? + @is_leader + end + + private + + def election_loop + while @running + attempt_election + sleep(HEARTBEAT_INTERVAL) + end + end + + def attempt_election + @is_leader = if Lock.acquire(name: LOCK_NAME) + true + else + false + end + rescue StandardError => e + Legion::Logging.warn "Leader#attempt_election failed: #{e.message}" if defined?(Legion::Logging) + @is_leader = false + end + + def resign + Lock.release(name: LOCK_NAME) if @is_leader + @is_leader = false + end + end + end +end diff --git a/lib/legion/cluster/lock.rb b/lib/legion/cluster/lock.rb new file mode 100644 index 00000000..bffdb601 --- /dev/null +++ b/lib/legion/cluster/lock.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + module Cluster + module Lock + module_function + + @tokens = if defined?(Concurrent::Map) + Concurrent::Map.new + else + {} + end + @tokens_mutex = Mutex.new unless defined?(Concurrent::Map) + + def tokens + @tokens + end + + def backend + if defined?(Legion::Cache) && + Legion::Cache.respond_to?(:const_defined?) && + Legion::Cache.const_defined?(:Redis, false) && + Legion::Cache::Redis.respond_to?(:client) && + !Legion::Cache::Redis.client.nil? + :redis + elsif defined?(Legion::Data) && + Legion::Data.respond_to?(:connection) && + !Legion::Data.connection.nil? + :postgres + else + :none + end + end + + def acquire(name:, ttl: 30, timeout: 5) # rubocop:disable Lint/UnusedMethodArgument + case backend + when :redis + acquire_redis(name: name, ttl: ttl) + when :postgres + acquire_postgres(name: name) + else + false + end + end + + def release(name:, token: nil) + case backend + when :redis + release_redis(name: name, token: token) + when :postgres + release_postgres(name: name) + else + false + end + end + + def extend_lock(name:, token: nil, ttl: 30) + case backend + when :redis + extend_lock_redis(name: name, token: token, ttl: ttl) + when :postgres + true + else + false + end + end + + def with_lock(name:, ttl: 30, timeout: 5) + acquired = acquire(name: name, ttl: ttl, timeout: timeout) + return unless acquired + + token = acquired == true ? nil : acquired + + begin + yield + ensure + release(name: name, token: token) + end + end + + def lock_key(name) + name.to_s.bytes.reduce(0) { |acc, b| ((acc * 31) + b) & 0x7FFFFFFF } + end + + def redis_key(name) + "legion:lock:#{name}" + end + + def acquire_redis(name:, ttl:) + client = Legion::Cache::Redis.client + token = SecureRandom.hex(16) + key = redis_key(name) + result = client.call('SET', key, token, 'NX', 'PX', ttl * 1000) + return nil unless result + + store_token(name, token) + token + rescue StandardError => e + Legion::Logging.debug "Lock#acquire_redis failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + nil + end + + def release_redis(name:, token:) + client = Legion::Cache::Redis.client + tok = token || fetch_token(name) + return false unless tok + + key = redis_key(name) + lua = <<~LUA + if redis.call('GET', KEYS[1]) == ARGV[1] then + redis.call('DEL', KEYS[1]) + return 1 + else + return 0 + end + LUA + result = client.call('EVAL', lua, 1, key, tok) + delete_token(name) + result == 1 + rescue StandardError => e + Legion::Logging.debug "Lock#release_redis failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + false + end + + def acquire_postgres(name:) + key = lock_key(name) + db = Legion::Data.connection + return false unless db + + db.fetch('SELECT pg_try_advisory_lock(?) AS acquired', key).first[:acquired] + rescue StandardError => e + Legion::Logging.debug "Lock#acquire_postgres failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + false + end + + def extend_lock_redis(name:, token:, ttl:) + tok = token || fetch_token(name) + return false unless tok + + client = Legion::Cache::Redis.client + key = redis_key(name) + lua = <<~LUA + if redis.call('GET', KEYS[1]) == ARGV[1] then + redis.call('PEXPIRE', KEYS[1], ARGV[2]) + return 1 + else + return 0 + end + LUA + result = client.call('EVAL', lua, 1, key, tok, (ttl * 1000).to_s) + result == 1 + rescue StandardError => e + Legion::Logging.debug "Lock#extend_lock_redis failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + false + end + + def release_postgres(name:) + key = lock_key(name) + db = Legion::Data.connection + return false unless db + + db.fetch('SELECT pg_advisory_unlock(?) AS released', key).first[:released] + rescue StandardError => e + Legion::Logging.debug "Lock#release_postgres failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + false + end + + def store_token(name, token) + if defined?(Concurrent::Map) + @tokens[name.to_s] = token + else + @tokens_mutex.synchronize { @tokens[name.to_s] = token } + end + end + + def fetch_token(name) + if defined?(Concurrent::Map) + @tokens[name.to_s] + else + @tokens_mutex.synchronize { @tokens[name.to_s] } + end + end + + def delete_token(name) + if defined?(Concurrent::Map) + @tokens.delete(name.to_s) + else + @tokens_mutex.synchronize { @tokens.delete(name.to_s) } + end + end + end + end +end diff --git a/lib/legion/compliance.rb b/lib/legion/compliance.rb new file mode 100644 index 00000000..06017df3 --- /dev/null +++ b/lib/legion/compliance.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'legion/compliance/phi_tag' +require 'legion/compliance/phi_access_log' +require 'legion/compliance/phi_erasure' + +module Legion + module Compliance + DEFAULTS = { + enabled: true, + classification_level: 'confidential', + phi_enabled: true, + pci_enabled: true, + pii_enabled: true, + fedramp_enabled: true, + log_redaction: true, + cache_phi_max_ttl: 3600 + }.freeze + + class << self + def setup + return unless defined?(Legion::Settings) + + Legion::Settings.merge_settings(:compliance, DEFAULTS) + Legion::Logging.info('[Compliance] max-classification profile active') if defined?(Legion::Logging) + end + + def enabled? + setting(:enabled) == true + end + + def phi_enabled? + setting(:phi_enabled) == true + end + + def pci_enabled? + setting(:pci_enabled) == true + end + + def pii_enabled? + setting(:pii_enabled) == true + end + + def fedramp_enabled? + setting(:fedramp_enabled) == true + end + + def classification_level + setting(:classification_level) || 'confidential' + end + + def profile + { + classification_level: classification_level, + phi: phi_enabled?, + pci: pci_enabled?, + pii: pii_enabled?, + fedramp: fedramp_enabled?, + log_redaction: setting(:log_redaction) == true, + cache_phi_max_ttl: setting(:cache_phi_max_ttl) || 3600 + } + end + + private + + def setting(key) + return nil unless defined?(Legion::Settings) + + Legion::Settings.dig(:compliance, key) + rescue StandardError + nil + end + end + end +end diff --git a/lib/legion/compliance/phi_access_log.rb b/lib/legion/compliance/phi_access_log.rb new file mode 100644 index 00000000..c2a92100 --- /dev/null +++ b/lib/legion/compliance/phi_access_log.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Legion + module Compliance + module PhiAccessLog + class << self + def log_access(resource:, action:, actor:, reason:) + return unless Legion::Compliance.phi_enabled? + return unless defined?(Legion::Audit) + + Legion::Audit.record( + event_type: 'phi_access', + principal_id: actor, + action: action, + resource: resource, + detail: { reason: reason, phi: true } + ) + rescue StandardError => e + Legion::Logging.error "[Compliance] PhiAccessLog#log_access failed: #{e.message}" if defined?(Legion::Logging) + end + end + end + end +end diff --git a/lib/legion/compliance/phi_erasure.rb b/lib/legion/compliance/phi_erasure.rb new file mode 100644 index 00000000..a22b50e6 --- /dev/null +++ b/lib/legion/compliance/phi_erasure.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Legion + module Compliance + module PhiErasure + class << self + def erase(task_id:, reason:) + result = { task_id: task_id, erased: false, steps: {} } + + result[:steps][:key_erasure] = erase_key(task_id) + result[:steps][:cache_purge] = purge_cache(task_id) + log_erasure(task_id: task_id, reason: reason) + result[:steps][:verification] = verify_erasure(task_id) + + key_result = result[:steps][:key_erasure] + verify_result = result[:steps][:verification] + + result[:erased] = key_result.nil? || (key_result.is_a?(Hash) && key_result[:erased] != false && + verify_result.is_a?(Hash) && verify_result[:erased] != false) + result + rescue StandardError => e + Legion::Logging.error "[Compliance] PhiErasure#erase failed task_id=#{task_id}: #{e.message}" if defined?(Legion::Logging) + { task_id: task_id, erased: false, error: e.message } + end + + private + + def erase_key(task_id) + return nil unless defined?(Legion::Crypt::Erasure) + + Legion::Crypt::Erasure.erase_tenant(tenant_id: task_id) + end + + def purge_cache(task_id) + return nil unless defined?(Legion::Cache) + + prefix = "phi:#{task_id}:" + Legion::Cache.delete(prefix) + { purged: true, prefix: prefix } + rescue StandardError => e + { purged: false, error: e.message } + end + + def log_erasure(task_id:, reason:) + return unless defined?(Legion::Compliance::PhiAccessLog) + + Legion::Compliance::PhiAccessLog.log_access( + resource: task_id, + action: 'erasure', + actor: 'system:phi_erasure', + reason: reason + ) + end + + def verify_erasure(task_id) + return nil unless defined?(Legion::Crypt::Erasure) + + Legion::Crypt::Erasure.verify_erasure(tenant_id: task_id) + end + end + end + end +end diff --git a/lib/legion/compliance/phi_tag.rb b/lib/legion/compliance/phi_tag.rb new file mode 100644 index 00000000..8f8d56d5 --- /dev/null +++ b/lib/legion/compliance/phi_tag.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Legion + module Compliance + module PhiTag + class << self + def phi?(metadata) + return false unless Legion::Compliance.phi_enabled? + return false unless metadata.is_a?(Hash) + + metadata[:phi] == true + end + + def tag(metadata) + base = metadata.is_a?(Hash) ? metadata : {} + base.merge(phi: true, data_classification: 'restricted') + end + + def tagged_cache_key(key) + str = key.to_s + return str if str.start_with?('phi:') + + "phi:#{str}" + end + end + end + end +end diff --git a/lib/legion/context.rb b/lib/legion/context.rb new file mode 100644 index 00000000..2180388c --- /dev/null +++ b/lib/legion/context.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + module Context + class SessionContext + attr_reader :session_id, :user_id, :started_at, :metadata + + def initialize(session_id: nil, user_id: nil, metadata: {}) + @session_id = session_id || SecureRandom.uuid + @user_id = user_id + @started_at = Time.now + @metadata = metadata + end + + def to_h + { session_id: session_id, user_id: user_id, started_at: started_at.iso8601 } + end + end + + class << self + def current_session + Thread.current[:legion_session_context] + end + + def with_session(ctx) + previous = Thread.current[:legion_session_context] + Thread.current[:legion_session_context] = ctx + yield + ensure + Thread.current[:legion_session_context] = previous + end + + def session_metadata + ctx = current_session + return {} unless ctx + + ctx.to_h + end + + def start_session(user_id: nil) + ctx = SessionContext.new(user_id: user_id) + Thread.current[:legion_session_context] = ctx + Legion::Logging.debug "[Context] session started: #{ctx.session_id}" if defined?(Legion::Logging) + ctx + end + + def end_session + ctx = Thread.current[:legion_session_context] + Legion::Logging.debug "[Context] session cleared: #{ctx&.session_id}" if defined?(Legion::Logging) + Thread.current[:legion_session_context] = nil + end + + def current_task_context + Thread.current[:legion_context] + end + + def with_task_context(message) + previous = Thread.current[:legion_context] + Thread.current[:legion_context] = { + task_id: message[:task_id], + conversation_id: message[:conversation_id], + chain_id: message[:chain_id], + function: message[:function], + runner_class: message[:runner_class] + }.compact + yield + ensure + Thread.current[:legion_context] = previous + end + end + end +end diff --git a/lib/legion/data/local_migrations/20250601000001_create_tbi_patterns.rb b/lib/legion/data/local_migrations/20250601000001_create_tbi_patterns.rb new file mode 100644 index 00000000..b879da2e --- /dev/null +++ b/lib/legion/data/local_migrations/20250601000001_create_tbi_patterns.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +Sequel.migration do + change do + create_table?(:tbi_patterns) do + primary_key :id + String :pattern_type, null: false + String :description, null: false + String :tier, null: false + # TEXT column holds JSON-encoded behavioral pattern data (up to 64KB) + String :pattern_data, text: true, null: false + Float :quality_score, null: false, default: 0.0 + Integer :invocation_count, null: false, default: 0 + Float :success_rate, null: false, default: 0.0 + # one-way SHA-256 prefix derived from pattern_type+tier+description; not reversible to the submitting instance + String :source_hash + Time :created_at, null: false + Time :updated_at, null: false + end + end +end diff --git a/lib/legion/data/local_migrations/20260319000001_create_extension_catalog.rb b/lib/legion/data/local_migrations/20260319000001_create_extension_catalog.rb new file mode 100644 index 00000000..9756b511 --- /dev/null +++ b/lib/legion/data/local_migrations/20260319000001_create_extension_catalog.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Sequel.migration do + change do + create_table(:extension_catalog) do + primary_key :id + String :lex_name, null: false, unique: true + String :state, null: false, default: 'registered' + Time :created_at + Time :updated_at + Time :started_at + Time :stopped_at + end + end +end diff --git a/lib/legion/data/local_migrations/20260319000002_create_extension_permissions.rb b/lib/legion/data/local_migrations/20260319000002_create_extension_permissions.rb new file mode 100644 index 00000000..8ce34f3a --- /dev/null +++ b/lib/legion/data/local_migrations/20260319000002_create_extension_permissions.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Sequel.migration do + change do + create_table(:extension_permissions) do + primary_key :id + String :lex_name, null: false + String :path, null: false + String :access_type, null: false + TrueClass :approved, default: false + Time :created_at + Time :updated_at + + index %i[lex_name path access_type], unique: true + end + end +end diff --git a/lib/legion/data/local_migrations/20260528000001_add_tbi_patterns_indexes.rb b/lib/legion/data/local_migrations/20260528000001_add_tbi_patterns_indexes.rb new file mode 100644 index 00000000..ac77cfbc --- /dev/null +++ b/lib/legion/data/local_migrations/20260528000001_add_tbi_patterns_indexes.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + alter_table(:tbi_patterns) do + add_index :pattern_type, name: :idx_tbi_patterns_type + add_index :tier, name: :idx_tbi_patterns_tier + end + end + + down do + alter_table(:tbi_patterns) do + drop_index :tier, name: :idx_tbi_patterns_tier + drop_index :pattern_type, name: :idx_tbi_patterns_type + end + end +end diff --git a/lib/legion/data/models/tbi_pattern.rb b/lib/legion/data/models/tbi_pattern.rb new file mode 100644 index 00000000..d47c0ee4 --- /dev/null +++ b/lib/legion/data/models/tbi_pattern.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +if defined?(Sequel) + module Legion + module Data + module Model + class TbiPattern < Sequel::Model(:tbi_patterns) + plugin :timestamps, update_on_create: true + + def validate + super + errors.add(:pattern_type, 'is required') if !pattern_type || pattern_type.to_s.strip.empty? + errors.add(:description, 'is required') if !description || description.to_s.strip.empty? + errors.add(:pattern_data, 'is required') if !pattern_data || pattern_data.to_s.strip.empty? + errors.add(:tier, 'is required') if !tier || tier.to_s.strip.empty? + end + end + end + end + end +end diff --git a/lib/legion/digital_worker.rb b/lib/legion/digital_worker.rb new file mode 100644 index 00000000..c81c0888 --- /dev/null +++ b/lib/legion/digital_worker.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + module DigitalWorker + class << self + def register(name:, extension_name:, entra_app_id:, owner_msid:, **opts) + Legion::Data::Model::DigitalWorker.create( + worker_id: SecureRandom.uuid, + name: name, + extension_name: extension_name, + entra_app_id: entra_app_id, + owner_msid: owner_msid, + owner_name: opts[:owner_name], + business_role: opts[:business_role], + risk_tier: opts[:risk_tier], + team: opts[:team], + manager_msid: opts[:manager_msid], + lifecycle_state: 'bootstrap', + consent_tier: 'supervised', + trust_score: 0.0 + ) + end + + def find(worker_id:) + Legion::Data::Model::DigitalWorker.first(worker_id: worker_id) + end + + def find_by_entra_app(entra_app_id:) + Legion::Data::Model::DigitalWorker.first(entra_app_id: entra_app_id) + end + + def active_workers + Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active') + end + + def by_owner(owner_msid:) + Legion::Data::Model::DigitalWorker.where(owner_msid: owner_msid) + end + + def by_team(team:) + Legion::Data::Model::DigitalWorker.where(team: team) + end + + def heartbeat(worker_id:, health_status: 'healthy', health_node: nil) + worker = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id) + return nil unless worker + + updates = { last_heartbeat_at: Time.now.utc, health_status: health_status } + updates[:health_node] = health_node if health_node + worker.update(updates) + worker + end + + def detect_orphans(stale_days: 7) + cutoff = Time.now.utc - (stale_days * 86_400) + active = Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'active') + active.all.select do |w| + w.last_heartbeat_at.nil? || w.last_heartbeat_at < cutoff + end + end + + def pause_orphans!(stale_days: 7, by: 'system:orphan_detection') + orphans = detect_orphans(stale_days: stale_days) + orphans.each do |worker| + Lifecycle.transition!( + worker, + to_state: 'paused', + by: by, + reason: "no heartbeat for #{stale_days}+ days", + authority_verified: true + ) + if defined?(Legion::Events) + Legion::Events.emit('worker.orphan_detected', { + worker_id: worker.worker_id, + owner_msid: worker.owner_msid, + last_heartbeat_at: worker.last_heartbeat_at, + at: Time.now.utc + }) + end + rescue Lifecycle::InvalidTransition => e + Legion::Logging.debug("[OrphanDetection] skip #{worker.worker_id}: #{e.message}") if defined?(Legion::Logging) + end + orphans + end + + def active_local_ids + return [] unless defined?(Registry) + + Registry.local_worker_ids + end + end + end +end diff --git a/lib/legion/digital_worker/airb.rb b/lib/legion/digital_worker/airb.rb new file mode 100644 index 00000000..46864b96 --- /dev/null +++ b/lib/legion/digital_worker/airb.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Legion + module DigitalWorker + module Airb + class << self + # Create an AIRB intake form for a worker registration. + # Returns an intake_id string. + def create_intake(worker_id, description:) + return mock_create_intake(worker_id, description) unless live_api? + + endpoint = api_endpoint + raise ArgumentError, 'AIRB API endpoint not configured' unless endpoint + + response = http_post( + "#{endpoint}/intakes", + { worker_id: worker_id, description: description, submitted_at: Time.now.utc.iso8601 } + ) + + response[:intake_id] || response['intake_id'] + rescue StandardError => e + log_warn "AIRB create_intake failed: #{e.message}" + nil + end + + # Check the AIRB approval status for a given intake_id. + # Returns: 'pending', 'approved', or 'rejected' + def check_status(intake_id) + return mock_check_status(intake_id) unless live_api? + + endpoint = api_endpoint + raise ArgumentError, 'AIRB API endpoint not configured' unless endpoint + + response = http_get("#{endpoint}/intakes/#{intake_id}/status") + response[:status] || response['status'] || 'pending' + rescue StandardError => e + log_warn "AIRB check_status failed for #{intake_id}: #{e.message}" + 'pending' + end + + # Sync AIRB status back to the Legion worker state. + # Calls approve/reject on the Registration module when AIRB has a decision. + def sync_status(worker_id) + return { synced: false, reason: 'DigitalWorker not defined' } unless defined?(Legion::DigitalWorker) + + worker = find_worker(worker_id) + return { synced: false, reason: 'worker not found' } unless worker + return { synced: false, reason: 'not pending approval' } unless worker.lifecycle_state == 'pending_approval' + + intake_id = lookup_intake_id(worker_id) + return { synced: false, reason: 'no intake_id found' } unless intake_id + + status = check_status(intake_id) + log_info "worker=#{worker_id} intake=#{intake_id} airb_status=#{status}" + + case status + when 'approved' + apply_airb_approval(worker_id) + when 'rejected' + apply_airb_rejection(worker_id) + else + { synced: false, reason: "airb_status=#{status}", intake_id: intake_id } + end + end + + private + + def live_api? + api_endpoint && api_credentials + end + + def api_endpoint + return nil unless defined?(Legion::Settings) + + Legion::Settings.dig(:airb, :api_endpoint) + end + + def api_credentials + return nil unless defined?(Legion::Settings) + + Legion::Settings.dig(:airb, :credentials) + end + + def http_post(url, payload) + require 'net/http' + require 'json' + uri = URI.parse(url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + req = Net::HTTP::Post.new(uri.request_uri, { 'Content-Type' => 'application/json' }) + req.body = ::JSON.generate(payload) + resp = http.request(req) + ::JSON.parse(resp.body, symbolize_names: true) + end + + def http_get(url) + require 'net/http' + require 'json' + uri = URI.parse(url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + req = Net::HTTP::Get.new(uri.request_uri) + resp = http.request(req) + ::JSON.parse(resp.body, symbolize_names: true) + end + + def mock_create_intake(worker_id, description) + intake_id = "airb-mock-#{worker_id[0..7]}-#{Time.now.utc.to_i}" + log_info "mock AIRB intake created: #{intake_id} desc=#{description[0..60]}" + intake_id + end + + def mock_check_status(_intake_id) + 'pending' + end + + def find_worker(worker_id) + return nil unless defined?(Legion::Data::Model::DigitalWorker) + + Legion::Data::Model::DigitalWorker.first(worker_id: worker_id) + end + + def lookup_intake_id(worker_id) + return nil unless defined?(Legion::Data::Model::DigitalWorker) + + worker = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id) + worker.respond_to?(:airb_intake_id) ? worker.airb_intake_id : nil + end + + def apply_airb_approval(worker_id) + Legion::DigitalWorker::Registration.approve(worker_id, approver: 'airb', notes: 'Auto-approved by AIRB') + { synced: true, action: 'approved', worker_id: worker_id } + rescue StandardError => e + log_warn "AIRB sync approve failed for #{worker_id}: #{e.message}" + { synced: false, reason: e.message } + end + + def apply_airb_rejection(worker_id) + Legion::DigitalWorker::Registration.reject(worker_id, approver: 'airb', reason: 'Rejected by AIRB review board') + { synced: true, action: 'rejected', worker_id: worker_id } + rescue StandardError => e + log_warn "AIRB sync reject failed for #{worker_id}: #{e.message}" + { synced: false, reason: e.message } + end + + def log_info(msg) + Legion::Logging.info "[airb] #{msg}" if defined?(Legion::Logging) + end + + def log_warn(msg) + Legion::Logging.warn "[airb] #{msg}" if defined?(Legion::Logging) + end + end + end + end +end diff --git a/lib/legion/digital_worker/lifecycle.rb b/lib/legion/digital_worker/lifecycle.rb new file mode 100644 index 00000000..65ed3eb6 --- /dev/null +++ b/lib/legion/digital_worker/lifecycle.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +module Legion + module DigitalWorker + module Lifecycle + TRANSITIONS = { + 'bootstrap' => %w[active terminated], + 'pending_approval' => %w[active rejected], + 'active' => %w[paused retired terminated], + 'paused' => %w[active retired terminated], + 'retired' => %w[terminated], + 'rejected' => [], + 'terminated' => [] + }.freeze + + GOVERNANCE_REQUIRED = { + %w[retired terminated] => :council_approval, + %w[active terminated] => :council_approval + }.freeze + + AUTHORITY_REQUIRED = { + %w[active paused] => :owner_or_manager, + %w[paused active] => :owner_or_manager, + %w[active retired] => :owner_or_manager + }.freeze + + # Map lifecycle states to lex-extinction containment levels + EXTINCTION_MAPPING = { + 'active' => 0, # no containment + 'paused' => 2, # capability restriction + 'retired' => 3, # supervised-only + 'terminated' => 4, # full termination (irreversible in lex-extinction) + 'pending_approval' => 1, # held — no capability, awaiting decision + 'rejected' => 4 # treated as terminated for containment + }.freeze + + # Map lifecycle states to lex-consent tiers + CONSENT_MAPPING = { + 'bootstrap' => :consult, # most restrictive during bootstrap + 'active' => :autonomous, # earned autonomy + 'paused' => :consult, # back to restrictive + 'retired' => :inform, # notification only + 'terminated' => :inform, + 'pending_approval' => :consult, # held at consult until approved + 'rejected' => :inform # read-only / no execution + }.freeze + + class InvalidTransition < StandardError; end + class GovernanceRequired < StandardError; end + class AuthorityRequired < StandardError; end + class GovernanceBlocked < StandardError; end + + def self.transition!(worker, to_state:, by:, reason: nil, **opts) + from_state = worker.lifecycle_state + allowed = TRANSITIONS.fetch(from_state, []) + + unless allowed.include?(to_state) + Legion::Logging.warn "[Lifecycle] invalid transition #{from_state} -> #{to_state} for #{worker.worker_id}" if defined?(Legion::Logging) + raise InvalidTransition, "cannot transition from #{from_state} to #{to_state}" + end + Legion::Logging.info "[Lifecycle] transition #{from_state} -> #{to_state} worker=#{worker.worker_id} by=#{by}" if defined?(Legion::Logging) + + if defined?(Legion::Extensions::Governance::Runners::Governance) + review = Legion::Extensions::Governance::Runners::Governance.review_transition( + worker_id: worker.is_a?(Hash) ? worker[:id] : worker.worker_id, + from_state: from_state, + to_state: to_state, + principal_id: by, + worker_owner: worker.respond_to?(:owner_msid) ? worker.owner_msid : nil + ) + raise GovernanceBlocked, "#{from_state} -> #{to_state} blocked: #{review[:reasons]&.join(', ')}" unless review[:allowed] + else + if governance_required?(from_state, to_state) + required = GOVERNANCE_REQUIRED[[from_state, to_state]] + raise GovernanceRequired, "#{from_state} -> #{to_state} requires #{required}" unless opts[:governance_override] == true + end + + authority = authority_type(from_state, to_state) + raise AuthorityRequired, "#{from_state} -> #{to_state} requires #{authority} (by: #{by})" if authority && opts[:authority_verified] != true + end + + if defined?(Legion::Extensions::Extinction::Client) + new_level = EXTINCTION_MAPPING[to_state] + current_level = EXTINCTION_MAPPING[from_state] || 0 + if new_level && new_level > current_level + Legion::Extensions::Extinction::Client.new.escalate( + level: new_level, + authority: by || :system, + reason: "lifecycle transition: #{from_state} -> #{to_state}" + ) + elsif new_level && new_level < current_level + Legion::Extensions::Extinction::Client.new.deescalate( + authority: by || :system, + reason: "lifecycle transition: #{from_state} -> #{to_state}", + target_level: new_level + ) + end + end + + if to_state == 'terminated' && + defined?(Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets) + begin + Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets + .delete_client_secret(worker_id: worker.worker_id) + rescue StandardError => e + Legion::Logging.warn("Credential revocation failed for #{worker.worker_id}: #{e.message}") if defined?(Legion::Logging) + end + end + + new_consent = CONSENT_MAPPING[to_state] + worker.update( + lifecycle_state: to_state, + consent_tier: new_consent ? new_consent.to_s : worker.consent_tier, + updated_at: Time.now.utc, + retired_at: %w[retired terminated].include?(to_state) ? Time.now.utc : worker.retired_at, + retired_by: %w[retired terminated].include?(to_state) ? by : worker.retired_by, + retired_reason: reason || worker.retired_reason + ) + sync_consent_tier(worker, new_consent) if new_consent + + if defined?(Legion::Events) + Legion::Events.emit('worker.lifecycle', { + worker_id: worker.worker_id, + from_state: from_state, + to_state: to_state, + by: by, + reason: reason, + extinction_level: extinction_level(to_state), + consent_tier: consent_tier(to_state), + at: Time.now.utc + }) + end + + if defined?(Legion::Audit) + begin + Legion::Audit.record( + event_type: 'lifecycle_transition', + principal_id: by, + principal_type: 'human', + action: 'transition', + resource: worker.worker_id, + source: 'system', + status: 'success', + detail: { from_state: from_state, to_state: to_state, reason: reason } + ) + rescue StandardError => e + Legion::Logging.debug("Audit in lifecycle.transition! failed: #{e.message}") if defined?(Legion::Logging) + end + end + + worker + end + + def self.valid_transition?(from_state, to_state) + TRANSITIONS.fetch(from_state, []).include?(to_state) + end + + def self.governance_required?(from_state, to_state) + GOVERNANCE_REQUIRED.key?([from_state, to_state]) + end + + def self.authority_type(from_state, to_state) + AUTHORITY_REQUIRED[[from_state, to_state]] + end + + def self.extinction_level(state) + EXTINCTION_MAPPING.fetch(state, 0) + end + + def self.consent_tier(state) + CONSENT_MAPPING.fetch(state, :consult) + end + + def self.sync_consent_tier(worker, tier) + return unless defined?(Legion::Extensions::Consent::Runners::Consent) + + Legion::Extensions::Consent::Runners::Consent.update_tier( + worker_id: worker.worker_id, + tier: tier.to_s + ) + rescue StandardError => e + Legion::Logging.debug("[Lifecycle] consent sync failed for #{worker.worker_id}: #{e.message}") if defined?(Legion::Logging) + end + private_class_method :sync_consent_tier + end + end +end diff --git a/lib/legion/digital_worker/registration.rb b/lib/legion/digital_worker/registration.rb new file mode 100644 index 00000000..024d5010 --- /dev/null +++ b/lib/legion/digital_worker/registration.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + module DigitalWorker + module Registration + APPROVAL_TIMEOUT_SECONDS = 172_800 # 48 hours default + + class << self + def register(worker_attrs) + risk_tier = worker_attrs[:risk_tier].to_s + + lifecycle_state = if approval_required?(risk_tier) + 'pending_approval' + else + 'bootstrap' + end + + worker = Legion::Data::Model::DigitalWorker.create( + worker_id: SecureRandom.uuid, + name: worker_attrs[:name], + extension_name: worker_attrs[:extension_name], + entra_app_id: worker_attrs[:entra_app_id], + owner_msid: worker_attrs[:owner_msid], + owner_name: worker_attrs[:owner_name], + business_role: worker_attrs[:business_role], + risk_tier: risk_tier.empty? ? nil : risk_tier, + team: worker_attrs[:team], + manager_msid: worker_attrs[:manager_msid], + lifecycle_state: lifecycle_state, + consent_tier: 'supervised', + trust_score: 0.0 + ) + + if lifecycle_state == 'pending_approval' + intake_id = create_airb_intake(worker) + log_info "worker=#{worker.worker_id} state=pending_approval airb_intake=#{intake_id}" + emit_event('worker.registration.pending', worker_id: worker.worker_id, risk_tier: risk_tier, intake_id: intake_id) + else + log_info "worker=#{worker.worker_id} state=bootstrap risk_tier=#{risk_tier}" + emit_event('worker.registration.created', worker_id: worker.worker_id, risk_tier: risk_tier) + end + + worker + end + + def approve(worker_id, approver:, notes: nil) + worker = find_pending!(worker_id) + + Lifecycle.transition!( + worker, + to_state: 'active', + by: approver, + reason: notes, + authority_verified: true + ) + + record_audit('worker_approved', worker_id, approver, { notes: notes }) + emit_event('worker.registration.approved', worker_id: worker_id, approver: approver) + log_info "worker=#{worker_id} approved by=#{approver}" + + worker + end + + def reject(worker_id, approver:, reason:) + worker = find_pending!(worker_id) + + Lifecycle.transition!( + worker, + to_state: 'rejected', + by: approver, + reason: reason, + authority_verified: true + ) + + record_audit('worker_rejected', worker_id, approver, { reason: reason }) + emit_event('worker.registration.rejected', worker_id: worker_id, approver: approver, reason: reason) + log_info "worker=#{worker_id} rejected by=#{approver} reason=#{reason}" + + worker + end + + def pending_approvals + return [] unless defined?(Legion::Data::Model::DigitalWorker) + + Legion::Data::Model::DigitalWorker.where(lifecycle_state: 'pending_approval').all + end + + def approval_required?(risk_tier) + %w[high critical].include?(risk_tier.to_s) + end + + def escalate(worker_id) + worker = find_worker(worker_id) + return { escalated: false, reason: 'worker not found' } unless worker + return { escalated: false, reason: 'not pending approval' } unless worker.lifecycle_state == 'pending_approval' + + timeout = settings_timeout + pending_seconds = worker.created_at ? (Time.now.utc - worker.created_at) : 0 + + if pending_seconds >= timeout + emit_event('worker.registration.escalated', worker_id: worker_id, pending_seconds: pending_seconds) + log_info "worker=#{worker_id} escalated pending_seconds=#{pending_seconds.to_i}" + { escalated: true, worker_id: worker_id, pending_seconds: pending_seconds.to_i } + else + remaining = (timeout - pending_seconds).to_i + { escalated: false, reason: 'timeout not reached', remaining_seconds: remaining } + end + end + + private + + def find_worker(worker_id) + return nil unless defined?(Legion::Data::Model::DigitalWorker) + + Legion::Data::Model::DigitalWorker.first(worker_id: worker_id) + end + + def find_pending!(worker_id) + worker = find_worker(worker_id) + raise ArgumentError, "worker not found: #{worker_id}" unless worker + + unless worker.lifecycle_state == 'pending_approval' + raise ArgumentError, + "worker #{worker_id} is not pending approval (state: #{worker.lifecycle_state})" + end + + worker + end + + def create_airb_intake(worker) + return nil unless defined?(Legion::DigitalWorker::Airb) + + Legion::DigitalWorker::Airb.create_intake( + worker.worker_id, + description: "Registration request for #{worker.name} (risk_tier: #{worker.risk_tier})" + ) + rescue StandardError => e + log_debug "AIRB intake creation failed: #{e.message}" + nil + end + + def settings_timeout + return APPROVAL_TIMEOUT_SECONDS unless defined?(Legion::Settings) + + Legion::Settings.dig(:digital_worker, :approval_timeout_seconds) || APPROVAL_TIMEOUT_SECONDS + end + + def emit_event(name, **payload) + return unless defined?(Legion::Events) + + Legion::Events.emit(name, **payload) + rescue StandardError => e + log_debug "event emit failed: #{e.message}" + end + + def record_audit(event_type, worker_id, principal, detail) + return unless defined?(Legion::Audit) + + Legion::Audit.record( + event_type: event_type, + principal_id: principal, + principal_type: 'human', + action: event_type, + resource: worker_id, + source: 'system', + status: 'success', + detail: detail + ) + rescue StandardError => e + log_debug "audit record failed: #{e.message}" + end + + def log_info(msg) + Legion::Logging.info "[registration] #{msg}" if defined?(Legion::Logging) + end + + def log_debug(msg) + Legion::Logging.debug "[registration] #{msg}" if defined?(Legion::Logging) + end + end + end + end +end diff --git a/lib/legion/digital_worker/registry.rb b/lib/legion/digital_worker/registry.rb new file mode 100644 index 00000000..a8994866 --- /dev/null +++ b/lib/legion/digital_worker/registry.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Legion + module DigitalWorker + module Registry + class WorkerNotFound < StandardError; end + class WorkerNotActive < StandardError; end + class InsufficientConsent < StandardError; end + + CONSENT_HIERARCHY = %w[supervised consult inform autonomous].freeze + + @local_workers = Set.new + @local_workers_mutex = Mutex.new + + def self.local_worker_ids + @local_workers_mutex.synchronize { @local_workers.to_a } + end + + def self.clear_local_workers! + @local_workers_mutex.synchronize { @local_workers.clear } + end + + def self.validate_execution!(worker_id:, required_consent: nil) + Legion::Logging.debug "[Registry] validate_execution: worker_id=#{worker_id}" if defined?(Legion::Logging) + worker = Legion::Data::Model::DigitalWorker.first(worker_id: worker_id) + + unless worker + Legion::Logging.warn "[Registry] worker not found: #{worker_id}" if defined?(Legion::Logging) + emit_blocked(worker_id: worker_id, reason: 'unregistered') + raise WorkerNotFound, "no registered worker with id #{worker_id}" + end + + unless worker.active? + Legion::Logging.warn "[Registry] worker not active: #{worker_id} state=#{worker.lifecycle_state}" if defined?(Legion::Logging) + emit_blocked(worker_id: worker_id, reason: "lifecycle_state=#{worker.lifecycle_state}") + raise WorkerNotActive, "worker #{worker_id} is #{worker.lifecycle_state}, not active" + end + + if required_consent && !consent_sufficient?(worker.consent_tier, required_consent) + if defined?(Legion::Logging) + Legion::Logging.warn "[Registry] insufficient consent: #{worker_id} tier=#{worker.consent_tier} required=#{required_consent}" + end + emit_blocked(worker_id: worker_id, reason: "consent=#{worker.consent_tier} < #{required_consent}") + raise InsufficientConsent, + "worker #{worker_id} consent tier #{worker.consent_tier} insufficient (needs #{required_consent})" + end + + @local_workers_mutex.synchronize { @local_workers.add(worker_id) } + Legion::Logging.info "[Registry] registered worker: #{worker_id}" if defined?(Legion::Logging) + worker + end + + def self.consent_sufficient?(current_tier, required_tier) + CONSENT_HIERARCHY.index(current_tier) >= CONSENT_HIERARCHY.index(required_tier) + end + + def self.emit_blocked(worker_id:, reason:) + return unless defined?(Legion::Events) + + Legion::Events.emit('worker.blocked', + worker_id: worker_id, + reason: reason, + at: Time.now.utc) + end + + private_class_method :emit_blocked + end + end +end diff --git a/lib/legion/digital_worker/risk_tier.rb b/lib/legion/digital_worker/risk_tier.rb new file mode 100644 index 00000000..01c5cf55 --- /dev/null +++ b/lib/legion/digital_worker/risk_tier.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Legion + module DigitalWorker + module RiskTier + TIERS = %w[low medium high critical].freeze + + # Maps AIRB risk tiers to governance and consent constraints. + # These constraints are enforced when a worker attempts to execute a task. + CONSTRAINTS = { + 'low' => { min_consent: 'inform', governance_gate: false, council_required: false }, + 'medium' => { min_consent: 'consult', governance_gate: false, council_required: false }, + 'high' => { min_consent: 'consult', governance_gate: true, council_required: true }, + 'critical' => { min_consent: 'supervised', governance_gate: true, council_required: true } + }.freeze + + def self.valid?(tier) + TIERS.include?(tier) + end + + def self.constraints_for(tier) + CONSTRAINTS.fetch(tier) { raise ArgumentError, "unknown risk tier: #{tier}. Valid: #{TIERS.join(', ')}" } + end + + def self.min_consent(tier) + constraints_for(tier)[:min_consent] + end + + def self.governance_required?(tier) + constraints_for(tier)[:governance_gate] + end + + def self.council_required?(tier) + constraints_for(tier)[:council_required] + end + + # Assign or change a worker's risk tier. Lowering risk requires governance approval. + def self.assign!(worker, tier:, by:, reason: nil) + raise ArgumentError, "invalid tier: #{tier}" unless valid?(tier) + + old_tier = worker.risk_tier + tier_lowered = old_tier && TIERS.index(tier) < TIERS.index(old_tier) + + if tier_lowered + Legion::Logging.warn "[risk_tier] lowering risk from #{old_tier} to #{tier} requires governance approval" + # In production: check governance approval here + end + + worker.update(risk_tier: tier, updated_at: Time.now.utc) + + event = { + event: :risk_tier_changed, + worker_id: worker.worker_id, + from_tier: old_tier, + to_tier: tier, + by: by, + reason: reason, + at: Time.now.utc + } + + Legion::Events.emit('worker.risk_tier_changed', **event) if defined?(Legion::Events) + Legion::Logging.info "[risk_tier] worker=#{worker.worker_id} tier: #{old_tier || 'none'} -> #{tier} by=#{by}" + + { assigned: true }.merge(event) + end + + # Validate that a worker's current consent tier meets the minimum for its risk tier + def self.consent_compliant?(worker) + return true unless worker.risk_tier + + min = min_consent(worker.risk_tier) + hierarchy = Legion::DigitalWorker::Registry::CONSENT_HIERARCHY + hierarchy.index(worker.consent_tier) >= hierarchy.index(min) + end + end + end +end diff --git a/lib/legion/digital_worker/value_metrics.rb b/lib/legion/digital_worker/value_metrics.rb new file mode 100644 index 00000000..db659bde --- /dev/null +++ b/lib/legion/digital_worker/value_metrics.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Legion + module DigitalWorker + module ValueMetrics + METRIC_TYPES = %i[counter gauge duration].freeze + + def self.record(worker_id:, metric_name:, metric_type:, value:, metadata: {}) + raise ArgumentError, "invalid metric_type: #{metric_type}" unless METRIC_TYPES.include?(metric_type) + + record = { + worker_id: worker_id, + metric_name: metric_name.to_s, + metric_type: metric_type.to_s, + value: value.to_f, + metadata: Legion::JSON.dump(metadata), + recorded_at: Time.now.utc + } + + Legion::Data.connection[:value_metrics].insert(record) if data_connected? + + Legion::Logging.debug "[value_metrics] recorded: worker=#{worker_id} #{metric_name}=#{value} (#{metric_type})" + record + end + + def self.latest_value(dataset) + order_expr = defined?(::Sequel) ? ::Sequel.desc(:recorded_at) : :recorded_at + dataset.order(order_expr).first&.dig(:value) + end + private_class_method :latest_value + + def self.data_connected? + defined?(Legion::Data) && + Legion::Data.respond_to?(:connection) && + Legion::Data.connection.respond_to?(:table_exists?) && + Legion::Data.connection.table_exists?(:value_metrics) + rescue StandardError => e + Legion::Logging.debug "ValueMetrics#data_connected? check failed: #{e.message}" if defined?(Legion::Logging) + false + end + private_class_method :data_connected? + + def self.for_worker(worker_id:, metric_name: nil, since: nil) + return [] unless data_connected? + + ds = Legion::Data.connection[:value_metrics].where(worker_id: worker_id) + ds = ds.where(metric_name: metric_name.to_s) if metric_name + ds = ds.where { recorded_at >= since } if since + ds.order(:recorded_at).all + end + + def self.summary(worker_id:) + return {} unless data_connected? + + ds = Legion::Data.connection[:value_metrics].where(worker_id: worker_id) + metrics = ds.select(:metric_name).distinct.select_map(:metric_name) + + metrics.each_with_object({}) do |name, acc| + subset = ds.where(metric_name: name) + acc[name] = { + count: subset.count, + sum: subset.sum(:value) || 0, + avg: subset.avg(:value)&.round(4) || 0, + min: subset.min(:value) || 0, + max: subset.max(:value) || 0, + latest: latest_value(subset) + } + end + end + end + end +end diff --git a/lib/legion/dispatch.rb b/lib/legion/dispatch.rb new file mode 100644 index 00000000..44d90bce --- /dev/null +++ b/lib/legion/dispatch.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'dispatch/local' + +module Legion + module Dispatch + class << self + def dispatcher + @dispatcher ||= Local.new + end + + def submit(&) + dispatcher.submit(&) + end + + def shutdown + @dispatcher&.stop + end + + def reset! + @dispatcher&.stop + @dispatcher = nil + end + end + end +end diff --git a/lib/legion/dispatch/local.rb b/lib/legion/dispatch/local.rb new file mode 100644 index 00000000..cf5e13e9 --- /dev/null +++ b/lib/legion/dispatch/local.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'concurrent-ruby' + +module Legion + module Dispatch + class Local + def initialize(pool_size: nil) + max = pool_size || Legion::Settings.dig(:dispatch, :local_pool_size) || 8 + @pool = Concurrent::FixedThreadPool.new(max) + end + + def start; end + + def submit(&block) + @pool.post do + block.call + rescue StandardError => e + Legion::Logging.error "[Dispatch::Local] #{e.message}" if defined?(Legion::Logging) + Legion::Logging.debug e.backtrace&.first(5) if defined?(Legion::Logging) + end + end + + def stop + return unless @pool.running? + + @pool.shutdown + @pool.wait_for_termination(15) + end + + def capacity + { + pool_size: @pool.max_length, + queue_length: @pool.queue_length, + running: @pool.running? + } + end + end + end +end diff --git a/lib/legion/docs/site_generator.rb b/lib/legion/docs/site_generator.rb new file mode 100644 index 00000000..ba7acfcf --- /dev/null +++ b/lib/legion/docs/site_generator.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +require 'fileutils' + +begin + require 'kramdown' +rescue LoadError => e + Legion::Logging.debug "SiteGenerator: kramdown not available, plain-text fallback will be used: #{e.message}" if defined?(Legion::Logging) +end + +begin + require 'rouge' +rescue LoadError => e + Legion::Logging.debug "SiteGenerator: rouge not available, syntax highlighting skipped: #{e.message}" if defined?(Legion::Logging) +end + +module Legion + module Docs + class SiteGenerator + GUIDE_SOURCES = [ + { source: 'docs/getting-started.md', title: 'Getting Started', section: 'guides' }, + { source: 'docs/overview.md', title: 'Architecture', section: 'guides' }, + { source: 'docs/extension-development.md', title: 'Extension Development', section: 'guides' }, + { source: 'docs/best-practices.md', title: 'Best Practices', section: 'guides' }, + { source: 'docs/protocol/LEGION_WIRE_PROTOCOL.md', title: 'Wire Protocol', section: 'protocol' } + ].freeze + + # Legacy constant — preserved so existing code that references SECTIONS still works. + SECTIONS = GUIDE_SOURCES.freeze + + def initialize(output_dir: 'docs/site') + @output_dir = output_dir + @pages = [] + end + + # Generate the full static site. + # + # Returns a hash with :output, :sections, :pages, and :files keys. + def generate + FileUtils.mkdir_p(@output_dir) + generate_guides + generate_cli_reference + generate_extension_reference + generate_index + { + output: @output_dir, + sections: GUIDE_SOURCES.size, + pages: @pages.size, + files: @pages.map { |p| p[:file] } + } + end + + private + + # --------------------------------------------------------------------------- + # Markdown rendering + # --------------------------------------------------------------------------- + + def render_markdown(content) + if defined?(Kramdown::Document) + highlighter = defined?(Rouge) ? :rouge : nil + opts = { auto_ids: true } + opts[:syntax_highlighter] = highlighter if highlighter + Kramdown::Document.new(content, **opts).to_html + else + # Plain-text fallback: wrap in

 so it is at least readable.
+          "
#{escape_html(content)}
" + end + end + + def escape_html(text) + text.gsub('&', '&').gsub('<', '<').gsub('>', '>') + end + + # --------------------------------------------------------------------------- + # HTML template + # --------------------------------------------------------------------------- + + def html_template(title:, body:, nav:) + <<~HTML + + + + + + #{escape_html(title)} — LegionIO Docs + + + + +
+

#{escape_html(title)}

+ #{body} +
+ + + HTML + end + + # --------------------------------------------------------------------------- + # Navigation sidebar + # --------------------------------------------------------------------------- + + def build_navigation + sections = @pages.group_by { |p| p[:section] } + html = +'' + sections.each do |section, pages| + html << "
#{escape_html(section.to_s.capitalize)}
\n" + pages.each do |page| + html << " #{escape_html(page[:title])}\n" + end + end + html + end + + # --------------------------------------------------------------------------- + # Index page + # --------------------------------------------------------------------------- + + def generate_index + nav = build_navigation + body = +"

Welcome to the LegionIO documentation.

\n" + + sections = @pages.group_by { |p| p[:section] } + sections.each do |section, pages| + body << "

#{escape_html(section.to_s.capitalize)}

\n\n" + end + + html = html_template(title: 'LegionIO Documentation', body: body, nav: nav) + write_page('index', html) + end + + # --------------------------------------------------------------------------- + # Guide pages (Markdown sources) + # --------------------------------------------------------------------------- + + def generate_guides + GUIDE_SOURCES.each do |entry| + slug = File.basename(entry[:source], '.md').downcase.tr('_', '-') + title = entry[:title] + section = entry[:section] + + markdown = if File.exist?(entry[:source]) + File.read(entry[:source]) + else + "# #{title}\n\n_Documentation coming soon._\n" + end + + register_page(slug: slug, title: title, section: section) + body = render_markdown(markdown) + nav = build_navigation + html = html_template(title: title, body: body, nav: nav) + write_page(slug, html) + end + end + + # --------------------------------------------------------------------------- + # CLI reference (introspects Thor commands when available) + # --------------------------------------------------------------------------- + + def generate_cli_reference + register_page(slug: 'cli-reference', title: 'CLI Reference', section: 'reference') + body = build_cli_body + nav = build_navigation + html = html_template(title: 'CLI Reference', body: body, nav: nav) + write_page('cli-reference', html) + end + + def build_cli_body + body = +"

Available legion commands:

\n" + + commands = introspect_thor_commands + if commands.empty? + body << "

CLI introspection unavailable — require LegionIO to see commands.

\n" + return body + end + + body << "\n\n\n" + commands.each do |cmd| + body << " " \ + "\n" + end + body << "\n
CommandDescription
#{escape_html(cmd[:name])}#{escape_html(cmd[:description])}
\n" + body + end + + def introspect_thor_commands + return [] unless defined?(Legion::CLI::Main) + + cmds = Legion::CLI::Main.all_commands.filter_map do |name, cmd| + next if name.start_with?('_') || name == 'help' + + { name: "legion #{name}", description: cmd.description.to_s.split("\n").first.to_s } + end + cmds.sort_by { |c| c[:name] } + rescue StandardError => e + Legion::Logging.debug "SiteGenerator#introspect_thor_commands failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + # --------------------------------------------------------------------------- + # Extension reference (discovered LEX gems) + # --------------------------------------------------------------------------- + + def generate_extension_reference + register_page(slug: 'extensions', title: 'Extensions', section: 'reference') + body = build_extensions_body + nav = build_navigation + html = html_template(title: 'Extensions', body: body, nav: nav) + write_page('extensions', html) + end + + def build_extensions_body + body = +"

Discovered LEX extensions:

\n" + + extensions = discover_extensions + if extensions.empty? + body << "

No extensions discovered. Ensure LEX gems are installed.

\n" + return body + end + + body << "\n\n\n" + extensions.each do |ext| + body << " " \ + "\n" + end + body << "\n
GemVersion
#{escape_html(ext[:name])}#{escape_html(ext[:version])}
\n" + body + end + + def discover_extensions + specs = if defined?(Bundler) + Bundler.load.specs.select { |s| s.name.start_with?('lex-') } + else + Gem::Specification.select { |s| s.name.start_with?('lex-') } + end + specs.map { |s| { name: s.name, version: s.version.to_s } } + .sort_by { |e| e[:name] } + rescue StandardError, LoadError => e + Legion::Logging.debug "SiteGenerator#discover_extensions failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + def register_page(slug:, title:, section:) + path = File.join(@output_dir, "#{slug}.html") + @pages << { slug: slug, title: title, section: section, file: path } + end + + def write_page(slug, html) + path = File.join(@output_dir, "#{slug}.html") + File.write(path, html) + path + end + end + end +end diff --git a/lib/legion/events.rb b/lib/legion/events.rb new file mode 100644 index 00000000..7f0e3888 --- /dev/null +++ b/lib/legion/events.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Legion + module Events + class << self + def listeners + @listeners ||= Hash.new { |h, k| h[k] = [] } + end + + def on(event_name, &block) + listeners[event_name.to_s] << block + block + end + + def off(event_name, block = nil) + if block + listeners[event_name.to_s].delete(block) + else + listeners.delete(event_name.to_s) + end + end + + def emit(event_name, **payload) + Legion::Logging.debug "[Events] emit: #{event_name}" if defined?(Legion::Logging) + event = { + event: event_name.to_s, + timestamp: Time.now, + **payload + } + + listeners[event_name.to_s].each do |listener| + listener.call(event) + rescue StandardError => e + Legion::Logging.log_exception(e, payload_summary: "[Events] listener error on #{event_name}", component_type: :event) + end + + # Also fire wildcard listeners + listeners['*'].each do |listener| + listener.call(event) + rescue StandardError => e + Legion::Logging.warn "[Events] wildcard listener error on #{event_name}: #{e.message}" + end + + event + end + + def once(event_name, &block) + wrapper = proc do |event| + block.call(event) + off(event_name, wrapper) + end + on(event_name, &wrapper) + end + + def clear + @listeners = nil + end + + def listener_count(event_name = nil) + if event_name + listeners[event_name.to_s].size + else + listeners.values.sum(&:size) + end + end + end + end +end diff --git a/lib/legion/extensions.rb b/lib/legion/extensions.rb index e6f131e9..d9e69d92 100755 --- a/lib/legion/extensions.rb +++ b/lib/legion/extensions.rb @@ -1,8 +1,16 @@ +# frozen_string_literal: true + +require 'legion/logging' require 'legion/extensions/core' +require 'legion/extensions/catalog' +require 'legion/extensions/handle_registry' +require 'legion/extensions/permissions' require 'legion/runner' module Legion module Extensions + SUBMODULE_SKIP = %i[VERSION Actor Actors Runners Helpers Transport Data].freeze + class << self def setup hook_extensions @@ -14,107 +22,439 @@ def hook_extensions @once_tasks = [] @poll_tasks = [] @subscription_tasks = [] + @local_tasks = [] @actors = [] + @running_instances = Concurrent::Array.new + @loaded_extensions = [] + reset_runtime_handles! + @pending_registrations = Concurrent::Array.new find_extensions - load_extensions + + phases = group_by_phase + llm_base_entries, llm_extension_entries = extract_llm_extension_entries!(phases) + llm_phases_loaded = false + phases.each do |phase_num, entries| + unless llm_phases_loaded || before_llm_extension_phase?(phase_num) + load_llm_extension_phases(llm_base_entries, llm_extension_entries) + llm_phases_loaded = true + end + + @pending_actors = Concurrent::Array.new + load_phase_extensions(phase_num, entries) + hook_phase_actors(phase_num) + end + load_llm_extension_phases(llm_base_entries, llm_extension_entries) unless llm_phases_loaded + + transition_loaded_extensions(:running) + Catalog.flush_persisted_transitions + + load_yaml_agents end - def shutdown + attr_reader :local_tasks + + def shutdown # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity return nil if @loaded_extensions.nil? - @subscription_tasks.each do |task| - task[:threadpool].shutdown - task[:threadpool].kill unless task[:threadpool].wait_for_termination(5) + deadline = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15 + shutdown_start = Time.now + + transition_loaded_extensions(:stopping) + + if @subscription_pool + @subscription_pool.shutdown + @subscription_pool.kill unless @subscription_pool.wait_for_termination(5) + @subscription_pool = nil end - @loop_tasks.each { |task| task[:running_class].cancel if task[:running_class].respond_to?(:cancel) } - @once_tasks.each { |task| task[:running_class].cancel if task[:running_class].respond_to?(:cancel) } - @timer_tasks.each { |task| task[:running_class].cancel if task[:running_class].respond_to?(:cancel) } - @poll_tasks.each { |task| task[:running_class].cancel if task[:running_class].respond_to?(:cancel) } + # Cancel all running instances (real objects, not new instances) + @running_instances&.each do |instance| + instance.cancel if instance.respond_to?(:cancel) + rescue StandardError => e + Legion::Logging.debug "Extension shutdown cancel failed: #{e.message}" if defined?(Legion::Logging) + end + + # Wait for in-flight work to drain, up to deadline + remaining = deadline - (Time.now - shutdown_start) + if remaining.positive? + drain_start = Time.now + loop do + elapsed = Time.now - drain_start + break if elapsed >= remaining + + still_active = @running_instances&.any? do |inst| + (inst.respond_to?(:channel) && inst.instance_variable_get(:@queue)&.channel&.open?) || + (inst.instance_variable_get(:@timer).respond_to?(:running?) && inst.instance_variable_get(:@timer).running?) || + (inst.instance_variable_get(:@loop) == true) + end + break unless still_active + + sleep 0.25 + end + end + + # Force-close any channels still open after deadline + elapsed = Time.now - shutdown_start + if elapsed >= deadline + Legion::Logging.warn "Shutdown deadline (#{deadline}s) reached, force-closing remaining actors" if defined?(Legion::Logging) + @running_instances&.each do |inst| + queue = inst.instance_variable_get(:@queue) + queue&.channel&.close if queue&.channel.respond_to?(:close) && queue.channel.open? + timer = inst.instance_variable_get(:@timer) + timer&.kill if timer.respond_to?(:kill) + inst.instance_variable_set(:@loop, false) if inst.instance_variable_defined?(:@loop) + rescue StandardError => e + Legion::Logging.debug "Force-close failed: #{e.message}" if defined?(Legion::Logging) + end + end + + @running_instances&.clear + + Legion::Dispatch.shutdown if defined?(Legion::Dispatch) && Legion::Dispatch.instance_variable_get(:@dispatcher) - Legion::Logging.info 'Successfully shut down all actors' + transition_loaded_extensions(:stopped) { |name| unregister_capabilities(name) } + Legion::Logging.info "Successfully shut down all actors (#{(Time.now - shutdown_start).round(1)}s)" end - def load_extensions - @extensions ||= {} - @loaded_extensions ||= [] - @extensions.each do |extension, values| - if values.key(:enabled) && !values[:enabled] - Legion::Logging.info "Skipping #{extension} because it's disabled" + def flush_pending_registrations! + return if @pending_registrations.nil? || @pending_registrations.empty? + + registrations = @pending_registrations + @pending_registrations = nil + + # Collect all runner hashes into a single batch payload + batch = registrations.map(&:opts).compact + count = batch.size + return if count.zero? + + Legion::Transport::Messages::LexRegister.new( + function: 'save', + opts: batch + ).publish + + Legion::Logging.info "[Extensions] flushed #{count} pending registrations (batched)" + rescue StandardError => e + Legion::Logging.warn "[Extensions] batch flush failed: #{e.message}" + end + + def require_identity_extensions + find_extensions.select { |entry| entry[:category] == :identity }.each do |entry| + gem_name = entry[:gem_name] + ext_settings = extension_settings_for_entry(entry) + + if ext_settings.is_a?(Hash) && ext_settings.key?(:enabled) && !ext_settings[:enabled] + Legion::Logging.info "Skipping #{gem_name} identity preload because it's disabled" next end - if Legion::Settings[:extensions].key?(extension.to_sym) && Legion::Settings[:extensions][extension.to_sym].key?(:enabled) && !Legion::Settings[:extensions][extension.to_sym][:enabled] # rubocop:disable Layout/LineLength + Catalog.register(gem_name) + register_extension_handle(gem_name, state: :registered, + latest_installed_version: latest_installed_version(gem_name)) + ensure_namespace(entry[:const_path]) if entry[:segments].length > 1 + gem_load(entry) + end + end + + def pause_actors + @running_instances&.each do |inst| + timer = inst.instance_variable_get(:@timer) + timer&.shutdown if timer.respond_to?(:shutdown) + rescue StandardError => e + Legion::Logging.error "pause_actors: #{e.class}: #{e.message}" if defined?(Legion::Logging) + end + Legion::Logging.warn 'All actors paused' if defined?(Legion::Logging) + end + + def load_phase_extensions(phase_num, entries) + eligible = entries.filter_map do |entry| + gem_name = entry[:gem_name] + ext_settings = extension_settings_for_entry(entry) + + if ext_settings.is_a?(Hash) && ext_settings.key?(:enabled) && !ext_settings[:enabled] + Legion::Logging.info "Skipping #{gem_name} because it's disabled" next end - unless load_extension(extension, values) - Legion::Logging.warn("#{extension} failed to load") - next + Catalog.register(gem_name) + register_extension_handle(gem_name, state: :registered, + latest_installed_version: latest_installed_version(gem_name)) + entry + end + + load_extensions_parallel(eligible) + + Legion::Logging.info( + "Phase #{phase_num}: #{eligible.count} extensions loaded " \ + "(subscription:#{@subscription_tasks.count}," \ + "every:#{@timer_tasks.count}," \ + "poll:#{@poll_tasks.count}," \ + "once:#{@once_tasks.count}," \ + "loop:#{@loop_tasks.count})" + ) + end + + def hook_phase_actors(phase_num) + return if @pending_actors.nil? || @pending_actors.empty? + + Legion::Logging.info "Phase #{phase_num}: hooking #{@pending_actors.size} deferred actors" + + groups = group_pending_actors + + %i[once poll every loop].each do |type| + next if groups[type].empty? + + groups[type].each { |actor| hook_actor(**actor) } + end + + hook_subscription_actors_pooled(groups[:subscription]) unless groups[:subscription].empty? + + dispatch_local_actors(@local_tasks) unless @local_tasks.empty? + + @pending_actors.clear + end + + def load_extensions_parallel(eligible) + return if eligible.empty? + + if defined?(Legion::Transport::Connection) && Legion::Transport::Connection.respond_to?(:open_build_session) + Legion::Transport::Connection.open_build_session + end + + max_threads = Legion::Settings.dig(:extensions, :parallel_pool_size) || 24 + pool_size = [eligible.count, max_threads].min + executor = Concurrent::FixedThreadPool.new(pool_size) + + futures = eligible.map do |entry| + Concurrent::Promises.future_on(executor, entry) do |e| + Thread.current[:legion_build_session] = true + load_extension(e) ? e : nil + end + end + + results = futures.map(&:value) + + executor.shutdown + executor.wait_for_termination(30) + + if defined?(Legion::Transport::Connection) && Legion::Transport::Connection.respond_to?(:close_build_session) + Legion::Transport::Connection.close_build_session + end + + results.each_with_index do |result, idx| + if result + Catalog.transition(result[:gem_name], :loaded) + transition_extension_handle(result[:gem_name], :loaded) + register_in_registry(gem_name: result[:gem_name], version: result[:version]) + @loaded_extensions.push(result[:gem_name]) + else + transition_extension_handle(eligible[idx][:gem_name], :failed) + Legion::Logging.warn("#{eligible[idx][:gem_name]} failed to load") end - @loaded_extensions.push(extension) - sleep(0.1) end - Legion::Logging.info "#{@extensions.count} extensions loaded with subscription:#{@subscription_tasks.count},every:#{@timer_tasks.count},poll:#{@poll_tasks.count},once:#{@once_tasks.count},loop:#{@loop_tasks.count}" end - def load_extension(extension, values) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/AbcSize - return unless gem_load(values[:gem_name], extension) + def load_extension(entry) # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity, Metrics/AbcSize, Metrics/MethodLength + ensure_namespace(entry[:const_path]) if entry[:segments].length > 1 + return unless gem_load(entry) - extension = Kernel.const_get(values[:extension_class]) - extension.extend Legion::Extensions::Core unless extension.singleton_class.included_modules.include? Legion::Extensions::Core + extension = Kernel.const_get(entry[:const_path]) + extension.extend Legion::Extensions::Core unless extension.singleton_class.include?(Legion::Extensions::Core) - min_version = Legion::Settings[:extensions][values[:extension_name]][:min_version] || nil - Legion::Logging.fatal values if min_version.is_a?(String) && Gem::Version.new(values[:version]) >= Gem::Version.new(min_version) + ext_name = entry[:segments].join('_') + ext_settings = extension_settings_for_entry(entry) + min_version = ext_settings[:min_version] if ext_settings.is_a?(Hash) + if min_version.is_a?(String) + begin + gem_spec = Gem::Specification.find_by_name(entry[:gem_name]) + if Gem::Version.new(gem_spec.version.to_s) < Gem::Version.new(min_version) + Legion::Logging.warn "#{entry[:gem_name]} v#{gem_spec.version} below min_version #{min_version}, skipping" + return false + end + rescue Gem::MissingSpecError + Legion::Logging.warn "Could not find gem spec for #{entry[:gem_name]}, skipping min_version check" + end + end if extension.data_required? && Legion::Settings[:data][:connected] == false - Legion::Logging.warn "#{values[:extension_name]} requires Legion::Data but isn't enabled, skipping" + Legion::Logging.warn "#{ext_name} requires Legion::Data but isn't enabled, skipping" return false end if extension.cache_required? && Legion::Settings[:cache][:connected] == false - Legion::Logging.warn "#{values[:extension_name]} requires Legion::Cache but isn't enabled, skipping" + Legion::Logging.warn "#{ext_name} requires Legion::Cache but isn't enabled, skipping" return false end if extension.crypt_required? && Legion::Settings[:crypt][:cs].nil? - Legion::Logging.warn "#{values[:extension_name]} requires Legion::Crypt but isn't ready, skipping" + Legion::Logging.warn "#{ext_name} requires Legion::Crypt but isn't ready, skipping" return false end if extension.vault_required? && Legion::Settings[:crypt][:vault][:connected] == false - Legion::Logging.warn "#{values[:extension_name]} requires Legion::Crypt::Vault but isn't enabled, skipping" + Legion::Logging.warn "#{ext_name} requires Legion::Crypt::Vault but isn't enabled, skipping" + return false + end + + if extension.llm_required? && (Legion::Settings[:llm].nil? || Legion::Settings[:llm][:connected] == false) + Legion::Logging.warn "#{ext_name} requires Legion::LLM but isn't enabled, skipping" + return false + end + + if extension.respond_to?(:skills_required?) && extension.skills_required? && + !Object.const_defined?('Legion::LLM::Skills', false) + Legion::Logging.warn "#{ext_name} requires Legion::LLM::Skills but isn't loaded, skipping" return false end has_logger = extension.respond_to?(:log) extension.autobuild - require 'legion/transport/messages/lex_register' - Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners).publish + register_capabilities(entry[:gem_name], extension.runners) if extension.respond_to?(:runners) + write_lex_cli_manifest(entry, extension) + register_absorber_capabilities(entry[:gem_name], extension.absorbers) if extension.respond_to?(:absorbers) - if extension.respond_to?(:meta_actors) && extension.meta_actors.is_a?(Array) - extension.meta_actors.each do |_key, actor| - extension.log.debug("hooking meta actor: #{actor}") if has_logger - hook_actor(**actor) + if extension.respond_to?(:meta_actors) && extension.meta_actors.is_a?(Hash) + extension.meta_actors.each_value do |actor| + extension.log.debug("deferring meta actor: #{actor}") if has_logger + @pending_actors << actor end end - extension.actors.each do |_key, actor| - extension.log.debug("hooking literal actor: #{actor}") if has_logger - hook_actor(**actor) + extension.actors.each_value do |actor| + extension.log.debug("deferring literal actor: #{actor}") if has_logger + @pending_actors << actor end + + autobuild_submodules(extension, has_logger) extension.log.info "Loaded v#{extension::VERSION}" + Legion::Events.emit('extension.loaded', name: ext_name, version: entry[:gem_name]) + + require 'legion/transport/messages/lex_register' + registration = Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners) + if @pending_registrations + @pending_registrations << registration + else + registration.publish + end + + begin + if defined?(Legion::Data) && defined?(Legion::Data::Model::DigitalWorker) + worker_id = "lex-#{ext_name}" + worker = Legion::Data::Model::DigitalWorker.find_or_create(worker_id: worker_id) do |w| + w.name = ext_name + w.extension_name = ext_name + w.lifecycle_state = 'active' + w.risk_tier = 'low' + w.team = 'extensions' + w.consent_tier = 'supervised' + w.entra_app_id = worker_id + w.owner_msid = 'system' + end + worker.update(updated_at: Time.now) if worker.updated_at + end + rescue StandardError => e + Legion::Logging.debug "Extensions#load_extension failed to register digital worker for #{ext_name}: #{e.message}" if defined?(Legion::Logging) + nil + end + register_extension_handle(entry[:gem_name], spec: entry[:spec], state: :loaded, loaded_at: Time.now, + latest_installed_version: latest_installed_version(entry[:gem_name])) + true rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace + Legion::Logging.log_exception(e, lex: entry[:gem_name], component_type: :boot) false end + ACTOR_TYPE_MAP = { + Once: :once, + Poll: :poll, + Every: :every, + Loop: :loop, + Subscription: :subscription + }.freeze + + def group_by_phase + settings_cats = ::Legion::Settings.dig(:extensions, :categories) || {} + categories = default_category_registry.merge(settings_cats) + default_phase = 1 + + @extensions.group_by do |entry| + cat = entry[:category] + categories.dig(cat, :phase) || default_phase + end.sort_by(&:first) + end + + def load_llm_extension_phases(base_entries, extension_entries) + run_extension_phase(:llm_base, base_entries) + + Legion::Logging.warn 'lex-llm-* extensions discovered without lex-llm; provider loading may fail' if base_entries.empty? && extension_entries.any? + + run_extension_phase(:llm_extensions, extension_entries.sort_by { |entry| entry[:gem_name] }) + end + + def before_llm_extension_phase?(phase_num) + phase_num.is_a?(Numeric) && phase_num < 1 + end + + def run_extension_phase(phase_num, entries) + return if entries.empty? + + @pending_actors = Concurrent::Array.new + load_phase_extensions(phase_num, entries) + hook_phase_actors(phase_num) + end + + def extract_llm_extension_entries!(phases) + base_entries = [] + extension_entries = [] + + phases.each do |(_, entries)| + entries.delete_if do |entry| + next false unless llm_extension_entry?(entry) + + if llm_base_extension_entry?(entry) + base_entries << entry + else + extension_entries << entry + end + true + end + end + phases.reject! { |_, entries| entries.empty? } + + [base_entries, extension_entries] + end + + def llm_extension_entry?(entry) + llm_base_extension_entry?(entry) || entry[:gem_name].start_with?('lex-llm-') + end + + def llm_base_extension_entry?(entry) + entry[:gem_name] == 'lex-llm' + end + + def group_pending_actors + groups = { once: [], poll: [], every: [], loop: [], subscription: [] } + @pending_actors.each do |actor| + type = resolve_actor_type(actor[:actor_class]) + groups[type] << actor + end + groups + end + + def resolve_actor_type(actor_class) + anc = actor_class.ancestors + ACTOR_TYPE_MAP.each do |const, type| + return type if anc.include?(Legion::Extensions::Actors.const_get(const)) + end + Legion::Logging.warn "Unknown actor type for #{actor_class}, defaulting to loop" + :loop + end + def hook_actor(extension:, extension_name:, actor_class:, size: 1, **opts) - size = if Legion::Settings[:extensions].key?(extension_name.to_sym) && Legion::Settings[:extensions][extension_name.to_sym].key?(:workers) - Legion::Settings[:extensions][extension_name.to_sym][:workers] + ext_settings = extension_settings_for_actor(extension_name, opts[:settings_path]) + size = if ext_settings.is_a?(Hash) && ext_settings.key?(:workers) + ext_settings[:workers] elsif size.is_a? Integer size else @@ -139,90 +479,785 @@ def hook_actor(extension:, extension_name:, actor_class:, size: 1, **opts) if actor_class.ancestors.include? Legion::Extensions::Actors::Every @timer_tasks.push(extension_hash) + @running_instances << extension_hash[:running_class] elsif actor_class.ancestors.include? Legion::Extensions::Actors::Once @once_tasks.push(extension_hash) + @running_instances << extension_hash[:running_class] elsif actor_class.ancestors.include? Legion::Extensions::Actors::Loop @loop_tasks.push(extension_hash) + @running_instances << extension_hash[:running_class] elsif actor_class.ancestors.include? Legion::Extensions::Actors::Poll @poll_tasks.push(extension_hash) + @running_instances << extension_hash[:running_class] elsif actor_class.ancestors.include? Legion::Extensions::Actors::Subscription - extension_hash[:threadpool] = Concurrent::FixedThreadPool.new(size) + hook_subscription_actors_pooled([extension_hash]) + else + Legion::Logging.fatal "#{actor_class} did not match any actor classes (ancestors: #{actor_class.ancestors.first(5).map(&:to_s)})" + end + end + + def register_in_registry(gem_name:, version: nil, description: nil) + return unless defined?(Legion::Registry) + return if Legion::Registry.lookup(gem_name) + + capabilities = read_gemspec_capabilities(gem_name) + entry = Legion::Registry::Entry.new( + name: gem_name, + version: version, + description: description, + capabilities: capabilities, + airb_status: 'pending', + risk_tier: 'low' + ) + Legion::Registry.register(entry) + register_sandbox_policy(gem_name: gem_name, capabilities: capabilities) + end + + def register_sandbox_policy(gem_name:, capabilities: []) + return unless defined?(Legion::Sandbox) + + Legion::Sandbox.register_policy(gem_name, capabilities: capabilities) + end + + private + + def autobuild_submodules(extension, has_logger) + return unless extension.is_a?(Module) + + extension.constants(false).each do |const_name| + next if SUBMODULE_SKIP.include?(const_name) + + submod = extension.const_get(const_name, false) + next unless submod.is_a?(Module) && submod.respond_to?(:autobuild) + + autobuild_one_submodule(extension, submod, const_name, has_logger) + rescue StandardError => e + Legion::Logging.warn "autobuild_submodules: failed for #{extension}::#{const_name} — #{e.message}" if defined?(Legion::Logging) + end + end + + def autobuild_one_submodule(extension, submod, const_name, has_logger) + submod.autobuild + collect_submodule_actors(submod, has_logger) + register_submodule_capabilities(extension, submod, const_name) + autobuild_submodules(submod, has_logger) + end + + def collect_submodule_actors(submod, has_logger) + if submod.respond_to?(:meta_actors) && submod.meta_actors.is_a?(Hash) + submod.meta_actors.each_value do |actor| + submod.log.debug("deferring submodule meta actor: #{actor}") if has_logger + @pending_actors << actor + end + end + + return unless submod.respond_to?(:actors) + + submod.actors.each_value do |actor| + submod.log.debug("deferring submodule literal actor: #{actor}") if has_logger + @pending_actors << actor + end + end + + def register_submodule_capabilities(extension, submod, const_name) + return unless submod.respond_to?(:runners) + + prefix = extension.respond_to?(:lex_name) ? extension.lex_name : extension.name + register_capabilities("#{prefix}/#{const_name}", submod.runners) + end + + def write_lex_cli_manifest(entry, extension) + require 'legion/cli/lex_cli_manifest' + + gem_name = entry[:gem_name] + gem_version = extension.const_defined?(:VERSION) ? extension::VERSION : '0.0.0' + + manifest = Legion::CLI::LexCliManifest.new + return unless manifest.stale?(gem_name, gem_version) + + alias_name = gem_name.delete_prefix('lex-') + commands = build_manifest_commands(extension) + manifest.write_manifest(gem_name: gem_name, gem_version: gem_version, + alias_name: alias_name, commands: commands) + rescue StandardError => e + Legion::Logging.debug "LexCliManifest write failed for #{gem_name}: #{e.message}" if defined?(Legion::Logging) + end + + def build_manifest_commands(extension) + return {} unless extension.respond_to?(:runners) + + extension.runners.each_with_object({}) do |(runner_name, meta), cmds| + runner_mod = meta[:runner_module] + next unless runner_mod + + methods = (meta[:class_methods] || {}).each_with_object({}) do |(fn_name, fn_meta), meths| + next if fn_name.to_s.start_with?('_') + + args = (fn_meta[:args] || []).map { |type, name| "#{name}:#{type}" } + meths[fn_name.to_s] = { desc: fn_name.to_s.tr('_', ' '), args: args } + end + next if methods.empty? + + cmds[runner_name.to_s] = { class_name: runner_mod.to_s, methods: methods } + end + end + + def read_gemspec_capabilities(gem_name) + spec = Gem::Specification.find_by_name(gem_name) + raw = spec.metadata['legion.capabilities'] + return [] unless raw + + raw.split(',').map(&:strip) + rescue Gem::MissingSpecError => e + Legion::Logging.debug "Extensions#read_gemspec_capabilities could not find spec for #{gem_name}: #{e.message}" if defined?(Legion::Logging) + [] + end + + def hook_subscription_actors_pooled(sub_actors) + max_channels = Legion::Settings.dig(:transport, :subscription_pool_size) || 16 + prepared = [] + + # Phase 1: Prepare all consumers (parallel, shared pool) + pool_size = [sub_actors.size, max_channels].min + @subscription_pool = Concurrent::FixedThreadPool.new(pool_size) + + sub_actors.each do |actor_hash| + actor_class = actor_hash[:actor_class] + ext_name = actor_hash[:extension_name] + size = resolve_subscription_worker_count(actor_hash) + + unless resolve_remote_invocable(ext_name, actor_hash) + @local_tasks.push(actor_hash) + next + end + size.times do - extension_hash[:threadpool].post do - klass = actor_class.new - if klass.respond_to?(:async) - klass.async.subscribe - else - klass.subscribe - end + entry = { actor_hash: actor_hash, instance: nil } + prepared << entry + @subscription_pool.post do + instance = actor_class.new + instance.prepare if instance.respond_to?(:prepare) + entry[:instance] = instance + rescue StandardError => e + Legion::Logging.error "Subscription prepare failed for #{ext_name}: #{e.message}" if defined?(Legion::Logging) end end - @subscription_tasks.push(extension_hash) - else - Legion::Logging.fatal 'did not match any actor classes' + + actor_hash[:running_class] = actor_class + @subscription_tasks.push(actor_hash) + end + + @subscription_pool.shutdown + @subscription_pool.wait_for_termination(30) + + # Phase 2: Activate sequentially (one basic.consume at a time) + prepared.each do |entry| + next unless entry[:instance] + + begin + entry[:instance].activate if entry[:instance].respond_to?(:activate) + @running_instances << entry[:instance] + rescue StandardError => e + ext_name = entry[:actor_hash][:extension_name] + Legion::Logging.error "[Subscription] activate failed for #{ext_name}: #{e.message}" if defined?(Legion::Logging) + end end end - def gem_load(gem_name, name) - require "#{Gem::Specification.find_by_name(gem_name).gem_dir}/lib/legion/extensions/#{name}" + def resolve_subscription_worker_count(actor_hash) + ext_settings = extension_settings_for_actor(actor_hash[:extension_name], actor_hash[:settings_path]) + + return ext_settings[:workers] if ext_settings.is_a?(Hash) && ext_settings.key?(:workers) + return actor_hash[:size] if actor_hash[:size].is_a?(Integer) + + actor_class = actor_hash[:actor_class] + # Check DSL-defined consumers + return actor_class.consumers if actor_class.respond_to?(:consumers) && actor_class.consumers.is_a?(Integer) + # Check size method + return actor_class.size if actor_class.respond_to?(:size) && actor_class.size.is_a?(Integer) + + 1 + end + + def resolve_remote_invocable(extension_name, opts = {}) + ext_settings = extension_settings_for_actor(extension_name, opts[:settings_path]) + runner_name = opts[:actor_name]&.to_sym + + # 1. Per-runner settings override + runner_setting = ext_settings&.dig(:runners, runner_name, :remote_invocable) + return runner_setting unless runner_setting.nil? + + # 2. Extension settings override + ext_setting = ext_settings&.dig(:remote_invocable) + return ext_setting unless ext_setting.nil? + + # 3. Runner class method (only if defined directly on the runner, not inherited) + runner_class = opts[:runner_class] + if runner_class.respond_to?(:remote_invocable?) + owner = runner_class.method(:remote_invocable?).owner + return runner_class.remote_invocable? if owner == runner_class.singleton_class || !owner.singleton_class? + end + + # 4. Extension module method + extension = opts[:extension] + return extension.remote_invocable? if extension.respond_to?(:remote_invocable?) + + # 5. Default + true + end + + def dispatch_local_actors(actors) + require 'legion/dispatch' + + actors.each do |actor_hash| + ext_name = actor_hash[:extension_name] + + runner_mod = actor_hash[:runner_class] + unless runner_mod + actor_str = actor_hash[:actor_class].to_s + runner_str = actor_str.sub('::Actor::', '::Runners::') + runner_mod = begin + Kernel.const_get(runner_str) + rescue NameError + Legion::Logging.warn "[LocalDispatch] runner not found for #{ext_name}: #{runner_str}" if defined?(Legion::Logging) + next + end + end + + actor_hash[:runner_module] = runner_mod + actor_hash[:running_class] = actor_hash[:actor_class] + @running_instances&.push(actor_hash[:actor_class]) + + Legion::Logging.info "[LocalDispatch] registered: #{ext_name}/#{actor_hash[:actor_name]}" if defined?(Legion::Logging) + end + end + + public + + def loaded_extension_modules + handles = extension_handles + active_names = handles.select(&:dispatchable?).map(&:lex_name) + constants(false).filter_map do |const_name| + mod = const_get(const_name, false) + next nil unless mod.is_a?(Module) && mod.respond_to?(:runner_modules) + next nil if handles.any? && !active_names.include?(module_lex_name(mod)) + + mod + rescue StandardError => e + Legion::Logging.warn("[Extensions] loaded_extension_modules: #{e.message}") if defined?(Legion::Logging) + nil + end + end + + # Legacy capability registration - now handled by Tools::Discovery + def unregister_capabilities(gem_name) + return unless defined?(Legion::Tools::Registry) && Legion::Tools::Registry.respond_to?(:unregister_extension) + + Legion::Tools::Registry.unregister_extension(gem_name) + end + + def register_absorber_capabilities(_gem_name, _absorbers); end + + def register_capabilities(_gem_name, _runners); end + + def gem_load(entry) + gem_name = entry[:gem_name] + require_path = entry[:require_path] + spec = Gem::Specification.find_by_name(gem_name) + gem_dir = spec.gem_dir + entry[:spec] = spec + entry[:version] = spec.version.to_s + require "#{gem_dir}/lib/#{require_path}" true + rescue Gem::MissingSpecError => e + Legion::Logging.warn "#{gem_name} gem not found: #{e.message}" + nil rescue LoadError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace - Legion::Logging.error "gem_path: #{gem_path}" unless gem_path.nil? - false + Legion::Logging.warn "#{gem_name} failed to load: #{e.message}" + nil + end + + def ensure_namespace(const_path) + parts = const_path.split('::') + current = ::Legion::Extensions + parts[2...-1].each do |part| + current.const_set(part, Module.new) unless current.const_defined?(part, false) + current = current.const_get(part, false) + end + end + + def gem_names_for_discovery + if defined?(Bundler) + Bundler.load.specs.map { |s| { name: s.name, version: s.version.to_s } } + else + Gem::Specification.latest_specs.map { |s| { name: s.name, version: s.version.to_s } } + end + end + + def apply_role_filter + role = Legion::Settings[:role] + return if role.nil? || role[:profile].nil? + + profile = role[:profile].to_sym + allowed = allowed_gem_names_for_profile(profile, role) + return if allowed.nil? + + before = @extensions.count + @extensions.select! { |entry| allowed.include?(entry[:gem_name]) } + Legion::Logging.info "Role profile :#{profile} filtered #{before} -> #{@extensions.count} extensions" + end + + def core_extension_names + %w[codegen conditioner exec health lex log metering node ping scheduler tasker task_pruner telemetry + transformer].freeze + end + + def ai_extension_names + native_llm_extension_names + end + + def native_llm_extension_names + %w[ + llm + llm-anthropic + llm-azure-foundry + llm-bedrock + llm-gemini + llm-ledger + llm-mlx + llm-ollama + llm-openai + llm-vertex + llm-vllm + ].freeze + end + + def legacy_ai_extension_names + %w[azure-ai bedrock claude foundry gemini ollama openai xai].freeze + end + + def service_extension_names + %w[consul github http microsoft_teams nomad redis s3 tfe vault].freeze + end + + def other_extension_names + %w[chef elastic_app_search elasticsearch influxdb memcached pagerduty pushbullet pushover slack sleepiq smtp + sonos ssh todoist twilio].freeze + end + + def dev_agentic_names + %w[attention coldstart curiosity dream empathy flow habit memory metacognition mood narrator personality + reflection salience temporal tick volition].freeze + end + + def agentic_extension_names + known_gem_names = ( + core_extension_names + service_extension_names + other_extension_names + + ai_extension_names + legacy_ai_extension_names + ).map { |n| "lex-#{n}" } + Array(@extensions).reject { |entry| known_gem_names.include?(entry[:gem_name]) }.map { |entry| entry[:gem_name] } + end + + def categorize_and_order(gem_names) + ext_settings = ::Legion::Settings[:extensions] || {} + categories = ext_settings[:categories] || default_category_registry + lists = { + identity: Array(ext_settings[:identity]), + core: Array(ext_settings[:core]), + ai: Array(ext_settings[:ai]), + gaia: Array(ext_settings[:gaia]) + } + ctx = { + blocked: Array(ext_settings[:blocked]), + agentic_cfg: ext_settings[:agentic] || {}, + categories: categories, + gem_set: gem_names.to_set, + ordered: [], + claimed: Set.new + } + + collect_list_category_gems(lists, ctx) + collect_prefix_category_gems(gem_names, ctx) + + (gem_names.to_a - ctx[:claimed].to_a - ctx[:blocked]).sort.each do |gn| + ctx[:ordered] << build_extension_entry(gn, :default, categories, nesting: false) + end + + ctx[:ordered] + end + + def check_reserved_words(gem_name, known_org: true) + return if known_org + + bare = gem_name.delete_prefix('lex-') + first_segment = bare.split('-').first + + configured_prefixes = begin + Array(::Legion::Settings.dig(:extensions, :reserved_prefixes)) + rescue StandardError => e + Legion::Logging.debug "Extensions#check_reserved_words failed to read reserved_prefixes: #{e.message}" if defined?(Legion::Logging) + [] + end + reserved_prefixes = configured_prefixes.empty? ? %w[core ai agentic gaia identity] : configured_prefixes + + configured_words = begin + Array(::Legion::Settings.dig(:extensions, :reserved_words)) + rescue StandardError => e + Legion::Logging.debug "Extensions#check_reserved_words failed to read reserved_words: #{e.message}" if defined?(Legion::Logging) + [] + end + reserved_words = configured_words.empty? ? %w[transport cache crypt data settings json logging llm rbac legion] : configured_words + + if reserved_prefixes.include?(first_segment) + ::Legion::Logging.warn( + "#{gem_name} uses reserved prefix '#{first_segment}' — " \ + "it will be loaded in the #{first_segment} category namespace" + ) + elsif reserved_words.include?(first_segment) + ::Legion::Logging.warn( + "#{gem_name} uses reserved word '#{first_segment}' as its first segment — " \ + 'this may shadow framework modules' + ) + end end def find_extensions - @extensions ||= {} - Gem::Specification.all_names.each do |gem| - next unless gem[0..3] == 'lex-' + return @extensions if @extensions + + all_specs = gem_names_for_discovery + lex_names = all_specs.select { |s| s[:name].start_with?('lex-') }.map { |s| s[:name] } + @extensions = categorize_and_order(lex_names) + apply_role_filter + @extensions + end + + def loaded_extensions + extension_handle_registry.loaded.map(&:lex_name) + end + + def extension_handles + extension_handle_registry.all + end + + def extension_handle(name) + extension_handle_registry.fetch(name) + end + + def register_extension_handle(name, **attrs) + extension_handle_registry.register(name, **attrs) + end + + def transition_extension_handle(name, state) + extension_handle_registry.transition(name, state) + end + + def update_extension_handle(name, **attrs) + extension_handle_registry.update(name, **attrs) + end + + def reset_runtime_handles! + extension_handle_registry.reset! + end + + def dispatch_allowed?(lex_name) + extension_handle_registry.dispatch_allowed?(normalize_lex_name(lex_name)) + end + + def dispatch_allowed_for_runner?(runner_class) + lex_name = lex_name_for_runner_class(runner_class) + return true unless lex_name - lex = gem.split('-') - @extensions[lex[1]] = { full_gem_name: gem, - gem_name: "lex-#{lex[1]}", - extension_name: lex[1], - version: lex[2], - extension_class: "Legion::Extensions::#{lex[1].split('_').collect(&:capitalize).join}" } + dispatch_allowed?(lex_name) + end + + def record_extension_resource(lex_name, resource_type, value) + handle = extension_handle(lex_name) || register_extension_handle(normalize_lex_name(lex_name)) + values = Array(handle.public_send(resource_type)) + return handle if values.include?(value) + + update_extension_handle(handle.lex_name, resource_type => values + [value]) + end + + def reload_extension(name) + gem_name = normalize_lex_name(name) + update_extension_handle(gem_name, reload_state: :updating) + unregister_capabilities(gem_name) + reset_runner_cache + + entry = @extensions&.find { |candidate| candidate[:gem_name] == gem_name } + raise "#{gem_name} failed to reload" if entry && !load_extension(entry) + + refresh_llm_provider_registry(gem_name) + update_extension_handle(gem_name, state: :running, reload_state: :idle, last_error: nil, + latest_installed_version: latest_installed_version(gem_name)) + true + rescue StandardError => e + update_extension_handle(gem_name, reload_state: :failed, last_error: e.message) + raise + end + + def extension_handle_registry + @extension_handle_registry ||= HandleRegistry.new + end + + def refresh_llm_provider_registry(gem_name) + return unless gem_name.start_with?('lex-llm-') && gem_name != 'lex-llm-ledger' + return unless defined?(Legion::LLM::Call::Providers) + + Legion::LLM::Call::Providers.rediscover_all_providers + end + + def transition_loaded_extensions(state) + @loaded_extensions&.each do |name| + Catalog.transition(name, state) + transition_extension_handle(name, state) + yield name if block_given? end + end + + def load_yaml_agents + @load_yaml_agents ||= begin + require 'legion/settings/agent_loader' + dir = default_agents_directory + definitions = Legion::Settings::AgentLoader.load_agents(dir) + definitions.each { |d| d[:_runner_module] = generate_yaml_runner(d) } + definitions + rescue LoadError => e + Legion::Logging.debug "Extensions#load_yaml_agents failed to load agent loader: #{e.message}" if defined?(Legion::Logging) + [] + end + end + + private + + def latest_installed_version(gem_name) + Gem::Specification.find_all_by_name(gem_name).map(&:version).max + rescue StandardError + nil + end - enabled = 0 - requested = 0 + def extension_settings_for_entry(entry) + extension_settings_for_path(entry[:settings_path]) + end + + def extension_settings_for_actor(extension_name, settings_path) + extension_settings_for_path(settings_path) || Legion::Settings.dig(:extensions, extension_name.to_sym) + end + + def extension_settings_for_path(settings_path) + path = Array(settings_path).map(&:to_sym) + return nil if path.empty? + + Legion::Settings.dig(:extensions, *path) + end - Legion::Settings[:extensions].each do |extension, values| - next if @extensions.key? extension.to_s - next if values[:enabled] == false + def reset_runner_cache + return unless defined?(Legion::Ingress) && Legion::Ingress.respond_to?(:reset_runner_cache!) - requested += 1 - next if values[:auto_install] == false - next if ENV['_'].include? 'bundle' + Legion::Ingress.reset_runner_cache! + end + + def normalize_lex_name(name) + str = name.to_s + str.start_with?('lex-') ? str : "lex-#{str.tr('.', '-').tr('_', '-')}" + end - Legion::Logging.warn "#{extension} is missing, attempting to install automatically.." - install = Gem.install("lex-#{extension}", values[:version]) - Legion::Logging.debug(install) - lex = Gem::Specification.find_by_name("lex-#{extension}") + def module_lex_name(mod) + parts = mod.name.to_s.split('::') + idx = parts.index('Extensions') + return nil unless idx + + extension_parts = extension_parts_from_const(parts, idx) + return nil if extension_parts.empty? + + "lex-#{extension_parts.join('-')}" + end - @extensions[extension.to_s] = { - full_gem_name: "lex-#{extension}-#{lex.version}", - gem_name: "lex-#{extension}", - extension_name: extension.to_s, - version: lex.version, - extension_class: "Legion::Extensions::#{extension.to_s.split('_').collect(&:capitalize).join}" - } + def lex_name_for_runner_class(runner_class) + parts = runner_class.to_s.split('::') + idx = parts.index('Extensions') + return nil unless idx - enabled += 1 + extension_parts = extension_parts_from_const(parts, idx) + return nil if extension_parts.empty? - rescue StandardError, Gem::MissingSpecError => e - Legion::Logging.error "Failed to auto install #{extension}, e: #{e.message}" + "lex-#{extension_parts.join('-')}" + end + + def extension_parts_from_const(parts, idx) + parts[(idx + 1)..].to_a.each_with_object([]) do |part, extension_parts| + break extension_parts if %w[Actor Actors Runners Helpers Transport Data Hooks Skills].include?(part) + + extension_parts << camel_to_snake(part) end - return true if requested == enabled + end + + def camel_to_snake(value) + value.to_s.gsub(/(? e + Legion::Logging.debug "Extensions#default_agents_directory failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def generate_yaml_runner(definition) + mod = Module.new + definition[:runner][:functions].each do |func| + method_name = func[:name].to_sym + case func[:type] + when 'llm' + prompt_template = func[:prompt] + model = func[:model] + mod.define_method(method_name) do |**kwargs| + prompt = prompt_template.gsub(/\{\{(\w+(?:\.\w+)*)\}\}/) do + keys = Regexp.last_match(1).split('.').map(&:to_sym) + kwargs.dig(*keys).to_s + end + if defined?(Legion::LLM) + Legion::LLM.chat(messages: [{ role: 'user', content: prompt }], model: model, + caller: { source: 'extension', command: 'llm_runner' }) + else + { success: false, reason: :llm_unavailable } + end + end + when 'script' + command = func[:command] + mod.define_method(method_name) do |**kwargs| + require 'open3' + input = defined?(Legion::JSON) ? Legion::JSON.dump(kwargs) : ::JSON.dump(kwargs) + stdout, stderr, status = Open3.capture3(command, stdin_data: input) + { success: status.success?, stdout: stdout, stderr: stderr, exit_code: status.exitstatus } + end + when 'http' + url = func[:url] + mod.define_method(method_name) do |**kwargs| + require 'net/http' + uri = URI(url) + body = defined?(Legion::JSON) ? Legion::JSON.dump(kwargs) : ::JSON.dump(kwargs) + response = Net::HTTP.post(uri, body, 'Content-Type' => 'application/json') + { success: response.is_a?(Net::HTTPSuccess), status: response.code.to_i, body: response.body } + end + end + end + mod + end + + public + + def lex_prefix(names) + names.map { |n| n.start_with?('lex-') ? n : "lex-#{n}" } + end + + def allowed_gem_names_for_profile(profile, role) + case profile + when :core then lex_prefix(core_extension_names) + when :cognitive then lex_prefix(core_extension_names + agentic_extension_names) + when :service then lex_prefix(core_extension_names + service_extension_names + other_extension_names) + when :dev then lex_prefix(core_extension_names + ai_extension_names + dev_agentic_names) + when :custom then lex_prefix(Array(role[:extensions]).map(&:to_s)) + end + end + + def collect_list_category_gems(lists, ctx) + lists.sort_by { |cat, _| ctx[:categories].dig(cat, :tier) || 99 }.each do |cat_name, gem_list| + gem_list.each do |gn| + next unless ctx[:gem_set].include?(gn) + next if ctx[:blocked].include?(gn) + + ctx[:ordered] << build_extension_entry(gn, cat_name, ctx[:categories], nesting: false) + ctx[:claimed].add(gn) + end + end + end + + def collect_prefix_category_gems(gem_names, ctx) + prefix_cats = ctx[:categories].select { |_, v| v[:type].to_s == 'prefix' } + .sort_by { |_, v| v[:tier] || 99 } + .to_h + prefix_cats.each_key do |cat_name| + prefix = "lex-#{cat_name}-" + matched = gem_names.select { |gn| gn.start_with?(prefix) && !ctx[:claimed].include?(gn) }.sort + matched.each do |gn| + next if ctx[:blocked].include?(gn) + next if cat_name == :agentic && agentic_blocked?(gn, ctx[:agentic_cfg]) + next if cat_name == :agentic && !agentic_allowed?(gn, ctx[:agentic_cfg]) + + ctx[:ordered] << build_extension_entry(gn, cat_name, ctx[:categories], nesting: true) + ctx[:claimed].add(gn) + end + end + end + + def build_extension_entry(gem_name, category, categories, nesting:) + segments = Helpers::Segments.derive_segments(gem_name) + tier = category == :default ? 5 : (categories.dig(category, :tier) || 5) + + # Multi-segment gem names: check if the gem actually uses nested directories + # (e.g. lex-agentic-memory -> agentic/memory/) or flat underscored naming + # (e.g. lex-swarm-github -> swarm_github.rb). Probe the gem's lib/ to decide. + nesting = true if segments.length > 1 + nesting = probe_nesting(gem_name, segments) if nesting && segments.length > 1 + + if nesting + const_path = Helpers::Segments.derive_const_path(gem_name) + require_path = Helpers::Segments.derive_require_path(gem_name) else - Legion::Logging.warn 'You must have auto_install_missing_lex set to true to auto install missing extensions' + flat_name = gem_name.delete_prefix('lex-').tr('-', '_') + const_path = "Legion::Extensions::#{flat_name.split('_').map(&:capitalize).join}" + require_path = "legion/extensions/#{flat_name}" end + + { gem_name: gem_name, category: category, tier: tier, + segments: segments, const_path: const_path, require_path: require_path, + settings_path: settings_path_for_entry(segments, nesting) } + end + + def settings_path_for_entry(segments, nesting) + if nesting + segments.map(&:to_sym) + else + [segments.join('_').to_sym] + end + end + + def probe_nesting(gem_name, segments) + gem_dir = Gem::Specification.find_by_name(gem_name).gem_dir + nested_path = "#{gem_dir}/lib/legion/extensions/#{segments.join('/')}.rb" + return true if File.exist?(nested_path) + + flat_path = "#{gem_dir}/lib/legion/extensions/#{segments.join('_')}.rb" + return false if File.exist?(flat_path) + + true # default to nested if neither found + rescue Gem::MissingSpecError + true + end + + def default_category_registry + { + identity: { type: :prefix, tier: 0, phase: 0 }, + core: { type: :list, tier: 1, phase: 1 }, + ai: { type: :list, tier: 2, phase: 1 }, + gaia: { type: :list, tier: 3, phase: 1 }, + agentic: { type: :prefix, tier: 4, phase: 1 } + } + end + + def agentic_blocked?(gem_name, config) + Array(config[:blocked]).any? { |pat| File.fnmatch(pat, gem_name) } + end + + def agentic_allowed?(gem_name, config) + return true if config[:allowed].nil? + + Array(config[:allowed]).any? { |pat| File.fnmatch(pat, gem_name) } end end end diff --git a/lib/legion/extensions/absorbers.rb b/lib/legion/extensions/absorbers.rb new file mode 100644 index 00000000..c9cec4ff --- /dev/null +++ b/lib/legion/extensions/absorbers.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require_relative 'absorbers/matchers/base' +require_relative 'absorbers/matchers/url' +require_relative 'absorbers/matchers/file' +require_relative 'absorbers/base' +require_relative 'absorbers/pattern_matcher' + +module Legion + module Extensions + module Absorbers + end + end +end diff --git a/lib/legion/extensions/absorbers/base.rb b/lib/legion/extensions/absorbers/base.rb new file mode 100644 index 00000000..8d9d2db7 --- /dev/null +++ b/lib/legion/extensions/absorbers/base.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +require_relative '../definitions' +require_relative '../helpers/lex' + +module Legion + module Extensions + module Absorbers + class Base + extend Legion::Extensions::Definitions + include Legion::Extensions::Helpers::Lex + + class TokenRevocationError < StandardError + end + + class TokenUnavailableError < StandardError + end + + attr_accessor :job_id, :runners + + class << self + def pattern(type, value, priority: 100) + @patterns ||= [] + @patterns << { type: type, value: value, priority: priority } + end + + def patterns + @patterns || [] + end + + def description(text = nil) + text ? @description = text : @description + end + end + + def absorb(url: nil, content: nil, metadata: {}, context: {}) + raise NotImplementedError, "#{self.class.name} must implement #absorb" + end + + # @deprecated Use {#absorb} instead. Will be removed in a future major release. + def handle(url: nil, content: nil, metadata: {}, context: {}) + Legion::Logging.warn("#{self.class.name}#handle is deprecated — use #absorb instead") if defined?(Legion::Logging) + absorb(url: url, content: content, metadata: metadata, context: context) + end + + def absorb_to_knowledge(content:, tags: [], scope: :global, **opts) + return fallback_absorb(:chunker, content, tags, scope, opts) unless chunker_available? + + target = resolve_apollo_target(scope) + return fallback_absorb(:apollo, content, tags, scope, opts) unless target + + sections = [{ heading: opts.delete(:heading) || 'absorbed', + content: content, + section_path: opts.delete(:section_path) || 'absorbed', + source_file: opts.delete(:source_file) || 'absorber' }] + chunks = Legion::Extensions::Knowledge::Helpers::Chunker.chunk(sections: sections) + embeddings = fetch_embeddings(chunks) + ingest_chunks(chunks, embeddings, tags, scope, opts) + end + + def absorb_raw(content:, tags: [], scope: :global, **) + target = resolve_apollo_target(scope) + unless target + Legion::Logging.warn("absorb_raw: Apollo not available for scope=#{scope}") if defined?(Legion::Logging) + return { success: false, error: :apollo_not_available } + end + + target.ingest(content: content, tags: Array(tags), scope: scope, **) + end + + def query_knowledge(text:, limit: 5, scope: :all, **) + case scope.to_sym + when :local + return { success: false, error: :apollo_not_available } unless apollo_local_available? + + Legion::Apollo::Local.query(text: text, limit: limit, **) + when :global + return { success: false, error: :apollo_not_available } unless apollo_available? + + Legion::Apollo.query(text: text, limit: limit, **) + else + query_all_scopes(text: text, limit: limit, **) + end + end + + def translate(source, type: :auto) + raise 'legion-data is required for translate — add it to your Gemfile' unless defined?(Legion::Data::Extract) + + Legion::Data::Extract.extract(source, type: type) + end + + def report_progress(message:, percent: nil) + return unless job_id + return unless defined?(Legion::Logging) + + Legion::Logging.info("absorb[#{job_id}] #{"#{percent}% " if percent}#{message}") + end + + def with_token(provider:) + raise TokenUnavailableError, "#{provider} token not available" unless token_manager_for(provider).token_valid? + raise TokenRevocationError, "#{provider} token has been revoked" if token_manager_for(provider).revoked? + + token = token_manager_for(provider).ensure_valid_token + raise TokenUnavailableError, "#{provider} token refresh failed" unless token + + yield token + rescue Legion::Auth::TokenManager::TokenExpiredError => e + raise TokenUnavailableError, e.message + end + + private + + def token_manager_for(provider) + @token_managers ||= {} + @token_managers[provider] ||= begin + require 'legion/auth/token_manager' + Legion::Auth::TokenManager.new(provider: provider) + end + end + + def chunker_available? + defined?(Legion::Extensions::Knowledge::Helpers::Chunker) + end + + def apollo_available? + defined?(Legion::Apollo) && + Legion::Apollo.respond_to?(:ingest) && + (!Legion::Apollo.respond_to?(:started?) || Legion::Apollo.started?) + end + + def apollo_local_available? + defined?(Legion::Apollo::Local) && + Legion::Apollo::Local.respond_to?(:ingest) && + (!Legion::Apollo::Local.respond_to?(:started?) || Legion::Apollo::Local.started?) + rescue NameError + false + end + + def resolve_apollo_target(scope) + case scope.to_sym + when :local + apollo_local_available? ? Legion::Apollo::Local : nil + else + apollo_available? ? Legion::Apollo : nil + end + end + + def query_all_scopes(text:, limit:, **) + local_results = apollo_local_available? ? Array((Legion::Apollo::Local.query(text: text, limit: limit, **) || {})[:results]) : [] + global_results = apollo_available? ? Array((Legion::Apollo.query(text: text, limit: limit, **) || {})[:results]) : [] + + if local_results.empty? && global_results.empty? && !apollo_local_available? && !apollo_available? + return { success: false, error: :apollo_not_available } + end + + seen = {} + merged = [] + local_results.each do |r| + key = r[:content_hash] || r[:content] + seen[key] = true + merged << r + end + global_results.each do |r| + key = r[:content_hash] || r[:content] + merged << r unless seen[key] + end + + { success: true, results: merged.first(limit), count: [merged.size, limit].min, scope: :all } + end + + def fallback_absorb(reason, content, tags, scope, opts) + if defined?(Legion::Logging) + label = reason == :chunker ? 'lex-knowledge not available' : 'Apollo not available' + Legion::Logging.warn("absorb_to_knowledge: #{label}, falling back to absorb_raw") + end + absorb_raw(content: content, tags: tags, scope: scope, **opts) + end + + def fetch_embeddings(chunks) + return [] unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:embed_batch) + + Legion::LLM.embed_batch(chunks.map { |c| c[:content] }) + rescue StandardError + [] + end + + def ingest_chunks(chunks, embeddings, tags, scope, opts) + target = resolve_apollo_target(scope) + return unless target + + chunks.each_with_index do |chunk, idx| + vector = embeddings.is_a?(Array) ? embeddings.dig(idx, :vector) : nil + payload = build_chunk_payload(chunk, tags, opts) + payload[:embedding] = vector if vector + target.ingest(content: payload[:content], tags: payload[:tags], + scope: scope, **payload.except(:content, :tags)) + end + end + + def build_chunk_payload(chunk, tags, opts) + payload = { + content: chunk[:content], + content_type: opts[:content_type] || 'absorbed_chunk', + content_hash: chunk[:content_hash], + tags: (Array(tags) + [chunk[:heading], 'absorbed']).compact.uniq, + metadata: { + source_file: chunk[:source_file], + heading: chunk[:heading], + section_path: chunk[:section_path], + chunk_index: chunk[:chunk_index], + token_count: chunk[:token_count] + }.merge(opts.fetch(:metadata, {})) + } + payload[:access_scope] = opts[:access_scope] if opts.key?(:access_scope) + payload + end + end + end + end +end diff --git a/lib/legion/extensions/absorbers/dispatch.rb b/lib/legion/extensions/absorbers/dispatch.rb new file mode 100644 index 00000000..e0cd8e0a --- /dev/null +++ b/lib/legion/extensions/absorbers/dispatch.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'uri' +require_relative 'pattern_matcher' + +module Legion + module Extensions + module Absorbers + module Dispatch + @dispatched = [] + @mutex = Mutex.new + + module_function + + def dispatch(input, context: {}) + context = default_context.merge(context) + + return { status: :depth_exceeded, input: input } if context[:depth] >= context[:max_depth] + + source_key = normalize_source_key(input) + return { status: :cycle_detected, input: input } if context[:ancestor_chain]&.any? { |a| a.include?(source_key) } + + absorber_class = PatternMatcher.resolve(input) + return nil unless absorber_class + + absorb_id = "absorb:#{SecureRandom.uuid}" + + record = { + absorb_id: absorb_id, + input: input, + absorber_class: absorber_class.name, + context: context.merge( + ancestor_chain: (context[:ancestor_chain] || []) + [absorb_id] + ), + status: :dispatched, + dispatched_at: Time.now.utc.iso8601 + } + + publish_to_transport(absorber_class, input, record) if transport_available? + + @mutex.synchronize { @dispatched << record } + record + end + + def dispatch_children(children, parent_context:) + children.map do |child| + child_context = parent_context.merge( + depth: parent_context[:depth] + 1, + parent_absorb_id: parent_context[:absorb_id] + ) + dispatch(child[:url] || child[:file_path], context: child_context) + end + end + + def dispatched + @mutex.synchronize { @dispatched.dup } + end + + def reset_dispatched! + @mutex.synchronize { @dispatched.clear } + end + + def default_context + { + depth: 0, + max_depth: max_depth_setting, + ancestor_chain: [], + conversation_id: nil, + requested_by: nil, + parent_absorb_id: nil + } + end + + def max_depth_setting + return 5 unless defined?(Legion::Settings) + + Legion::Settings[:absorbers]&.dig(:max_depth) || 5 + end + + def normalize_source_key(input) + input.to_s.gsub(%r{^https?://}, '').gsub(/[?#].*/, '') + end + + def transport_available? + defined?(Legion::Transport) && + Legion::Transport.respond_to?(:connected?) && + Legion::Transport.connected? + end + + def publish_to_transport(absorber_class, _input, record) + require_relative 'transport' + Transport.publish_absorb_request(absorber_class: absorber_class, record: record) + end + + def extract_urls(text) + URI.extract(text, %w[http https]).uniq + end + end + end + end +end diff --git a/lib/legion/extensions/absorbers/matchers/base.rb b/lib/legion/extensions/absorbers/matchers/base.rb new file mode 100644 index 00000000..0984ee13 --- /dev/null +++ b/lib/legion/extensions/absorbers/matchers/base.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Absorbers + module Matchers + class Base + @registry = {} + + class << self + attr_reader :registry + + def inherited(subclass) + super + TracePoint.new(:end) do |tp| + if tp.self == subclass + register(subclass) if subclass.respond_to?(:type) && subclass.type + tp.disable + end + end.enable + end + + def register(matcher_class) + @registry[matcher_class.type] = matcher_class + end + + def for_type(type) + @registry[type&.to_sym] + end + + def type = nil + + def match?(_pattern, _input) + raise NotImplementedError, "#{name} must implement .match?" + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/absorbers/matchers/file.rb b/lib/legion/extensions/absorbers/matchers/file.rb new file mode 100644 index 00000000..72698d2b --- /dev/null +++ b/lib/legion/extensions/absorbers/matchers/file.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Legion + module Extensions + module Absorbers + module Matchers + class File < Base + def self.match?(pattern, input) + return false unless input.is_a?(::String) + + ::File.fnmatch(pattern, input, ::File::FNM_PATHNAME | ::File::FNM_DOTMATCH) + end + + def self.type + :file + end + end + end + end + end +end diff --git a/lib/legion/extensions/absorbers/matchers/url.rb b/lib/legion/extensions/absorbers/matchers/url.rb new file mode 100644 index 00000000..5c1c5200 --- /dev/null +++ b/lib/legion/extensions/absorbers/matchers/url.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'uri' + +module Legion + module Extensions + module Absorbers + module Matchers + class Url < Base + def self.type = :url + + def self.match?(pattern, input) + uri = parse_uri(input) + return false unless uri + + host_pattern, path_pattern = split_pattern(pattern) + return false unless host_matches?(host_pattern, uri.host) + + path_matches?(path_pattern || '**', uri.path) + end + + class << self + private + + def parse_uri(input) + str = input.to_s.strip + str = "https://#{str}" unless str.match?(%r{\A\w+://}) + uri = URI.parse(str) + return nil unless uri.is_a?(URI::HTTP) && uri.host + + uri + rescue URI::InvalidURIError + nil + end + + def split_pattern(pattern) + clean = pattern.sub(%r{\A\w+://}, '') + parts = clean.split('/', 2) + [parts[0], parts[1]] + end + + def host_matches?(pattern, host) + return false unless host + + regex = Regexp.new( + "\\A#{Regexp.escape(pattern).gsub('\\*', '[^.]+')}\\z", + Regexp::IGNORECASE + ) + regex.match?(host) + end + + def path_matches?(pattern, path) + path = path.to_s.sub(%r{\A/}, '') + escaped = Regexp.escape(pattern) + .gsub('\\*\\*', '__.DOUBLE_STAR__.') + .gsub('\\*', '[^/]*') + .gsub('__.DOUBLE_STAR__.', '.*') + Regexp.new("\\A#{escaped}\\z").match?(path) + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/absorbers/pattern_matcher.rb b/lib/legion/extensions/absorbers/pattern_matcher.rb new file mode 100644 index 00000000..62edf72f --- /dev/null +++ b/lib/legion/extensions/absorbers/pattern_matcher.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Absorbers + module PatternMatcher + @registrations = [] + @mutex = Mutex.new + + module_function + + def register(absorber_class) + @mutex.synchronize do + absorber_class.patterns.each do |pat| + @registrations << { + type: pat[:type], + value: pat[:value], + priority: pat[:priority], + absorber_class: absorber_class, + description: absorber_class.description + } + end + end + end + + def resolve(input) + matches = @mutex.synchronize { @registrations.dup }.select do |reg| + matcher = Matchers::Base.for_type(reg[:type]) + next false unless matcher + + matcher.match?(reg[:value], input) + end + return nil if matches.empty? + + matches.min_by { |m| [m[:priority], -m[:value].gsub('*', '').length] }&.dig(:absorber_class) + end + + def list + @mutex.synchronize { @registrations.dup } + end + + def registrations + @mutex.synchronize { @registrations.dup } + end + + def reset! + @mutex.synchronize { @registrations.clear } + end + end + end + end +end diff --git a/lib/legion/extensions/absorbers/transport.rb b/lib/legion/extensions/absorbers/transport.rb new file mode 100644 index 00000000..f7df4799 --- /dev/null +++ b/lib/legion/extensions/absorbers/transport.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + module Extensions + module Absorbers + module Transport + module_function + + def publish_absorb_request(absorber_class:, record:) + lex = lex_name_from_absorber_class(absorber_class) + name = absorber_name_from_class(absorber_class) + msg = build_message(lex_name: lex, absorber_name: name, record: record) + return msg unless transport_connected? + + exchange = Legion::Transport::Exchange.new(msg[:exchange], type: :topic, durable: true) + exchange.publish( + Legion::JSON.dump(msg[:payload]), + routing_key: msg[:routing_key], + content_type: 'application/json', + message_id: record[:absorb_id] + ) + msg + end + + def build_message(lex_name:, absorber_name:, record:) + input = record[:input].to_s + { + exchange: "lex.#{lex_name}", + routing_key: "lex.#{lex_name}.absorbers.#{absorber_name}.absorb", + payload: { + type: 'absorb.request', + version: '1.0', + id: SecureRandom.uuid, + absorb_id: record[:absorb_id], + timestamp: Time.now.utc.iso8601, + url: input.start_with?('http') ? input : nil, + file_path: input.start_with?('http') ? nil : input, + context: record[:context], + metadata: record[:metadata] || {} + } + } + end + + def lex_name_from_absorber_class(klass) + name = klass.name.to_s + # Legion::Extensions::MicrosoftTeams::Absorbers::Meeting -> microsoft_teams + # Lex::Example::Absorbers::Content -> example + m = name.match(/Legion::Extensions::(\w+)::Absorbers::/) || + name.match(/Lex::(\w+)::Absorbers::/) + return 'unknown' unless m + + m[1].gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase + end + + def absorber_name_from_class(klass) + klass.name.to_s.split('::').last + .gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase + end + + def transport_connected? + defined?(Legion::Transport) && + Legion::Transport.respond_to?(:connected?) && + Legion::Transport.connected? + end + end + end + end +end diff --git a/lib/legion/extensions/actors/absorber_dispatch.rb b/lib/legion/extensions/actors/absorber_dispatch.rb new file mode 100644 index 00000000..00a5cab0 --- /dev/null +++ b/lib/legion/extensions/actors/absorber_dispatch.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + module Extensions + module Actors + module AbsorberDispatch + module_function + + def dispatch(input:, job_id: nil, context: {}) + job_id ||= SecureRandom.hex(8) + absorber_class = Absorbers::PatternMatcher.resolve(input) + + unless absorber_class + publish_event("absorb.failed.#{job_id}", job_id: job_id, error: 'no handler found for input') + return { success: false, error: 'no handler found for input', job_id: job_id } + end + + absorber = absorber_class.new + absorber.job_id = job_id + result = absorber.absorb(url: input, content: context[:content], + metadata: context[:metadata] || {}, context: context) + publish_event("absorb.complete.#{job_id}", job_id: job_id, absorber: absorber_class.name, + result: result) + { success: true, job_id: job_id, absorber: absorber_class.name, result: result } + rescue StandardError => e + Legion::Logging.error("AbsorberDispatch failed: #{e.message}") if defined?(Legion::Logging) + publish_event("absorb.failed.#{job_id}", job_id: job_id, error: e.message) + { success: false, job_id: job_id, error: e.message } + end + + def publish_event(routing_key, **payload) + return unless defined?(Legion::Transport) + + session = Legion::Transport.respond_to?(:session) ? Legion::Transport.session : nil + if session.respond_to?(:open?) + return unless session.open? + elsif session.nil? + return + end + + message_class = + if defined?(Legion::Transport::Messages::Dynamic) + Legion::Transport::Messages::Dynamic + elsif defined?(Legion::Transport::Message) + Legion::Transport::Message + end + return unless message_class + + message_class.new(routing_key: routing_key, **payload).publish + rescue StandardError => e + Legion::Logging.warn("AbsorberDispatch publish failed: #{e.message}") if defined?(Legion::Logging) + end + end + end + end +end diff --git a/lib/legion/extensions/actors/base.rb b/lib/legion/extensions/actors/base.rb index e719e864..e5c3fe73 100755 --- a/lib/legion/extensions/actors/base.rb +++ b/lib/legion/extensions/actors/base.rb @@ -1,29 +1,62 @@ +# frozen_string_literal: true + +require_relative 'dsl' + module Legion module Extensions module Actors module Base + extend Legion::Extensions::Actors::Dsl include Legion::Extensions::Helpers::Lex + define_dsl_accessor :use_runner, default: true + define_dsl_accessor :check_subtask, default: true + define_dsl_accessor :generate_task, default: false + define_dsl_accessor :enabled, default: true + define_dsl_accessor :remote_invocable, default: true + def runner - Legion::Runner.run(runner_class: runner_class, function: function, check_subtask: check_subtask?, generate_task: generate_task?) + with_log_context(function) do + Legion::Runner.run(runner_class: runner_class, function: function, + check_subtask: check_subtask?, generate_task: generate_task?) + end rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace + handle_exception(e) end def manual - runner_class.send(runner_function, **args) + klass = runner_class + klass = Kernel.const_get(klass) if klass.is_a?(String) + func = respond_to?(:runner_function) ? runner_function : :action + if klass == self.class + unless respond_to?(func) + raise NoMethodError, + "#{self.class} resolved runner_class to itself but does not define '#{func}'. " \ + 'Override runner_class or define the method on the actor.' + end + send(func, **args) + else + klass.send(func, **args) + end rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace + handle_exception(e) end def function nil end + def self.included(base) + base.extend(Legion::Extensions::Actors::Dsl) unless base.singleton_class.include?(Legion::Extensions::Actors::Dsl) + base.define_dsl_accessor(:use_runner, default: true) unless base.respond_to?(:use_runner) + base.define_dsl_accessor(:check_subtask, default: true) unless base.respond_to?(:check_subtask) + base.define_dsl_accessor(:generate_task, default: false) unless base.respond_to?(:generate_task) + base.define_dsl_accessor(:enabled, default: true) unless base.respond_to?(:enabled) + base.define_dsl_accessor(:remote_invocable, default: true) unless base.respond_to?(:remote_invocable) + end + def use_runner? - true + self.class.respond_to?(:use_runner) ? self.class.use_runner : true end def args @@ -31,15 +64,19 @@ def args end def check_subtask? - true + self.class.respond_to?(:check_subtask) ? self.class.check_subtask : true end def generate_task? - false + self.class.respond_to?(:generate_task) ? self.class.generate_task : false end def enabled? - true + self.class.respond_to?(:enabled) ? self.class.enabled : true + end + + def remote_invocable? + self.class.respond_to?(:remote_invocable) ? self.class.remote_invocable : true end end end diff --git a/lib/legion/extensions/actors/defaults.rb b/lib/legion/extensions/actors/defaults.rb index 9f81becd..bd4e9dbe 100755 --- a/lib/legion/extensions/actors/defaults.rb +++ b/lib/legion/extensions/actors/defaults.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion module Extensions module Actors diff --git a/lib/legion/extensions/actors/dsl.rb b/lib/legion/extensions/actors/dsl.rb new file mode 100644 index 00000000..5eea9712 --- /dev/null +++ b/lib/legion/extensions/actors/dsl.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Actors + module Dsl + def define_dsl_accessor(name, default:) + define_singleton_method(name) do |val = :_unset| + if val == :_unset + if instance_variable_defined?(:"@#{name}") + instance_variable_get(:"@#{name}") + elsif superclass.respond_to?(name) + superclass.public_send(name) + else + default + end + else + instance_variable_set(:"@#{name}", val) + end + end + + define_method(name) do + self.class.public_send(name) + end + end + end + end + end +end diff --git a/lib/legion/extensions/actors/every.rb b/lib/legion/extensions/actors/every.rb index 354da7be..7846861e 100755 --- a/lib/legion/extensions/actors/every.rb +++ b/lib/legion/extensions/actors/every.rb @@ -1,46 +1,64 @@ +# frozen_string_literal: true + require_relative 'base' +require_relative 'fingerprint' +require_relative 'dsl' module Legion module Extensions module Actors class Every + extend Legion::Extensions::Actors::Dsl include Legion::Extensions::Actors::Base + include Legion::Extensions::Actors::Fingerprint + + define_dsl_accessor :time, default: 1 + define_dsl_accessor :timeout, default: 5 + define_dsl_accessor :run_now, default: false def initialize(**_opts) - @timer = Concurrent::TimerTask.new(execution_interval: time, timeout_interval: timeout, run_now: run_now?) do - use_runner? ? runner : manual + @executing = Concurrent::AtomicBoolean.new(false) + @timer = Concurrent::TimerTask.new(execution_interval: time, run_now: run_now?) do + if @executing.make_true + begin + log.debug "[Every] tick: #{self.class}" if defined?(log) + skip_or_run { use_runner? ? runner : manual } + rescue StandardError => e + log.error "[Every] tick failed for #{self.class}: #{e.class}: #{e.message}" if defined?(log) + handle_exception(e) if defined?(log) + ensure + @executing.make_false + end + elsif defined?(log) + log.debug "[Every] skipped (previous still running): #{self.class}" + end end - @timer.execute + initial_delay = respond_to?(:delay) ? delay.to_f : 0 + if initial_delay.positive? + Concurrent::ScheduledTask.execute(initial_delay) { @timer.execute } + else + @timer.execute + end rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace - end - - def time - 1 - end - - def timeout - 5 + handle_exception(e) end def run_now? - false + run_now end def action(**_opts) - Legion::Logging.warn 'An extension is using the default block from Legion::Extensions::Runners::Every' + log.warn 'An extension is using the default block from Legion::Extensions::Runners::Every' end def cancel - Legion::Logging.debug 'Cancelling Legion Timer' + log.debug 'Cancelling Legion Timer' return true unless @timer.respond_to?(:shutdown) @timer.shutdown rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace + handle_exception(e) end end end diff --git a/lib/legion/extensions/actors/fingerprint.rb b/lib/legion/extensions/actors/fingerprint.rb new file mode 100644 index 00000000..2e071dcc --- /dev/null +++ b/lib/legion/extensions/actors/fingerprint.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'digest' + +module Legion + module Extensions + module Actors + module Fingerprint + def skip_if_unchanged? + false + end + + def fingerprint_source + bucket = respond_to?(:time) ? time.to_i : 60 + bucket = 1 if bucket < 1 + (Time.now.utc.to_i / bucket).to_s + end + + def compute_fingerprint + Digest::SHA256.hexdigest(fingerprint_source.to_s) + end + + def unchanged? + return false if @last_fingerprint.nil? + + compute_fingerprint == @last_fingerprint + end + + def store_fingerprint! + @last_fingerprint = compute_fingerprint + end + + def skip_or_run + if skip_if_unchanged? && unchanged? + Legion::Logging.debug "#{self.class} skipped: fingerprint unchanged (#{@last_fingerprint[0, 8]}...)" + return + end + + yield + store_fingerprint! + end + end + end + end +end diff --git a/lib/legion/extensions/actors/loop.rb b/lib/legion/extensions/actors/loop.rb index f10a1e23..7093c274 100755 --- a/lib/legion/extensions/actors/loop.rb +++ b/lib/legion/extensions/actors/loop.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'base' module Legion @@ -11,8 +13,7 @@ def initialize @loop = true async.run rescue StandardError => e - Legion::Logging.error e - Legion::Logging.error e.backtrace + handle_exception(e) end def run diff --git a/lib/legion/extensions/actors/nothing.rb b/lib/legion/extensions/actors/nothing.rb index 3950641f..b585e17f 100755 --- a/lib/legion/extensions/actors/nothing.rb +++ b/lib/legion/extensions/actors/nothing.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'base' module Legion @@ -6,8 +8,6 @@ module Actors class Nothing include Legion::Extensions::Actors::Base - def initialize; end - def cancel; end end end diff --git a/lib/legion/extensions/actors/once.rb b/lib/legion/extensions/actors/once.rb index ead02c00..96d91e6e 100755 --- a/lib/legion/extensions/actors/once.rb +++ b/lib/legion/extensions/actors/once.rb @@ -1,11 +1,17 @@ +# frozen_string_literal: true + require_relative 'base' +require_relative 'dsl' module Legion module Extensions module Actors class Once + extend Legion::Extensions::Actors::Dsl include Legion::Extensions::Actors::Base + define_dsl_accessor :delay, default: 1.0 + def initialize return unless enabled? diff --git a/lib/legion/extensions/actors/poll.rb b/lib/legion/extensions/actors/poll.rb index 2084f4c4..80671d58 100755 --- a/lib/legion/extensions/actors/poll.rb +++ b/lib/legion/extensions/actors/poll.rb @@ -1,50 +1,75 @@ +# frozen_string_literal: true + require_relative 'base' +require_relative 'fingerprint' +require_relative 'dsl' require 'time' module Legion module Extensions module Actors class Poll + extend Legion::Extensions::Actors::Dsl include Legion::Extensions::Actors::Base + include Legion::Extensions::Actors::Fingerprint - def initialize # rubocop:disable Metrics/AbcSize - log.debug "Starting timer for #{self.class} with #{{ execution_interval: time, timeout_interval: timeout, run_now: run_now?, check_subtask: check_subtask? }}" - @timer = Concurrent::TimerTask.new(execution_interval: time, timeout_interval: timeout, run_now: run_now?) do - t1 = Time.now - log.debug "Running #{self.class}" - old_result = Legion::Cache.get(cache_name) - log.debug "Cached value for #{self.class}: #{old_result}" - results = Legion::JSON.load(Legion::JSON.dump(manual)) - Legion::Cache.set(cache_name, results, time * 2) + define_dsl_accessor :time, default: 9 + define_dsl_accessor :timeout, default: 5 + define_dsl_accessor :run_now, default: true + define_dsl_accessor :int_percentage_normalize, default: 0.00 - unless old_result.nil? - results[:diff] = Hashdiff.diff(results, old_result, numeric_tolerance: 0.0, array_path: false) do |_path, obj1, obj2| - if int_percentage_normalize.positive? && obj1.is_a?(Integer) && obj2.is_a?(Integer) - obj1.between?(obj2 * (1 - int_percentage_normalize), obj2 * (1 + int_percentage_normalize)) - end + def initialize + log.debug "Starting timer for #{self.class} with #{{ execution_interval: time, run_now: run_now?, +check_subtask: check_subtask? }}" + @executing = Concurrent::AtomicBoolean.new(false) + @timer = Concurrent::TimerTask.new(execution_interval: time, run_now: run_now?) do + if @executing.make_true + begin + skip_or_run { poll_cycle } + rescue StandardError => e + handle_exception(e, level: :fatal) + ensure + @executing.make_false end - results[:changed] = results[:diff].count.positive? + else + Legion::Logging.debug "[Poll] skipped (previous still running): #{self.class}" + end + end + @timer.execute + rescue StandardError => e + handle_exception(e) + end - Legion::Logging.info results[:diff] if results[:changed] - Legion::Transport::Messages::CheckSubtask.new(runner_class: runner_class.to_s, - function: runner_function, - result: results, - type: 'poll_result', - polling: true).publish + def poll_cycle + t1 = Time.now + log.debug "Running #{self.class}" + old_result = Legion::Cache.get(cache_name) + log.debug "Cached value for #{self.class}: #{old_result}" + results = Legion::JSON.load(Legion::JSON.dump(manual)) + Legion::Cache.set(cache_name, results, time * 2) + + unless old_result.nil? + results[:diff] = Hashdiff.diff(results, old_result, numeric_tolerance: 0.0, array_path: false) do |_path, obj1, obj2| + if int_percentage_normalize.positive? && obj1.is_a?(Integer) && obj2.is_a?(Integer) + obj1.between?(obj2 * (1 - int_percentage_normalize), obj2 * (1 + int_percentage_normalize)) + end end + results[:changed] = results[:diff].any? - sleep_time = 1 - (Time.now - t1) - sleep(sleep_time) if sleep_time.positive? - log.debug("#{self.class} result: #{results}") - results - rescue StandardError => e - Legion::Logging.fatal e.message - Legion::Logging.fatal e.backtrace + Legion::Logging.info results[:diff] if results[:changed] + Legion::Transport::Messages::CheckSubtask.new(runner_class: runner_class.to_s, + function: runner_function, + result: results, + type: 'poll_result', + polling: true).publish end - @timer.execute + + sleep_time = 1 - (Time.now - t1) + sleep(sleep_time) if sleep_time.positive? + log.debug("#{self.class} result: #{results}") + results rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace + handle_exception(e, level: :fatal) end def cache_name @@ -55,20 +80,8 @@ def int_percentage_normalize 0.00 end - def time - 9 - end - def run_now? - true - end - - def check_subtask? - true - end - - def timeout - 5 + run_now end def action(_payload = {}) @@ -79,7 +92,7 @@ def cancel Legion::Logging.debug 'Cancelling Legion Poller' @timer.shutdown rescue StandardError => e - Legion::Logging.error e.message + handle_exception(e) end end end diff --git a/lib/legion/extensions/actors/retry_policy.rb b/lib/legion/extensions/actors/retry_policy.rb new file mode 100644 index 00000000..29251960 --- /dev/null +++ b/lib/legion/extensions/actors/retry_policy.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Actors + module RetryPolicy + DEFAULT_THRESHOLD = 2 + RETRY_COUNT_HEADER = 'x-retry-count' + + module_function + + def should_retry?(retry_count:, threshold:) + return true if threshold.nil? + + retry_count < threshold + end + + def extract_retry_count(headers) + return 0 if headers.nil? + + count = headers[RETRY_COUNT_HEADER] || headers[RETRY_COUNT_HEADER.to_sym] || 0 + count.to_i + end + + def retry_threshold + threshold = nil + if defined?(Legion::Settings) + threshold = Legion::Settings.dig(:fleet, :poison_message_threshold) + threshold ||= Legion::Settings.dig(:transport, :retry_threshold) + end + threshold || DEFAULT_THRESHOLD + rescue StandardError + DEFAULT_THRESHOLD + end + end + end + end +end diff --git a/lib/legion/extensions/actors/singleton.rb b/lib/legion/extensions/actors/singleton.rb new file mode 100644 index 00000000..07255ddc --- /dev/null +++ b/lib/legion/extensions/actors/singleton.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Actors + module Singleton + def self.included(base) + base.prepend(ExecutionGuard) + end + + def singleton_role + self.class.name&.gsub('::', '_')&.downcase || 'unknown' + end + + def singleton_ttl + [time * 3, 30].max + end + + module ExecutionGuard + def initialize(**opts) + @leader_token = nil + super + end + + private + + def singleton_enabled? + return false unless defined?(Legion::Settings) + + cluster = Legion::Settings[:cluster] + cluster.is_a?(Hash) && cluster[:singleton_enabled] == true + rescue StandardError + false + end + + def skip_or_run(&) + return super unless singleton_enabled? + return super unless defined?(Legion::Lock) || defined?(Legion::Cluster::Lock) + + role = singleton_role + ttl_secs = singleton_ttl + + if @leader_token.nil? + @leader_token = acquire_singleton_lock(role, ttl_secs) + return unless @leader_token + else + extended = extend_singleton_lock(role, @leader_token, ttl_secs) + unless extended + @leader_token = acquire_singleton_lock(role, ttl_secs) + return unless @leader_token + end + end + + super + end + + def acquire_singleton_lock(role, ttl_secs) + if defined?(Legion::Cluster::Lock) + Legion::Cluster::Lock.acquire(name: "leader:#{role}", ttl: ttl_secs) + else + Legion::Lock.acquire("leader:#{role}", ttl: ttl_secs * 1000) + end + end + + def extend_singleton_lock(role, token, ttl_secs) + if defined?(Legion::Cluster::Lock) + Legion::Cluster::Lock.extend_lock(name: "leader:#{role}", token: token, ttl: ttl_secs) + else + Legion::Lock.extend_lock("leader:#{role}", token, ttl: ttl_secs * 1000) + end + end + end + end + end + end +end diff --git a/lib/legion/extensions/actors/subscription.rb b/lib/legion/extensions/actors/subscription.rb index 8983dcea..849ebac2 100755 --- a/lib/legion/extensions/actors/subscription.rb +++ b/lib/legion/extensions/actors/subscription.rb @@ -1,21 +1,41 @@ +# frozen_string_literal: true + require_relative 'base' +require_relative 'dsl' +require_relative 'retry_policy' require 'date' +require 'securerandom' module Legion module Extensions module Actors + class UnrecoverableMessageError < StandardError; end + class Subscription + extend Legion::Extensions::Actors::Dsl include Concurrent::Async include Legion::Extensions::Actors::Base include Legion::Extensions::Helpers::Transport + define_dsl_accessor :consumers, default: 1 + define_dsl_accessor :manual_ack, default: true + define_dsl_accessor :delay_start, default: 0 + define_dsl_accessor :block, default: false + define_dsl_accessor :prefetch, default: 2 + define_dsl_accessor :routing_key_hint, default: nil + + def self.pattern(routing_key = nil) + return routing_key_hint unless routing_key + + routing_key_hint(routing_key) + end + def initialize(**_options) super() @queue = queue.new @queue.channel.prefetch(prefetch) if defined? prefetch rescue StandardError => e - log.fatal e.message - log.fatal e.backtrace + handle_exception(e, level: :fatal) end def create_queue @@ -23,13 +43,12 @@ def create_queue exchange_object = default_exchange.new queue_object = Kernel.const_get(queue_string).new - queue_object.bind(exchange_object, routing_key: actor_name) - queue_object.bind(exchange_object, routing_key: "#{lex_name}.#{actor_name}.#") + queue_object.bind(exchange_object, routing_key: "#{amqp_prefix}.runners.#{runner_name}.#") end def queue - create_queue unless queues.const_defined?(actor_const) - Kernel.const_get queue_string + create_queue unless queues.const_defined?(actor_const, false) + queues.const_get(actor_const, false) end def queue_string @@ -45,20 +64,69 @@ def cancel true end - def block - false - end + def prepare + @dedicated_channel = create_dedicated_channel + @queue = queue.new + reassign_queue_channel(@queue, @dedicated_channel) + @dedicated_channel.prefetch(prefetch) if defined? prefetch + consumer_tag = "#{Legion::Settings[:client][:name]}_#{lex_name}_#{runner_name}_#{SecureRandom.uuid}" + @consumer = Bunny::Consumer.new(@queue.channel, @queue, consumer_tag, false, false) + @consumer.on_delivery do |delivery_info, metadata, payload| + fn = nil + message = process_message(payload, metadata, delivery_info) + fn = find_function(message) + log.debug "[Subscription] message received: #{lex_name}/#{fn}" if defined?(log) - def consumers - 1 - end + affinity_result = check_region_affinity(message) + if affinity_result == :reject + log.warn '[Subscription] nack: region affinity mismatch' + @queue.reject(delivery_info.delivery_tag) if manual_ack + next + end - def manual_ack - true + record_cross_region_metric(message) if affinity_result == :remote + + if use_runner? + dispatch_runner(message, runner_class, fn, check_subtask?, generate_task?) + else + runner_class.send(fn, **message) + end + @queue.acknowledge(delivery_info.delivery_tag) if manual_ack + + cancel if Legion::Settings[:client][:shutting_down] + rescue UnrecoverableMessageError => e + handle_exception(e, lex: lex_name, fn: fn, routing_key: delivery_info.routing_key) + log.warn "[Subscription] dead-lettering unrecoverable message for #{lex_name}/#{fn}: #{e.message}" + @queue.reject(delivery_info.delivery_tag, requeue: false) if manual_ack + rescue StandardError => e + handle_exception(e, lex: lex_name, fn: fn, routing_key: delivery_info.routing_key) + reject_or_retry(delivery_info, metadata, payload) if manual_ack + end + log.info "[Subscription] prepared: #{lex_name}/#{runner_name}" + rescue StandardError => e + handle_exception(e, level: :fatal) end - def delay_start - 0 + def activate + unless @consumer + log.warn "[Subscription] skipping activate for #{lex_name}/#{runner_name}: no consumer (prepare failed?)" + return + end + + if @queue.channel.open? + @queue.subscribe_with(@consumer) + else + log.warn "[Subscription] channel closed before activate for #{lex_name}/#{runner_name}, re-preparing" + prepare + if @consumer && @queue.channel.open? + @queue.subscribe_with(@consumer) + else + log.error "[Subscription] re-prepare failed for #{lex_name}/#{runner_name}, skipping activate" + return + end + end + + log.info "[Subscription] activated: #{lex_name}/#{runner_name} (consumer registered)" end def include_metadata_in_message? @@ -67,7 +135,11 @@ def include_metadata_in_message? def process_message(message, metadata, delivery_info) payload = if metadata.content_encoding && metadata.content_encoding == 'encrypted/cs' - Legion::Crypt.decrypt(message, metadata.headers['iv']) + headers = metadata.headers || {} + iv = headers['iv'] || headers[:iv] + raise UnrecoverableMessageError, "encrypted/cs message missing iv header (#{lex_name}/#{runner_name})" if iv.nil? + + Legion::Crypt.decrypt(message, iv) elsif metadata.content_encoding && metadata.content_encoding == 'encrypted/pk' Legion::Crypt.decrypt_from_keypair(metadata.headers[:public_key], message) else @@ -81,58 +153,186 @@ def process_message(message, metadata, delivery_info) end if include_metadata_in_message? message = message.merge(metadata.headers.transform_keys(&:to_sym)) unless metadata.headers.nil? - message[:routing_key] = if Legion::Transport::TYPE == 'march_hare' - metadata.routing_key - else - delivery_info[:routing_key] - end + message[:routing_key] = delivery_info[:routing_key] end + message[:message_id] ||= metadata.message_id if metadata.respond_to?(:message_id) && metadata.message_id + message[:correlation_id] ||= metadata.correlation_id if metadata.respond_to?(:correlation_id) && metadata.correlation_id + message[:timestamp] = (message[:timestamp_in_ms] / 1000).round if message.key?(:timestamp_in_ms) && !message.key?(:timestamp) message[:datetime] = Time.at(message[:timestamp].to_i).to_datetime.to_s if message.key?(:timestamp) message end def find_function(message = {}) - return runner_function if actor_class.instance_methods(false).include?(:runner_function) - return function if actor_class.instance_methods(false).include?(:function) - return action if actor_class.instance_methods(false).include?(:action) + return runner_function if actor_class.method_defined?(:runner_function, false) + return function if actor_class.method_defined?(:function, false) + return action if actor_class.method_defined?(:action, false) return message[:function] if message.key? :function function end def subscribe - sleep(delay_start) - consumer_tag = "#{Legion::Settings[:client][:name]}_#{lex_name}_#{runner_name}_#{Thread.current.object_id}" + log.info "[Subscription] subscribing: #{lex_name}/#{runner_name}" + sleep(delay_start) if delay_start.positive? + consumer_tag = "#{Legion::Settings[:client][:name]}_#{lex_name}_#{runner_name}_#{SecureRandom.uuid}" on_cancellation = block { cancel } @consumer = @queue.subscribe(manual_ack: manual_ack, block: false, consumer_tag: consumer_tag, on_cancellation: on_cancellation) do |*rmq_message| - payload = rmq_message.pop - metadata = rmq_message.last - delivery_info = rmq_message.first + delivery_info = nil + metadata = nil + payload = nil + fn = nil + delivery_info = rmq_message.first + metadata = rmq_message.last + payload = rmq_message.pop message = process_message(payload, metadata, delivery_info) + fn = find_function(message) + log.debug "[Subscription] message received: #{lex_name}/#{fn}" if defined?(log) + + affinity_result = check_region_affinity(message) + if affinity_result == :reject + log.warn "[Subscription] nack: region affinity mismatch region=#{message[:region]} affinity=#{message[:region_affinity]}" + @queue.reject(delivery_info.delivery_tag) if manual_ack + next + end + + if affinity_result == :remote + log.debug 'Processing remote-region message ' \ + "(region=#{message[:region]}, affinity=#{message[:region_affinity]})" + record_cross_region_metric(message) + end + if use_runner? - Legion::Runner.run(**message, - runner_class: runner_class, - function: find_function(message), - check_subtask: check_subtask?, - generate_task: generate_task?) + dispatch_runner(message, runner_class, fn, check_subtask?, generate_task?) else - runner_class.send(find_function(message), **message) + runner_class.send(fn, **message) end @queue.acknowledge(delivery_info.delivery_tag) if manual_ack cancel if Legion::Settings[:client][:shutting_down] + rescue UnrecoverableMessageError => e + handle_exception(e, lex: lex_name, fn: fn) + log.warn "[Subscription] dead-lettering unrecoverable message for #{lex_name}/#{fn}: #{e.message}" + @queue.reject(delivery_info.delivery_tag, requeue: false) if manual_ack && delivery_info rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace - Legion::Logging.error message - Legion::Logging.error function - @queue.reject(delivery_info.delivery_tag) if manual_ack + handle_exception(e) + log.warn "[Subscription] retry-or-dlq for #{lex_name}/#{fn}" + reject_or_retry(delivery_info, metadata, payload) if manual_ack && delivery_info + end + log.info "[Subscription] subscribed: #{lex_name}/#{runner_name} (consumer registered)" if defined?(log) + end + + private + + def record_cross_region_metric(message) + return unless defined?(Legion::Extensions::Telemetry::Runners::Telemetry) + + Legion::Extensions::Telemetry::Runners::Telemetry.record_cross_region( + from_region: message[:region], + to_region: Legion::Region.current, + affinity: message[:region_affinity] + ) + rescue StandardError => e + log.debug "Subscription#record_cross_region_metric failed: #{e.message}" if defined?(log) + nil + end + + def check_region_affinity(message) + return :local unless defined?(Legion::Region) + + region = message[:region] + affinity = message[:region_affinity] + Legion::Region.affinity_for(region, affinity) + end + + def dispatch_runner(message, runner_cls, function, check_subtask, generate_task) + unless extension_dispatch_allowed? + log.warn "[Subscription] rejecting #{lex_name}/#{function}: extension is not accepting new work" if defined?(log) + return { success: false, status: 'task.blocked', error: { code: 'extension_quiescing' } } + end + + run_block = lambda { + ctx = message.merge(runner_class: runner_cls.to_s, function: function.to_s) + Legion::Context.with_task_context(ctx) do + Legion::Runner.run(**message, + runner_class: runner_cls, + function: function, + check_subtask: check_subtask, + generate_task: generate_task) + end + } + + if defined?(Legion::Telemetry::OpenInference) + Legion::Telemetry::OpenInference.chain_span(type: 'task_chain') { |_span| run_block.call } + else + run_block.call end end + + def extension_dispatch_allowed? + return true unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:dispatch_allowed?) + + Legion::Extensions.dispatch_allowed?(lex_name) + end + + def reject_or_retry(delivery_info, metadata, payload) + headers = metadata&.headers || {} + retry_count = RetryPolicy.extract_retry_count(headers) + threshold = RetryPolicy.retry_threshold + + if RetryPolicy.should_retry?(retry_count: retry_count, threshold: threshold) + base_delay = Legion::Settings.dig(:fleet, :transport, :retry_base_delay_seconds) || 1 + max_delay = Legion::Settings.dig(:fleet, :transport, :retry_max_delay_seconds) || 30 + delay = [base_delay * (2**retry_count), max_delay].min + log.info "[Subscription] retrying message in #{delay}s (attempt #{retry_count + 1}/#{threshold}) for #{lex_name}" + sleep(delay) + if republish_with_retry_count(delivery_info, metadata, payload, retry_count + 1) + @queue.acknowledge(delivery_info.delivery_tag) + else + @queue.reject(delivery_info.delivery_tag, requeue: false) + end + else + log.warn "[Subscription] dead-lettering message after #{retry_count} retries for #{lex_name}" + @queue.reject(delivery_info.delivery_tag, requeue: false) + end + end + + def create_dedicated_channel + s = Legion::Transport::Connection.session + raise IOError, 'transport session unavailable' unless s&.open? + + settings = Legion::Transport::Connection.settings + s.create_channel(nil, settings[:channel][:default_worker_pool_size], false, 10) + end + + def reassign_queue_channel(queue_instance, new_channel) + old_channel = queue_instance.channel + old_channel.deregister_queue(queue_instance) if old_channel.respond_to?(:deregister_queue) + queue_instance.instance_variable_set(:@channel, new_channel) + new_channel.register_queue(queue_instance) if new_channel.respond_to?(:register_queue) + end + + def republish_with_retry_count(_delivery_info, metadata, payload, new_count) + headers = (metadata&.headers || {}).dup + headers[RetryPolicy::RETRY_COUNT_HEADER] = new_count + + exchange = @queue.channel.default_exchange + exchange.publish( + payload, + routing_key: @queue.name, + headers: headers, + content_type: metadata&.content_type, + content_encoding: metadata&.content_encoding, + persistent: true + ) + true + rescue StandardError => e + log.warn "[Subscription] republish failed, dead-lettering: #{e.message}" + false + end end end end diff --git a/lib/legion/extensions/builders/absorbers.rb b/lib/legion/extensions/builders/absorbers.rb new file mode 100644 index 00000000..7a41fb1d --- /dev/null +++ b/lib/legion/extensions/builders/absorbers.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Legion + module Extensions + module Builder + module Absorbers + include Legion::Extensions::Builder::Base + + def build_absorbers + @absorbers = {} + absorber_files = find_files('absorbers') + return if absorber_files.empty? + + require_files(absorber_files) + + absorber_files.each do |file| + snake_name = file.split('/').last.sub('.rb', '') + class_name = snake_name.split('_').collect(&:capitalize).join + absorber_class = "#{lex_class}::Absorbers::#{class_name}" + + next unless Kernel.const_defined?(absorber_class) + + klass = Kernel.const_get(absorber_class) + next unless klass < Legion::Extensions::Absorbers::Base + + @absorbers[snake_name.to_sym] = { + extension: lex_name, + extension_class: lex_class, + absorber_name: snake_name, + absorber_class: absorber_class, + absorber_module: klass, + patterns: klass.patterns, + description: klass.description + } + + Legion::Extensions::Absorbers::PatternMatcher.register(klass) + + next unless defined?(Legion::API) && Legion::API.respond_to?(:router) + + absorber_methods = klass.public_instance_methods(false).reject { |m| m.to_s.start_with?('_') } + absorber_methods = [:absorb] if absorber_methods.empty? + absorber_methods.each do |method_name| + Legion::API.router.register_extension_route( + lex_name: lex_name, + amqp_prefix: respond_to?(:amqp_prefix) ? amqp_prefix : "lex.#{lex_name}", + component_type: 'absorbers', + component_name: snake_name, + method_name: method_name.to_s, + runner_class: klass, + definition: klass.respond_to?(:definition_for) ? klass.definition_for(method_name) : nil + ) + end + end + rescue StandardError => e + Legion::Logging.error("Failed to build absorbers: #{e.message}") if defined?(Legion::Logging) + end + + def absorbers + @absorbers || {} + end + end + end + end +end diff --git a/lib/legion/extensions/builders/actors.rb b/lib/legion/extensions/builders/actors.rb index 31797d51..8bb94e21 100755 --- a/lib/legion/extensions/builders/actors.rb +++ b/lib/legion/extensions/builders/actors.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'base' module Legion @@ -19,9 +21,15 @@ def build_actor_list actor_files.each do |file| actor_name = file.split('/').last.sub('.rb', '') actor_class = "#{lex_class}::Actor::#{actor_name.split('_').collect(&:capitalize).join}" + unless Kernel.const_defined?(actor_class) + Legion::Logging.warn "[Actors] constant #{actor_class} not defined, skipping" if defined?(Legion::Logging) + next + end + log.info "[Actors] built actor: #{actor_class}" if defined?(Legion::Logging) @actors[actor_name.to_sym] = { extension: lex_class.to_s.downcase, extension_name: extension_name, + settings_path: settings_path, actor_name: actor_name, actor_class: Kernel.const_get(actor_class), type: 'literal' @@ -30,6 +38,11 @@ def build_actor_list end def build_meta_actor_list + if lex_class.respond_to?(:remote_invocable?) && !lex_class.remote_invocable? + log.debug "[Actors] skipping meta actors for #{lex_class} (remote_invocable=false)" + return + end + @runners.each do |runner, attr| next if @actors[runner.to_sym].is_a? Hash @@ -38,6 +51,7 @@ def build_meta_actor_list @actors[runner.to_sym] = { extension: attr[:extension], extension_name: attr[:extension_name], + settings_path: attr[:settings_path], actor_name: attr[:runner_name], actor_class: Kernel.const_get(actor_class), type: 'meta' diff --git a/lib/legion/extensions/builders/base.rb b/lib/legion/extensions/builders/base.rb index 0a5401b0..68ce2983 100755 --- a/lib/legion/extensions/builders/base.rb +++ b/lib/legion/extensions/builders/base.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion module Extensions module Builder diff --git a/lib/legion/extensions/builders/helpers.rb b/lib/legion/extensions/builders/helpers.rb index b85feecb..85085536 100755 --- a/lib/legion/extensions/builders/helpers.rb +++ b/lib/legion/extensions/builders/helpers.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require_relative 'base' module Legion diff --git a/lib/legion/extensions/builders/hooks.rb b/lib/legion/extensions/builders/hooks.rb new file mode 100644 index 00000000..46528357 --- /dev/null +++ b/lib/legion/extensions/builders/hooks.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Legion + module Extensions + module Builder + module Hooks + include Legion::Extensions::Builder::Base + + attr_reader :hooks + + def build_hooks + @hooks = {} + return unless Dir.exist? "#{extension_path}/hooks" + + require_files(hook_files) + build_hook_list + end + + def build_hook_list + hook_files.each do |file| + hook_name = file.split('/').last.sub('.rb', '') + hook_class_name = "#{lex_class}::Hooks::#{hook_name.split('_').collect(&:capitalize).join}" + + next unless Kernel.const_defined?(hook_class_name) + + hook_class = Kernel.const_get(hook_class_name) + next unless hook_class < Legion::Extensions::Hooks::Base + + route_path = "#{extension_name}/#{hook_name}" + runner = resolve_hook_runner(hook_class) + + @hooks[hook_name.to_sym] = { + extension: lex_class.to_s.downcase, + extension_name: extension_name, + settings_path: settings_path, + hook_name: hook_name, + hook_class: hook_class, + route_path: route_path + } + + next unless defined?(Legion::API) && Legion::API.respond_to?(:router) + + # Register hook component in the router (explicit methods derived from hook class) + hook_methods = hook_class.public_instance_methods(false).reject { |m| m.to_s.start_with?('_') } + hook_methods = [:handle] if hook_methods.empty? + hook_methods.each do |method_name| + Legion::API.router.register_extension_route( + lex_name: extension_name, + amqp_prefix: respond_to?(:amqp_prefix) ? amqp_prefix : "lex.#{extension_name.to_s.tr('_', '.')}", + component_type: 'hooks', + component_name: hook_name, + method_name: method_name.to_s, + runner_class: runner || hook_class, + definition: hook_class.respond_to?(:definition_for) ? hook_class.definition_for(method_name) : nil + ) + end + end + end + + def hook_files + @hook_files ||= find_files('hooks') + end + + private + + def resolve_hook_runner(hook_class) + ref = hook_class.new.runner_class + if ref.is_a?(String) + Kernel.const_defined?(ref) ? Kernel.const_get(ref) : nil + elsif ref.is_a?(Class) + ref + end + end + end + end + end +end diff --git a/lib/legion/extensions/builders/routes.rb b/lib/legion/extensions/builders/routes.rb new file mode 100644 index 00000000..275c6e7d --- /dev/null +++ b/lib/legion/extensions/builders/routes.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Legion + module Extensions + module Builder + module Routes + include Legion::Extensions::Builder::Base + + attr_reader :routes + + def build_routes + @routes = {} + return if lex_route_settings[:enabled] == false + return if extension_disabled? + + @runners.each_value do |runner_info| + runner_name = runner_info[:runner_name] + runner_class = runner_info[:runner_class] + runner_module = runner_info[:runner_module] + next if runner_module.nil? + next if excluded_runner?(runner_name) + + methods = runner_module.instance_methods(false) + methods -= runner_module.skip_routes if runner_module.respond_to?(:skip_routes) + methods -= excluded_functions_for + + methods.each do |function| + route_path = "#{extension_name}/#{runner_name}/#{function}" + defn = runner_module.respond_to?(:definition_for) ? runner_module.definition_for(function) : nil + log.info "[Routes] auto-route registered: POST /api/extensions/#{extension_name}/runners/#{runner_name}/#{function}" + @routes[route_path] = { + lex_name: extension_name, + runner_name: runner_name, + function: function, + component_type: 'runners', + runner_class: runner_class, + route_path: route_path, + definition: defn + } + + next unless defined?(Legion::API) && Legion::API.respond_to?(:router) + + Legion::API.router.register_extension_route( + lex_name: extension_name, + amqp_prefix: respond_to?(:amqp_prefix) ? amqp_prefix : "lex.#{extension_name.to_s.tr('_', '.')}", + component_type: 'runners', + component_name: runner_name, + method_name: function.to_s, + runner_class: runner_class, + definition: defn + ) + end + end + end + + private + + def lex_route_settings + return {} unless defined?(Legion::Settings) + + Legion::Settings.dig(:api, :lex_routes) || {} + end + + def extension_disabled? + lex_route_settings.dig(:extensions, extension_name.to_sym, :enabled) == false + end + + def excluded_runner?(runner_name) + runners_list = Array(lex_route_settings.dig(:extensions, extension_name.to_sym, :exclude_runners)) + runners_list.include?(runner_name) + end + + def excluded_functions_for + functions_list = Array(lex_route_settings.dig(:extensions, extension_name.to_sym, :exclude_functions)) + functions_list.select { |f| f.is_a?(String) || f.is_a?(Symbol) }.map(&:to_sym) + end + end + end + end +end diff --git a/lib/legion/extensions/builders/runners.rb b/lib/legion/extensions/builders/runners.rb index ae517bea..02b9628b 100755 --- a/lib/legion/extensions/builders/runners.rb +++ b/lib/legion/extensions/builders/runners.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + require_relative 'base' +require_relative '../definitions' module Legion module Extensions @@ -18,44 +21,75 @@ def build_runners def build_runner_list runner_files.each do |file| runner_name = file.split('/').last.sub('.rb', '') - runner_class = "#{lex_class}::Runners::#{runner_name.split('_').collect(&:capitalize).join}" + runner_class = "#{lex_class}::Runners::#{runner_name.split('_').collect(&:capitalize).join}" loaded_runner = Kernel.const_get(runner_class) + loaded_runner.extend(Legion::Extensions::Definitions) unless loaded_runner.respond_to?(:definition) + ensure_lex_helpers(loaded_runner, runner_class) + Legion::Logging.debug "[Runners] registered: #{runner_class}" if defined?(Legion::Logging) + @runners[runner_name.to_sym] = build_runner_entry(runner_name, runner_class, loaded_runner, file) + populate_runner_methods(runner_name, loaded_runner) + end + end - @runners[runner_name.to_sym] = { - extension: lex_class.to_s.downcase, - extension_name: extension_name, - extension_class: lex_class, - runner_name: runner_name, - runner_class: runner_class, - runner_path: file, - class_methods: {} - } - - @runners[runner_name.to_sym][:scheduled_tasks] = loaded_runner.scheduled_tasks if loaded_runner.method_defined? :scheduled_tasks + def build_runner_entry(runner_name, runner_class, loaded_runner, file) + entry = { + extension: lex_class.to_s.downcase, + extension_name: extension_name, + settings_path: settings_path, + extension_class: lex_class, + runner_name: runner_name, + runner_class: runner_class, + runner_module: loaded_runner, + runner_path: file, + class_methods: {} + } + entry[:scheduled_tasks] = loaded_runner.scheduled_tasks if loaded_runner.method_defined?(:scheduled_tasks) + entry[:trigger_words] = if loaded_runner.respond_to?(:trigger_words) && loaded_runner.trigger_words.any? + loaded_runner.trigger_words + else + [runner_name] + end + entry[:desc] = settings[:runners][runner_name.to_sym][:desc] if settings.key?(:runners) && settings[:runners].key?(runner_name.to_sym) + entry + end - if settings.key?(:runners) && settings[:runners].key?(runner_name.to_sym) - @runners[runner_name.to_sym][:desc] = settings[:runners][runner_name.to_sym][:desc] - end + def populate_runner_methods(runner_name, loaded_runner) + loaded_runner.public_instance_methods(false).each do |runner_method| + @runners[runner_name.to_sym][:class_methods][runner_method] = { + args: loaded_runner.instance_method(runner_method).parameters + } + end + loaded_runner.methods(false).each do |runner_method| + next if %i[scheduled_tasks runner_description].include?(runner_method) - loaded_runner.public_instance_methods(false).each do |runner_method| - @runners[runner_name.to_sym][:class_methods][runner_method] = { - args: loaded_runner.instance_method(runner_method).parameters - } - end + @runners[runner_name.to_sym][:class_methods][runner_method] = { + args: loaded_runner.method(runner_method).parameters + } + end + end - loaded_runner.methods(false).each do |runner_method| - next if %i[scheduled_tasks runner_description].include? runner_method + def runner_modules + return [] unless defined?(@runners) && @runners.is_a?(Hash) - @runners[runner_name.to_sym][:class_methods][runner_method] = { - args: loaded_runner.method(runner_method).parameters - } - end - end + @runners.values.filter_map { |r| r[:runner_module] } end def runner_files @runner_files ||= find_files('runners') end + + private + + def ensure_lex_helpers(runner_module, runner_class) + return unless Legion::Extensions.const_defined?(:Helpers, false) && + Legion::Extensions::Helpers.const_defined?(:Lex, false) + + lex_mod = Legion::Extensions::Helpers::Lex + return if runner_module.ancestors.include?(lex_mod) + + runner_module.include(lex_mod) + Legion::Logging.info "[Runners] auto-included Helpers::Lex into #{runner_class}" if defined?(Legion::Logging) + end end end end diff --git a/lib/legion/extensions/builders/skills.rb b/lib/legion/extensions/builders/skills.rb new file mode 100644 index 00000000..03dd7f03 --- /dev/null +++ b/lib/legion/extensions/builders/skills.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Legion + module Extensions + module Builder + module Skills + include Legion::Extensions::Builder::Base + + attr_reader :skills + + def build_skills + return unless Object.const_defined?('Legion::LLM::Skills', false) + return unless Object.const_defined?('Legion::LLM', false) && + Legion::LLM.respond_to?(:started?) && Legion::LLM.started? + return if Legion::LLM.settings.dig(:skills, :enabled) == false + + @skills = {} + lex_mod = lex_class.is_a?(::Module) ? lex_class : ::Kernel.const_get(lex_class.to_s) + lex_mod.const_set(:Skills, ::Module.new) unless lex_mod.const_defined?(:Skills, false) + require_files(skill_files) + build_skill_list + end + + def build_skill_list + skill_files.each do |file| + skill_name = file.split('/').last.sub('.rb', '') + skill_class_name = "#{lex_class}::Skills::#{skill_name.split('_').collect(&:capitalize).join}" + loaded_skill = Kernel.const_get(skill_class_name) + Legion::LLM::Skills::Registry.register(loaded_skill) + @skills[skill_name.to_sym] = { + skill_class: skill_class_name, + skill_module: loaded_skill + } + Legion::Logging.debug "[Skills] registered: #{skill_class_name}" if defined?(Legion::Logging) + end + end + + def skill_files + @skill_files ||= find_files('skills') + end + end + end + end +end diff --git a/lib/legion/extensions/capability.rb b/lib/legion/extensions/capability.rb new file mode 100644 index 00000000..19a42fa6 --- /dev/null +++ b/lib/legion/extensions/capability.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Legion + module Extensions + Capability = ::Data.define( + :name, :extension, :runner, :function, + :description, :parameters, :tags, :loaded_at + ) do + def self.from_absorber(extension:, absorber:, patterns: [], description: nil) + absorber_name = absorber.name&.split('::')&.last || absorber.object_id.to_s + snake = absorber_name.gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase + canonical = "#{extension}:absorber:#{snake}" + new( + name: canonical, + extension: extension, + runner: 'Absorber', + function: absorber_name, + description: description, + parameters: { input: { type: :string, required: true } }, + tags: ['absorber'] + patterns.map { |p| "pattern:#{p[:type]}:#{p[:value]}" }, + loaded_at: Time.now + ) + end + + def self.from_runner(extension:, runner:, function:, **opts) + canonical = "#{extension}:#{runner.to_s.gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase}:#{function}" + new( + name: canonical, + extension: extension, + runner: runner.to_s, + function: function.to_s, + description: opts[:description], + parameters: opts[:parameters] || {}, + tags: Array(opts[:tags]), + loaded_at: Time.now + ) + end + + def matches_intent?(text) + words = text.downcase.split(/\s+/) + searchable = [description, *tags, extension, runner, function] + .compact.join(' ').downcase + + matching = words.count { |w| searchable.include?(w) } + matching.to_f / [words.length, 1].max >= 0.4 + end + + def to_mcp_tool + snake_runner = runner.gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase + tool_name = "legion.#{extension.delete_prefix('lex-').tr('-', '_')}.#{snake_runner}.#{function}" + properties = (parameters || {}).transform_values do |v| + v.is_a?(Hash) ? v : { type: v.to_s } + end + + { + name: tool_name, + description: description || "#{extension} #{runner}##{function}", + input_schema: { + type: 'object', + properties: properties, + required: parameters&.select { |_, v| v.is_a?(Hash) && v[:required] }&.keys&.map(&:to_s) || [] + } + } + end + end + end +end diff --git a/lib/legion/extensions/catalog.rb b/lib/legion/extensions/catalog.rb new file mode 100644 index 00000000..ffb5ffbc --- /dev/null +++ b/lib/legion/extensions/catalog.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require_relative 'catalog/registry' +require_relative 'catalog/available' + +module Legion + module Extensions + module Catalog + STATES = %i[registered loaded starting running stopping stopped].freeze + STATE_ORDER = STATES.each_with_index.to_h.freeze + + class << self + def register(lex_name, state: :registered) + return if @entries&.key?(lex_name) + + entries[lex_name] = { + state: state, + registered_at: Time.now, + started_at: nil, + stopped_at: nil + } + end + + def transition(lex_name, new_state) + return unless entries.key?(lex_name) + + entries[lex_name][:state] = new_state + entries[lex_name][:started_at] = Time.now if new_state == :running + entries[lex_name][:stopped_at] = Time.now if new_state == :stopped + + publish_transition(lex_name, new_state) + persist_transition(lex_name, new_state) + end + + def state(lex_name) + entries.dig(lex_name, :state) + end + + def entry(lex_name) + entries[lex_name] + end + + def loaded?(lex_name) + s = state(lex_name) + return false unless s + + STATE_ORDER[s] >= STATE_ORDER[:loaded] + end + + def running?(lex_name) + state(lex_name) == :running + end + + def all + entries.dup + end + + def reset! + @entries = {} + @extension_catalog_available = nil + @extension_catalog_connection_id = nil + @warned_missing_extension_catalog = false + end + + def flush_persisted_transitions + pending = nil + @pending_persists_mutex ||= Mutex.new + @pending_persists_mutex.synchronize do + return if @pending_persists.nil? || @pending_persists.empty? + + pending = @pending_persists.dup + @pending_persists.clear + end + + return unless defined?(Legion::Data::Local) && + Legion::Data::Local.respond_to?(:connected?) && + Legion::Data::Local.connected? + + ensure_local_migration_registered! + return warn_missing_extension_catalog_once unless extension_catalog_table_available? + + model = Legion::Data::Local.model(:extension_catalog) + now = Time.now + Legion::Data::Local.connection.transaction do + pending.each do |lex_name, new_state| + existing = model.where(lex_name: lex_name).first + if existing + next if existing.respond_to?(:state) && existing.state == new_state.to_s + + existing.update(state: new_state.to_s, updated_at: now) + else + model.insert(lex_name: lex_name, state: new_state.to_s, created_at: now, updated_at: now) + end + end + end + Legion::Logging.info "Catalog persisted #{pending.size} transitions" if defined?(Legion::Logging) + rescue StandardError => e + Legion::Logging.warn { "Catalog flush failed: #{e.class}: #{e.message}" } if defined?(Legion::Logging) + end + + private + + def entries + @entries ||= {} + end + + def publish_transition(lex_name, new_state) + return unless defined?(Legion::Transport::Connection) && + Legion::Transport::Connection.respond_to?(:session_open?) && + Legion::Transport::Connection.session_open? + + payload = Legion::JSON.dump( + lex_name: lex_name, + state: new_state.to_s, + timestamp: Time.now.to_i + ) + + catalog_exchange.publish(payload, routing_key: "legion.catalog.#{lex_name}.#{new_state}", + content_type: 'application/json', persistent: true) + rescue StandardError => e + @catalog_exchange = nil + Legion::Logging.warn { "Catalog publish failed for #{lex_name}=#{new_state}: #{e.class}: #{e.message}" } if defined?(Legion::Logging) + end + + def catalog_exchange + return @catalog_exchange if @catalog_exchange&.channel&.open? + + @catalog_exchange = Legion::Transport::Exchange.new('legion.catalog') + end + + def persist_transition(lex_name, new_state) + @pending_persists_mutex ||= Mutex.new + @pending_persists_mutex.synchronize do + @pending_persists ||= {} + @pending_persists[lex_name] = new_state + end + end + + def extension_catalog_table_available? + connection = Legion::Data::Local.connection + return false unless connection + + connection_id = connection.object_id + return true if @extension_catalog_connection_id == connection_id && @extension_catalog_available == true + + available = + if connection.respond_to?(:tables) + connection.tables.include?(:extension_catalog) + else + connection.respond_to?(:table_exists?) && connection.table_exists?(:extension_catalog) + end + + if available + @extension_catalog_connection_id = connection_id + @extension_catalog_available = true + else + @extension_catalog_connection_id = nil if @extension_catalog_connection_id == connection_id + @extension_catalog_available = nil + end + + available + rescue StandardError => e + Legion::Logging.warn { "Catalog table availability check failed: #{e.class}: #{e.message}" } if defined?(Legion::Logging) + false + end + + def ensure_local_migration_registered! + return unless defined?(Legion::Data::Local) && + Legion::Data::Local.respond_to?(:register_migrations) + + path = extension_catalog_migrations_path + return unless Dir.exist?(path) + + registered = if Legion::Data::Local.respond_to?(:registered_migrations) + Legion::Data::Local.registered_migrations + else + {} + end + return if registered.is_a?(Hash) && registered.key?(:extension_catalog) + + Legion::Data::Local.register_migrations(name: :extension_catalog, path: path) + rescue StandardError => e + Legion::Logging.warn { "Catalog migration registration failed: #{e.class}: #{e.message}" } if defined?(Legion::Logging) + end + + def extension_catalog_migrations_path + File.expand_path('../data/local_migrations', __dir__) + end + + def warn_missing_extension_catalog_once + return false if @warned_missing_extension_catalog + + @warned_missing_extension_catalog = true + Legion::Logging.warn('Catalog persist skipped: extension_catalog table is missing in Legion::Data::Local') if defined?(Legion::Logging) + false + end + end + + send(:ensure_local_migration_registered!) if defined?(Legion::Data::Local) + end + end +end diff --git a/lib/legion/extensions/catalog/available.rb b/lib/legion/extensions/catalog/available.rb new file mode 100644 index 00000000..2bf46a9e --- /dev/null +++ b/lib/legion/extensions/catalog/available.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Catalog + module Available + EXTENSIONS = [ + # core + { name: 'lex-acp', category: 'core', description: 'Agent communication protocol' }, + { name: 'lex-audit', category: 'core', description: 'Audit logging and trail' }, + { name: 'lex-codegen', category: 'core', description: 'Code generation pipeline' }, + { name: 'lex-conditioner', category: 'core', description: 'Task chain conditioning' }, + { name: 'lex-detect', category: 'core', description: 'Environment detection and recommendations' }, + { name: 'lex-exec', category: 'core', description: 'Shell command execution' }, + { name: 'lex-health', category: 'core', description: 'Health monitoring and metrics' }, + { name: 'lex-lex', category: 'core', description: 'Extension management' }, + { name: 'lex-llm-ledger', category: 'core', description: 'LLM cost and usage ledger' }, + { name: 'lex-log', category: 'core', description: 'Log shipping and aggregation' }, + { name: 'lex-metering', category: 'core', description: 'Resource metering and accounting' }, + { name: 'lex-node', category: 'core', description: 'Node identity and registration' }, + { name: 'lex-ping', category: 'core', description: 'Connectivity checks' }, + { name: 'lex-react', category: 'core', description: 'Event-driven reaction engine' }, + { name: 'lex-scheduler', category: 'core', description: 'Cron and interval scheduling' }, + { name: 'lex-synapse', category: 'core', description: 'Agent-to-agent relationships' }, + { name: 'lex-tasker', category: 'core', description: 'Task management and lifecycle' }, + { name: 'lex-telemetry', category: 'core', description: 'OpenTelemetry tracing integration' }, + { name: 'lex-transformer', category: 'core', description: 'Task chain transformation' }, + { name: 'lex-webhook', category: 'core', description: 'Inbound webhook receiver' }, + # ai + { name: 'lex-xai', category: 'ai', description: 'xAI Grok provider integration' }, + { name: 'lex-llm', category: 'ai', description: 'Common LLM provider base and routing metadata' }, + { name: 'lex-llm-anthropic', category: 'ai', description: 'Anthropic LLM provider integration' }, + { name: 'lex-llm-azure-foundry', category: 'ai', description: 'Azure AI Foundry hosted LLM provider integration' }, + { name: 'lex-llm-bedrock', category: 'ai', description: 'AWS Bedrock hosted LLM provider integration' }, + { name: 'lex-llm-gemini', category: 'ai', description: 'Google Gemini LLM provider integration' }, + { name: 'lex-llm-mlx', category: 'ai', description: 'Apple MLX local LLM provider integration' }, + { name: 'lex-llm-ollama', category: 'ai', description: 'Ollama LLM provider integration' }, + { name: 'lex-llm-openai', category: 'ai', description: 'OpenAI LLM provider integration' }, + { name: 'lex-llm-vertex', category: 'ai', description: 'Google Vertex AI hosted LLM provider integration' }, + { name: 'lex-llm-vllm', category: 'ai', description: 'vLLM OpenAI-compatible provider integration' }, + # agentic + { name: 'lex-agentic-affect', category: 'agentic', description: 'Affective state modeling' }, + { name: 'lex-agentic-attention', category: 'agentic', description: 'Attentional focus and salience' }, + { name: 'lex-agentic-defense', category: 'agentic', description: 'Defensive behavior and threat response' }, + { name: 'lex-agentic-executive', category: 'agentic', description: 'Executive function and planning' }, + { name: 'lex-agentic-homeostasis', category: 'agentic', description: 'Internal state regulation' }, + { name: 'lex-agentic-imagination', category: 'agentic', description: 'Generative imagination and hypothesis' }, + { name: 'lex-agentic-inference', category: 'agentic', description: 'Probabilistic inference engine' }, + { name: 'lex-agentic-integration', category: 'agentic', description: 'Cross-domain knowledge integration' }, + { name: 'lex-agentic-language', category: 'agentic', description: 'Natural language understanding' }, + { name: 'lex-agentic-learning', category: 'agentic', description: 'Online learning and adaptation' }, + { name: 'lex-agentic-memory', category: 'agentic', description: 'Long-term memory and recall' }, + { name: 'lex-agentic-self', category: 'agentic', description: 'Self-model and identity' }, + { name: 'lex-agentic-social', category: 'agentic', description: 'Social cognition and theory of mind' }, + { name: 'lex-adapter', category: 'agentic', description: 'Protocol and format adaptation' }, + { name: 'lex-apollo', category: 'agentic', description: 'Shared knowledge store client' }, + { name: 'lex-autofix', category: 'agentic', description: 'Autonomous code fix pipeline' }, + { name: 'lex-coldstart', category: 'agentic', description: 'Bootstrap knowledge ingestion' }, + { name: 'lex-cost-scanner', category: 'agentic', description: 'Cloud cost scanning and analysis' }, + { name: 'lex-dataset', category: 'agentic', description: 'Dataset management and versioning' }, + { name: 'lex-eval', category: 'agentic', description: 'LLM evaluation framework' }, + { name: 'lex-extinction', category: 'agentic', description: 'Worker lifecycle termination' }, + { name: 'lex-factory', category: 'agentic', description: 'Spec-to-code generation pipeline' }, + { name: 'lex-finops', category: 'agentic', description: 'FinOps cost optimization' }, + { name: 'lex-governance', category: 'agentic', description: 'Policy and compliance governance' }, + { name: 'lex-knowledge', category: 'agentic', description: 'Corpus ingestion and knowledge query' }, + { name: 'lex-mesh', category: 'agentic', description: 'Agent mesh and preference exchange' }, + { name: 'lex-mind-growth', category: 'agentic', description: 'Autonomous cognitive expansion' }, + { name: 'lex-onboard', category: 'agentic', description: 'New agent onboarding workflow' }, + { name: 'lex-pilot-infra-monitor', category: 'agentic', description: 'Infrastructure monitoring pilot' }, + { name: 'lex-pilot-knowledge-assist', category: 'agentic', description: 'Knowledge assist pilot worker' }, + { name: 'lex-privatecore', category: 'agentic', description: 'Private execution enclave' }, + { name: 'lex-prompt', category: 'agentic', description: 'Prompt management and versioning' }, + { name: 'lex-swarm', category: 'agentic', description: 'Multi-agent swarm orchestration' }, + { name: 'lex-swarm-github', category: 'agentic', description: 'GitHub code review swarm' }, + { name: 'lex-tick', category: 'agentic', description: 'Gaia tick cycle driver' }, + # identity + { name: 'lex-identity-approle', category: 'identity', description: 'Vault AppRole identity provider' }, + { name: 'lex-identity-aws', category: 'identity', description: 'AWS IAM identity provider' }, + { name: 'lex-identity-entra', category: 'identity', description: 'Microsoft Entra identity provider' }, + { name: 'lex-identity-github', category: 'identity', description: 'GitHub App identity provider' }, + { name: 'lex-identity-kerberos', category: 'identity', description: 'Kerberos identity provider' }, + { name: 'lex-identity-kubernetes', category: 'identity', description: 'Kubernetes service account identity provider' }, + { name: 'lex-identity-ldap', category: 'identity', description: 'LDAP identity provider' }, + { name: 'lex-identity-system', category: 'identity', description: 'System identity provider' }, + # service integrations + { name: 'lex-consul', category: 'service', description: 'HashiCorp Consul service mesh integration' }, + { name: 'lex-github', category: 'service', description: 'GitHub API integration' }, + { name: 'lex-http', category: 'service', description: 'Generic HTTP client runner' }, + { name: 'lex-kerberos', category: 'service', description: 'Kerberos authentication integration' }, + { name: 'lex-microsoft_teams', category: 'service', description: 'Microsoft Teams messaging integration' }, + { name: 'lex-nomad', category: 'service', description: 'HashiCorp Nomad job integration' }, + { name: 'lex-redis', category: 'service', description: 'Redis integration' }, + { name: 'lex-s3', category: 'service', description: 'AWS S3 object storage integration' }, + { name: 'lex-tfe', category: 'service', description: 'Terraform Enterprise integration' }, + { name: 'lex-uais', category: 'service', description: 'UHG AI Services integration' }, + { name: 'lex-vault', category: 'service', description: 'HashiCorp Vault secrets integration' }, + # other integrations + { name: 'lex-aha', category: 'other', description: 'Aha! roadmap integration' }, + { name: 'lex-chef', category: 'other', description: 'Chef infrastructure automation' }, + { name: 'lex-cloudflare', category: 'other', description: 'Cloudflare DNS and CDN integration' }, + { name: 'lex-discord', category: 'other', description: 'Discord messaging integration' }, + { name: 'lex-dns', category: 'other', description: 'DNS query and management' }, + { name: 'lex-docker', category: 'other', description: 'Docker container integration' }, + { name: 'lex-dynatrace', category: 'other', description: 'Dynatrace APM integration' }, + { name: 'lex-elastic_app_search', category: 'other', description: 'Elastic App Search integration' }, + { name: 'lex-elasticsearch', category: 'other', description: 'Elasticsearch integration' }, + { name: 'lex-gitlab', category: 'other', description: 'GitLab integration' }, + { name: 'lex-google-calendar', category: 'other', description: 'Google Calendar integration' }, + { name: 'lex-grafana', category: 'other', description: 'Grafana dashboard integration' }, + { name: 'lex-home-assistant', category: 'other', description: 'Home Assistant smart home integration' }, + { name: 'lex-influxdb', category: 'other', description: 'InfluxDB time series integration' }, + { name: 'lex-infoblox', category: 'other', description: 'Infoblox IPAM/DNS integration' }, + { name: 'lex-jenkins', category: 'other', description: 'Jenkins CI/CD integration' }, + { name: 'lex-jfrog', category: 'other', description: 'JFrog Artifactory integration' }, + { name: 'lex-jira', category: 'other', description: 'Jira issue tracking integration' }, + { name: 'lex-kafka', category: 'other', description: 'Apache Kafka messaging integration' }, + { name: 'lex-kubernetes', category: 'other', description: 'Kubernetes cluster integration' }, + { name: 'lex-lambda', category: 'other', description: 'AWS Lambda function integration' }, + { name: 'lex-memcached', category: 'other', description: 'Memcached cache integration' }, + { name: 'lex-mongodb', category: 'other', description: 'MongoDB integration' }, + { name: 'lex-mqtt', category: 'other', description: 'MQTT IoT messaging integration' }, + { name: 'lex-openweathermap', category: 'other', description: 'OpenWeatherMap weather integration' }, + { name: 'lex-pagerduty', category: 'other', description: 'PagerDuty alerting integration' }, + { name: 'lex-pihole', category: 'other', description: 'Pi-hole DNS filtering integration' }, + { name: 'lex-postgres', category: 'other', description: 'PostgreSQL database integration' }, + { name: 'lex-prometheus', category: 'other', description: 'Prometheus metrics integration' }, + { name: 'lex-pushbullet', category: 'other', description: 'Pushbullet notification integration' }, + { name: 'lex-pushover', category: 'other', description: 'Pushover notification integration' }, + { name: 'lex-sftp', category: 'other', description: 'SFTP file transfer integration' }, + { name: 'lex-slack', category: 'other', description: 'Slack messaging integration' }, + { name: 'lex-sleepiq', category: 'other', description: 'SleepIQ bed sensor integration' }, + { name: 'lex-smtp', category: 'other', description: 'SMTP email integration' }, + { name: 'lex-sonos', category: 'other', description: 'Sonos audio integration' }, + { name: 'lex-sqs', category: 'other', description: 'AWS SQS queue integration' }, + { name: 'lex-ssh', category: 'other', description: 'SSH remote execution integration' }, + { name: 'lex-telegram', category: 'other', description: 'Telegram messaging integration' }, + { name: 'lex-todoist', category: 'other', description: 'Todoist task management integration' }, + { name: 'lex-twilio', category: 'other', description: 'Twilio SMS/voice integration' }, + { name: 'lex-wled', category: 'other', description: 'WLED LED controller integration' } + ].each(&:freeze).freeze + + class << self + def all + EXTENSIONS.map(&:dup) + end + + def by_category(category) + EXTENSIONS.select { |e| e[:category] == category }.map(&:dup) + end + + def find(name) + entry = EXTENSIONS.find { |e| e[:name] == name } + entry&.dup + end + end + end + end + end +end diff --git a/lib/legion/extensions/catalog/registry.rb b/lib/legion/extensions/catalog/registry.rb new file mode 100644 index 00000000..86c7b71b --- /dev/null +++ b/lib/legion/extensions/catalog/registry.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Catalog + module Registry + @capabilities = [] + @by_name = {} + @mutex = Mutex.new + @on_change_callbacks = [] + + module_function + + def register(capability) + @mutex.synchronize do + return if @by_name.key?(capability.name) + + @capabilities << capability + @by_name[capability.name] = capability + end + notify_change + end + + def unregister(name) + @mutex.synchronize do + cap = @by_name.delete(name) + @capabilities.delete(cap) if cap + return unless cap + end + notify_change + end + + def unregister_extension(extension_name) + @mutex.synchronize do + removed = @capabilities.select { |c| c.extension == extension_name } + removed.each do |cap| + @by_name.delete(cap.name) + @capabilities.delete(cap) + end + return if removed.empty? + end + notify_change + end + + def capabilities + @mutex.synchronize { @capabilities.dup.freeze } + end + + def find(name:) + @mutex.synchronize { @by_name[name] } + end + + def find_by_intent(text) + @mutex.synchronize do + @capabilities.select { |c| c.matches_intent?(text) } + end + end + + def for_mcp + @mutex.synchronize { @capabilities.dup } + end + + def find_by_mcp_name(mcp_name) + @mutex.synchronize do + @capabilities.find { |cap| cap.to_mcp_tool[:name] == mcp_name } + end + end + + def for_override(tool_name) + @mutex.synchronize do + normalized = tool_name.downcase.tr('-', '_') + @capabilities.find do |cap| + cap.function.downcase == normalized || + cap.name.downcase.end_with?(normalized) || + cap.tags.any? { |t| t.downcase == normalized } + end + end + end + + def count + @mutex.synchronize { @capabilities.length } + end + + def on_change(&block) + @mutex.synchronize { @on_change_callbacks << block } + end + + def reset! + @mutex.synchronize do + @capabilities.clear + @by_name.clear + @on_change_callbacks.clear + end + end + + def notify_change + callbacks = @mutex.synchronize { @on_change_callbacks.dup } + callbacks.each do |cb| + cb.call + rescue StandardError => e + Legion::Logging.warn("Catalog::Registry on_change error: #{e.message}") if defined?(Legion::Logging) + end + end + + private_class_method :notify_change + end + end + end +end diff --git a/lib/legion/extensions/core.rb b/lib/legion/extensions/core.rb index 59af84a7..ebaead6f 100755 --- a/lib/legion/extensions/core.rb +++ b/lib/legion/extensions/core.rb @@ -1,7 +1,15 @@ +# frozen_string_literal: true + +require_relative 'absorbers' +require_relative 'builders/absorbers' require_relative 'builders/actors' require_relative 'builders/helpers' +require_relative 'builders/hooks' +require_relative 'builders/routes' require_relative 'builders/runners' +require_relative 'builders/skills' +require_relative 'helpers/segments' require_relative 'helpers/core' require_relative 'helpers/task' require_relative 'helpers/logger' @@ -10,6 +18,24 @@ require_relative 'helpers/data' require_relative 'helpers/cache' +begin + require 'legion/llm/helpers/llm' +rescue LoadError => e + Legion::Logging.debug "Extensions::Core: legion-llm helpers not available: #{e.message}" if defined?(Legion::Logging) +end + +begin + require_relative 'helpers/llm' +rescue LoadError => e + Legion::Logging.debug "Extensions::Core: local llm helper not available: #{e.message}" if defined?(Legion::Logging) +end + +begin + require_relative 'helpers/knowledge' +rescue LoadError => e + Legion::Logging.debug "Extensions::Core: knowledge helper not available: #{e.message}" if defined?(Legion::Logging) +end + require_relative 'actors/base' require_relative 'actors/every' require_relative 'actors/loop' @@ -17,18 +43,25 @@ require_relative 'actors/poll' require_relative 'actors/subscription' require_relative 'actors/nothing' +require_relative 'hooks/base' module Legion module Extensions module Core include Legion::Extensions::Helpers::Transport include Legion::Extensions::Helpers::Lex + include Legion::Extensions::Helpers::Knowledge if defined?(Legion::Extensions::Helpers::Knowledge) + include Legion::Extensions::Builder::Absorbers include Legion::Extensions::Builder::Runners include Legion::Extensions::Builder::Helpers include Legion::Extensions::Builder::Actors + include Legion::Extensions::Builder::Hooks + include Legion::Extensions::Builder::Routes + include Legion::Extensions::Builder::Skills def autobuild + Legion::Logging.debug "[Core] autobuild start: #{name}" if defined?(Legion::Logging) @actors = {} @meta_actors = {} @runners = {} @@ -39,16 +72,32 @@ def autobuild @messages = {} build_settings build_transport - build_data if Legion::Settings[:data][:connected] && data_required? + if Legion::Settings[:data][:connected] && (data_required? || data_migrations_available?) + Legion::Logging.debug "[Core] building data for #{name}" if defined?(Legion::Logging) + build_data + end build_helpers build_runners + generate_messages_from_definitions + build_absorbers build_actors + build_hooks + build_routes + build_skills if skills_required? + Legion::Logging.debug "[Core] autobuild complete: #{name}" if defined?(Legion::Logging) end def data_required? false end + def data_migrations_available? + Dir[File.join(extension_path.to_s, 'data', 'migrations', '*.rb')].any? + rescue StandardError => e + log.debug "[Core] data migration discovery failed for #{name}: #{e.message}" if defined?(log) + false + end + def transport_required? true end @@ -65,9 +114,89 @@ def vault_required? false end + def llm_required? + false + end + + def skills_required? + false + end + + def remote_invocable? + true + end + + def mcp_tools? + true + end + + def mcp_tools_deferred? + true + end + + def sticky_tools? + true + end + + def trigger_words + lex_name.split('_') + end + + # Auto-generate AMQP message classes for each runner method that has a definition. + # Explicit Messages::* classes in the transport directory take precedence. + # Runs after build_runners so definitions are populated. + def generate_messages_from_definitions + ctx = message_generation_context + return unless ctx + + @runners.each do |runner_name, attr| + generate_runner_messages(ctx, runner_name, attr) + end + rescue StandardError => e + log.warn "[Core] generate_messages_from_definitions failed: #{e.message}" if defined?(log) + end + + def message_generation_context + return unless defined?(Legion::Transport::Message) + return unless lex_class.const_defined?('Transport', false) + + transport_mod = lex_class::Transport + return unless transport_mod.const_defined?('Messages', false) && transport_mod.const_defined?('Exchanges', false) + + default_exch = transport_mod.default_exchange + { messages_mod: transport_mod::Messages, default_exch: default_exch, prefix: amqp_prefix } + rescue StandardError + nil + end + + def generate_runner_messages(ctx, runner_name, attr) + runner_module = attr[:runner_module] + return unless runner_module.respond_to?(:definitions) + + runner_module.definitions.each_key do |method_name| + sanitized = method_name.to_s.delete('?!') + const_name = "#{camelize(runner_name)}#{camelize(sanitized)}" + next if ctx[:messages_mod].const_defined?(const_name, false) + + rk_value = "#{ctx[:prefix]}.runners.#{runner_name}.#{method_name}" + ctx[:messages_mod].const_set(const_name, Class.new(Legion::Transport::Message) do + define_method(:exchange) { ctx[:default_exch] } + define_method(:routing_key) { rk_value } + end) + end + rescue StandardError => e + log.warn "[Core] message generation error for #{runner_name}: #{e.message}" if defined?(log) + end + + def camelize(name) + name.to_s.split('_').collect(&:capitalize).join + end + def build_data + Legion::Logging.debug "[Core] build_data: #{name}" if defined?(Legion::Logging) auto_generate_data lex_class::Data.build + Legion::Logging.info "[Core] data built: #{name}" if defined?(Legion::Logging) end def build_transport @@ -75,35 +204,25 @@ def build_transport require "#{extension_path}/transport/autobuild" extension_class::Transport::AutoBuild.build log.warn 'still using transport::autobuild, please upgrade' - elsif File.exist? "#{extension_path}/transport.rb" + return + end + + if File.exist? "#{extension_path}/transport.rb" require "#{extension_path}/transport" - extension_class::Transport.build + unless extension_class::Transport.respond_to?(:build) + log.warn "#{extension_class}::Transport does not respond to build, auto-generating" + auto_generate_transport + end else auto_generate_transport - extension_class::Transport.build end + extension_class::Transport.build end def build_settings - if Legion::Settings[:extensions].key?(lex_name.to_sym) - Legion::Settings[:default_extension_settings].each do |key, value| - Legion::Settings[:extensions][lex_name.to_sym][key.to_sym] = if Legion::Settings[:extensions][lex_name.to_sym].key?(key.to_sym) - value.merge(Legion::Settings[:extensions][lex_name.to_sym][key.to_sym]) - else - value - end - end - else - Legion::Settings[:extensions][lex_name.to_sym] = Legion::Settings[:default_extension_settings] - end - - default_settings.each do |key, value| - Legion::Settings[:extensions][lex_name.to_sym][key.to_sym] = if Legion::Settings[:extensions][lex_name.to_sym].key?(key.to_sym) - value.merge(Legion::Settings[:extensions][lex_name.to_sym][key.to_sym]) - else - value - end - end + target = extension_settings_target + merge_extension_defaults!(target, Legion::Settings[:default_extension_settings] || {}) + merge_extension_defaults!(target, default_settings) end def default_settings @@ -113,18 +232,63 @@ def default_settings def auto_generate_transport require 'legion/extensions/transport' log.debug 'running meta magic to generate a transport base class' - return if Kernel.const_defined? "#{lex_class}::Transport" - - Kernel.const_get(lex_class.to_s).const_set('Transport', Module.new { extend Legion::Extensions::Transport }) + if lex_class.const_defined?(:Transport, false) + mod = lex_class.const_get(:Transport, false) + mod.extend(Legion::Extensions::Transport) unless mod.respond_to?(:build) + else + lex_class.const_set(:Transport, Module.new { extend Legion::Extensions::Transport }) + end end def auto_generate_data require 'legion/extensions/data' log.debug 'running meta magic to generate a data base class' - Kernel.const_get(lex_class.to_s).const_set('Data', Module.new { extend Legion::Extensions::Data }) + if lex_class.const_defined?(:Data, false) + mod = lex_class.const_get(:Data, false) + mod.extend(Legion::Extensions::Data) unless mod.respond_to?(:build) + else + lex_class.const_set(:Data, Module.new { extend Legion::Extensions::Data }) + end rescue StandardError => e - log.error e.message - log.error e.backtrace + handle_exception(e, lex: lex_name, operation: 'auto_generate_data') + end + + def deep_dup_settings_value(value) + case value + when Hash + value.each_with_object({}) do |(key, nested), duplicated| + duplicated[key.to_sym] = deep_dup_settings_value(nested) + end + when Array + value.map { |item| deep_dup_settings_value(item) } + else + value.dup + end + rescue TypeError + value + end + + def extension_settings_target + settings_path.reduce(Legion::Settings[:extensions]) do |current, key| + current[key] = {} unless current[key].is_a?(Hash) + current[key] + end + end + + def merge_extension_defaults!(target, defaults) + defaults.each do |key, value| + key = key.to_sym + target[key] = if target.key?(key) + merge_extension_default_value(deep_dup_settings_value(value), target[key]) + else + deep_dup_settings_value(value) + end + end + end + + def merge_extension_default_value(default_value, current_value) + merge_extension_defaults!(current_value, default_value) if default_value.is_a?(Hash) && current_value.is_a?(Hash) + current_value end end end diff --git a/lib/legion/extensions/data.rb b/lib/legion/extensions/data.rb index 7cc18ffe..1f379ab5 100755 --- a/lib/legion/extensions/data.rb +++ b/lib/legion/extensions/data.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'legion/extensions/data/migrator' require 'legion/extensions/data/model' @@ -8,16 +10,15 @@ module Data include Legion::Extensions::Helpers::Logger def build - Legion::Logging.fatal 'testing inside run' @models = [] @migrations = [] - if Dir[File.expand_path("#{data_path}/migrations/*.rb")].count.positive? + if Dir[File.expand_path("#{data_path}/migrations/*.rb")].any? log.debug('Has migrations, checking status') run end models = Dir[File.expand_path("#{data_path}/models/*.rb")] - if models.count.positive? + if models.any? log.debug('Including LEX models') models.each do |file| require file @@ -48,8 +49,6 @@ def migrate_class end def run - Legion::Logging.fatal 'testing inside run' - return true if migrate_class.is_current? log.debug('Running LEX schema migrator') diff --git a/lib/legion/extensions/data/migrator.rb b/lib/legion/extensions/data/migrator.rb index 2e5f0d7a..c130f83e 100755 --- a/lib/legion/extensions/data/migrator.rb +++ b/lib/legion/extensions/data/migrator.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'sequel/extensions/migration' module Legion @@ -22,9 +24,9 @@ def default_schema_table def schema_dataset dataset = Legion::Data::Connection.sequel.from(default_schema_table).where(namespace: @extension) - return dataset if dataset.count.positive? + return dataset if dataset.any? - Legion::Data::Model::Extension.insert(active: 1, namespace: @extension, name: @lex_name) + Legion::Data::Model::Extension.insert(active: true, namespace: @extension, name: @lex_name) Legion::Data::Connection.sequel.from(default_schema_table).where(namespace: @extension) end alias ds schema_dataset diff --git a/lib/legion/extensions/data/model.rb b/lib/legion/extensions/data/model.rb index d98eb6b2..5ff26aef 100755 --- a/lib/legion/extensions/data/model.rb +++ b/lib/legion/extensions/data/model.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion module Extensions module Data diff --git a/lib/legion/extensions/definitions.rb b/lib/legion/extensions/definitions.rb new file mode 100644 index 00000000..2f051de4 --- /dev/null +++ b/lib/legion/extensions/definitions.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Definitions + DEFAULTS = { + remote_invocable: true, + mcp_exposed: true, + idempotent: false, + risk_tier: :standard, + tags: [], + requires: [], + inputs: {}, + outputs: {} + }.freeze + + def definition(method_name, **opts) + base = DEFAULTS.transform_values do |value| + case value + when Array, Hash + value.dup + else + value + end + end + own_definitions[method_name.to_sym] = base.merge(opts) + end + + def definitions + if respond_to?(:superclass) && superclass.respond_to?(:definitions) + superclass.definitions.merge(own_definitions) + else + own_definitions.dup + end + end + + def definition_for(method_name) + definitions[method_name.to_sym] + end + + private + + def own_definitions + @own_definitions ||= {} + end + end + end +end diff --git a/lib/legion/extensions/gem_source.rb b/lib/legion/extensions/gem_source.rb new file mode 100644 index 00000000..4fb2e554 --- /dev/null +++ b/lib/legion/extensions/gem_source.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module GemSource + DEFAULT_SOURCE = 'https://rubygems.org' + + class << self + def configured_sources + raw = begin + Legion::Settings.dig(:extensions, :sources) + rescue StandardError + nil + end + return [{ url: DEFAULT_SOURCE }] unless raw.is_a?(Array) && raw.any? + + raw.map { |s| s.is_a?(Hash) ? s : { url: s.to_s } } + end + + def source_urls + configured_sources.map { |s| s[:url] }.compact + end + + def source_args_for_cli + urls = source_urls + return '' if urls.empty? || urls == [DEFAULT_SOURCE] + + "#{urls.map { |url| "--source #{url}" }.join(' ')} --clear-sources" + end + + def install_gem(name, version: nil, gem_bin: nil, source_override: nil) + require 'open3' + gem_bin ||= File.join(RbConfig::CONFIG['bindir'], 'gem') + args = [gem_bin, 'install', name, '--no-document'] + args.push('-v', version) if version + + if source_override + args.push('--source', source_override, '--clear-sources') + else + urls = source_urls + unless urls.empty? || urls == [DEFAULT_SOURCE] + urls.each { |url| args.push('--source', url) } + args.push('--clear-sources') + end + end + + stdout, stderr, status = Open3.capture3(*args) + { success: status.success?, output: "#{stdout}\n#{stderr}".strip, command: args.join(' ') } + end + + def apply_credentials! + configured_sources.each do |source| + cred = source[:credentials] || source[:token] + next unless cred + + url = source[:url] + resolved = resolve_credential(cred) + next unless resolved + + Gem.configuration.set_api_key(url, resolved) + rescue StandardError => e + Legion::Logging.debug "GemSource: credential setup failed for #{url}: #{e.message}" if defined?(Legion::Logging) + end + end + + def setup! + apply_credentials! + + urls = source_urls + return if urls.empty? || urls == [DEFAULT_SOURCE] + + urls.each do |url| + Gem.sources << url unless Gem.sources.include?(url) + end + end + + private + + def resolve_credential(value) + return value unless value.start_with?('env:') + + env_key = value.delete_prefix('env:') + ENV.fetch(env_key, nil) + end + end + end + end +end diff --git a/lib/legion/extensions/handle.rb b/lib/legion/extensions/handle.rb new file mode 100644 index 00000000..24804927 --- /dev/null +++ b/lib/legion/extensions/handle.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Legion + module Extensions + class Handle + STATES = %i[registered loaded starting running stopping stopped failed].freeze + LOADED_STATES = %i[loaded starting running stopping].freeze + DISPATCHABLE_STATES = %i[loaded starting running].freeze + RELOAD_STATES = %i[idle pending updating rolling_back failed].freeze + DISPATCH_BLOCKING_RELOAD_STATES = %i[updating rolling_back].freeze + + attr_reader :lex_name, :gem_name, :active_version, :state, :reload_state, :hot_reloadable, + :latest_installed_version, :spec, :gem_dir, :loaded_features, :actors, :routes, :tools, :absorbers, + :runners, :loaded_at, :last_error + + def initialize(**attrs) + lex_name = attrs.fetch(:lex_name) + spec = attrs[:spec] + @lex_name = lex_name.to_s + @gem_name = (attrs[:gem_name] || lex_name).to_s + @spec = spec + @active_version = normalize_version(attrs[:active_version] || spec&.version) + @latest_installed_version = normalize_version(attrs[:latest_installed_version] || attrs[:installed_version] || @active_version) + @state = normalize_state(attrs.fetch(:state, :registered)) + @reload_state = normalize_reload_state(attrs.fetch(:reload_state, :idle)) + @hot_reloadable = attrs[:hot_reloadable] == true + @gem_dir = attrs[:gem_dir] || spec&.gem_dir + @loaded_features = Array(attrs.fetch(:loaded_features, [])).dup.freeze + @actors = Array(attrs.fetch(:actors, [])).dup.freeze + @routes = Array(attrs.fetch(:routes, [])).dup.freeze + @tools = Array(attrs.fetch(:tools, [])).dup.freeze + @absorbers = Array(attrs.fetch(:absorbers, [])).dup.freeze + @runners = Array(attrs.fetch(:runners, [])).dup.freeze + @loaded_at = attrs.fetch(:loaded_at, Time.now) + @last_error = attrs[:last_error] + end + + def loaded? + LOADED_STATES.include?(state) + end + + def running? + state == :running + end + + def pending_reload? + return false if active_version.nil? || latest_installed_version.nil? + + latest_installed_version > active_version + end + + def dispatchable? + DISPATCHABLE_STATES.include?(state) && !DISPATCH_BLOCKING_RELOAD_STATES.include?(reload_state) + end + + def with(**attrs) + self.class.new(**to_h, **attrs) + end + + def to_h + { + lex_name: lex_name, + gem_name: gem_name, + active_version: active_version, + latest_installed_version: latest_installed_version, + state: state, + reload_state: reload_state, + hot_reloadable: hot_reloadable, + spec: spec, + gem_dir: gem_dir, + loaded_features: loaded_features, + actors: actors, + routes: routes, + tools: tools, + absorbers: absorbers, + runners: runners, + loaded_at: loaded_at, + last_error: last_error + } + end + + private + + def normalize_version(value) + return nil if value.nil? + return value if value.is_a?(Gem::Version) + + Gem::Version.new(value.to_s) + end + + def normalize_state(value) + normalized = value.to_sym + return normalized if STATES.include?(normalized) + + raise ArgumentError, "unknown extension state: #{value.inspect}" + end + + def normalize_reload_state(value) + normalized = value.to_sym + return normalized if RELOAD_STATES.include?(normalized) + + raise ArgumentError, "unknown extension reload state: #{value.inspect}" + end + end + end +end diff --git a/lib/legion/extensions/handle_registry.rb b/lib/legion/extensions/handle_registry.rb new file mode 100644 index 00000000..f88b65e3 --- /dev/null +++ b/lib/legion/extensions/handle_registry.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'legion/extensions/handle' + +module Legion + module Extensions + class HandleRegistry + def initialize + @handles = {} + @mutex = Mutex.new + end + + def register(lex_name, **attrs) + key = normalize_name(lex_name) + @mutex.synchronize do + current = @handles[key] + @handles[key] = current ? current.with(**attrs) : Handle.new(lex_name: key, **attrs) + end + end + + def transition(lex_name, state) + update(lex_name, state: state) + end + + def update(lex_name, **attrs) + key = normalize_name(lex_name) + @mutex.synchronize do + current = @handles[key] || Handle.new(lex_name: key) + @handles[key] = current.with(**attrs) + end + end + + def fetch(lex_name) + @mutex.synchronize { @handles[normalize_name(lex_name)] } + end + + def all + @mutex.synchronize { @handles.values.dup } + end + + def running + all.select(&:running?) + end + + def loaded + all.select(&:loaded?) + end + + def dispatch_allowed?(lex_name) + handle = fetch(lex_name) + return true unless handle + + handle.dispatchable? + end + + def delete(lex_name) + @mutex.synchronize { @handles.delete(normalize_name(lex_name)) } + end + + def reset! + @mutex.synchronize { @handles.clear } + end + + private + + def normalize_name(lex_name) + lex_name.to_s + end + end + end +end diff --git a/lib/legion/extensions/helpers/base.rb b/lib/legion/extensions/helpers/base.rb index 11d8e5d1..bd2e2e03 100755 --- a/lib/legion/extensions/helpers/base.rb +++ b/lib/legion/extensions/helpers/base.rb @@ -1,20 +1,65 @@ +# frozen_string_literal: true + module Legion module Extensions module Helpers module Base + # Words that mark the boundary between extension namespace segments and + # internal module structure. Segment extraction stops at these words. + NAMESPACE_BOUNDARIES = %w[Actor Actors Runners Helpers Transport Data].freeze + + def segments + @segments ||= derive_segments_from_namespace + end + + def lex_slug + segments.join('.') + end + + def log_tag + Helpers::Segments.segments_to_log_tag(segments) + end + + def amqp_prefix + Helpers::Segments.segments_to_amqp_prefix(segments) + end + + def settings_path + Helpers::Segments.segments_to_settings_path(segments) + end + + def table_prefix + Helpers::Segments.segments_to_table_prefix(segments) + end + def lex_class - @lex_class ||= Kernel.const_get(calling_class_array[0..2].join('::')) + @lex_class ||= begin + parts = calling_class_array + ext_idx = parts.index('Extensions') + # All LEX extensions must be under Legion::Extensions::. If 'Extensions' + # is not present, this is a misconfigured caller — fail loudly. + raise ArgumentError, "#{calling_class} is not under Legion::Extensions namespace" unless ext_idx + + end_idx = ext_idx + 1 + end_idx += 1 while end_idx < parts.length && !NAMESPACE_BOUNDARIES.include?(parts[end_idx]) + # NameError cannot occur here: lex_class is only ever called from autobuild, + # build_transport, build_runners, build_actors, and transport helpers — all of + # which execute while the extension module is already required and fully defined. + # The constant we resolve (e.g. Legion::Extensions::Http) is the very module + # that owns this method, so it must already exist. + Kernel.const_get(parts[0...end_idx].join('::')) + end end alias extension_class lex_class def lex_name - @lex_name ||= calling_class_array[2].gsub(/(?= 1 + base_name = segs.join('-') + gem_name = "lex-#{base_name}" + gem_dir = begin + Gem::Specification.find_by_name(gem_name).gem_dir + rescue Gem::MissingSpecError + begin + Gem::Specification.find_by_name("lex-#{base_name.tr('_', '-')}").gem_dir + rescue Gem::MissingSpecError + segs.pop + next + end + end + break + end + + unless gem_dir + Legion::Logging.error "#{self.class}: could not find gem for segments #{segments.inspect}" + return nil + end + + require_path = Helpers::Segments.derive_require_path("lex-#{segments.join('-')}") + "#{gem_dir}/lib/#{require_path}" end alias extension_path full_path @@ -76,6 +149,28 @@ def to_dotted_hash(hash, recursive_key = '') end end end + + private + + def derive_segments_from_namespace + parts = calling_class_array + ext_idx = parts.index('Extensions') + return [camelize_to_snake(parts[0])] unless ext_idx + + ext_parts = [] + ((ext_idx + 1)...parts.length).each do |i| + break if NAMESPACE_BOUNDARIES.include?(parts[i]) + + ext_parts << camelize_to_snake(parts[i]) + end + ext_parts.empty? ? [camelize_to_snake(parts[ext_idx + 1])] : ext_parts + end + + def camelize_to_snake(str) + str.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase + end end end end diff --git a/lib/legion/extensions/helpers/cache.rb b/lib/legion/extensions/helpers/cache.rb index 485c5282..3b702879 100755 --- a/lib/legion/extensions/helpers/cache.rb +++ b/lib/legion/extensions/helpers/cache.rb @@ -1,22 +1,14 @@ +# frozen_string_literal: true + require 'legion/extensions/helpers/base' +require 'legion/cache/helper' module Legion module Extensions module Helpers module Cache include Legion::Extensions::Helpers::Base - - def cache_namespace - @cache_namespace ||= lex_name - end - - def cache_set(key, value, ttl: 60, **) - Legion::Cache.set(cache_namespace + key, value, ttl: ttl) - end - - def cache_get(key) - Legion::Cache.get(cache_namespace + key) - end + include Legion::Cache::Helper end end end diff --git a/lib/legion/extensions/helpers/core.rb b/lib/legion/extensions/helpers/core.rb index 0f93f201..a1205e6c 100755 --- a/lib/legion/extensions/helpers/core.rb +++ b/lib/legion/extensions/helpers/core.rb @@ -1,17 +1,14 @@ +# frozen_string_literal: true + require_relative 'base' +require 'legion/settings/helper' + module Legion module Extensions module Helpers module Core include Legion::Extensions::Helpers::Base - - def settings - if Legion::Settings[:extensions].key?(lex_filename.to_sym) - Legion::Settings[:extensions][lex_filename.to_sym] - else - { logger: { level: 'info', extended: false, internal: false } } - end - end + include Legion::Settings::Helper # looks local, then in crypt, then settings, then cache, then env def find_setting(name, **opts) diff --git a/lib/legion/extensions/helpers/data.rb b/lib/legion/extensions/helpers/data.rb index 647de318..9412f79c 100755 --- a/lib/legion/extensions/helpers/data.rb +++ b/lib/legion/extensions/helpers/data.rb @@ -1,22 +1,14 @@ +# frozen_string_literal: true + require 'legion/extensions/helpers/base' +require 'legion/data/helper' module Legion module Extensions module Helpers module Data include Legion::Extensions::Helpers::Base - - def data_path - @data_path ||= "#{full_path}/data" - end - - def data_class - @data_class ||= lex_class::Data - end - - def models_class - @models_class ||= data_class::Model - end + include Legion::Data::Helper end end end diff --git a/lib/legion/extensions/helpers/knowledge.rb b/lib/legion/extensions/helpers/knowledge.rb new file mode 100644 index 00000000..9c48d582 --- /dev/null +++ b/lib/legion/extensions/helpers/knowledge.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Legion + module Extensions + module Helpers + module Knowledge + include Legion::Extensions::Helpers::Base + + def ingest_knowledge(content_or_path, type: :auto, tags: [], scope: :global, **opts) + target = resolve_ingest_target(scope) + return { success: false, error: :apollo_not_available } unless target + + text, metadata = extract_if_needed(content_or_path, type: type) + return { success: false, error: :extraction_failed, detail: metadata } unless text + + extraction_tags = metadata_to_tags(metadata) if metadata + all_tags = Array(tags) + Array(extraction_tags) + Array(knowledge_default_tags) + + target.ingest( + content: text, + tags: all_tags, + source_channel: opts[:source_channel] || derive_lex_name, + **opts.except(:source_channel) + ) + end + + def query_knowledge(text:, limit: 5, scope: nil, **) + scope ||= default_query_scope + + case scope.to_sym + when :local then query_local(text: text, limit: limit, **) + when :global then query_global(text: text, limit: limit, **) + else query_all(text: text, limit: limit, **) + end + end + + # --- Status --- + # Override these in your LEX to customise availability checks. + + def knowledge_connected? + knowledge_global_connected? || knowledge_local_connected? + rescue StandardError + false + end + + def knowledge_global_connected? + global_available? + rescue StandardError + false + end + + def knowledge_local_connected? + local_available? + rescue StandardError + false + end + + # --- Layered Defaults --- + # Override in your LEX to set extension-level defaults. + # Resolution chain: LEX override -> Settings -> hardcoded fallback + + # Override to set a custom default query scope for this extension. + # Resolution: LEX override -> Settings[:apollo][:local][:default_query_scope] -> :all + def knowledge_default_scope + return :all unless defined?(Legion::Settings) + + scope = Legion::Settings.dig(:apollo, :local, :default_query_scope) + scope ? scope.to_sym : :all + rescue StandardError + :all + end + + # Override to automatically attach extension-level tags to every ingest call. + # Resolution: LEX override -> [] (no default tags) + def knowledge_default_tags + [] + rescue StandardError + [] + end + + private + + def resolve_ingest_target(scope) + case scope.to_sym + when :local + local_available? ? Legion::Apollo::Local : nil + else + global_available? ? Legion::Apollo : nil + end + end + + def query_local(text:, limit:, **) + unless local_available? + Legion::Logging.debug 'query_knowledge(:local) called but Apollo::Local is not available' if defined?(Legion::Logging) + return { success: false, error: :apollo_not_available } + end + + Legion::Apollo::Local.query(text: text, limit: limit, **) + end + + def query_global(text:, limit:, **) + unless global_available? + Legion::Logging.debug 'query_knowledge(:global) called but Apollo is not available' if defined?(Legion::Logging) + return { success: false, error: :apollo_not_available } + end + + Legion::Apollo.query(text: text, limit: limit, **) + end + + def query_all(text:, limit:, **) + local_results = local_available? ? Array((Legion::Apollo::Local.query(text: text, limit: limit, **) || {})[:results]) : [] + global_results = global_available? ? Array((Legion::Apollo.query(text: text, limit: limit, **) || {})[:results]) : [] + + return { success: false, error: :apollo_not_available } if local_results.empty? && global_results.empty? && !local_available? && !global_available? + + merged = merge_results(local_results, global_results) + { success: true, results: merged.first(limit), count: [merged.size, limit].min, mode: :all } + end + + def merge_results(local_results, global_results) + seen = {} + merged = [] + + local_results.each do |r| + key = r[:content_hash] || r[:content] + seen[key] = true + merged << r + end + + global_results.each do |r| + key = r[:content_hash] || r[:content] + merged << r unless seen[key] + end + + merged + end + + def global_available? + defined?(Legion::Apollo) && Legion::Apollo.started? + end + + def local_available? + defined?(Legion::Apollo::Local) && Legion::Apollo::Local.started? + end + + def default_query_scope + knowledge_default_scope + end + + def extract_if_needed(content_or_path, type:) + return extract_file(content_or_path, type: type) if content_or_path.is_a?(String) && File.exist?(content_or_path) + return extract_file(content_or_path, type: type) if content_or_path.respond_to?(:read) + + [content_or_path.to_s, nil] + end + + def extract_file(source, type:) + return [source.to_s, nil] unless defined?(Legion::Data::Extract) + + result = Legion::Data::Extract.extract(source, type: type) + if result[:text] + [result[:text], result[:metadata]] + else + [nil, result] + end + end + + def metadata_to_tags(metadata) + tags = [] + tags << metadata[:type].to_s if metadata[:type] + tags << "pages:#{metadata[:pages]}" if metadata[:pages] + tags + end + + def derive_lex_name + parts = self.class.name&.split('::') + parts && parts[2] ? parts[2].downcase : 'unknown' + end + end + end + end +end diff --git a/lib/legion/extensions/helpers/lex.rb b/lib/legion/extensions/helpers/lex.rb index b0f95c0a..1b26f926 100755 --- a/lib/legion/extensions/helpers/lex.rb +++ b/lib/legion/extensions/helpers/lex.rb @@ -1,31 +1,31 @@ +# frozen_string_literal: true + +require 'legion/json/helper' +require_relative 'core' +require_relative 'logger' +require_relative 'secret' +require_relative 'cache' +require_relative 'transport' +require_relative 'task' + +begin + require_relative 'data' +rescue LoadError + nil +end + module Legion module Extensions module Helpers module Lex include Legion::Extensions::Helpers::Core include Legion::Extensions::Helpers::Logger - - def function_example(function, example) - function_set(function, :example, example) - end - - def function_options(function, options) - function_set(function, :options, options) - end - - def function_desc(function, desc) - function_set(function, :desc, desc) - end - - def function_set(function, key, value) - unless respond_to? function - log.debug "function_#{key} called but function doesn't exist, f: #{function}" - return nil - end - settings[:functions] = {} if settings[:functions].nil? - settings[:functions][function] = {} if settings[:functions][function].nil? - settings[:functions][function][key] = value - end + include Legion::JSON::Helper + include Legion::Extensions::Helpers::Secret + include Legion::Extensions::Helpers::Cache + include Legion::Extensions::Helpers::Transport + include Legion::Extensions::Helpers::Task + include Legion::Extensions::Helpers::Data if defined?(Legion::Extensions::Helpers::Data) def runner_desc(desc) settings[:runners] = {} if settings[:runners].nil? @@ -34,8 +34,12 @@ def runner_desc(desc) end def self.included(base) - base.send :extend, Legion::Extensions::Helpers::Core if base.instance_of?(Class) - base.send :extend, Legion::Extensions::Helpers::Logger if base.instance_of?(Class) + if base.instance_of?(Class) + base.send :extend, Legion::Extensions::Helpers::Core + base.send :extend, Legion::Extensions::Helpers::Logger + base.send :extend, Legion::Extensions::Helpers::Cache + base.send :extend, Legion::Extensions::Helpers::Transport + end base.extend base if base.instance_of?(Module) end diff --git a/lib/legion/extensions/helpers/llm.rb b/lib/legion/extensions/helpers/llm.rb new file mode 100644 index 00000000..f266e8d5 --- /dev/null +++ b/lib/legion/extensions/helpers/llm.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'legion/extensions/helpers/base' + +begin + require 'legion/llm/helper' +rescue LoadError + # legion-llm not available; LLM helper methods will be absent. + # Extensions declaring llm_required? are skipped when the gem is missing. +end + +module Legion + module Extensions + module Helpers + module LLM + include Legion::Extensions::Helpers::Base + include Legion::LLM::Helper if defined?(Legion::LLM::Helper) + end + end + end +end diff --git a/lib/legion/extensions/helpers/logger.rb b/lib/legion/extensions/helpers/logger.rb index f5b682d4..31fa0610 100755 --- a/lib/legion/extensions/helpers/logger.rb +++ b/lib/legion/extensions/helpers/logger.rb @@ -1,38 +1,22 @@ +# frozen_string_literal: true + +require_relative 'base' + module Legion module Extensions module Helpers module Logger - def log - return @log unless @log.nil? - - logger_hash = { lex: lex_filename || nil } - logger_hash[:lex] = lex_filename.first if logger_hash[:lex].is_a? Array - if respond_to?(:settings) && settings.key?(:logger) - logger_hash[:level] = settings[:logger].key?(:level) ? settings[:logger][:level] : 'info' - logger_hash[:log_file] = settings[:logger][:log_file] if settings[:logger].key? :log_file - logger_hash[:trace] = settings[:logger][:trace] if settings[:logger].key? :trace - logger_hash[:extended] = settings[:logger][:extended] if settings[:logger].key? :extended - elsif respond_to?(:settings) - Legion::Logging.warn Legion::Settings[:extensions][lex_filename.to_sym] - Legion::Logging.warn "#{lex_name} has settings but no :logger key" - end - @log = Legion::Logging::Logger.new(**logger_hash) - end + include Legion::Extensions::Helpers::Base + include Legion::Logging::Helper - def handle_exception(exception, task_id: nil, **opts) - log.error exception.message + " for task_id: #{task_id} but was logged " - log.error exception.backtrace[0..10] - log.error opts + def handle_runner_exception(exception, task_id: nil, **opts) # rubocop:disable Style/ArgumentsForwarding + handle_exception(exception, task_id: task_id, **opts) # rubocop:disable Style/ArgumentsForwarding unless task_id.nil? Legion::Transport::Messages::TaskLog.new( task_id: task_id, runner_class: to_s, - entry: { - exception: true, - message: exception.message, - **opts - } + entry: { exception: true, message: exception.message, **opts } ).publish end diff --git a/lib/legion/extensions/helpers/secret.rb b/lib/legion/extensions/helpers/secret.rb new file mode 100644 index 00000000..ea821db9 --- /dev/null +++ b/lib/legion/extensions/helpers/secret.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Helpers + class SecretAccessor + def initialize(lex_name:) + @lex_name = lex_name + @warned = false + end + + def [](name, shared: false, user: nil) + return nil unless crypt_available? + + Legion::Crypt.get(resolve_path(name, shared: shared, user: user)) + rescue StandardError => e + log_warn("secret read failed for #{name}: #{e.message}") + nil + end + + def []=(name, value) + return unless crypt_available? + + Legion::Crypt.write(resolve_path(name, shared: false, user: nil), **value) + rescue StandardError => e + log_warn("secret write failed for #{name}: #{e.message}") + end + + def write(name, shared: false, user: nil, **data) + return false unless crypt_available? + + Legion::Crypt.write(resolve_path(name, shared: shared, user: user), **data) + true + rescue StandardError => e + log_warn("secret write failed for #{name}: #{e.message}") + false + end + + def exist?(name, shared: false, user: nil) + return false unless crypt_available? + + Legion::Crypt.exist?(resolve_path(name, shared: shared, user: user)) + rescue StandardError + false + end + + def delete(name, shared: false, user: nil) + return false unless crypt_available? + + Legion::Crypt.delete(resolve_path(name, shared: shared, user: user)) + true + rescue StandardError => e + log_warn("secret delete failed for #{name}: #{e.message}") + false + end + + private + + def resolve_path(name, shared:, user:) + prefix = shared ? 'shared' : "users/#{resolve_user(user)}" + "#{prefix}/#{@lex_name}/#{name}" + end + + def resolve_user(explicit_user) + return explicit_user if explicit_user + + Secret.resolved_identity || ENV.fetch('USER', 'default') + end + + def crypt_available? + return false unless defined?(Legion::Crypt) + + unless @warned || vault_connected? + log_warn('Vault not connected — secret operations may fail') + @warned = true + end + true + end + + def vault_connected? + return Legion::Crypt.vault_connected? if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:vault_connected?) + + defined?(Legion::Settings) && + Legion::Settings[:crypt]&.dig(:vault, :connected) == true + rescue StandardError + false + end + + def log_warn(msg) + Legion::Logging.warn("[Secret] #{msg}") if defined?(Legion::Logging) + end + end + + module Secret + @resolved_identity = nil + @identity_source = nil + + class << self + attr_reader :resolved_identity, :identity_source + + def resolve_identity! + @resolved_identity = nil + @identity_source = nil + + if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:kerberos_principal) && + Legion::Crypt.kerberos_principal + @resolved_identity = Legion::Crypt.kerberos_principal + @identity_source = :kerberos + elsif entra_principal + @resolved_identity = entra_principal + @identity_source = :entra + end + + @resolved_identity + end + + def reset_identity! + @resolved_identity = nil + @identity_source = nil + end + + private + + def entra_principal + return nil unless defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache) + + cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache + return nil unless cache.respond_to?(:instance) + + instance = cache.instance + return nil unless instance.respond_to?(:user_principal) + + principal = instance.user_principal + principal unless principal.nil? || principal.empty? + rescue StandardError + nil + end + end + + def secret + @secret ||= SecretAccessor.new(lex_name: lex_name) + end + end + end + end +end diff --git a/lib/legion/extensions/helpers/segments.rb b/lib/legion/extensions/helpers/segments.rb new file mode 100644 index 00000000..24b8dbe2 --- /dev/null +++ b/lib/legion/extensions/helpers/segments.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Helpers + module Segments + module_function + + COMPOUND_SUFFIXES = { + %w[llm azure foundry] => %w[llm azure_foundry] + }.freeze + + def derive_segments(gem_name) + segments = gem_name.delete_prefix('lex-').split('-') + COMPOUND_SUFFIXES.fetch(segments, segments) + end + + def derive_namespace(gem_name) + derive_segments(gem_name).map { |s| s.split('_').map(&:capitalize).join } + end + + def derive_const_path(gem_name) + "Legion::Extensions::#{derive_namespace(gem_name).join('::')}" + end + + def derive_require_path(gem_name) + "legion/extensions/#{derive_segments(gem_name).join('/')}" + end + + def segments_to_log_tag(segments) + segments.map { |s| "[#{s}]" }.join + end + + def segments_to_amqp_prefix(segments) + "lex.#{segments.join('.')}" + end + + def segments_to_settings_path(segments) + segments.map(&:to_sym) + end + + def segments_to_table_prefix(segments) + segments.join('_') + end + + def categorize_gem(gem_name, categories:, lists:) + # Check defined lists first (list membership takes priority) + lists.each do |cat_name, gem_list| + next unless categories.key?(cat_name) + + return { category: cat_name, tier: categories[cat_name][:tier] } if gem_list.include?(gem_name) + end + + # Check prefix-matched categories + bare = gem_name.delete_prefix('lex-') + categories.each do |cat_name, cat_config| + next unless cat_config[:type] == :prefix + + return { category: cat_name, tier: cat_config[:tier] } if bare.start_with?("#{cat_name}-") + end + + { category: :default, tier: 5 } + end + end + end + end +end diff --git a/lib/legion/extensions/helpers/task.rb b/lib/legion/extensions/helpers/task.rb index 77e254f9..5c2fd22c 100755 --- a/lib/legion/extensions/helpers/task.rb +++ b/lib/legion/extensions/helpers/task.rb @@ -1,3 +1,7 @@ +# frozen_string_literal: true + +require_relative 'base' +require_relative 'logger' require 'legion/transport' require 'legion/transport/messages/task_update' require 'legion/transport/messages/task_log' @@ -6,6 +10,9 @@ module Legion module Extensions module Helpers module Task + include Legion::Extensions::Helpers::Base + include Legion::Extensions::Helpers::Logger + def generate_task_log(task_id:, function:, runner_class: to_s, **payload) begin if Legion::Settings[:data][:connected] @@ -14,8 +21,7 @@ def generate_task_log(task_id:, function:, runner_class: to_s, **payload) return true if Legion::Data::Model::TaskLog.insert(task_id: task_id, function_id: function_id, entry: Legion::JSON.dump(payload)) end rescue StandardError => e - log.warn e.backtrace - log.warn("generate_task_log failed, reverting to rmq message, e: #{e.message}") + handle_exception(e, level: :warn) end Legion::Transport::Messages::TaskLog.new(task_id: task_id, runner_class: runner_class, function: function, entry: payload).publish end @@ -39,8 +45,7 @@ def task_update(task_id, status, use_database: true, **opts) end Legion::Transport::Messages::TaskUpdate.new(**update_hash).publish rescue StandardError => e - log.fatal e.message - log.fatal e.backtrace + handle_exception(e, level: :fatal) raise e end diff --git a/lib/legion/extensions/helpers/transport.rb b/lib/legion/extensions/helpers/transport.rb index 9e69ea6e..b12b9633 100755 --- a/lib/legion/extensions/helpers/transport.rb +++ b/lib/legion/extensions/helpers/transport.rb @@ -1,43 +1,14 @@ +# frozen_string_literal: true + require_relative 'base' +require 'legion/transport/helper' module Legion module Extensions module Helpers module Transport include Legion::Extensions::Helpers::Base - - def transport_path - @transport_path ||= "#{full_path}/transport" - end - - def transport_class - @transport_class ||= lex_class::Transport - end - - def messages - @messages ||= transport_class::Messages - end - - def queues - @queues ||= transport_class::Queues - end - - def exchanges - @exchanges ||= transport_class::Exchanges - end - - def default_exchange - @default_exchange ||= build_default_exchange - end - - def build_default_exchange - exchange = "#{transport_class}::Exchanges::#{lex_const}" - return Object.const_get(exchange) if transport_class::Exchanges.const_defined? lex_const - - transport_class::Exchanges.const_set(lex_const, Class.new(Legion::Transport::Exchange)) - @default_exchange = Kernel.const_get(exchange) - @default_exchange - end + include Legion::Transport::Helper end end end diff --git a/lib/legion/extensions/hooks/base.rb b/lib/legion/extensions/hooks/base.rb new file mode 100644 index 00000000..99f3b878 --- /dev/null +++ b/lib/legion/extensions/hooks/base.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require_relative '../definitions' + +module Legion + module Extensions + module Hooks + class Base + extend Legion::Extensions::Definitions + include Legion::Extensions::Helpers::Lex + + class << self + # DSL: route based on a request header value + # route_header 'X-GitHub-Event', + # 'push' => :on_push, + # 'pull_request' => :on_pull_request + def route_header(header_name, mapping = {}) + @route_type = :header + @route_header_name = header_name.upcase.tr('-', '_') + @route_mapping = mapping.transform_keys(&:to_s) + end + + # DSL: route based on a payload field value + # route_field :event_type, + # 'build.completed' => :on_build, + # 'deploy.started' => :on_deploy + def route_field(field_name, mapping = {}) + @route_type = :field + @route_field_name = field_name.to_sym + @route_mapping = mapping.transform_keys(&:to_s) + end + + # DSL: verify via HMAC signature (GitHub, Slack, Stripe pattern) + # verify_hmac header: 'X-Hub-Signature-256', + # secret: :webhook_secret, + # algorithm: 'SHA256', + # prefix: 'sha256=' + def verify_hmac(header:, secret:, algorithm: 'SHA256', prefix: 'sha256=') + @verify_type = :hmac + @verify_config = { header: header.upcase.tr('-', '_'), secret: secret, algorithm: algorithm, prefix: prefix } + end + + # DSL: verify via bearer/static token in a header + # verify_token header: 'Authorization', secret: :webhook_token + def verify_token(header: 'Authorization', secret: :webhook_token) + @verify_type = :token + @verify_config = { header: header.upcase.tr('-', '_'), secret: secret } + end + + # DSL: declare a sub-path suffix appended to the auto-generated hook route + # mount '/callback' # e.g. /api/extensions/microsoft_teams/hooks/auth/callback + def mount(path) + @mount_path = path + end + + attr_reader :route_type, :route_header_name, :route_field_name, + :route_mapping, :verify_type, :verify_config, :mount_path + end + + # Instance methods called by the API layer + + # Determine which runner function to call. + # Returns a symbol (function name) or nil (unhandled). + def route(headers, payload) + case self.class.route_type + when :header + route_by_header(headers) + when :field + route_by_field(payload) + else + :handle # deprecated fallback; prefer explicit route_header/route_field + end + end + + # Verify the request is authentic. + # Returns true/false. + def verify(headers, body) + case self.class.verify_type + when :hmac + verify_hmac(headers, body) + when :token + verify_token(headers) + else + true + end + end + + # Which runner class handles this hook's functions. + # Default: the first runner in the extension, or one matching the hook name. + def runner_class + nil + end + + private + + def route_by_header(headers) + header_key = "HTTP_#{self.class.route_header_name}" + value = headers[header_key]&.to_s + self.class.route_mapping&.fetch(value, nil) + end + + def route_by_field(payload) + value = payload[self.class.route_field_name]&.to_s + self.class.route_mapping&.fetch(value, nil) + end + + def verify_hmac(headers, body) + config = self.class.verify_config + secret = resolve_secret(config[:secret]) + return true if secret.nil? + + header_key = "HTTP_#{config[:header]}" + signature = headers[header_key] + return false if signature.nil? + + expected = "#{config[:prefix]}#{OpenSSL::HMAC.hexdigest(config[:algorithm], secret, body)}" + secure_compare(expected, signature) + end + + def verify_token(headers) + config = self.class.verify_config + secret = resolve_secret(config[:secret]) + return true if secret.nil? + + header_key = "HTTP_#{config[:header]}" + token = headers[header_key]&.sub(/^Bearer\s+/i, '') + return false if token.nil? + + secure_compare(secret, token) + end + + def resolve_secret(secret_name) + return secret_name if secret_name.is_a?(String) + + find_setting(secret_name) + end + + def secure_compare(left, right) + return false if left.nil? || right.nil? + return false if left.bytesize != right.bytesize + + left.bytes.zip(right.bytes).reduce(0) { |acc, (x, y)| acc | (x ^ y) }.zero? + end + end + end + end +end diff --git a/lib/legion/extensions/permissions.rb b/lib/legion/extensions/permissions.rb new file mode 100644 index 00000000..3d2da359 --- /dev/null +++ b/lib/legion/extensions/permissions.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module Legion + module Extensions + module Permissions + SANDBOX_BASE = File.expand_path('~/.legionio/data').freeze + + DENY_LIST = [ + File.expand_path('~/.ssh'), + File.expand_path('~/.gnupg'), + File.expand_path('~/.aws/credentials') + ].freeze + + class << self + def sandbox_path(lex_name) + File.join(SANDBOX_BASE, lex_name) + end + + def allowed?(lex_name, path, access_type) + expanded = File.expand_path(path) + return false if denied?(expanded) + return true if in_sandbox?(lex_name, expanded) + return true if auto_approved?(lex_name, expanded) + return true if explicitly_approved?(lex_name, expanded, access_type) + + false + end + + def approve(lex_name, path, access_type) + approvals[approval_key(lex_name, path, access_type)] = true + persist_approval(lex_name, path, access_type, true) + end + + def deny(lex_name, path, access_type) + approvals[approval_key(lex_name, path, access_type)] = false + persist_approval(lex_name, path, access_type, false) + end + + def approved?(lex_name, path, access_type) + approvals[approval_key(lex_name, path, access_type)] == true + end + + def add_auto_approve(lex_name, globs) + auto_approve_globs[lex_name] ||= [] + auto_approve_globs[lex_name].concat(Array(globs)) + end + + def declared_paths(lex_name) + declarations[lex_name] || { read_paths: [], write_paths: [] } + end + + def register_paths(lex_name, read_paths: [], write_paths: []) + declarations[lex_name] = { read_paths: Array(read_paths), write_paths: Array(write_paths) } + end + + def reset! + @approvals = {} + @auto_approve_globs = {} + @declarations = {} + end + + private + + def approvals + @approvals ||= {} + end + + def auto_approve_globs + @auto_approve_globs ||= {} + end + + def declarations + @declarations ||= {} + end + + def denied?(expanded_path) + DENY_LIST.any? { |denied| expanded_path.start_with?(denied) || expanded_path == denied } + end + + def in_sandbox?(lex_name, expanded_path) + expanded_path.start_with?(sandbox_path(lex_name)) + end + + def auto_approved?(lex_name, expanded_path) + global_globs = load_global_auto_approve + lex_globs = auto_approve_globs[lex_name] || load_lex_auto_approve(lex_name) + (global_globs + (lex_globs || [])).any? do |glob| + normalized = glob.end_with?('**') ? "#{glob}/*" : glob + File.fnmatch(normalized, expanded_path, File::FNM_PATHNAME) + end + end + + def explicitly_approved?(lex_name, expanded_path, access_type) + approvals.any? do |key, approved| + next false unless approved + + k_lex, k_path, k_type = key.split('|', 3) + k_lex == lex_name && k_type == access_type.to_s && expanded_path.start_with?(k_path) + end + end + + def approval_key(lex_name, path, access_type) + "#{lex_name}|#{path}|#{access_type}" + end + + def load_global_auto_approve + return [] unless defined?(Legion::Settings) + + Legion::Settings.dig(:permissions, :auto_approve) || [] + rescue StandardError => e + Legion::Logging.debug "Permissions#load_global_auto_approve failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def load_lex_auto_approve(lex_name) + return [] unless defined?(Legion::Settings) + + Legion::Settings.dig(lex_name.tr('-', '_').to_sym, :permissions, :auto_approve) || [] + rescue StandardError => e + Legion::Logging.debug "Permissions#load_lex_auto_approve failed for #{lex_name}: #{e.message}" if defined?(Legion::Logging) + [] + end + + def persist_approval(lex_name, path, access_type, approved) + return unless defined?(Legion::Data::Local) && + Legion::Data::Local.respond_to?(:connected?) && + Legion::Data::Local.connected? + + model = Legion::Data::Local.model(:extension_permissions) + existing = model.where(lex_name: lex_name, path: path, access_type: access_type.to_s).first + if existing + existing.update(approved: approved, updated_at: Time.now) + else + model.insert(lex_name: lex_name, path: path, access_type: access_type.to_s, + approved: approved, created_at: Time.now, updated_at: Time.now) + end + rescue StandardError => e + Legion::Logging.warn "Permissions#persist_approval failed for #{lex_name} #{path}: #{e.message}" if defined?(Legion::Logging) + nil + end + end + end + end +end diff --git a/lib/legion/extensions/transport.rb b/lib/legion/extensions/transport.rb index 177b045b..c849d478 100755 --- a/lib/legion/extensions/transport.rb +++ b/lib/legion/extensions/transport.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion module Extensions module Transport @@ -7,6 +9,7 @@ module Transport attr_accessor :exchanges, :queues, :consumers, :messages def build + log.debug "[Transport] build start: #{lex_name}" @queues = [] @exchanges = [] @messages = [] @@ -19,15 +22,17 @@ def build build_e_to_q(additional_e_to_q) auto_create_dlx_exchange auto_create_dlx_queue + auto_generate_messages + log.info "[Transport] built exchanges=#{@exchanges.count} queues=#{@queues.count} for #{lex_name}" rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace + log.error "[Transport] build failed for #{lex_name}" + handle_exception(e, lex: lex_name) end def generate_base_modules - lex_class.const_set('Transport', Module.new) unless lex_class.const_defined?('Transport') + lex_class.const_set('Transport', Module.new) unless lex_class.const_defined?('Transport', false) %w[Queues Exchanges Messages Consumers].each do |thing| - next if transport_class.const_defined? thing + next if transport_class.const_defined?(thing, false) transport_class.const_set(thing, Module.new) end @@ -35,7 +40,7 @@ def generate_base_modules def require_transport_items { exchanges: @exchanges, queues: @queues, consumers: @consumers, messages: @messages }.each do |item, obj| - Dir[File.expand_path("#{transport_path}/#{item}/*.rb")].sort.each do |file| + Dir[File.expand_path("#{transport_path}/#{item}/*.rb")].each do |file| require file file_name = file.to_s.split('/').last.split('.').first obj.push(file_name) unless obj.include?(file_name) @@ -43,23 +48,22 @@ def require_transport_items end end - def auto_create_exchange(exchange, default_exchange = false) # rubocop:disable Style/OptionalBooleanParameter + def auto_create_exchange(exchange, default_exchange: false) if Object.const_defined? exchange - Legion::Logging.warn "#{exchange} is already defined" + log.warn "#{exchange} is already defined" return end return build_default_exchange if default_exchange + ext_amqp = amqp_prefix transport_class::Exchanges.const_set(exchange.split('::').pop, Class.new(Legion::Transport::Exchange) do - def exchange_name - self.class.ancestors.first.to_s.split('::')[5].downcase - end + define_method(:exchange_name) { "#{ext_amqp}.#{self.class.to_s.split('::').last.downcase}" } end) end def auto_create_queue(queue) if Kernel.const_defined?(queue) - Legion::Logging.warn "#{queue} is already defined" + log.warn "#{queue} is already defined" return end @@ -67,13 +71,19 @@ def auto_create_queue(queue) end def auto_create_dlx_exchange - dlx = if transport_class::Exchanges.const_defined? 'Dlx' + return unless remote_invocable_extension? + + dlx = if transport_class::Exchanges.const_defined?('Dlx', false) transport_class::Exchanges::Dlx else transport_class::Exchanges.const_set('Dlx', Class.new(default_exchange) do def exchange_name "#{super}.dlx" end + + def default_type + 'fanout' + end end) end @@ -81,35 +91,81 @@ def exchange_name end def auto_create_dlx_queue - return if transport_class::Queues.const_defined?('Dlx') + return unless remote_invocable_extension? + return if transport_class::Queues.const_defined?('Dlx', false) special_name = default_exchange.new.exchange_name dlx_queue = Legion::Transport::Queue.new "#{special_name}.dlx", auto_delete: false dlx_queue.bind("#{special_name}.dlx", { routing_key: '#' }) end + def auto_generate_messages + return unless defined?(@runners) && @runners.is_a?(Hash) + + messages_mod = transport_class::Messages + ext_amqp = amqp_prefix + @runners.each_value { |info| auto_generate_runner_messages(info, messages_mod, ext_amqp) } + rescue StandardError => e + log.error("[Transport] auto-generate messages failed: #{e.message}") if respond_to?(:log) + end + + def auto_generate_runner_messages(runner_info, messages_mod, ext_amqp) + runner_name = runner_info[:runner_name] + runner_module = runner_info[:runner_module] + return if runner_module.nil? + return unless runner_module.respond_to?(:definition_for) + + methods = runner_module.respond_to?(:instance_methods) ? runner_module.instance_methods(false) : [] + methods.each { |method_name| auto_generate_message(runner_name, method_name, runner_module, messages_mod, ext_amqp) } + end + + def auto_generate_message(runner_name, method_name, runner_module, messages_mod, ext_amqp) + defn = runner_module.definition_for(method_name) + return if defn.nil? || defn[:inputs].nil? || defn[:inputs].empty? + + class_name = "#{runner_name.to_s.split('_').collect(&:capitalize).join}#{method_name.to_s.split('_').collect(&:capitalize).join}" + return if messages_mod.const_defined?(class_name, false) + + routing_key = "#{ext_amqp}.runners.#{runner_name}.#{method_name}" + msg_class = Class.new(Legion::Transport::Message) do + define_method(:exchange_name) { ext_amqp } + define_method(:routing_key) { routing_key } + end + messages_mod.const_set(class_name, msg_class) + end + def build_e_to_q(array) array.each do |binding| binding[:routing_key] = nil unless binding.key? :routing_key binding[:to] = nil unless binding.key?(:to) binding[:from] = default_exchange if !binding.key?(:from) || binding[:from].nil? bind_e_to_q(**binding) + rescue StandardError => e + log.warn '[transport] failed to build exchange-to-queue binding ' \ + "from=#{binding[:from].inspect} to=#{binding[:to].inspect} " \ + "routing_key=#{binding[:routing_key].inspect} binding=#{binding.inspect}" + handle_exception(e, handled: false, level: :warn) + raise e end end def bind_e_to_q(to:, from: default_exchange, routing_key: nil, **) + log.debug "[transport] building auto binding exchange: #{from}, routing_key: #{routing_key}, to: #{to}" if from.is_a? String - from = "#{transport_class}::Exchanges::#{from.split('_').collect(&:capitalize).join}" unless from.include?('::') + from = "#{transport_class}::Exchanges::#{from.tr('.', '_').split('_').collect(&:capitalize).join}" unless from.include?('::') auto_create_exchange(from) unless Object.const_defined? from end if to.is_a? String - to = "#{transport_class}::Queues::#{to.split('_').collect(&:capitalize).join}" unless to.include?('::') + to = "#{transport_class}::Queues::#{to.tr('.', '_').split('_').collect(&:capitalize).join}" unless to.include?('::') auto_create_queue(to) unless Object.const_defined?(to) end routing_key = to.to_s.split('::').last.downcase if routing_key.nil? bind(from, to, routing_key: routing_key) + rescue StandardError => e + handle_exception(e, handled: false, level: :warn, from: from, to: to, routing_key: routing_key) + raise e end def build_e_to_e @@ -125,6 +181,9 @@ def build_e_to_e end bind(binding[:from], binding[:to], binding) + rescue StandardError => e + handle_exception(e, handled: false, level: :warn) + raise e end end @@ -133,18 +192,15 @@ def bind(from, to, routing_key: nil, **_options) to = to.is_a?(String) ? Kernel.const_get(to).new : to.new to.bind(from, routing_key: routing_key) rescue StandardError => e - log.fatal e.message - log.fatal e.backtrace - log.fatal({ from: from, to: to, routing_key: routing_key }) + handle_exception(e, level: :fatal, from: from, to: to, routing_key: routing_key) end def e_to_q - [] if !@exchanges.count != 1 - auto = [] - @queues.each do |queue| - auto.push(from: @exchanges.first, to: queue, routing_key: queue) + return [] if @exchanges.count != 1 + + @queues.map do |queue| + { from: @exchanges.first, to: queue, routing_key: "#{amqp_prefix}.runners.#{queue}.#" } end - auto end def e_to_e @@ -154,6 +210,12 @@ def e_to_e def additional_e_to_q [] end + + def remote_invocable_extension? + return lex_class.remote_invocable? if lex_class.respond_to?(:remote_invocable?) + + true + end end end end diff --git a/lib/legion/fleet/conditioner_rules.rb b/lib/legion/fleet/conditioner_rules.rb new file mode 100644 index 00000000..874a0101 --- /dev/null +++ b/lib/legion/fleet/conditioner_rules.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Legion + module Fleet + module ConditionerRules + # Conditioner rules that complement the relationship conditions. + # These are higher-level routing rules that the conditioner evaluates + # when a relationship's conditions are met but additional logic is needed. + # + # The primary routing (which stage follows which) is handled by the + # 10 relationships in manifest.yml. These rules provide supplementary + # conditioning for edge cases. + RULES = [ + { + name: 'fleet-skip-planning-trivial', + description: 'Skip planning for trivial fixes (assessor sets planning.enabled=false)', + conditions: { + all: [ + { fact: 'results.config.complexity', operator: 'equal', value: 'trivial' }, + { fact: 'results.config.planning.enabled', operator: 'equal', value: true } + ] + }, + action: :override, + overrides: { 'results.config.planning.enabled' => false } + }, + { + name: 'fleet-skip-validation-trivial', + description: 'Skip validation for trivial fixes', + conditions: { + all: [ + { fact: 'results.config.complexity', operator: 'equal', value: 'trivial' }, + { fact: 'results.config.validation.enabled', operator: 'equal', value: true } + ] + }, + action: :override, + overrides: { 'results.config.validation.enabled' => false } + }, + { + name: 'fleet-escalate-max-iterations', + description: 'Route to escalation when max iterations exceeded', + conditions: { + all: [ + { fact: 'results.pipeline.review_result.verdict', operator: 'equal', value: 'rejected' }, + { fact: 'results.pipeline.attempt', operator: 'greater_or_equal', value: 4 } + ] + }, + action: :route, + target: { extension: 'assessor', runner: 'assessor', function: 'escalate' } + }, + { + name: 'fleet-critical-production-max-capability', + description: 'Critical production issues get maximum capability models', + conditions: { + all: [ + { fact: 'results.config.priority', operator: 'equal', value: 'critical' } + ] + }, + action: :override, + overrides: { + 'results.config.implementation.solvers' => 3, + 'results.config.implementation.validators' => 3, + 'results.config.implementation.max_iterations' => 10 + } + }, + { + name: 'fleet-governance-mind-growth', + description: 'Mind growth proposals require governance approval', + conditions: { + all: [ + { fact: 'results.source', operator: 'equal', value: 'mind_growth' }, + { fact: 'results.config.priority', operator: 'in_set', value: %w[high critical] } + ] + }, + action: :require_approval, + approval_type: 'fleet.governance.mind_growth' + } + ].freeze + + def self.rules + RULES + end + + def self.seed! + return { success: false, error: :data_not_available } unless defined?(Legion::Data) + + seeded = RULES.map { |rule| rule[:name] } + { success: true, seeded: seeded } + end + end + end +end diff --git a/lib/legion/fleet/manifest.yml b/lib/legion/fleet/manifest.yml new file mode 100644 index 00000000..f2a2949f --- /dev/null +++ b/lib/legion/fleet/manifest.yml @@ -0,0 +1,244 @@ +--- +name: fleet-pipeline +version: "1.0.0" +description: >- + Fleet Pipeline: universal intake-to-done engine. Connects assessor, planner, + developer, and validator via conditioner-driven routing. 10 relationships + define the flexible pipeline graph per design spec section 4. + +requires: + - lex-assessor + - lex-planner + - lex-developer + - lex-validator + - lex-codegen + - lex-eval + - lex-exec + - lex-tasker + - lex-conditioner + - lex-transformer + +relationships: + # Relationship 1: Assessor -> Planner (if planning enabled) + - name: fleet-assess-to-plan + trigger: + extension: assessor + runner: assessor + function: assess + action: + extension: planner + runner: planner + function: plan + conditions: + all: + - fact: results.config.planning.enabled + operator: equal + value: true + allow_new_chains: true + + # Relationship 2: Assessor -> Developer (if planning disabled) + - name: fleet-assess-to-develop + trigger: + extension: assessor + runner: assessor + function: assess + action: + extension: developer + runner: developer + function: implement + conditions: + all: + - fact: results.config.planning.enabled + operator: equal + value: false + allow_new_chains: true + + # Relationship 3: Planner -> Developer (chain inherited) + - name: fleet-plan-to-develop + trigger: + extension: planner + runner: planner + function: plan + action: + extension: developer + runner: developer + function: implement + allow_new_chains: false + + # Relationship 4: Developer -> Validator (if validation enabled) + - name: fleet-develop-to-validate + trigger: + extension: developer + runner: developer + function: implement + action: + extension: validator + runner: validator + function: validate + conditions: + all: + - fact: results.config.validation.enabled + operator: equal + value: true + allow_new_chains: false + + # Relationship 4b: Developer feedback -> Validator (when validation enabled) + - name: fleet-feedback-to-validate + trigger: + extension: developer + runner: developer + function: incorporate_feedback + action: + extension: validator + runner: validator + function: validate + conditions: + all: + - fact: results.config.validation.enabled + operator: equal + value: true + allow_new_chains: false + + # Relationship 4c: Developer feedback -> Escalate (when results.escalate == true) + - name: fleet-feedback-to-escalate + trigger: + extension: developer + runner: developer + function: incorporate_feedback + action: + extension: assessor + runner: assessor + function: escalate + conditions: + all: + - fact: results.escalate + operator: equal + value: true + allow_new_chains: false + + # Relationship 5: Developer -> Ship (if validation disabled) + - name: fleet-develop-to-ship + trigger: + extension: developer + runner: developer + function: implement + action: + extension: developer + runner: ship + function: finalize + conditions: + all: + - fact: results.config.validation.enabled + operator: equal + value: false + allow_new_chains: false + + # Relationship 6: Validator -> Ship (approved) + - name: fleet-validate-to-ship + trigger: + extension: validator + runner: validator + function: validate + action: + extension: developer + runner: ship + function: finalize + conditions: + all: + - fact: results.pipeline.review_result.verdict + operator: equal + value: approved + allow_new_chains: false + + # Relationship 7: Validator -> Developer feedback (rejected, under limit) + # NOTE: value 4 (not 5) because attempt starts at 0 and increments before + # re-entering implement. With value=4: attempts 0,1,2,3 retry (4 retries). + # Attempt 4 escalates. This gives exactly max_iterations=5 total runs. + # IMPORTANT: This hardcoded value is a safety net. The developer's + # incorporate_feedback runner checks the per-item limit internally, + # allowing different max_iterations per work item without reseeding. + - name: fleet-validate-to-feedback + trigger: + extension: validator + runner: validator + function: validate + action: + extension: developer + runner: developer + function: incorporate_feedback + conditions: + all: + - fact: results.pipeline.review_result.verdict + operator: equal + value: rejected + - fact: results.pipeline.attempt + operator: less_than + value: 4 + allow_new_chains: false + + # Relationship 8: Validator -> Escalate (rejected, at limit) + # NOTE: Safety-net fallback. Primary enforcement is in incorporate_feedback. + - name: fleet-validate-to-escalate + trigger: + extension: validator + runner: validator + function: validate + action: + extension: assessor + runner: assessor + function: escalate + conditions: + all: + - fact: results.pipeline.review_result.verdict + operator: equal + value: rejected + - fact: results.pipeline.attempt + operator: greater_or_equal + value: 4 + allow_new_chains: false + +settings: + fleet: + enabled: true + sources: [] + llm: + routing: + escalation: + enabled: true + planning: + enabled: true + solvers: 1 + validators: 1 + max_iterations: 2 + implementation: + solvers: 1 + validators: 3 + max_iterations: 5 + validation: + enabled: true + run_tests: true + run_lint: true + security_scan: true + adversarial_review: true + feedback: + drain_enabled: true + max_drain_rounds: 3 + summarize_after: 2 + workspace: + isolation: worktree + cleanup_on_complete: true + context: + load_repo_docs: true + load_file_tree: true + max_context_files: 50 + tracing: + stage_comments: true + token_tracking: true + safety: + poison_message_threshold: 2 + cancel_allowed: true + selection: + strategy: test_winner + escalation: + on_max_iterations: human + consent_domain: fleet.shipping diff --git a/lib/legion/fleet/settings.rb b/lib/legion/fleet/settings.rb new file mode 100644 index 00000000..c8098be1 --- /dev/null +++ b/lib/legion/fleet/settings.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module Legion + module Fleet + module Settings + FLEET_DEFAULTS = { + enabled: false, + poison_message_threshold: 2, + + transport: { + retry_base_delay_seconds: 1, + retry_max_delay_seconds: 30 + }, + + git: { + depth: 5 + }, + + workspace: { + base_dir: '~/.legionio/fleet/repos', + worktree_base: '~/.legionio/fleet/worktrees', + isolation: :worktree, + cleanup_on_complete: true, + cleanup_clones: false + }, + + materialization: { + strategy: :clone + }, + + work_item: { + description_max_bytes: 32_768, + instructions_max_bytes: 16_384 + }, + + cache: { + dedup_ttl_seconds: 86_400, + payload_ttl_seconds: 86_400, + context_ttl_seconds: 86_400, + worktree_ttl_seconds: 86_400 + }, + + planning: { + enabled: true, + solvers: 1, + validators: 2, + max_iterations: 5 + }, + + implementation: { + solvers: 1, + validators: 3, + max_iterations: 5, + models: nil + }, + + validation: { + enabled: true, + run_tests: true, + run_lint: true, + security_scan: true, + adversarial_review: true, + reviewer_models: nil, + quality_gate_threshold: 0.8, + quality_weights: { + completeness: 0.35, + correctness: 0.35, + quality: 0.20, + security: 0.10 + } + }, + + feedback: { + drain_enabled: true, + max_drain_rounds: 3, + summarize_after: 2 + }, + + context: { + load_repo_docs: true, + load_file_tree: true, + max_context_files: 50, + inline_content_max_bytes: 32_768, + url_fetch_timeout_seconds: 30, + url_fetch_max_bytes: 1_048_576 + }, + + llm: { + thinking_budget_base_tokens: 16_000, + thinking_budget_max_tokens: 64_000, + validator_timeout_seconds: 120 + }, + + model_selection: { + basic_max: 0.3, + moderate_max: 0.6 + }, + + github: { + pr_files_per_page: 30, + bot_username: nil, + token: nil + }, + + tracing: { + stage_comments: true, + token_tracking: true + }, + + safety: { + cancel_allowed: true + }, + + selection: { + strategy: :test_winner + }, + + escalation: { + on_max_iterations: :human, + consent_domain: 'fleet.shipping' + } + }.freeze + + LLM_ROUTING_OVERRIDES = { + escalation: { + enabled: true, + pipeline_enabled: true, + max_attempts: 3, + quality_threshold: 50 + } + }.freeze + + def self.apply! + return unless defined?(Legion::Settings) + + Legion::Settings.loader.load_module_settings({ fleet: FLEET_DEFAULTS }) + Legion::Settings.loader.load_module_settings({ llm: { routing: LLM_ROUTING_OVERRIDES } }) + end + end + end +end diff --git a/lib/legion/fleet/settings_defaults.rb b/lib/legion/fleet/settings_defaults.rb new file mode 100644 index 00000000..4d0875fc --- /dev/null +++ b/lib/legion/fleet/settings_defaults.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'json' +require 'fileutils' + +module Legion + module Fleet + module SettingsDefaults + DEFAULTS = { + fleet: { + enabled: true, + sources: [], + llm: { + routing: { + escalation: { + enabled: true + } + } + }, + planning: { + enabled: true, + solvers: 1, + validators: 1, + max_iterations: 2 + }, + implementation: { + solvers: 1, + validators: 3, + max_iterations: 5 + }, + validation: { + enabled: true, + run_tests: true, + run_lint: true, + security_scan: true, + adversarial_review: true + }, + feedback: { + drain_enabled: true, + max_drain_rounds: 3, + summarize_after: 2 + }, + workspace: { + isolation: :worktree, + cleanup_on_complete: true + }, + context: { + load_repo_docs: true, + load_file_tree: true, + max_context_files: 50 + }, + tracing: { + stage_comments: true, + token_tracking: true + }, + safety: { + poison_message_threshold: 2, + cancel_allowed: true + }, + selection: { + strategy: :test_winner + }, + escalation: { + on_max_iterations: :human, + consent_domain: 'fleet.shipping' + } + } + }.freeze + + def self.defaults + DEFAULTS + end + + def self.write_settings_file(path, force: false) + return { success: false, reason: :exists } if File.exist?(path) && !force + + ::FileUtils.mkdir_p(File.dirname(path)) + File.write(path, ::JSON.pretty_generate(DEFAULTS)) + { success: true, path: path } + end + end + end +end diff --git a/lib/legion/graph/builder.rb b/lib/legion/graph/builder.rb new file mode 100644 index 00000000..af9a60a0 --- /dev/null +++ b/lib/legion/graph/builder.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Legion + module Graph + module Builder + class << self + def build(chain_id: nil, worker_id: nil, limit: 100) # rubocop:disable Lint/UnusedMethodArgument + Legion::Logging.debug "[Graph::Builder] build chain_id=#{chain_id} limit=#{limit}" if defined?(Legion::Logging) + return { nodes: {}, edges: [] } unless db_available? + + ds = Legion::Data.connection[:relationships].limit(limit) + ds = ds.where(chain_id: chain_id) if chain_id + + nodes = {} + edges = [] + + ds.each do |rel| + trigger = rel[:trigger] || "node_#{rel[:id]}_from" + action = rel[:action] || "node_#{rel[:id]}_to" + + nodes[trigger] ||= { label: trigger, type: 'trigger' } + nodes[action] ||= { label: action, type: 'action' } + edges << { + from: trigger, + to: action, + label: rel[:runner_function] || '', + chain_id: rel[:chain_id] + } + end + + Legion::Logging.debug "[Graph::Builder] built nodes=#{nodes.size} edges=#{edges.size}" if defined?(Legion::Logging) + { nodes: nodes, edges: edges } + end + + private + + def db_available? + defined?(Legion::Data) && Legion::Data.connection&.table_exists?(:relationships) + rescue StandardError => e + Legion::Logging.debug "Graph::Builder#db_available? check failed: #{e.message}" if defined?(Legion::Logging) + false + end + end + end + end +end diff --git a/lib/legion/graph/exporter.rb b/lib/legion/graph/exporter.rb new file mode 100644 index 00000000..cc037c4a --- /dev/null +++ b/lib/legion/graph/exporter.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Legion + module Graph + module Exporter + class << self + def to_mermaid(graph) + Legion::Logging.debug "[Graph::Exporter] to_mermaid nodes=#{graph[:nodes].size} edges=#{graph[:edges].size}" if defined?(Legion::Logging) + lines = ['graph TD'] + node_ids = {} + counter = 0 + + graph[:nodes].each do |key, node| + counter += 1 + id = "N#{counter}" + node_ids[key] = id + lines << " #{id}[#{node[:label]}]" + end + + graph[:edges].each do |edge| + from = node_ids[edge[:from]] + to = node_ids[edge[:to]] + next unless from && to + + lines << if edge[:label] && !edge[:label].empty? + " #{from} -->|#{edge[:label]}| #{to}" + else + " #{from} --> #{to}" + end + end + + lines.join("\n") + end + + def to_dot(graph) + Legion::Logging.debug "[Graph::Exporter] to_dot nodes=#{graph[:nodes].size} edges=#{graph[:edges].size}" if defined?(Legion::Logging) + lines = ['digraph legion_tasks {', ' rankdir=LR;'] + + graph[:nodes].each do |key, node| + label = dot_escape(node[:label]) + shape = node[:type] == 'trigger' ? 'box' : 'ellipse' + lines << " \"#{key}\" [label=\"#{label}\" shape=#{shape}];" + end + + graph[:edges].each do |edge| + escaped = dot_escape(edge[:label]) + label = escaped && !escaped.empty? ? " [label=\"#{escaped}\"]" : '' + lines << " \"#{edge[:from]}\" -> \"#{edge[:to]}\"#{label};" + end + + lines << '}' + lines.join("\n") + end + + private + + def dot_escape(str) + return str unless str.is_a?(String) + + result = String.new(capacity: str.length) + str.each_char do |ch| + escaped = case ch + when '\\' then '\\\\' + when '"' then '\\"' + else ch + end + result << escaped + end + result + end + end + end + end +end diff --git a/lib/legion/guardrails.rb b/lib/legion/guardrails.rb new file mode 100644 index 00000000..e1df0662 --- /dev/null +++ b/lib/legion/guardrails.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Legion + module Guardrails + SYSTEM_CALLER = { requested_by: { identity: 'system:guardrails', type: :system, credential: :internal } }.freeze + + module EmbeddingSimilarity + class << self + def check(input, safe_embeddings:, threshold: 0.3) + unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:embed) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started? + return { safe: true, + reason: 'no embeddings service' } + end + + input_vec = Legion::LLM.embed(input) + return { safe: true, reason: 'embedding failed' } unless input_vec + + min_dist = safe_embeddings.map { |se| cosine_distance(input_vec, se) }.min || 1.0 + safe = min_dist <= threshold + if !safe && defined?(Legion::Logging) + Legion::Logging.warn "[Guardrails] EmbeddingSimilarity rejected input: distance=#{min_dist.round(4)} threshold=#{threshold}" + end + { safe: safe, distance: min_dist.round(4), threshold: threshold } + rescue StandardError + { safe: true, reason: 'embedding failed' } + end + + def cosine_distance(vec_a, vec_b) + return 1.0 if vec_a.nil? || vec_b.nil? || vec_a.empty? || vec_b.empty? + + dot = vec_a.zip(vec_b).sum { |x, y| (x || 0) * (y || 0) } + mag_a = Math.sqrt(vec_a.sum { |x| x**2 }) + mag_b = Math.sqrt(vec_b.sum { |x| x**2 }) + return 1.0 if mag_a.zero? || mag_b.zero? + + 1.0 - (dot / (mag_a * mag_b)) + end + end + end + + module RAGRelevancy + class << self + def check(question:, context:, answer:, threshold: 3) + return { relevant: true, reason: 'no LLM' } unless defined?(Legion::LLM) + + result = Legion::LLM.chat( + message: [ + { role: 'system', + content: 'Rate 1-5 how relevant the answer is to the question given the context. Reply ONLY with the number.' }, + { role: 'user', content: "Question: #{question}\nContext: #{context}\nAnswer: #{answer}" } + ], + caller: Guardrails::SYSTEM_CALLER + ) + score = result[:content].to_s.strip.to_i + relevant = score >= threshold + Legion::Logging.warn "[Guardrails] RAGRelevancy rejected answer: score=#{score} threshold=#{threshold}" if !relevant && defined?(Legion::Logging) + { relevant: relevant, score: score, threshold: threshold } + rescue StandardError => e + Legion::Logging.warn "Guardrails::RAGRelevancy#check failed: #{e.message}" if defined?(Legion::Logging) + { relevant: true, reason: 'check failed' } + end + end + end + end +end diff --git a/lib/legion/helpers/context.rb b/lib/legion/helpers/context.rb new file mode 100644 index 00000000..a1e2df74 --- /dev/null +++ b/lib/legion/helpers/context.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'fileutils' + +module Legion + module Helpers + module Context + class << self + def write(agent_id:, filename:, content:) + path = agent_path(agent_id, filename) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, content) + { success: true, path: path } + end + + def read(agent_id:, filename:) + path = agent_path(agent_id, filename) + return { success: false, reason: :not_found } unless File.exist?(path) + + { success: true, content: File.read(path), path: path } + end + + def list(agent_id: nil) + base = agent_id ? File.join(context_dir, agent_id.to_s) : context_dir + return { success: true, files: [] } unless Dir.exist?(base) + + files = Dir.glob(File.join(base, '**', '*')).select { |f| File.file?(f) } + .map do |f| + f.sub("#{context_dir}/", + '') + end + { success: true, files: files } + end + + def cleanup(max_age: 86_400) + return { success: true, removed: 0 } unless Dir.exist?(context_dir) + + cutoff = Time.now.utc - max_age + removed = 0 + Dir.glob(File.join(context_dir, '**', '*')).select { |f| File.file?(f) }.each do |f| + next unless File.mtime(f) < cutoff + + File.delete(f) + removed += 1 + end + { success: true, removed: removed } + end + + def context_dir + dir = Legion::Settings.dig(:context, :directory) if defined?(Legion::Settings) + dir || File.join(Dir.pwd, '.legion-context') + end + + private + + def agent_path(agent_id, filename) + File.join(context_dir, agent_id.to_s, filename.to_s) + end + end + end + end +end diff --git a/lib/legion/identity.rb b/lib/legion/identity.rb new file mode 100644 index 00000000..8ad07898 --- /dev/null +++ b/lib/legion/identity.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'concurrent/array' + +module Legion + module Identity + class << self + attr_accessor :pending_registrations + end + self.pending_registrations = Concurrent::Array.new + end +end + +require_relative 'identity/trust' +require_relative 'identity/resolver' diff --git a/lib/legion/identity/broker.rb b/lib/legion/identity/broker.rb new file mode 100644 index 00000000..a9cb8a6b --- /dev/null +++ b/lib/legion/identity/broker.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true + +require 'concurrent' + +module Legion + module Identity + module Broker + GROUPS_CACHE_TTL = 60 + AUDIT_QUEUE_MAX = 1000 + AUDIT_DROP_LOG_INTERVAL = 100 + + class << self + include Legion::Logging::Helper + + def token_for(provider_name, qualifier: nil, for_context: nil, purpose: nil, context: nil) + name = provider_name.to_sym + resolved = resolve_qualifier(name, qualifier: qualifier, for_context: for_context) + lease = lease_for(name, qualifier: resolved) + token = lease&.valid? ? lease.token : nil + emit_audit(provider: name, qualifier: resolved, purpose: purpose, context: context, granted: !token.nil?) + token + end + + def credential_for(provider_name, qualifier: nil, for_context: nil, purpose: nil, context: nil) + token_for(provider_name, qualifier: qualifier, for_context: for_context, purpose: purpose, context: context) + end + + def lease_for(provider_name, qualifier: nil) + name = provider_name.to_sym + resolved = qualifier || default_qualifier_for(name) + key = [name, resolved].freeze + + renewer = renewers[key] + return renewer.current_lease if renewer + + static_ref = static_leases[key] + static_ref&.get + end + + def renewer_for(provider_name, qualifier: nil) + name = provider_name.to_sym + resolved = qualifier || default_qualifier_for(name) + renewers[[name, resolved].freeze] + end + + def credentials_for(provider_name, qualifier: nil, service: nil) + name = provider_name.to_sym + resolved = qualifier || default_qualifier_for(name) + lease = lease_for(name, qualifier: resolved) + return nil unless lease&.valid? + + { token: lease.token, provider: name, service: service, lease: lease } + end + + def register_provider(provider_name, provider:, lease:, qualifier: :default, default: false) + name = provider_name.to_sym + qual = qualifier + key = [name, qual].freeze + + # Set default qualifier: first registration or explicit default: true + default_qualifiers[name] = qual if default || !default_qualifiers.key?(name) + + # Store provider instance (first-write-wins per provider name) + provider_instances[name] ||= provider + + # Stop existing renewer for this specific tuple key + renewers[key]&.stop! + + if lease&.expires_at.nil? && !lease&.renewable + # Static credential — store without a background renewal thread + renewers.delete(key) + static_leases[key] = Concurrent::AtomicReference.new(lease) + else + # Dynamic credential — create LeaseRenewer + static_leases.delete(key) + renewers[key] = LeaseRenewer.new( + provider_name: name, + provider: provider, + lease: lease + ) + end + end + + def refresh_credential(provider_name, qualifier: nil) + name = provider_name.to_sym + resolved = qualifier || default_qualifier_for(name) + key = [name, resolved].freeze + + ref = static_leases[key] + return false unless ref + + provider = provider_instances[name] + return false unless provider.respond_to?(:provide_token) + + new_lease = provider.provide_token + return false unless new_lease&.valid? + + ref.set(new_lease) + true + end + + def authenticated? + Identity::Process.resolved? + end + + def groups + cached = @groups_cache&.get + return cached[:groups] if cached && (Time.now - cached[:fetched_at]) < GROUPS_CACHE_TTL + + if @groups_fetch_in_progress.make_true + begin + fetched = fetch_groups + @groups_cache.set({ groups: fetched, fetched_at: Time.now }) + fetched + ensure + @groups_fetch_in_progress.make_false + end + else + loop do + current = @groups_cache&.get + return current[:groups] if current + + break unless @groups_fetch_in_progress.true? + + sleep(0.01) + end + + cached ? cached[:groups] : [] + end + end + + def invalidate_groups_cache! + @groups_cache.set(nil) + end + + def emails + process_state = Identity::Process.identity_hash + metadata = process_state[:metadata] || {} + Array(metadata[:emails]) + end + + def providers + all_keys = (renewers.keys + static_leases.keys) + all_keys.map(&:first).uniq + end + + def credentials_available(provider_name) + name = provider_name.to_sym + all_keys = (renewers.keys + static_leases.keys) + all_keys.select { |k| k.first == name }.map(&:last).uniq + end + + def leases + result = {} + renewers.each do |key, renewer| + provider_name, qualifier = key + result[provider_name] ||= {} + result[provider_name][qualifier] = renewer.current_lease&.to_h + end + static_leases.each do |key, ref| + provider_name, qualifier = key + result[provider_name] ||= {} + result[provider_name][qualifier] = ref.get&.to_h unless result[provider_name].key?(qualifier) + end + result + end + + def shutdown + renewers.each_value do |r| + r.stop! + rescue Exception # rubocop:disable Lint/RescueException + nil + end + renewers.clear + static_leases.clear + provider_instances.clear + default_qualifiers.clear + stop_audit_drainer + end + + def reset! + shutdown + @groups_cache = Concurrent::AtomicReference.new(nil) + @groups_fetch_in_progress = Concurrent::AtomicBoolean.new(false) + @audit_queue = Concurrent::Array.new + @audit_drops = Concurrent::AtomicFixnum.new(0) + @audit_drainer = nil + @audit_drainer_started = Concurrent::AtomicBoolean.new(false) + end + + private + + def resolve_qualifier(provider_name, qualifier: nil, for_context: nil) + return qualifier if qualifier + + if for_context + provider = provider_instances[provider_name] + if provider.respond_to?(:resolve_qualifier) + resolved = provider.resolve_qualifier(for_context) + return resolved if resolved + end + end + + default_qualifier_for(provider_name) + end + + def default_qualifier_for(provider_name) + default_qualifiers[provider_name] || :default + end + + def renewers + @renewers ||= Concurrent::Hash.new + end + + def static_leases + @static_leases ||= Concurrent::Hash.new + end + + def provider_instances + @provider_instances ||= Concurrent::Hash.new + end + + def default_qualifiers + @default_qualifiers ||= Concurrent::Hash.new + end + + def audit_queue + @audit_queue ||= Concurrent::Array.new + end + + def emit_audit(provider:, qualifier:, purpose:, context:, granted:) + ensure_audit_drainer_started + event = { + provider: provider, + qualifier: qualifier, + purpose: purpose, + context: context, + granted: granted, + timestamp: Time.now + } + + if audit_queue.size >= AUDIT_QUEUE_MAX + drops = (@audit_drops ||= Concurrent::AtomicFixnum.new(0)).increment + log.warn("Audit queue full, dropping event (total drops: #{drops})") if (drops % AUDIT_DROP_LOG_INTERVAL).zero? + else + audit_queue.push(event) + end + end + + def ensure_audit_drainer_started + # Intentionally a no-op until publish_audit_event has a real + # implementation. Starting a drainer before a durable sink exists + # causes queued audit events to be silently discarded. + @ensure_audit_drainer_started ||= Concurrent::AtomicBoolean.new(false) + end + + def stop_audit_drainer + # No background drainer is started until publish_audit_event has a + # real implementation. Keep this method for API compatibility. + @audit_drainer = nil + @audit_drainer_started = Concurrent::AtomicBoolean.new(false) + end + + def publish_audit_event(event) + # Future: publish to transport / log store. + # Until then, events remain in the queue for inspection and are not + # drained by a background thread. + event + end + + def fetch_groups + process_groups = Identity::Process.identity_hash[:groups] + return process_groups if process_groups && !process_groups.empty? + + return db_groups if db_available? + + [] + end + + def db_groups + return [] unless defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected? + + model = begin + Legion::Data::Model::Identity::GroupMembership + rescue StandardError + nil + end + return [] unless model + + principal_id = Identity::Process.id + memberships = model.where(principal_id: principal_id, status: 'active').all + memberships.filter_map do |m| + m.group.name + rescue StandardError + nil + end + rescue StandardError => e + log.warn("Broker.db_groups failed: #{e.message}") + [] + end + + def db_available? + defined?(Legion::Data) && + Legion::Data.respond_to?(:connected?) && + Legion::Data.connected? + end + end + + # Initialize atomics at module definition time + @groups_cache = Concurrent::AtomicReference.new(nil) + @groups_fetch_in_progress = Concurrent::AtomicBoolean.new(false) + @audit_queue = Concurrent::Array.new + @audit_drops = Concurrent::AtomicFixnum.new(0) + @audit_drainer = nil + @audit_drainer_started = Concurrent::AtomicBoolean.new(false) + end + end +end diff --git a/lib/legion/identity/grant.rb b/lib/legion/identity/grant.rb new file mode 100644 index 00000000..be4744fc --- /dev/null +++ b/lib/legion/identity/grant.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Legion + module Identity + class Grant + attr_reader :grant_id, :token, :provider, :qualifier, :purpose, :result, :reason, :expires_at + + def initialize(grant_id:, token:, provider:, result:, qualifier: :default, purpose: nil, reason: nil, expires_at: nil) # rubocop:disable Metrics/ParameterLists + @grant_id = grant_id + @token = token + @provider = provider + @qualifier = qualifier + @purpose = purpose + @result = result + @reason = reason + @expires_at = expires_at + freeze + end + + def granted? = result == :granted + def denied? = result == :denied + end + end +end diff --git a/lib/legion/identity/lease.rb b/lib/legion/identity/lease.rb new file mode 100644 index 00000000..dbcd165f --- /dev/null +++ b/lib/legion/identity/lease.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Legion + module Identity + class Lease + attr_reader :provider, :credential, :lease_id, :expires_at, :renewable, :issued_at, :metadata + + def initialize(provider:, credential:, lease_id: nil, expires_at: nil, renewable: false, issued_at: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists + @provider = provider + @credential = credential + @lease_id = lease_id + @expires_at = expires_at + @renewable = renewable + @issued_at = issued_at || Time.now + @metadata = metadata.freeze + end + + def token + credential + end + + def expired? + return false if expires_at.nil? + + Time.now >= expires_at + end + + def stale? + return false if expires_at.nil? || issued_at.nil? + + elapsed = Time.now - issued_at + total = expires_at - issued_at + return false if total <= 0 + + elapsed >= (total * 0.5) + end + + def ttl_seconds + return nil if expires_at.nil? + + remaining = expires_at - Time.now + remaining.negative? ? 0 : remaining.to_i + end + + def valid? + !credential.nil? && !expired? + end + + def to_h + { + provider: provider, + lease_id: lease_id, + expires_at: expires_at&.iso8601, + renewable: renewable, + issued_at: issued_at&.iso8601, + ttl: ttl_seconds, + valid: valid?, + metadata: metadata + } + end + end + end +end diff --git a/lib/legion/identity/lease_renewer.rb b/lib/legion/identity/lease_renewer.rb new file mode 100644 index 00000000..96525b3e --- /dev/null +++ b/lib/legion/identity/lease_renewer.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'concurrent' + +module Legion + module Identity + class LeaseRenewer + include Legion::Logging::Helper + + attr_reader :provider_name, :provider + + BACKOFF_SLEEP = 5 + MIN_SLEEP = 1 + DEFAULT_SLEEP = 60 + + def initialize(provider_name:, provider:, lease:) + @provider_name = provider_name + @provider = provider + @lease = Concurrent::AtomicReference.new(lease) + @stop = Concurrent::AtomicBoolean.new(false) + @thread = Thread.new { run_loop } + @thread.name = "lease-renewer-#{provider_name}" + @thread.abort_on_exception = false + end + + def current_lease + @lease.get + end + + def stop! + @stop.make_true + @thread&.wakeup rescue nil # rubocop:disable Style/RescueModifier + @thread&.join(5) + end + + def alive? + @thread&.alive? || false + end + + private + + def run_loop + until @stop.true? + lease = @lease.get + sleep_time = compute_sleep(lease) + interruptible_sleep(sleep_time) + break if @stop.true? + + renew + end + end + + def renew + new_lease = @provider.provide_token + @lease.set(new_lease) if new_lease&.valid? + rescue StandardError => e + log_renewal_failure(e) + interruptible_sleep(BACKOFF_SLEEP) + end + + def compute_sleep(lease) + return DEFAULT_SLEEP if lease.nil? || lease.expires_at.nil? || lease.issued_at.nil? + + remaining = lease.expires_at - Time.now + half_remaining = remaining / 2.0 + [half_remaining, MIN_SLEEP].max + end + + def interruptible_sleep(seconds) + deadline = Time.now + seconds + sleep([1, deadline - Time.now].min) while Time.now < deadline && !@stop.true? + end + + def log_renewal_failure(error) + if defined?(Legion::Logging) + log.warn("renewal failed: #{error.message}") + else + $stderr.puts "[LeaseRenewer][#{@provider_name}] renewal failed: #{error.message}" # rubocop:disable Style/StderrPuts + end + end + end + end +end diff --git a/lib/legion/identity/middleware.rb b/lib/legion/identity/middleware.rb new file mode 100644 index 00000000..8730f6cf --- /dev/null +++ b/lib/legion/identity/middleware.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module Legion + module Identity + class Middleware + SKIP_PATHS = %w[/api/health /api/ready /api/openapi.json /metrics].freeze + LOOPBACK_BINDS = %w[127.0.0.1 ::1 localhost].freeze + + def initialize(app, require_auth: false) + @app = app + @require_auth = require_auth + end + + def call(env) + return @app.call(env) if skip_path?(env['PATH_INFO']) + + # Bridge from existing auth middleware + auth_claims = env['legion.auth'] + auth_method = env['legion.auth_method'] + + request = if auth_claims + build_request(auth_claims, auth_method) + elsif @require_auth + # Auth middleware already handled 401 for protected paths; + # this is a safety net for any path that slipped through. + nil + else + # No auth required (loopback bind, lite mode, etc.). + # Set a system-level principal so audit trails always have an identity. + system_principal + end + + env['legion.principal'] = request + + # Bridge to RBAC principal if legion-rbac is loaded. + # This is a data bridge — set regardless of enforce/audit mode so + # the RBAC middleware always has a typed principal to evaluate. + # Guard: require Legion::Rbac.enabled? to confirm the real gem is loaded + # (not a minimal test stub), and rescue construction errors defensively. + if request && defined?(Legion::Rbac::Principal) && + defined?(Legion::Rbac) && Legion::Rbac.respond_to?(:enabled?) && + Legion::Rbac.enabled? + begin + env['legion.rbac_principal'] = Legion::Rbac::Principal.new( + id: request.principal_id, + type: request.kind == :service ? :worker : request.kind, + roles: request.roles, + team: request.metadata&.dig(:team) + ) + rescue StandardError + # Best-effort bridge: leave legion.rbac_principal unset on construction errors. + end + end + + @app.call(env) + end + + # Returns whether the API should require authentication. + # Skips auth for lite mode and loopback binds (local dev / CI). + def self.require_auth?(bind:, mode:) + return false if mode == :lite + return false if LOOPBACK_BINDS.include?(bind) + + true + end + + private + + def skip_path?(path) + SKIP_PATHS.any? { |p| path.start_with?(p) } + end + + def build_request(claims, method) + # Use worker_id as principal_id when present — worker tokens encode both + # worker_id and sub=owner_msid, and we want the worker's identity, not the owner's. + principal_id = claims[:worker_id] || claims[:sub] || claims[:owner_msid] + + # For worker tokens (scope: 'worker' or worker_id present), derive canonical_name + # from the worker's own identity. Production worker JWTs omit :name and carry + # sub=owner_msid, so falling back to claims[:sub] would inherit the owner's identity. + worker_token = claims[:scope] == 'worker' || claims[:worker_id] + display_name = claims[:name] || (worker_token ? principal_id : claims[:sub]) + + # Separate group OIDs/names from Entra app roles — they are NOT equivalent. + # claims[:groups] = group OIDs/names (for GroupRoleMapper) + # claims[:roles] = Entra app roles (pre-assigned at token-exchange time) + groups = Array(claims[:groups]) + roles = Array(claims[:roles]) + + # Enrich with group-derived RBAC roles when legion-rbac is loaded (including audit mode). + resolved_roles = if defined?(Legion::Rbac::GroupRoleMapper) && + Legion::Rbac.respond_to?(:enabled?) && + Legion::Rbac.enabled? + group_roles = Legion::Rbac::GroupRoleMapper.resolve_roles(groups: groups) + (roles + group_roles).uniq + else + roles + end + + Identity::Request.from_auth_context({ + sub: principal_id, + name: display_name, + kind: determine_kind(claims, method), + groups: groups, + resolved_roles: resolved_roles, + source: method&.to_sym + }) + end + + def determine_kind(claims, method) + return :service if claims[:scope] == 'worker' || claims[:worker_id] + return :human if method == 'kerberos' || claims[:scope] == 'human' + + :human + end + + def system_principal + attrs = system_identity_attributes + + if @system_principal&.canonical_name != attrs[:canonical_name] || + @system_principal&.kind != attrs[:kind] || + @system_principal&.source != Identity::Request::SOURCE_NORMALIZATION.fetch(attrs[:source], attrs[:source]) + @system_principal = Identity::Request.new( + principal_id: "system:#{attrs[:canonical_name]}", + canonical_name: attrs[:canonical_name], + kind: attrs[:kind], + groups: [], + source: attrs[:source] + ) + end + @system_principal + end + + def system_identity_attributes + process = defined?(Legion::Identity::Process) ? Legion::Identity::Process : nil + canonical = process_value(process, :canonical_name) + canonical = 'system' if canonical.nil? || canonical.to_s.empty? + + { + canonical_name: canonical.to_s, + kind: process_value(process, :kind) || :service, + source: process_value(process, :source) || :local + } + end + + def process_value(process, method_name) + return nil unless process.respond_to?(method_name) + + process.public_send(method_name) + rescue StandardError + nil + end + end + end +end diff --git a/lib/legion/identity/process.rb b/lib/legion/identity/process.rb new file mode 100644 index 00000000..97799131 --- /dev/null +++ b/lib/legion/identity/process.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require 'socket' +require 'concurrent/atomic/atomic_reference' +require 'concurrent/atomic/atomic_boolean' + +module Legion + module Identity + module Process + EMPTY_STATE = { + id: nil, + canonical_name: nil, + kind: nil, + source: nil, + persistent: false, + groups: [].freeze, + metadata: {}.freeze, + trust: nil, + aliases: {}.freeze, + providers: {}.freeze, + profile: {}.freeze, + db_principal_id: nil, + db_identity_id: nil + }.freeze + + class << self + def id + state = @state.get + state[:id] || Legion.instance_id + end + + def canonical_name + state = @state.get + state[:canonical_name] || 'anonymous' + end + + def kind + @state.get[:kind] + end + + def mode + Legion::Mode.current + end + + def queue_prefix + name = canonical_name + case mode + when :worker then "worker.#{name}.#{Legion.instance_id}" + when :infra then "infra.#{name}.#{safe_hostname}" + when :lite then "lite.#{name}.#{Legion.instance_id}" + else "agent.#{name}.#{safe_hostname}" + end + end + + def resolved? + @resolved.true? + end + + def persistent? + @state.get[:persistent] == true + end + + def source + @state.get[:source] + end + + def trust + @state.get[:trust] + end + + def aliases + @state.get[:aliases] || {}.freeze + end + + def providers + @state.get[:providers] || {}.freeze + end + + def profile + @state.get[:profile] || {}.freeze + end + + def db_principal_id + @state.get[:db_principal_id] + end + + def db_identity_id + @state.get[:db_identity_id] + end + + def identity_hash + { + id: id, + canonical_name: canonical_name, + kind: kind, + source: source, + mode: mode, + queue_prefix: queue_prefix, + resolved: resolved?, + persistent: persistent?, + groups: @state.get[:groups] || [], + metadata: @state.get[:metadata] || {}, + trust: trust, + aliases: aliases, + providers: providers, + profile: profile, + db_principal_id: @state.get[:db_principal_id], + db_identity_id: @state.get[:db_identity_id] + } + end + + def bind!(provider, identity_hash) + @provider = provider + provider_source = provider.respond_to?(:provider_name) ? provider.provider_name : nil + @state.set({ + id: identity_hash[:id], + canonical_name: identity_hash[:canonical_name], + kind: identity_hash[:kind], + source: identity_hash.key?(:source) ? identity_hash[:source] : provider_source, + persistent: identity_hash.fetch(:persistent, true), + groups: Array(identity_hash[:groups]).compact.freeze, + metadata: identity_hash[:metadata].is_a?(Hash) ? identity_hash[:metadata].dup.freeze : {}.freeze, + trust: identity_hash[:trust], + aliases: identity_hash[:aliases].is_a?(Hash) ? identity_hash[:aliases].dup.freeze : {}.freeze, + providers: identity_hash[:providers].is_a?(Hash) ? identity_hash[:providers].dup.freeze : {}.freeze, + profile: identity_hash[:profile].is_a?(Hash) ? identity_hash[:profile].dup.freeze : {}.freeze, + db_principal_id: identity_hash[:db_principal_id], + db_identity_id: identity_hash[:db_identity_id] + }) + @resolved.make_true + end + + def bind_fallback! + user = ENV.fetch('USER', 'anonymous') + @state.set({ + id: nil, + canonical_name: user, + kind: :human, + source: :system, + persistent: false, + groups: [].freeze, + metadata: {}.freeze, + trust: nil, + aliases: {}.freeze, + providers: {}.freeze, + profile: {}.freeze + }) + @resolved.make_false + end + + def refresh_credentials + return unless defined?(@provider) && @provider.respond_to?(:refresh) + + @provider.refresh + end + + def reset! + @state = Concurrent::AtomicReference.new(EMPTY_STATE.dup) + @resolved = Concurrent::AtomicBoolean.new(false) + @provider = nil + end + + private + + def safe_hostname + ::Socket.gethostname.downcase + .gsub(/[^a-z0-9]+/, '-') + .gsub(/\A-+|-+\z/, '') + end + end + + # Initialize atomics at module definition time + reset! + end + end +end diff --git a/lib/legion/identity/request.rb b/lib/legion/identity/request.rb new file mode 100644 index 00000000..4a5b83c7 --- /dev/null +++ b/lib/legion/identity/request.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Legion + module Identity + class Request + # Maps middleware-emitted source values to the canonical credential enum. + # :local is emitted by Middleware#system_principal for unauthenticated loopback + # requests and must normalize to :system to maintain audit trail consistency. + # :jwt is intentionally kept distinct — JWT is the transport, not the provider. + # Entra-specific identification requires issuer inspection (Phase 7 concern). + SOURCE_NORMALIZATION = { + api_key: :api, + jwt: :jwt, + kerberos: :kerberos, + local: :system, + system: :system + }.freeze + + attr_reader :principal_id, :canonical_name, :kind, :groups, :roles, :source, :metadata + + alias id principal_id + + def initialize(principal_id:, canonical_name:, kind:, groups: [], roles: [], source: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists + @principal_id = principal_id + @canonical_name = canonical_name + @kind = kind + @groups = groups.freeze + @roles = roles.freeze + @source = SOURCE_NORMALIZATION.fetch(source&.to_sym, source) + @metadata = metadata.freeze + freeze + end + + # Reads the already-resolved identity from the Rack env (set by middleware). + # Returns nil when the key is absent. + def self.from_env(env) + env['legion.principal'] + end + + # Builds a Request from a parsed auth claims hash with symbol keys: + # { sub:, name:, preferred_username:, kind:, groups:, resolved_roles:, source: } + # resolved_roles is the final merged set of Entra app roles + group-derived RBAC + # roles (populated by Identity::Middleware before calling this method). + # The source value is normalized via SOURCE_NORMALIZATION at construction time. + def self.from_auth_context(claims_hash) + raw_name = claims_hash[:name] || claims_hash[:preferred_username] || '' + stripped = raw_name.to_s.strip.downcase + stripped = stripped.split('@', 2).first if stripped.include?('@') + canonical = stripped.gsub('.', '-').gsub(/[^a-z0-9_-]/, '') + raw_source = claims_hash[:source]&.to_sym + normalized_source = SOURCE_NORMALIZATION.fetch(raw_source, raw_source) + + new( + principal_id: claims_hash[:sub], + canonical_name: canonical, + kind: claims_hash[:kind] || :human, + groups: claims_hash[:groups] || [], + roles: Array(claims_hash[:resolved_roles]), + source: normalized_source + ) + end + + def identity_hash + { + principal_id: principal_id, + canonical_name: canonical_name, + kind: kind, + groups: groups, + roles: roles, + source: source + } + end + + # Maps to RBAC principal format. + # :service workers are represented as :worker in RBAC. + def to_rbac_principal + { + identity: canonical_name, + type: kind == :service ? :worker : kind + } + end + + # Pipeline-compatible caller hash (matches legion-llm pipeline format). + def to_caller_hash + { + requested_by: { + id: principal_id, + identity: canonical_name, + type: kind, + credential: source + } + } + end + end + end +end diff --git a/lib/legion/identity/resolver.rb b/lib/legion/identity/resolver.rb new file mode 100644 index 00000000..19355d95 --- /dev/null +++ b/lib/legion/identity/resolver.rb @@ -0,0 +1,559 @@ +# frozen_string_literal: true + +require 'securerandom' +require 'fileutils' +require 'concurrent/array' +require 'concurrent/atomic/atomic_reference' +require 'concurrent/atomic/atomic_boolean' +require 'concurrent/promises' + +module Legion + module Identity + module Resolver + TIMEOUT_SECONDS = 5 + + class << self + include Legion::Logging::Helper + + def register(provider) + return if @providers.any? { |p| p.provider_name == provider.provider_name } + + log.debug("register: #{provider.provider_name} type=#{provider.provider_type} trust=#{provider.trust_level}") + @providers << provider + end + + def resolve!(timeout: TIMEOUT_SECONDS) + log.debug("resolve!: starting with #{@providers.size} providers, timeout=#{timeout}s") + drain_pending_registrations + + auth_providers, profile_providers, fallback_providers = partition_providers + log.debug("resolve!: partitioned auth=#{auth_providers.map(&:provider_name)} " \ + "profile=#{profile_providers.map(&:provider_name)} " \ + "fallback=#{fallback_providers.map(&:provider_name)}") + + winning_provider, winning_result, provider_results = resolve_auth(auth_providers, timeout: timeout) + + if winning_provider.nil? + log.debug('resolve!: no auth winner, trying cached identity') + winning_provider, winning_result, cached_results = resolve_cached_identity + provider_results.merge!(cached_results) if cached_results + end + + if winning_provider.nil? + log.debug('resolve!: no auth winner, trying fallback providers') + winning_provider, winning_result, fallback_results = resolve_auth(fallback_providers, timeout: timeout) + provider_results.merge!(fallback_results) if fallback_results + end + + unless winning_provider + log.debug('resolve!: no provider resolved, identity unresolved') + @resolved.make_false + @composite.set(nil) + return nil + end + + canonical = winning_result[:canonical_name] + trust_level = winning_provider.trust_level + source = winning_provider.provider_name + log.debug("resolve!: winner=#{source} canonical=#{canonical} trust=#{trust_level}") + + profile_data = resolve_profiles(profile_providers, canonical, timeout: timeout) + log.debug("resolve!: profiles resolved groups=#{profile_data[:groups].size} profile_keys=#{profile_data[:profile].keys}") + + composite = assemble_composite( + provider_results, profile_data, + winning_result: winning_result, + trust_level: trust_level, + source: source + ) + + bind_and_persist(winning_provider, composite, trust_level) + log.debug("resolve!: complete canonical=#{composite[:canonical_name]} providers=#{composite[:providers].keys}") + composite + end + + def upgrade!(provider, result) + current = @composite.get + return unless current + + log.debug("upgrade!: provider=#{provider.provider_name} trust=#{provider.trust_level} current_canonical=#{current[:canonical_name]}") + + new_trust = provider.trust_level + new_canonical = result[:canonical_name] || current[:canonical_name] + canonical_changed = new_canonical != current[:canonical_name] + + # Only promote the composite trust level when the new provider's trust + # is strictly higher (lower rank index) than the current level. + # This prevents an accidental downgrade if upgrade! is called with a + # lower-trust provider such as one with :unverified trust. + current_trust = current[:trust] + effective_trust = if defined?(Legion::Identity::Trust) && + Legion::Identity::Trust.respond_to?(:above?) && + Legion::Identity::Trust.above?(new_trust, current_trust) + new_trust + else + current_trust + end + + new_aliases = current[:aliases].dup + provider_identity = result[:provider_identity] + if provider_identity + existing = Array(new_aliases[provider.provider_name]) + new_aliases[provider.provider_name] = (existing + [provider_identity]).uniq + end + + new_providers = current[:providers].dup + new_providers[provider.provider_name] = { + status: :resolved, + trust: new_trust, + resolved_at: Time.now + } + + updated = current.merge( + canonical_name: new_canonical, + trust: effective_trust, + source: provider.provider_name, + aliases: new_aliases, + providers: new_providers + ) + + handle_canonical_change(current[:canonical_name], new_canonical, updated) if canonical_changed + + @composite.set(updated) + Legion::Identity::Process.bind!(provider, updated) if defined?(Legion::Identity::Process) + + if defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader) && Legion::Settings.loader.respond_to?(:settings) + Legion::Settings.loader.settings[:client] ||= {} + Legion::Settings.loader.settings[:client][:name] = Legion::Identity::Process.queue_prefix + end + + persist_identity_json(new_canonical, updated[:kind]) unless new_trust == :unverified + + log.debug("upgrade!: complete canonical=#{new_canonical} trust=#{effective_trust} canonical_changed=#{canonical_changed}") + updated + end + + def resolved? + @resolved.true? + end + + def composite + @composite.get + end + + def providers + @providers.dup + end + + attr_reader :session_id + + def reset! + @composite = Concurrent::AtomicReference.new(nil) + @resolved = Concurrent::AtomicBoolean.new(false) + @session_id = SecureRandom.uuid + end + + def reset_all! + reset! + @providers = Concurrent::Array.new + end + + private + + def drain_pending_registrations + return unless defined?(Legion::Identity) && Legion::Identity.respond_to?(:pending_registrations) + + pending = Legion::Identity.pending_registrations + return if pending.nil? || pending.empty? + + log.debug("drain_pending_registrations: draining #{pending.size} pending providers") + drained = [] + drained << pending.shift until pending.empty? + drained.each { |p| register(p) } + end + + def partition_providers + auth = [] + profile = [] + fallback = [] + + @providers.each do |p| + case p.provider_type + when :auth then auth << p + when :profile then profile << p + when :fallback then fallback << p + end + end + + auth.sort_by! { |p| [-p.priority, p.trust_weight] } + fallback.sort_by! { |p| [-p.priority, p.trust_weight] } + + [auth, profile, fallback] + end + + def resolve_auth(auth_providers, timeout:) + return [nil, nil, {}] if auth_providers.empty? + + log.debug("resolve_auth: racing #{auth_providers.map(&:provider_name)} timeout=#{timeout}s") + futures = auth_providers.map do |provider| + Concurrent::Promises.future { provider.resolve } + end + + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout + provider_results = {} + auth_providers.zip(futures).each do |provider, future| + result = nil + remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + future.wait(remaining.positive? ? remaining : 0) + result = future.value(0) if future.resolved? + status = auth_future_status(future, result) + log.debug("resolve_auth: #{provider.provider_name} status=#{status}" \ + "#{" canonical=#{result[:canonical_name]}" if status == :resolved}") + + provider_results[provider.provider_name] = { + status: status, + trust: (status == :resolved ? provider.trust_level : nil), + resolved_at: (status == :resolved ? Time.now : nil), + provider: provider, + result: (status == :resolved ? result : nil) + } + end + + resolved_entries = provider_results.select { |_, v| v[:status] == :resolved } + if resolved_entries.empty? + log.debug('resolve_auth: no providers resolved') + [nil, nil, provider_results] + else + winner_name = resolved_entries.min_by do |_, v| + p = v[:provider] + [-p.priority, p.trust_weight] + end&.first + + log.debug("resolve_auth: winner=#{winner_name}") + winner_info = provider_results[winner_name] + [winner_info[:provider], winner_info[:result], provider_results] + end + end + + def resolve_cached_identity + cached = read_cached_identity + return [nil, nil, {}] unless cached + + provider = cached_identity_provider + result = { + canonical_name: cached[:canonical_name], + kind: cached[:kind] || :human, + source: :identity_json, + persistent: true + } + + [ + provider, + result, + { + provider.provider_name => { + status: :resolved, + trust: provider.trust_level, + resolved_at: Time.now, + provider: provider, + result: result + } + } + ] + end + + def read_cached_identity + path = File.expand_path('~/.legionio/settings/identity.json') + return nil unless File.file?(path) + + data = if defined?(Legion::JSON) + Legion::JSON.load(File.read(path)) + else + require 'json' + ::JSON.parse(File.read(path), symbolize_names: true) + end + canonical = data[:canonical_name] || data['canonical_name'] + return nil if canonical.to_s.strip.empty? + + { + canonical_name: canonical.to_s, + kind: (data[:kind] || data['kind'] || :human).to_sym + } + rescue StandardError => e + log.warn("identity.json read failed: #{e.message}") + nil + end + + def cached_identity_provider + @cached_identity_provider ||= Module.new do + module_function + + def provider_name = :identity_cache + def provider_type = :auth + def priority = -100 + def trust_weight = 150 + def trust_level = :cached + end + end + + def auth_future_status(future, result) + if future.rejected? + :failed + elsif !future.resolved? + :timeout + elsif result.is_a?(Hash) && result[:canonical_name] + :resolved + else + :no_identity + end + end + + def resolve_profiles(profile_providers, canonical, timeout:) + return { groups: [], profile: {}, provider_results: {} } if profile_providers.empty? + + futures = profile_providers.map do |provider| + Concurrent::Promises.future { resolve_profile_provider(provider, canonical) } + end + + deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + timeout + groups = [] + profile = {} + pr = {} + + profile_providers.zip(futures).each do |provider, future| + remaining = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + future.wait(remaining.positive? ? remaining : 0) + result = future.resolved? ? future.value(0) : nil + + if future.fulfilled? && result.is_a?(Hash) + groups.concat(Array(result[:groups])) if result[:groups] + profile.merge!(result[:profile]) if result[:profile].is_a?(Hash) + pr[provider.provider_name] = { status: :resolved, trust: provider.trust_level, resolved_at: Time.now } + else + pr[provider.provider_name] = { status: (future.rejected? ? :failed : :timeout), trust: nil, resolved_at: nil } + end + end + + { groups: groups.uniq, profile: profile, provider_results: pr } + end + + def resolve_profile_provider(provider, canonical) + if provider.respond_to?(:resolve_all) + provider.resolve_all(canonical_name: canonical) + else + provider.resolve(canonical_name: canonical) + end + end + + def assemble_composite(provider_results, profile_data, winning_result:, trust_level:, source:) + aliases = build_aliases(provider_results) + providers_map = build_providers_map(provider_results, profile_data) + + { + id: nil, + canonical_name: winning_result[:canonical_name], + kind: winning_result[:kind] || :human, + trust: trust_level, + source: source, + persistent: true, + aliases: aliases, + groups: profile_data[:groups], + profile: profile_data[:profile], + providers: providers_map, + metadata: {} + } + end + + def build_aliases(provider_results) + aliases = {} + provider_results.each do |name, info| + next unless info[:status] == :resolved && info[:result] + + pi = info[:result][:provider_identity] + aliases[name] = [pi] if pi + end + aliases + end + + def build_providers_map(provider_results, profile_data) + providers_map = {} + provider_results.each do |name, info| + providers_map[name] = { + status: info[:status], + trust: info[:trust], + resolved_at: info[:resolved_at] + } + end + profile_data[:provider_results].each do |name, info| + providers_map[name] = info + end + providers_map + end + + def bind_and_persist(winning_provider, composite, trust_level) + log.debug("bind_and_persist: binding provider=#{winning_provider.provider_name} trust=#{trust_level}") + Legion::Identity::Process.bind!(winning_provider, composite) if defined?(Legion::Identity::Process) + + if defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader) && Legion::Settings.loader.respond_to?(:settings) + Legion::Settings.loader.settings[:client] ||= {} + Legion::Settings.loader.settings[:client][:name] = Legion::Identity::Process.queue_prefix + log.debug("bind_and_persist: client name set to #{Legion::Identity::Process.queue_prefix}") + end + + persist_to_db(composite) + persist_identity_json(composite[:canonical_name], composite[:kind]) unless trust_level == :unverified + + @composite.set(composite) + @resolved.make_true + log.debug('bind_and_persist: resolved=true') + end + + def persist_to_db(composite) + unless defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected? + log.debug('persist_to_db: skipped — Legion::Data not connected') + return + end + + log.debug("persist_to_db: persisting canonical=#{composite[:canonical_name]} providers=#{composite[:providers]&.keys}") + now = Time.now.utc + provider_model = Legion::Data::Model::Identity::Provider + audit_model = Legion::Data::Model::Identity::AuditLog + + upsert_providers(composite, provider_model, now) + principal = upsert_principal(composite, now) + upsert_identities(composite, provider_model, principal, now) + + audit_model.create( + principal_id: principal.id, + event_type: 'identity.resolved', + provider_name: composite[:source].to_s, + trust_level: composite[:trust]&.to_s, + detail_payload: Legion::JSON.dump( + { + source: composite[:source], + trust: composite[:trust], + node_id: composite[:node_id], + session_id: @session_id + } + ), + node_ref: composite[:node_id], + session_ref: @session_id + ) + rescue StandardError => e + log.warn("DB persistence failed: #{e.message}") + end + + def upsert_providers(composite, provider_model, now) + composite[:providers]&.each_key do |name| + existing = provider_model.where(name: name.to_s).first + if existing + existing.update(updated_at: now) + else + provider_model.create( + name: name.to_s, + provider_type: 'authenticate', + facing: 'both', + source: 'resolver', + enabled: true + ) + end + end + end + + def upsert_principal(composite, now) + principal_model = Legion::Data::Model::Identity::Principal + principal = principal_model.where( + canonical_name: composite[:canonical_name], + kind: composite[:kind].to_s + ).first + + if principal + principal.update(last_seen_at: now, updated_at: now) + principal + else + principal_model.create( + canonical_name: composite[:canonical_name], + kind: composite[:kind].to_s, + active: true, + last_seen_at: now + ) + end + end + + def upsert_identities(composite, provider_model, principal, now) + identity_model = Legion::Data::Model::Identity::Identity + composite[:aliases]&.each do |provider_name, identities| + provider_row = provider_model.where(name: provider_name.to_s).first + next unless provider_row + + Array(identities).each do |ident| + upsert_single_identity(identity_model, principal, provider_row, ident, now) + end + end + end + + def upsert_single_identity(identity_model, principal, provider_row, ident, now) + existing = identity_model.where( + principal_id: principal.id, + provider_id: provider_row.id, + provider_identity_key: ident + ).first + + if existing + existing.update(last_authenticated_at: now, updated_at: now) + else + identity_model.create( + principal_id: principal.id, + provider_id: provider_row.id, + provider_identity_key: ident, + active: true, + last_authenticated_at: now + ) + end + end + + def persist_identity_json(canonical_name, kind) + dir = File.expand_path('~/.legionio/settings') + FileUtils.mkdir_p(dir) + path = File.join(dir, 'identity.json') + payload = { canonical_name: canonical_name, kind: kind } + json = if defined?(Legion::JSON) + Legion::JSON.dump(payload) + else + require 'json' + ::JSON.generate(payload) + end + File.write(path, json) + rescue StandardError => e + log.warn("identity.json write failed: #{e.message}") + end + + def handle_canonical_change(old_canonical, new_canonical, _composite) + if defined?(Legion::Settings) && Legion::Settings.respond_to?(:loader) + settings = Legion::Settings.loader.settings + settings[:client] ||= {} + settings[:client][:name] = new_canonical + end + + return unless defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected? + + old_principal = Legion::Data::Model::Identity::Principal.where(canonical_name: old_canonical).first + Legion::Data::Model::Identity::AuditLog.create( + principal_id: old_principal&.id, + event_type: 'identity.canonical_changed', + provider_name: 'resolver', + detail_payload: Legion::JSON.dump({ old: old_canonical, new: new_canonical }) + ) + rescue StandardError => e + log.warn("canonical change handling failed: #{e.message}") + end + end + + # Initialize atomics at module definition time + @providers = Concurrent::Array.new + @composite = Concurrent::AtomicReference.new(nil) + @resolved = Concurrent::AtomicBoolean.new(false) + @session_id = SecureRandom.uuid + end + end +end diff --git a/lib/legion/identity/trust.rb b/lib/legion/identity/trust.rb new file mode 100644 index 00000000..3e19319f --- /dev/null +++ b/lib/legion/identity/trust.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Legion + module Identity + module Trust + LEVELS = %i[verified authenticated configured cached unverified].freeze + RANK = LEVELS.each_with_index.to_h.freeze + + module_function + + def levels + LEVELS + end + + def rank(level) + RANK[level] + end + + def above?(level_a, level_b) + rank_a = RANK[level_a] + rank_b = RANK[level_b] + return false if rank_a.nil? || rank_b.nil? + + rank_a < rank_b + end + + def at_least?(level, minimum) + rank_level = RANK[level] + rank_min = RANK[minimum] + return false if rank_level.nil? || rank_min.nil? + + rank_level <= rank_min + end + end + end +end diff --git a/lib/legion/ingress.rb b/lib/legion/ingress.rb new file mode 100644 index 00000000..5f7d6647 --- /dev/null +++ b/lib/legion/ingress.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +module Legion + module Ingress + MAX_PAYLOAD_SIZE = 524_288 # 512KB serialized + RUNNER_CLASS_PATTERN = /\A[A-Z][A-Za-z0-9:]+\z/ + FUNCTION_PATTERN = /\A[a-z_][a-z0-9_]*[!?]?\z/ + + class PayloadTooLarge < StandardError; end + class InvalidRunnerClass < StandardError; end + class InvalidFunction < StandardError; end + + class << self + # Normalize a payload from any source into a runner-compatible message hash. + # This is the universal entry point — AMQP subscriptions, HTTP webhooks, CLI + # commands, and API endpoints all feed through here. + # + # @param payload [Hash, String] raw payload (JSON string or hash) + # @param runner_class [String, Class, nil] target runner class + # @param function [String, Symbol, nil] target function name + # @param source [String] origin identifier (amqp, http, cli, etc.) + # @param opts [Hash] additional context merged into the message + # @return [Hash] normalized message ready for Runner.run + def normalize(payload:, runner_class: nil, function: nil, source: 'unknown', **opts) + message = parse_payload(payload) + + if message.is_a?(Hash) && defined?(Legion::JSON) + serialized_size = Legion::JSON.dump(message).bytesize + raise PayloadTooLarge, "payload exceeds #{MAX_PAYLOAD_SIZE} bytes" if serialized_size > MAX_PAYLOAD_SIZE + end + + message[:runner_class] = runner_class || message[:runner_class] + message[:function] = function || message[:function] + message[:source] = source + message[:timestamp] ||= Time.now.to_i + message[:datetime] ||= Time.at(message[:timestamp]).to_datetime.to_s + message.merge(opts) + end + + # Normalize and execute via Legion::Runner.run. + # Returns the runner result hash. + def run(payload:, runner_class: nil, function: nil, source: 'unknown', principal: nil, **opts) # rubocop:disable Metrics/ParameterLists,Metrics/MethodLength + Legion::Logging.info "[Ingress] run: source=#{source} runner_class=#{runner_class} function=#{function}" if defined?(Legion::Logging) + check_subtask = opts.fetch(:check_subtask, true) + generate_task = opts.fetch(:generate_task, true) + message = normalize(payload: payload, runner_class: runner_class, + function: function, source: source, + **opts.except(:check_subtask, :generate_task, :principal)) + + Legion::Logging.debug "[Ingress] payload keys: #{message.keys}" if defined?(Legion::Logging) + + rc = message.delete(:runner_class) + fn = message.delete(:function) + + if rc.nil? + Legion::Logging.warn '[Ingress] runner_class is missing' if defined?(Legion::Logging) + raise 'runner_class is required' + end + raise 'function is required' if fn.nil? + + rc_str = rc.to_s + raise InvalidRunnerClass, "invalid runner_class format: #{rc_str}" unless rc_str.match?(RUNNER_CLASS_PATTERN) + + fn_str = fn.to_s + raise InvalidFunction, "invalid function format: #{fn_str}" unless fn_str.match?(FUNCTION_PATTERN) + + unless extension_dispatch_allowed?(rc) + return { + success: false, + status: 'task.blocked', + error: { code: 'extension_quiescing', message: "extension for #{rc} is not accepting new work" } + } + end + + # RAI invariant #2: registration precedes permission + if defined?(Legion::DigitalWorker::Registry) && message[:worker_id] + Legion::DigitalWorker::Registry.validate_execution!( + worker_id: message[:worker_id], + required_consent: message[:required_consent] + ) + end + + if defined?(Legion::Rbac) + principal ||= Legion::Rbac::Principal.local_admin + Legion::Rbac.authorize_execution!(principal: principal, runner_class: rc.to_s, function: fn.to_s) + end + + Legion::Events.emit('ingress.received', runner_class: rc.to_s, function: fn, source: source) + + resolved_rc = begin + resolve_runner_class(rc) + rescue InvalidRunnerClass + rc + end + + if local_runner?(rc) + Legion::Logging.debug "[Ingress] local short-circuit: #{rc}.#{fn}" if defined?(Legion::Logging) + ctx = message.merge(runner_class: rc.to_s, function: fn.to_s) + return Legion::Context.with_task_context(ctx) { resolved_rc.send(fn.to_sym, **message) } + end + + runner_block = lambda { + ctx = message.merge(runner_class: rc.to_s, function: fn.to_s) + Legion::Context.with_task_context(ctx) do + Legion::Runner.run( + runner_class: resolved_rc, + function: fn, + check_subtask: check_subtask, + generate_task: generate_task, + **message + ) + end + } + + if defined?(Legion::Telemetry::OpenInference) + Legion::Telemetry::OpenInference.tool_span(name: "#{rc}.#{fn}", parameters: message) { |_span| runner_block.call } + else + runner_block.call + end + rescue PayloadTooLarge => e + Legion::Logging.error "[Ingress] payload_too_large: #{e.message}" if defined?(Legion::Logging) + { success: false, status: 'task.blocked', error: { code: 'payload_too_large', message: e.message } } + rescue InvalidRunnerClass => e + Legion::Logging.error "[Ingress] invalid_runner_class: #{e.message}" if defined?(Legion::Logging) + { success: false, status: 'task.blocked', error: { code: 'invalid_runner_class', message: e.message } } + rescue InvalidFunction => e + Legion::Logging.error "[Ingress] invalid_function: #{e.message}" if defined?(Legion::Logging) + { success: false, status: 'task.blocked', error: { code: 'invalid_function', message: e.message } } + rescue Legion::DigitalWorker::Registry::WorkerNotFound => e + Legion::Logging.error "[Ingress] worker_not_found: #{e.message}" if defined?(Legion::Logging) + { success: false, status: 'task.blocked', error: { code: 'worker_not_found', message: e.message } } + rescue Legion::DigitalWorker::Registry::WorkerNotActive => e + Legion::Logging.error "[Ingress] worker_not_active: #{e.message}" if defined?(Legion::Logging) + { success: false, status: 'task.blocked', error: { code: 'worker_not_active', message: e.message } } + rescue Legion::DigitalWorker::Registry::InsufficientConsent => e + Legion::Logging.error "[Ingress] insufficient_consent: #{e.message}" if defined?(Legion::Logging) + { success: false, status: 'task.blocked', error: { code: 'insufficient_consent', message: e.message } } + end + + def local_runner?(runner_class) + return false unless defined?(Legion::Extensions) && Legion::Extensions.local_tasks.is_a?(Array) + + klass = resolve_runner_class(runner_class) + Legion::Extensions.local_tasks.any? { |t| t[:runner_module] == klass } + rescue NameError, InvalidRunnerClass + false + end + + def reset_runner_cache! + @registered_runner_modules = nil + end + + private + + def extension_dispatch_allowed?(runner_class) + return true unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:dispatch_allowed_for_runner?) + + Legion::Extensions.dispatch_allowed_for_runner?(runner_class) + end + + def resolve_runner_class(runner_class) + return runner_class unless runner_class.is_a?(String) + + raise InvalidRunnerClass, "invalid runner_class format: #{runner_class}" unless runner_class.match?(RUNNER_CLASS_PATTERN) + + resolved = registered_runner_modules[runner_class] + raise InvalidRunnerClass, "unregistered runner_class: #{runner_class}" unless resolved + + resolved + end + + def registered_runner_modules + return @registered_runner_modules if defined?(@registered_runner_modules) && @registered_runner_modules + + modules = {} + if defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:loaded_extension_modules) + Legion::Extensions.loaded_extension_modules.each do |mod| + modules[mod.to_s] = mod + end + end + if defined?(Legion::Extensions) && Legion::Extensions.local_tasks.is_a?(Array) + Legion::Extensions.local_tasks.each do |t| + mod = t[:runner_module] + modules[mod.to_s] = mod if mod + end + end + @registered_runner_modules = modules + end + + def parse_payload(payload) + case payload + when Hash + payload.transform_keys(&:to_sym) + when String + Legion::JSON.load(payload).transform_keys(&:to_sym) + when NilClass + {} + else + { value: payload } + end + end + end + end +end diff --git a/lib/legion/isolation.rb b/lib/legion/isolation.rb new file mode 100644 index 00000000..b7389caf --- /dev/null +++ b/lib/legion/isolation.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Legion + module Isolation + class Context + attr_reader :agent_id, :tenant_id, :allowed_tools, :risk_tier + + def initialize(agent_id:, tenant_id: nil, allowed_tools: [], risk_tier: :standard) + @agent_id = agent_id + @tenant_id = tenant_id + @allowed_tools = allowed_tools.map(&:to_s).freeze + @risk_tier = risk_tier.to_sym + end + + def tool_allowed?(tool_name) + allowed_tools.empty? || allowed_tools.include?(tool_name.to_s) + end + + def data_filter + filter = { agent_id: agent_id } + filter[:tenant_id] = tenant_id if tenant_id + filter + end + end + + class << self + def current + Thread.current[:legion_isolation_context] + end + + def with_context(context) + previous = Thread.current[:legion_isolation_context] + Thread.current[:legion_isolation_context] = context + yield + ensure + Thread.current[:legion_isolation_context] = previous + end + + def enforce_tool_access!(tool_name) + ctx = current + return true unless ctx + + raise SecurityError, "Agent #{ctx.agent_id} not authorized for tool: #{tool_name}" unless ctx.tool_allowed?(tool_name) + + true + end + end + end +end diff --git a/lib/legion/leader.rb b/lib/legion/leader.rb new file mode 100644 index 00000000..323c215b --- /dev/null +++ b/lib/legion/leader.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'concurrent' +require_relative 'lock' + +module Legion + module Leader + class << self + def elect(role, ttl: 30) + ttl_ms = ttl * 1000 + token = Legion::Lock.acquire("leader:#{role}", ttl: ttl_ms) + return nil unless token + + @leaders ||= {} + @leaders[role.to_sym] = { token: token, ttl_ms: ttl_ms } + token + end + + def leader?(role) + return false unless @leaders&.dig(role.to_sym, :token) + + Legion::Lock.locked?("leader:#{role}") + end + + def resign(role) + return false unless @leaders&.dig(role.to_sym) + + entry = @leaders.delete(role.to_sym) + stop_renewal(role) + Legion::Lock.release("leader:#{role}", entry[:token]) + end + + def with_leadership(role, ttl: 30) + token = elect(role, ttl: ttl) + raise Legion::Lock::NotAcquired, "could not elect leader for: #{role}" unless token + + start_renewal(role, ttl) + yield + ensure + resign(role) + end + + def reset! + @leaders&.each_key { |role| resign(role) } + @leaders = {} + @renewals&.each_value(&:shutdown) + @renewals = {} + end + + private + + def start_renewal(role, ttl) + @renewals ||= {} + interval = [ttl / 3, 1].max + entry = @leaders[role.to_sym] + return unless entry + + @renewals[role.to_sym] = Concurrent::TimerTask.new(execution_interval: interval) do + success = Legion::Lock.extend_lock("leader:#{role}", entry[:token], ttl: entry[:ttl_ms]) + unless success + log_warn("Lost leadership for #{role}") + @renewals[role.to_sym]&.shutdown + end + end + @renewals[role.to_sym].execute + end + + def stop_renewal(role) + @renewals ||= {} + @renewals.delete(role.to_sym)&.shutdown + end + + def log_warn(msg) + Legion::Logging.warn(msg) if defined?(Legion::Logging) + end + end + end +end diff --git a/lib/legion/lex.rb b/lib/legion/lex.rb index c3830e36..d8b2cab9 100755 --- a/lib/legion/lex.rb +++ b/lib/legion/lex.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'thor' require 'legion/cli/version' require 'legion/cli/lex/actor' diff --git a/lib/legion/lock.rb b/lib/legion/lock.rb new file mode 100644 index 00000000..83e4dfaf --- /dev/null +++ b/lib/legion/lock.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + module Lock + class NotAcquired < StandardError; end + + RELEASE_SCRIPT = <<~LUA + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + LUA + + EXTEND_SCRIPT = <<~LUA + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("pexpire", KEYS[1], ARGV[2]) + else + return 0 + end + LUA + + class << self + def acquire(name, ttl: 30_000) + token = SecureRandom.uuid + key = lock_key(name) + result = with_redis { |conn| conn.set(key, token, nx: true, px: ttl) } + result ? token : nil + rescue StandardError => e + Legion::Logging.debug "Lock#acquire failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + nil + end + + def release(name, token) + key = lock_key(name) + result = with_redis { |conn| conn.eval(RELEASE_SCRIPT, keys: [key], argv: [token]) } + result == 1 + rescue StandardError => e + Legion::Logging.debug "Lock#release failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + false + end + + def with_lock(name, ttl: 30_000) + token = acquire(name, ttl: ttl) + raise NotAcquired, "could not acquire lock: #{name}" unless token + + yield + ensure + release(name, token) if token + end + + def extend_lock(name, token, ttl: 30_000) + key = lock_key(name) + result = with_redis { |conn| conn.eval(EXTEND_SCRIPT, keys: [key], argv: [token, ttl.to_s]) } + result == 1 + rescue StandardError => e + Legion::Logging.debug "Lock#extend_lock failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + false + end + + def locked?(name) + with_redis { |conn| conn.exists?(lock_key(name)) } + rescue StandardError => e + Legion::Logging.debug "Lock#locked? failed for name=#{name}: #{e.message}" if defined?(Legion::Logging) + false + end + + private + + def lock_key(name) + "legion:lock:#{name}" + end + + def with_redis(&) + Legion::Cache.client.with(&) + end + end + end +end diff --git a/lib/legion/memory/consolidator.rb b/lib/legion/memory/consolidator.rb new file mode 100644 index 00000000..977a3b8c --- /dev/null +++ b/lib/legion/memory/consolidator.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +require 'fileutils' + +module Legion + module Memory + module Consolidator + LOCK_FILE = File.expand_path('~/.legionio/cache/memory_consolidation.lock') + SESSIONS_DIR = File.expand_path('~/.legion/sessions') + + class << self + def run(force: false) + return { success: false, reason: :disabled } unless enabled? + return { success: false, reason: :gates_failed, details: gate_status } unless force || gates_pass? + return { success: false, reason: :locked } unless acquire_lock + + begin + result = consolidate + touch_lock + publish_to_apollo(result[:insights]) if result[:insights]&.any? + { success: true, insights_count: result[:insights]&.length || 0, **result } + ensure + release_lock + end + rescue StandardError => e + Legion::Logging.error "[Consolidator] failed: #{e.message}" if defined?(Legion::Logging) + { success: false, reason: :error, error: e.message } + end + + def gate_status + { + time_gate: time_gate_passes?, + session_gate: session_gate_passes?, + lock_gate: lock_gate_passes? + } + end + + def gates_pass? + time_gate_passes? && session_gate_passes? && lock_gate_passes? + end + + def enabled? + settings = consolidation_settings + settings.fetch(:enabled, false) + end + + def consolidation_settings + raw = begin + Legion::Settings.dig(:memory, :consolidation) + rescue StandardError + nil + end + defaults = { + enabled: false, + min_hours: 24, + min_sessions: 5, + scan_interval_minutes: 10, + max_index_lines: 200 + } + raw.is_a?(Hash) ? defaults.merge(raw) : defaults + end + + private + + def time_gate_passes? + return true unless File.exist?(LOCK_FILE) + + min_hours = consolidation_settings[:min_hours] + age_hours = (Time.now - File.mtime(LOCK_FILE)) / 3600.0 + age_hours >= min_hours + end + + def session_gate_passes? + return false unless Dir.exist?(SESSIONS_DIR) + + cutoff = File.exist?(LOCK_FILE) ? File.mtime(LOCK_FILE) : Time.at(0) + recent = Dir.glob(File.join(SESSIONS_DIR, '*.json')).count do |path| + File.mtime(path) > cutoff + end + recent >= consolidation_settings[:min_sessions] + end + + def lock_gate_passes? + return true unless File.exist?(LOCK_FILE) + + !File.exist?("#{LOCK_FILE}.active") + end + + def acquire_lock + FileUtils.mkdir_p(File.dirname(LOCK_FILE)) + File.open("#{LOCK_FILE}.active", File::WRONLY | File::CREAT | File::EXCL) do |f| + f.write(::Process.pid.to_s) + end + true + rescue Errno::EEXIST + false + rescue StandardError => e + Legion::Logging.debug "[Consolidator] acquire_lock failed: #{e.message}" if defined?(Legion::Logging) + false + end + + def release_lock + FileUtils.rm_f("#{LOCK_FILE}.active") + end + + def touch_lock + FileUtils.mkdir_p(File.dirname(LOCK_FILE)) + FileUtils.touch(LOCK_FILE) + end + + def consolidate + transcripts = load_recent_transcripts + return { insights: [], transcripts_scanned: 0 } if transcripts.empty? + + existing_memory = load_existing_memory + + if llm_available? + insights = extract_insights_via_llm(transcripts, existing_memory) + write_insights(insights) if insights.any? + { insights: insights, transcripts_scanned: transcripts.length } + else + { insights: [], transcripts_scanned: transcripts.length, reason: :llm_unavailable } + end + end + + def load_recent_transcripts + return [] unless Dir.exist?(SESSIONS_DIR) + + cutoff = File.exist?(LOCK_FILE) ? File.mtime(LOCK_FILE) : Time.at(0) + max = consolidation_settings[:min_sessions] * 2 + + Dir.glob(File.join(SESSIONS_DIR, '*.json')) + .select { |p| File.mtime(p) > cutoff } + .sort_by { |p| File.mtime(p) } + .last(max) + .map { |p| extract_transcript_summary(p) } + .compact + end + + def extract_transcript_summary(path) + raw = File.read(path, encoding: 'utf-8') + data = defined?(Legion::JSON) ? Legion::JSON.load(raw) : JSON.parse(raw, symbolize_names: true) + messages = data[:messages] || [] + + user_msgs = messages.select { |m| m[:role]&.to_s == 'user' } + .map { |m| m[:content].to_s[0..300] } + .first(10) + return nil if user_msgs.empty? + + { name: data[:name], messages: user_msgs.join("\n"), cwd: data[:cwd] } + rescue StandardError => e + Legion::Logging.debug "[Consolidator] transcript parse failed for #{path}: #{e.message}" if defined?(Legion::Logging) + nil + end + + def load_existing_memory + require 'legion/cli/chat/memory_store' + Legion::CLI::Chat::MemoryStore.load_context + rescue StandardError + nil + end + + def llm_available? + defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat) + end + + def extract_insights_via_llm(transcripts, existing_memory) + transcript_text = transcripts.map do |t| + "Session: #{t[:name]} (#{t[:cwd]})\n#{t[:messages]}" + end.join("\n---\n") + + prompt = <<~PROMPT + You are a memory consolidation agent. Analyze these recent session transcripts and extract cross-session insights. + + ## Existing Memory + #{existing_memory || '(empty)'} + + ## Recent Session Transcripts + #{transcript_text} + + Extract insights as a JSON array. Each insight should have: + - "text": a concise one-line insight (pattern, preference, or learning) + - "category": one of "pattern", "preference", "learning", "project" + + Only include genuinely new insights not already in existing memory. Return [] if nothing new. + Respond with ONLY the JSON array, no other text. + PROMPT + + response = Legion::LLM.chat( + message: prompt, + caller: { requested_by: { type: :system, identity: 'legion:internal:memory:consolidator' } } + ) + content = extract_response_content(response) + + parse_insights(content) + rescue StandardError => e + Legion::Logging.warn "[Consolidator] LLM extraction failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def extract_response_content(response) + if response.is_a?(Hash) + (response[:response] || response[:content] || response['response'] || response['content']).to_s + elsif response.respond_to?(:content) + response.content.to_s + else + response.to_s + end + end + + def parse_insights(text) + json_match = text.match(/\[.*\]/m) + return [] unless json_match + + parsed = defined?(Legion::JSON) ? Legion::JSON.load(json_match[0]) : JSON.parse(json_match[0], symbolize_names: true) + return [] unless parsed.is_a?(Array) + + parsed.select { |i| i.is_a?(Hash) && (i[:text] || i['text']) } + .map { |i| { text: (i[:text] || i['text']).to_s, category: (i[:category] || i['category'] || 'learning').to_s } } + rescue StandardError + [] + end + + def write_insights(insights) + require 'legion/cli/chat/memory_store' + insights.each do |insight| + Legion::CLI::Chat::MemoryStore.add( + "[#{insight[:category]}] #{insight[:text]}", + scope: :global + ) + end + Legion::Logging.info "[Consolidator] wrote #{insights.length} insights to global memory" if defined?(Legion::Logging) + end + + def publish_to_apollo(insights) + return unless defined?(Legion::Apollo) && Legion::Apollo.respond_to?(:ingest) + + insights.each do |insight| + Legion::Apollo.ingest( + content: insight[:text], + tags: ['memory_consolidation', 'cross_session', insight[:category]], + knowledge_domain: 'memory', + source_agent: 'system:memory_consolidator', + is_inference: true + ) + end + rescue StandardError => e + Legion::Logging.debug "[Consolidator] Apollo publish failed: #{e.message}" if defined?(Legion::Logging) + end + end + end + end +end diff --git a/lib/legion/metrics.rb b/lib/legion/metrics.rb new file mode 100644 index 00000000..04dd76f4 --- /dev/null +++ b/lib/legion/metrics.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Legion + module Metrics + class << self + def available? + defined?(Prometheus::Client) ? true : false + end + + def setup + return unless available? + + init_registry + init_metrics + register_event_listeners + end + + attr_reader :registry + + def render + return '' unless available? + + Prometheus::Client::Formats::Text.marshal(@registry) + end + + def refresh_gauges + return unless available? + + @metrics[:uptime].set(::Process.clock_gettime(::Process::CLOCK_MONOTONIC)) + refresh_active_workers + refresh_rolling_window + end + + def reset! + @registry = nil + @metrics = nil + @listeners&.each { |name, block| Legion::Events.off(name, block) if defined?(Legion::Events) } + @listeners = {} + end + + private + + def init_registry + @registry = Prometheus::Client::Registry.new + @metrics = {} + end + + def init_metrics + @metrics[:uptime] = @registry.gauge(:legion_uptime_seconds, docstring: 'Process uptime') + @metrics[:active_workers] = @registry.gauge(:legion_active_workers, + docstring: 'Active workers', labels: [:lifecycle_state]) + @metrics[:tasks_total] = @registry.counter(:legion_tasks_total, + docstring: 'Total tasks', labels: [:status]) + @metrics[:tasks_per_second] = @registry.gauge(:legion_tasks_per_second, docstring: 'Task throughput') + @metrics[:error_rate] = @registry.gauge(:legion_error_rate, docstring: 'Error rate') + @metrics[:consent_violations] = @registry.counter(:legion_consent_violations_total, + docstring: 'Consent violations') + @metrics[:llm_requests] = @registry.counter(:legion_llm_requests_total, + docstring: 'LLM calls', labels: %i[provider model]) + @metrics[:llm_tokens] = @registry.counter(:legion_llm_tokens_total, + docstring: 'LLM tokens', labels: %i[provider model type]) + @window = Concurrent::Array.new + end + + def register_event_listeners + @listeners = {} + + @listeners['ingress.received'] = Legion::Events.on('ingress.received') do |_| + @metrics[:tasks_total].increment(labels: { status: 'queued' }) + @window << { time: Time.now, error: false } + end + + @listeners['runner.success'] = Legion::Events.on('runner.success') do |_| + @metrics[:tasks_total].increment(labels: { status: 'success' }) + end + + @listeners['runner.failure'] = Legion::Events.on('runner.failure') do |_| + @metrics[:tasks_total].increment(labels: { status: 'failure' }) + @window << { time: Time.now, error: true } + end + + @listeners['governance.consent_violation'] = Legion::Events.on('governance.consent_violation') do |_| + @metrics[:consent_violations].increment + end + + @listeners['metering.recorded'] = Legion::Events.on('metering.recorded') do |event| + provider = event[:provider].to_s + model = event[:model].to_s + @metrics[:llm_requests].increment(labels: { provider: provider, model: model }) + @metrics[:llm_tokens].increment(labels: { provider: provider, model: model, type: 'input' }, + by: event[:input_tokens].to_i) + @metrics[:llm_tokens].increment(labels: { provider: provider, model: model, type: 'output' }, + by: event[:output_tokens].to_i) + end + end + + def refresh_active_workers + return unless defined?(Legion::Data::Model::DigitalWorker) + + Legion::Data::Model::DigitalWorker + .group_and_count(:lifecycle_state) + .each { |row| @metrics[:active_workers].set(row[:count], labels: { lifecycle_state: row[:lifecycle_state] }) } + rescue StandardError => e + Legion::Logging.debug "Metrics#refresh_active_workers failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def refresh_rolling_window + cutoff = Time.now - 60 + @window.reject! { |e| e[:time] < cutoff } + total = @window.size + errors = @window.count { |e| e[:error] } + @metrics[:tasks_per_second].set(total.to_f / 60.0) + @metrics[:error_rate].set(total.positive? ? errors.to_f / total : 0.0) + end + end + end +end diff --git a/lib/legion/mode.rb b/lib/legion/mode.rb new file mode 100644 index 00000000..9dfa0980 --- /dev/null +++ b/lib/legion/mode.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Legion + module Mode + LEGACY_MAP = { full: :agent, api: :worker, router: :worker, worker: :worker, lite: :lite }.freeze + + class << self + def current + raw = ENV['LEGION_MODE'] || + settings_dig(:mode) || + settings_dig(:process, :mode) || + legacy_role + normalize(raw) + end + + def agent? + current == :agent + end + + def worker? + current == :worker + end + + def infra? + current == :infra + end + + def lite? + current == :lite + end + + private + + def normalize(raw) + return :agent if raw.nil? + + sym = raw.to_s.downcase.strip.to_sym + return sym if %i[agent worker infra lite].include?(sym) + + LEGACY_MAP.fetch(sym, :agent) + end + + def legacy_role + settings_dig(:process, :role) + end + + def fetch_setting_value(container, key) + value = container[key] + return value unless value.nil? + + alternate_key = case key + when Symbol then key.to_s + when String then key.to_sym + end + return value if alternate_key.nil? + + container[alternate_key] + end + + def settings_dig(*keys) + return nil unless defined?(Legion::Settings) && Legion::Settings.respond_to?(:[]) + + result = Legion::Settings + keys.each do |k| + return nil unless result.respond_to?(:[]) + + result = fetch_setting_value(result, k) + return nil if result.nil? && keys.last != k + end + result + rescue StandardError + nil + end + end + end +end diff --git a/lib/legion/notebook/generator.rb b/lib/legion/notebook/generator.rb new file mode 100644 index 00000000..7b2d9eac --- /dev/null +++ b/lib/legion/notebook/generator.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'json' + +module Legion + module Notebook + module Generator + NOTEBOOK_TEMPLATE = { + 'nbformat' => 4, + 'nbformat_minor' => 5, + 'metadata' => { + 'kernelspec' => { + 'display_name' => 'Python 3', + 'language' => 'python', + 'name' => 'python3' + }, + 'language_info' => { + 'name' => 'python' + } + }, + 'cells' => [] + }.freeze + + def self.generate(description:, kernel: 'python3', model: nil, provider: nil) + raise ArgumentError, 'legion-llm is required for notebook generation' unless defined?(Legion::LLM) + + prompt = build_prompt(description, kernel) + response = call_llm(prompt, model: model, provider: provider) + parse_notebook_response(response) + end + + def self.write(path, notebook_data) + File.write(path, ::JSON.pretty_generate(notebook_data)) + end + + def self.build_prompt(description, kernel) + <<~PROMPT + Generate a Jupyter notebook as valid JSON (.ipynb format) for the following task: + + #{description} + + Requirements: + - Use kernel: #{kernel} + - Include a markdown cell with a title and description at the top + - Include well-commented code cells + - Include markdown explanation cells between code sections + - Return ONLY the raw JSON, no markdown fences, no explanation + + The JSON must follow the .ipynb format with these top-level keys: + nbformat, nbformat_minor, metadata, cells + + Each cell must have: cell_type, metadata, source (array of strings), outputs (array), execution_count + PROMPT + end + + def self.call_llm(prompt, model: nil, provider: nil) + kwargs = { messages: [{ role: 'user', content: prompt }] } + kwargs[:model] = model if model + kwargs[:provider] = provider.to_sym if provider + Legion::LLM.chat(**kwargs, caller: { source: 'cli', command: 'notebook' }) + end + + def self.parse_notebook_response(response) + content = response[:content].to_s.strip + # Strip markdown fences if the LLM wrapped the JSON + content = content.gsub(/\A```(?:json)?\n?/, '').gsub(/\n?```\z/, '').strip + data = ::JSON.parse(content) + validate_notebook!(data) + data + rescue ::JSON::ParserError => e + raise ArgumentError, "LLM returned invalid JSON: #{e.message}" + end + + def self.validate_notebook!(data) + raise ArgumentError, 'Missing nbformat key' unless data.key?('nbformat') + raise ArgumentError, 'Missing cells key' unless data.key?('cells') + raise ArgumentError, 'cells must be an array' unless data['cells'].is_a?(Array) + end + end + end +end diff --git a/lib/legion/notebook/parser.rb b/lib/legion/notebook/parser.rb new file mode 100644 index 00000000..772b6121 --- /dev/null +++ b/lib/legion/notebook/parser.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'json' + +module Legion + module Notebook + module Parser + def self.parse(path) + data = ::JSON.parse(File.read(path)) + { + metadata: data['metadata'], + kernel: data.dig('metadata', 'kernelspec', 'display_name'), + language: data.dig('metadata', 'kernelspec', 'language') || 'python', + cells: Array(data['cells']).map { |c| parse_cell(c) } + } + end + + def self.parse_cell(cell) + { + type: cell['cell_type'], + source: Array(cell['source']).join, + outputs: Array(cell.fetch('outputs', [])).map { |o| parse_output(o) } + } + end + + def self.parse_output(output) + text = case output['output_type'] + when 'execute_result', 'display_data' + data = output.fetch('data', {}) + Array(data.fetch('text/plain', [])).join + when 'error' + "#{output['ename']}: #{output['evalue']}" + else + Array(output.fetch('text', [])).join + end + + { + output_type: output['output_type'], + text: text + } + end + end + end +end diff --git a/lib/legion/notebook/renderer.rb b/lib/legion/notebook/renderer.rb new file mode 100644 index 00000000..a0c1072c --- /dev/null +++ b/lib/legion/notebook/renderer.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Legion + module Notebook + module Renderer + RESET = "\e[0m" + BOLD = "\e[1m" + DIM = "\e[2m" + YELLOW = "\e[33m" + CYAN = "\e[36m" + GREEN = "\e[32m" + RED = "\e[31m" + RULE = "\e[2m#{'─' * 60}\e[0m".freeze + + def self.render_notebook(notebook, color: true) + lines = [] + kernel = notebook[:kernel] + lines << (color ? "#{BOLD}#{CYAN}Kernel: #{kernel}#{RESET}" : "Kernel: #{kernel}") if kernel + + notebook[:cells].each_with_index do |cell, idx| + lines << '' + lines << render_cell_header(idx + 1, cell[:type], color) + lines << render_cell_source(cell, notebook[:language], color) + lines += render_cell_outputs(cell[:outputs], color) unless cell[:outputs].empty? + end + + lines.join("\n") + end + + def self.render_cell_header(index, type, color) + label = "[#{type}] Cell #{index}" + color ? "#{BOLD}#{YELLOW}#{label}#{RESET}" : label + end + + def self.render_cell_source(cell, language, color) + return '' if cell[:source].empty? + + if cell[:type] == 'code' + highlight(cell[:source], language, color) + else + color ? "#{DIM}#{cell[:source]}#{RESET}" : cell[:source] + end + end + + def self.render_cell_outputs(outputs, color) + outputs.filter_map do |output| + next if output[:text].to_s.strip.empty? + + prefix = color ? "#{DIM} => " : ' => ' + suffix = color ? RESET : '' + "#{prefix}#{output[:text].strip}#{suffix}" + end + end + + def self.highlight(code, language, color) + return code unless color + + begin + require 'rouge' + lexer = Rouge::Lexer.find(language.to_s) || Rouge::Lexers::PlainText.new + formatter = Rouge::Formatters::Terminal256.new(Rouge::Themes::Monokai.new) + formatter.format(lexer.lex(code)) + rescue LoadError => e + Legion::Logging.debug "Notebook::Renderer#highlight rouge not available: #{e.message}" if defined?(Legion::Logging) + code + end + end + + def self.rule(color) + color ? RULE : ('-' * 60) + end + end + end +end diff --git a/lib/legion/phi.rb b/lib/legion/phi.rb new file mode 100644 index 00000000..94a14a2d --- /dev/null +++ b/lib/legion/phi.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'openssl' +require 'legion/phi/access_log' +require 'legion/phi/erasure' + +module Legion + module Phi + PHI_TAG = :phi + + DEFAULT_PHI_PATTERNS = %w[ + ssn + social_security + mrn + medical_record + dob + date_of_birth + patient_name + first_name + last_name + full_name + phone + phone_number + email + address + zip + zipcode + postal_code + diagnosis + icd_code + npi + insurance_id + member_id + account_number + credit_card + passport + drivers_license + ip_address + device_id + ].freeze + + module_function + + # Marks specific hash fields as containing PHI by adding __phi_fields metadata. + def tag(data, fields:) + raise ArgumentError, 'data must be a Hash' unless data.is_a?(Hash) + raise ArgumentError, 'fields must be an Array' unless fields.is_a?(Array) + + result = data.dup + existing = result[:__phi_fields] || [] + result[:__phi_fields] = (existing + fields.map(&:to_sym)).uniq + result + end + + # Returns true if the hash has a PHI tag. + def tagged?(data) + return false unless data.is_a?(Hash) + + data.key?(:__phi_fields) && !data[:__phi_fields].nil? + end + + # Returns the list of PHI-tagged field names. + def phi_fields(data) + return [] unless tagged?(data) + + data[:__phi_fields] || [] + end + + # Returns a copy of data with all PHI fields replaced with [REDACTED]. + def redact(data) + return data unless data.is_a?(Hash) + + fields = phi_fields(data) + auto_detect_fields(data) + fields = fields.uniq + + result = data.dup + fields.each do |field| + result[field] = '[REDACTED]' if result.key?(field) + end + result + end + + # Cryptographic erasure: re-encrypt PHI fields with a throwaway key, then destroy the key. + # Returns the erased record (PHI fields replaced with erasure markers). + def erase(data, key_id:) + return data unless data.is_a?(Hash) + + fields = phi_fields(data) + auto_detect_fields(data) + fields = fields.uniq + + Erasure.erase_record(record: data, phi_fields: fields, key_id: key_id) + end + + # Auto-detect PHI fields by matching field names against configurable patterns. + def auto_detect_fields(data) + return [] unless data.is_a?(Hash) + + patterns = phi_patterns + data.keys.select do |key| + key_str = key.to_s.downcase + patterns.any? { |pat| key_str.match?(pat) } + end + end + + # Returns the configured PHI field patterns (regex strings). + def phi_patterns + configured = settings_patterns + return compiled_defaults if configured.nil? || configured.empty? + + configured.map { |p| Regexp.new(p, Regexp::IGNORECASE) } + rescue StandardError => e + Legion::Logging.warn "Phi#phi_patterns failed to compile configured patterns: #{e.message}" if defined?(Legion::Logging) + compiled_defaults + end + + def compiled_defaults + DEFAULT_PHI_PATTERNS.map { |p| Regexp.new("\\b#{Regexp.escape(p)}\\b", Regexp::IGNORECASE) } + end + + def settings_patterns + return nil unless defined?(Legion::Settings) + + Legion::Settings.dig(:phi, :field_patterns) + rescue StandardError => e + Legion::Logging.debug "Phi#settings_patterns failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + public_class_method :auto_detect_fields, :phi_patterns + end +end diff --git a/lib/legion/phi/access_log.rb b/lib/legion/phi/access_log.rb new file mode 100644 index 00000000..1896b9b6 --- /dev/null +++ b/lib/legion/phi/access_log.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Legion + module Phi + module AccessLog + AUDIT_EVENT_TYPE = 'phi_access' + + module_function + + # Logs PHI access to the audit trail. Returns true on success, false on failure. + def log_access(actor:, resource:, action:, phi_fields:, reason: nil) + entry = build_entry(actor: actor, resource: resource, action: action, + phi_fields: phi_fields, reason: reason) + persist(entry) + true + rescue StandardError => e + emit_warning("PHI access log failed: #{e.message}") + false + end + + # Same as log_access but raises on failure. + def log_access!(actor:, resource:, action:, phi_fields:, reason: nil) + entry = build_entry(actor: actor, resource: resource, action: action, + phi_fields: phi_fields, reason: reason) + persist!(entry) + true + end + + # Query recent PHI access records for a given resource. + def recent_access(resource:, limit: 100) + if defined?(Legion::Audit) + query_via_audit(resource: resource, limit: limit) + else + query_in_memory(resource: resource, limit: limit) + end + end + + def build_entry(actor:, resource:, action:, phi_fields:, reason:) + { + actor: actor.to_s, + resource: resource.to_s, + action: action.to_s, + phi_fields: Array(phi_fields).map(&:to_s), + reason: reason&.to_s, + timestamp: Time.now.utc.iso8601 + } + end + + def persist(entry) + if defined?(Legion::Audit) + record_via_audit(entry) + else + log_to_logger(entry) + end + end + + def persist!(entry) + if defined?(Legion::Audit) + record_via_audit!(entry) + else + log_to_logger(entry) + end + end + + def record_via_audit(entry) + Legion::Audit.record( + event_type: AUDIT_EVENT_TYPE, + principal_id: entry[:actor], + action: entry[:action], + resource: entry[:resource], + source: 'phi', + detail: format_detail(entry) + ) + rescue StandardError => e + emit_warning("PHI audit record failed: #{e.message}") + end + + def record_via_audit!(entry) + Legion::Audit.record( + event_type: AUDIT_EVENT_TYPE, + principal_id: entry[:actor], + action: entry[:action], + resource: entry[:resource], + source: 'phi', + detail: format_detail(entry) + ) + end + + def log_to_logger(entry) + return unless defined?(Legion::Logging) + + Legion::Logging.info( + "[PHI ACCESS] actor=#{entry[:actor]} resource=#{entry[:resource]} " \ + "action=#{entry[:action]} fields=#{entry[:phi_fields].join(',')} " \ + "reason=#{entry[:reason]} at=#{entry[:timestamp]}" + ) + end + + def emit_warning(message) + Legion::Logging.warn(message) if defined?(Legion::Logging) + rescue NoMethodError + Kernel.warn(message) + end + + def format_detail(entry) + "fields=#{entry[:phi_fields].join(',')};reason=#{entry[:reason]}" + end + + def query_via_audit(resource:, limit:) + return [] unless defined?(Legion::Data::Model::AuditLog) + + Legion::Audit.recent(limit: limit, resource: resource, event_type: AUDIT_EVENT_TYPE) + rescue StandardError => e + Legion::Logging.warn "Phi::AccessLog#query_via_audit failed for resource=#{resource}: #{e.message}" if defined?(Legion::Logging) + [] + end + + def query_in_memory(**) + [] + end + + public_class_method :log_access, :log_access!, :recent_access + end + end +end diff --git a/lib/legion/phi/erasure.rb b/lib/legion/phi/erasure.rb new file mode 100644 index 00000000..6ee1ba2a --- /dev/null +++ b/lib/legion/phi/erasure.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'openssl' + +module Legion + module Phi + module Erasure + ERASURE_MARKER = '[ERASED]' + ERASURE_ALGORITHM = 'aes-256-gcm' + + module_function + + # Erase all PHI for a data subject. Returns an erasure audit entry. + def erase_for_subject(subject_id:) + timestamp = Time.now.utc.iso8601 + entry = { + subject_id: subject_id.to_s, + erased_at: timestamp, + method: 'cryptographic_erasure', + algorithm: ERASURE_ALGORITHM, + key_id: generate_key_id, + status: 'completed' + } + append_erasure_log(entry) + entry + end + + # Erase PHI in a single record by encrypting PHI fields with a throwaway key. + # The key is immediately discarded, making the data unrecoverable. + def erase_record(record:, phi_fields:, key_id: nil) + return record unless record.is_a?(Hash) + return record if phi_fields.nil? || phi_fields.empty? + + key_id ||= generate_key_id + ephemeral_key = generate_ephemeral_key + + result = record.dup + phi_fields.each do |field| + next unless result.key?(field) + + result[field] = encrypt_and_erase(result[field], ephemeral_key, key_id) + end + + # Destroy the ephemeral key immediately — data is now unrecoverable + ephemeral_key.replace(OpenSSL::Random.random_bytes(32)) + ephemeral_key = nil + + result + end + + # Returns the in-process erasure audit trail. + def erasure_log + @erasure_log ||= [] + @erasure_log.dup.freeze + end + + # Clears the in-process erasure log (used for testing). + def reset_erasure_log! + @erasure_log = [] + end + + def encrypt_and_erase(value, key, key_id) + return ERASURE_MARKER if value.nil? + + plaintext = value.to_s + cipher = OpenSSL::Cipher.new(ERASURE_ALGORITHM) + cipher.encrypt + cipher.key = key[0, 32] + iv = cipher.random_iv + cipher.iv = iv + + ciphertext = cipher.update(plaintext) + cipher.final + tag = cipher.auth_tag + + # Return an erasure marker with minimal forensic metadata (no recoverable data) + "#{ERASURE_MARKER}[key_id=#{key_id},iv=#{iv.unpack1('H*')},tag=#{tag.unpack1('H*')},len=#{ciphertext.bytesize}]" + rescue OpenSSL::Cipher::CipherError => e + Legion::Logging.warn "Phi::Erasure#encrypt_and_erase cipher error for key_id=#{key_id}: #{e.message}" if defined?(Legion::Logging) + ERASURE_MARKER + end + + def generate_ephemeral_key + OpenSSL::Random.random_bytes(32) + end + + def generate_key_id + OpenSSL::Random.random_bytes(16).unpack1('H*') + end + + def append_erasure_log(entry) + @erasure_log ||= [] + @erasure_log << entry + + if defined?(Legion::Audit) + Legion::Audit.record( + event_type: 'phi_erasure', + principal_id: entry[:subject_id], + action: 'erase', + resource: "subject/#{entry[:subject_id]}", + source: 'phi_erasure', + detail: "method=#{entry[:method]};algorithm=#{entry[:algorithm]};key_id=#{entry[:key_id]}" + ) + elsif defined?(Legion::Logging) + Legion::Logging.info( + "[PHI ERASURE] subject=#{entry[:subject_id]} method=#{entry[:method]} " \ + "algorithm=#{entry[:algorithm]} at=#{entry[:erased_at]}" + ) + end + rescue StandardError => e + # Never raise from erasure log — ensure the erase always appears to succeed + Legion::Logging.warn "Phi::Erasure#append_erasure_log failed for subject=#{entry[:subject_id]}: #{e.message}" if defined?(Legion::Logging) + end + + public_class_method :erase_for_subject, :erase_record, :erasure_log, :reset_erasure_log! + end + end +end diff --git a/lib/legion/process.rb b/lib/legion/process.rb index 1ec50ed1..7a13befb 100755 --- a/lib/legion/process.rb +++ b/lib/legion/process.rb @@ -1,12 +1,19 @@ +# frozen_string_literal: true + require 'fileutils' +require 'concurrent/atomic/atomic_boolean' module Legion class Process + class << self + attr_accessor :quit_flag + end + def self.run!(options) Legion::Process.new(options).run! end - attr_reader :options, :quit, :service + attr_reader :options, :service def initialize(options) @options = options @@ -14,6 +21,10 @@ def initialize(options) options[:pidfile] = File.expand_path(pidfile) if pidfile? end + def quit + @quit.is_a?(Concurrent::AtomicBoolean) ? @quit.true? : !!@quit + end + def daemonize? options[:daemonize] end @@ -41,16 +52,19 @@ def info(msg) def run! start_time = Time.now @options[:time_limit] = @options[:time_limit].to_i if @options.key? :time_limit - @quit = false + @quit = Concurrent::AtomicBoolean.new(false) + self.class.quit_flag = @quit check_pid daemonize if daemonize? write_pid trap_signals + retrap_after_puma until quit sleep(1) - @quit = true if @options.key?(:time_limit) && Time.now - start_time > @options[:time_limit] + @quit.make_true if @options.key?(:time_limit) && Time.now - start_time > @options[:time_limit] end + @retrap_thread&.kill Legion::Logging.info('Legion is shutting down!') Legion.shutdown Legion::Logging.info('Legion has shutdown. Goodbye!') @@ -73,7 +87,8 @@ def write_pid if pidfile? begin File.open(pidfile, ::File::CREAT | ::File::EXCL | ::File::WRONLY) { |f| f.write(::Process.pid.to_s) } - at_exit { File.delete(pidfile) if File.exist?(pidfile) } + Legion::Logging.info "[Process] PID #{::Process.pid} written to #{pidfile}" if defined?(Legion::Logging) + at_exit { FileUtils.rm_f(pidfile) } rescue Errno::EEXIST check_pid retry @@ -102,22 +117,39 @@ def pid_status(pidfile) ::Process.kill(0, pid) :running - rescue Errno::ESRCH + rescue Errno::ESRCH => e + Legion::Logging.debug "Process#pid_status: pid=#{pid} is dead: #{e.message}" if defined?(Legion::Logging) :dead - rescue Errno::EPERM + rescue Errno::EPERM => e + Legion::Logging.debug "Process#pid_status: pid=#{pid} not owned: #{e.message}" if defined?(Legion::Logging) :not_owned end def trap_signals trap('SIGTERM') do - info 'sigterm' + Legion::Logging.info '[Process] received SIGTERM, shutting down' if defined?(Legion::Logging) + @quit.make_true end trap('SIGHUP') do - info 'sithup' + Legion::Logging.info '[Process] received SIGHUP, triggering reload' if defined?(Legion::Logging) + info 'sighup: triggering reload' + Thread.new { Legion.reload } end + trap('SIGINT') do - @quit = true + Legion::Logging.info '[Process] received SIGINT, shutting down' if defined?(Legion::Logging) + @quit.make_true + end + end + + def retrap_after_puma + @retrap_thread = Thread.new do + 15.times do + sleep 1 + trap('SIGINT') { @quit.make_true } + trap('SIGTERM') { @quit.make_true } + end end end end diff --git a/lib/legion/process_role.rb b/lib/legion/process_role.rb new file mode 100644 index 00000000..12e8d7c5 --- /dev/null +++ b/lib/legion/process_role.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Legion + module ProcessRole + ROLES = { + full: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true }, + agent: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true }, + api: { transport: true, cache: true, data: true, extensions: false, api: true, llm: false, gaia: false, crypt: true, supervision: false }, + worker: { transport: true, cache: true, data: true, extensions: true, api: false, llm: true, gaia: true, crypt: true, supervision: true }, + router: { transport: true, cache: true, data: false, extensions: true, api: false, llm: false, gaia: false, crypt: true, supervision: false }, + lite: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: false, supervision: true }, + infra: { transport: true, cache: true, data: true, extensions: true, api: true, llm: true, gaia: true, crypt: true, supervision: true } + }.freeze + + def self.resolve(role_name) + key = role_name.to_sym + unless ROLES.key?(key) + warn_unrecognized(key) + key = :full + end + ROLES[key] + end + + def self.current + settings = begin + defined?(Legion::Settings) ? Legion::Settings[:process] : nil + rescue StandardError => e + Legion::Logging.debug "ProcessRole#current failed to read process settings: #{e.message}" if defined?(Legion::Logging) + nil + end + return :full unless settings.is_a?(Hash) + + role = settings[:role] + return :full if role.nil? + + role.to_sym + end + + def self.role?(name) + current == name.to_sym + end + + def self.warn_unrecognized(key) + message = "ProcessRole: unrecognized role '#{key}', falling back to :full" + if defined?(Legion::Logging) && Legion::Logging.respond_to?(:warn) + Legion::Logging.warn(message) + else + warn "[Legion] #{message}" + end + end + private_class_method :warn_unrecognized + end +end diff --git a/lib/legion/prompts.rb b/lib/legion/prompts.rb new file mode 100644 index 00000000..81fba34f --- /dev/null +++ b/lib/legion/prompts.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Legion + module Prompts + class << self + def get(name, version: :production) + client.get_prompt(name: name, tag: version.to_s) + end + + def list + client.list_prompts + end + + private + + def client + require 'legion/extensions/prompt/client' + Legion::Extensions::Prompt::Client.new + rescue LoadError => e + raise LoadError, "lex-prompt is not installed: #{e.message}" + end + end + end +end diff --git a/lib/legion/provider.rb b/lib/legion/provider.rb new file mode 100644 index 00000000..d47ac3ad --- /dev/null +++ b/lib/legion/provider.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require 'tsort' +require 'timeout' + +module Legion + class Provider + class CyclicDependencyError < StandardError; end + class MissingDependencyError < StandardError; end + + class << self + def provides(name = nil) + if name + @provides = name.to_sym + Registry.register(self) + end + @provides + end + + def depends_on(*deps) + if deps.any? + @depends_on = deps.map(&:to_sym) + else + @depends_on || [] + end + end + + def adapters(mapping = nil) + if mapping + @adapters = mapping + else + @adapters || {} + end + end + end + + attr_reader :mode + + def initialize(mode: :full) + @mode = mode + end + + def select_adapter(mode) + @mode = mode + adapter_path = self.class.adapters[mode] + require adapter_path if adapter_path + end + + def boot + raise NotImplementedError, "#{self.class}#boot must be implemented" + end + + def shutdown + # default no-op + end + + def name + self.class.provides + end + end + + class Provider + module Registry + class << self + include TSort + + def providers + @providers ||= {} + end + + def register(provider_class) + key = provider_class.provides + return unless key + + providers[key] = provider_class + end + + def boot_order + validate_dependencies! + tsort + rescue TSort::Cyclic => e + raise Provider::CyclicDependencyError, "cyclic dependency detected: #{e.message}" + end + + def boot!(mode: :full, timeout: 30) + booted = [] + boot_order.each do |key| + klass = providers[key] + instance = klass.new(mode: mode) + instance.select_adapter(mode) + + Timeout.timeout(timeout) { instance.boot } + Legion::Readiness.mark_ready(key) if defined?(Legion::Readiness) + booted << instance + rescue Timeout::Error => e + Legion::Logging.error "Provider :#{key} boot timed out (#{timeout}s)" if defined?(Legion::Logging) + shutdown!(booted) + raise Provider::MissingDependencyError, "provider :#{key} timed out during boot: #{e.message}" + rescue StandardError => e + Legion::Logging.error "Provider :#{key} boot failed: #{e.message}" if defined?(Legion::Logging) + shutdown!(booted) + raise + end + booted + end + + def shutdown!(instances) + instances.reverse_each do |instance| + instance.shutdown + Legion::Readiness.mark_not_ready(instance.name) if defined?(Legion::Readiness) + rescue StandardError => e + Legion::Logging.warn "Provider shutdown error for #{instance.name}: #{e.message}" if defined?(Legion::Logging) + end + end + + def reset! + @providers = {} + end + + private + + def tsort_each_node(&) + providers.each_key(&) + end + + def tsort_each_child(node, &) + klass = providers[node] + return unless klass + + klass.depends_on.each(&) + end + + def validate_dependencies! + providers.each do |name, klass| + klass.depends_on.each do |dep| + next if providers.key?(dep) + + raise Provider::MissingDependencyError, + "provider :#{name} depends on :#{dep} which is not registered" + end + end + end + end + end + end +end diff --git a/lib/legion/python.rb b/lib/legion/python.rb new file mode 100644 index 00000000..480c5fbb --- /dev/null +++ b/lib/legion/python.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Legion + module Python + VENV_DIR = (ENV['LEGION_PYTHON_VENV'] || File.expand_path('~/.legionio/python')).freeze + MARKER = File.expand_path('~/.legionio/.python-venv').freeze + + PACKAGES = %w[ + python-pptx + python-docx + openpyxl + pandas + pillow + requests + lxml + PyYAML + tabulate + markdown + ].freeze + + SYSTEM_CANDIDATES = %w[ + /opt/homebrew/bin/python3 + /usr/local/bin/python3 + /usr/bin/python3 + ].freeze + + module_function + + def venv_exists? + File.exist?("#{VENV_DIR}/pyvenv.cfg") + end + + def venv_python + "#{VENV_DIR}/bin/python3" + end + + def venv_pip + "#{VENV_DIR}/bin/pip" + end + + def venv_python_exists? + File.executable?(venv_python) + end + + def venv_pip_exists? + File.executable?(venv_pip) + end + + def interpreter + return venv_python if venv_python_exists? + + find_system_python3 || 'python3' + end + + def pip + return venv_pip if venv_pip_exists? + + 'pip3' + end + + def find_system_python3 + path_python = `command -v python3 2>/dev/null`.strip + candidates = SYSTEM_CANDIDATES.dup + candidates.unshift(path_python) unless path_python.empty? + candidates.uniq.find { |p| File.executable?(p) } + end + end +end diff --git a/lib/legion/readiness.rb b/lib/legion/readiness.rb new file mode 100644 index 00000000..4a4d4742 --- /dev/null +++ b/lib/legion/readiness.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'concurrent' + +module Legion + module Readiness + REQUIRED_COMPONENTS = %i[settings crypt transport cache data extensions api].freeze + OPTIONAL_COMPONENTS = %i[rbac llm apollo gaia identity].freeze + COMPONENTS = (REQUIRED_COMPONENTS + OPTIONAL_COMPONENTS).freeze + DRAIN_TIMEOUT = 5 + + class << self + def status + @status ||= Concurrent::Hash.new + end + + def mark_ready(component) + status[component.to_sym] = true + Legion::Logging.info "[Readiness] #{component} is ready" if defined?(Legion::Logging) + end + + def mark_not_ready(component) + status[component.to_sym] = false + Legion::Logging.debug "[Readiness] #{component} is not ready" if defined?(Legion::Logging) + end + + def mark_skipped(component) + status[component.to_sym] = :skipped + Legion::Logging.debug "[Readiness] #{component} skipped (optional)" if defined?(Legion::Logging) + end + + def ready?(component = nil) + if component + result = [true, :skipped].include?(status[component.to_sym]) + Legion::Logging.warn "[Readiness] #{component} is not ready" if !result && defined?(Legion::Logging) + return result + end + + not_ready = COMPONENTS.reject { |c| [true, :skipped].include?(status[c]) } + not_ready.each { |c| Legion::Logging.warn "[Readiness] #{c} is not ready" } if !not_ready.empty? && defined?(Legion::Logging) + not_ready.empty? + end + + def wait_until_not_ready(*components, timeout: DRAIN_TIMEOUT) + deadline = Time.now + timeout + loop do + break if components.all? { |c| status[c] != true } + break if Time.now > deadline + + sleep(0.1) + end + end + + def reset + @status = nil + end + + def to_h + COMPONENTS.to_h do |c| + val = status[c] + [c, [true, :skipped].include?(val)] + end + end + end + end +end diff --git a/lib/legion/region.rb b/lib/legion/region.rb new file mode 100644 index 00000000..11e38480 --- /dev/null +++ b/lib/legion/region.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require 'net/http' + +module Legion + module Region + include Legion::Logging::Helper if defined?(Legion::Logging::Helper) + + module_function + + UNSET = Object.new.freeze + EXPECTED_METADATA_ERRORS = [ + Net::OpenTimeout, + Net::ReadTimeout, + Errno::EHOSTUNREACH, + Errno::ECONNREFUSED, + Errno::ENETUNREACH, + IOError, + SocketError + ].freeze + + def current + setting = defined?(Legion::Settings) ? Legion::Settings.dig(:region, :current) : nil + return setting unless blank_region?(setting) + + @detected_region = UNSET unless instance_variable_defined?(:@detected_region) + return nil if @detected_region.equal?(UNSET) && @metadata_detection_complete == true + return @detected_region unless @detected_region.equal?(UNSET) + + @detected_region = detect_from_metadata + @metadata_detection_complete = true + @detected_region + rescue StandardError => e + Legion::Logging.debug "Region#current failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def reset! + remove_instance_variable(:@detected_region) if instance_variable_defined?(:@detected_region) + remove_instance_variable(:@metadata_detection_complete) if instance_variable_defined?(:@metadata_detection_complete) + end + + def local?(target_region) + target_region.nil? || target_region == current + end + + def affinity_for(message_region, affinity) + return :local if local?(message_region) || affinity == 'any' + return :remote if affinity == 'prefer_local' + return :reject if affinity == 'require_local' + + :local + end + + def primary + return nil unless defined?(Legion::Settings) + + Legion::Settings.dig(:region, :primary) + rescue StandardError => e + Legion::Logging.debug "Region#primary failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def failover + return nil unless defined?(Legion::Settings) + + Legion::Settings.dig(:region, :failover) + rescue StandardError => e + Legion::Logging.debug "Region#failover failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def peers + return [] unless defined?(Legion::Settings) + + Legion::Settings.dig(:region, :peers) || [] + rescue StandardError => e + Legion::Logging.debug "Region#peers failed: #{e.message}" if defined?(Legion::Logging) + [] + end + + def detect_from_metadata + detect_aws_region || detect_azure_region + rescue StandardError => e + Legion::Logging.debug "Region#detect_from_metadata failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def detect_aws_region + uri = URI('http://169.254.169.254/latest/meta-data/placement/region') + token_uri = URI('http://169.254.169.254/latest/api/token') + + token = Net::HTTP.start(token_uri.host, token_uri.port, open_timeout: 1, read_timeout: 1) do |http| + req = Net::HTTP::Put.new(token_uri) + req['X-aws-ec2-metadata-token-ttl-seconds'] = '21600' + http.request(req).body + end + + Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http| + req = Net::HTTP::Get.new(uri) + req['X-aws-ec2-metadata-token'] = token + response = http.request(req) + response.is_a?(Net::HTTPSuccess) ? response.body.strip : nil + end + rescue *EXPECTED_METADATA_ERRORS + nil + rescue StandardError => e + Legion::Logging.debug "Region#detect_aws_region failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def detect_azure_region + uri = URI('http://169.254.169.254/metadata/instance/compute/location?api-version=2021-02-01&format=text') + + Net::HTTP.start(uri.host, uri.port, open_timeout: 1, read_timeout: 1) do |http| + req = Net::HTTP::Get.new(uri) + req['Metadata'] = 'true' + response = http.request(req) + response.is_a?(Net::HTTPSuccess) ? response.body.strip : nil + end + rescue *EXPECTED_METADATA_ERRORS + nil + rescue StandardError => e + Legion::Logging.debug "Region#detect_azure_region failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def blank_region?(value) + value.nil? || (value.respond_to?(:empty?) && value.empty?) + end + private_class_method :blank_region? + end +end diff --git a/lib/legion/region/failover.rb b/lib/legion/region/failover.rb new file mode 100644 index 00000000..1dd30ade --- /dev/null +++ b/lib/legion/region/failover.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Legion + module Region + module Failover + module_function + + MAX_LAG_SECONDS = 30 + + def promote!(region:) + validate_target!(region) + + lag = replication_lag + raise LagTooHighError, "replication lag #{lag.round(1)}s exceeds #{MAX_LAG_SECONDS}s threshold" if lag && lag > MAX_LAG_SECONDS + + previous = Legion::Settings.dig(:region, :primary) + Legion::Settings[:region][:primary] = region + Legion::Events.emit('region.failover', from: previous, to: region) if defined?(Legion::Events) + + { promoted: region, previous: previous, lag_seconds: lag } + end + + def replication_lag + return nil unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection + + row = Legion::Data.connection.fetch( + 'SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) AS lag' + ).first + row[:lag]&.to_f + rescue StandardError => e + Legion::Logging.debug "Region::Failover#replication_lag failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def validate_target!(region) + peers = Legion::Settings.dig(:region, :peers) || [] + failover = Legion::Settings.dig(:region, :failover) + known = (peers + [failover].compact).uniq + + return if known.include?(region) + + raise UnknownRegionError, "'#{region}' is not a known peer or failover region (known: #{known.join(', ')})" + end + + class LagTooHighError < StandardError; end + class UnknownRegionError < StandardError; end + end + end +end diff --git a/lib/legion/registry.rb b/lib/legion/registry.rb new file mode 100644 index 00000000..84daf60f --- /dev/null +++ b/lib/legion/registry.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +module Legion + module Registry + VALID_STATUSES = %i[pending_review approved rejected deprecated sunset active].freeze + + class Entry + ATTRS = %i[name version author risk_tier permissions airb_status + description homepage checksum capabilities + status review_notes reject_reason successor sunset_date + submitted_at approved_at rejected_at deprecated_at].freeze + + attr_reader(*ATTRS) + + def initialize(**attrs) + ATTRS.each { |a| instance_variable_set(:"@#{a}", attrs[a]) } + @risk_tier ||= 'low' + @airb_status ||= 'pending' + @capabilities ||= [] + @permissions ||= [] + @status ||= :active + end + + def approved? + airb_status == 'approved' + end + + def deprecated? + %i[deprecated sunset].include?(status) + end + + def pending_review? + status == :pending_review + end + + def to_h + ATTRS.to_h { |a| [a, send(a)] } + end + end + + class << self + def register(entry) + raise ArgumentError, "Extension name '#{entry.name}' violates naming convention" if defined?(Governance) && !Governance.check_name(entry.name) + + store[entry.name] = entry + + if defined?(Governance) && Governance.auto_approve?(entry.risk_tier) + update_entry(entry.name, entry, status: :approved, airb_status: 'approved', approved_at: Time.now.utc) + end + + Persistence.persist(store[entry.name]) if defined?(Persistence) + end + + def unregister(name) + store.delete(name.to_s) + end + + def lookup(name) + store[name.to_s] + end + + def all + store.values + end + + def search(query) + pattern = query.to_s.downcase + store.values.select do |e| + e.name.downcase.include?(pattern) || + (e.description || '').downcase.include?(pattern) + end + end + + def approved + store.values.select(&:approved?) + end + + def by_risk_tier(tier) + store.values.select { |e| e.risk_tier == tier.to_s } + end + + def clear! + @store = {} + end + + # Review workflow + + def submit_for_review(name) + entry = find_or_raise(name) + update_entry(name, entry, status: :pending_review, submitted_at: Time.now.utc) + true + end + + def approve(name, notes: nil) + entry = find_or_raise(name) + update_entry(name, entry, + status: :approved, + airb_status: 'approved', + review_notes: notes, + approved_at: Time.now.utc) + true + end + + def reject(name, reason: nil) + entry = find_or_raise(name) + update_entry(name, entry, + status: :rejected, + reject_reason: reason, + rejected_at: Time.now.utc) + true + end + + def deprecate(name, successor: nil, sunset_date: nil) + entry = find_or_raise(name) + update_entry(name, entry, + status: :deprecated, + successor: successor, + sunset_date: sunset_date, + deprecated_at: Time.now.utc) + true + end + + def pending_reviews + store.values.select(&:pending_review?) + end + + def usage_stats(name) + entry = lookup(name.to_s) + return nil unless entry + + { + name: entry.name, + version: entry.version, + install_count: 0, + active_instances: 0, + last_updated: nil, + downloads_7d: 0, + downloads_30d: 0 + } + end + + private + + def store + @store ||= {} + end + + def find_or_raise(name) + entry = lookup(name.to_s) + raise ArgumentError, "Extension '#{name}' not found in registry" unless entry + + entry + end + + def update_entry(name, entry, **overrides) + attrs = entry.to_h.merge(overrides) + store[name.to_s] = Entry.new(**attrs) + Persistence.persist(store[name.to_s]) if defined?(Persistence) + end + end + end +end + +require_relative 'registry/persistence' +require_relative 'registry/governance' diff --git a/lib/legion/registry/governance.rb b/lib/legion/registry/governance.rb new file mode 100644 index 00000000..cf21be9d --- /dev/null +++ b/lib/legion/registry/governance.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Legion + module Registry + module Governance + DEFAULTS = { + require_airb_approval: false, + auto_approve_risk_tiers: %w[low], + review_required_risk_tiers: %w[medium high critical], + naming_convention: 'lex-[a-z][a-z0-9_]*(?:-[a-z][a-z0-9_]*)*', + deprecation_notice_days: 30 + }.freeze + + class << self + def config + @config ||= load_config + end + + def check_name(name) + pattern = Regexp.new("\\A#{config[:naming_convention]}\\z") + pattern.match?(name.to_s) + end + + def auto_approve?(risk_tier) + config[:auto_approve_risk_tiers].include?(risk_tier.to_s) + end + + def review_required?(risk_tier) + config[:review_required_risk_tiers].include?(risk_tier.to_s) + end + + def reset! + @config = nil + end + + private + + def load_config + return DEFAULTS unless defined?(Legion::Settings) + + overrides = Legion::Settings.dig(:registry, :governance) + return DEFAULTS.merge(overrides) if overrides.is_a?(Hash) + + DEFAULTS + rescue StandardError => e + Legion::Logging.debug "Registry::Governance#load_config failed: #{e.message}" if defined?(Legion::Logging) + DEFAULTS + end + end + end + end +end diff --git a/lib/legion/registry/persistence.rb b/lib/legion/registry/persistence.rb new file mode 100644 index 00000000..5600fec1 --- /dev/null +++ b/lib/legion/registry/persistence.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Legion + module Registry + module Persistence + class << self + def data_available? + return false unless defined?(Legion::Data) + return false unless Legion::Data.respond_to?(:connection) && Legion::Data.connection + + Legion::Data.connection.table_exists?(:extensions_registry) + rescue StandardError => e + Legion::Logging.debug "Registry::Persistence#data_available? check failed: #{e.message}" if defined?(Legion::Logging) + false + end + + def load_from_db + return 0 unless data_available? + + count = 0 + registry_dataset.each do |row| + entry = Entry.new( + name: row[:name], + version: row[:version], + author: row[:author], + description: row[:description], + status: row[:status]&.to_sym, + airb_status: row[:airb_status], + risk_tier: row[:risk_tier] + ) + Legion::Registry.register(entry) + count += 1 + end + count + end + + def persist(entry) + return false unless data_available? + + attrs = persistence_attrs(entry) + existing = registry_dataset.where(name: entry.name).first + + if existing + registry_dataset.where(name: entry.name).update(**attrs, updated_at: Time.now) + else + registry_dataset.insert(**attrs, created_at: Time.now, updated_at: Time.now) + end + + true + rescue StandardError => e + Legion::Logging.warn("Registry::Persistence failed to persist #{entry.name}: #{e.message}") if defined?(Legion::Logging) + false + end + + private + + def registry_dataset + Legion::Data.connection[:extensions_registry] + end + + def persistence_attrs(entry) + parts = entry.name.to_s.split('-') + mod_name = parts.map(&:capitalize).join('::') + + { + name: entry.name, + module_name: mod_name, + status: entry.status.to_s, + description: entry.description + } + end + end + end + end +end diff --git a/lib/legion/registry/security_scanner.rb b/lib/legion/registry/security_scanner.rb new file mode 100644 index 00000000..04cec2ac --- /dev/null +++ b/lib/legion/registry/security_scanner.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'digest' + +module Legion + module Registry + class SecurityScanner + CHECKS = %i[checksum naming_convention gemspec_metadata static_analysis].freeze + + DANGEROUS_PATTERNS = [ + { pattern: /\bKernel\.eval\b|\beval\s*\(/, label: 'eval' }, + { pattern: /\bKernel\.system\b|\bsystem\s*\(/, label: 'system' }, + { pattern: /\bKernel\.exec\b|\bexec\s*\(/, label: 'exec' }, + { pattern: /\bIO\.popen\b/, label: 'IO.popen' }, + { pattern: /\bOpen3\b/, label: 'Open3' }, + { pattern: /`[^`]+`/, label: 'backtick subshell' } + ].freeze + + def scan(gem_path: nil, name: nil, gemspec: nil, source_path: nil) + results = CHECKS.map do |check| + send(check, gem_path: gem_path, name: name, gemspec: gemspec, source_path: source_path) + end + { + passed: results.all? { |r| r[:status] != :fail }, + checks: results, + scanned_at: Time.now + } + end + + private + + def checksum(gem_path:, **_) + return { check: :checksum, status: :skip, details: 'no gem path' } unless gem_path && File.exist?(gem_path.to_s) + + hash = Digest::SHA256.file(gem_path).hexdigest + { check: :checksum, status: :pass, details: hash } + end + + def naming_convention(name:, **_) + return { check: :naming_convention, status: :skip, details: 'no name' } unless name + + if name.match?(/\Alex-[a-z][a-z0-9_]*(?:-[a-z][a-z0-9_]*)*\z/) + { check: :naming_convention, status: :pass, details: name } + else + { check: :naming_convention, status: :fail, + details: "#{name} does not match lex-[a-z][a-z0-9_]*(?:-[a-z][a-z0-9_]*)*" } + end + end + + def gemspec_metadata(gemspec:, **_) + return { check: :gemspec_metadata, status: :skip, details: 'no gemspec' } unless gemspec + + has_caps = gemspec.metadata&.key?('legion.capabilities') + status = has_caps ? :pass : :warn + { check: :gemspec_metadata, status: status, + details: has_caps ? 'capabilities declared' : 'no capabilities declared' } + end + + def static_analysis(source_path:, **_) + return { check: :static_analysis, status: :skip, details: 'no source path' } unless source_path && Dir.exist?(source_path.to_s) + + findings = [] + Dir.glob(File.join(source_path, '**', '*.rb')).each do |file| + relative = file.delete_prefix("#{source_path}/") + File.foreach(file).with_index(1) do |line, lineno| + DANGEROUS_PATTERNS.each do |entry| + findings << "#{relative}:#{lineno} #{entry[:label]}" if line.match?(entry[:pattern]) + end + end + end + + if findings.empty? + { check: :static_analysis, status: :pass, details: 'no dangerous patterns found' } + else + { check: :static_analysis, status: :warn, details: findings.join('; ') } + end + end + end + end +end diff --git a/lib/legion/runner.rb b/lib/legion/runner.rb index b62b30e8..7e5ef530 100755 --- a/lib/legion/runner.rb +++ b/lib/legion/runner.rb @@ -1,11 +1,18 @@ +# frozen_string_literal: true + require_relative 'runner/log' require_relative 'runner/status' +require_relative 'context' require 'legion/transport' require 'legion/transport/messages/check_subtask' module Legion module Runner - def self.run(runner_class:, function:, task_id: nil, args: nil, check_subtask: true, generate_task: true, parent_id: nil, master_id: nil, catch_exceptions: false, **opts) # rubocop:disable Layout/LineLength, Metrics/CyclomaticComplexity, Metrics/ParameterLists + def self.run(runner_class:, function:, task_id: nil, args: nil, check_subtask: true, generate_task: true, parent_id: nil, master_id: nil, catch_exceptions: false, **opts) # rubocop:disable Layout/LineLength, Metrics/CyclomaticComplexity, Metrics/ParameterLists, Metrics/MethodLength, Metrics/PerceivedComplexity + started_at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + lex_tag = derive_lex_tag(runner_class) + rlog = runner_logger(lex_tag) + rlog.info "[Runner] start: #{runner_class}##{function} task_id=#{task_id}" runner_class = Kernel.const_get(runner_class) if runner_class.is_a? String if task_id.nil? && generate_task @@ -25,33 +32,89 @@ def self.run(runner_class:, function:, task_id: nil, args: nil, check_subtask: t # result = Fiber.new { Fiber.yield runner_class.send(function, **args) } raise 'No Function defined' if function.nil? - result = runner_class.send(function, **args) - rescue Legion::Exception::HandledTask - status = 'task.exception' - result = { error: {} } - rescue StandardError => e - runner_class.handle_exception(e, - **opts, - runner_class: runner_class, - args: args, - function: function, - task_id: task_id, - generate_task: generate_task, - check_subtask: check_subtask) - status = 'task.exception' - result = { success: false, status: status, error: { message: e.message, backtrace: e.backtrace } } - raise e unless catch_exceptions + result = nil + status = nil + Legion::Context.with_task_context(opts.merge(task_id: task_id, function: function, runner_class: runner_class.to_s)) do + result = if runner_class.respond_to?(:with_log_context) + runner_class.with_log_context(function) { runner_class.send(function, **args) } + else + runner_class.send(function, **args) + end + rescue Legion::Exception::HandledTask => e + rlog.debug "[Runner] HandledTask raised in #{runner_class}##{function}: #{e.message}" + status = 'task.exception' + result = { error: {} } + rescue StandardError => e + rlog.error "[Runner] exception in #{runner_class}##{function}: #{e.message}" + status = 'task.exception' + result = { success: false, status: status, error: { message: e.message, backtrace: e.backtrace } } + if runner_class.respond_to?(:handle_runner_exception) + begin + runner_class.handle_runner_exception(e, + **opts, + runner_class: runner_class, + args: args, + function: function, + task_id: task_id, + generate_task: generate_task, + check_subtask: check_subtask) + rescue Legion::Exception::HandledTask => handled + rlog.debug "[Runner] HandledTask raised while handling exception in #{runner_class}##{function}: #{handled.message}" + end + end + raise e unless catch_exceptions + end ensure status = 'task.completed' if status.nil? + duration_ms = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - started_at) * 1000).round + rlog.info "[Runner] complete: #{runner_class}##{function} status=#{status} duration_ms=#{duration_ms}" + Legion::Events.emit("task.#{status == 'task.completed' ? 'completed' : 'failed'}", + task_id: task_id, runner_class: runner_class.to_s, function: function, status: status) Legion::Runner::Status.update(task_id: task_id, status: status) unless task_id.nil? if check_subtask && status == 'task.completed' Legion::Transport::Messages::CheckSubtask.new(runner_class: runner_class, function: function, result: result, original_args: args, + task_id: task_id, + master_id: master_id, **opts).publish end + if defined?(Legion::Audit) + begin + error_message = status == 'task.exception' ? result&.dig(:error, :message) : nil + Legion::Audit.record( + event_type: 'runner_execution', + principal_id: opts[:principal_id] || opts[:worker_id] || 'system', + principal_type: opts[:principal_type] || 'system', + action: 'execute', + resource: "#{runner_class}/#{function}", + source: opts[:source] || 'unknown', + status: status == 'task.completed' ? 'success' : 'failure', + duration_ms: duration_ms, + detail: { task_id: task_id, error: error_message } + ) + rescue StandardError => e + rlog.debug("Audit in runner.run failed: #{e.message}") + end + end return { success: true, status: status, result: result, task_id: task_id } # rubocop:disable Lint/EnsureReturn end + + def self.derive_lex_tag(runner_class) + name = runner_class.is_a?(String) ? runner_class : runner_class.to_s + parts = name.split('::') + ext_idx = parts.index('Extensions') + return parts.last.downcase unless ext_idx && parts[ext_idx + 1] + + parts[ext_idx + 1].gsub(/([A-Z]++)([A-Z][a-z])/, '\1_\2') + .gsub(/([a-z\d])([A-Z])/, '\1_\2') + .downcase + end + + def self.runner_logger(tag) + @runner_loggers ||= {} + @runner_loggers[tag] ||= Legion::Logging::Logger.new(lex: tag) + end end end diff --git a/lib/legion/runner/log.rb b/lib/legion/runner/log.rb index bed88b42..55274450 100755 --- a/lib/legion/runner/log.rb +++ b/lib/legion/runner/log.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion module Runner module Log diff --git a/lib/legion/runner/status.rb b/lib/legion/runner/status.rb index 018d784f..d44f4553 100755 --- a/lib/legion/runner/status.rb +++ b/lib/legion/runner/status.rb @@ -1,47 +1,47 @@ +# frozen_string_literal: true + module Legion module Runner module Status - def self.update(task_id:, status: 'task.completed', **opts) - Legion::Logging.debug "Legion::Runner::Status.update called, #{task_id}, status: #{status}, #{opts}" + def self.update(task_id:, status: 'task.completed', **) + Legion::Logging.debug "[Status] transition task_id=#{task_id} -> #{status}" if defined?(Legion::Logging) return if status.nil? if Legion::Settings[:data][:connected] - update_db(task_id: task_id, status: status, **opts) + update_db(task_id: task_id, status: status, **) else - update_rmq(task_id: task_id, status: status, **opts) + update_rmq(task_id: task_id, status: status, **) end end - def self.update_rmq(task_id:, status: 'task.completed', **opts) + def self.update_rmq(task_id:, status: 'task.completed', **) return if status.nil? - Legion::Transport::Messages::TaskUpdate.new(task_id: task_id, status: status, **opts).publish + retries = 0 + Legion::Transport::Messages::TaskUpdate.new(task_id: task_id, status: status, **).publish rescue StandardError => e - Legion::Logging.fatal e.message - Legion::Logging.fatal e.backtrace - retries ||= 0 - Legion::Logging.fatal 'Will retry in 3 seconds' if retries < 5 - sleep(3) - retry if (retries += 1) < 5 + retries += 1 + Legion::Logging.log_exception(e, level: :fatal, payload_summary: "[Status] update_rmq failed (attempt #{retries}/3)", component_type: :runner) + retry if retries < 3 end - def self.update_db(task_id:, status: 'task.completed', **opts) + def self.update_db(task_id:, status: 'task.completed', **) return if status.nil? task = Legion::Data::Model::Task[task_id] task.update(status: status) rescue StandardError => e - Legion::Logging.warn e.message - Legion::Logging.warn 'Legion::Runner.update_status_db failed, defaulting to rabbitmq' - Legion::Logging.warn e.backtrace - update_rmq(task_id: task_id, status: status, **opts) + Legion::Logging.log_exception(e, level: :warn, + payload_summary: "[Status] update_db failed for task_id=#{task_id}, falling back to RabbitMQ update", + component_type: :runner) + update_rmq(task_id: task_id, status: status, **) end def self.generate_task_id(runner_class:, function:, status: 'task.queued', **opts) - Legion::Logging.debug "Legion::Runner::Status.generate_task_id called, #{runner_class}, #{function}, status: #{status}, #{opts}" + Legion::Logging.debug "[Status] generate_task_id: #{runner_class}##{function} status=#{status}" if defined?(Legion::Logging) return nil unless Legion::Settings[:data][:connected] - runner = Legion::Data::Model::Runner.where(namespace: runner_class.to_s.downcase).first + runner = Legion::Data::Model::Runner.where(namespace: runner_class.to_s).first return nil if runner.nil? function = Legion::Data::Model::Function.where(runner_id: runner.values[:id], name: function).first @@ -60,8 +60,7 @@ def self.generate_task_id(runner_class:, function:, status: 'task.queued', **opt { success: true, task_id: Legion::Data::Model::Task.insert(insert), **insert } rescue StandardError => e - Legion::Logging.error e.message - Legion::Logging.error e.backtrace + Legion::Logging.log_exception(e, component_type: :runner) raise(e) end end diff --git a/lib/legion/sandbox.rb b/lib/legion/sandbox.rb new file mode 100644 index 00000000..258bb598 --- /dev/null +++ b/lib/legion/sandbox.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Legion + module Sandbox + class Policy + CAPABILITIES = %w[ + network:outbound network:inbound + filesystem:read filesystem:write + llm:invoke llm:embed + data:read data:write + cache:read cache:write + transport:publish transport:subscribe + ].freeze + + attr_reader :extension_name, :capabilities, :allowed_domains + + def initialize(extension_name:, capabilities: [], allowed_domains: nil) + @extension_name = extension_name + @capabilities = capabilities.select { |c| CAPABILITIES.include?(c) }.freeze + @allowed_domains = allowed_domains&.map(&:to_s)&.freeze + end + + def allowed?(capability) + capabilities.include?(capability.to_s) + end + + def domain_allowed?(agent_domain) + return true if allowed_domains.nil? || allowed_domains.empty? + + allowed_domains.include?(agent_domain.to_s) + end + end + + class << self + def register_policy(extension_name, capabilities:, allowed_domains: nil) + policies[extension_name] = Policy.new( + extension_name: extension_name, + capabilities: capabilities, + allowed_domains: allowed_domains + ) + end + + def policy_for(extension_name) + policies[extension_name] || Policy.new(extension_name: extension_name) + end + + def enforce!(extension_name, capability) + return true unless enforcement_enabled? + + policy = policy_for(extension_name) + raise SecurityError, "Extension #{extension_name} not authorized for: #{capability}" unless policy.allowed?(capability) + + true + end + + def allowed?(extension_name: nil, gem_name: nil, capability: nil, agent_domain: nil) + ext = extension_name || gem_name + return true unless enforcement_enabled? + + policy = policy_for(ext) + + return false if capability && !policy.allowed?(capability) + + return false if agent_domain && !policy.domain_allowed?(agent_domain) + + true + end + + def enforcement_enabled? + return false unless defined?(Legion::Settings) + + Legion::Settings.dig(:sandbox, :enabled) != false + end + + def clear! + @policies = {} + end + + private + + def policies + @policies ||= {} + end + end + end +end diff --git a/lib/legion/service.rb b/lib/legion/service.rb index 1415f39e..63ce7c28 100755 --- a/lib/legion/service.rb +++ b/lib/legion/service.rb @@ -1,73 +1,311 @@ +# frozen_string_literal: true + +require 'timeout' +require 'legion/logging' +require_relative 'readiness' +require_relative 'mode' +require_relative 'process_role' + module Legion class Service + include Legion::Logging::Helper + + class << self + include Legion::Logging::Helper + + private + + def resolve_logger_settings + raw_logging = (Legion::Settings[:logging] if defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])) + raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default + end + end + def modules - [Legion::Crypt, Legion::Transport, Legion::Cache, Legion::Data, Legion::Supervision].freeze + base = [Legion::Crypt, Legion::Transport, Legion::Cache, Legion::Data, Legion::Supervision] + base << Legion::LLM if defined?(Legion::LLM) + base << Legion::Gaia if defined?(Legion::Gaia) + base.freeze end - def initialize(transport: true, cache: true, data: true, supervision: true, extensions: true, crypt: true, log_level: 'info') # rubocop:disable Metrics/ParameterLists - setup_logging(log_level: log_level) - Legion::Logging.debug('Starting Legion::Service') + def initialize(transport: nil, cache: nil, data: nil, supervision: nil, extensions: nil, # rubocop:disable Metrics/CyclomaticComplexity,Metrics/ParameterLists,Metrics/MethodLength,Metrics/PerceivedComplexity,Metrics/AbcSize + crypt: nil, api: nil, llm: nil, gaia: nil, log_level: nil, http_port: nil, + role: nil) + role_opts = Legion::ProcessRole.resolve(role || Legion::ProcessRole.current) + transport = role_opts[:transport] if transport.nil? + cache = role_opts[:cache] if cache.nil? + data = role_opts[:data] if data.nil? + supervision = role_opts[:supervision] if supervision.nil? + extensions = role_opts[:extensions] if extensions.nil? + crypt = role_opts[:crypt] if crypt.nil? + api = role_opts[:api] if api.nil? + llm = role_opts[:llm] if llm.nil? + gaia = role_opts[:gaia] if gaia.nil? + + setup_logging(log_level: bootstrap_log_level(log_level)) + log.debug('Starting Legion::Service') setup_settings - Legion::Logging.info("node name: #{Legion::Settings[:client][:name]}") + apply_cli_overrides(http_port: http_port) + setup_compliance + setup_local_mode + reconfigure_logging(log_level) + log.info("node name: #{Legion::Settings[:client][:name]}") if crypt require 'legion/crypt' Legion::Crypt.start + Legion::Readiness.mark_ready(:crypt) + setup_mtls_rotation + # Phase 5: fetch short-lived bootstrap RMQ creds from Vault before transport connects. + # Service is the authoritative gate (vault_connected? + dynamic_rmq_creds?). + fetch_phase5_bootstrap_creds unless Legion::Mode.respond_to?(:lite?) && Legion::Mode.lite? + end + + Legion::Settings.resolve_secrets! + + if transport + setup_transport + Legion::Readiness.mark_ready(:transport) + setup_logging_transport + end + + setup_dispatch + + if cache + begin + require 'legion/cache' + Legion::Cache.setup + Legion::Readiness.mark_ready(:cache) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.initialize.cache', fallback: 'cache_local') + begin + Legion::Cache::Local.setup + log.info 'Legion::Cache::Local connected (fallback)' + rescue StandardError => e2 + handle_exception(e2, level: :warn, operation: 'service.initialize.cache_local') + end + Legion::Readiness.mark_ready(:cache) + end end - setup_transport if transport + if data + begin + setup_data + Legion::Readiness.mark_ready(:data) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.initialize.data', fallback: 'data_local') + begin + require 'legion/data' + Legion::Data::Local.setup if defined?(Legion::Data::Local) + log.info 'Legion::Data::Local connected (fallback)' + rescue StandardError => e2 + handle_exception(e2, level: :warn, operation: 'service.initialize.data_local') + end + Legion::Readiness.mark_ready(:data) + end + end + + if data + setup_rbac + else + Legion::Readiness.mark_skipped(:rbac) + end + setup_cluster if data - require 'legion/cache' if cache + setup_identity_before_llm(extensions: extensions, transport: transport) + + if llm + begin + setup_llm + Legion::Readiness.mark_ready(:llm) + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.initialize.llm', availability: 'missing') + log.info 'Legion::LLM gem is not installed' + Legion::Readiness.mark_skipped(:llm) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.initialize.llm') + Legion::Readiness.mark_skipped(:llm) + end + else + Legion::Readiness.mark_skipped(:llm) + end - setup_data if data + begin + setup_apollo + Legion::Readiness.mark_ready(:apollo) + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.initialize.apollo', availability: 'missing') + log.info 'Legion::Apollo gem is not installed, starting without Apollo' + Legion::Readiness.mark_skipped(:apollo) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.initialize.apollo') + Legion::Readiness.mark_skipped(:apollo) + end + + if gaia + begin + setup_gaia + Legion::Readiness.mark_ready(:gaia) + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.initialize.gaia', availability: 'missing') + log.info 'Legion::Gaia gem is not installed' + Legion::Readiness.mark_skipped(:gaia) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.initialize.gaia') + Legion::Readiness.mark_skipped(:gaia) + end + else + Legion::Readiness.mark_skipped(:gaia) + end + + setup_telemetry + setup_audit_archiver + setup_safety_metrics setup_supervision if supervision - require 'legion/runner' - load_extensions if extensions + + if extensions + load_extensions + Legion::Readiness.mark_ready(:extensions) + setup_generated_functions + end + + # Re-run identity after full extension load so any providers with autobuild-time + # registration can upgrade the pre-LLM identity. + db_available = defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected? + setup_identity if transport || db_available + register_credential_providers if extensions && (transport || db_available) + + register_core_tools + + Legion::Gaia.registry&.rediscover if gaia && defined?(Legion::Gaia) && Legion::Gaia.started? + + Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer.setup if defined?(Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer) Legion::Crypt.cs if crypt + + setup_alerts + setup_metrics + setup_task_outcome_observer + + # Pre-warm MCP server in background; async embedding build + Thread.new do + require 'legion/mcp' if defined?(Legion::Settings) && !defined?(Legion::MCP) + Legion::MCP.server if defined?(Legion::MCP) && Legion::MCP.respond_to?(:server) + Legion::MCP::Server.populate_embedding_index if defined?(Legion::MCP::Server) && Legion::MCP::Server.respond_to?(:populate_embedding_index) + rescue StandardError => e + log.warn("MCP pre-warm failed: #{e.message}") + end + + require 'sinatra/base' + require 'legion/api/default_settings' + api_settings = Legion::Settings[:api] + @api_enabled = api && api_settings[:enabled] + setup_apm if @api_enabled + setup_api if @api_enabled + setup_network_watchdog Legion::Settings[:client][:ready] = true + Legion::Events.emit('service.ready') end - def setup_data - if RUBY_ENGINE == 'truffleruby' - Legion::Logging.error 'Legion::Data does not support truffleruby, please use MRI for any LEX that require it ' - Legion::Settings[:data][:connected] = false - return false + def setup_local_mode + if lite_mode? + log.info 'Starting in lite mode (zero infrastructure)' + Legion::Settings.set_prop(:dev, true) + require 'legion/transport/local' + require 'legion/crypt/mock_vault' if defined?(Legion::Crypt) + return end + return unless local_mode? + + log.info 'Starting in local development mode' + Legion::Settings.set_prop(:dev, true) + + require 'legion/transport/local' + require 'legion/crypt/mock_vault' + end + + def local_mode? + ENV['LEGION_LOCAL'] == 'true' || + Legion::Settings[:local_mode] == true + end + + def lite_mode? + Legion::Mode.lite? + end + + def setup_data + log.info 'Setting up Legion::Data' require 'legion/data' Legion::Settings.merge_settings(:data, Legion::Data::Settings.default) Legion::Data.setup - rescue LoadError - Legion::Logging.info 'Legion::Data gem is not installed, please install it manually with gem install legion-data' + log.info 'Legion::Data connected' + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.setup_data', availability: 'missing') + log.info 'Legion::Data gem is not installed, please install it manually with gem install legion-data' rescue StandardError => e - Legion::Logging.warn "Legion::Data failed to load, starting without it. e: #{e.message}" + handle_exception(e, level: :warn, operation: 'service.setup_data') end - # noinspection RubyArgCount - def default_paths - [ - '/etc/legionio', - "#{ENV['home']}/legionio", - '~/legionio', - './settings' - ] + def setup_rbac + require 'legion/rbac' + Legion::Rbac.setup + Legion::Readiness.mark_ready(:rbac) + log.info 'Legion::Rbac loaded' + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.setup_rbac', availability: 'missing') + log.debug 'Legion::Rbac gem is not installed, starting without RBAC' + Legion::Readiness.mark_skipped(:rbac) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_rbac') + Legion::Readiness.mark_skipped(:rbac) end - def setup_settings(default_dir = __dir__) - require 'legion/settings' - config_directory = default_dir - default_paths.each do |path| - next unless Dir.exist? path + def setup_cluster + cluster_settings = Legion::Settings[:cluster] + return unless cluster_settings.is_a?(Hash) && cluster_settings[:leader_election] == true + + require 'legion/cluster' + return unless defined?(Legion::Cluster::Leader) + + @cluster_leader = Legion::Cluster::Leader.new + @cluster_leader.start + log.info('Cluster leader election started') + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_cluster') + end - Legion::Logging.info "Using #{path} for settings" - config_directory = path - break + def setup_settings + require 'legion/settings' + directories = Legion::Settings::Loader.default_directories + existing = directories.select { |d| Dir.exist?(d) } + log.info "Settings search directories: #{directories.inspect}" + existing.each { |d| log.info "Settings: will load from #{d}" } + if Legion::Settings.respond_to?(:loaded?) && Legion::Settings.loaded? + log.info 'Legion::Settings already loaded, skipping reload' + else + Legion::Settings.load(config_dirs: existing) end + Legion::Readiness.mark_ready(:settings) + log.info('Legion::Settings Loaded') + self.class.log_privacy_mode_status + end + + def setup_compliance + require 'legion/compliance' + Legion::Compliance.setup + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.setup_compliance', availability: 'missing') + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_compliance') + end + + def apply_cli_overrides(http_port: nil) + return unless http_port - Legion::Logging.info "Using directory #{config_directory} for settings" - Legion::Settings.load(config_dir: config_directory) - Legion::Logging.info('Legion::Settings Loaded') + Legion::Settings[:api] ||= {} + Legion::Settings[:api][:port] = http_port + log.info "CLI override: API port set to #{http_port}" end def setup_logging(log_level: 'info', **_opts) @@ -75,56 +313,954 @@ def setup_logging(log_level: 'info', **_opts) Legion::Logging.setup(log_level: log_level, level: log_level, trace: true) end + def reconfigure_logging(cli_level = nil) + ls = Legion::Settings[:logging] || {} + level = if cli_level.respond_to?(:empty?) && cli_level.empty? + nil + else + cli_level + end + level ||= ls[:level] || 'info' + + Legion::Logging.setup( + level: level, + format: (ls[:format] || 'text').to_sym, + log_file: ls[:log_file], + log_stdout: ls.fetch(:log_stdout, true), + trace: ls.fetch(:trace, true), + async: ls.fetch(:async, true), + include_pid: ls.fetch(:include_pid, false), + color: true + ) + end + + def setup_apm + apm_settings = Legion::Settings.dig(:api, :elastic_apm) || {} + return unless apm_settings[:enabled] + + require 'elastic-apm' + + config = build_apm_config(apm_settings) + ElasticAPM.start(**config) + @apm_running = true + log.info "Elastic APM started: server=#{config[:server_url]} service=#{config[:service_name]}" + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.setup_apm', availability: 'missing') + log.info 'elastic-apm gem is not installed, starting without APM' + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_apm') + end + + def shutdown_apm + return unless @apm_running + + ElasticAPM.stop if defined?(ElasticAPM) && ElasticAPM.running? + @apm_running = false + log.info 'Elastic APM stopped' + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.shutdown_apm') + end + + def setup_api + if @api_thread&.alive? + log.warn 'API already running, skipping duplicate setup_api call' + return + end + + require 'legion/api' + api_settings = Legion::Settings[:api] + port = api_settings[:port] + bind = api_settings[:bind] + + Legion::API.set :port, port + Legion::API.set :bind, bind + Legion::API.set :server, :puma + Legion::API.set :environment, :production + + puma_cfg = api_settings[:puma] + min_threads = puma_cfg[:min_threads] + max_threads = puma_cfg[:max_threads] + thread_spec = "#{min_threads}:#{max_threads}" + puma_timeouts = { + persistent_timeout: puma_cfg[:persistent_timeout], + first_data_timeout: puma_cfg[:first_data_timeout] + }.compact + + tls_cfg = build_api_tls_config(api_settings) + if tls_cfg + Legion::API.set :ssl_bind_options, tls_cfg + Legion::API.set :server_settings, { quiet: true, Threads: thread_spec, **puma_timeouts, + **ssl_server_settings(tls_cfg, bind, port) } + log.info "Starting Legion API (TLS) on #{bind}:#{port}" + else + require 'puma' + puma_log = ::Puma::LogWriter.new(StringIO.new, StringIO.new) + Legion::API.set :server_settings, { log_writer: puma_log, quiet: true, Threads: thread_spec, **puma_timeouts } + log.info "Starting Legion API on #{bind}:#{port}" + end + + # Mount identity middleware — bridges legion.auth to legion.principal. + # Identity MUST be mounted before RBAC so env['legion.rbac_principal'] is + # populated before the RBAC middleware reads it. + if defined?(Legion::Identity::Middleware) + require_auth = Legion::Identity::Middleware.require_auth?(bind: bind, mode: Legion::Mode.current) + Legion::API.use Legion::Identity::Middleware, require_auth: require_auth + end + + # Mount RBAC middleware after Identity — reads env['legion.rbac_principal'] + # set by Identity::Middleware above. Only mount when a compatible RBAC + # integration is present and enabled to avoid mixed-version request + # failures. + if defined?(Legion::Rbac::Middleware) && + defined?(Legion::Rbac::Principal) && + Legion::Rbac.respond_to?(:enabled?) && + Legion::Rbac.enabled? + Legion::API.use Legion::Rbac::Middleware + end + + @api_thread = Thread.new do + retries = 0 + max_retries = api_settings[:bind_retries] + retry_wait = api_settings[:bind_retry_wait] + + begin + raise Errno::EADDRINUSE, "port #{port} already bound" if port_in_use?(bind, port) + + Legion::API.run!(traps: false) + rescue Errno::EADDRINUSE + retries += 1 + if retries <= max_retries + log.warn "Port #{port} in use, retrying in #{retry_wait}s (attempt #{retries}/#{max_retries})" + sleep retry_wait + retry + else + log.error "Port #{port} still in use after #{max_retries} attempts, API disabled" + Legion::Readiness.mark_not_ready(:api) + end + ensure + Legion::Process.quit_flag&.make_true if !@shutdown && defined?(Legion::Process) + end + end + Legion::Readiness.mark_ready(:api) + rescue LoadError => e + handle_exception(e, level: :warn, operation: 'service.setup_api', dependency: 'api') + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_api') + end + + def setup_llm + log.info 'Setting up Legion::LLM' + require 'legion/llm' + Legion::Settings.merge_settings('llm', Legion::LLM::Settings.default) + Legion::Settings.loader.settings[:llm][:api][:use_namespaces] = true + preload_llm_providers + Legion::LLM.start + log.info 'Legion::LLM started' + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.setup_llm', availability: 'missing') + log.info 'Legion::LLM gem is not installed, starting without LLM support' + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_llm') + end + + def preload_llm_providers + require 'legion/extensions/llm' + gems = llm_provider_gems + gems.each do |gem_name, require_path| + require require_path + log.debug "[service] loaded #{gem_name}" + rescue LoadError => e + log.warn "[service] #{gem_name} failed to load: #{e.message}" + rescue StandardError => e + handle_exception(e, level: :warn, operation: "service.preload_llm_provider.#{gem_name}") + end + registered = defined?(Legion::LLM::Call::Registry) ? Legion::LLM::Call::Registry.all_instances : [] + log.info "[service] llm providers preloaded gems=#{gems.size} instances=#{registered.size}" + rescue LoadError => e + handle_exception(e, level: :warn, operation: 'service.preload_llm_providers', availability: 'lex-llm not installed') + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.preload_llm_providers') + end + + def llm_provider_gems + specs = if defined?(Bundler) + Bundler.load.specs.map { |s| s.respond_to?(:name) ? s.name : s[:name].to_s } + else + Gem::Specification.latest_specs.map(&:name) + end + specs.filter_map do |name| + next unless name.start_with?('lex-llm-') && name != 'lex-llm-ledger' + + provider_name = name.delete_prefix('lex-llm-').tr('-', '_') + require_path = "legion/extensions/llm/#{provider_name}" + [name, require_path] + end + end + + def setup_gaia + log.info 'Setting up Legion::Gaia' + require 'legion/gaia' + Legion::Settings.merge_settings('gaia', Legion::Gaia::Settings.default) + Legion::Gaia.boot + log.info 'Legion::Gaia booted' + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.setup_gaia', availability: 'missing') + log.info 'Legion::Gaia gem is not installed, starting without cognitive layer' + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_gaia') + end + + def setup_apollo + log.info 'Setting up Legion::Apollo' + require 'legion/apollo' + Legion::Apollo.start + Legion::Apollo::Local.start if defined?(Legion::Apollo::Local) + log.info 'Legion::Apollo started' + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.setup_apollo', availability: 'missing') + log.info 'Legion::Apollo gem is not installed, starting without Apollo' + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_apollo') + end + + def setup_dispatch + require 'legion/dispatch' + Legion::Dispatch.dispatcher.start + log.info "[Service] Dispatch started (strategy: #{Legion::Dispatch.dispatcher.class.name})" + end + def setup_transport + log.info 'Setting up Legion::Transport' require 'legion/transport' Legion::Settings.merge_settings('transport', Legion::Transport::Settings.default) Legion::Transport::Connection.setup + log.info 'Legion::Transport connected' + end + + def setup_identity + require_relative 'identity/process' + require_relative 'identity/broker' + require_relative 'identity/lease' + require_relative 'identity/lease_renewer' + require_relative 'identity/request' + require_relative 'identity/middleware' + + # Resolve identity from available providers (Phase 4 adds real providers) + require_relative 'identity' unless defined?(Legion::Identity::Resolver) + + Legion::Identity::Resolver.resolve! + + unless Legion::Identity::Resolver.resolved? + Legion::Identity::Process.bind_fallback! + log.info "[Identity] fallback identity: #{Legion::Identity::Process.canonical_name}" + end + + # Phase 5: Swap from bootstrap RMQ credentials to identity-scoped credentials. + # Gate on vault_connected? + dynamic_rmq_creds? — NOT on resolved? (fallback identity + # still needs scoped creds via the mode-based role). + if defined?(Legion::Crypt) && + Legion::Crypt.respond_to?(:vault_connected?) && Legion::Crypt.vault_connected? && + Legion::Crypt.respond_to?(:dynamic_rmq_creds?) && Legion::Crypt.dynamic_rmq_creds? && + Legion::Crypt.respond_to?(:swap_to_identity_creds) && + !Legion::Mode.lite? + log.info '[Identity] swapping to identity-scoped RMQ credentials' + Legion::Crypt.swap_to_identity_creds(mode: Legion::Mode.current) + end + + # Re-resolve secrets for any identity-scoped lease:// refs (task 2.25) + Legion::Settings.resolve_secrets! if Legion::Settings.respond_to?(:resolve_secrets!) + + # Fire-and-forget JWKS prefetch + jwks_url = Legion::Settings.dig(:identity, :jwks_endpoint) || Legion::Settings.dig(:crypt, :jwt, :jwks_endpoint) + if jwks_url && defined?(Legion::Crypt::JwksClient) + Legion::Crypt::JwksClient.prefetch!(jwks_url) + Legion::Crypt::JwksClient.start_background_refresh!(jwks_url) + end + + log.info "[Identity] resolved=#{Legion::Identity::Process.resolved?} mode=#{Legion::Mode.current} queue_prefix=#{Legion::Identity::Process.queue_prefix}" + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_identity') + Legion::Identity::Process.bind_fallback! if defined?(Legion::Identity::Process) && !Legion::Identity::Process.resolved? + ensure + Legion::Readiness.mark_ready(:identity) + end + + def setup_logging_transport + return unless defined?(Legion::Transport::Connection) + return unless Legion::Transport::Connection.session_open? + + lt_settings = begin + Legion::Settings.dig(:logging, :transport) || {} + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'service.setup_logging_transport.read_settings') + {} + end + return unless lt_settings[:enabled] == true + + forward_logs = lt_settings.fetch(:forward_logs, true) + forward_exceptions = lt_settings.fetch(:forward_exceptions, true) + return unless forward_logs || forward_exceptions + + log_session = Legion::Transport::Connection.create_dedicated_session(name: 'legion-logging') + @log_session = log_session + log_channel = log_session.create_channel + log_channel.prefetch(1) + exchange = log_channel.topic('legion.logging', durable: true) + + if forward_logs + Legion::Logging.log_writer = lambda { |event, routing_key:, headers: {}, properties: {}| + begin + next unless log_channel&.open? + + exchange.publish( + Legion::JSON.dump(event), + routing_key: routing_key, + headers: headers, + **properties + ) + rescue StandardError + nil + end + } + end + + if forward_exceptions + Legion::Logging.exception_writer = lambda { |event, routing_key:, headers:, properties:| + begin + next unless log_channel&.open? + + exchange.publish( + Legion::JSON.dump(event), + routing_key: routing_key, + headers: headers, + **properties + ) + rescue StandardError + nil + end + } + end + + modes = [] + modes << 'logs' if forward_logs + modes << 'exceptions' if forward_exceptions + log.info("Logging transport wired: #{modes.join(' + ')} (dedicated session)") + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_logging_transport') + teardown_logging_transport + end + + def teardown_logging_transport + Legion::Logging.log_writer = nil + Legion::Logging.exception_writer = nil + @log_session&.close if @log_session.respond_to?(:close) && + (!@log_session.respond_to?(:open?) || @log_session.open?) + @log_session = nil + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'service.teardown_logging_transport') + nil + end + + def setup_alerts + alerts_settings = Legion::Settings[:alerts] + enabled = alerts_settings.is_a?(Hash) ? alerts_settings[:enabled] : false + return unless enabled + + require 'legion/alerts' + Legion::Alerts.setup + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_alerts') + end + + def setup_metrics + require 'legion/metrics' + Legion::Metrics.setup + log.debug 'Legion::Metrics initialized' + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_metrics') + end + + def setup_task_outcome_observer + require_relative 'task_outcome_observer' + return unless Legion::TaskOutcomeObserver.enabled? + + Legion::TaskOutcomeObserver.setup + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_task_outcome_observer') + end + + def setup_telemetry + return unless begin + Legion::Settings.dig(:telemetry, :enabled) + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'service.setup_telemetry.read_enabled') + false + end + + require 'opentelemetry/sdk' + require 'opentelemetry-exporter-otlp' + require_relative 'telemetry' + + endpoint = Legion::Settings.dig(:telemetry, :otlp_endpoint) || 'http://localhost:4318' + service_name = "legion-#{Legion::Settings[:client][:name]}" + + OpenTelemetry::SDK.configure do |c| + c.service_name = service_name + c.service_version = Legion::VERSION + c.add_span_processor( + OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new( + OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint: endpoint) + ) + ) + end + + log.info "OpenTelemetry initialized: endpoint=#{endpoint} service=#{service_name}" + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.setup_telemetry', availability: 'missing') + log.info 'OpenTelemetry gems not installed, starting without telemetry' + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_telemetry', endpoint: endpoint, service_name: service_name) + end + + def setup_audit_archiver + require_relative 'audit/archiver_actor' + return unless Legion::Audit::ArchiverActor.enabled? + + @audit_archiver_thread = Thread.new do + loop do + Legion::Audit::ArchiverActor.new.run_archival + rescue StandardError => e + handle_exception(e, level: :error, operation: 'service.audit_archiver.run') + ensure + sleep Legion::Audit::ArchiverActor::INTERVAL_SECONDS + end + end + @audit_archiver_thread.abort_on_exception = false + log.info 'Audit archiver actor started' + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_audit_archiver') + end + + def shutdown_audit_archiver + @audit_archiver_thread&.kill + @audit_archiver_thread = nil + end + + def setup_safety_metrics + require_relative 'telemetry/safety_metrics' + Legion::Telemetry::SafetyMetrics.start + rescue LoadError => e + handle_exception(e, level: :debug, operation: 'service.setup_safety_metrics', availability: 'missing') + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'service.setup_safety_metrics') end def setup_supervision + log.info 'Setting up Legion::Supervision' require 'legion/supervision' @supervision = Legion::Supervision.setup + log.info 'Legion::Supervision started' + end + + def shutdown_api + return unless @api_thread + + Legion::API.quit! if defined?(Legion::API) && Legion::API.running? + @api_thread.kill + @api_thread = nil + Legion::Readiness.mark_not_ready(:api) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.shutdown_api') end def shutdown - Legion::Logging.info('Legion::Service.shutdown was called') + log.info('Legion::Service.shutdown was called') @shutdown = true Legion::Settings[:client][:shutting_down] = true - sleep(0.5) - Legion::Extensions.shutdown - sleep(1) - Legion::Data.shutdown if Legion::Settings[:data][:connected] - Legion::Cache.shutdown - Legion::Transport::Connection.shutdown - Legion::Crypt.shutdown - end - - def reload - Legion::Logging.info 'Legion::Service.reload was called' - Legion::Extensions.shutdown - sleep(1) - Legion::Data.shutdown - Legion::Cache.shutdown - Legion::Transport::Connection.shutdown - Legion::Crypt.shutdown + Legion::Events.emit('service.shutting_down') + + shutdown_network_watchdog + shutdown_audit_archiver + shutdown_api + shutdown_apm + + Legion::Metrics.reset! if defined?(Legion::Metrics) + + if defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:started?) && Legion::Gaia.started? + shutdown_component('Gaia') { Legion::Gaia.shutdown } + Legion::Readiness.mark_not_ready(:gaia) + end + + if @cluster_leader + @cluster_leader.stop + @cluster_leader = nil + end + + shutdown_component('Dispatch') { Legion::Dispatch.shutdown } if defined?(Legion::Dispatch) + + Legion::Tools::Registry.clear if defined?(Legion::Tools::Registry) + + ext_timeout = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15 + shutdown_component('Extensions', timeout: ext_timeout) { Legion::Extensions.shutdown } + Legion::Readiness.mark_not_ready(:extensions) + + if Legion::Settings[:llm]&.dig(:connected) + shutdown_component('LLM') { Legion::LLM.shutdown } + Legion::Readiness.mark_not_ready(:llm) + end + + if defined?(Legion::Rbac) && Legion::Settings[:rbac]&.dig(:connected) + shutdown_component('Rbac') { Legion::Rbac.shutdown } + Legion::Readiness.mark_not_ready(:rbac) + end + + shutdown_component('Data') { Legion::Data.shutdown } if Legion::Settings[:data][:connected] + Legion::Readiness.mark_not_ready(:data) + + Legion::Leader.reset! if defined?(Legion::Leader) + + shutdown_component('Cache') { Legion::Cache.shutdown } + Legion::Readiness.mark_not_ready(:cache) + + # Identity: cooperative shutdown of Broker (stops all LeaseRenewer threads) + if defined?(Legion::Identity::Broker) + shutdown_component('Identity::Broker') { Legion::Identity::Broker.shutdown } + Legion::Readiness.mark_not_ready(:identity) + end + + # Stop JWKS background refresh + if defined?(Legion::Crypt::JwksClient) && Legion::Crypt::JwksClient.respond_to?(:stop_background_refresh!) + Legion::Crypt::JwksClient.stop_background_refresh! + end + + teardown_logging_transport + shutdown_component('Transport') { Legion::Transport::Connection.shutdown } + Legion::Readiness.mark_not_ready(:transport) + + shutdown_mtls_rotation + # Phase 5: Revoke bootstrap RMQ lease on clean shutdown (defense-in-depth; + # lease expires naturally if process crashes before identity swap). + shutdown_component('Crypt bootstrap lease') do + Legion::Crypt.revoke_bootstrap_lease if defined?(Legion::Crypt) && Legion::Crypt.respond_to?(:revoke_bootstrap_lease) + end + shutdown_component('Crypt') { Legion::Crypt.shutdown if defined?(Legion::Crypt) } + Legion::Readiness.mark_not_ready(:crypt) + Legion::Settings[:client][:ready] = false + Legion::Events.emit('service.shutdown') + end + + def reload # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + return if @reloading + + @reloading = true + log.info 'Legion::Service.reload was called' + Legion::Settings[:client][:ready] = false + + shutdown_network_watchdog + shutdown_api + shutdown_apm + + if defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:started?) && Legion::Gaia.started? + shutdown_component('Gaia') { Legion::Gaia.shutdown } + Legion::Readiness.mark_not_ready(:gaia) + end + + Legion::Tools::Registry.clear if defined?(Legion::Tools::Registry) + Legion::Tools::EmbeddingCache.clear_memory if defined?(Legion::Tools::EmbeddingCache) && Legion::Tools::EmbeddingCache.respond_to?(:clear_memory) + + ext_timeout = Legion::Settings.dig(:extensions, :shutdown_timeout) || 15 + shutdown_component('Extensions', timeout: ext_timeout) { Legion::Extensions.shutdown } + Legion::Readiness.mark_not_ready(:extensions) + + shutdown_component('Data') { Legion::Data.shutdown } + Legion::Readiness.mark_not_ready(:data) + + shutdown_component('Cache') { Legion::Cache.shutdown } + Legion::Readiness.mark_not_ready(:cache) + + teardown_logging_transport + shutdown_component('Transport') { Legion::Transport::Connection.shutdown } + Legion::Readiness.mark_not_ready(:transport) + + shutdown_component('Crypt') { Legion::Crypt.shutdown if defined?(Legion::Crypt) } + Legion::Readiness.mark_not_ready(:crypt) + + Legion::Readiness.wait_until_not_ready(:transport, :data, :cache, :crypt) + + Legion::Settings.load(force: true, config_dirs: Legion::Settings::Loader.default_directories.select { |d| Dir.exist?(d) }) + Legion::Readiness.mark_ready(:settings) + + Legion::Crypt.start if defined?(Legion::Crypt) + Legion::Readiness.mark_ready(:crypt) if defined?(Legion::Crypt) + # Phase 5: fetch bootstrap RMQ creds after Vault reconnects on reload. + fetch_phase5_bootstrap_creds unless Legion::Mode.lite? + + # Resolve lease:// URIs with freshly loaded settings + new Vault token. + Legion::Settings.resolve_secrets! if Legion::Settings.respond_to?(:resolve_secrets!) - sleep(5) - setup_settings - Legion::Crypt.start setup_transport + Legion::Readiness.mark_ready(:transport) + teardown_logging_transport + setup_logging_transport + + Legion::Identity::Process.refresh_credentials if defined?(Legion::Identity::Process) + + require 'legion/cache' unless defined?(Legion::Cache) + Legion::Cache.setup + Legion::Readiness.mark_ready(:cache) + setup_data + Legion::Readiness.mark_ready(:data) + + if defined?(Legion::Rbac) + setup_rbac + else + Legion::Readiness.mark_skipped(:rbac) + end + + setup_identity_before_llm(extensions: true, transport: true) + + if defined?(Legion::LLM) + setup_llm + else + Legion::Readiness.mark_skipped(:llm) + end + + if defined?(Legion::Apollo) + setup_apollo + Legion::Readiness.mark_ready(:apollo) + else + Legion::Readiness.mark_skipped(:apollo) + end + + if defined?(Legion::Gaia) + setup_gaia + Legion::Readiness.mark_ready(:gaia) + else + Legion::Readiness.mark_skipped(:gaia) + end + setup_supervision load_extensions + Legion::Readiness.mark_ready(:extensions) + + # Phase 5: re-run identity resolution after extensions are loaded so that + # any identity providers registered by lex-identity-* extensions are + # available to the resolver (mirrors the boot-time ordering). + setup_identity - Legion::Crypt.cs + db_available = defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected? + transport_available = defined?(Legion::Transport::Connection) && Legion::Transport::Connection.respond_to?(:session_open?) && Legion::Transport::Connection.session_open? + register_credential_providers if transport_available || db_available + Legion::Extensions.flush_pending_registrations! if defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:flush_pending_registrations!) + + register_core_tools + + Legion::Crypt.cs if defined?(Legion::Crypt) + setup_apm if @api_enabled + setup_api if @api_enabled + + if defined?(Legion::MCP) + Legion::MCP.reset! + Legion::MCP.server if Legion::MCP.respond_to?(:server) + end + setup_network_watchdog Legion::Settings[:client][:ready] = true - Legion::Logging.info 'Legion has been reloaded' + Legion::Events.emit('service.ready') + log.info 'Legion has been reloaded' + ensure + @reloading = false end def load_extensions require 'legion/runner' Legion::Extensions.hook_extensions end + + def setup_identity_before_llm(extensions:, transport:) + require_relative 'identity' if File.exist?(File.expand_path('identity.rb', __dir__)) + Legion::Extensions.require_identity_extensions if extensions && + defined?(Legion::Extensions) && + Legion::Extensions.respond_to?(:require_identity_extensions) + + db_available = defined?(Legion::Data) && Legion::Data.respond_to?(:connected?) && Legion::Data.connected? + setup_identity if transport || db_available + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_identity_before_llm') + end + + def register_core_tools + require 'legion/tools' + Legion::Tools.register_all + Legion::Tools::Discovery.discover_and_register + future = Legion::Tools::TriggerIndex.build_async! + if future.respond_to?(:rescue) + @trigger_index_build_future = future.rescue do |e| + handle_exception(e, level: :warn, operation: 'service.register_core_tools.trigger_index_build') + nil + end + end + Legion::Tools::EmbeddingCache.setup + + log.info( + "Tools registered: #{Legion::Tools::Registry.tools.size} always, " \ + "#{Legion::Tools::Registry.deferred_tools.size} deferred" + ) + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.register_core_tools') + end + + def setup_generated_functions + return unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry) + + loaded = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.load_on_boot + log.info("Loaded #{loaded} generated functions") if loaded.to_i.positive? + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_generated_functions') + end + + def setup_mtls_rotation + enabled = Legion::Settings[:security]&.dig(:mtls, :enabled) + return unless enabled + + unless defined?(Legion::Crypt::CertRotation) + require 'legion/crypt/mtls' + require 'legion/crypt/cert_rotation' + end + return unless defined?(Legion::Crypt::CertRotation) + + @cert_rotation = Legion::Crypt::CertRotation.new + @cert_rotation.start + log.info '[mTLS] CertRotation started' + rescue LoadError => e + handle_exception(e, level: :warn, operation: 'service.setup_mtls_rotation', availability: 'missing') + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_mtls_rotation') + end + + def shutdown_mtls_rotation + return unless @cert_rotation + + @cert_rotation.stop + @cert_rotation = nil + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.shutdown_mtls_rotation') + end + + def self.log_privacy_mode_status + privacy = if Legion.const_defined?('Settings') && Legion::Settings.respond_to?(:enterprise_privacy?) + Legion::Settings.enterprise_privacy? + else + ENV['LEGION_ENTERPRISE_PRIVACY'] == 'true' + end + + message = if privacy + 'enterprise_data_privacy enabled: cloud LLM blocked, telemetry suppressed' + else + 'enterprise_data_privacy disabled: all tiers available' + end + + if Legion.const_defined?('Logging') + log.info(message) + else + $stdout.puts "[Legion] #{message}" + end + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'service.log_privacy_mode_status') if defined?(Legion::Logging) + nil + end + + def shutdown_component(name, timeout: 5, &) + Timeout.timeout(timeout, &) + rescue Timeout::Error + log.warn "#{name} shutdown timed out after #{timeout}s, forcing" + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.shutdown_component', component: name, timeout: timeout) + end + + def setup_network_watchdog + return unless Legion::Settings.dig(:network, :watchdog, :enabled) + + @consecutive_failures = Concurrent::AtomicFixnum.new(0) + threshold = Legion::Settings.dig(:network, :watchdog, :failure_threshold) || 5 + interval = Legion::Settings.dig(:network, :watchdog, :check_interval) || 15 + + @network_watchdog = Concurrent::TimerTask.new(execution_interval: interval) do + if network_healthy? + prev = @consecutive_failures.value + @consecutive_failures.value = 0 + if prev >= threshold + log.info '[Watchdog] Network restored, triggering reload' + Thread.new { Legion.reload } unless @reloading + end + else + count = @consecutive_failures.increment + log.warn "[Watchdog] Network check failed (#{count}/#{threshold})" + if count == threshold + log.error '[Watchdog] Network failure threshold reached, pausing actors' + Legion::Extensions.pause_actors if Legion::Extensions.respond_to?(:pause_actors) + end + end + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'service.network_watchdog.check') + end + @network_watchdog.execute + log.info "[Watchdog] Network watchdog started (interval=#{interval}s, threshold=#{threshold})" + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.setup_network_watchdog') + end + + def shutdown_network_watchdog + @network_watchdog&.shutdown + @network_watchdog = nil + end + + def network_healthy? + return true if defined?(Legion::Transport::Connection) && Legion::Transport::Connection.lite_mode? + + checks = [] + checks << Legion::Transport::Connection.session_open? if Legion::Settings[:transport][:connected] + if Legion::Settings[:data][:connected] && defined?(Legion::Data::Connection) + checks << (Legion::Data::Connection.sequel&.test_connection rescue false) # rubocop:disable Style/RescueModifier + end + checks << Legion::Cache.connected? if Legion::Settings[:cache][:connected] && defined?(Legion::Cache) + return true if checks.empty? + + checks.any? + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'service.network_healthy?') + false + end + + private + + # Phase 5: fetch short-lived bootstrap RMQ credentials from Vault. + # Called after Crypt.start (boot) and after Crypt.start (reload). + # Service owns the gate so Crypt.fetch_bootstrap_rmq_creds can be unconditional. + def fetch_phase5_bootstrap_creds + return unless defined?(Legion::Crypt) + return unless Legion::Crypt.respond_to?(:fetch_bootstrap_rmq_creds) + return unless Legion::Crypt.respond_to?(:vault_connected?) && Legion::Crypt.vault_connected? + return unless Legion::Crypt.respond_to?(:dynamic_rmq_creds?) && Legion::Crypt.dynamic_rmq_creds? + + Legion::Crypt.fetch_bootstrap_rmq_creds + end + + def register_credential_providers + return unless defined?(Legion::Identity::Broker) && defined?(Legion::Extensions) + + Legion::Extensions.loaded_extension_modules.each do |ext| + identity_mod = find_credential_identity(ext) + next unless identity_mod + + name = identity_mod.provider_name + next if Legion::Identity::Broker.providers.include?(name) + + lease = identity_mod.provide_token + next unless lease + + Legion::Identity::Broker.register_provider(name, provider: identity_mod, lease: lease) + log.info "[Identity] registered credential provider #{name} with Broker" + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'service.register_credential_providers') + end + end + + def find_credential_identity(ext) + return nil unless ext.respond_to?(:const_defined?) && ext.const_defined?(:Identity, false) + + identity = ext.const_get(:Identity, false) + return nil unless identity.respond_to?(:provider_type) && identity.provider_type == :credential + return nil unless identity.respond_to?(:provide_token) + + identity + end + + def bootstrap_log_level(cli_level) + cli_level = nil if cli_level.respond_to?(:empty?) && cli_level.empty? + return cli_level if cli_level + + raw_logging = (Legion::Settings[:logging] if defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])) + + level = raw_logging[:level] if raw_logging.is_a?(Hash) + level || Legion::Logging::Settings.default[:level] || 'info' + end + + def resolve_logger_settings + raw_logging = (Legion::Settings[:logging] if defined?(Legion::Settings) && Legion::Settings.respond_to?(:[])) + raw_logging.is_a?(Hash) ? raw_logging : Legion::Logging::Settings.default + end + + def port_in_use?(bind, port) + TCPServer.new(bind, port).close + false + rescue Errno::EADDRINUSE + true + end + + def build_api_tls_config(api_settings) + tls = api_settings[:tls] || {} + tls = tls.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } + return nil unless tls[:enabled] == true + + cert = tls[:cert] + key = tls[:key] + + unless cert && !cert.to_s.empty? && key && !key.to_s.empty? + log.warn 'api.tls enabled but cert or key is missing — falling back to plain HTTP' + return nil + end + + { + cert: cert, + key: key, + ca: tls[:ca], + verify_mode: verify_mode_for(tls[:verify]) + }.compact + end + + def build_apm_config(apm) + { + server_url: apm[:server_url] || 'http://localhost:8200', + api_key: apm[:api_key], + secret_token: apm[:secret_token], + api_buffer_size: apm[:api_buffer_size] || 256, + api_request_size: apm[:api_request_size] || '750kb', + api_request_time: apm[:api_request_time] || '10s', + capture_body: apm.fetch(:capture_body, 'off'), + capture_headers: apm.fetch(:capture_headers, true), + capture_env: apm.fetch(:capture_env, true), + disable_send: apm.fetch(:disable_send, false), + environment: apm[:environment] || Legion::Settings[:environment] || 'development', + framework_name: 'LegionIO', + framework_version: Legion::VERSION, + hostname: apm[:hostname] || Legion::Settings[:client][:name], + ignore_url_patterns: apm[:ignore_url_patterns] || %w[/api/health /api/ready], + logger: Legion::Logging.log, + pool_size: apm[:pool_size] || 1, + service_name: apm[:service_name] || 'LegionIO', + service_node_name: apm[:service_node_name] || Legion::Settings[:client][:name], + service_version: apm[:service_version] || Legion::VERSION, + transaction_sample_rate: apm[:sample_rate] || 1.0, + verify_server_cert: apm.fetch(:verify_server_cert, true), + central_config: apm.fetch(:central_config, true), + span_frames_min_duration: apm[:span_frames_min_duration] + }.compact + end + + def ssl_server_settings(tls_cfg, bind, port) + return {} unless tls_cfg + + { binds: ["ssl://#{bind}:#{port}?cert=#{tls_cfg[:cert]}&key=#{tls_cfg[:key]}"] } + end + + def verify_mode_for(verify) + case verify.to_s + when 'none' then 'none' + when 'mutual' then 'force_peer' + else 'peer' + end + end end end diff --git a/lib/legion/supervision.rb b/lib/legion/supervision.rb index 131360b0..d8a310ff 100755 --- a/lib/legion/supervision.rb +++ b/lib/legion/supervision.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion module Supervision class << self diff --git a/lib/legion/task_outcome_observer.rb b/lib/legion/task_outcome_observer.rb new file mode 100644 index 00000000..548bc1d1 --- /dev/null +++ b/lib/legion/task_outcome_observer.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +module Legion + module TaskOutcomeObserver + class << self + def setup + return unless enabled? + + Legion::Events.on('task.completed') do |payload| + handle_outcome(payload, success: true) + end + + Legion::Events.on('task.failed') do |payload| + handle_outcome(payload, success: false) + end + + setup_llm_reflection_hook + Legion::Logging.info '[TaskOutcomeObserver] wired to task.completed and task.failed' + rescue StandardError => e + Legion::Logging.warn "[TaskOutcomeObserver] setup failed: #{e.message}" if defined?(Legion::Logging) + end + + def enabled? + settings = begin + Legion::Settings[:task_outcome_observer] + rescue StandardError + nil + end + return true unless settings.is_a?(Hash) + + settings.fetch(:enabled, true) + end + + private + + def handle_outcome(payload, success:) + return unless observable_outcome?(payload) + + runner_class = outcome_value(payload, :runner_class).to_s + function = outcome_value(payload, :function).to_s + domain = derive_domain(runner_class) + + record_learning(domain: domain, success: success) + publish_lesson(runner: runner_class, function: function, success: success) + rescue StandardError => e + Legion::Logging.warn "[TaskOutcomeObserver] handle_outcome error: #{e.class}: #{e.message}" if defined?(Legion::Logging) + end + + def derive_domain(runner_class) + parts = runner_class.split('::') + last = parts.last + return 'unknown' unless last + + last.gsub(/([A-Z])/, '_\1').delete_prefix('_').downcase + end + + def observable_outcome?(payload) + !outcome_value(payload, :task_id).to_s.strip.empty? && + !outcome_value(payload, :runner_class).to_s.strip.empty? && + !outcome_value(payload, :function).to_s.strip.empty? + end + + def outcome_value(payload, key) + return unless payload.respond_to?(:[]) + + payload[key] || payload[key.to_s] + end + + def record_learning(domain:, success:) + client = meta_learning_client + return unless client + + domain_id = resolve_learning_domain_id(client, domain) + return unless domain_id + + client.record_learning_episode(domain_id: domain_id, success: success) + rescue StandardError => e + Legion::Logging.warn "[TaskOutcomeObserver] record_learning failed: #{e.class}: #{e.message}" if defined?(Legion::Logging) + end + + def publish_lesson(runner:, function:, success:, **_opts) + return unless defined?(Legion::Apollo) && Legion::Apollo.respond_to?(:ingest) + + outcome = success ? 'succeeded' : 'failed' + domain = derive_domain(runner) + + Legion::Apollo.ingest( + content: "task #{runner}##{function} #{outcome}", + tags: ['task_outcome', outcome, domain], + knowledge_domain: 'operational', + source_agent: 'system:task_observer', + is_inference: false + ) + rescue StandardError => e + Legion::Logging.warn "[TaskOutcomeObserver] publish_lesson failed: #{e.class}: #{e.message}" if defined?(Legion::Logging) + end + + def setup_llm_reflection_hook + return unless defined?(Legion::LLM) + + reflection_enabled = begin + Legion::Settings.dig(:llm, :reflection, :enabled) + rescue StandardError + false + end + return unless reflection_enabled + + return unless defined?(Legion::LLM::Hooks::Reflection) + + Legion::LLM::Hooks::Reflection.install + Legion::Logging.info '[TaskOutcomeObserver] LLM reflection hook auto-installed' + rescue StandardError => e + Legion::Logging.warn "[TaskOutcomeObserver] LLM reflection hook install failed: #{e.class}: #{e.message}" if defined?(Legion::Logging) + end + + def meta_learning_client + return unless defined?(Legion::Extensions::Agentic::Learning::MetaLearning::Client) + + @meta_learning_client ||= Legion::Extensions::Agentic::Learning::MetaLearning::Client.new + end + + def resolve_learning_domain_id(client, domain) + domain_map = learning_domain_map + return domain_map[domain] if domain_map.key?(domain) + + result = client.create_learning_domain(name: domain) + return if result.is_a?(Hash) && result[:error] + + domain_id = result[:id] + domain_map[domain] = domain_id if domain_id + domain_id + end + + def learning_domain_map + @learning_domain_map ||= {} + end + end + end +end diff --git a/lib/legion/team.rb b/lib/legion/team.rb new file mode 100644 index 00000000..e4f47b5f --- /dev/null +++ b/lib/legion/team.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'legion/team/cost_attribution' + +module Legion + module Team + class << self + def current + Legion::Settings.dig(:team, :name) || 'default' + end + + def members + Legion::Settings.dig(:team, :members) || [] + end + + def find(name) + teams = Legion::Settings[:teams] || {} + teams[name.to_sym] + end + + def list + (Legion::Settings[:teams] || {}).keys.map(&:to_s) + end + end + end +end diff --git a/lib/legion/team/cost_attribution.rb b/lib/legion/team/cost_attribution.rb new file mode 100644 index 00000000..e56e40e7 --- /dev/null +++ b/lib/legion/team/cost_attribution.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Legion + module Team + module CostAttribution + def self.tag(metadata = {}) + metadata.merge( + team: Legion::Team.current, + user: Legion::Settings.dig(:team, :user) || ENV.fetch('USER', nil) + ) + end + end + end +end diff --git a/lib/legion/telemetry.rb b/lib/legion/telemetry.rb new file mode 100644 index 00000000..482ba7d7 --- /dev/null +++ b/lib/legion/telemetry.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'legion/logging/helper' + +module Legion + module Telemetry + extend Legion::Logging::Helper + + autoload :OpenInference, 'legion/telemetry/open_inference' + autoload :SafetyMetrics, 'legion/telemetry/safety_metrics' + + module_function + + def otel_available? + defined?(OpenTelemetry::Trace) && + OpenTelemetry::Trace.current_span != OpenTelemetry::Trace::Span::INVALID + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'telemetry.otel_available') + false + end + + def enabled? + defined?(OpenTelemetry::SDK) ? true : false + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'telemetry.enabled') + false + end + + def with_span(name, kind: :internal, attributes: {}, &) + unless enabled? + return yield(nil) if block_given? + + return + end + + log.debug { "[Telemetry] starting span=#{name} kind=#{kind}" } + tracer = OpenTelemetry.tracer_provider.tracer('legion', Legion::VERSION) + tracer.in_span(name, kind: kind, attributes: sanitize_attributes(attributes), &) + rescue StandardError => e + raise if block_given? && !otel_init_error?(e) + + handle_exception(e, level: :debug, operation: 'telemetry.with_span', span_name: name, kind: kind) + yield(nil) if block_given? + end + + def record_exception(span, exception) + return unless span.respond_to?(:record_exception) + + span.record_exception(exception) + span.status = OpenTelemetry::Trace::Status.error(exception.message) + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'telemetry.record_exception') + nil + end + + def sanitize_attributes(hash, max_keys: 20) + return {} unless hash.is_a?(Hash) + + hash.first(max_keys).to_h do |k, v| + val = case v + when String, Integer, Float, TrueClass, FalseClass then v + else v.to_s + end + [k.to_s, val] + end + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'telemetry.sanitize_attributes') + {} + end + + def configure_exporter + backend = tracing_settings[:exporter]&.to_sym || :none + + case backend + when :otlp + configure_otlp + when :console + configure_console + end + end + + def tracing_settings + telemetry = Legion::Settings[:telemetry] + return {} unless telemetry.is_a?(Hash) + + tracing = telemetry[:tracing] + tracing.is_a?(Hash) ? tracing : {} + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'telemetry.tracing_settings') + {} + end + + def otel_init_error?(error) + error.message.include?('OpenTelemetry') || error.message.include?('tracer') + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'telemetry.otel_init_error?') + false + end + + def configure_otlp + require 'opentelemetry-exporter-otlp' + + endpoint = tracing_settings[:endpoint] || 'http://localhost:4318/v1/traces' + headers = tracing_settings[:headers] || {} + + exporter = OpenTelemetry::Exporter::OTLP::Exporter.new( + endpoint: endpoint, + headers: headers + ) + + processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new( + exporter, + max_queue_size: 2048, + max_export_batch_size: tracing_settings[:batch_size] || 512 + ) + + OpenTelemetry.tracer_provider.add_span_processor(processor) + log.info "OTLP exporter configured: #{endpoint}" + true + rescue LoadError + log.warn 'opentelemetry-exporter-otlp gem not available' + false + rescue StandardError => e + handle_exception(e, level: :warn, operation: 'telemetry.configure_otlp', endpoint: endpoint) + false + end + + def configure_console + return false unless defined?(OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter) + + exporter = OpenTelemetry::SDK::Trace::Export::ConsoleSpanExporter.new + processor = OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter) + OpenTelemetry.tracer_provider.add_span_processor(processor) + log.info 'Console telemetry exporter configured' + true + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'telemetry.configure_console') + false + end + end +end diff --git a/lib/legion/telemetry/open_inference.rb b/lib/legion/telemetry/open_inference.rb new file mode 100644 index 00000000..69232db1 --- /dev/null +++ b/lib/legion/telemetry/open_inference.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +module Legion + module Telemetry + module OpenInference + DEFAULT_TRUNCATE = 4096 + + module_function + + def open_inference_enabled? + return false unless Legion::Telemetry.enabled? + + settings = begin + Legion::Settings.dig(:telemetry, :open_inference) + rescue StandardError => e + Legion::Logging.debug "OpenInference#open_inference_enabled? failed to read settings: #{e.message}" if defined?(Legion::Logging) + {} + end + settings.is_a?(Hash) ? settings.fetch(:enabled, true) : true + rescue StandardError => e + Legion::Logging.debug "OpenInference#open_inference_enabled? failed: #{e.message}" if defined?(Legion::Logging) + false + end + + def include_io? + settings = begin + Legion::Settings.dig(:telemetry, :open_inference) + rescue StandardError => e + Legion::Logging.debug "OpenInference#include_io? failed to read settings: #{e.message}" if defined?(Legion::Logging) + {} + end + settings.is_a?(Hash) ? settings.fetch(:include_input_output, true) : true + rescue StandardError => e + Legion::Logging.debug "OpenInference#include_io? failed: #{e.message}" if defined?(Legion::Logging) + true + end + + def truncate_limit + settings = begin + Legion::Settings.dig(:telemetry, :open_inference) + rescue StandardError => e + Legion::Logging.debug "OpenInference#truncate_limit failed to read settings: #{e.message}" if defined?(Legion::Logging) + {} + end + settings.is_a?(Hash) ? settings.fetch(:truncate_values_at, DEFAULT_TRUNCATE) : DEFAULT_TRUNCATE + rescue StandardError => e + Legion::Logging.debug "OpenInference#truncate_limit failed: #{e.message}" if defined?(Legion::Logging) + DEFAULT_TRUNCATE + end + + def llm_span(model:, provider: nil, invocation_params: {}, input: nil) + unless open_inference_enabled? + return yield(nil) if block_given? + + return + end + + attrs = base_attrs('LLM').merge('llm.model_name' => model) + attrs['llm.provider'] = provider if provider + attrs['llm.invocation_parameters'] = invocation_params.to_json unless invocation_params.empty? + attrs['input.value'] = truncate_value(input.to_s) if input && include_io? + attrs.merge!(genai_attrs(model: model, provider: provider)) + + Legion::Telemetry.with_span("llm.#{model}", kind: :client, attributes: attrs) do |span| + result = yield(span) + annotate_llm_result(span, result) if span + result + end + end + + def embedding_span(model:, dimensions: nil, &) + unless open_inference_enabled? + return yield(nil) if block_given? + + return + end + + attrs = base_attrs('EMBEDDING').merge('embedding.model_name' => model) + attrs['embedding.dimensions'] = dimensions if dimensions + attrs.merge!(genai_attrs(model: model, provider: 'embedding')) + + Legion::Telemetry.with_span("embedding.#{model}", kind: :client, attributes: attrs, &) + end + + def tool_span(name:, parameters: {}) + unless open_inference_enabled? + return yield(nil) if block_given? + + return + end + + attrs = base_attrs('TOOL').merge('tool.name' => name) + attrs['tool.parameters'] = parameters.to_json unless parameters.empty? + + Legion::Telemetry.with_span("tool.#{name}", kind: :internal, attributes: attrs) do |span| + result = yield(span) + annotate_output(span, result) if span && include_io? + result + end + end + + def chain_span(type: 'task_chain', relationship_id: nil, &) + unless open_inference_enabled? + return yield(nil) if block_given? + + return + end + + attrs = base_attrs('CHAIN').merge('chain.type' => type) + attrs['chain.relationship_id'] = relationship_id if relationship_id + + Legion::Telemetry.with_span("chain.#{type}", kind: :internal, attributes: attrs, &) + end + + def evaluator_span(template:) + unless open_inference_enabled? + return yield(nil) if block_given? + + return + end + + attrs = base_attrs('EVALUATOR').merge('eval.template' => template) + + Legion::Telemetry.with_span("eval.#{template}", kind: :internal, attributes: attrs) do |span| + result = yield(span) + annotate_eval_result(span, result) if span && result.is_a?(Hash) + result + end + end + + def agent_span(name:, mode: nil, phase_count: nil, budget_ms: nil, &) + unless open_inference_enabled? + return yield(nil) if block_given? + + return + end + + attrs = base_attrs('AGENT').merge('agent.name' => name) + attrs['agent.mode'] = mode.to_s if mode + attrs['agent.phase_count'] = phase_count if phase_count + attrs['agent.budget_ms'] = budget_ms if budget_ms + + Legion::Telemetry.with_span("agent.#{name}", kind: :internal, attributes: attrs, &) + end + + def retriever_span(name:, query: nil, top_k: nil, &) + unless open_inference_enabled? + return yield(nil) if block_given? + + return + end + + attrs = base_attrs('RETRIEVER').merge('retriever.name' => name) + attrs['retriever.top_k'] = top_k if top_k + attrs['input.value'] = truncate_value(query.to_s) if query && include_io? + + Legion::Telemetry.with_span("retriever.#{name}", kind: :client, attributes: attrs, &) + end + + def reranker_span(model:, query: nil, top_k: nil, &) + unless open_inference_enabled? + return yield(nil) if block_given? + + return + end + + attrs = base_attrs('RERANKER').merge('reranker.model_name' => model) + attrs['reranker.top_k'] = top_k if top_k + attrs['input.value'] = truncate_value(query.to_s) if query && include_io? + + Legion::Telemetry.with_span("reranker.#{model}", kind: :internal, attributes: attrs, &) + end + + def guardrail_span(name:, input: nil) + unless open_inference_enabled? + return yield(nil) if block_given? + + return + end + + attrs = base_attrs('GUARDRAIL').merge('guardrail.name' => name) + attrs['input.value'] = truncate_value(input.to_s) if input && include_io? + + Legion::Telemetry.with_span("guardrail.#{name}", kind: :internal, attributes: attrs) do |span| + result = yield(span) + annotate_guardrail_result(span, result) if span && result.is_a?(Hash) + result + end + end + + def truncate_value(str, max: nil) + limit = max || truncate_limit + str.length > limit ? str[0...limit] : str + end + + def genai_attrs(model:, provider: nil) + h = { 'gen_ai.request.model' => model } + h['gen_ai.system'] = provider if provider + h + end + + def base_attrs(kind) + { 'openinference.span.kind' => kind } + end + + def annotate_llm_result(span, result) + return unless span.respond_to?(:set_attribute) && result.is_a?(Hash) + + # OpenInference attributes + span.set_attribute('llm.token_count.prompt', result[:input_tokens]) if result[:input_tokens] + span.set_attribute('llm.token_count.completion', result[:output_tokens]) if result[:output_tokens] + span.set_attribute('output.value', truncate_value(result[:content].to_s)) if include_io? && result[:content] + + # GenAI semantic convention attributes + span.set_attribute('gen_ai.usage.input_tokens', result[:input_tokens]) if result[:input_tokens] + span.set_attribute('gen_ai.usage.output_tokens', result[:output_tokens]) if result[:output_tokens] + span.set_attribute('gen_ai.response.finish_reason', result[:stop_reason].to_s) if result[:stop_reason] + span.set_attribute('gen_ai.response.model', result[:model].to_s) if result[:model] + rescue StandardError => e + Legion::Logging.debug "OpenInference#annotate_llm_result failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def annotate_output(span, result) + return unless span.respond_to?(:set_attribute) + + val = result.is_a?(Hash) ? result.to_json : result.to_s + span.set_attribute('output.value', truncate_value(val)) + rescue StandardError => e + Legion::Logging.debug "OpenInference#annotate_output failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def annotate_eval_result(span, result) + return unless span.respond_to?(:set_attribute) + + span.set_attribute('eval.score', result[:score]) if result[:score] + span.set_attribute('eval.passed', result[:passed]) unless result[:passed].nil? + span.set_attribute('eval.explanation', result[:explanation]) if result[:explanation] + rescue StandardError => e + Legion::Logging.debug "OpenInference#annotate_eval_result failed: #{e.message}" if defined?(Legion::Logging) + nil + end + + def annotate_guardrail_result(span, result) + return unless span.respond_to?(:set_attribute) + + span.set_attribute('guardrail.passed', result[:passed]) unless result[:passed].nil? + span.set_attribute('guardrail.score', result[:score]) unless result[:score].nil? + span.set_attribute('output.value', truncate_value(result[:explanation].to_s)) if include_io? && result[:explanation] + rescue StandardError => e + Legion::Logging.debug "OpenInference#annotate_guardrail_result failed: #{e.message}" if defined?(Legion::Logging) + nil + end + end + end +end diff --git a/lib/legion/telemetry/safety_metrics.rb b/lib/legion/telemetry/safety_metrics.rb new file mode 100644 index 00000000..1cb9eb40 --- /dev/null +++ b/lib/legion/telemetry/safety_metrics.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +module Legion + module Telemetry + class SlidingWindow + def initialize(window_seconds) + @window = window_seconds + @entries = [] + @mutex = Mutex.new + end + + def push(**entry) + @mutex.synchronize do + @entries << entry.merge(at: Time.now) + prune! + end + end + + def count + @mutex.synchronize do + prune! + @entries.size + end + end + + def count_for(**filters) + @mutex.synchronize do + prune! + @entries.count { |e| filters.all? { |k, v| e[k] == v } } + end + end + + def entries_matching(**filters) + @mutex.synchronize do + prune! + @entries.select { |e| filters.all? { |k, v| e[k] == v } } + end + end + + private + + def prune! + cutoff = Time.now - @window + @entries.reject! { |e| e[:at] < cutoff } + end + end + + module SafetyMetrics + WINDOWS = { + actions: 60, + failures: 300, + successes: 300, + confidence: 300 + }.freeze + + module_function + + def start + return unless safety_enabled? + + init_windows + register_prometheus_metrics + subscribe_events + end + + def init_windows + @windows = WINDOWS.transform_values { |secs| SlidingWindow.new(secs) } + end + + def subscribe_events + return unless defined?(Legion::Events) + + Legion::Events.on('ingress.received') { |e| record_action(**e) } + Legion::Events.on('runner.failure') { |e| record_failure(**e) } + Legion::Events.on('runner.success') { |e| record_success(**e) } + Legion::Events.on('rbac.deny') { |e| record_escalation(**e) } + Legion::Events.on('governance.consent_violation') { |e| record_governance(**e) } + Legion::Events.on('privatecore.probe_detected') { |e| record_probe(**e) } + Legion::Events.on('synapse.confidence_update') { |e| record_confidence(**e) } + end + + def record_action(agent_id: 'unknown', **) + @windows[:actions]&.push(agent: agent_id) + end + + def record_failure(agent_id: 'unknown', **) + @windows[:failures]&.push(agent: agent_id, type: :failure) + end + + def record_success(agent_id: 'unknown', **) + @windows[:successes]&.push(agent: agent_id, type: :success) + end + + def record_escalation(agent_id: 'unknown', **) # rubocop:disable Lint/UnusedMethodArgument + @escalation_count = (@escalation_count || 0) + 1 + end + + def record_governance(**) + @governance_count = (@governance_count || 0) + 1 + end + + def record_probe(**) + @probe_count = (@probe_count || 0) + 1 + end + + def record_confidence(agent_id: 'unknown', delta: 0.0, **) + @windows[:confidence]&.push(agent: agent_id, delta: delta) + end + + def actions_per_minute(agent_id) + @windows[:actions]&.count_for(agent: agent_id) || 0 + end + + def tool_failure_ratio(agent_id) + fails = @windows[:failures]&.count_for(agent: agent_id) || 0 + successes = @windows[:successes]&.count_for(agent: agent_id) || 0 + total = fails + successes + total.zero? ? 0.0 : fails.to_f / total + end + + def confidence_drift(agent_id) + entries = @windows[:confidence]&.entries_matching(agent: agent_id) || [] + return 0.0 if entries.empty? + + entries.sum { |e| e[:delta] || 0.0 } / entries.size + end + + def scope_escalation_total + @escalation_count || 0 + end + + def governance_override_total + @governance_count || 0 + end + + def probe_detection_total + @probe_count || 0 + end + + def safety_enabled? + Legion::Settings.dig(:telemetry, :safety, :enabled) + rescue StandardError => e + Legion::Logging.debug "SafetyMetrics#safety_enabled? failed: #{e.message}" if defined?(Legion::Logging) + false + end + + def register_prometheus_metrics + return unless defined?(Legion::Metrics) && Legion::Metrics.respond_to?(:register_gauge) + + Legion::Metrics.register_gauge(:legion_safety_actions_per_minute, + 'Runner invocations per agent per minute') + Legion::Metrics.register_gauge(:legion_safety_tool_failure_ratio, + 'Tool failure percentage over 5m window') + Legion::Metrics.register_gauge(:legion_safety_confidence_drift, + 'Rate of confidence decrease across synapses') + Legion::Metrics.register_counter(:legion_safety_scope_escalation_total, + 'Denied access attempts') + Legion::Metrics.register_counter(:legion_safety_governance_override_total, + 'Governance constraint violations') + Legion::Metrics.register_counter(:legion_safety_probe_detection_total, + 'Detected prompt injection probes') + rescue StandardError => e + Legion::Logging.debug "SafetyMetrics#register_prometheus_metrics failed: #{e.message}" if defined?(Legion::Logging) + nil + end + end + end +end diff --git a/lib/legion/tenant_context.rb b/lib/legion/tenant_context.rb new file mode 100644 index 00000000..7cb98b5d --- /dev/null +++ b/lib/legion/tenant_context.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Legion + module TenantContext + class << self + def current + Thread.current[:legion_tenant_id] + end + + def set(tenant_id) + Thread.current[:legion_tenant_id] = tenant_id + end + + def clear + Thread.current[:legion_tenant_id] = nil + end + + def with(tenant_id) + prev = current + set(tenant_id) + yield + ensure + set(prev) + end + end + end +end diff --git a/lib/legion/tenants.rb b/lib/legion/tenants.rb new file mode 100644 index 00000000..44d7ade1 --- /dev/null +++ b/lib/legion/tenants.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Legion + module Tenants + class << self + def create(tenant_id:, name: nil, max_workers: 10, max_queue_depth: 10_000, **) + return { error: 'tenant_exists' } if find(tenant_id) + + Legion::Data.connection[:tenants].insert( + tenant_id: tenant_id, + name: name || tenant_id, + max_workers: max_workers, + max_queue_depth: max_queue_depth, + status: 'active', + created_at: Time.now.utc, + updated_at: Time.now.utc + ) + { created: true, tenant_id: tenant_id } + end + + def find(tenant_id) + Legion::Data.connection[:tenants].where(tenant_id: tenant_id).first + rescue StandardError => e + Legion::Logging.debug("Tenants#find failed: #{e.message}") if defined?(Legion::Logging) + nil + end + + def suspend(tenant_id:, **) + Legion::Data.connection[:tenants] + .where(tenant_id: tenant_id) + .update(status: 'suspended', updated_at: Time.now.utc) + { suspended: true, tenant_id: tenant_id } + end + + def list(**) + Legion::Data.connection[:tenants].all + end + + def check_quota(tenant_id:, resource:, **) + tenant = find(tenant_id) + return { allowed: true } unless tenant + + case resource + when :workers + count = Legion::Data.connection[:digital_workers].where(tenant_id: tenant_id).count + { allowed: count < tenant[:max_workers], current: count, limit: tenant[:max_workers] } + else + { allowed: true } + end + end + end + end +end diff --git a/lib/legion/tools.rb b/lib/legion/tools.rb new file mode 100644 index 00000000..0ffc5230 --- /dev/null +++ b/lib/legion/tools.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Legion + module Tools + @tool_classes = [] + @mutex = Mutex.new + + class << self + def tool_classes + @mutex.synchronize { @tool_classes.dup } + end + + def register_class(klass) + @mutex.synchronize do + @tool_classes << klass unless @tool_classes.include?(klass) + end + end + + def register_all + @mutex.synchronize { @tool_classes.dup }.each do |klass| + Legion::Tools::Registry.register(klass) + end + end + end + end +end + +require_relative 'tools/registry' +require_relative 'tools/base' +require_relative 'tools/discovery' +require_relative 'tools/embedding_cache' +require_relative 'tools/trigger_index' + +Dir[File.join(__dir__, 'tools', '*.rb')].each do |f| + require f unless f.end_with?('/base.rb', '/registry.rb', '/discovery.rb', '/embedding_cache.rb', + '/trigger_index.rb') +end diff --git a/lib/legion/tools/base.rb b/lib/legion/tools/base.rb new file mode 100644 index 00000000..77ca2d33 --- /dev/null +++ b/lib/legion/tools/base.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Legion + module Tools + class Base + class << self + # Lazy delegation instead of include Helper — Base loads at require time + # before Settings is initialized; Helper#log builds TaggedLogger which + # calls derive_log_segments -> Settings -> possible recursion. + # Subclass static tools (Do, Status, Config) CAN include Helper safely. + def log + Legion::Logging.respond_to?(:logger) ? Legion::Logging.logger : nil + end + + def handle_exception(err, **opts) + log&.warn("[Legion::Tools] #{opts[:operation] || 'unknown'}: #{err.message}") + end + + def tool_name(name = nil) + name ? @tool_name = name : @tool_name + end + + def description(desc = nil) + desc ? @description = desc : (@description || '') + end + + def input_schema(schema = nil) + schema ? @input_schema = schema : @input_schema + end + + def deferred(val = nil) + return @deferred || false if val.nil? + + @deferred = val + end + + def deferred? + deferred + end + + # Metadata that replaces Capability - Tools::Registry IS the catalog + def extension(val = nil) + return @extension if val.nil? + + @extension = val + end + + def runner(val = nil) + return @runner if val.nil? + + @runner = val + end + + def tags(val = nil) + return @tags || [] if val.nil? + + @tags = val + end + + def mcp_category(val = nil) + return @mcp_category if val.nil? + + @mcp_category = val + end + + def mcp_tier(val = nil) + return @mcp_tier if val.nil? + + @mcp_tier = val + end + + def trigger_words(val = nil) + return @trigger_words || [] if val.nil? + + @trigger_words = val + end + + def sticky(val = nil) + return @sticky.nil? || @sticky if val.nil? + + @sticky = val + end + + def call(**_args) + raise NotImplementedError, "#{name} must implement .call" + end + + def text_response(data) + text = data.is_a?(String) ? data : Legion::JSON.dump(data) + { content: [{ type: 'text', text: text }] } + end + + def error_response(msg) + { content: [{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true } + end + end + end + end +end diff --git a/lib/legion/tools/config.rb b/lib/legion/tools/config.rb new file mode 100644 index 00000000..6693d626 --- /dev/null +++ b/lib/legion/tools/config.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Legion + module Tools + class Config < Base + tool_name 'legion.get_config' + description 'Get Legion configuration (sensitive values are redacted).' + input_schema( + type: 'object', + properties: { + section: { type: 'string', description: 'Specific config section (e.g., "transport", "data")' } + } + ) + + SENSITIVE_KEYS = %i[password secret token key cert private_key api_key].freeze + + class << self + include Legion::Logging::Helper + + def call(section: nil) + settings = Legion::Settings.loader.to_hash + + if section + key = section.to_sym + return error_response("Setting '#{section}' not found") unless settings.key?(key) + + value = settings[key] + value = redact_hash(value) if value.is_a?(Hash) + text_response({ key: key, value: value }) + else + text_response(redact_hash(settings)) + end + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :tool_config_call) + error_response("Failed to get config: #{e.message}") + end + + private + + def redact_value(key, value) + normalized_key = key.to_s.downcase + if value.is_a?(Hash) + redact_hash(value) + elsif value.is_a?(Array) + value.map { |elem| elem.is_a?(Hash) ? redact_hash(elem) : elem } + elsif SENSITIVE_KEYS.any? { |s| normalized_key.include?(s.to_s) } + '[REDACTED]' + else + value + end + end + + def redact_hash(hash) + return hash unless hash.is_a?(Hash) + + hash.each_with_object({}) do |(k, v), result| + result[k] = redact_value(k, v) + end + end + end + + Legion::Tools.register_class(self) + end + end +end diff --git a/lib/legion/tools/discovery.rb b/lib/legion/tools/discovery.rb new file mode 100644 index 00000000..9481ae88 --- /dev/null +++ b/lib/legion/tools/discovery.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: true + +module Legion + module Tools + module Discovery + # Extension/runner pairs that should always be loaded (not deferred) + # nil means all runners for that extension; array means specific runners only + ALWAYS_LOADED = { + 'apollo' => ['knowledge'], + 'eval' => ['evaluation'] + }.freeze + + class << self + def log + Legion::Logging.respond_to?(:logger) ? Legion::Logging.logger : nil + end + + def handle_exception(err, **opts) + log&.warn("[Tools::Discovery] #{opts[:operation]}: #{err.message}") + end + + def discover_and_register + return unless defined?(Legion::Extensions) + + exts = loaded_extensions + log&.info("[Tools::Discovery] scanning #{exts.size} extensions") + + exts.each do |ext| + discover_runners(ext) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :discovery_process_extension) + end + + log&.info( + "[Tools::Discovery] done: always=#{Registry.tools.size} " \ + "deferred=#{Registry.deferred_tools.size}" + ) + end + + private + + def loaded_extensions + if Legion::Extensions.respond_to?(:loaded_extension_modules) + Legion::Extensions.loaded_extension_modules || [] + else + Legion::Extensions.constants(false).filter_map do |const_name| + mod = Legion::Extensions.const_get(const_name, false) + next nil unless mod.is_a?(Module) && mod.respond_to?(:runner_modules) + + mod + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :discovery_loaded_extensions) + nil + end + end + end + + def discover_runners(ext) + return unless ext.respond_to?(:runner_modules) + + ext.runner_modules.each do |runner_mod| + next unless runner_mod.respond_to?(:settings) && runner_mod.settings.is_a?(Hash) + next unless resolve_mcp_tools_enabled(ext, runner_mod) + + functions = runner_mod.settings[:functions] + functions = synthesize_functions(ext, runner_mod) if functions.nil? || functions.empty? + next if functions.nil? || functions.empty? + + is_deferred = resolve_deferred(ext, runner_mod) + functions.each do |func_name, meta| + register_function(ext, runner_mod, func_name, meta, is_deferred) + end + end + end + + def synthesize_functions(ext, runner_mod) + return {} unless ext.respond_to?(:runners) && ext.runners.is_a?(Hash) + + runner_entry = ext.runners.values.find { |r| r[:runner_module] == runner_mod } + return {} unless runner_entry&.dig(:class_methods).is_a?(Hash) + + runner_entry[:class_methods].each_with_object({}) do |(method_name, method_info), funcs| + defn = runner_mod.respond_to?(:definition_for) ? runner_mod.definition_for(method_name) : nil + funcs[method_name] = { + desc: defn&.dig(:desc) || method_name.to_s, + options: build_schema_from_args(method_info[:args]), + args: method_info[:args] + } + end + end + + def build_schema_from_args(args) + return {} if args.nil? || args.empty? + + properties = {} + required = [] + + args.each do |type, name| + next if name.nil? || %i[** * block].include?(name) + + param_name = name.to_s + properties[param_name] = { type: 'string' } + required << param_name if type == :req + end + + return {} if properties.empty? + + schema = { properties: properties } + schema[:required] = required unless required.empty? + schema + end + + def register_function(ext, runner_mod, func_name, meta, is_deferred) + defn = runner_mod.respond_to?(:definition_for) ? runner_mod.definition_for(func_name) : nil + + ext_default = ext.respond_to?(:mcp_tools?) ? ext.mcp_tools? : true + return unless resolve_exposed(defn, meta, ext_default) + + requires = defn&.dig(:requires)&.map(&:to_s) || meta[:requires] + return unless deps_satisfied?(requires) + + tool_class = build_tool_class( + ext: ext, runner_mod: runner_mod, func_name: func_name, + meta: meta, defn: defn, deferred: is_deferred + ) + return unless Legion::Tools::Registry.register(tool_class) + + register_in_settings_extensions(tool_class, ext, runner_mod, is_deferred) + record_tool_owner(ext, tool_class) + end + + def resolve_mcp_tools_enabled(ext, runner_mod) + return runner_mod.mcp_tools? if runner_mod.respond_to?(:mcp_tools?) + + ext.respond_to?(:mcp_tools?) ? ext.mcp_tools? : true + end + + def resolve_deferred(ext, runner_mod) + ext_name = derive_extension_name(ext) + runner_name = derive_runner_snake(runner_mod) + if ALWAYS_LOADED.key?(ext_name) + runners = ALWAYS_LOADED[ext_name] + return false if runners.nil? || runners.include?(runner_name) + end + + return runner_mod.mcp_tools_deferred? if runner_mod.respond_to?(:mcp_tools_deferred?) + + ext.respond_to?(:mcp_tools_deferred?) ? ext.mcp_tools_deferred? : true + end + + def resolve_exposed(defn, meta, ext_default) + return defn[:mcp_exposed] unless defn.nil? || defn[:mcp_exposed].nil? + return meta[:expose] unless meta[:expose].nil? + + ext_default + end + + def deps_satisfied?(deps) + return true if deps.nil? || deps.empty? + + deps.all? do |dep| + parts = dep.delete_prefix('::').split('::').reject(&:empty?) + current = Object + parts.all? do |part| + current.const_defined?(part, false) ? (current = current.const_get(part, false)) && true : false + end + end + end + + def build_tool_class(ext:, runner_mod:, func_name:, meta:, defn:, deferred:) # rubocop:disable Metrics/ParameterLists + attrs = tool_attributes(ext, runner_mod, func_name, meta, defn, deferred) + create_tool_class(attrs, runner_mod, func_name) + end + + def tool_attributes(ext, runner_mod, func_name, meta, defn, deferred) # rubocop:disable Metrics/ParameterLists + ext_name = derive_extension_name(ext) + runner_snake = derive_runner_snake(runner_mod) + { + tool_name: defn&.dig(:mcp_prefix) || "legion-#{ext_name}-#{runner_snake}-#{func_name}", + description: meta[:desc] || defn&.dig(:desc) || "#{ext_name}##{func_name}", + input_schema: normalize_schema(defn&.dig(:inputs)&.any? ? defn[:inputs] : meta[:options]), + mcp_category: defn&.dig(:mcp_category), + mcp_tier: defn&.dig(:mcp_tier), + deferred: deferred, + ext_name: ext_name, + runner_snake: runner_snake, + trigger_words: merge_trigger_words(ext, runner_mod), + sticky: ext.respond_to?(:sticky_tools?) ? ext.sticky_tools? == true : true + } + end + + def create_tool_class(attrs, runner_ref, func_ref) + Class.new(Legion::Tools::Base) do + tool_name attrs[:tool_name] + description attrs[:description] + input_schema(attrs[:input_schema]) + deferred(attrs[:deferred]) + extension(attrs[:ext_name]) + runner(attrs[:runner_snake]) + mcp_category(attrs[:mcp_category]) if attrs[:mcp_category] + mcp_tier(attrs[:mcp_tier]) if attrs[:mcp_tier] + trigger_words(attrs[:trigger_words]) + sticky(attrs[:sticky]) + + define_singleton_method(:call) do |**params| + if runner_ref.respond_to?(func_ref) + result = runner_ref.public_send(func_ref, **params) + text = result.is_a?(String) ? result : Legion::JSON.dump(result) + text_response(text) + else + error_response("function #{func_ref} not found on #{runner_ref}") + end + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :"discovery_call_#{func_ref}") + error_response(e.message) + end + end + end + + def register_in_settings_extensions(tool_class, ext, runner_mod, is_deferred) + return unless defined?(Legion::Settings::Extensions) && + Legion::Settings::Extensions.respond_to?(:register_tool) + + ext_name = derive_extension_name(ext) + Legion::Settings::Extensions.register_tool(tool_class.tool_name, { + description: tool_class.respond_to?(:description) ? tool_class.description : nil, + input_schema: tool_class.respond_to?(:input_schema) ? tool_class.input_schema : {}, + tool_class: tool_class, + dispatch_type: :class_call, + extension: "lex-#{ext_name}", + runner: derive_runner_snake(runner_mod), + source: :tools_discovery, + deferred: is_deferred, + trigger_words: tool_class.respond_to?(:trigger_words) ? tool_class.trigger_words : [], + sticky: tool_class.respond_to?(:sticky?) ? tool_class.sticky? : true, + mcp_tier: tool_class.respond_to?(:mcp_tier) ? tool_class.mcp_tier : nil + }) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :register_in_settings_extensions) + end + + def record_tool_owner(ext, tool_class) + return unless defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:record_extension_resource) + + ext_name = derive_extension_name(ext) + Legion::Extensions.record_extension_resource("lex-#{ext_name.tr('_', '-')}", :tools, tool_class.tool_name) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :record_tool_owner) + end + + def merge_trigger_words(ext, runner_mod) + ext_words = ext.respond_to?(:trigger_words) ? Array(ext.trigger_words) : [] + + # Prefer explicit trigger_words on the runner module itself. + # Fall back to the runner entry stored by builders/runners.rb, which + # defaults to [runner_name] when the module doesn't define them. + runner_words = if runner_mod.respond_to?(:trigger_words) && runner_mod.trigger_words.any? + Array(runner_mod.trigger_words) + elsif ext.respond_to?(:runners) && ext.runners.is_a?(Hash) + entry = ext.runners.values.find { |r| r[:runner_module] == runner_mod } + Array(entry&.dig(:trigger_words)) + else + [] + end + + (ext_words + runner_words).uniq + end + + def derive_runner_snake(runner_mod) + mod_name = runner_mod.name + return 'unknown' unless mod_name + + last = mod_name.split('::').last + last.gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase + end + + def normalize_schema(schema) + schema = { properties: {} } if schema.nil? || schema.empty? + schema = schema.dup + schema[:type] ||= 'object' + schema[:properties] ||= {} + schema + end + + def derive_extension_name(ext) + if ext.respond_to?(:lex_name) + ext.lex_name.delete_prefix('lex-').tr('-', '_') + else + mod_name = ext.name + return 'unknown' unless mod_name + + last = mod_name.split('::').last + last.gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase + end + end + end + end + end +end diff --git a/lib/legion/tools/do.rb b/lib/legion/tools/do.rb new file mode 100644 index 00000000..5e95350d --- /dev/null +++ b/lib/legion/tools/do.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'securerandom' + +module Legion + module Tools + class Do < Base + tool_name 'legion.do' + description 'Execute a Legion action by describing what you want to do in natural language. ' \ + 'Routes to the best matching tool automatically.' + input_schema( + type: 'object', + properties: { + intent: { + type: 'string', + description: 'Natural language description (e.g., "list all running tasks")' + }, + params: { + type: 'object', + description: 'Parameters to pass to the matched tool', + additionalProperties: true + }, + context: { + type: 'object', + description: 'Additional context (service, environment, etc.)', + additionalProperties: true + } + }, + required: ['intent'] + ) + + class << self + include Legion::Logging::Helper + + def call(intent:, params: {}, context: {}) + request_id = context[:request_id] || "do_#{SecureRandom.hex(6)}" + tool_params = params.transform_keys(&:to_sym) + + # Try Tier 0 (cached patterns) if MCP TierRouter is available + tier_result = try_tier0(intent, tool_params, context, request_id: request_id) + case tier_result&.dig(:tier) + when 0 + return text_response(tier_result[:response].merge( + _meta: { tier: 0, latency_ms: tier_result[:latency_ms], + confidence: tier_result[:pattern_confidence] } + )) + when 1 + llm_result = try_llm(intent, hint: tier_result[:pattern], request_id: request_id) + return text_response({ result: llm_result, _meta: { tier: 1 } }) if llm_result + when 2 + llm_result = try_llm(intent, request_id: request_id) + return text_response({ result: llm_result, _meta: { tier: 2 } }) if llm_result + end + + # Fall back to Registry tool matching + matched = match_tool(intent) + return error_response("No matching tool found for intent: #{intent}") if matched.nil? + + result = tool_params.empty? ? matched.call : matched.call(**tool_params) + record_feedback(intent, matched.tool_name, success: true) + result.is_a?(Hash) ? result : text_response(result) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :tool_do_call) + error_response("Failed: #{e.message}") + end + + private + + def match_tool(intent) + if defined?(Legion::MCP::ContextCompiler) + matched = Legion::MCP::ContextCompiler.match_tool(intent) + return matched if matched + end + + match_tool_from_registry(intent) + rescue StandardError + nil + end + + def match_tool_from_registry(intent) + return nil unless defined?(Legion::Tools::Registry) + + normalized = normalize_tool_text(intent) + return nil if normalized.empty? + + tools = Legion::Tools::Registry.all_tools + return nil if tools.empty? + + tools + .map { |t| [t, score_tool_match(t, normalized)] } + .select { |(_t, score)| score.positive? } + .max_by { |(_t, score)| score } + &.first + rescue StandardError + nil + end + + def score_tool_match(tool, normalized_intent) + name = normalize_tool_text(tool.tool_name) + description = normalize_tool_text(tool.respond_to?(:description) ? tool.description : nil) + return 0 if name.empty? && description.empty? + + intent_terms = normalized_intent.split + score = 0 + score += 100 if !name.empty? && normalized_intent.include?(name) + score += 50 if !description.empty? && normalized_intent.include?(description) + score += (intent_terms & name.split).length * 10 + score += (intent_terms & description.split).length * 3 + score + rescue StandardError + 0 + end + + def normalize_tool_text(text) + text.to_s.downcase.gsub(/[^a-z0-9]+/, ' ').strip + end + + def try_tier0(intent, params, context, request_id: nil) + return nil unless defined?(Legion::MCP::TierRouter) + + Legion::MCP::TierRouter.route( + intent: intent, params: params.transform_keys(&:to_sym), + context: context.to_h.transform_keys(&:to_sym).merge(request_id: request_id) + ) + rescue StandardError + nil + end + + def try_llm(intent, hint: nil, _request_id: nil) + return nil unless defined?(Legion::LLM) && Legion::LLM.started? + + prompt = hint ? "Known pattern: #{hint[:intent_text]}. User intent: #{intent}" : intent + Legion::LLM.ask(message: prompt) + rescue StandardError + nil + end + + def record_feedback(intent, tool_name, success:) + return unless defined?(Legion::MCP::Observer) + + Legion::MCP::Observer.record_intent_with_result( + intent: intent, tool_name: tool_name, success: success + ) + rescue StandardError + nil + end + end + + Legion::Tools.register_class(self) + end + end +end diff --git a/lib/legion/tools/embedding_cache.rb b/lib/legion/tools/embedding_cache.rb new file mode 100644 index 00000000..751d8e1b --- /dev/null +++ b/lib/legion/tools/embedding_cache.rb @@ -0,0 +1,425 @@ +# frozen_string_literal: true + +require 'digest' +require 'time' + +module Legion + module Tools + module EmbeddingCache + MIGRATION_PATH = File.expand_path('embedding_cache/migrations', __dir__) + L0_MAX_ENTRIES = 1000 + CACHE_TTL = 86_400 # 24 hours + + # L0: in-memory - always available + @memory_cache = {} + @memory_mutex = Mutex.new + + class << self + def log + Legion::Logging.respond_to?(:logger) ? Legion::Logging.logger : nil + end + + def handle_exception(err, **opts) + log&.warn("[Tools::EmbeddingCache] #{opts[:operation]}: #{err.message}") + end + + def setup + Legion::Data::Local.register_migrations(name: 'embedding_cache', path: MIGRATION_PATH) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :embedding_cache_setup) + end + + def available? + true # L0 is always available + end + + def content_hash(text) + Digest::MD5.hexdigest(text.to_s) + end + + # --- 5-tier read cascade --- + + def lookup(content_hash:, model:) + key = "embed:#{content_hash}:#{model}" + + # L0 + vec = memory_get(key) + return vec if vec + + # Tier 1 + vec = cache_local_get(key) + if vec + memory_set(key, vec) + return vec + end + + # Tier 2 + vec = cache_global_get(key) + if vec + memory_set(key, vec) + cache_local_set(key, vec) + return vec + end + + # Tier 3 + row = data_local_get(content_hash, model) + if row + vec = parse_vector(row[:vector]) + if vec + memory_set(key, vec) + cache_local_set(key, vec) + cache_global_set(key, vec) + return vec + end + end + + # Tier 4 + row = data_global_get(content_hash, model) + if row + vec = parse_vector(row[:vector]) + if vec + memory_set(key, vec) + cache_local_set(key, vec) + cache_global_set(key, vec) + data_local_store(content_hash: content_hash, model: model, + tool_name: row[:tool_name], vector: vec) + return vec + end + end + + nil + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :embedding_cache_lookup) + nil + end + + def bulk_lookup(content_hashes:, model:) + return {} if content_hashes.empty? + + result = {} + remaining = content_hashes.dup + + # L0 / Tier 1 / Tier 2 + remaining.dup.each do |h| + key = "embed:#{h}:#{model}" + + vec = memory_get(key) + if vec + result[h] = vec + remaining.delete(h) + next + end + + vec = cache_local_get(key) + if vec + result[h] = vec + memory_set(key, vec) + remaining.delete(h) + next + end + + vec = cache_global_get(key) + next unless vec + + result[h] = vec + memory_set(key, vec) + cache_local_set(key, vec) + remaining.delete(h) + end + + # Tier 3 + bulk_data_lookup(remaining, model, result, :local) if remaining.any? + + # Tier 4 + bulk_data_lookup(remaining, model, result, :global) if remaining.any? + + result + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :embedding_cache_bulk_lookup) + result || {} + end + + # Write to all 5 tiers + def store(content_hash:, model:, tool_name:, vector:) + key = "embed:#{content_hash}:#{model}" + memory_set(key, vector) + cache_local_set(key, vector) + cache_global_set(key, vector) + data_local_store(content_hash: content_hash, model: model, + tool_name: tool_name, vector: vector) + data_global_store(content_hash: content_hash, model: model, + tool_name: tool_name, vector: vector) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :embedding_cache_store) + end + + def bulk_store(entries) + return if entries.nil? || entries.empty? + + cache_hash = {} + entries.each do |entry| + key = "embed:#{entry[:content_hash]}:#{entry[:model]}" + memory_set(key, entry[:vector]) + cache_hash[key] = entry[:vector] + end + + bulk_cache_store(cache_hash) + bulk_data_local_store(entries) if data_local_available? + bulk_data_global_store(entries) if data_global_available? + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :embedding_cache_bulk_store) + end + + def clear + clear_memory + clear_cache_tiers + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :embedding_cache_clear) + end + + def clear_memory + @memory_mutex.synchronize { @memory_cache.clear } + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :embedding_cache_clear_memory) + end + + def purge_persistent! + clear_memory + data_local_connection[:tool_embedding_cache].delete if data_local_available? + data_global_connection[:tool_embedding_cache].delete if data_global_available? + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :embedding_cache_purge_persistent) + end + + def stats + { + memory: @memory_mutex.synchronize { @memory_cache.size }, + cache_local: cache_local_available?, + cache_global: cache_global_available?, + data_local: data_local_available? ? data_local_connection[:tool_embedding_cache].count : 0, + data_global: data_global_available? ? data_global_connection[:tool_embedding_cache].count : 0 + } + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :embedding_cache_stats) + {} + end + + private + + # --- L0 --- + def memory_get(key) + @memory_mutex.synchronize { @memory_cache[key]&.dup } + end + + def memory_set(key, vector) + @memory_mutex.synchronize do + @memory_cache.delete(@memory_cache.keys.first) if @memory_cache.size >= L0_MAX_ENTRIES && !@memory_cache.key?(key) + @memory_cache[key] = vector.dup.freeze + end + end + + # --- Tier availability --- + def cache_local_available? + defined?(Legion::Cache) && Legion::Cache.local.enabled? && Legion::Cache.local.connected? + rescue StandardError + false + end + + def cache_global_available? + defined?(Legion::Cache) && Legion::Cache.enabled? && Legion::Cache.connected? + rescue StandardError + false + end + + def data_local_available? + defined?(Legion::Data::Local) && Legion::Data::Local.connected? && + Legion::Data::Local.connection.table_exists?(:tool_embedding_cache) + rescue StandardError + false + end + + def data_global_available? + defined?(Legion::Data) && Legion::Data.connected? && + Legion::Data.connection.table_exists?(:tool_embedding_cache) + rescue StandardError + false + end + + def clear_cache_tiers + Legion::Cache.local.flush if cache_local_available? && Legion::Cache.local.respond_to?(:flush) + Legion::Cache.flush if cache_global_available? && Legion::Cache.respond_to?(:flush) + rescue StandardError => e + handle_exception(e, level: :debug, handled: true, operation: :clear_cache_tiers) + end + + # --- Cache tier helpers --- + def cache_local_get(key) + return nil unless cache_local_available? + + result = Legion::Cache.local.get(key) + result.is_a?(Array) ? result : nil + rescue StandardError + nil + end + + def cache_local_set(key, vector) + return unless cache_local_available? + + Legion::Cache.local.set(key, vector, ttl: CACHE_TTL, async: false) + rescue StandardError + nil + end + + def cache_global_get(key) + return nil unless cache_global_available? + + result = Legion::Cache.get(key) + result.is_a?(Array) ? result : nil + rescue StandardError + nil + end + + def cache_global_set(key, vector) + return unless cache_global_available? + + Legion::Cache.set(key, vector, ttl: CACHE_TTL, async: false) + rescue StandardError + nil + end + + # --- Data tier helpers --- + def data_local_connection + Legion::Data::Local.connection + end + + def data_global_connection + Legion::Data.connection + end + + def data_local_get(content_hash, model) + return nil unless data_local_available? + + data_local_connection[:tool_embedding_cache] + .where(content_hash: content_hash, model: model).first + rescue StandardError + nil + end + + def data_global_get(content_hash, model) + return nil unless data_global_available? + + data_global_connection[:tool_embedding_cache] + .where(content_hash: content_hash, model: model).first + rescue StandardError + nil + end + + def data_local_store(content_hash:, model:, tool_name:, vector:) + return unless data_local_available? + + vec_json = vector.is_a?(String) ? vector : Legion::JSON.dump(vector) + Legion::Data::Local.upsert( + :tool_embedding_cache, + { content_hash: content_hash, model: model, tool_name: tool_name, + vector: vec_json, embedded_at: Time.now.utc }, + conflict_keys: %i[content_hash model] + ) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :data_local_store) + end + + def data_global_store(content_hash:, model:, tool_name:, vector:) + return unless data_global_available? + + vec_json = vector.is_a?(String) ? vector : Legion::JSON.dump(vector) + data_global_connection[:tool_embedding_cache] + .insert_conflict(target: %i[content_hash model], update: { + vector: vec_json, tool_name: tool_name, embedded_at: Time.now.utc + }) + .insert(content_hash: content_hash, model: model, tool_name: tool_name, + vector: vec_json, embedded_at: Time.now.utc) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :data_global_store) + end + + # --- Bulk helpers --- + def bulk_cache_store(cache_hash) + return if cache_hash.empty? + + if cache_local_available? + begin + Legion::Cache.local.mset(cache_hash, ttl: CACHE_TTL, async: false) + rescue StandardError => e + handle_exception(e, level: :debug, handled: true, operation: :bulk_cache_local_mset) + end + end + + return unless cache_global_available? + + Legion::Cache.mset(cache_hash, ttl: CACHE_TTL, async: false) + rescue StandardError => e + handle_exception(e, level: :debug, handled: true, operation: :bulk_cache_global_mset) + end + + def bulk_data_lookup(remaining, model, result, tier) + available = tier == :local ? data_local_available? : data_global_available? + return unless available + + conn = tier == :local ? data_local_connection : data_global_connection + conn[:tool_embedding_cache].where(content_hash: remaining, model: model).all.each do |row| + vec = parse_vector(row[:vector]) + next unless vec + + h = row[:content_hash] + result[h] = vec + memory_set("embed:#{h}:#{model}", vec) + cache_local_set("embed:#{h}:#{model}", vec) + cache_global_set("embed:#{h}:#{model}", vec) + remaining.delete(h) + end + end + + def bulk_data_local_store(entries) + now = Time.now.utc + ds = data_local_connection[:tool_embedding_cache] + data_local_connection.transaction do + entries.each do |entry| + vec_json = entry[:vector].is_a?(String) ? entry[:vector] : Legion::JSON.dump(entry[:vector]) + ds.insert_conflict(target: %i[content_hash model], update: { + vector: vec_json, tool_name: entry[:tool_name], embedded_at: now + }).insert(content_hash: entry[:content_hash], model: entry[:model], + tool_name: entry[:tool_name], vector: vec_json, embedded_at: now) + end + end + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :bulk_data_local_store) + end + + def bulk_data_global_store(entries) + now = Time.now.utc + ds = data_global_connection[:tool_embedding_cache] + data_global_connection.transaction do + entries.each do |entry| + vec_json = entry[:vector].is_a?(String) ? entry[:vector] : Legion::JSON.dump(entry[:vector]) + ds.insert_conflict(target: %i[content_hash model], update: { + vector: vec_json, tool_name: entry[:tool_name], embedded_at: now + }).insert(content_hash: entry[:content_hash], model: entry[:model], + tool_name: entry[:tool_name], vector: vec_json, embedded_at: now) + end + end + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :bulk_data_global_store) + end + + def parse_vector(json_str) + return nil unless json_str + + vec = json_str.is_a?(Array) ? json_str : Legion::JSON.load(json_str) + vec.is_a?(Array) ? vec : nil + rescue StandardError + nil + end + end + end + end +end diff --git a/lib/legion/tools/embedding_cache/migrations/001_create_tool_embedding_cache.rb b/lib/legion/tools/embedding_cache/migrations/001_create_tool_embedding_cache.rb new file mode 100644 index 00000000..fc6d3c4a --- /dev/null +++ b/lib/legion/tools/embedding_cache/migrations/001_create_tool_embedding_cache.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Sequel.migration do + change do + create_table(:tool_embedding_cache) do + primary_key :id + String :content_hash, size: 32, null: false + String :model, null: false + String :tool_name, null: false + String :vector, text: true, null: false + Time :embedded_at, null: false + unique %i[content_hash model] + end + end +end diff --git a/lib/legion/tools/embedding_cache/migrations/002_add_tool_name_index.rb b/lib/legion/tools/embedding_cache/migrations/002_add_tool_name_index.rb new file mode 100644 index 00000000..9cf8e985 --- /dev/null +++ b/lib/legion/tools/embedding_cache/migrations/002_add_tool_name_index.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +Sequel.migration do + up do + alter_table(:tool_embedding_cache) do + add_index :tool_name, name: :idx_tool_embedding_cache_tool_name + end + end + + down do + alter_table(:tool_embedding_cache) do + drop_index :tool_name, name: :idx_tool_embedding_cache_tool_name + end + end +end diff --git a/lib/legion/tools/registry.rb b/lib/legion/tools/registry.rb new file mode 100644 index 00000000..6ad22414 --- /dev/null +++ b/lib/legion/tools/registry.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Legion + module Tools + module Registry + @always = [] + @deferred = [] + @mutex = Mutex.new + + class << self + def register(tool_class) + name = tool_class.tool_name + is_deferred = tool_class.respond_to?(:deferred?) && tool_class.deferred? + bucket = is_deferred ? :deferred : :always + + @mutex.synchronize do + target = bucket == :deferred ? @deferred : @always + other = bucket == :deferred ? @always : @deferred + + if target.any? { |t| t.tool_name == name } || other.any? { |t| t.tool_name == name } + if defined?(Legion::Logging) + Legion::Logging.warn( + "[Tools::Registry] duplicate registration rejected: #{name} " \ + "(attempted by #{tool_class.name || tool_class.inspect})" + ) + end + return false + end + + target << tool_class + true + end + end + + def tools + @mutex.synchronize { @always.dup } + end + + def deferred_tools + @mutex.synchronize { @deferred.dup } + end + + def all_tools + @mutex.synchronize { @always.dup + @deferred.dup } + end + + def find(name) + @mutex.synchronize do + @always.find { |t| t.tool_name == name } || + @deferred.find { |t| t.tool_name == name } + end + end + + def always_loaded_names + tools.map(&:tool_name) + end + + def for_extension(ext_name) + normalized = normalize_extension(ext_name) + all_tools.select { |t| t.respond_to?(:extension) && normalize_extension(t.extension) == normalized } + end + + def for_runner(runner_name) + all_tools.select { |t| t.respond_to?(:runner) && t.runner == runner_name } + end + + def tagged(tag) + all_tools.select { |t| t.respond_to?(:tags) && t.tags.include?(tag) } + end + + def clear + @mutex.synchronize do + @always.clear + @deferred.clear + end + end + + def unregister_extension(ext_name) + normalized = normalize_extension(ext_name) + @mutex.synchronize do + before = @always.size + @deferred.size + @always.reject! { |t| t.respond_to?(:extension) && normalize_extension(t.extension) == normalized } + @deferred.reject! { |t| t.respond_to?(:extension) && normalize_extension(t.extension) == normalized } + before - (@always.size + @deferred.size) + end + end + + private + + def normalize_extension(ext_name) + ext_name.to_s.delete_prefix('lex-').tr('-', '_') + end + end + end + end +end diff --git a/lib/legion/tools/status.rb b/lib/legion/tools/status.rb new file mode 100644 index 00000000..580227f8 --- /dev/null +++ b/lib/legion/tools/status.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Legion + module Tools + class Status < Base + tool_name 'legion.get_status' + description 'Get Legion service health status and component info.' + input_schema(type: 'object', properties: {}) + + class << self + include Legion::Logging::Helper + + def call(**_args) + status = { + version: defined?(Legion::VERSION) ? Legion::VERSION : 'unknown', + ready: readiness_check, + components: components_check, + node: node_name + } + text_response(status) + rescue StandardError => e + handle_exception(e, level: :warn, handled: true, operation: :tool_status_call) + error_response("Failed to get status: #{e.message}") + end + + private + + def readiness_check + Legion::Readiness.ready? + rescue StandardError + false + end + + def components_check + Legion::Readiness.to_h + rescue StandardError + {} + end + + def node_name + Legion::Settings[:client][:name] + rescue StandardError + 'unknown' + end + end + + Legion::Tools.register_class(self) + end + end +end diff --git a/lib/legion/tools/trigger_index.rb b/lib/legion/tools/trigger_index.rb new file mode 100644 index 00000000..6ef22c55 --- /dev/null +++ b/lib/legion/tools/trigger_index.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Legion + module Tools + module TriggerIndex + @index = if defined?(Concurrent::Map) + Concurrent::Map.new + else + {} + end + @mutex = Mutex.new unless defined?(Concurrent::Map) + + class << self + def build_from_registry + clear + Registry.all_tools.each do |tool_class| + words = Array(tool_class.trigger_words) + next if words.empty? + + normalized = words.flat_map { |w| w.downcase.gsub(/[^a-z ]/, ' ').split }.uniq + normalized.each { |word| add_tool_for_word(word, tool_class) } + end + end + + def build_async! + if defined?(Concurrent::Promises) + Concurrent::Promises.future { build_from_registry } + else + build_from_registry + end + end + + def match(word_set) + matched = Set.new + per_word = {} + word_set.each do |word| + normalized = word.to_s.downcase.gsub(/[^a-z ]/, ' ').strip + next if normalized.empty? + + tools = read_word(normalized) + next unless tools + + per_word[normalized] = tools + matched.merge(tools) + end + [matched, per_word] + end + + def empty? + if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map) + @index.each_pair.none? + else + @index.empty? + end + end + + def size + if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map) + count = 0 + @index.each_pair { count += 1 } + count + else + @index.size + end + end + + def clear + if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map) + @index = Concurrent::Map.new + else + @mutex.synchronize { @index = {} } + end + end + + private + + def add_tool_for_word(word, tool_class) + if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map) + @index.compute(word) { |existing| ((existing || Set.new) + Set[tool_class]).freeze } + else + @mutex.synchronize do + @index[word] ||= Set.new + @index[word] = (@index[word] + Set[tool_class]).freeze + end + end + end + + def read_word(word) + if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map) + @index[word] + else + @mutex&.synchronize { @index[word] } + end + end + end + end + end +end diff --git a/lib/legion/trace_search.rb b/lib/legion/trace_search.rb new file mode 100644 index 00000000..2a97c76d --- /dev/null +++ b/lib/legion/trace_search.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +module Legion + module TraceSearch + SCHEMA_TEMPLATE = <<~PROMPT + You translate natural language queries into JSON filter objects for the metering_records table. + Current date/time: %s + + Columns: id (integer), worker_id (string), event_type (string), extension (string), + runner_function (string), status (string: success/failure), input_tokens (integer), + output_tokens (integer), cost_usd (float), wall_clock_ms (integer), recorded_at (datetime) + + Return ONLY a valid JSON object with these possible keys: + - "where": hash of column => value filters (e.g. {"status": "failure"}) + - "order": column name to sort by (prefix with "-" for descending, e.g. "-cost_usd") + - "limit": integer limit (default 50) + - "date_from": ISO date string for recorded_at >= filter + - "date_to": ISO date string for recorded_at <= filter + + For relative time references, compute ISO dates from the current date/time above: + - "today" => date_from is today's date at 00:00 + - "last hour" => date_from is 1 hour ago + - "this week" => date_from is Monday of this week + - "yesterday" => date_from/date_to bracket yesterday + + Examples: + - "failed tasks" => {"where": {"status": "failure"}} + - "most expensive calls" => {"order": "-cost_usd", "limit": 20} + - "tasks by worker-1 today" => {"where": {"worker_id": "worker-1"}, "date_from": "%s"} + + Return ONLY the JSON object, no explanation. + PROMPT + + FILTER_SCHEMA = { + type: 'object', + properties: { + where: { type: 'object' }, + order: { type: 'string' }, + limit: { type: 'integer' }, + date_from: { type: 'string' }, + date_to: { type: 'string' } + } + }.freeze + + ALLOWED_COLUMNS = %w[ + id worker_id event_type extension runner_function status + input_tokens output_tokens cost_usd wall_clock_ms recorded_at + ].freeze + + class << self + def search(query, limit: 50) + Legion::Logging.info "[TraceSearch] query: #{query.inspect} limit=#{limit}" if defined?(Legion::Logging) + parsed = generate_filter(query) + return { results: [], error: 'no filter generated' } unless parsed + + execute_filter(parsed, limit) + rescue StandardError => e + Legion::Logging.error "[TraceSearch] search failed: #{e.message}" if defined?(Legion::Logging) + { results: [], error: e.message } + end + + def generate_filter(query) + return nil unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:structured) + + result = Legion::LLM.structured( + messages: [ + { role: 'system', content: schema_context }, + { role: 'user', content: query } + ], + schema: FILTER_SCHEMA, + caller: { source: 'cli', command: 'trace' } + ) + Legion::Logging.error "[TraceSearch] LLM filter generation failed for query: #{query.inspect}" if !result[:valid] && defined?(Legion::Logging) + result[:data] if result[:valid] + rescue StandardError => e + handle_exception(e, level: :debug, handled: true, operation: 'trace_search.generate_filter') if respond_to?(:handle_exception) + nil + end + + def schema_context + now = Time.now + format(SCHEMA_TEMPLATE, current_time: now.iso8601, today: now.strftime('%Y-%m-%d')) + end + + def execute_filter(parsed, default_limit) + return { results: [], error: 'data unavailable' } unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection + + ds = Legion::Data.connection[:metering_records] + + if parsed[:where].is_a?(Hash) + safe_where = parsed[:where].select { |k, _| ALLOWED_COLUMNS.include?(k.to_s) } + ds = ds.where(safe_where.transform_keys(&:to_sym)) + end + + ds = apply_date_filters(ds, parsed) + ds = apply_ordering(ds, parsed) + + limit = [parsed[:limit] || default_limit, 200].min + total = ds.count + results = ds.limit(limit).all + { results: results, count: results.size, total: total, truncated: total > limit, filter: parsed } + end + + def apply_date_filters(dataset, parsed) + if parsed[:date_from] + from = safe_parse_time(parsed[:date_from]) + dataset = dataset.where { recorded_at >= from } if from + end + if parsed[:date_to] + to = safe_parse_time(parsed[:date_to]) + dataset = dataset.where { recorded_at <= to } if to + end + dataset + end + + def safe_parse_time(value) + return value if value.is_a?(Time) + + Time.parse(value.to_s) + rescue ArgumentError + nil + end + + def apply_ordering(dataset, parsed) + return dataset unless parsed[:order].is_a?(String) + + col = parsed[:order].delete_prefix('-') + return dataset unless ALLOWED_COLUMNS.include?(col) + + parsed[:order].start_with?('-') ? dataset.order(Sequel.desc(col.to_sym)) : dataset.order(col.to_sym) + end + + def summarize(query) + parsed = generate_filter(query) + return { error: 'no filter generated' } unless parsed + + compute_summary(parsed) + rescue StandardError => e + Legion::Logging.error("[TraceSearch] summarize failed: #{e.message}") if defined?(Legion::Logging) + { error: e.message } + end + + def compute_summary(parsed) + return { error: 'data unavailable' } unless defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection + + ds = build_filtered_dataset(parsed) + row = aggregate_stats(ds) + + format_summary(ds, row, parsed) + end + + def build_filtered_dataset(parsed) + ds = Legion::Data.connection[:metering_records] + if parsed[:where].is_a?(Hash) + safe_where = parsed[:where].select { |k, _| ALLOWED_COLUMNS.include?(k.to_s) } + ds = ds.where(safe_where.transform_keys(&:to_sym)) + end + apply_date_filters(ds, parsed) + end + + def aggregate_stats(dataset) + dataset.select( + Sequel.function(:count, Sequel.lit('*')).as(:total_records), + Sequel.function(:sum, :input_tokens).as(:total_tokens_in), + Sequel.function(:sum, :output_tokens).as(:total_tokens_out), + Sequel.function(:sum, :cost_usd).as(:total_cost), + Sequel.function(:avg, :wall_clock_ms).as(:avg_latency_ms), + Sequel.function(:max, :wall_clock_ms).as(:max_latency_ms), + Sequel.function(:min, :recorded_at).as(:earliest), + Sequel.function(:max, :recorded_at).as(:latest) + ).first || {} + end + + def format_summary(dataset, row, parsed) + { + total_records: row[:total_records] || 0, + total_tokens_in: row[:total_tokens_in] || 0, + total_tokens_out: row[:total_tokens_out] || 0, + total_cost: (row[:total_cost] || 0).to_f.round(4), + avg_latency_ms: (row[:avg_latency_ms] || 0).to_f.round(1), + max_latency_ms: row[:max_latency_ms] || 0, + time_range: { from: row[:earliest], to: row[:latest] }, + status_counts: dataset.group_and_count(:status).all.to_h { |r| [r[:status], r[:count]] }, + top_extensions: top_by(dataset, :extension).map { |r| { name: r[:extension], count: r[:count] } }, + top_workers: top_by(dataset, :worker_id).map { |r| { id: r[:worker_id], count: r[:count] } }, + filter: parsed + } + end + + def top_by(dataset, column, limit: 5) + dataset.group_and_count(column).order(Sequel.desc(:count)).limit(limit).all + end + + def detect_anomalies(threshold: 2.0) + return { error: 'data unavailable' } unless data_available? + + now = Time.now.utc + recent = period_stats(now - 3600, now) + baseline = period_stats(now - 86_400, now - 3600) + + build_anomaly_report(recent, baseline, threshold) + rescue StandardError => e + Legion::Logging.error("[TraceSearch] detect_anomalies failed: #{e.message}") if defined?(Legion::Logging) + { error: e.message } + end + + def trend(hours: 24, buckets: 12) + return { error: 'data unavailable' } unless data_available? + + now = Time.now.utc + bucket_seconds = (hours * 3600.0 / buckets).to_i + start_time = now - (hours * 3600) + + data = buckets.times.map do |i| + bucket_start = start_time + (i * bucket_seconds) + bucket_end = bucket_start + bucket_seconds + stats = period_stats(bucket_start, bucket_end) + { time: bucket_start.iso8601, **stats } + end + + { buckets: data, hours: hours, bucket_count: buckets, bucket_minutes: bucket_seconds / 60 } + rescue StandardError => e + Legion::Logging.error("[TraceSearch] trend failed: #{e.message}") if defined?(Legion::Logging) + { error: e.message } + end + + private + + def data_available? + defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection + end + + def period_stats(from, to) + ds = Legion::Data.connection[:metering_records].where { recorded_at >= from }.where { recorded_at <= to } + row = ds.select( + Sequel.function(:count, Sequel.lit('*')).as(:count), + Sequel.function(:avg, :cost_usd).as(:avg_cost), + Sequel.function(:avg, :wall_clock_ms).as(:avg_latency), + Sequel.function(:sum, :input_tokens).as(:input_tokens), + Sequel.function(:sum, :output_tokens).as(:output_tokens) + ).first || {} + + failures = ds.where(status: 'failure').count + total = row[:count] || 0 + + row.merge(failure_rate: total.positive? ? failures.to_f / total : 0.0) + end + + def build_anomaly_report(recent, baseline, threshold) + anomalies = [] + anomalies.concat(check_metric(:avg_cost, recent, baseline, threshold, 'Average cost')) + anomalies.concat(check_metric(:avg_latency, recent, baseline, threshold, 'Average latency')) + anomalies.concat(check_metric(:failure_rate, recent, baseline, threshold, 'Failure rate')) + + { + anomalies: anomalies, + recent_count: recent[:count] || 0, + baseline_count: baseline[:count] || 0, + recent_period: 'last 1 hour', + baseline_period: 'previous 23 hours' + } + end + + def check_metric(key, recent, baseline, threshold, label) + recent_val = (recent[key] || 0).to_f + baseline_val = (baseline[key] || 0).to_f + return [] if baseline_val.zero? || recent_val <= baseline_val + + ratio = recent_val / baseline_val + return [] unless ratio >= threshold + + [{ metric: label, recent: recent_val.round(4), baseline: baseline_val.round(4), + ratio: ratio.round(2), severity: ratio >= threshold * 2 ? 'critical' : 'warning' }] + end + end + end +end diff --git a/lib/legion/trigger.rb b/lib/legion/trigger.rb new file mode 100644 index 00000000..c0c867a6 --- /dev/null +++ b/lib/legion/trigger.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'openssl' +require 'securerandom' +require_relative 'trigger/envelope' +require_relative 'trigger/sources/base' +require_relative 'trigger/sources/github' +require_relative 'trigger/sources/slack' +require_relative 'trigger/sources/linear' + +module Legion + module Trigger + SOURCES = { + 'github' => Sources::Github, + 'slack' => Sources::Slack, + 'linear' => Sources::Linear + }.freeze + + class << self + def source_for(name) + klass = SOURCES[name.to_s] + raise ArgumentError, "unknown trigger source: #{name} (available: #{SOURCES.keys.join(', ')})" unless klass + + klass.new + end + + def process(source_name:, headers:, body_raw:, body:) + adapter = source_for(source_name) + secret = secret_for(source_name) + + verified = if secret + adapter.verify_signature(headers: headers, body_raw: body_raw, secret: secret) + else + false + end + + normalized = adapter.normalize(headers: headers, body: body) + envelope = Envelope.new(**normalized, verified: verified) + + return { success: false, reason: :duplicate, delivery_id: envelope.delivery_id } if duplicate?(envelope) + + return { success: false, reason: :unverified } if !verified && require_verified?(source_name) + + bridge(envelope) + mark_seen(envelope) + + { success: true, correlation_id: envelope.correlation_id, routing_key: envelope.routing_key } + rescue ArgumentError => e + { success: false, reason: :unknown_source, error: e.message } + rescue StandardError => e + Legion::Logging.error "[Trigger] process failed: #{e.message}" if defined?(Legion::Logging) + dead_letter(source_name, body_raw, e) + { success: false, reason: :error, error: e.message } + end + + def registered_sources + SOURCES.keys + end + + private + + def bridge(envelope) + return unless defined?(Legion::Transport::Connection) && Legion::Transport::Connection.session_open? + + channel = Legion::Transport::Connection.default_channel + exchange = channel.topic('legion.trigger', durable: true) + payload = defined?(Legion::JSON) ? Legion::JSON.dump(envelope.to_h) : envelope.to_h.to_json + + exchange.publish(payload, routing_key: envelope.routing_key, persistent: true, + headers: { 'x-correlation-id' => envelope.correlation_id }) + Legion::Logging.info "[Trigger] bridged #{envelope.routing_key} (#{envelope.correlation_id})" if defined?(Legion::Logging) + rescue StandardError => e + Legion::Logging.error "[Trigger] bridge failed: #{e.message}" if defined?(Legion::Logging) + raise + end + + def secret_for(source_name) + Legion::Settings.dig(:trigger, :sources, source_name.to_sym, :secret) + rescue StandardError + nil + end + + def require_verified?(source_name) + Legion::Settings.dig(:trigger, :sources, source_name.to_sym, :require_verified) != false + rescue StandardError + true + end + + def duplicate?(envelope) + return false unless envelope.delivery_id + return false unless defined?(Legion::Cache) && Legion::Cache.respond_to?(:get) + + Legion::Cache.get("trigger:seen:#{envelope.delivery_id}") + rescue StandardError + false + end + + def mark_seen(envelope) + return unless envelope.delivery_id + return unless defined?(Legion::Cache) && Legion::Cache.respond_to?(:set) + + Legion::Cache.set("trigger:seen:#{envelope.delivery_id}", '1', ttl: 86_400) + rescue StandardError => e + Legion::Logging.debug "[Trigger] mark_seen failed: #{e.message}" if defined?(Legion::Logging) + end + + def dead_letter(source_name, body_raw, error) + return unless defined?(Legion::Transport::Connection) && Legion::Transport::Connection.session_open? + + channel = Legion::Transport::Connection.default_channel + exchange = channel.topic('legion.trigger', durable: true) + payload = { source: source_name, body: body_raw.to_s[0..4096], error: error.message, + timestamp: Time.now.iso8601 } + raw = defined?(Legion::JSON) ? Legion::JSON.dump(payload) : payload.to_json + exchange.publish(raw, routing_key: 'trigger.dead_letter', persistent: true) + rescue StandardError => e + Legion::Logging.debug "[Trigger] dead_letter failed: #{e.message}" if defined?(Legion::Logging) + end + end + end +end diff --git a/lib/legion/trigger/envelope.rb b/lib/legion/trigger/envelope.rb new file mode 100644 index 00000000..a57cbbd0 --- /dev/null +++ b/lib/legion/trigger/envelope.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Legion + module Trigger + class Envelope + attr_reader :source, :event_type, :action, :delivery_id, :verified, + :correlation_id, :received_at, :payload + + def initialize(source:, event_type:, payload:, action: nil, delivery_id: nil, # rubocop:disable Metrics/ParameterLists + verified: false, correlation_id: nil) + @source = source + @event_type = event_type + @action = action + @delivery_id = delivery_id + @verified = verified + @correlation_id = correlation_id || generate_correlation_id + @received_at = Time.now.iso8601 + @payload = payload + end + + def routing_key + safe_event = event_type.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')[0, 64] + parts = ['trigger', source, safe_event].reject { |p| p.nil? || p.empty? } + parts.join('.') + end + + def to_h + { + source: source, + event_type: event_type, + action: action, + delivery_id: delivery_id, + verified: verified, + correlation_id: correlation_id, + received_at: received_at, + payload: payload + } + end + + private + + def generate_correlation_id + "leg-#{SecureRandom.hex(8)}" + end + end + end +end diff --git a/lib/legion/trigger/sources/base.rb b/lib/legion/trigger/sources/base.rb new file mode 100644 index 00000000..1d1accb2 --- /dev/null +++ b/lib/legion/trigger/sources/base.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Legion + module Trigger + module Sources + class Base + class << self + def signature_header(name = nil) + name ? @signature_header = name : @signature_header + end + + def event_header(name = nil) + name ? @event_header = name : @event_header + end + + def delivery_header(name = nil) + name ? @delivery_header = name : @delivery_header + end + + def source_name(name = nil) + name ? @source_name = name : @source_name + end + end + + def normalize(headers:, body:) + raise NotImplementedError, "#{self.class}#normalize must be implemented" + end + + def verify_signature(headers:, body_raw:, secret:) + sig_header = self.class.signature_header + return false unless sig_header + + provided = headers[sig_header] + return false unless provided + + expected = compute_signature(body_raw, secret) + secure_compare(provided, expected) + end + + private + + def dig_body(body, key) + return nil unless body.is_a?(Hash) + + body[key] || body[key.to_sym] || body[key.to_s] + end + + def compute_signature(body_raw, secret) + digest = OpenSSL::HMAC.hexdigest('SHA256', secret, body_raw) + "sha256=#{digest}" + end + + def secure_compare(provided, expected) + OpenSSL.secure_compare(provided, expected) + end + end + end + end +end diff --git a/lib/legion/trigger/sources/github.rb b/lib/legion/trigger/sources/github.rb new file mode 100644 index 00000000..7f61ff6d --- /dev/null +++ b/lib/legion/trigger/sources/github.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Legion + module Trigger + module Sources + class Github < Base + source_name 'github' + signature_header 'HTTP_X_HUB_SIGNATURE_256' + event_header 'HTTP_X_GITHUB_EVENT' + delivery_header 'HTTP_X_GITHUB_DELIVERY' + + def normalize(headers:, body:) + { + source: 'github', + event_type: headers[self.class.event_header], + action: dig_body(body, 'action'), + delivery_id: headers[self.class.delivery_header], + payload: body + } + end + end + end + end +end diff --git a/lib/legion/trigger/sources/linear.rb b/lib/legion/trigger/sources/linear.rb new file mode 100644 index 00000000..8856be39 --- /dev/null +++ b/lib/legion/trigger/sources/linear.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Legion + module Trigger + module Sources + class Linear < Base + source_name 'linear' + signature_header 'HTTP_LINEAR_SIGNATURE' + event_header 'HTTP_LINEAR_EVENT' + delivery_header 'HTTP_LINEAR_DELIVERY' + + def normalize(headers:, body:) + { + source: 'linear', + event_type: headers[self.class.event_header] || dig_body(body, 'type') || 'unknown', + action: dig_body(body, 'action'), + delivery_id: headers[self.class.delivery_header], + payload: body + } + end + end + end + end +end diff --git a/lib/legion/trigger/sources/slack.rb b/lib/legion/trigger/sources/slack.rb new file mode 100644 index 00000000..a6f8471c --- /dev/null +++ b/lib/legion/trigger/sources/slack.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Legion + module Trigger + module Sources + class Slack < Base + source_name 'slack' + signature_header 'HTTP_X_SLACK_SIGNATURE' + event_header nil + delivery_header 'HTTP_X_SLACK_REQUEST_TIMESTAMP' + + def normalize(headers:, body:) # rubocop:disable Lint/UnusedMethodArgument + event = dig_body(body, 'event') || {} + { + source: 'slack', + event_type: dig_body(body, 'type') || 'unknown', + action: dig_body(event, 'type'), + delivery_id: dig_body(body, 'event_id'), + payload: body + } + end + + def verify_signature(headers:, body_raw:, secret:) + timestamp = headers['HTTP_X_SLACK_REQUEST_TIMESTAMP'] + return false unless timestamp + return false if (Time.now.to_i - timestamp.to_i).abs > 300 + + sig_basestring = "v0:#{timestamp}:#{body_raw}" + digest = OpenSSL::HMAC.hexdigest('SHA256', secret, sig_basestring) + expected = "v0=#{digest}" + provided = headers[self.class.signature_header] + return false unless provided + + secure_compare(provided, expected) + end + end + end + end +end diff --git a/lib/legion/version.rb b/lib/legion/version.rb old mode 100755 new mode 100644 index f453246a..484813b0 --- a/lib/legion/version.rb +++ b/lib/legion/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Legion - VERSION = '1.2.1'.freeze + VERSION = '1.9.42' end diff --git a/lib/legion/webhooks.rb b/lib/legion/webhooks.rb new file mode 100644 index 00000000..ab0ec365 --- /dev/null +++ b/lib/legion/webhooks.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +require 'openssl' +require 'net/http' +require 'uri' +require 'legion/logging/helper' + +module Legion + module Webhooks + DISPATCH_CACHE_TTL = 5 + + class << self + include Legion::Logging::Helper + + def register(url:, secret:, event_types: ['*'], max_retries: 5, **) + return { error: 'data_unavailable' } unless db_available? + + id = Legion::Data.connection[:webhooks].insert( + url: url, + secret: secret, + event_types: Legion::JSON.dump(event_types), + max_retries: max_retries, + status: 'active', + created_at: Time.now.utc, + updated_at: Time.now.utc + ) + invalidate_dispatch_cache! + { registered: true, id: id } + end + + def unregister(id:, **) + return { error: 'data_unavailable' } unless db_available? + + Legion::Data.connection[:webhooks].where(id: id).delete + invalidate_dispatch_cache! + { unregistered: true } + end + + def list(**) + return [] unless db_available? + + Legion::Data.connection[:webhooks].where(status: 'active').all + end + + def dispatch(event_name, payload) + return unless db_available? + + webhooks = active_dispatch_webhooks + webhooks.each do |wh| + patterns = event_patterns_for(wh, event_name: event_name) + next unless patterns.any? { |p| File.fnmatch?(p, event_name) } + + log.debug { "[Webhooks] dispatching event=#{event_name} webhook_id=#{wh[:id]} patterns=#{patterns.size}" } + deliver(wh, event_name, payload) + end + end + + def deliver(webhook, event_name, payload, attempt: 1) + log.info "[Webhooks] delivery attempt #{attempt} for event=#{event_name} url=#{webhook[:url]}" + body = delivery_body(event_name, payload) + signature = compute_signature(webhook[:secret], body) + + response = perform_delivery_request(webhook[:url], event_name, body, signature) + success = response.code.to_i < 400 + + if success + log.info "[Webhooks] delivered event=#{event_name} status=#{response.code}" + else + log.warn "[Webhooks] delivery failed event=#{event_name} status=#{response.code} url=#{webhook[:url]}" + end + + handle_delivery_response( + webhook: webhook, + event_name: event_name, + payload: payload, + response: response, + success: success, + attempt: attempt + ) + rescue StandardError => e + handle_exception( + e, + level: :error, + operation: 'webhooks.deliver', + event_name: event_name, + webhook_id: webhook[:id], + attempt: attempt, + url: webhook[:url] + ) + handle_delivery_exception(webhook, event_name, payload, attempt, e) + end + + def compute_signature(secret, body) + OpenSSL::HMAC.hexdigest('SHA256', secret, body) + end + + private + + def invalidate_dispatch_cache! + @active_webhooks_cache = nil + @active_webhooks_cached_at = nil + @pattern_cache = {} + end + + def active_dispatch_webhooks + cache_valid = @active_webhooks_cache && @active_webhooks_cached_at && + (monotonic_now - @active_webhooks_cached_at) < DISPATCH_CACHE_TTL + return @active_webhooks_cache if cache_valid + + @active_webhooks_cache = Legion::Data.connection[:webhooks].where(status: 'active').all + @active_webhooks_cached_at = monotonic_now + @active_webhooks_cache + end + + def monotonic_now + ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + end + + def event_patterns_for(webhook, event_name:) + @pattern_cache ||= {} + cached_entry = @pattern_cache[webhook[:id]] + + if cached_entry && + cached_entry[:updated_at] == webhook[:updated_at] && + cached_entry[:event_types] == webhook[:event_types] + return cached_entry[:patterns] + end + + patterns = parse_event_patterns(webhook[:event_types], webhook_id: webhook[:id], event_name: event_name) + @pattern_cache[webhook[:id]] = { + updated_at: webhook[:updated_at], + event_types: webhook[:event_types], + patterns: patterns + } + patterns + end + + def parse_event_patterns(raw_event_types, webhook_id:, event_name:) + parsed = Legion::JSON.load(raw_event_types) + Array(parsed).map(&:to_s).reject(&:empty?).then { |patterns| patterns.empty? ? ['*'] : patterns } + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'webhooks.dispatch.parse_event_types', + event_name: event_name, webhook_id: webhook_id) + ['*'] + end + + def delivery_body(event_name, payload) + Legion::JSON.dump({ event: event_name, payload: payload, timestamp: Time.now.utc.iso8601 }) + end + + def perform_delivery_request(url, event_name, body, signature) + uri = URI.parse(url) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.open_timeout = 5 + http.read_timeout = 10 + + request = Net::HTTP::Post.new(uri.request_uri) + request['Content-Type'] = 'application/json' + request['X-Legion-Signature'] = "sha256=#{signature}" + request['X-Legion-Event'] = event_name + request.body = body + http.request(request) + end + + def handle_delivery_response(delivery) + error_message = "http_status=#{delivery[:response].code}" unless delivery[:success] + record_delivery( + webhook_id: delivery[:webhook][:id], + event_name: delivery[:event_name], + status: delivery[:response].code.to_i, + success: delivery[:success], + error: error_message, + attempt: delivery[:attempt] + ) + return { delivered: true, status: delivery[:response].code.to_i } if delivery[:success] + + finalize_failure( + webhook: delivery[:webhook], + event_name: delivery[:event_name], + payload: delivery[:payload], + attempt: delivery[:attempt], + error: error_message, + response_status: delivery[:response].code.to_i + ) + end + + def handle_delivery_exception(webhook, event_name, payload, attempt, error) + record_delivery( + webhook_id: webhook[:id], + event_name: event_name, + status: nil, + success: false, + error: error.message, + attempt: attempt + ) + finalize_failure( + webhook: webhook, + event_name: event_name, + payload: payload, + attempt: attempt, + error: error.message + ) + end + + def finalize_failure(failure) + if retry_pending?(failure[:webhook], failure[:attempt]) + next_attempt = failure[:attempt] + 1 + log.warn "[Webhooks] retrying event=#{failure[:event_name]} next_attempt=#{next_attempt}" + return deliver(failure[:webhook], failure[:event_name], failure[:payload], attempt: next_attempt) + end + + dead_letter(failure[:webhook][:id], failure[:event_name], failure[:payload], failure[:attempt], failure[:error]) + { delivered: false, error: failure[:error], dead_lettered: true, status: failure[:response_status] } + end + + def retry_pending?(webhook, attempt) + attempt <= retry_limit(webhook) + end + + def retry_limit(webhook) + retries = webhook[:max_retries].to_i + retries.negative? ? 0 : retries + end + + def db_available? + defined?(Legion::Data) && Legion::Data.respond_to?(:connection) && Legion::Data.connection + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'webhooks.db_available?') + false + end + + def record_delivery(delivery) + Legion::Data.connection[:webhook_deliveries].insert( + webhook_id: delivery[:webhook_id], + event_name: delivery[:event_name], + response_status: delivery[:status], + success: delivery[:success], + attempt: delivery.fetch(:attempt, 1), + error: delivery[:error], + delivered_at: Time.now.utc + ) + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'webhooks.record_delivery', + webhook_id: delivery[:webhook_id], event_name: delivery[:event_name], + status: delivery[:status], success: delivery[:success], attempt: delivery.fetch(:attempt, 1)) + nil + end + + def dead_letter(webhook_id, event_name, payload, attempts, error) + Legion::Data.connection[:webhook_dead_letters].insert( + webhook_id: webhook_id, + event_name: event_name, + payload: Legion::JSON.dump(payload), + attempts: attempts, + last_error: error, + created_at: Time.now.utc + ) + rescue StandardError => e + handle_exception(e, level: :debug, operation: 'webhooks.dead_letter', + webhook_id: webhook_id, event_name: event_name, attempts: attempts) + nil + end + end + end +end diff --git a/lib/legion/workflow.rb b/lib/legion/workflow.rb new file mode 100644 index 00000000..4f6cb00e --- /dev/null +++ b/lib/legion/workflow.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Legion + module Workflow + autoload :Manifest, 'legion/workflow/manifest' + autoload :Loader, 'legion/workflow/loader' + end +end diff --git a/lib/legion/workflow/loader.rb b/lib/legion/workflow/loader.rb new file mode 100644 index 00000000..ee978f54 --- /dev/null +++ b/lib/legion/workflow/loader.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Legion + module Workflow + class Loader + def install(manifest) + return { success: false, errors: manifest.errors } unless manifest.valid? + + missing = check_requirements(manifest.requires) + return { success: false, error: :missing_gems, gems: missing } if missing.any? + + chain_id = find_or_create_chain(manifest.name) + ids = [] + + manifest.relationships.each_with_index do |rel, idx| + trigger_id = resolve_function_id(rel[:trigger]) + return { success: false, error: :trigger_not_found, relationship: rel[:name] || idx } unless trigger_id + + action_id = resolve_function_id(rel[:action]) + return { success: false, error: :action_not_found, relationship: rel[:name] || idx } unless action_id + + id = Legion::Data::Model::Relationship.insert( + trigger_id: trigger_id, + action_id: action_id, + name: rel[:name], + chain_id: chain_id, + conditions: rel[:conditions] ? Legion::JSON.dump(rel[:conditions]) : nil, + transformation: rel[:transformation] ? Legion::JSON.dump(rel[:transformation]) : nil, + delay: rel.fetch(:delay, 0), + allow_new_chains: idx.zero? || rel[:allow_new_chains], + active: true, + status: 'active', + relationship_type: 'chain' + ) + ids << id + end + + { success: true, chain_id: chain_id, relationship_ids: ids } + end + + def uninstall(name) + chain = Legion::Data::Model::Chain.where(name: name).first + return { success: false, error: :not_found } unless chain + + chain_id = chain.values[:id] + count = Legion::Data::Model::Relationship.where(chain_id: chain_id).delete + chain.delete + + { success: true, deleted_relationships: count } + end + + def list + Legion::Data::Model::Chain.all.map do |chain| + v = chain.values + rel_count = Legion::Data::Model::Relationship.where(chain_id: v[:id]).count + { id: v[:id], name: v[:name], relationships: rel_count } + end + end + + def status(name) + chain = Legion::Data::Model::Chain.where(name: name).first + return { success: false, error: :not_found } unless chain + + chain_id = chain.values[:id] + rels = Legion::Data::Model::Relationship + .where(chain_id: chain_id) + .all + .map { |r| format_relationship(r) } + + { success: true, name: name, chain_id: chain_id, relationships: rels } + end + + private + + def check_requirements(requires) + requires.select do |gem_name| + Gem::Specification.find_all_by_name(gem_name).empty? + end + end + + def find_or_create_chain(name) + existing = Legion::Data::Model::Chain.where(name: name).first + return existing.values[:id] if existing + + Legion::Data::Model::Chain.insert(name: name) + end + + def resolve_function_id(ref) + ext = Legion::Data::Model::Extension.where(name: ref[:extension].to_s).first + return nil unless ext + + runner = Legion::Data::Model::Runner.where( + extension_id: ext.values[:id], + name: ref[:runner].to_s + ).first + return nil unless runner + + func = Legion::Data::Model::Function.where( + runner_id: runner.values[:id], + name: ref[:function].to_s + ).first + + func&.values&.[](:id) + end + + def format_relationship(rel) + v = rel.values + trigger = v[:trigger_id] ? Legion::Data::Model::Function[v[:trigger_id]] : nil + action = v[:action_id] ? Legion::Data::Model::Function[v[:action_id]] : nil + + { + id: v[:id], + name: v[:name], + trigger: trigger&.values&.[](:name), + action: action&.values&.[](:name), + conditions: !v[:conditions].nil?, + active: v[:active] + } + end + end + end +end diff --git a/lib/legion/workflow/manifest.rb b/lib/legion/workflow/manifest.rb new file mode 100644 index 00000000..568ddf9a --- /dev/null +++ b/lib/legion/workflow/manifest.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'yaml' + +module Legion + module Workflow + class Manifest + attr_reader :name, :version, :description, :requires, :relationships, :settings + + def initialize(path:) + raw = YAML.safe_load_file(path, symbolize_names: true) + @name = raw[:name] + @version = raw[:version] + @description = raw[:description] + @requires = raw[:requires] || [] + @relationships = parse_relationships(raw[:relationships] || []) + @settings = raw[:settings] || {} + end + + def valid? + errors.empty? + end + + def errors + errs = [] + errs << 'name is required' unless name + errs << 'at least one relationship is required' if relationships.empty? + relationships.each_with_index do |rel, i| + errs << "relationship #{i}: trigger is required" unless rel[:trigger] + errs << "relationship #{i}: action is required" unless rel[:action] + %i[trigger action].each do |key| + next unless rel[key] + + %i[extension runner function].each do |field| + errs << "relationship #{i}: #{key}.#{field} is required" unless rel[key][field] + end + end + end + errs + end + + private + + def parse_relationships(rels) + rels.map do |rel| + { + name: rel[:name], + trigger: rel[:trigger], + action: rel[:action], + conditions: rel[:conditions], + transformation: rel[:transformation], + delay: rel.fetch(:delay, 0), + allow_new_chains: rel.fetch(:allow_new_chains, false) + } + end + end + end + end +end diff --git a/public/governance/index.html b/public/governance/index.html new file mode 100644 index 00000000..bd8f995e --- /dev/null +++ b/public/governance/index.html @@ -0,0 +1,284 @@ + + + + + + Legion Governance Board + + + + + +
+
Loading approvals...
+ + + + + + + + + + + + + + + +
IDTypeRequesterPayloadCreatedStatusActions
Loading...
+
+ + +

Confirm Action

+ + +
+ + +
+
+ + + + diff --git a/public/workflow/index.html b/public/workflow/index.html new file mode 100644 index 00000000..071c2989 --- /dev/null +++ b/public/workflow/index.html @@ -0,0 +1,216 @@ + + + + + + Legion Workflow Visualizer + + + + + + + +
+
+ +
+
Loading...
+ + + + diff --git a/scripts/fleet_smoke_test.rb b/scripts/fleet_smoke_test.rb new file mode 100755 index 00000000..3e56f5a4 --- /dev/null +++ b/scripts/fleet_smoke_test.rb @@ -0,0 +1,237 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Fleet Pipeline Smoke Test +# ========================= +# Runs against a live RabbitMQ instance to verify exchange/queue topology +# and basic message flow. +# +# Prerequisites: +# - RabbitMQ running on localhost:5672 (or set RABBITMQ_URL) +# - Legion gems installed: legion-transport, legion-settings, legion-json +# - Fleet extensions deployed: lex-assessor, lex-planner, lex-developer, lex-validator +# +# Usage: +# ruby scripts/fleet_smoke_test.rb +# RABBITMQ_URL=amqp://user:pass@host:5672 ruby scripts/fleet_smoke_test.rb + +require 'json' +require 'securerandom' +require 'timeout' + +# Suppress legion logging noise +ENV['LEGION_LOG_LEVEL'] ||= 'error' + +class FleetSmokeTest + FLEET_EXCHANGES = %w[ + lex.assessor lex.planner lex.developer lex.validator + ].freeze + + FLEET_QUEUES = %w[ + lex.assessor.runners.assessor + lex.planner.runners.planner + lex.developer.runners.developer + lex.developer.runners.ship + lex.validator.runners.validator + ].freeze + + ABSORBER_QUEUES = %w[ + lex.github.absorbers.issues.absorb + ].freeze + + attr_reader :results + + def initialize + @results = [] + @passed = 0 + @failed = 0 + end + + def run + puts '=' * 60 + puts 'Fleet Pipeline Smoke Test' + puts '=' * 60 + puts + + check_dependencies + setup_transport + check_exchanges + check_queues + check_absorber_queues + test_publish_consume + teardown + + report + end + + private + + def check_dependencies + section('Checking dependencies') + + %w[legion-transport legion-settings legion-json].each do |gem_name| + Gem::Specification.find_by_name(gem_name) + pass("#{gem_name} installed") + rescue Gem::MissingSpecError + fail_test("#{gem_name} not installed") + end + end + + def setup_transport + section('Connecting to RabbitMQ') + + require 'legion/settings' + require 'legion/logging' + require 'legion/transport' + + Legion::Logging.setup(log_level: 'error', level: 'error', trace: false) + Legion::Settings.load + + if ENV['RABBITMQ_URL'] + Legion::Settings.loader.settings[:transport] ||= {} + Legion::Settings.loader.settings[:transport][:url] = ENV.fetch('RABBITMQ_URL', nil) + end + + Legion::Settings.merge_settings('transport', Legion::Transport::Settings.default) + Legion::Transport::Connection.setup + pass('Connected to RabbitMQ') + rescue StandardError => e + fail_test("RabbitMQ connection failed: #{e.message}") + puts "\n Set RABBITMQ_URL or configure transport in ~/.legionio/settings/" + exit 1 + end + + def check_exchanges + section('Checking fleet exchanges') + + channel = Legion::Transport::Connection.session.create_channel + FLEET_EXCHANGES.each do |name| + check_or_create_exchange(channel, name) + channel = Legion::Transport::Connection.session.create_channel + end + end + + def check_or_create_exchange(channel, name) + channel.exchange_declare(name, 'topic', passive: true) + pass("Exchange #{name} exists") + rescue Bunny::NotFound + channel = Legion::Transport::Connection.session.create_channel + channel.exchange_declare(name, 'topic', durable: true) + pass("Exchange #{name} created") + rescue StandardError => e + fail_test("Exchange #{name} check failed: #{e.message}") + end + + def check_queues + section('Checking fleet queues') + + channel = Legion::Transport::Connection.session.create_channel + FLEET_QUEUES.each do |name| + check_or_create_queue(channel, name) + channel = Legion::Transport::Connection.session.create_channel + end + end + + def check_absorber_queues + section('Checking absorber queues') + + channel = Legion::Transport::Connection.session.create_channel + ABSORBER_QUEUES.each do |name| + check_or_create_queue(channel, name, prefix: 'Absorber queue') + channel = Legion::Transport::Connection.session.create_channel + end + end + + def check_or_create_queue(channel, name, prefix: 'Queue') + q = channel.queue(name, durable: true, passive: true) + pass("#{prefix} #{name} exists (depth: #{q.message_count})") + rescue Bunny::NotFound + channel = Legion::Transport::Connection.session.create_channel + channel.queue(name, durable: true) + pass("#{prefix} #{name} created") + rescue StandardError => e + fail_test("#{prefix} #{name} check failed: #{e.message}") + end + + def test_publish_consume + section('Testing publish/consume round-trip') + + channel = Legion::Transport::Connection.session.create_channel + test_queue_name = "fleet.smoke_test.#{SecureRandom.hex(4)}" + + exchange = channel.topic('lex.assessor', durable: true) + queue = channel.queue(test_queue_name, durable: false, auto_delete: true) + queue.bind(exchange, routing_key: "#{test_queue_name}.#") + + test_payload = { + work_item_id: SecureRandom.uuid, + source: 'smoke_test', + title: 'Fleet smoke test message', + timestamp: Time.now.utc.iso8601 + } + + exchange.publish( + JSON.generate(test_payload), + routing_key: "#{test_queue_name}.test", + content_type: 'application/json', + persistent: false + ) + + received = nil + Timeout.timeout(5) do + _, _, body = queue.pop + received = body ? JSON.parse(body, symbolize_names: true) : nil + end + + if received && received[:work_item_id] == test_payload[:work_item_id] + pass('Publish/consume round-trip successful') + else + fail_test('Message not received or payload mismatch') + end + rescue Timeout::Error + fail_test('Publish/consume timed out after 5 seconds') + rescue StandardError => e + fail_test("Publish/consume failed: #{e.message}") + ensure + queue&.delete + end + + def teardown + Legion::Transport::Connection.shutdown + rescue StandardError + nil + end + + def section(title) + puts + puts "--- #{title} ---" + end + + def pass(message) + @passed += 1 + @results << { status: :pass, message: message } + puts " [PASS] #{message}" + end + + def fail_test(message) + @failed += 1 + @results << { status: :fail, message: message } + puts " [FAIL] #{message}" + end + + def report + puts + puts '=' * 60 + total = @passed + @failed + if @failed.zero? + puts "ALL #{total} CHECKS PASSED" + else + puts "#{@passed}/#{total} passed, #{@failed} FAILED" + end + puts '=' * 60 + + exit(@failed.zero? ? 0 : 1) + end +end + +FleetSmokeTest.new.run if $PROGRAM_NAME == __FILE__ diff --git a/scripts/rollout-ci-workflow.sh b/scripts/rollout-ci-workflow.sh new file mode 100755 index 00000000..5824af8f --- /dev/null +++ b/scripts/rollout-ci-workflow.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +set -uo pipefail + +# rollout-ci-workflow.sh +# +# Replaces per-repo CI workflows with a call to the org-level reusable workflow. +# Commits and pushes to each repo. +# +# Usage: +# ./scripts/rollout-ci-workflow.sh # apply and push +# ./scripts/rollout-ci-workflow.sh --dry-run # preview without changes + +LEGION_DIR="/Users/miverso2/rubymine/legion" +DRY_RUN=false + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + esac +done + +# repos that need services +needs_rabbitmq="LegionIO legion-transport" +needs_redis="LegionIO legion-cache lex-redis" + +get_workflow() { + local name="$1" + local rabbit=false + local redis=false + + for r in $needs_rabbitmq; do [ "$r" = "$name" ] && rabbit=true; done + for r in $needs_redis; do [ "$r" = "$name" ] && redis=true; done + + cat </dev/null; then + echo "[$name] already using reusable workflow, skipping" + skipped=$((skipped + 1)) + continue + fi + + echo "[$name] updating ci.yml" + + if [ "$DRY_RUN" = true ]; then + echo " [dry-run] would write:" + echo "$workflow_content" | sed 's/^/ /' + echo "" + count=$((count + 1)) + continue + fi + + mkdir -p "$dir/.github/workflows" + echo "$workflow_content" > "$workflow_file" + + cd "$dir" + + # ensure correct git identity + git_email=$(git config user.email 2>/dev/null || true) + if [ "$git_email" != "matthewdiverson@gmail.com" ]; then + git config user.name "Esity" + git config user.email "matthewdiverson@gmail.com" + fi + + git add .github/workflows/ci.yml + if git diff --cached --quiet; then + echo " no changes, skipping" + skipped=$((skipped + 1)) + continue + fi + + git commit -m "switch to org-level reusable ci workflow" || { echo " commit failed"; errors=$((errors + 1)); continue; } + git push 2>&1 || { echo " push failed"; errors=$((errors + 1)); continue; } + + echo " done" + count=$((count + 1)) + cd "$LEGION_DIR" +done + +echo "" +echo "updated: $count | skipped: $skipped | errors: $errors" diff --git a/scripts/sync-github-labels-topics.sh b/scripts/sync-github-labels-topics.sh new file mode 100755 index 00000000..d45bc379 --- /dev/null +++ b/scripts/sync-github-labels-topics.sh @@ -0,0 +1,281 @@ +#!/usr/bin/env bash +set -euo pipefail + +# sync-github-labels-topics.sh +# +# Applies standardized GitHub topics and issue labels across all LegionIO repos. +# Requires: gh CLI authenticated with repo admin access. +# Compatible with bash 3.2+ (macOS default). +# +# Usage: +# ./scripts/sync-github-labels-topics.sh # apply everything +# ./scripts/sync-github-labels-topics.sh --dry-run # preview without changes +# ./scripts/sync-github-labels-topics.sh --labels # labels only +# ./scripts/sync-github-labels-topics.sh --topics # topics only + +ORG="LegionIO" +DRY_RUN=false +DO_LABELS=true +DO_TOPICS=true + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --labels) DO_TOPICS=false ;; + --topics) DO_LABELS=false ;; + --help|-h) + echo "Usage: $0 [--dry-run] [--labels] [--topics]" + exit 0 + ;; + esac +done + +run() { + if [ "$DRY_RUN" = true ]; then + echo "[dry-run] $*" + else + "$@" + fi +} + +# ───────────────────────────────────────────────────────────────── +# LABEL DEFINITIONS +# Each line: name|color|description +# ───────────────────────────────────────────────────────────────── + +LABELS=" +type:bug|d73a4a|Something isn't working +type:enhancement|a2eeef|New feature or improvement +type:docs|0075ca|Documentation only +type:chore|e4e669|Maintenance, deps, CI +type:breaking|b60205|Breaking change +priority:critical|b60205|Must fix immediately +priority:high|d93f0b|Next up +priority:medium|fbca04|Normal priority +priority:low|0e8a16|Nice to have +area:transport|c5def5|RabbitMQ / AMQP messaging +area:crypt|c5def5|Encryption, Vault, JWT +area:data|c5def5|Database / Sequel ORM +area:cache|c5def5|Redis / Memcached caching +area:settings|c5def5|Configuration management +area:logging|c5def5|Logging +area:json|c5def5|JSON serialization +area:cli|c5def5|CLI commands +area:api|c5def5|REST API +area:mcp|c5def5|MCP server +area:extensions|c5def5|Extension system / LEX +area:actors|c5def5|Actor execution modes +area:runners|c5def5|Runner functions +good first issue|7057ff|Good for newcomers +help wanted|008672|Extra attention needed +" + +# default labels to remove (replaced by type: prefixed versions) +LABELS_TO_REMOVE="bug enhancement documentation duplicate invalid question wontfix" + +# ───────────────────────────────────────────────────────────────── +# TOPIC DEFINITIONS +# ───────────────────────────────────────────────────────────────── + +get_topics() { + local repo="$1" + local base="legionio,ruby" + + case "$repo" in + # skip non-code repos + .github|catalog) + echo "" + return + ;; + + # framework + LegionIO) + echo "${base},legion-framework,legion-core,mcp,model-context-protocol,sinatra,cli,async" + ;; + + # meta / org-level + agentic-ai) + echo "${base},ai,multi-agent" + ;; + Legion) + echo "${base},legion-framework" + ;; + + # core libraries + legion-transport) echo "${base},legion-core,rabbitmq,amqp" ;; + legion-crypt) echo "${base},legion-core,vault,encryption,jwt" ;; + legion-data) echo "${base},legion-core,sequel,database" ;; + legion-cache) echo "${base},legion-core,redis,memcached,caching" ;; + legion-json) echo "${base},legion-core,json" ;; + legion-logging) echo "${base},legion-core,logging" ;; + legion-settings) echo "${base},legion-core,configuration" ;; + legion-llm) echo "${base},legion-core,ai,llm" ;; + + # built-in extensions + lex-node) echo "${base},legion-extension,legion-builtin,cluster,heartbeat" ;; + lex-node_manager) echo "${base},legion-extension,legion-builtin,cluster" ;; + lex-tasker) echo "${base},legion-extension,legion-builtin" ;; + lex-conditioner) echo "${base},legion-extension,legion-builtin" ;; + lex-transformer) echo "${base},legion-extension,legion-builtin" ;; + lex-scheduler) echo "${base},legion-extension,legion-builtin,cron,scheduling" ;; + task_pruner) echo "${base},legion-extension,legion-builtin" ;; + lex-mesh) echo "${base},legion-extension,legion-builtin,networking" ;; + lex-swarm) echo "${base},legion-extension,legion-builtin,multi-agent" ;; + lex-swarm-github) echo "${base},legion-extension,legion-builtin,multi-agent" ;; + lex-memory) echo "${base},legion-extension,legion-builtin,ai" ;; + lex-emotion) echo "${base},legion-extension,legion-builtin,ai" ;; + lex-identity) echo "${base},legion-extension,legion-builtin,identity,auth" ;; + lex-trust) echo "${base},legion-extension,legion-builtin,security" ;; + lex-governance) echo "${base},legion-extension,legion-builtin,governance" ;; + lex-consent) echo "${base},legion-extension,legion-builtin,security" ;; + lex-prediction) echo "${base},legion-extension,legion-builtin,ai" ;; + lex-coldstart) echo "${base},legion-extension,legion-builtin,ai" ;; + lex-conflict) echo "${base},legion-extension,legion-builtin,conflict-resolution" ;; + lex-extinction) echo "${base},legion-extension,legion-builtin,governance" ;; + lex-tick) echo "${base},legion-extension,legion-builtin,timing,clock" ;; + lex-privatecore) echo "${base},legion-extension,legion-builtin,security" ;; + lex-lex) echo "${base},legion-extension,legion-builtin" ;; + + # service extensions - notifications + lex-slack) echo "${base},legion-extension,notifications" ;; + lex-pushbullet) echo "${base},legion-extension,notifications" ;; + lex-pushover) echo "${base},legion-extension,notifications" ;; + lex-smtp) echo "${base},legion-extension,notifications" ;; + lex-twilio) echo "${base},legion-extension,notifications" ;; + + # service extensions - datastore + lex-redis) echo "${base},legion-extension,datastore" ;; + lex-memcached) echo "${base},legion-extension,datastore" ;; + lex-elasticsearch) echo "${base},legion-extension,datastore" ;; + lex-elastic_app_search) echo "${base},legion-extension,datastore" ;; + lex-influxdb) echo "${base},legion-extension,datastore" ;; + lex-s3) echo "${base},legion-extension,datastore" ;; + + # service extensions - monitoring + lex-pagerduty) echo "${base},legion-extension,monitoring" ;; + lex-ping) echo "${base},legion-extension,monitoring" ;; + lex-health) echo "${base},legion-extension,monitoring" ;; + lex-log) echo "${base},legion-extension,monitoring" ;; + + # service extensions - ai + lex-claude) echo "${base},legion-extension,ai" ;; + lex-openai) echo "${base},legion-extension,ai" ;; + lex-gemini) echo "${base},legion-extension,ai" ;; + + # service extensions - infrastructure + lex-chef) echo "${base},legion-extension,infrastructure" ;; + lex-ssh) echo "${base},legion-extension,infrastructure" ;; + lex-http) echo "${base},legion-extension,infrastructure" ;; + lex-github) echo "${base},legion-extension,infrastructure" ;; + lex-pihole) echo "${base},legion-extension,infrastructure" ;; + + # service extensions - productivity + lex-todoist) echo "${base},legion-extension,productivity" ;; + + # service extensions - smart home + lex-sonos) echo "${base},legion-extension,smart-home" ;; + lex-ecobee) echo "${base},legion-extension,smart-home" ;; + lex-esphome) echo "${base},legion-extension,smart-home" ;; + lex-myq) echo "${base},legion-extension,smart-home" ;; + lex-sleepiq) echo "${base},legion-extension,smart-home" ;; + lex-wled) echo "${base},legion-extension,smart-home" ;; + + # catch-all for unknown lex-* repos + lex-*) + echo "${base},legion-extension" + ;; + + # catch-all for unknown legion-* repos + legion-*) + echo "${base},legion-core" + ;; + + *) + echo "${base}" + ;; + esac +} + +# ───────────────────────────────────────────────────────────────── +# APPLY LABELS +# ───────────────────────────────────────────────────────────────── + +apply_labels() { + local repo="$1" + local full="${ORG}/${repo}" + + echo " labels: syncing..." + + # fetch existing labels once + local existing + existing=$(gh label list --repo "$full" --json name --jq '.[].name' 2>/dev/null || true) + + # remove default labels that conflict with our type: labels + for old_label in $LABELS_TO_REMOVE; do + if echo "$existing" | grep -qx "$old_label"; then + echo " removing default label: $old_label" + run gh label delete "$old_label" --repo "$full" --yes + fi + done + + # create or update our labels + echo "$LABELS" | while IFS='|' read -r name color desc; do + [ -z "$name" ] && continue + + if echo "$existing" | grep -qxF "$name"; then + run gh label edit "$name" --repo "$full" --color "$color" --description "$desc" + else + echo " creating label: $name" + run gh label create "$name" --repo "$full" --color "$color" --description "$desc" + fi + done +} + +# ───────────────────────────────────────────────────────────────── +# APPLY TOPICS +# ───────────────────────────────────────────────────────────────── + +apply_topics() { + local repo="$1" + local full="${ORG}/${repo}" + local topics + topics=$(get_topics "$repo") + + if [ -z "$topics" ]; then + echo " topics: skipped (non-code repo)" + return + fi + + echo " topics: ${topics}" + run gh repo edit "$full" --add-topic "$topics" +} + +# ───────────────────────────────────────────────────────────────── +# MAIN +# ───────────────────────────────────────────────────────────────── + +echo "Fetching repos from ${ORG}..." +REPOS=$(gh repo list "$ORG" --limit 200 --json name --jq '.[].name' | sort) +REPO_COUNT=$(echo "$REPOS" | wc -l | tr -d ' ') + +echo "Found ${REPO_COUNT} repos" +if [ "$DRY_RUN" = true ]; then + echo "=== DRY RUN MODE ===" +fi +echo "" + +for repo in $REPOS; do + echo "[$repo]" + + if [ "$DO_TOPICS" = true ]; then + apply_topics "$repo" + fi + + if [ "$DO_LABELS" = true ]; then + apply_labels "$repo" + fi + + echo "" +done + +echo "done." diff --git a/sourcehawk.yml b/sourcehawk.yml deleted file mode 100644 index a228e9b9..00000000 --- a/sourcehawk.yml +++ /dev/null @@ -1,4 +0,0 @@ - -config-locations: - - https://raw.githubusercontent.com/optum/.github/main/sourcehawk.yml - diff --git a/spec/api/acp_spec.rb b/spec/api/acp_spec.rb new file mode 100644 index 00000000..5b910279 --- /dev/null +++ b/spec/api/acp_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'ACP API routes' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /.well-known/agent.json' do + it 'returns an agent card' do + get '/.well-known/agent.json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:name]).to be_a(String) + expect(body[:protocol]).to eq('acp/1.0') + end + end + + describe 'POST /api/acp/tasks' do + it 'accepts a task and returns 202' do + allow(Legion::Ingress).to receive(:run).and_return({ task_id: 1, success: true }) + post '/api/acp/tasks', Legion::JSON.dump({ input: { text: 'test' } }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(202) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('queued') + end + end + + describe 'GET /api/acp/tasks/:id' do + it 'returns 404 for unknown task' do + get '/api/acp/tasks/99999' + expect(last_response.status).to eq(404) + end + end +end diff --git a/spec/api/api_spec_helper.rb b/spec/api/api_spec_helper.rb new file mode 100644 index 00000000..572b6326 --- /dev/null +++ b/spec/api/api_spec_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'legion/crypt' +require 'legion/api' + +module ApiSpecSetup + def self.configure_settings + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + loader.settings[:data] = { connected: false } + loader.settings[:transport] = { connected: false } + loader.settings[:extensions] = {} + end +end diff --git a/spec/api/audit_spec.rb b/spec/api/audit_spec.rb new file mode 100644 index 00000000..93bb3b39 --- /dev/null +++ b/spec/api/audit_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Audit API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/audit' do + it 'returns 503 when data is not connected' do + get '/api/audit' + expect(last_response.status).to eq(503) + end + end + + describe 'GET /api/audit/verify' do + it 'returns 503 when data is not connected' do + get '/api/audit/verify' + expect(last_response.status).to eq(503) + end + end +end diff --git a/spec/api/auth_human_spec.rb b/spec/api/auth_human_spec.rb new file mode 100644 index 00000000..e37ddfd7 --- /dev/null +++ b/spec/api/auth_human_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' +require 'legion/api/token' + +# Stub Legion::Rbac::EntraClaimsMapper if legion-rbac is not installed +unless defined?(Legion::Rbac::EntraClaimsMapper) + module Legion + module Rbac + module EntraClaimsMapper + DEFAULT_ROLE_MAP = { + 'Legion.Admin' => 'admin', + 'Legion.Supervisor' => 'supervisor', + 'Legion.Worker' => 'worker', + 'Legion.Observer' => 'governance-observer' + }.freeze + + module_function + + def map_claims(entra_claims, role_map: DEFAULT_ROLE_MAP, group_map: {}, default_role: 'worker') # rubocop:disable Lint/UnusedMethodArgument + roles = [] + Array(entra_claims[:roles]).each do |r| + roles << role_map[r] if role_map[r] + end + roles << default_role if roles.empty? + { sub: entra_claims[:oid], name: entra_claims[:name], roles: roles, team: entra_claims[:tid], scope: 'human' } + end + end + end + end +end + +RSpec.describe 'Human Auth Flow' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + let(:entra_settings) do + { + tenant_id: 'tenant-1', + client_id: 'legion-web-app', + client_secret: 'test-secret', + redirect_uri: 'http://localhost:4567/api/auth/callback', + role_map: Legion::Rbac::EntraClaimsMapper::DEFAULT_ROLE_MAP, + group_map: {}, + default_role: 'worker' + } + end + + before do + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:rbac).and_return({ entra: entra_settings }) + end + + describe 'GET /api/auth/authorize' do + before do + allow(Legion::Crypt::JWT).to receive(:issue).and_return('state-token') + end + + it 'redirects to Entra authorization endpoint' do + get '/api/auth/authorize' + expect(last_response.status).to eq(302) + location = last_response.headers['Location'] + expect(location).to include('login.microsoftonline.com/tenant-1/oauth2/v2.0/authorize') + expect(location).to include('client_id=legion-web-app') + expect(location).to include('response_type=code') + end + end + + describe 'GET /api/auth/callback' do + let(:id_token_claims) do + { oid: 'user-oid', name: 'Jane Doe', tid: 'tenant-1', + roles: ['Legion.Supervisor'], groups: [] } + end + + before do + allow(Legion::Crypt::JWT).to receive(:verify).and_return({ nonce: 'x', purpose: 'oauth_state' }) + allow(Legion::API::Routes::AuthHuman).to receive(:exchange_code) + .and_return({ id_token: 'entra-id-token', access_token: 'entra-at' }) + allow(Legion::Crypt::JWT).to receive(:verify_with_jwks).and_return(id_token_claims) + allow(Legion::API::Token).to receive(:issue_human_token).and_return('legion-human-jwt') + end + + it 'exchanges code and returns Legion JWT in JSON mode' do + get '/api/auth/callback', { code: 'auth-code', state: 'state-token' }, + 'HTTP_ACCEPT' => 'application/json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:access_token]).to eq('legion-human-jwt') + expect(body[:data][:roles]).to eq(['supervisor']) + end + + it 'returns 400 when code is missing' do + get '/api/auth/callback', { state: 'state-token' }, 'HTTP_ACCEPT' => 'application/json' + expect(last_response.status).to eq(400) + end + + it 'returns 400 when Entra returns an error' do + get '/api/auth/callback', { error: 'access_denied', error_description: 'denied' }, + 'HTTP_ACCEPT' => 'application/json' + expect(last_response.status).to eq(400) + end + + it 'validates CSRF state token' do + allow(Legion::Crypt::JWT).to receive(:verify).with('bad-state') + .and_raise(Legion::Crypt::JWT::Error, 'invalid') + get '/api/auth/callback', { code: 'auth-code', state: 'bad-state' }, + 'HTTP_ACCEPT' => 'application/json' + expect(last_response.status).to eq(400) + end + + it 'redirects in browser mode' do + get '/api/auth/callback', { code: 'auth-code', state: 'state-token' }, + 'HTTP_ACCEPT' => 'text/html' + expect(last_response.status).to eq(302) + expect(last_response.headers['Location']).to include('access_token=legion-human-jwt') + end + end +end diff --git a/spec/api/auth_spec.rb b/spec/api/auth_spec.rb new file mode 100644 index 00000000..e1401c58 --- /dev/null +++ b/spec/api/auth_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' +require 'legion/api/token' + +# Stub Legion::Rbac::EntraClaimsMapper if legion-rbac is not installed +unless defined?(Legion::Rbac::EntraClaimsMapper) + module Legion + module Rbac + module EntraClaimsMapper + DEFAULT_ROLE_MAP = { + 'Legion.Admin' => 'admin', + 'Legion.Supervisor' => 'supervisor', + 'Legion.Worker' => 'worker', + 'Legion.Observer' => 'governance-observer' + }.freeze + + module_function + + def map_claims(entra_claims, role_map: DEFAULT_ROLE_MAP, group_map: {}, default_role: 'worker') # rubocop:disable Lint/UnusedMethodArgument + roles = [] + Array(entra_claims[:roles]).each do |r| + roles << role_map[r] if role_map[r] + end + roles << default_role if roles.empty? + { sub: entra_claims[:oid], name: entra_claims[:name], roles: roles, team: entra_claims[:tid], scope: 'human' } + end + end + end + end +end + +RSpec.describe 'Auth API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + let(:valid_body) do + { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: 'entra-jwt-token' + } + end + + let(:entra_claims) do + { oid: 'user-oid', name: 'Jane Doe', tid: 'tenant-1', + roles: ['Legion.Supervisor'], groups: [] } + end + + let(:rbac_entra_settings) do + { + tenant_id: 'tenant-1', + role_map: Legion::Rbac::EntraClaimsMapper::DEFAULT_ROLE_MAP, + group_map: {}, + default_role: 'worker' + } + end + + before do + allow(Legion::Settings).to receive(:[]).and_call_original + rbac_hash = { entra: rbac_entra_settings } + allow(Legion::Settings).to receive(:[]).with(:rbac).and_return(rbac_hash) + end + + describe 'POST /api/auth/token' do + context 'with valid Entra token' do + before do + allow(Legion::Crypt::JWT).to receive(:verify_with_jwks).and_return(entra_claims) + allow(Legion::API::Token).to receive(:issue_human_token).and_return('legion-jwt-123') + end + + it 'returns a Legion access token' do + post '/api/auth/token', Legion::JSON.dump(valid_body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:access_token]).to eq('legion-jwt-123') + expect(body[:data][:token_type]).to eq('Bearer') + expect(body[:data][:roles]).to eq(['supervisor']) + end + + it 'issues token with mapped roles' do + expect(Legion::API::Token).to receive(:issue_human_token).with( + hash_including(msid: 'user-oid', roles: ['supervisor']) + ).and_return('legion-jwt-123') + post '/api/auth/token', Legion::JSON.dump(valid_body), 'CONTENT_TYPE' => 'application/json' + end + end + + context 'with expired Entra token' do + before do + allow(Legion::Crypt::JWT).to receive(:verify_with_jwks) + .and_raise(Legion::Crypt::JWT::ExpiredTokenError, 'token has expired') + end + + it 'returns 401' do + post '/api/auth/token', Legion::JSON.dump(valid_body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(401) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('token_expired') + end + end + + context 'with invalid grant_type' do + it 'returns 400' do + body = valid_body.merge(grant_type: 'authorization_code') + post '/api/auth/token', Legion::JSON.dump(body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + end + end + + context 'with missing subject_token' do + it 'returns 400' do + body = valid_body.except(:subject_token) + post '/api/auth/token', Legion::JSON.dump(body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + end + end + + context 'with no tenant configured' do + let(:rbac_entra_settings) { { tenant_id: nil } } + + before do + allow(Legion::Crypt::JWT).to receive(:verify_with_jwks) + end + + it 'returns 500' do + post '/api/auth/token', Legion::JSON.dump(valid_body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(500) + end + end + end +end diff --git a/spec/api/auth_worker_spec.rb b/spec/api/auth_worker_spec.rb new file mode 100644 index 00000000..4800433d --- /dev/null +++ b/spec/api/auth_worker_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' +require 'legion/api/token' + +RSpec.describe 'POST /api/auth/worker-token' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + let(:valid_body) do + { grant_type: 'client_credentials', entra_token: 'entra-jwt' } + end + + let(:mock_worker) do + double('worker', worker_id: 'wkr-uuid-1', owner_msid: 'owner@uhg.com', + lifecycle_state: 'active', entra_app_id: 'app-123') + end + + before do + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:identity).and_return({ entra: { tenant_id: 'tenant-1' } }) + stub_const('Legion::Data::Model::DigitalWorker', double('DW')) + end + + context 'with valid Entra token' do + before do + allow(Legion::Crypt::JWT).to receive(:verify_with_jwks).and_return({ appid: 'app-123' }) + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(entra_app_id: 'app-123').and_return(mock_worker) + allow(Legion::API::Token).to receive(:issue_worker_token).and_return('legion-jwt-456') + end + + it 'returns a Legion worker JWT' do + post '/api/auth/worker-token', Legion::JSON.dump(valid_body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:access_token]).to eq('legion-jwt-456') + expect(body[:data][:scope]).to eq('worker') + expect(body[:data][:worker_id]).to eq('wkr-uuid-1') + end + + it 'issues token with correct worker_id' do + expect(Legion::API::Token).to receive(:issue_worker_token).with( + hash_including(worker_id: 'wkr-uuid-1', owner_msid: 'owner@uhg.com') + ).and_return('legion-jwt-456') + post '/api/auth/worker-token', Legion::JSON.dump(valid_body), 'CONTENT_TYPE' => 'application/json' + end + end + + context 'with invalid grant_type' do + it 'returns 400' do + body = valid_body.merge(grant_type: 'authorization_code') + post '/api/auth/worker-token', Legion::JSON.dump(body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + end + end + + context 'with missing entra_token' do + it 'returns 400' do + body = valid_body.except(:entra_token) + post '/api/auth/worker-token', Legion::JSON.dump(body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + end + end + + context 'with expired Entra token' do + before do + allow(Legion::Crypt::JWT).to receive(:verify_with_jwks) + .and_raise(Legion::Crypt::JWT::ExpiredTokenError, 'expired') + end + + it 'returns 401' do + post '/api/auth/worker-token', Legion::JSON.dump(valid_body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(401) + end + end + + context 'when worker not found' do + before do + allow(Legion::Crypt::JWT).to receive(:verify_with_jwks).and_return({ appid: 'unknown-app' }) + allow(Legion::Data::Model::DigitalWorker).to receive(:first).and_return(nil) + end + + it 'returns 404' do + post '/api/auth/worker-token', Legion::JSON.dump(valid_body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(404) + end + end + + context 'when worker not active' do + before do + paused_worker = double('worker', worker_id: 'wkr-2', lifecycle_state: 'paused', + owner_msid: 'o@uhg.com', entra_app_id: 'app-x') + allow(Legion::Crypt::JWT).to receive(:verify_with_jwks).and_return({ appid: 'app-x' }) + allow(Legion::Data::Model::DigitalWorker).to receive(:first).and_return(paused_worker) + end + + it 'returns 403' do + post '/api/auth/worker-token', Legion::JSON.dump(valid_body), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(403) + end + end +end diff --git a/spec/api/capacity_spec.rb b/spec/api/capacity_spec.rb new file mode 100644 index 00000000..053794b5 --- /dev/null +++ b/spec/api/capacity_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Capacity API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + before do + allow(Legion::API::Routes::Capacity).to receive(:fetch_worker_list).and_return([ + { worker_id: 'w1', status: 'active' }, + { worker_id: 'w2', status: 'active' }, + { worker_id: 'w3', status: 'stopped' } + ]) + end + + describe 'GET /api/capacity' do + it 'returns aggregate capacity' do + get '/api/capacity' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:active_workers]).to eq(2) + expect(body[:data][:max_throughput_tps]).to eq(20) + end + end + + describe 'GET /api/capacity/forecast' do + it 'returns forecast with default params' do + get '/api/capacity/forecast' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:current_workers]).to eq(2) + end + + it 'accepts custom growth rate' do + get '/api/capacity/forecast', days: 30, growth_rate: 0.5 + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:projected_workers]).to be >= 2 + end + end + + describe 'GET /api/capacity/workers' do + it 'returns per-worker stats' do + get '/api/capacity/workers' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data].size).to eq(3) + end + end +end diff --git a/spec/api/catalog_spec.rb b/spec/api/catalog_spec.rb new file mode 100644 index 00000000..acf37694 --- /dev/null +++ b/spec/api/catalog_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Routes::ExtensionCatalog' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + before do + Legion::Extensions::Catalog.reset! + Legion::Extensions::Catalog.register('lex-detect', state: :running) + Legion::Extensions::Catalog.register('lex-node', state: :loaded) + end + + describe 'GET /api/catalog' do + it 'returns all catalog entries' do + get '/api/catalog' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + expect(body[:data].size).to eq(2) + end + end + + describe 'GET /api/catalog/:name' do + it 'returns a single extension manifest' do + get '/api/catalog/lex-detect' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('lex-detect') + expect(body[:data][:state]).to eq('running') + end + + it 'returns 404 for unknown extension' do + get '/api/catalog/lex-nonexistent' + expect(last_response.status).to eq(404) + end + end +end diff --git a/spec/api/chains_spec.rb b/spec/api/chains_spec.rb new file mode 100644 index 00000000..b9fbcaa7 --- /dev/null +++ b/spec/api/chains_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Chains API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/chains' do + it 'returns 503 when data is not connected' do + get '/api/chains' + expect(last_response.status).to eq(503) + end + end +end diff --git a/spec/api/codegen_spec.rb b/spec/api/codegen_spec.rb new file mode 100644 index 00000000..81d5aef6 --- /dev/null +++ b/spec/api/codegen_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Codegen API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/codegen/status' do + it 'returns 503 when codegen subsystem is unavailable' do + get '/api/codegen/status' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body).to have_key(:error) + end + end + + describe 'GET /api/codegen/generated' do + it 'returns 503 when codegen registry is unavailable' do + get '/api/codegen/generated' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body).to have_key(:error) + end + end + + describe 'GET /api/codegen/gaps' do + it 'returns detected gaps' do + get '/api/codegen/gaps' + expect(last_response.status).to eq(200) + end + end + + describe 'POST /api/codegen/cycle' do + it 'triggers a cycle' do + post '/api/codegen/cycle' + expect(last_response.status).to eq(200) + end + end +end diff --git a/spec/api/events_spec.rb b/spec/api/events_spec.rb new file mode 100644 index 00000000..34820c86 --- /dev/null +++ b/spec/api/events_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Events API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/events/recent' do + it 'returns recent events as an array' do + get '/api/events/recent' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + end + + it 'respects count parameter' do + get '/api/events/recent?count=5' + expect(last_response.status).to eq(200) + end + end + + describe '.stop_queue_stream' do + it 'signals and joins the worker thread during cleanup' do + queue = Queue.new + worker = instance_double(Thread, alive?: true) + listener = double('listener') + + allow(worker).to receive(:join) + allow(Legion::Events).to receive(:off) + + Legion::API::Routes::Events.stop_queue_stream(queue: queue, worker: worker, listener: listener) + + expect(Legion::Events).to have_received(:off).with('*', listener) + expect(worker).to have_received(:join).with(0.1) + expect(queue.pop).to equal(Legion::API::Routes::Events::SSE_STOP) + end + end +end diff --git a/spec/api/extensions_spec.rb b/spec/api/extensions_spec.rb new file mode 100644 index 00000000..7e0fda2e --- /dev/null +++ b/spec/api/extensions_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Extensions API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + before do + Legion::Extensions::Catalog.reset! + Legion::Extensions::Catalog.register('lex-example', state: :running) + Legion::Extensions::Catalog.transition('lex-example', :running) + allow(Legion::Extensions).to receive(:loaded_extension_modules).and_return([]) + end + + describe 'GET /api/extension_catalog' do + it 'returns 200 with catalog entries' do + get '/api/extension_catalog' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + end + end + + describe 'GET /api/extension_catalog/:name' do + it 'returns 404 when extension is not in catalog' do + get '/api/extension_catalog/lex-nonexistent' + expect(last_response.status).to eq(404) + end + end + + describe 'POST /api/extension_catalog/:name/runners/:runner_name/functions/:func_name/invoke' do + it 'returns 404 when extension is not in catalog' do + post '/api/extension_catalog/lex-nonexistent/runners/foo/functions/bar/invoke', + '{}', 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(404) + end + end +end diff --git a/spec/api/gaia_spec.rb b/spec/api/gaia_spec.rb new file mode 100644 index 00000000..bed3209b --- /dev/null +++ b/spec/api/gaia_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Gaia API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/gaia/status' do + context 'when Legion::Gaia is not defined' do + it 'returns 503 with started: false' do + get '/api/gaia/status' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:started]).to eq(false) + end + end + + context 'when Legion::Gaia is defined but not started' do + before do + gaia = Module.new do + def self.started? = false + end + stub_const('Legion::Gaia', gaia) + end + + it 'returns 503 with started: false' do + get '/api/gaia/status' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:started]).to eq(false) + end + end + + context 'when Legion::Gaia is defined and started' do + let(:gaia_status) { { started: true, version: '1.0.0', uptime: 42 } } + + before do + status = gaia_status + gaia = Module.new do + define_singleton_method(:started?) { true } + define_singleton_method(:status) { status } + end + stub_const('Legion::Gaia', gaia) + end + + it 'returns 200 with gaia status data' do + get '/api/gaia/status' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:started]).to eq(true) + expect(body[:data][:version]).to eq('1.0.0') + expect(body[:data][:uptime]).to eq(42) + end + + it 'includes meta with timestamp and node' do + get '/api/gaia/status' + body = Legion::JSON.load(last_response.body) + expect(body[:meta]).to have_key(:timestamp) + expect(body[:meta][:node]).to eq('test-node') + end + end + end +end diff --git a/spec/api/governance_spec.rb b/spec/api/governance_spec.rb new file mode 100644 index 00000000..1f7da9e6 --- /dev/null +++ b/spec/api/governance_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Governance API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + before do + allow_any_instance_of(Legion::API).to receive(:require_data!).and_return(true) + end + + describe 'GET /api/governance/approvals' do + it 'returns a list of approvals' do + allow_any_instance_of(Legion::API).to receive(:run_governance_runner).and_return( + { success: true, approvals: [], count: 0 } + ) + + get '/api/governance/approvals' + expect(last_response.status).to eq(200) + end + end + + describe 'POST /api/governance/approvals' do + it 'creates a new approval' do + allow_any_instance_of(Legion::API).to receive(:run_governance_runner).and_return( + { success: true, approval_id: 1, status: 'pending' } + ) + + post '/api/governance/approvals', + Legion::JSON.dump({ approval_type: 'worker_deploy', payload: { name: 'test-worker' }, + requester_id: 'user-1' }), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(201) + end + end + + describe 'PUT /api/governance/approvals/:id/approve' do + it 'approves an approval' do + allow_any_instance_of(Legion::API).to receive(:run_governance_runner).and_return( + { success: true, approval_id: 1, status: 'approved' } + ) + + put '/api/governance/approvals/1/approve', + Legion::JSON.dump({ reviewer_id: 'reviewer-1' }), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(200) + end + end + + describe 'PUT /api/governance/approvals/:id/reject' do + it 'rejects an approval' do + allow_any_instance_of(Legion::API).to receive(:run_governance_runner).and_return( + { success: true, approval_id: 1, status: 'rejected' } + ) + + put '/api/governance/approvals/1/reject', + Legion::JSON.dump({ reviewer_id: 'reviewer-1' }), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(200) + end + end +end diff --git a/spec/api/health_spec.rb b/spec/api/health_spec.rb new file mode 100644 index 00000000..3561e61d --- /dev/null +++ b/spec/api/health_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Health and Readiness API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/health' do + it 'returns ok status' do + get '/api/health' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('ok') + expect(body[:data][:version]).to eq(Legion::VERSION) + expect(body[:data][:uptime_seconds]).to be_an(Integer) + expect(body[:data][:uptime]).to eq(body[:data][:uptime_seconds]) + end + end + + describe 'GET /api/ready' do + it 'returns readiness with component status' do + get '/api/ready' + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to have_key(:ready) + expect(body[:data]).to have_key(:components) + end + end +end diff --git a/spec/api/helpers_collection_spec.rb b/spec/api/helpers_collection_spec.rb new file mode 100644 index 00000000..3791bf57 --- /dev/null +++ b/spec/api/helpers_collection_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' +require 'sinatra/base' + +class HelperCollectionDataset + attr_reader :count_calls + + def initialize(items) + @items = items + @count_calls = 0 + end + + def count + @count_calls += 1 + @items.length + end + + def limit(limit, offset) + @items.slice(offset, limit) || [] + end +end + +RSpec.describe 'API helper collection responses' do + include Rack::Test::Methods + + before(:all) { ApiSpecSetup.configure_settings } + + let(:dataset) { HelperCollectionDataset.new(Array.new(50) { |index| { id: index + 1 } }) } + + let(:test_app) do + current_dataset = dataset + + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + set :dataset, current_dataset + + get '/items' do + json_collection(settings.dataset) + end + end + end + + def app + test_app + end + + it 'avoids counting by default on a full page' do + get '/items?limit=25' + + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(dataset.count_calls).to eq(0) + expect(body[:meta][:count]).to eq(25) + expect(body[:meta]).not_to have_key(:total) + expect(body[:meta][:has_more]).to be(true) + end + + it 'includes total when explicitly requested' do + get '/items?limit=25&include_total=true' + + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(dataset.count_calls).to eq(1) + expect(body[:meta][:total]).to eq(50) + expect(body[:meta][:has_more]).to be(true) + end +end diff --git a/spec/api/helpers_spec.rb b/spec/api/helpers_spec.rb new file mode 100644 index 00000000..90ef2f0f --- /dev/null +++ b/spec/api/helpers_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe Legion::API::Helpers do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'response envelope' do + it 'returns JSON with data and meta keys on /api/health' do + get '/api/health' + body = Legion::JSON.load(last_response.body) + expect(body).to have_key(:data) + expect(body).to have_key(:meta) + expect(body[:meta]).to have_key(:timestamp) + expect(body[:meta][:node]).to eq('test-node') + end + end + + describe 'not_found handler' do + it 'returns 404 with error envelope for unknown routes' do + get '/api/nonexistent' + expect(last_response.status).to eq(404) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq(404) + expect(body[:status]).to eq('failed') + end + end + + describe 'require_data!' do + it 'returns 503 when data is not connected' do + get '/api/tasks' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('data_unavailable') + end + end +end diff --git a/spec/api/knowledge_spec.rb b/spec/api/knowledge_spec.rb new file mode 100644 index 00000000..0b11808b --- /dev/null +++ b/spec/api/knowledge_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Knowledge API' do + include Rack::Test::Methods + + def app = Legion::API + + before(:all) { ApiSpecSetup.configure_settings } + + # Stub the Knowledge runners module tree so require_knowledge_ingest! succeeds + before do + stub_const('Legion::Extensions::Knowledge', Module.new) unless defined?(Legion::Extensions::Knowledge) + stub_const('Legion::Extensions::Knowledge::Runners', Module.new) unless defined?(Legion::Extensions::Knowledge::Runners) + stub_const('Legion::Extensions::Knowledge::Runners::Ingest', Module.new) unless defined?(Legion::Extensions::Knowledge::Runners::Ingest) + end + + describe 'POST /api/knowledge/ingest' do + let(:tmpfile) do + path = File.join(Dir.mktmpdir, 'test.md') + File.write(path, '# Test content') + path + end + let(:tmpdir) { Dir.mktmpdir('knowledge-test') } + + after do + FileUtils.rm_rf(File.dirname(tmpfile)) if File.exist?(tmpfile) + FileUtils.rm_rf(tmpdir) if File.directory?(tmpdir) + end + + it 'dispatches a file path to ingest_file with only :file_path and :force' do + expect(Legion::Extensions::Knowledge::Runners::Ingest) + .to receive(:ingest_file) + .with(file_path: tmpfile, force: false) + .and_return(success: true, chunks_created: 1) + + post '/api/knowledge/ingest', + Legion::JSON.dump({ path: tmpfile, force: false }), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(200) + end + + it 'does not forward :dry_run to ingest_file even when present in the body' do + expect(Legion::Extensions::Knowledge::Runners::Ingest) + .to receive(:ingest_file) + .with(hash_excluding(:dry_run)) + .and_return(success: true, chunks_created: 1) + + post '/api/knowledge/ingest', + Legion::JSON.dump({ path: tmpfile, force: false, dry_run: true }), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(200) + end + + it 'dispatches a directory path to ingest_corpus with :dry_run honored' do + expect(Legion::Extensions::Knowledge::Runners::Ingest) + .to receive(:ingest_corpus) + .with(path: tmpdir, force: false, dry_run: true) + .and_return(success: true, files_scanned: 0) + + post '/api/knowledge/ingest', + Legion::JSON.dump({ path: tmpdir, force: false, dry_run: true }), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(200) + end + + it 'returns 400 when neither :content nor :path is supplied' do + post '/api/knowledge/ingest', + Legion::JSON.dump({ force: false }), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(400) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('missing_param') + end + end + + describe 'POST /api/knowledge/status' do + let(:tmpdir) { Dir.mktmpdir('knowledge-status-test') } + + around do |example| + loader = Legion::Settings.loader + original_knowledge = loader.settings[:knowledge] + original_env = ENV.fetch('LEGION_CORPUS_PATH', nil) + loader.settings[:knowledge] = {} + ENV.delete('LEGION_CORPUS_PATH') + example.run + ensure + loader.settings[:knowledge] = original_knowledge + if original_env.nil? + ENV.delete('LEGION_CORPUS_PATH') + else + ENV['LEGION_CORPUS_PATH'] = original_env + end + FileUtils.rm_rf(tmpdir) if File.directory?(tmpdir) + end + + it 'returns 400 when body path, knowledge.default_corpus_path, and LEGION_CORPUS_PATH are all unset' do + post '/api/knowledge/status', + Legion::JSON.dump({}), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(400) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('missing_param') + end + + it 'does NOT default to the daemon cwd (Dir.pwd) when no path source is configured' do + expect(Legion::Extensions::Knowledge::Runners::Ingest).not_to receive(:scan_corpus) + + post '/api/knowledge/status', + Legion::JSON.dump({}), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(400) + end + + it 'uses knowledge.default_corpus_path when set and body has no path' do + Legion::Settings.loader.settings[:knowledge] = { default_corpus_path: tmpdir } + + expect(Legion::Extensions::Knowledge::Runners::Ingest) + .to receive(:scan_corpus) + .with(path: tmpdir) + .and_return(success: true, path: tmpdir, file_count: 0, total_bytes: 0) + + post '/api/knowledge/status', + Legion::JSON.dump({}), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(200) + end + + it 'uses LEGION_CORPUS_PATH env var when knowledge.default_corpus_path is not set' do + ENV['LEGION_CORPUS_PATH'] = tmpdir + + expect(Legion::Extensions::Knowledge::Runners::Ingest) + .to receive(:scan_corpus) + .with(path: tmpdir) + .and_return(success: true, path: tmpdir, file_count: 0, total_bytes: 0) + + post '/api/knowledge/status', + Legion::JSON.dump({}), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(200) + end + + it 'prefers an explicit body[:path] over knowledge.default_corpus_path and LEGION_CORPUS_PATH' do + Legion::Settings.loader.settings[:knowledge] = { default_corpus_path: '/settings/path' } + ENV['LEGION_CORPUS_PATH'] = '/env/path' + + expect(Legion::Extensions::Knowledge::Runners::Ingest) + .to receive(:scan_corpus) + .with(path: tmpdir) + .and_return(success: true, path: tmpdir, file_count: 0, total_bytes: 0) + + post '/api/knowledge/status', + Legion::JSON.dump({ path: tmpdir }), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(200) + end + + it 'prefers knowledge.default_corpus_path over LEGION_CORPUS_PATH' do + Legion::Settings.loader.settings[:knowledge] = { default_corpus_path: tmpdir } + ENV['LEGION_CORPUS_PATH'] = '/env/path' + + expect(Legion::Extensions::Knowledge::Runners::Ingest) + .to receive(:scan_corpus) + .with(path: tmpdir) + .and_return(success: true, path: tmpdir, file_count: 0, total_bytes: 0) + + post '/api/knowledge/status', + Legion::JSON.dump({}), + { 'CONTENT_TYPE' => 'application/json' } + + expect(last_response.status).to eq(200) + end + end +end diff --git a/spec/api/lex_dispatch_hooks_spec.rb b/spec/api/lex_dispatch_hooks_spec.rb new file mode 100644 index 00000000..8017f704 --- /dev/null +++ b/spec/api/lex_dispatch_hooks_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' +require 'legion/extensions/hooks/base' +require 'legion/ingress' + +RSpec.describe 'LexDispatch hook-aware dispatch' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + # A stub runner class that is NOT a Hooks::Base subclass + let(:plain_runner_class) do + klass = Class.new do + def self.name + 'Lex::TestExt::Runners::Webhook' + end + end + stub_const('Lex::TestExt::Runners::Webhook', klass) + klass + end + + # A Hooks::Base subclass with token verification and header routing + let(:hook_class) do + klass = Class.new(Legion::Extensions::Hooks::Base) do + route_header 'X-Event-Type', 'push' => :on_push + + verify_token header: 'Authorization', secret: 'test-secret' + + def runner_class + nil + end + end + # Give it a name so Class#name works in error messages + stub_const('Lex::TestExt::Hooks::Github', klass) + klass + end + + # Register routes before each test group (reset router between groups) + before do + Legion::API.router.clear! + plain_runner_class # ensure constant is defined before registration + end + + after do + Legion::API.router.clear! + end + + describe 'scenario 1: Hooks::Base subclass with failing verification' do + before do + Legion::API.router.register_extension_route( + lex_name: 'test_ext', + component_type: 'hooks', + component_name: 'github', + method_name: 'receive', + runner_class: hook_class, + amqp_prefix: '', + definition: nil + ) + end + + it 'returns 401 when Authorization header is missing' do + post '/api/extensions/test_ext/hooks/github/receive', + Legion::JSON.dump({ ref: 'refs/heads/main' }), + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_EVENT_TYPE' => 'push' + # No Authorization header → verify_token returns false + expect(last_response.status).to eq(401) + body = Legion::JSON.load(last_response.body) + expect(body[:status]).to eq('failed') + expect(body[:error][:code]).to eq(401) + expect(body[:error][:message]).to eq('hook verification failed') + end + + it 'returns 401 when Authorization header value is wrong' do + post '/api/extensions/test_ext/hooks/github/receive', + Legion::JSON.dump({ ref: 'refs/heads/main' }), + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_EVENT_TYPE' => 'push', + 'HTTP_AUTHORIZATION' => 'wrong-secret' + expect(last_response.status).to eq(401) + end + end + + describe 'scenario 2: Hooks::Base subclass with nil route' do + before do + Legion::API.router.register_extension_route( + lex_name: 'test_ext', + component_type: 'hooks', + component_name: 'github', + method_name: 'receive', + runner_class: hook_class, + amqp_prefix: '', + definition: nil + ) + end + + it 'returns 422 when the event type does not match any mapping' do + post '/api/extensions/test_ext/hooks/github/receive', + Legion::JSON.dump({ ref: 'refs/heads/main' }), + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'test-secret', + 'HTTP_X_EVENT_TYPE' => 'unknown_event' + # verify passes, but route returns nil (no mapping for 'unknown_event') + expect(last_response.status).to eq(422) + body = Legion::JSON.load(last_response.body) + expect(body[:status]).to eq('failed') + expect(body[:error][:code]).to eq(422) + expect(body[:error][:message]).to eq('hook could not route this event') + end + end + + describe 'scenario 3: Hooks::Base subclass with successful verify+route' do + before do + Legion::API.router.register_extension_route( + lex_name: 'test_ext', + component_type: 'hooks', + component_name: 'github', + method_name: 'receive', + runner_class: hook_class, + amqp_prefix: '', + definition: nil + ) + + allow(Legion::Ingress).to receive(:run).and_return({ status: 'success', result: { dispatched: true } }) + end + + it 'dispatches to Ingress with source: hook and the routed function' do + post '/api/extensions/test_ext/hooks/github/receive', + Legion::JSON.dump({ ref: 'refs/heads/main' }), + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'test-secret', + 'HTTP_X_EVENT_TYPE' => 'push' + + expect(Legion::Ingress).to have_received(:run).with( + hash_including( + function: :on_push, + source: 'hook', + generate_task: true, + check_subtask: true + ) + ) + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:status]).to eq('success') + end + end + + describe 'scenario 4: non-Base runner with component_type hooks passes through normally' do + before do + Legion::API.router.register_extension_route( + lex_name: 'test_ext', + component_type: 'hooks', + component_name: 'webhook', + method_name: 'receive', + runner_class: plain_runner_class, + amqp_prefix: '', + definition: nil + ) + + allow(Legion::Ingress).to receive(:run).and_return({ status: 'success', result: nil }) + end + + it 'calls Ingress with source: lex_dispatch (not hook lifecycle)' do + post '/api/extensions/test_ext/hooks/webhook/receive', + Legion::JSON.dump({ event: 'ping' }), + 'CONTENT_TYPE' => 'application/json' + + expect(Legion::Ingress).to have_received(:run).with( + hash_including( + source: 'lex_dispatch', + function: :receive + ) + ) + expect(last_response.status).to eq(200) + end + end +end diff --git a/spec/api/lex_dispatch_spec.rb b/spec/api/lex_dispatch_spec.rb new file mode 100644 index 00000000..7d5d6285 --- /dev/null +++ b/spec/api/lex_dispatch_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'LexDispatch v3.0 Routes' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + let(:mock_router) { Legion::API.router } + + before { mock_router.clear! } + + describe 'GET /api/extensions/index' do + it 'returns empty array when no extensions registered' do + get '/api/extensions/index' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:extensions]).to eq([]) + end + + it 'lists registered extension names' do + mock_router.register_extension_route( + lex_name: 'my_ext', amqp_prefix: 'lex.my.ext', + component_type: 'runners', component_name: 'fetcher', + method_name: 'fetch', runner_class: 'Lex::MyExt::Runners::Fetcher', + definition: nil + ) + get '/api/extensions/index' + body = Legion::JSON.load(last_response.body) + expect(body[:extensions]).to include('my_ext') + end + end + + describe 'GET /api/extensions/:lex/:type/:name/:method' do + it 'returns route contract with definition' do + mock_router.register_extension_route( + lex_name: 'my_ext', amqp_prefix: 'lex.my.ext', + component_type: 'runners', component_name: 'fetcher', + method_name: 'fetch', runner_class: 'Lex::MyExt::Runners::Fetcher', + definition: { desc: 'fetch data', inputs: { url: { type: :string } } } + ) + + get '/api/extensions/my_ext/runners/fetcher/fetch' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:extension]).to eq('my_ext') + expect(body[:component_type]).to eq('runners') + expect(body[:method]).to eq('fetch') + expect(body[:definition][:desc]).to eq('fetch data') + end + + it 'returns 404 for unknown route' do + get '/api/extensions/unknown/runners/nothing/nope' + expect(last_response.status).to eq(404) + end + end + + describe 'POST /api/extensions/:lex/:type/:name/:method' do + before do + mock_router.register_extension_route( + lex_name: 'my_ext', amqp_prefix: 'lex.my.ext', + component_type: 'runners', component_name: 'fetcher', + method_name: 'fetch', runner_class: 'Lex::MyExt::Runners::Fetcher', + definition: nil + ) + + # Ensure the constant exists so extension_loaded_locally? returns true + stub_const('Lex::MyExt::Runners::Fetcher', Class.new) + end + + it 'dispatches to Ingress.run with correct params' do + allow(Legion::Ingress).to receive(:run).and_return({ task_id: 42, status: 'queued', result: nil }) + + post '/api/extensions/my_ext/runners/fetcher/fetch', + Legion::JSON.dump({ url: 'https://example.com' }), + 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(200) + expect(Legion::Ingress).to have_received(:run).with( + hash_including( + runner_class: 'Lex::MyExt::Runners::Fetcher', + function: :fetch, + source: 'lex_dispatch' + ) + ) + end + + it 'returns 404 for unregistered route' do + post '/api/extensions/my_ext/runners/fetcher/nonexistent', + Legion::JSON.dump({}), + 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(404) + end + + it 'returns 400 for invalid JSON body' do + post '/api/extensions/my_ext/runners/fetcher/fetch', + 'not-json{{{', + 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(400) + end + + it 'includes envelope fields from X-Legion headers' do + allow(Legion::Ingress).to receive(:run).and_return({ task_id: 1, status: 'queued', result: nil }) + + post '/api/extensions/my_ext/runners/fetcher/fetch', + Legion::JSON.dump({}), + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_LEGION_CONVERSATION_ID' => 'conv-123' + + body = Legion::JSON.load(last_response.body) + expect(body[:conversation_id]).to eq('conv-123') + end + + it 'handles empty body gracefully' do + allow(Legion::Ingress).to receive(:run).and_return({ task_id: 5, status: 'queued', result: nil }) + + post '/api/extensions/my_ext/runners/fetcher/fetch' + + expect(last_response.status).to eq(200) + end + end + + describe 'GET /api/discovery' do + it 'includes extensions in discovery response' do + mock_router.register_extension_route( + lex_name: 'github', amqp_prefix: 'lex.github', + component_type: 'runners', component_name: 'repo', + method_name: 'list', runner_class: 'Lex::Github::Runners::Repo', + definition: nil + ) + + get '/api/discovery' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:extensions]).to include('github') + end + end +end diff --git a/spec/api/logs_spec.rb b/spec/api/logs_spec.rb new file mode 100644 index 00000000..6e120301 --- /dev/null +++ b/spec/api/logs_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Logs API' do + include Rack::Test::Methods + + def app = Legion::API + + before(:all) { ApiSpecSetup.configure_settings } + + let(:valid_error_payload) do + { + level: 'error', + message: 'something broke', + exception_class: 'RuntimeError', + backtrace: ['cli.rb:42:in `start\''], + component_type: 'cli', + source: 'legion', + command: 'legion chat prompt hello' + } + end + + let(:valid_warn_payload) do + { + level: 'warn', + message: 'something suspicious happened', + source: 'legion' + } + end + + before do + logging_exchange = double('Logging Exchange', publish: nil) + allow(Legion::Transport::Exchanges::Logging).to receive(:cached_instance).and_return(logging_exchange) + allow(Legion::Logging::EventBuilder).to receive(:send).with(:legion_versions).and_return({}) + end + + describe 'POST /api/logs' do + context 'with a valid error payload' do + it 'returns 201' do + post '/api/logs', Legion::JSON.dump(valid_error_payload), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(201) + end + + it 'returns published: true' do + post '/api/logs', Legion::JSON.dump(valid_error_payload), 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:published]).to be true + end + + it 'returns a routing_key in the response' do + post '/api/logs', Legion::JSON.dump(valid_error_payload), 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:routing_key]).to include('legion.logging.exception.error.cli.legion') + end + end + + context 'with a valid warn payload' do + it 'returns 201' do + post '/api/logs', Legion::JSON.dump(valid_warn_payload), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(201) + end + + it 'returns published: true' do + post '/api/logs', Legion::JSON.dump(valid_warn_payload), 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:published]).to be true + end + + it 'uses the log routing key (no exception_class)' do + post '/api/logs', Legion::JSON.dump(valid_warn_payload), 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:routing_key]).to include('legion.logging.log.warn.cli.legion') + end + end + + context 'when level is missing' do + it 'returns 422' do + post '/api/logs', Legion::JSON.dump({ message: 'oops' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + end + + it 'returns an error message about level' do + post '/api/logs', Legion::JSON.dump({ message: 'oops' }), 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:error][:message]).to include('level') + end + end + + context 'when level is invalid (e.g. "debug")' do + it 'returns 422' do + post '/api/logs', Legion::JSON.dump({ level: 'debug', message: 'too noisy' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + end + + it 'returns an error message about level' do + post '/api/logs', Legion::JSON.dump({ level: 'debug', message: 'too noisy' }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:error][:message]).to include('level') + end + end + + context 'when message is missing' do + it 'returns 422' do + post '/api/logs', Legion::JSON.dump({ level: 'error' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + end + + it 'returns an error message about message' do + post '/api/logs', Legion::JSON.dump({ level: 'error' }), 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:error][:message]).to include('message') + end + end + + context 'when message is empty' do + it 'returns 422' do + post '/api/logs', Legion::JSON.dump({ level: 'error', message: ' ' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + end + end + + context 'routing key construction' do + it 'uses exception routing key when exception_class is present' do + post '/api/logs', Legion::JSON.dump(valid_error_payload), 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:routing_key]).to start_with('legion.logging.exception.') + end + + it 'uses log routing key when exception_class is absent' do + post '/api/logs', Legion::JSON.dump(valid_warn_payload), 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:routing_key]).to start_with('legion.logging.log.') + end + + it 'defaults source to "unknown" when not provided' do + post '/api/logs', Legion::JSON.dump({ level: 'warn', message: 'test' }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:routing_key]).to end_with('.unknown') + end + end + end +end diff --git a/spec/api/middleware/auth_spec.rb b/spec/api/middleware/auth_spec.rb new file mode 100644 index 00000000..ec4741db --- /dev/null +++ b/spec/api/middleware/auth_spec.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +require_relative '../api_spec_helper' + +unless defined?(Legion::Crypt::JWT) + module Legion + module Crypt + module JWT + class Error < StandardError; end + class InvalidTokenError < Error; end + class ExpiredTokenError < Error; end + + def self.verify(...) = nil + end + end + end +end + +RSpec.describe Legion::API::Middleware::Auth do + let(:ok_app) { ->(_env) { [200, { 'content-type' => 'text/plain' }, ['ok']] } } + let(:signing_key) { 'test-secret-key' } + let(:valid_claims) { { sub: 'user123', worker_id: 'w1', scope: 'worker' } } + + def build_middleware(opts = {}) + described_class.new(ok_app, opts) + end + + def make_env(path: '/api/tasks', headers: {}) + env = Rack::MockRequest.env_for(path) + headers.each { |k, v| env[k] = v } + env + end + + describe 'when disabled (default)' do + subject(:middleware) { build_middleware } + + it 'passes through all requests without inspecting headers' do + env = make_env(path: '/api/tasks') + status, = middleware.call(env) + expect(status).to eq(200) + end + + it 'passes through requests with no Authorization header' do + env = make_env(path: '/api/sensitive') + status, = middleware.call(env) + expect(status).to eq(200) + end + end + + describe 'when enabled' do + subject(:middleware) { build_middleware(enabled: true, signing_key: signing_key) } + + describe 'skip paths' do + it 'passes through /api/health without a token' do + env = make_env(path: '/api/health') + status, = middleware.call(env) + expect(status).to eq(200) + end + + it 'passes through /api/ready without a token' do + env = make_env(path: '/api/ready') + status, = middleware.call(env) + expect(status).to eq(200) + end + + it 'passes through paths that start with /api/health (e.g. /api/health/live)' do + env = make_env(path: '/api/health/live') + status, = middleware.call(env) + expect(status).to eq(200) + end + end + + describe 'missing Authorization header' do + it 'returns 401' do + env = make_env(path: '/api/tasks') + status, = middleware.call(env) + expect(status).to eq(401) + end + + it 'returns JSON error body' do + env = make_env(path: '/api/tasks') + status, headers, body = middleware.call(env) + expect(status).to eq(401) + expect(headers['content-type']).to eq('application/json') + parsed = Legion::JSON.load(body.first) + expect(parsed[:error][:code]).to eq(401) + expect(parsed[:error][:message]).to eq('missing Authorization header') + end + end + + describe 'invalid or expired token' do + before do + allow(Legion::Crypt::JWT).to receive(:verify).and_raise(Legion::Crypt::JWT::InvalidTokenError, 'bad sig') + end + + it 'returns 401' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer bad.token.here' }) + status, = middleware.call(env) + expect(status).to eq(401) + end + + it 'returns JSON body with invalid or expired token message' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer bad.token.here' }) + _status, _headers, body = middleware.call(env) + parsed = Legion::JSON.load(body.first) + expect(parsed[:error][:message]).to eq('invalid or expired token') + end + end + + describe 'expired token' do + before do + allow(Legion::Crypt::JWT).to receive(:verify).and_raise(Legion::Crypt::JWT::ExpiredTokenError, 'expired') + end + + it 'returns 401' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer expired.token' }) + status, = middleware.call(env) + expect(status).to eq(401) + end + end + + describe 'valid token' do + before do + allow(Legion::Crypt::JWT).to receive(:verify).and_return(valid_claims) + end + + it 'passes through to the app (returns 200)' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer valid.token.here' }) + status, = middleware.call(env) + expect(status).to eq(200) + end + + it 'sets legion.auth in env with the claims hash' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer valid.token.here' }) + middleware.call(env) + expect(env['legion.auth']).to eq(valid_claims) + end + + it 'sets legion.worker_id from claims' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer valid.token.here' }) + middleware.call(env) + expect(env['legion.worker_id']).to eq('w1') + end + + it 'sets legion.owner_msid from sub claim' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer valid.token.here' }) + middleware.call(env) + expect(env['legion.owner_msid']).to eq('user123') + end + + it 'passes the token to JWT.verify with the configured signing key' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer mytoken' }) + middleware.call(env) + expect(Legion::Crypt::JWT).to have_received(:verify).with('mytoken', verification_key: signing_key) + end + end + + describe 'Bearer token extraction' do + before do + allow(Legion::Crypt::JWT).to receive(:verify).and_return(valid_claims) + end + + it 'accepts Bearer with mixed case prefix' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'BEARER mytoken' }) + status, = middleware.call(env) + expect(status).to eq(200) + end + + it 'rejects a non-Bearer scheme (e.g. Basic)' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Basic dXNlcjpwYXNz' }) + status, = middleware.call(env) + expect(status).to eq(401) + _s, _h, body = middleware.call(env) + parsed = Legion::JSON.load(body.first) + expect(parsed[:error][:message]).to eq('missing Authorization header') + end + end + end + + describe 'Negotiate/SPNEGO (Kerberos)' do + subject(:middleware) { build_middleware(enabled: true, signing_key: signing_key) } + + let(:kerberos_claims) { { sub: 'kuser@REALM.EXAMPLE.COM', scope: 'kerberos' } } + let(:auth_result_success) do + { success: true, principal: 'kuser@REALM.EXAMPLE.COM', groups: ['grid-admins'], output_token: 'servertoken456' } + end + let(:auth_result_no_output_token) do + { success: true, principal: 'kuser@REALM.EXAMPLE.COM', groups: [], output_token: nil } + end + + before do + stub_const('Legion::Extensions::Kerberos::Client', Class.new do + def authenticate(**_kwargs); end + end) + stub_const('Legion::Rbac::KerberosClaimsMapper', Module.new do + def self.map_with_fallback(**_kwargs); end + end) + end + + context 'when Kerberos is available and auth succeeds' do + before do + allow(Legion::Extensions::Kerberos::Client).to receive(:new).and_return( + instance_double('Legion::Extensions::Kerberos::Client', authenticate: auth_result_success) + ) + allow(Legion::Rbac::KerberosClaimsMapper).to receive(:map_with_fallback).and_return(kerberos_claims) + end + + it 'passes through to the app (returns 200)' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate dGVzdHRva2Vu' }) + status, = middleware.call(env) + expect(status).to eq(200) + end + + it 'sets legion.auth_method to kerberos' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate dGVzdHRva2Vu' }) + middleware.call(env) + expect(env['legion.auth_method']).to eq('kerberos') + end + + it 'sets legion.auth to the mapped claims' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate dGVzdHRva2Vu' }) + middleware.call(env) + expect(env['legion.auth']).to eq(kerberos_claims) + end + + it 'sets legion.owner_msid from claims[:sub]' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate dGVzdHRva2Vu' }) + middleware.call(env) + expect(env['legion.owner_msid']).to eq('kuser@REALM.EXAMPLE.COM') + end + + it 'adds WWW-Authenticate response header with output token' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate dGVzdHRva2Vu' }) + _status, headers, = middleware.call(env) + expect(headers['WWW-Authenticate']).to eq('Negotiate servertoken456') + end + + it 'omits WWW-Authenticate header when output_token is nil' do + allow(Legion::Extensions::Kerberos::Client).to receive(:new).and_return( + instance_double('Legion::Extensions::Kerberos::Client', authenticate: auth_result_no_output_token) + ) + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate dGVzdHRva2Vu' }) + _status, headers, = middleware.call(env) + expect(headers['WWW-Authenticate']).to be_nil + end + + it 'passes principal and groups to KerberosClaimsMapper' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate dGVzdHRva2Vu' }) + middleware.call(env) + expect(Legion::Rbac::KerberosClaimsMapper).to have_received(:map_with_fallback).with( + hash_including(principal: 'kuser@REALM.EXAMPLE.COM', groups: ['grid-admins']) + ) + end + + it 'accepts Negotiate with mixed case prefix' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'NEGOTIATE dGVzdHRva2Vu' }) + status, = middleware.call(env) + expect(status).to eq(200) + end + end + + context 'when Kerberos is available but authenticate returns success: false' do + before do + allow(Legion::Extensions::Kerberos::Client).to receive(:new).and_return( + instance_double('Legion::Extensions::Kerberos::Client', + authenticate: { success: false, principal: nil, groups: [], output_token: nil }) + ) + end + + it 'returns 401 with Kerberos authentication failed' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate badtoken' }) + status, _headers, body = middleware.call(env) + expect(status).to eq(401) + parsed = Legion::JSON.load(body.first) + expect(parsed[:error][:message]).to eq('Kerberos authentication failed') + end + end + + context 'when Kerberos is available but verify_negotiate raises an exception' do + before do + allow(Legion::Extensions::Kerberos::Client).to receive(:new).and_return( + instance_double('Legion::Extensions::Kerberos::Client').tap do |d| + allow(d).to receive(:authenticate).and_raise(StandardError, 'GSSAPI error') + end + ) + end + + it 'returns 401 with Kerberos authentication failed' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate errortoken' }) + status, _headers, body = middleware.call(env) + expect(status).to eq(401) + parsed = Legion::JSON.load(body.first) + expect(parsed[:error][:message]).to eq('Kerberos authentication failed') + end + end + + context 'when lex-kerberos is not loaded (kerberos_available? is false)' do + before do + hide_const('Legion::Extensions::Kerberos::Client') + hide_const('Legion::Rbac::KerberosClaimsMapper') + allow(Legion::Crypt::JWT).to receive(:verify).and_return(valid_claims) + end + + it 'falls through to Bearer JWT check' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate sometoken' }) + status, = middleware.call(env) + # Negotiate header present but lex-kerberos not loaded -> falls through -> + # Bearer check finds no Bearer token -> 401 missing Authorization header + expect(status).to eq(401) + end + + it 'does not call KerberosClaimsMapper' do + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Negotiate sometoken' }) + middleware.call(env) + # No error = mapper was not called (it's hidden) + end + + it 'allows a subsequent Bearer token to authenticate normally' do + allow(Legion::Crypt::JWT).to receive(:verify).and_return(valid_claims) + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer valid.token' }) + status, = middleware.call(env) + expect(status).to eq(200) + expect(env['legion.auth_method']).to eq('jwt') + end + end + + context 'skip paths' do + it 'passes through /api/auth/negotiate without a token' do + env = make_env(path: '/api/auth/negotiate') + status, = middleware.call(env) + expect(status).to eq(200) + end + end + end + + describe 'owner_msid fallback' do + subject(:middleware) { build_middleware(enabled: true, signing_key: signing_key) } + + it 'falls back to owner_msid key when sub is absent' do + claims_no_sub = { owner_msid: 'fallback_user', worker_id: 'w2', scope: 'worker' } + allow(Legion::Crypt::JWT).to receive(:verify).and_return(claims_no_sub) + env = make_env(path: '/api/tasks', headers: { 'HTTP_AUTHORIZATION' => 'Bearer token' }) + middleware.call(env) + expect(env['legion.owner_msid']).to eq('fallback_user') + end + end +end diff --git a/spec/api/nodes_spec.rb b/spec/api/nodes_spec.rb new file mode 100644 index 00000000..c88b6fbb --- /dev/null +++ b/spec/api/nodes_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Nodes API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/nodes' do + it 'returns 503 when data is not connected' do + get '/api/nodes' + expect(last_response.status).to eq(503) + end + end + + describe 'GET /api/nodes/:id' do + it 'returns 503 when data is not connected' do + get '/api/nodes/1' + expect(last_response.status).to eq(503) + end + end +end diff --git a/spec/api/old_systems_removed_spec.rb b/spec/api/old_systems_removed_spec.rb new file mode 100644 index 00000000..34224e7c --- /dev/null +++ b/spec/api/old_systems_removed_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Old route systems removed' do + before(:all) { ApiSpecSetup.configure_settings } + + describe 'Legion::API class methods' do + it 'does not respond to hook_registry' do + expect(Legion::API).not_to respond_to(:hook_registry) + end + + it 'does not respond to register_hook' do + expect(Legion::API).not_to respond_to(:register_hook) + end + + it 'does not respond to find_hook' do + expect(Legion::API).not_to respond_to(:find_hook) + end + + it 'does not respond to find_hook_by_path' do + expect(Legion::API).not_to respond_to(:find_hook_by_path) + end + + it 'does not respond to registered_hooks' do + expect(Legion::API).not_to respond_to(:registered_hooks) + end + + it 'does not respond to route_registry' do + expect(Legion::API).not_to respond_to(:route_registry) + end + + it 'does not respond to register_route' do + expect(Legion::API).not_to respond_to(:register_route) + end + + it 'does not respond to find_route_by_path' do + expect(Legion::API).not_to respond_to(:find_route_by_path) + end + + it 'does not respond to registered_routes' do + expect(Legion::API).not_to respond_to(:registered_routes) + end + + it 'responds to router' do + expect(Legion::API).to respond_to(:router) + end + end +end diff --git a/spec/api/openapi_spec.rb b/spec/api/openapi_spec.rb new file mode 100644 index 00000000..60ec51b4 --- /dev/null +++ b/spec/api/openapi_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' +require 'legion/api/openapi' + +RSpec.describe Legion::API::OpenAPI do + before(:all) { ApiSpecSetup.configure_settings } + + describe '.spec' do + subject(:spec) { described_class.spec } + + it 'returns a Hash' do + expect(spec).to be_a(Hash) + end + + it 'has openapi version 3.1.0' do + expect(spec[:openapi]).to eq('3.1.0') + end + + it 'has info block with title' do + expect(spec[:info][:title]).to eq('LegionIO REST API') + end + + it 'has info block with version matching Legion::VERSION' do + expect(spec[:info][:version]).to eq(Legion::VERSION) + end + + it 'has paths key' do + expect(spec).to have_key(:paths) + end + + it 'includes Lex in tags' do + tag_names = spec[:tags].map { |t| t[:name] } + expect(tag_names).to include('Lex') + end + + it 'has components key' do + expect(spec).to have_key(:components) + end + + it 'has tags key' do + expect(spec).to have_key(:tags) + end + + it 'has servers key' do + expect(spec).to have_key(:servers) + end + + describe 'paths' do + subject(:paths) { spec[:paths] } + + %w[ + /api/health + /api/ready + /api/tasks + /api/tasks/{id} + /api/tasks/{id}/logs + /api/extension_catalog + /api/extension_catalog/available + /api/extension_catalog/{name} + /api/extension_catalog/{name}/runners + /api/extension_catalog/{name}/runners/{runner_name} + /api/extension_catalog/{name}/runners/{runner_name}/functions + /api/extension_catalog/{name}/runners/{runner_name}/functions/{function_name} + /api/extension_catalog/{name}/runners/{runner_name}/functions/{function_name}/invoke + /api/nodes + /api/nodes/{id} + /api/schedules + /api/schedules/{id} + /api/schedules/{id}/logs + /api/relationships + /api/relationships/{id} + /api/chains + /api/chains/{id} + /api/settings + /api/settings/{key} + /api/events + /api/events/recent + /api/transport + /api/transport/exchanges + /api/transport/queues + /api/transport/publish + /api/hooks + /api/hooks/{lex_name}/{hook_name} + /api/lex + /api/workers + /api/workers/{id} + /api/workers/{id}/lifecycle + /api/workers/{id}/tasks + /api/workers/{id}/events + /api/workers/{id}/costs + /api/workers/{id}/value + /api/workers/{id}/roi + /api/teams/{team}/workers + /api/teams/{team}/costs + /api/coldstart/ingest + /api/gaia/status + /api/openapi.json + ].each do |route| + it "includes path #{route}" do + expect(paths).to have_key(route) + end + end + + it 'marks health as security-free' do + expect(paths['/api/health'][:get][:security]).to eq([]) + end + + it 'marks openapi.json as security-free' do + expect(paths['/api/openapi.json'][:get][:security]).to eq([]) + end + + it 'has GET /api/tasks with Tasks tag' do + expect(paths['/api/tasks'][:get][:tags]).to include('Tasks') + end + + it 'has GET /api/lex with Lex tag' do + expect(paths['/api/lex'][:get][:tags]).to include('Lex') + end + + it 'has POST /api/tasks' do + expect(paths['/api/tasks']).to have_key(:post) + end + + it 'has DELETE /api/tasks/{id}' do + expect(paths['/api/tasks/{id}']).to have_key(:delete) + end + + it 'marks relationships as stub (501 response)' do + responses = paths['/api/relationships'][:get][:responses] + expect(responses).to have_key('501') + end + + it 'marks chains as stub (501 response)' do + responses = paths['/api/chains'][:get][:responses] + expect(responses).to have_key('501') + end + + it 'has PATCH /api/workers/{id}/lifecycle' do + expect(paths['/api/workers/{id}/lifecycle']).to have_key(:patch) + end + end + + describe 'components' do + subject(:components) { spec[:components] } + + it 'has securitySchemes' do + expect(components).to have_key(:securitySchemes) + end + + it 'has BearerAuth security scheme' do + expect(components[:securitySchemes]).to have_key(:BearerAuth) + end + + it 'has ApiKeyAuth security scheme' do + expect(components[:securitySchemes]).to have_key(:ApiKeyAuth) + end + + it 'has schemas' do + expect(components).to have_key(:schemas) + end + + %i[Meta MetaCollection ErrorResponse TaskObject WorkerObject].each do |schema| + it "has #{schema} schema" do + expect(components[:schemas]).to have_key(schema) + end + end + + it 'ErrorResponse has error and meta properties' do + error_schema = components[:schemas][:ErrorResponse] + expect(error_schema[:properties]).to have_key(:error) + expect(error_schema[:properties]).to have_key(:meta) + end + + it 'MetaCollection has total, limit, offset properties' do + meta = components[:schemas][:MetaCollection] + expect(meta[:properties]).to have_key(:total) + expect(meta[:properties]).to have_key(:limit) + expect(meta[:properties]).to have_key(:offset) + end + end + end + + describe '.to_json' do + it 'returns a String' do + expect(described_class.to_json).to be_a(String) + end + + it 'returns valid JSON' do + expect { JSON.parse(described_class.to_json) }.not_to raise_error + end + + it 'includes openapi version in JSON output' do + parsed = JSON.parse(described_class.to_json) + expect(parsed['openapi']).to eq('3.1.0') + end + + it 'includes paths in JSON output' do + parsed = JSON.parse(described_class.to_json) + expect(parsed['paths']).to be_a(Hash) + end + + it 'includes components in JSON output' do + parsed = JSON.parse(described_class.to_json) + expect(parsed['components']).to be_a(Hash) + end + end +end + +RSpec.describe 'GET /api/openapi.json' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + it 'returns 200' do + get '/api/openapi.json' + expect(last_response.status).to eq(200) + end + + it 'returns JSON content-type' do + get '/api/openapi.json' + expect(last_response.content_type).to include('application/json') + end + + it 'returns valid JSON' do + get '/api/openapi.json' + expect { JSON.parse(last_response.body) }.not_to raise_error + end + + it 'includes openapi version' do + get '/api/openapi.json' + parsed = JSON.parse(last_response.body) + expect(parsed['openapi']).to eq('3.1.0') + end +end diff --git a/spec/api/org_chart_spec.rb b/spec/api/org_chart_spec.rb new file mode 100644 index 00000000..4d5f4045 --- /dev/null +++ b/spec/api/org_chart_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Org Chart API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/org-chart' do + context 'when data is not connected' do + it 'returns 503' do + get '/api/org-chart' + expect(last_response.status).to eq(503) + end + end + + context 'when data is connected' do + let(:extension_model) { double('Legion::Data::Model::Extension') } + let(:function_model) { double('Legion::Data::Model::Function') } + let(:worker_model) { double('Legion::Data::Model::DigitalWorker') } + + before do + stub_const('Legion::Data::Model::Extension', extension_model) + stub_const('Legion::Data::Model::Function', function_model) + stub_const('Legion::Data::Model::DigitalWorker', worker_model) + Legion::Settings.loader.settings[:data] = { connected: true } + end + + after do + Legion::Settings.loader.settings[:data] = { connected: false } + end + + it 'returns a departments structure' do + ext = double('ext', id: 1, name: 'lex-audit') + func = double('func', name: 'audit.write') + worker = double('worker', id: 1, name: 'audit-bot', lifecycle_state: 'active', extension_name: 'lex-audit') + + allow(extension_model).to receive(:all).and_return([ext]) + allow(function_model).to receive(:where).with(extension_id: 1).and_return(double(all: [func])) + allow(worker_model).to receive(:all).and_return([worker]) + + get '/api/org-chart' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:departments]).to be_an(Array) + expect(body[:data][:departments].first[:name]).to eq('lex-audit') + end + + it 'returns empty departments when no extensions exist' do + allow(extension_model).to receive(:all).and_return([]) + allow(worker_model).to receive(:all).and_return([]) + + get '/api/org-chart' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:departments]).to eq([]) + end + end + end +end diff --git a/spec/api/relationships_spec.rb b/spec/api/relationships_spec.rb new file mode 100644 index 00000000..89091e35 --- /dev/null +++ b/spec/api/relationships_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Relationships API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/relationships' do + it 'returns 503 when data is not connected' do + get '/api/relationships' + expect(last_response.status).to eq(503) + end + end +end diff --git a/spec/api/schedules_spec.rb b/spec/api/schedules_spec.rb new file mode 100644 index 00000000..f0613497 --- /dev/null +++ b/spec/api/schedules_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Schedules API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/schedules' do + it 'returns 503 when data is not connected' do + get '/api/schedules' + expect(last_response.status).to eq(503) + end + end + + describe 'POST /api/schedules' do + it 'returns 503 when data is not connected' do + post '/api/schedules', '{}', 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + end + end +end diff --git a/spec/api/settings_spec.rb b/spec/api/settings_spec.rb new file mode 100644 index 00000000..4c135b34 --- /dev/null +++ b/spec/api/settings_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Settings API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/settings' do + it 'returns settings with sensitive values redacted' do + get '/api/settings' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_a(Hash) + end + end + + describe 'GET /api/settings/:key' do + it 'returns a specific setting' do + get '/api/settings/client' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:key]).to eq('client') + end + + it 'returns 404 for unknown setting' do + get '/api/settings/nonexistent_setting_xyz' + expect(last_response.status).to eq(404) + end + end + + describe 'PUT /api/settings/:key' do + it 'rejects writes to read-only sections' do + put '/api/settings/crypt', Legion::JSON.dump({ value: 'test' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(403) + end + + it 'rejects writes to transport section' do + put '/api/settings/transport', Legion::JSON.dump({ value: 'test' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(403) + end + + it 'requires a value field' do + put '/api/settings/test_key', Legion::JSON.dump({ other: 'field' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + end + end +end diff --git a/spec/api/stats_spec.rb b/spec/api/stats_spec.rb new file mode 100644 index 00000000..2f24e6ba --- /dev/null +++ b/spec/api/stats_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Stats API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/stats' do + it 'returns 200 with all subsystem sections' do + get '/api/stats' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to have_key(:extensions) + expect(body[:data]).to have_key(:gaia) + expect(body[:data]).to have_key(:transport) + expect(body[:data]).to have_key(:cache) + expect(body[:data]).to have_key(:cache_local) + expect(body[:data]).to have_key(:llm) + expect(body[:data]).to have_key(:data) + expect(body[:data]).to have_key(:data_local) + expect(body[:data]).to have_key(:api) + expect(body[:meta]).to have_key(:timestamp) + end + + it 'returns extension counts' do + get '/api/stats' + body = Legion::JSON.load(last_response.body) + ext = body[:data][:extensions] + %i[loaded discovered subscription every poll once loop running].each do |key| + expect(ext).to have_key(key) + end + end + + it 'returns api section with port and routes' do + get '/api/stats' + body = Legion::JSON.load(last_response.body) + api = body[:data][:api] + expect(api).to have_key(:port) + expect(api).to have_key(:routes) + end + + it 'isolates subsystem errors without failing the response' do + allow(Legion::Extensions).to receive(:instance_variable_get).and_raise(RuntimeError, 'extensions boom') + + get '/api/stats' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:extensions][:error]).to eq('extensions boom') + # Other sections still populated + expect(body[:data][:api]).to have_key(:port) + end + end +end diff --git a/spec/api/tasks_spec.rb b/spec/api/tasks_spec.rb new file mode 100644 index 00000000..84ba8d98 --- /dev/null +++ b/spec/api/tasks_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Tasks API' do + include Rack::Test::Methods + + def app = Legion::API + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'collection routes' do + it 'GET /api/tasks returns 503 when data is not connected' do + get '/api/tasks' + expect(last_response.status).to eq(503) + end + + it 'POST /api/tasks returns 422 when runner_class is missing' do + post '/api/tasks', Legion::JSON.dump({ function: 'test' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + expect(Legion::JSON.load(last_response.body)[:error][:code]).to eq('missing_field') + end + + it 'POST /api/tasks returns 422 when function is missing' do + post '/api/tasks', Legion::JSON.dump({ runner_class: 'SomeRunner' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + expect(Legion::JSON.load(last_response.body)[:error][:code]).to eq('missing_field') + end + end + + describe 'member routes' do + it 'GET /api/tasks/:id returns 503 when data is not connected' do + get '/api/tasks/1' + expect(last_response.status).to eq(503) + end + + it 'DELETE /api/tasks/:id returns 503 when data is not connected' do + delete '/api/tasks/1' + expect(last_response.status).to eq(503) + end + + it 'GET /api/tasks/:id/logs returns 503 when data is not connected' do + get '/api/tasks/1/logs' + expect(last_response.status).to eq(503) + end + end +end diff --git a/spec/api/tbi_patterns_spec.rb b/spec/api/tbi_patterns_spec.rb new file mode 100644 index 00000000..c42f675d --- /dev/null +++ b/spec/api/tbi_patterns_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'TBI Patterns API' do + include Rack::Test::Methods + + def app = Legion::API + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'POST /api/tbi/patterns/export' do + it 'returns 503 when data is not connected' do + post '/api/tbi/patterns/export', + Legion::JSON.dump({ pattern_type: 'behavioral', description: 'x', tier: 'tier1', pattern_data: {} }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + end + end + + describe 'GET /api/tbi/patterns' do + it 'returns 503 when data is not connected' do + get '/api/tbi/patterns' + expect(last_response.status).to eq(503) + end + end + + describe 'GET /api/tbi/patterns/:id' do + it 'returns 503 when data is not connected' do + get '/api/tbi/patterns/1' + expect(last_response.status).to eq(503) + end + end + + describe 'PATCH /api/tbi/patterns/:id/score' do + it 'returns 503 when data is not connected' do + patch '/api/tbi/patterns/1/score', + Legion::JSON.dump({ invocation_count: 10, success_rate: 0.9 }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + end + end + + describe 'GET /api/tbi/patterns/discover' do + it 'returns 501 not implemented' do + get '/api/tbi/patterns/discover' + expect(last_response.status).to eq(501) + expect(Legion::JSON.load(last_response.body)[:error][:code]).to eq('not_implemented') + end + end + + describe 'Routes::TbiPatterns helpers' do + let(:mod) { Legion::API::Routes::TbiPatterns } + + describe '.serialize_pattern_data' do + it 'returns a String unchanged' do + expect(mod.serialize_pattern_data('foo')).to eq('foo') + end + + it 'JSON-encodes a hash' do + result = mod.serialize_pattern_data({ a: 1 }) + expect(result).to be_a(String) + expect(result).not_to be_empty + end + + it 'falls back gracefully when JSON encoding raises' do + call_count = 0 + allow(Legion::JSON).to receive(:dump) do |arg| + call_count += 1 + raise StandardError, 'encoding failure' if call_count == 1 + + arg.to_s + end + result = mod.serialize_pattern_data({ broken: true }) + expect(result).to be_a(String) + end + end + + describe '.parse_integer' do + it 'returns default for nil' do + expect(mod.parse_integer(nil, 5)).to eq(5) + end + + it 'returns default for blank string' do + expect(mod.parse_integer(' ', 5)).to eq(5) + end + + it 'returns default for non-numeric input' do + expect(mod.parse_integer('abc', 7)).to eq(7) + end + + it 'parses a valid integer string' do + expect(mod.parse_integer('42', 0)).to eq(42) + end + + it 'parses a numeric value directly' do + expect(mod.parse_integer(10, 0)).to eq(10) + end + + it 'clamps negative values to 0' do + expect(mod.parse_integer('-5', 0)).to eq(0) + expect(mod.parse_integer(-3, 0)).to eq(0) + end + end + + describe '.parse_float' do + it 'returns default for nil' do + expect(mod.parse_float(nil, 1.0)).to eq(1.0) + end + + it 'returns default for blank string' do + expect(mod.parse_float(' ', 1.5)).to eq(1.5) + end + + it 'returns default for non-numeric input' do + expect(mod.parse_float('bad', 2.0)).to eq(2.0) + end + + it 'parses a valid float string' do + expect(mod.parse_float('0.75', 0.0)).to be_within(0.001).of(0.75) + end + + it 'parses a numeric value directly' do + expect(mod.parse_float(0.5, 0.0)).to be_within(0.001).of(0.5) + end + + it 'clamps values to 0.0..1.0 range' do + expect(mod.parse_float('-0.5', 0.0)).to eq(0.0) + expect(mod.parse_float('2.0', 0.0)).to eq(1.0) + end + end + + describe '.compute_quality' do + it 'returns a float between 0 and 1' do + score = mod.compute_quality(invocation_count: 50, success_rate: 0.8, tier: 'tier3') + expect(score).to be_a(Float) + expect(score).to be_between(0.0, 1.0) + end + + it 'produces higher scores for higher invocation counts' do + low = mod.compute_quality(invocation_count: 0, success_rate: 0.5, tier: 'tier1') + high = mod.compute_quality(invocation_count: 100, success_rate: 0.5, tier: 'tier1') + expect(high).to be > low + end + end + + describe '.anonymize' do + it 'strips identifying keys' do + body = { pattern_type: 'x', tier: 'tier1', description: 'y', + node_id: 'abc', hostname: 'myhost', ip_address: '10.0.0.1', worker_id: 'w1' } + result = mod.anonymize(body) + expect(result.keys).not_to include(:node_id, :hostname, :ip_address, :worker_id) + end + + it 'includes a 16-char source_hash' do + body = { pattern_type: 'behavioral', tier: 'tier2', description: 'test' } + result = mod.anonymize(body) + expect(result[:source_hash]).to be_a(String) + expect(result[:source_hash].length).to eq(16) + end + + it 'produces the same hash for identical inputs' do + body = { pattern_type: 'x', tier: 'tier1', description: 'y' } + expect(mod.anonymize(body)[:source_hash]).to eq(mod.anonymize(body)[:source_hash]) + end + end + end +end diff --git a/spec/api/transport_spec.rb b/spec/api/transport_spec.rb new file mode 100644 index 00000000..e61ab225 --- /dev/null +++ b/spec/api/transport_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Transport API' do + include Rack::Test::Methods + + def app = Legion::API + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'status' do + it 'GET /api/transport returns connection status' do + get '/api/transport' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + %i[connected session_open channel_open connector].each do |key| + expect(body[:data]).to have_key(key) + end + end + end + + describe 'discovery' do + it 'GET /api/transport/exchanges returns exchange list' do + get '/api/transport/exchanges' + expect(last_response.status).to eq(200) + expect(Legion::JSON.load(last_response.body)[:data]).to be_an(Array) + end + + it 'GET /api/transport/queues returns queue list' do + get '/api/transport/queues' + expect(last_response.status).to eq(200) + expect(Legion::JSON.load(last_response.body)[:data]).to be_an(Array) + end + end + + describe 'publish' do + it 'requires exchange field' do + post '/api/transport/publish', Legion::JSON.dump({ routing_key: 'test' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + expect(Legion::JSON.load(last_response.body)[:error][:message]).to include('exchange') + end + + it 'requires routing_key field' do + post '/api/transport/publish', Legion::JSON.dump({ exchange: 'test' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + expect(Legion::JSON.load(last_response.body)[:error][:message]).to include('routing_key') + end + end +end diff --git a/spec/api/worker_health_spec.rb b/spec/api/worker_health_spec.rb new file mode 100644 index 00000000..3e8785cf --- /dev/null +++ b/spec/api/worker_health_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' + +RSpec.describe 'Workers Health API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + let(:worker_id) { 'w-health-123' } + let(:worker_model) { double('Legion::Data::Model::DigitalWorker') } + let(:node_model) { double('Legion::Data::Model::Node') } + let(:worker) do + double('worker', + worker_id: worker_id, + health_status: 'online', + last_heartbeat_at: Time.now, + health_node: 'test-node', + values: { worker_id: worker_id, health_status: 'online' }) + end + + describe 'GET /api/workers/:id/health' do + context 'when data is not connected' do + it 'returns 503' do + get "/api/workers/#{worker_id}/health" + expect(last_response.status).to eq(503) + end + end + + context 'when data is connected' do + before do + stub_const('Legion::Data::Model::DigitalWorker', worker_model) + stub_const('Legion::Data::Model::Node', node_model) + Legion::Settings.loader.settings[:data] = { connected: true } + end + + after do + Legion::Settings.loader.settings[:data] = { connected: false } + end + + it 'returns health details for an existing worker' do + allow(worker_model).to receive(:first).with(worker_id: worker_id).and_return(worker) + node = double('node', parsed_metrics: { memory_rss_mb: 142 }) + allow(node_model).to receive(:[]).with(name: 'test-node').and_return(node) + + get "/api/workers/#{worker_id}/health" + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:health_status]).to eq('online') + expect(body[:data][:health_node]).to eq('test-node') + expect(body[:data][:node_metrics][:memory_rss_mb]).to eq(142) + end + + it 'returns 404 for unknown worker' do + allow(worker_model).to receive(:first).with(worker_id: 'unknown').and_return(nil) + + get '/api/workers/unknown/health' + expect(last_response.status).to eq(404) + end + + it 'returns nil node_metrics when worker has no health_node' do + offline_worker = double('worker', + worker_id: worker_id, + health_status: 'unknown', + last_heartbeat_at: nil, + health_node: nil, + values: { worker_id: worker_id, health_status: 'unknown' }) + allow(worker_model).to receive(:first).with(worker_id: worker_id).and_return(offline_worker) + + get "/api/workers/#{worker_id}/health" + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:node_metrics]).to be_nil + end + end + end + + describe 'GET /api/workers?health_status=online' do + context 'when data is connected' do + let(:dataset) { double('dataset') } + + before do + stub_const('Legion::Data::Model::DigitalWorker', worker_model) + Legion::Settings.loader.settings[:data] = { connected: true } + end + + after do + Legion::Settings.loader.settings[:data] = { connected: false } + end + + it 'filters workers by health_status' do + allow(worker_model).to receive(:order).with(:id).and_return(dataset) + allow(dataset).to receive(:where).with(health_status: 'online').and_return(dataset) + allow(dataset).to receive(:count).and_return(1) + allow(dataset).to receive(:limit).and_return(dataset) + allow(dataset).to receive(:all).and_return([worker]) + + get '/api/workers?health_status=online' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + end + end + end +end diff --git a/spec/api/workers_spec.rb b/spec/api/workers_spec.rb new file mode 100644 index 00000000..06749fbf --- /dev/null +++ b/spec/api/workers_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' +require 'legion/digital_worker/lifecycle' + +RSpec.describe 'Workers API lifecycle' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + let(:worker_id) { 'w-abc-123' } + let(:worker) do + double('worker', + worker_id: worker_id, + lifecycle_state: 'active', + values: { worker_id: worker_id, lifecycle_state: 'paused' }) + end + + def patch_lifecycle(id, body) + patch "/api/workers/#{id}/lifecycle", + Legion::JSON.dump(body), + 'CONTENT_TYPE' => 'application/json' + end + + describe 'PATCH /api/workers/:id/lifecycle' do + context 'when data is not connected' do + it 'returns 503' do + patch_lifecycle(worker_id, { state: 'paused' }) + expect(last_response.status).to eq(503) + end + end + + context 'when data is connected' do + let(:worker_model) { double('Legion::Data::Model::DigitalWorker') } + + before do + stub_const('Legion::Data::Model::DigitalWorker', worker_model) + Legion::Settings.loader.settings[:data] = { connected: true } + allow(worker_model).to receive(:first).with(worker_id: worker_id).and_return(worker) + end + + after do + Legion::Settings.loader.settings[:data] = { connected: false } + end + + context 'when state is missing' do + it 'returns 422 with missing_field error' do + patch_lifecycle(worker_id, { reason: 'test' }) + expect(last_response.status).to eq(422) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('missing_field') + end + end + + context 'when transition is invalid' do + it 'returns 422 with invalid_transition error' do + allow(Legion::DigitalWorker::Lifecycle).to receive(:transition!) + .and_raise(Legion::DigitalWorker::Lifecycle::InvalidTransition, + 'cannot transition from active to bootstrap') + + patch_lifecycle(worker_id, { state: 'bootstrap' }) + expect(last_response.status).to eq(422) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('invalid_transition') + end + end + + context 'when GovernanceRequired is raised (no governance_override)' do + it 'returns 403 with governance_required error code' do + allow(Legion::DigitalWorker::Lifecycle).to receive(:transition!) + .and_raise(Legion::DigitalWorker::Lifecycle::GovernanceRequired, + 'active -> terminated requires council_approval') + + patch_lifecycle(worker_id, { state: 'terminated' }) + expect(last_response.status).to eq(403) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('governance_required') + expect(body[:error][:message]).to match(/council_approval/) + end + end + + context 'when AuthorityRequired is raised (no authority_verified)' do + it 'returns 403 with authority_required error code' do + allow(Legion::DigitalWorker::Lifecycle).to receive(:transition!) + .and_raise(Legion::DigitalWorker::Lifecycle::AuthorityRequired, + 'active -> paused requires owner_or_manager') + + patch_lifecycle(worker_id, { state: 'paused' }) + expect(last_response.status).to eq(403) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('authority_required') + expect(body[:error][:message]).to match(/owner_or_manager/) + end + end + + context 'when governance_override is provided in the request body' do + it 'passes governance_override: true to transition!' do + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker, + to_state: 'terminated', + by: 'api', + reason: nil, + governance_override: true, + authority_verified: false + ).and_return(worker) + + patch_lifecycle(worker_id, { state: 'terminated', governance_override: true }) + expect(last_response.status).to eq(200) + end + end + + context 'when authority_verified is provided in the request body' do + it 'passes authority_verified: true to transition!' do + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker, + to_state: 'paused', + by: 'api', + reason: nil, + governance_override: false, + authority_verified: true + ).and_return(worker) + + patch_lifecycle(worker_id, { state: 'paused', authority_verified: true }) + expect(last_response.status).to eq(200) + end + end + + context 'when worker is not found' do + it 'returns 404' do + allow(worker_model).to receive(:first).with(worker_id: 'unknown').and_return(nil) + + patch_lifecycle('unknown', { state: 'paused' }) + expect(last_response.status).to eq(404) + end + end + end + end +end diff --git a/spec/api/workflow_spec.rb b/spec/api/workflow_spec.rb new file mode 100644 index 00000000..32f65c0e --- /dev/null +++ b/spec/api/workflow_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative 'api_spec_helper' +require 'legion/graph/builder' + +RSpec.describe 'Workflow API' do + include Rack::Test::Methods + + def app + Legion::API + end + + before(:all) { ApiSpecSetup.configure_settings } + + describe 'GET /api/relationships/graph' do + context 'when data is not connected' do + it 'returns 503' do + get '/api/relationships/graph' + expect(last_response.status).to eq(503) + end + end + + context 'when data is connected' do + before do + Legion::Settings.loader.settings[:data] = { connected: true } + end + + after do + Legion::Settings.loader.settings[:data] = { connected: false } + end + + it 'returns nodes and edges' do + allow(Legion::Graph::Builder).to receive(:build).and_return({ + nodes: { + 'lex-audit.write' => { label: 'lex-audit.write', type: 'trigger' }, + 'lex-data.store' => { label: 'lex-data.store', type: 'action' } + }, + edges: [{ from: 'lex-audit.write', to: 'lex-data.store', label: 'persist', +chain_id: nil }] + }) + + get '/api/relationships/graph' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:nodes]).to be_an(Array) + expect(body[:data][:edges]).to be_an(Array) + expect(body[:data][:nodes].size).to eq(2) + expect(body[:data][:edges].size).to eq(1) + end + + it 'returns empty graph when no relationships exist' do + allow(Legion::Graph::Builder).to receive(:build).and_return({ nodes: {}, edges: [] }) + + get '/api/relationships/graph' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:nodes]).to eq([]) + expect(body[:data][:edges]).to eq([]) + end + + it 'filters by extension parameter' do + allow(Legion::Graph::Builder).to receive(:build).and_return({ + nodes: { + 'lex-audit.write' => { label: 'lex-audit.write', type: 'trigger' }, + 'lex-data.store' => { label: 'lex-data.store', type: 'action' } + }, + edges: [{ from: 'lex-audit.write', to: 'lex-data.store', label: 'persist', +chain_id: nil }] + }) + + get '/api/relationships/graph', extension: 'lex-audit' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + node_ids = body[:data][:nodes].map { |n| n[:id] } + expect(node_ids).to include('lex-audit.write') + end + end + end +end diff --git a/spec/cli/admin_command_spec.rb b/spec/cli/admin_command_spec.rb new file mode 100644 index 00000000..28289d30 --- /dev/null +++ b/spec/cli/admin_command_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/admin_command' + +RSpec.describe Legion::CLI::AdminCommand do + describe '.detect_old_exchanges' do + subject(:detect) { described_class.detect_old_exchanges(exchanges) } + + context 'when both legion.X and lex.X exist' do + let(:exchanges) do + [ + { name: 'lex.runner', type: 'topic' }, + { name: 'legion.runner', type: 'topic' }, + { name: 'lex.actor', type: 'direct' }, + { name: 'legion.actor', type: 'direct' } + ] + end + + it 'returns both matched legion.* exchanges' do + expect(detect.size).to eq(2) + end + + it 'returns legion.runner as a candidate' do + expect(detect.map { |e| e[:name] }).to include('legion.runner') + end + + it 'returns legion.actor as a candidate' do + expect(detect.map { |e| e[:name] }).to include('legion.actor') + end + + it 'does not return the lex.* exchanges themselves' do + names = detect.map { |e| e[:name] } + expect(names).not_to include('lex.runner') + expect(names).not_to include('lex.actor') + end + end + + context 'when there is no lex.* counterpart for a legion.* exchange' do + let(:exchanges) do + [ + { name: 'legion.orphan', type: 'topic' }, + { name: 'lex.other', type: 'direct' } + ] + end + + it 'does not return legion.orphan (no lex.orphan exists)' do + expect(detect).to be_empty + end + end + + context 'when core exchanges like task, node, extensions exist without lex.* counterparts' do + let(:exchanges) do + [ + { name: 'legion.task', type: 'topic' }, + { name: 'legion.node', type: 'topic' }, + { name: 'legion.extensions', type: 'fanout' } + ] + end + + it 'does not return legion.task' do + names = detect.map { |e| e[:name] } + expect(names).not_to include('legion.task') + end + + it 'does not return legion.node' do + names = detect.map { |e| e[:name] } + expect(names).not_to include('legion.node') + end + + it 'does not return legion.extensions' do + names = detect.map { |e| e[:name] } + expect(names).not_to include('legion.extensions') + end + + it 'returns an empty list' do + expect(detect).to be_empty + end + end + + context 'when the exchange list is empty' do + let(:exchanges) { [] } + + it 'returns an empty array' do + expect(detect).to be_empty + end + end + + context 'when only lex.* exchanges exist (no legion.* at all)' do + let(:exchanges) do + [ + { name: 'lex.runner', type: 'topic' }, + { name: 'lex.actor', type: 'direct' } + ] + end + + it 'returns an empty array' do + expect(detect).to be_empty + end + end + + context 'when a partial overlap exists (some pairs matched, some not)' do + let(:exchanges) do + [ + { name: 'lex.runner', type: 'topic' }, + { name: 'legion.runner', type: 'topic' }, + { name: 'legion.old_only', type: 'direct' } + ] + end + + it 'returns only the matched exchange' do + expect(detect.size).to eq(1) + end + + it 'returns legion.runner' do + expect(detect.first[:name]).to eq('legion.runner') + end + + it 'does not return legion.old_only (no lex.old_only counterpart)' do + names = detect.map { |e| e[:name] } + expect(names).not_to include('legion.old_only') + end + end + + context 'when exchanges have mixed prefixes and unrelated names' do + let(:exchanges) do + [ + { name: '', type: 'direct' }, + { name: 'amq.direct', type: 'direct' }, + { name: 'amq.topic', type: 'topic' }, + { name: 'lex.events', type: 'fanout' }, + { name: 'legion.events', type: 'fanout' } + ] + end + + it 'returns only legion.events' do + expect(detect.size).to eq(1) + expect(detect.first[:name]).to eq('legion.events') + end + end + end + + describe 'Thor registration' do + let(:command) { described_class.commands['purge_topology'] } + + it 'has a purge-topology command' do + expect(described_class.commands).to have_key('purge_topology') + end + + it 'declares --dry-run option' do + expect(command.options).to have_key(:dry_run) + end + + it 'declares --execute option' do + expect(command.options).to have_key(:execute) + end + + it 'declares --host option' do + expect(command.options).to have_key(:host) + end + + it 'defaults dry_run to true' do + expect(command.options[:dry_run].default).to be true + end + + it 'defaults execute to false' do + expect(command.options[:execute].default).to be false + end + end +end diff --git a/spec/cli/auth_kerberos_spec.rb b/spec/cli/auth_kerberos_spec.rb new file mode 100644 index 00000000..7d321323 --- /dev/null +++ b/spec/cli/auth_kerberos_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/auth_command' + +RSpec.describe Legion::CLI::Auth do + it 'registers kerberos as a Thor command' do + expect(described_class.commands).to have_key('kerberos') + end + + it 'has the correct description for kerberos' do + expect(described_class.commands['kerberos'].description).to eq('Authenticate using Kerberos TGT from your workstation') + end +end diff --git a/spec/cli/bootstrap_command_spec.rb b/spec/cli/bootstrap_command_spec.rb new file mode 100644 index 00000000..1534854b --- /dev/null +++ b/spec/cli/bootstrap_command_spec.rb @@ -0,0 +1,781 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/bootstrap_command' +require 'legion/cli/config_import' +require 'legion/cli/config_scaffold' +require 'legion/cli/setup_command' + +RSpec.describe Legion::CLI::Bootstrap do + let(:out) do + instance_double( + Legion::CLI::Output::Formatter, + success: nil, warn: nil, error: nil, + header: nil, spacer: nil, json: nil + ) + end + let(:cli) { described_class.new } + + before do + allow(cli).to receive(:formatter).and_return(out) + allow(cli).to receive(:options).and_return(default_options) + end + + let(:default_options) do + { json: false, no_color: true, skip_packs: false, start: false, force: false } + end + + # --------------------------------------------------------------------------- + # Class structure / Thor registration + # --------------------------------------------------------------------------- + + describe 'Thor registration' do + it 'has an execute command' do + expect(described_class.commands).to have_key('execute') + end + + it 'sets exit_on_failure? to true' do + expect(described_class.exit_on_failure?).to be true + end + + it 'declares --skip-packs class option' do + expect(described_class.class_options).to have_key(:skip_packs) + end + + it 'declares --start class option' do + expect(described_class.class_options).to have_key(:start) + end + + it 'declares --force class option' do + expect(described_class.class_options).to have_key(:force) + end + + it 'declares --clean class option' do + expect(described_class.class_options).to have_key(:clean) + end + + it 'declares --json class option' do + expect(described_class.class_options).to have_key(:json) + end + end + + describe 'Main registration' do + it 'registers bootstrap on Legion::CLI::Main' do + expect(Legion::CLI::Main.subcommand_classes).to have_key('bootstrap') + end + + it 'maps bootstrap to Legion::CLI::Bootstrap' do + expect(Legion::CLI::Main.subcommand_classes['bootstrap']).to eq(described_class) + end + end + + # --------------------------------------------------------------------------- + # Pre-flight check helpers + # --------------------------------------------------------------------------- + + describe 'pre-flight checks' do + let(:warns) { [] } + + describe '#check_klist' do + context 'when klist succeeds with a principal in output' do + before do + allow(cli).to receive(:shell_capture).with('klist').and_return( + ['Default principal: user@UHG.COM', true] + ) + end + + it 'returns status :ok' do + result = cli.send(:check_klist, out, warns) + expect(result[:status]).to eq(:ok) + end + + it 'does not add any warnings' do + cli.send(:check_klist, out, warns) + expect(warns).to be_empty + end + end + + context 'when klist exit status is failure' do + before do + allow(cli).to receive(:shell_capture).with('klist').and_return(['', false]) + end + + it 'returns status :warn' do + result = cli.send(:check_klist, out, warns) + expect(result[:status]).to eq(:warn) + end + + it 'adds a warning message' do + cli.send(:check_klist, out, warns) + expect(warns).not_to be_empty + end + + it 'message mentions kinit' do + cli.send(:check_klist, out, warns) + expect(warns.first).to include('kinit') + end + end + + context 'when klist exits ok but output has no principal/credentials string' do + before do + allow(cli).to receive(:shell_capture).with('klist').and_return(['no matching output', true]) + end + + it 'returns status :warn' do + result = cli.send(:check_klist, out, warns) + expect(result[:status]).to eq(:warn) + end + end + + context 'when shell_capture raises an exception' do + before do + allow(cli).to receive(:shell_capture).with('klist').and_raise(Errno::ENOENT, 'klist not found') + end + + it 'returns status :warn' do + result = cli.send(:check_klist, out, warns) + expect(result[:status]).to eq(:warn) + end + + it 'adds a message mentioning klist check failed' do + cli.send(:check_klist, out, warns) + expect(warns.first).to include('klist check failed') + end + end + end + + describe '#check_brew' do + context 'when brew is available' do + before do + allow(cli).to receive(:shell_capture).with('brew --version').and_return(['Homebrew 4.0.0', true]) + end + + it 'returns status :ok' do + result = cli.send(:check_brew, out, warns) + expect(result[:status]).to eq(:ok) + end + + it 'does not add warnings' do + cli.send(:check_brew, out, warns) + expect(warns).to be_empty + end + end + + context 'when brew is not available' do + before do + allow(cli).to receive(:shell_capture).with('brew --version').and_return(['', false]) + end + + it 'returns status :warn' do + result = cli.send(:check_brew, out, warns) + expect(result[:status]).to eq(:warn) + end + + it 'message mentions brew.sh' do + cli.send(:check_brew, out, warns) + expect(warns.first).to include('brew.sh') + end + end + + context 'when shell_capture raises an exception' do + before do + allow(cli).to receive(:shell_capture).with('brew --version').and_raise(Errno::ENOENT, 'brew not found') + end + + it 'returns status :warn and captures the error' do + result = cli.send(:check_brew, out, warns) + expect(result[:status]).to eq(:warn) + expect(warns.first).to include('brew check failed') + end + end + end + + describe '#check_legionio_binary' do + context 'when legionio works' do + before do + allow(cli).to receive(:shell_capture).with('legionio version').and_return(['legionio 1.6.4', true]) + end + + it 'returns status :ok' do + result = cli.send(:check_legionio_binary, out, warns) + expect(result[:status]).to eq(:ok) + end + end + + context 'when legionio binary fails' do + before do + allow(cli).to receive(:shell_capture).with('legionio version').and_return(['', false]) + end + + it 'returns status :warn' do + result = cli.send(:check_legionio_binary, out, warns) + expect(result[:status]).to eq(:warn) + end + + it 'message mentions reinstall' do + cli.send(:check_legionio_binary, out, warns) + expect(warns.first).to include('reinstall') + end + end + + context 'when shell_capture raises an exception' do + before do + allow(cli).to receive(:shell_capture).with('legionio version').and_raise(Errno::ENOENT, 'not found') + end + + it 'returns status :warn' do + result = cli.send(:check_legionio_binary, out, warns) + expect(result[:status]).to eq(:warn) + end + end + end + end + + # --------------------------------------------------------------------------- + # Pack extraction from config JSON + # --------------------------------------------------------------------------- + + describe 'pack extraction' do + it 'removes :packs key from config before writing' do + config = { packs: ['agentic'], llm: { enabled: true } } + pack_names = Array(config.delete(:packs)).map(&:to_s).reject(&:empty?) + expect(config).not_to have_key(:packs) + expect(pack_names).to eq(['agentic']) + end + + it 'handles missing packs key gracefully' do + config = { llm: { enabled: true } } + pack_names = Array(config.delete(:packs)).map(&:to_s).reject(&:empty?) + expect(pack_names).to eq([]) + end + + it 'handles empty packs array' do + config = { packs: [], llm: { enabled: true } } + pack_names = Array(config.delete(:packs)).map(&:to_s).reject(&:empty?) + expect(pack_names).to eq([]) + end + + it 'handles multiple packs' do + config = { packs: %w[agentic llm], llm: { enabled: true } } + pack_names = Array(config.delete(:packs)).map(&:to_s).reject(&:empty?) + expect(pack_names).to eq(%w[agentic llm]) + end + end + + # --------------------------------------------------------------------------- + # Shared stubs used by execute integration tests + # --------------------------------------------------------------------------- + + def stub_happy_path(opts = {}) + allow(Legion::CLI::ConfigImport).to receive(:fetch_source) + .and_return(opts.fetch(:body, '{}')) + allow(Legion::CLI::ConfigImport).to receive(:parse_payload) + .and_return(opts.fetch(:config, {})) + allow(Legion::CLI::ConfigImport).to receive(:write_config) + .and_return(opts.fetch(:paths, ['/tmp/bootstrapped_settings.json'])) + allow(cli).to receive(:run_preflight_checks).and_return({}) + allow(cli).to receive(:install_packs).and_return([]) + allow(cli).to receive(:print_summary) + end + + # --------------------------------------------------------------------------- + # Config fetch delegation + # --------------------------------------------------------------------------- + + describe 'config fetch delegation' do + it 'delegates to ConfigImport.fetch_source for HTTP URLs' do + expect(Legion::CLI::ConfigImport).to receive(:fetch_source) + .with('https://example.com/demo.json').and_return('{}') + stub_happy_path(body: '{}') + cli.execute('https://example.com/demo.json') + end + + it 'delegates to ConfigImport.fetch_source for local file paths' do + expect(Legion::CLI::ConfigImport).to receive(:fetch_source) + .with('/tmp/bootstrap.json').and_return('{}') + stub_happy_path(body: '{}') + cli.execute('/tmp/bootstrap.json') + end + end + + # --------------------------------------------------------------------------- + # Config write delegation + # --------------------------------------------------------------------------- + + describe 'config write delegation' do + it 'delegates to ConfigImport.write_config with force: true when --force is set' do + allow(cli).to receive(:options).and_return(default_options.merge(force: true)) + allow(Legion::CLI::ConfigImport).to receive(:fetch_source).and_return('{}') + allow(Legion::CLI::ConfigImport).to receive(:parse_payload).and_return({ llm: { enabled: true } }) + expect(Legion::CLI::ConfigImport).to receive(:write_config) + .with({ llm: { enabled: true } }, force: true).and_return(['/tmp/llm.json']) + allow(cli).to receive(:run_preflight_checks).and_return({}) + allow(cli).to receive(:install_packs).and_return([]) + allow(cli).to receive(:print_summary) + cli.execute('/tmp/bootstrap.json') + end + + it 'passes force: false by default' do + allow(Legion::CLI::ConfigImport).to receive(:fetch_source).and_return('{}') + allow(Legion::CLI::ConfigImport).to receive(:parse_payload).and_return({}) + expect(Legion::CLI::ConfigImport).to receive(:write_config) + .with({}, force: false).and_return([]) + allow(cli).to receive(:run_preflight_checks).and_return({}) + allow(cli).to receive(:install_packs).and_return([]) + allow(cli).to receive(:print_summary) + cli.execute('/tmp/bootstrap.json') + end + end + + # --------------------------------------------------------------------------- + # Pack install invocation + # --------------------------------------------------------------------------- + + describe 'pack install invocation' do + before do + allow(cli).to receive(:options).and_return(default_options.merge(skip_packs: false)) + allow(Legion::CLI::ConfigImport).to receive(:fetch_source).and_return('{"packs":["agentic"]}') + allow(Legion::CLI::ConfigImport).to receive(:parse_payload) + .and_return({ packs: ['agentic'], llm: { enabled: true } }) + allow(Legion::CLI::ConfigImport).to receive(:write_config).and_return(['/tmp/llm.json']) + allow(cli).to receive(:run_preflight_checks).and_return({}) + allow(cli).to receive(:print_summary) + end + + it 'calls install_packs with the extracted pack names' do + expect(cli).to receive(:install_packs).with(['agentic'], out).and_return([]) + cli.execute('/tmp/bootstrap.json') + end + + context 'when --skip-packs is set' do + before { allow(cli).to receive(:options).and_return(default_options.merge(skip_packs: true)) } + + it 'does not call install_packs' do + expect(cli).not_to receive(:install_packs) + cli.execute('/tmp/bootstrap.json') + end + end + end + + # --------------------------------------------------------------------------- + # install_packs helper + # --------------------------------------------------------------------------- + + describe '#install_packs' do + before do + allow(RbConfig::CONFIG).to receive(:[]).with('bindir').and_return('/usr/bin') + end + + it 'warns and skips unknown pack names' do + expect(out).to receive(:warn).with(a_string_including('Unknown pack')) + result = cli.send(:install_packs, ['nonexistent_pack'], out) + expect(result).to be_empty + end + + it 'returns empty array for empty pack_names' do + result = cli.send(:install_packs, [], out) + expect(result).to eq([]) + end + + it 'returns a result entry per known pack' do + allow(cli).to receive(:install_pack_gems).and_return([{ name: 'legion-llm', status: 'installed' }]) + allow(Gem::Specification).to receive(:reset) + result = cli.send(:install_packs, ['llm'], out) + expect(result.size).to eq(1) + expect(result.first[:pack]).to eq('llm') + end + end + + # --------------------------------------------------------------------------- + # install_single_gem helper + # --------------------------------------------------------------------------- + + describe '#install_single_gem' do + let(:gem_bin) { '/usr/bin/gem' } + + context 'when gem installs successfully' do + before do + allow(cli).to receive(:shell_capture) + .with('/usr/bin/gem install lex-foo --no-document --source https://rubygems.org/') + .and_return(['Successfully installed lex-foo-0.1.0', true]) + end + + it 'returns installed status' do + result = cli.send(:install_single_gem, 'lex-foo', gem_bin, out) + expect(result).to eq({ name: 'lex-foo', status: 'installed' }) + end + end + + context 'when gem install fails' do + before do + allow(cli).to receive(:shell_capture) + .with('/usr/bin/gem install lex-foo --no-document --source https://rubygems.org/') + .and_return(["ERROR: Could not find gem 'lex-foo'", false]) + end + + it 'returns failed status with error message' do + result = cli.send(:install_single_gem, 'lex-foo', gem_bin, out) + expect(result[:status]).to eq('failed') + expect(result[:error]).to be_a(String) + end + end + end + + # --------------------------------------------------------------------------- + # build_summary + # --------------------------------------------------------------------------- + + describe '#build_summary' do + let(:config) { { llm: { enabled: true } } } + let(:results) { { packs_requested: ['agentic'], packs_installed: [], preflight: {} } } + let(:warns) { ['test warning'] } + + before do + Legion::CLI::ConfigScaffold::SUBSYSTEMS.each do |s| + path = File.join(Legion::CLI::ConfigImport::SETTINGS_DIR, "#{s}.json") + allow(File).to receive(:exist?).with(path).and_return(true) + end + end + + it 'includes config_sections' do + summary = cli.send(:build_summary, config, results, warns) + expect(summary[:config_sections]).to include('llm') + end + + it 'includes packs_requested' do + summary = cli.send(:build_summary, config, results, warns) + expect(summary[:packs_requested]).to eq(['agentic']) + end + + it 'includes warnings' do + summary = cli.send(:build_summary, config, results, warns) + expect(summary[:warnings]).to eq(['test warning']) + end + + it 'includes subsystem_files hash keyed by subsystem names' do + summary = cli.send(:build_summary, config, results, warns) + expect(summary[:subsystem_files]).to be_a(Hash) + expect(summary[:subsystem_files].keys).to include(*Legion::CLI::ConfigScaffold::SUBSYSTEMS) + end + end + + # --------------------------------------------------------------------------- + # build_scaffold_opts + # --------------------------------------------------------------------------- + + describe '#build_scaffold_opts' do + it 'returns a hash with force: false' do + opts = cli.send(:build_scaffold_opts) + expect(opts[:force]).to be false + end + + it 'returns a hash with json: false' do + opts = cli.send(:build_scaffold_opts) + expect(opts[:json]).to be false + end + end + + # --------------------------------------------------------------------------- + # --skip-packs flag + # --------------------------------------------------------------------------- + + describe '--skip-packs flag' do + before do + allow(cli).to receive(:options).and_return(default_options.merge(skip_packs: true)) + stub_happy_path(config: { packs: ['agentic'], llm: { enabled: true } }) + allow(Legion::CLI::ConfigImport).to receive(:parse_payload) + .and_return({ packs: ['agentic'], llm: { enabled: true } }) + end + + it 'skips pack installation' do + expect(cli).not_to receive(:install_packs) + cli.execute('/tmp/bootstrap.json') + end + + it 'sets packs_installed to empty array in results' do + results_captured = nil + allow(cli).to receive(:build_summary) do |_config, results, _warns| + results_captured = results + {} + end + allow(cli).to receive(:print_summary) + cli.execute('/tmp/bootstrap.json') + expect(results_captured[:packs_installed]).to eq([]) + end + end + + # --------------------------------------------------------------------------- + # --start flag + # --------------------------------------------------------------------------- + + describe '--start flag' do + before do + allow(cli).to receive(:options).and_return(default_options.merge(start: true)) + stub_happy_path + end + + it 'calls start_services when --start is set' do + expect(cli).to receive(:start_services).with(out).and_return({ redis: true, legionio: true }) + cli.execute('/tmp/bootstrap.json') + end + + it 'does not call start_services when --start is false' do + allow(cli).to receive(:options).and_return(default_options.merge(start: false)) + expect(cli).not_to receive(:start_services) + cli.execute('/tmp/bootstrap.json') + end + end + + # --------------------------------------------------------------------------- + # --clean flag + # --------------------------------------------------------------------------- + + describe '--clean flag' do + let(:tmpdir) { Dir.mktmpdir('legion_bootstrap_clean') } + + before do + stub_const('Legion::CLI::ConfigImport::SETTINGS_DIR', tmpdir) + File.write(File.join(tmpdir, 'transport.json'), '{}') + File.write(File.join(tmpdir, 'llm.json'), '{}') + end + + after { FileUtils.rm_rf(tmpdir) } + + context 'when --clean is set' do + before do + allow(cli).to receive(:options).and_return(default_options.merge(clean: true)) + stub_happy_path + end + + it 'removes existing json files before import' do + cli.execute('/tmp/bootstrap.json') + expect(Dir.glob(File.join(tmpdir, '*.json'))).to be_empty + end + + it 'sets results[:cleaned] to the removed file list' do + results_captured = nil + allow(cli).to receive(:build_summary) do |_config, results, _warns| + results_captured = results + {} + end + cli.execute('/tmp/bootstrap.json') + expect(results_captured[:cleaned]).to be_an(Array) + expect(results_captured[:cleaned].size).to eq(2) + end + end + + context 'when --clean is not set' do + before do + allow(cli).to receive(:options).and_return(default_options.merge(clean: false)) + stub_happy_path + end + + it 'does not remove existing files' do + cli.execute('/tmp/bootstrap.json') + expect(Dir.glob(File.join(tmpdir, '*.json')).size).to eq(2) + end + + it 'does not set results[:cleaned]' do + results_captured = nil + allow(cli).to receive(:build_summary) do |_config, results, _warns| + results_captured = results + {} + end + cli.execute('/tmp/bootstrap.json') + expect(results_captured).not_to have_key(:cleaned) + end + end + + context 'when --clean is set but no files exist' do + before do + FileUtils.rm_f(Dir.glob(File.join(tmpdir, '*.json'))) + allow(cli).to receive(:options).and_return(default_options.merge(clean: true)) + stub_happy_path + end + + it 'returns an empty array' do + results_captured = nil + allow(cli).to receive(:build_summary) do |_config, results, _warns| + results_captured = results + {} + end + cli.execute('/tmp/bootstrap.json') + expect(results_captured[:cleaned]).to eq([]) + end + end + end + + # --------------------------------------------------------------------------- + # Scaffold skipping (source-provided bootstrap always skips scaffold) + # --------------------------------------------------------------------------- + + describe 'scaffold skipping' do + before { stub_happy_path } + + it 'does not call ConfigScaffold.run' do + expect(Legion::CLI::ConfigScaffold).not_to receive(:run) + cli.execute('/tmp/bootstrap.json') + end + + it 'sets results[:scaffold] to :skipped' do + results_captured = nil + allow(cli).to receive(:build_summary) do |_config, results, _warns| + results_captured = results + {} + end + cli.execute('/tmp/bootstrap.json') + expect(results_captured[:scaffold]).to eq(:skipped) + end + end + + # --------------------------------------------------------------------------- + # --json output mode + # --------------------------------------------------------------------------- + + describe '--json output mode' do + before do + allow(cli).to receive(:options).and_return(default_options.merge(json: true)) + stub_happy_path + allow(cli).to receive(:build_summary).and_return({ config_sections: [] }) + allow(cli).to receive(:print_summary) + end + + it 'calls out.json with the results hash containing config_written array' do + expect(out).to receive(:json).with(hash_including(config_written: ['/tmp/bootstrapped_settings.json'])) + cli.execute('/tmp/bootstrap.json') + end + + it 'does not call out.header' do + expect(out).not_to receive(:header) + cli.execute('/tmp/bootstrap.json') + end + end + + # --------------------------------------------------------------------------- + # Error handling + # --------------------------------------------------------------------------- + + describe 'error handling' do + context 'when fetch_source raises CLI::Error (bad URL / 404)' do + before do + allow(Legion::CLI::ConfigImport).to receive(:fetch_source) + .and_raise(Legion::CLI::Error, 'HTTP 404: Not Found') + allow(cli).to receive(:run_preflight_checks).and_return({}) + end + + it 'outputs the error message' do + expect(out).to receive(:error).with('HTTP 404: Not Found') + expect { cli.execute('https://example.com/missing.json') }.to raise_error(SystemExit) + end + end + + context 'when parse_payload raises CLI::Error (invalid JSON)' do + before do + allow(Legion::CLI::ConfigImport).to receive(:fetch_source).and_return('not json') + allow(Legion::CLI::ConfigImport).to receive(:parse_payload) + .and_raise(Legion::CLI::Error, 'Source is not valid JSON or base64-encoded JSON') + allow(cli).to receive(:run_preflight_checks).and_return({}) + end + + it 'outputs the error and raises SystemExit' do + expect(out).to receive(:error).with(a_string_including('not valid JSON')) + expect { cli.execute('/tmp/bad.json') }.to raise_error(SystemExit) + end + end + + context 'when fetch_source raises CLI::Error (network / 503)' do + before do + allow(Legion::CLI::ConfigImport).to receive(:fetch_source) + .and_raise(Legion::CLI::Error, 'HTTP 503: Service Unavailable') + allow(cli).to receive(:run_preflight_checks).and_return({}) + end + + it 'outputs error and raises SystemExit' do + expect(out).to receive(:error).with(a_string_including('503')) + expect { cli.execute('https://example.com/bootstrap.json') }.to raise_error(SystemExit) + end + end + end + + # --------------------------------------------------------------------------- + # run_brew_service helper + # --------------------------------------------------------------------------- + + describe '#run_brew_service' do + context 'when brew services start succeeds' do + before do + allow(cli).to receive(:shell_capture) + .with('brew services start redis').and_return(['Successfully started redis', true]) + end + + it 'returns true' do + result = cli.send(:run_brew_service, 'redis', out) + expect(result).to be true + end + end + + context 'when brew services start fails' do + before do + allow(cli).to receive(:shell_capture) + .with('brew services start redis').and_return(['Error: redis not installed', false]) + end + + it 'returns false' do + result = cli.send(:run_brew_service, 'redis', out) + expect(result).to be false + end + end + + context 'when shell_capture raises an exception' do + before do + allow(cli).to receive(:shell_capture) + .with('brew services start redis').and_raise(Errno::ENOENT, 'brew not found') + end + + it 'returns false without raising' do + result = cli.send(:run_brew_service, 'redis', out) + expect(result).to be false + end + end + end + + # --------------------------------------------------------------------------- + # Summary output (print_summary smoke) + # --------------------------------------------------------------------------- + + describe '#print_summary' do + let(:summary) do + { + config_sections: %w[llm transport], + packs_requested: ['agentic'], + packs_installed: [{ pack: 'agentic', results: [{ name: 'legion-llm', status: 'installed' }] }], + subsystem_files: { 'transport' => true, 'data' => false }, + warnings: [], + preflight: {} + } + end + + it 'calls out.header with Bootstrap Summary' do + expect(out).to receive(:header).with('Bootstrap Summary') + cli.send(:print_summary, out, summary) + end + + it 'calls out.success for successfully installed packs' do + expect(out).to receive(:success).with(a_string_including('agentic')) + cli.send(:print_summary, out, summary) + end + + it 'prints warnings section when warnings are present' do + summary_with_warn = summary.merge(warnings: ['something went wrong']) + expect(out).to receive(:warn).with('something went wrong') + cli.send(:print_summary, out, summary_with_warn) + end + + it 'is a no-op in json mode' do + allow(cli).to receive(:options).and_return(default_options.merge(json: true)) + expect(out).not_to receive(:header) + cli.send(:print_summary, out, summary) + end + end +end diff --git a/spec/cli/chat/extension_tool_loader_spec.rb b/spec/cli/chat/extension_tool_loader_spec.rb new file mode 100644 index 00000000..e628931f --- /dev/null +++ b/spec/cli/chat/extension_tool_loader_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require 'legion/cli/chat/extension_tool' +require 'legion/cli/chat/extension_tool_loader' + +RSpec.describe Legion::CLI::Chat::ExtensionToolLoader do + before do + described_class.reset! + end + + describe '.discover' do + it 'returns an array' do + expect(described_class.discover).to be_an(Array) + end + + it 'returns empty array when no extensions are loaded' do + allow(described_class).to receive(:loaded_extension_paths).and_return([]) + expect(described_class.discover).to eq([]) + end + end + + describe '.tools_dir_for' do + it 'returns the tools directory path for an extension' do + path = described_class.tools_dir_for('/fake/lib/legion/extensions/redis') + expect(path).to eq('/fake/lib/legion/extensions/redis/tools') + end + end + + describe '.collect_tool_classes' do + it 'collects Legion::Tools::Base subclasses from a module' do + mod = Module.new + tool_class = Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + description 'Test tool' + permission_tier :read + def execute = 'ok' + end + allow(mod).to receive(:constants).and_return([:TestTool]) + allow(mod).to receive(:const_get).with(:TestTool).and_return(tool_class) + + tools = described_class.collect_tool_classes(mod) + expect(tools).to eq([tool_class]) + end + + it 'skips non-Tool classes' do + mod = Module.new + not_a_tool = Class.new + allow(mod).to receive(:constants).and_return([:NotATool]) + allow(mod).to receive(:const_get).with(:NotATool).and_return(not_a_tool) + + expect(described_class.collect_tool_classes(mod)).to eq([]) + end + end + + describe '.tool_enabled?' do + it 'returns true by default' do + expect(described_class.tool_enabled?('redis')).to be true + end + + it 'returns false when tools.enabled is false in settings' do + allow(described_class).to receive(:extension_settings).with('redis').and_return({ tools: { enabled: false } }) + expect(described_class.tool_enabled?('redis')).to be false + end + end + + describe '.effective_tier' do + let(:tool_class) do + Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + description 'Test' + permission_tier :read + end + end + + it 'returns the class-declared tier when no settings override' do + expect(described_class.effective_tier(tool_class, 'redis')).to eq(:read) + end + + it 'returns the settings override when it is more restrictive' do + allow(described_class).to receive(:settings_tier_for).and_return(:shell) + expect(described_class.effective_tier(tool_class, 'redis')).to eq(:shell) + end + end +end diff --git a/spec/cli/chat/extension_tool_spec.rb b/spec/cli/chat/extension_tool_spec.rb new file mode 100644 index 00000000..35d2be62 --- /dev/null +++ b/spec/cli/chat/extension_tool_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require 'legion/cli/chat/extension_tool' + +RSpec.describe Legion::CLI::Chat::ExtensionTool do + let(:read_tool) do + Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + description 'A read tool' + permission_tier :read + end + end + + let(:default_tool) do + Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + description 'A default tool' + end + end + + let(:shell_tool) do + Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + description 'A shell tool' + permission_tier :shell + end + end + + it 'returns the declared tier' do + expect(read_tool.declared_permission_tier).to eq(:read) + end + + it 'defaults to :write when no tier declared' do + expect(default_tool.declared_permission_tier).to eq(:write) + end + + it 'supports :shell tier' do + expect(shell_tool.declared_permission_tier).to eq(:shell) + end + + it 'rejects invalid tiers' do + expect do + Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + permission_tier :admin + end + end.to raise_error(ArgumentError, /invalid permission tier/i) + end +end diff --git a/spec/cli/chat/permissions_spec.rb b/spec/cli/chat/permissions_spec.rb new file mode 100644 index 00000000..f477135e --- /dev/null +++ b/spec/cli/chat/permissions_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require 'legion/cli/chat/tool_registry' +require 'legion/cli/chat/extension_tool' + +RSpec.describe Legion::CLI::Chat::Permissions do + let(:read_tool) do + Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + description 'Read tool' + permission_tier :read + end + end + + let(:write_tool) do + Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + description 'Write tool' + permission_tier :write + end + end + + after { described_class.clear_extension_tiers! } + + describe '.register_extension_tier' do + it 'registers a tier for an extension tool class' do + described_class.register_extension_tier(read_tool, :read) + expect(described_class.tier_for(read_tool)).to eq(:read) + end + end + + describe '.tier_for with extension tools' do + it 'returns :read for registered read-tier extension tools' do + described_class.register_extension_tier(read_tool, :read) + expect(described_class.tier_for(read_tool)).to eq(:read) + end + + it 'returns :write for registered write-tier extension tools' do + described_class.register_extension_tier(write_tool, :write) + expect(described_class.tier_for(write_tool)).to eq(:write) + end + + it 'returns :read for unregistered tools (default fallback)' do + expect(described_class.tier_for(read_tool)).to eq(:read) + end + end + + describe '.tier_for with builtin tools' do + it 'returns :read for ReadFile' do + expect(described_class.tier_for(Legion::CLI::Chat::Tools::ReadFile)).to eq(:read) + end + + it 'returns :write for WriteFile' do + expect(described_class.tier_for(Legion::CLI::Chat::Tools::WriteFile)).to eq(:write) + end + + it 'returns :shell for RunCommand' do + expect(described_class.tier_for(Legion::CLI::Chat::Tools::RunCommand)).to eq(:shell) + end + end +end diff --git a/spec/cli/chat/plan_mode_extension_tools_spec.rb b/spec/cli/chat/plan_mode_extension_tools_spec.rb new file mode 100644 index 00000000..e9b2c5f3 --- /dev/null +++ b/spec/cli/chat/plan_mode_extension_tools_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require 'legion/cli/chat/tool_registry' +require 'legion/cli/chat/extension_tool' + +RSpec.describe 'Plan mode with extension tools' do + let(:read_ext_tool) do + Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + description 'Read ext tool' + permission_tier :read + end + end + + let(:write_ext_tool) do + Class.new(Legion::Tools::Base) do + include Legion::CLI::Chat::ExtensionTool + + description 'Write ext tool' + permission_tier :write + end + end + + after { Legion::CLI::Chat::Permissions.clear_extension_tiers! } + + it 'read-tier extension tools survive plan mode filtering' do + perms = Legion::CLI::Chat::Permissions + perms.register_extension_tier(read_ext_tool, :read) + perms.register_extension_tier(write_ext_tool, :write) + + all_tools = [ + Legion::CLI::Chat::Tools::ReadFile, + Legion::CLI::Chat::Tools::WriteFile, + read_ext_tool, + write_ext_tool + ] + + # Plan mode keeps only read-tier tools + plan_tools = all_tools.select do |t| + perms.tier_for(t) == :read + end + + expect(plan_tools).to include(Legion::CLI::Chat::Tools::ReadFile) + expect(plan_tools).to include(read_ext_tool) + expect(plan_tools).not_to include(Legion::CLI::Chat::Tools::WriteFile) + expect(plan_tools).not_to include(write_ext_tool) + end + + it 'write-tier extension tools are excluded in plan mode' do + perms = Legion::CLI::Chat::Permissions + perms.register_extension_tier(write_ext_tool, :write) + + plan_tools = [write_ext_tool].select { |t| perms.tier_for(t) == :read } + expect(plan_tools).to be_empty + end +end diff --git a/spec/cli/chat/tool_registry_spec.rb b/spec/cli/chat/tool_registry_spec.rb new file mode 100644 index 00000000..da0d84f2 --- /dev/null +++ b/spec/cli/chat/tool_registry_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +require 'legion/cli/chat/tool_registry' +require 'legion/cli/chat/extension_tool_loader' + +RSpec.describe Legion::CLI::Chat::ToolRegistry do + describe '.builtin_tools' do + it 'returns 40 built-in tools' do + expect(described_class.builtin_tools.length).to eq(40) + end + end + + describe '.all_tools' do + before { Legion::CLI::Chat::ExtensionToolLoader.reset! } + + it 'includes builtin tools' do + expect(described_class.all_tools).to include(*described_class.builtin_tools) + end + + it 'includes extension tools when available' do + fake_tool = Class.new(Legion::Tools::Base) do + description 'Fake extension tool' + def execute = 'ok' + end + allow(Legion::CLI::Chat::ExtensionToolLoader).to receive(:discover).and_return([fake_tool]) + + tools = described_class.all_tools + expect(tools).to include(fake_tool) + expect(tools.length).to eq(41) + end + end +end diff --git a/spec/cli/codegen_command_spec.rb b/spec/cli/codegen_command_spec.rb new file mode 100644 index 00000000..34078d66 --- /dev/null +++ b/spec/cli/codegen_command_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe Legion::CLI::CodegenCommand do + subject(:cli) { described_class.new } + + describe '#status' do + it 'responds to status' do + expect(cli).to respond_to(:status) + end + end + + describe '#list' do + it 'responds to list' do + expect(cli).to respond_to(:list) + end + end + + describe '#show' do + it 'responds to show' do + expect(cli).to respond_to(:show) + end + end + + describe '#approve' do + it 'responds to approve' do + expect(cli).to respond_to(:approve) + end + end + + describe '#reject' do + it 'responds to reject' do + expect(cli).to respond_to(:reject) + end + end + + describe '#gaps' do + it 'responds to gaps' do + expect(cli).to respond_to(:gaps) + end + end + + describe '#cycle' do + it 'responds to cycle' do + expect(cli).to respond_to(:cycle) + end + end +end diff --git a/spec/cli/config_reset_spec.rb b/spec/cli/config_reset_spec.rb new file mode 100644 index 00000000..90497b47 --- /dev/null +++ b/spec/cli/config_reset_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/config_command' +require 'legion/cli/config_import' + +RSpec.describe Legion::CLI::Config, '#reset' do + let(:out) do + instance_double( + Legion::CLI::Output::Formatter, + success: nil, warn: nil, error: nil, + header: nil, spacer: nil, json: nil + ) + end + let(:cli) { described_class.new } + let(:tmpdir) { Dir.mktmpdir('legion_config_reset') } + + before do + allow(cli).to receive(:formatter).and_return(out) + end + + after { FileUtils.rm_rf(tmpdir) } + + describe 'when files exist' do + before do + File.write(File.join(tmpdir, 'transport.json'), '{}') + File.write(File.join(tmpdir, 'llm.json'), '{}') + File.write(File.join(tmpdir, 'keep.yaml'), 'not json') + end + + context 'with --force' do + before do + allow(cli).to receive(:options).and_return(json: false, no_color: true, force: true, config_dir: tmpdir) + end + + it 'removes all .json files' do + cli.reset + expect(Dir.glob(File.join(tmpdir, '*.json'))).to be_empty + end + + it 'preserves non-json files' do + cli.reset + expect(File.exist?(File.join(tmpdir, 'keep.yaml'))).to be true + end + + it 'reports the count removed' do + expect(out).to receive(:success).with(a_string_matching(/removed 2 json file/i)) + cli.reset + end + end + + context 'with --json and --force' do + before do + allow(cli).to receive(:options).and_return(json: true, no_color: true, force: true, config_dir: tmpdir) + end + + it 'outputs json with removed files' do + expect(out).to receive(:json).with(hash_including(:removed, :directory)) + cli.reset + end + end + + context 'without --force (interactive confirmation)' do + before do + allow(cli).to receive(:options).and_return(json: false, no_color: true, force: false, config_dir: tmpdir) + end + + it 'removes files when user confirms with y' do + allow($stdin).to receive(:gets).and_return("y\n") + cli.reset + expect(Dir.glob(File.join(tmpdir, '*.json'))).to be_empty + end + + it 'removes files when user confirms with yes' do + allow($stdin).to receive(:gets).and_return("yes\n") + cli.reset + expect(Dir.glob(File.join(tmpdir, '*.json'))).to be_empty + end + + it 'aborts when user declines' do + allow($stdin).to receive(:gets).and_return("n\n") + cli.reset + expect(Dir.glob(File.join(tmpdir, '*.json')).size).to eq(2) + end + + it 'aborts on empty input' do + allow($stdin).to receive(:gets).and_return("\n") + cli.reset + expect(Dir.glob(File.join(tmpdir, '*.json')).size).to eq(2) + end + + it 'prints abort message when declined' do + allow($stdin).to receive(:gets).and_return("n\n") + expect(out).to receive(:warn).with('Aborted.') + cli.reset + end + end + end + + describe 'when no files exist' do + before do + FileUtils.mkdir_p(tmpdir) + allow(cli).to receive(:options).and_return(json: false, no_color: true, force: true, config_dir: tmpdir) + end + + it 'warns that no files were found' do + expect(out).to receive(:warn).with(a_string_including('No JSON files found')) + cli.reset + end + end + + describe 'uses SETTINGS_DIR by default' do + before do + allow(cli).to receive(:options).and_return(json: false, no_color: true, force: true, config_dir: nil) + allow(Dir).to receive(:glob).and_return([]) + end + + it 'falls back to ConfigImport::SETTINGS_DIR' do + expect(Dir).to receive(:glob).with(File.join(Legion::CLI::ConfigImport::SETTINGS_DIR, '*.json')) + cli.reset + end + end +end diff --git a/spec/cli/dashboard/renderer_spec.rb b/spec/cli/dashboard/renderer_spec.rb new file mode 100644 index 00000000..83e89eff --- /dev/null +++ b/spec/cli/dashboard/renderer_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/dashboard/renderer' + +RSpec.describe Legion::CLI::Dashboard::Renderer do + subject(:renderer) { described_class.new(width: 80) } + + describe '#render' do + it 'includes org chart section when departments are present' do + data = { + workers: [], + events: [], + health: {}, + departments: [ + { + name: 'lex-audit', + roles: [ + { name: 'audit.write', workers: [{ name: 'audit-bot', status: 'active' }] } + ] + } + ], + fetched_at: Time.now + } + output = renderer.render(data) + expect(output).to include('Org Chart:') + expect(output).to include('lex-audit') + expect(output).to include('audit.write') + expect(output).to include('audit-bot') + end + + it 'shows no departments message when empty' do + data = { workers: [], events: [], health: {}, departments: [], fetched_at: Time.now } + output = renderer.render(data) + expect(output).to include('(no departments)') + end + + it 'handles missing departments key' do + data = { workers: [], events: [], health: {}, fetched_at: Time.now } + output = renderer.render(data) + expect(output).to include('(no departments)') + end + end +end diff --git a/spec/cli/error_forwarder_spec.rb b/spec/cli/error_forwarder_spec.rb new file mode 100644 index 00000000..68eb2623 --- /dev/null +++ b/spec/cli/error_forwarder_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/error_forwarder' + +RSpec.describe Legion::CLI::ErrorForwarder do + let(:exception) { RuntimeError.new('something broke') } + let(:http_double) { instance_double(Net::HTTP) } + let(:response_double) { instance_double(Net::HTTPResponse) } + + before do + exception.set_backtrace(['cli.rb:42:in `start\'', 'exe/legion:10:in `
\'']) + allow(Net::HTTP).to receive(:new).and_return(http_double) + allow(http_double).to receive(:open_timeout=) + allow(http_double).to receive(:read_timeout=) + allow(http_double).to receive(:request).and_return(response_double) + end + + describe '.forward_error' do + it 'sends a POST request to /api/logs' do + expect(http_double).to receive(:request) do |req| + expect(req).to be_a(Net::HTTP::Post) + expect(req.path).to eq('/api/logs') + response_double + end + described_class.forward_error(exception) + end + + it 'includes level "error" in the payload' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:level]).to eq('error') + response_double + end + described_class.forward_error(exception) + end + + it 'includes the exception message in the payload' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:message]).to eq('something broke') + response_double + end + described_class.forward_error(exception) + end + + it 'includes the exception class name in the payload' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:exception_class]).to eq('RuntimeError') + response_double + end + described_class.forward_error(exception) + end + + it 'includes the backtrace in the payload' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:backtrace]).to be_an(Array) + expect(body[:backtrace].first).to include('cli.rb') + response_double + end + described_class.forward_error(exception) + end + + it 'includes the command when provided' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:command]).to eq('legion chat prompt hello') + response_double + end + described_class.forward_error(exception, command: 'legion chat prompt hello') + end + + it 'sets component_type to "cli"' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:component_type]).to eq('cli') + response_double + end + described_class.forward_error(exception) + end + end + + describe '.forward_warning' do + it 'sends a POST request to /api/logs' do + expect(http_double).to receive(:request) do |req| + expect(req.path).to eq('/api/logs') + response_double + end + described_class.forward_warning('suspicious activity') + end + + it 'includes level "warn" in the payload' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:level]).to eq('warn') + response_double + end + described_class.forward_warning('suspicious activity') + end + + it 'includes the message in the payload' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:message]).to eq('suspicious activity') + response_double + end + described_class.forward_warning('suspicious activity') + end + + it 'includes the command when provided' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:command]).to eq('legion check') + response_double + end + described_class.forward_warning('suspicious activity', command: 'legion check') + end + + it 'does not include exception_class' do + expect(http_double).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body).not_to have_key(:exception_class) + response_double + end + described_class.forward_warning('suspicious activity') + end + end + + describe 'error resilience' do + it 'silently swallows Errno::ECONNREFUSED (daemon not running)' do + allow(http_double).to receive(:request).and_raise(Errno::ECONNREFUSED) + expect { described_class.forward_error(exception) }.not_to raise_error + end + + it 'silently swallows Net::OpenTimeout' do + allow(http_double).to receive(:request).and_raise(Net::OpenTimeout) + expect { described_class.forward_error(exception) }.not_to raise_error + end + + it 'silently swallows Net::ReadTimeout' do + allow(http_double).to receive(:request).and_raise(Net::ReadTimeout) + expect { described_class.forward_error(exception) }.not_to raise_error + end + + it 'silently swallows SocketError' do + allow(http_double).to receive(:request).and_raise(SocketError) + expect { described_class.forward_warning('msg') }.not_to raise_error + end + + it 'silently swallows arbitrary StandardError' do + allow(http_double).to receive(:request).and_raise(StandardError, 'unexpected') + expect { described_class.forward_error(exception) }.not_to raise_error + end + end +end diff --git a/spec/cli/generate_tool_spec.rb b/spec/cli/generate_tool_spec.rb new file mode 100644 index 00000000..e6a4daf9 --- /dev/null +++ b/spec/cli/generate_tool_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'legion/cli' + +RSpec.describe 'legion generate tool' do + let(:generator) { Legion::CLI::Generate.new } + let(:tmpparent) { Dir.mktmpdir } + let(:tmpdir) { File.join(tmpparent, 'lex-redis').tap { |d| FileUtils.mkdir_p(d) } } + + before { Dir.chdir(tmpdir) } + + after do + Dir.chdir(File.expand_path('../../..', __dir__)) + FileUtils.rm_rf(tmpparent) + end + + it 'creates the tool file' do + generator.tool('get_key') + path = File.join(tmpdir, 'lib/legion/extensions/redis/tools/get_key.rb') + expect(File.exist?(path)).to be true + end + + it 'creates the spec file' do + generator.tool('get_key') + path = File.join(tmpdir, 'spec/tools/get_key_spec.rb') + expect(File.exist?(path)).to be true + end + + it 'generates valid Ruby in the tool file' do + generator.tool('get_key') + path = File.join(tmpdir, 'lib/legion/extensions/redis/tools/get_key.rb') + content = File.read(path) + expect(content).to include('class GetKey < Legion::Tools::Base') + expect(content).to include('permission_tier :write') + expect(content).to include('def self.call') + expect(content).to include('Legion::Extensions::Redis::Client') + end + + it 'generates valid Ruby in the spec file' do + generator.tool('get_key') + path = File.join(tmpdir, 'spec/tools/get_key_spec.rb') + content = File.read(path) + expect(content).to include('RSpec.describe Legion::Extensions::Redis::Tools::GetKey') + expect(content).to include('be_a(String)') + end +end diff --git a/spec/events_spec.rb b/spec/events_spec.rb new file mode 100644 index 00000000..3a1b67f9 --- /dev/null +++ b/spec/events_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/events' + +RSpec.describe Legion::Events do + before { described_class.clear } + after { described_class.clear } + + describe '.on' do + it 'registers a listener and returns the block' do + block = described_class.on('test.event') { |_e| nil } + expect(block).to be_a(Proc) + end + + it 'registers multiple listeners for same event' do + described_class.on('test.event') { |_e| nil } + described_class.on('test.event') { |_e| nil } + expect(described_class.listener_count('test.event')).to eq(2) + end + end + + describe '.emit' do + it 'calls registered listeners with event hash' do + received = nil + described_class.on('test.event') { |e| received = e } + described_class.emit('test.event', key: 'value') + expect(received).to be_a(Hash) + expect(received[:key]).to eq('value') + end + + it 'includes event name and timestamp in event hash' do + received = nil + described_class.on('test.event') { |e| received = e } + described_class.emit('test.event') + expect(received[:event]).to eq('test.event') + expect(received[:timestamp]).to be_a(Time) + end + + it 'fires wildcard listeners' do + received = nil + described_class.on('*') { |e| received = e } + described_class.emit('any.event', data: 42) + expect(received[:event]).to eq('any.event') + expect(received[:data]).to eq(42) + end + + it 'catches listener errors without propagating' do + described_class.on('error.event') { |_e| raise 'boom' } + expect { described_class.emit('error.event') }.not_to raise_error + end + + it 'returns the event hash' do + result = described_class.emit('test.event', key: 'val') + expect(result).to be_a(Hash) + expect(result[:event]).to eq('test.event') + end + end + + describe '.off' do + it 'removes all listeners for an event' do + described_class.on('test.event') { |_e| nil } + described_class.on('test.event') { |_e| nil } + described_class.off('test.event') + expect(described_class.listener_count('test.event')).to eq(0) + end + + it 'removes a specific listener' do + block = described_class.on('test.event') { |_e| nil } + described_class.on('test.event') { |_e| nil } + described_class.off('test.event', block) + expect(described_class.listener_count('test.event')).to eq(1) + end + end + + describe '.once' do + it 'fires listener only once' do + count = 0 + described_class.once('once.event') { |_e| count += 1 } + described_class.emit('once.event') + described_class.emit('once.event') + expect(count).to eq(1) + end + + it 'auto-removes the listener after firing' do + described_class.once('once.event') { |_e| nil } + described_class.emit('once.event') + expect(described_class.listener_count('once.event')).to eq(0) + end + end + + describe '.clear' do + it 'removes all listeners' do + described_class.on('a') { |_e| nil } + described_class.on('b') { |_e| nil } + described_class.clear + expect(described_class.listener_count).to eq(0) + end + end + + describe '.listener_count' do + it 'returns count for a specific event' do + described_class.on('test') { |_e| nil } + described_class.on('test') { |_e| nil } + expect(described_class.listener_count('test')).to eq(2) + end + + it 'returns total count across all events' do + described_class.on('a') { |_e| nil } + described_class.on('b') { |_e| nil } + described_class.on('b') { |_e| nil } + expect(described_class.listener_count).to eq(3) + end + + it 'returns 0 for events with no listeners' do + expect(described_class.listener_count('nonexistent')).to eq(0) + end + end +end diff --git a/spec/extensions/actors/base_spec.rb b/spec/extensions/actors/base_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/actors/base_spec.rb +++ b/spec/extensions/actors/base_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/actors/every_spec.rb b/spec/extensions/actors/every_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/actors/every_spec.rb +++ b/spec/extensions/actors/every_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/actors/loop_spec.rb b/spec/extensions/actors/loop_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/actors/loop_spec.rb +++ b/spec/extensions/actors/loop_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/actors/once_spec.rb b/spec/extensions/actors/once_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/actors/once_spec.rb +++ b/spec/extensions/actors/once_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/actors/poll_spec.rb b/spec/extensions/actors/poll_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/actors/poll_spec.rb +++ b/spec/extensions/actors/poll_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/actors/subscription_spec.rb b/spec/extensions/actors/subscription_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/actors/subscription_spec.rb +++ b/spec/extensions/actors/subscription_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/builders/absorbers_spec.rb b/spec/extensions/builders/absorbers_spec.rb new file mode 100644 index 00000000..68194386 --- /dev/null +++ b/spec/extensions/builders/absorbers_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/builders/absorbers' + +RSpec.describe Legion::Extensions::Builder::Absorbers do + let(:dummy_builder) do + Class.new do + include Legion::Extensions::Builder::Absorbers + + def lex_name + 'test_lex' + end + + def lex_class + 'Lex::TestLex' + end + + def find_files(_dir) + [] + end + + def require_files(_files); end + end.new + end + + let(:absorber_class) do + Class.new(Legion::Extensions::Absorbers::Base) do + def self.name + 'Lex::TestLex::Absorbers::WebPage' + end + + def self.patterns + [{ type: :url, value: 'example.com/*', priority: 100 }] + end + + def self.description + 'Absorbs web pages' + end + + def absorb(url: nil, **_kwargs) + { url: url } + end + end + end + + describe '#build_absorbers' do + context 'when Legion::API is not defined' do + before do + allow(dummy_builder).to receive(:find_files).with('absorbers').and_return(['/fake/web_page.rb']) + allow(dummy_builder).to receive(:require_files) + allow(Kernel).to receive(:const_defined?).and_call_original + allow(Kernel).to receive(:const_defined?).with('Lex::TestLex::Absorbers::WebPage').and_return(true) + allow(Kernel).to receive(:const_get).with('Lex::TestLex::Absorbers::WebPage').and_return(absorber_class) + hide_const('Legion::API') if defined?(Legion::API) + end + + it 'registers the absorber with PatternMatcher without raising' do + expect(Legion::Extensions::Absorbers::PatternMatcher).to receive(:register).with(absorber_class) + expect { dummy_builder.build_absorbers }.not_to raise_error + end + + it 'populates @absorbers hash' do + allow(Legion::Extensions::Absorbers::PatternMatcher).to receive(:register) + dummy_builder.build_absorbers + expect(dummy_builder.absorbers).to have_key(:web_page) + end + end + + context 'when Legion::API is available with a router' do + let(:mock_router) { instance_double('Legion::API::Router') } + + before do + allow(dummy_builder).to receive(:find_files).with('absorbers').and_return(['/fake/web_page.rb']) + allow(dummy_builder).to receive(:require_files) + allow(Kernel).to receive(:const_defined?).and_call_original + allow(Kernel).to receive(:const_defined?).with('Lex::TestLex::Absorbers::WebPage').and_return(true) + allow(Kernel).to receive(:const_get).with('Lex::TestLex::Absorbers::WebPage').and_return(absorber_class) + allow(Legion::Extensions::Absorbers::PatternMatcher).to receive(:register) + + stub_const('Legion::API', Module.new) + allow(Legion::API).to receive(:respond_to?).with(:router).and_return(true) + allow(Legion::API).to receive(:router).and_return(mock_router) + allow(mock_router).to receive(:register_extension_route) + end + + it 'calls register_extension_route with component_type absorbers' do + expect(mock_router).to receive(:register_extension_route).with( + hash_including(component_type: 'absorbers') + ).at_least(:once) + dummy_builder.build_absorbers + end + + it 'passes the correct lex_name' do + expect(mock_router).to receive(:register_extension_route).with( + hash_including(lex_name: 'test_lex') + ).at_least(:once) + dummy_builder.build_absorbers + end + + it 'passes the absorber class as runner_class' do + expect(mock_router).to receive(:register_extension_route).with( + hash_including(runner_class: absorber_class) + ).at_least(:once) + dummy_builder.build_absorbers + end + + it 'passes the snake_name as component_name' do + expect(mock_router).to receive(:register_extension_route).with( + hash_including(component_name: 'web_page') + ).at_least(:once) + dummy_builder.build_absorbers + end + + it 'passes the default amqp_prefix when amqp_prefix is not defined' do + expect(mock_router).to receive(:register_extension_route).with( + hash_including(amqp_prefix: 'lex.test_lex') + ).at_least(:once) + dummy_builder.build_absorbers + end + end + + context 'when absorber class has no public instance methods' do + let(:bare_absorber_class) do + Class.new(Legion::Extensions::Absorbers::Base) do + def self.name + 'Lex::TestLex::Absorbers::Bare' + end + + def self.patterns + [] + end + + def self.description + nil + end + end + end + + let(:mock_router) { instance_double('Legion::API::Router') } + + before do + allow(dummy_builder).to receive(:find_files).with('absorbers').and_return(['/fake/bare.rb']) + allow(dummy_builder).to receive(:require_files) + allow(Kernel).to receive(:const_defined?).and_call_original + allow(Kernel).to receive(:const_defined?).with('Lex::TestLex::Absorbers::Bare').and_return(true) + allow(Kernel).to receive(:const_get).with('Lex::TestLex::Absorbers::Bare').and_return(bare_absorber_class) + allow(Legion::Extensions::Absorbers::PatternMatcher).to receive(:register) + + stub_const('Legion::API', Module.new) + allow(Legion::API).to receive(:respond_to?).with(:router).and_return(true) + allow(Legion::API).to receive(:router).and_return(mock_router) + allow(mock_router).to receive(:register_extension_route) + end + + it 'falls back to :absorb as the method_name' do + expect(mock_router).to receive(:register_extension_route).with( + hash_including(method_name: 'absorb') + ) + dummy_builder.build_absorbers + end + end + + context 'when absorber files list is empty' do + before do + allow(dummy_builder).to receive(:find_files).with('absorbers').and_return([]) + end + + it 'returns early without populating @absorbers' do + dummy_builder.build_absorbers + expect(dummy_builder.absorbers).to be_empty + end + end + end + + describe '#absorbers' do + it 'returns an empty hash before build_absorbers is called' do + expect(dummy_builder.absorbers).to eq({}) + end + end +end diff --git a/spec/extensions/builders/actors_spec.rb b/spec/extensions/builders/actors_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/builders/actors_spec.rb +++ b/spec/extensions/builders/actors_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/builders/base.rb b/spec/extensions/builders/base.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/builders/base.rb +++ b/spec/extensions/builders/base.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/builders/helpers.rb b/spec/extensions/builders/helpers.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/builders/helpers.rb +++ b/spec/extensions/builders/helpers.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/builders/routes_spec.rb b/spec/extensions/builders/routes_spec.rb new file mode 100644 index 00000000..34654e70 --- /dev/null +++ b/spec/extensions/builders/routes_spec.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/builders/routes' + +RSpec.describe Legion::Extensions::Builder::Routes do + let(:dummy_builder) do + Class.new do + include Legion::Extensions::Helpers::Logger + include Legion::Extensions::Builder::Routes + + def extension_name + 'test_lex' + end + + def lex_name + 'test_lex' + end + + def lex_class + 'Lex::TestLex' + end + end.new + end + + let(:simple_runner_module) do + Module.new do + def process_item; end + + def fetch_data; end + end + end + + let(:runner_with_skip) do + mod = Module.new do + def process_item; end + + def internal_helper; end + + def self.skip_routes + %i[internal_helper] + end + end + mod + end + + let(:empty_runner_module) do + Module.new + end + + def setup_runners(builder, runners_hash) + builder.instance_variable_set(:@runners, runners_hash) + end + + before do + allow(Legion::Settings).to receive(:dig).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:api, :lex_routes).and_return(nil) + end + + describe '#build_routes' do + context 'with a simple runner module' do + before do + setup_runners(dummy_builder, { + runner1: { + runner_name: 'runner1', + runner_class: 'TestLex::Runners::Runner1', + runner_module: simple_runner_module + } + }) + dummy_builder.build_routes + end + + it 'populates @routes' do + expect(dummy_builder.routes).not_to be_empty + end + + it 'creates route entries for each public instance method' do + methods = dummy_builder.routes.values.map { |r| r[:function] } + expect(methods).to include(:process_item) + expect(methods).to include(:fetch_data) + end + + it 'includes required keys in each route entry' do + route = dummy_builder.routes.values.first + expect(route).to have_key(:lex_name) + expect(route).to have_key(:runner_name) + expect(route).to have_key(:function) + expect(route).to have_key(:runner_class) + expect(route).to have_key(:route_path) + end + + it 'sets lex_name to the extension name' do + route = dummy_builder.routes.values.first + expect(route[:lex_name]).to eq('test_lex') + end + + it 'sets runner_name from the runner entry' do + route = dummy_builder.routes.values.first + expect(route[:runner_name]).to eq('runner1') + end + + it 'sets runner_class from the runner entry' do + route = dummy_builder.routes.values.first + expect(route[:runner_class]).to eq('TestLex::Runners::Runner1') + end + + it 'builds route_path as lex_name/runner_name/function' do + route = dummy_builder.routes.values.find { |r| r[:function] == :process_item } + expect(route[:route_path]).to eq('test_lex/runner1/process_item') + end + end + + context 'with no runners' do + before do + setup_runners(dummy_builder, {}) + dummy_builder.build_routes + end + + it 'results in empty routes' do + expect(dummy_builder.routes).to be_empty + end + end + + context 'with an empty runner module' do + before do + setup_runners(dummy_builder, { + empty_runner: { + runner_name: 'empty_runner', + runner_class: 'TestLex::Runners::EmptyRunner', + runner_module: empty_runner_module + } + }) + dummy_builder.build_routes + end + + it 'produces no route entries' do + expect(dummy_builder.routes).to be_empty + end + end + + context 'with skip_routes DSL on runner module' do + before do + setup_runners(dummy_builder, { + runner1: { + runner_name: 'runner1', + runner_class: 'TestLex::Runners::Runner1', + runner_module: runner_with_skip + } + }) + dummy_builder.build_routes + end + + it 'excludes methods listed in skip_routes' do + methods = dummy_builder.routes.values.map { |r| r[:function] } + expect(methods).not_to include(:internal_helper) + end + + it 'includes methods not in skip_routes' do + methods = dummy_builder.routes.values.map { |r| r[:function] } + expect(methods).to include(:process_item) + end + end + + context 'when globally disabled via settings' do + before do + allow(Legion::Settings).to receive(:dig).with(:api, :lex_routes).and_return({ enabled: false }) + setup_runners(dummy_builder, { + runner1: { + runner_name: 'runner1', + runner_class: 'TestLex::Runners::Runner1', + runner_module: simple_runner_module + } + }) + dummy_builder.build_routes + end + + it 'returns empty routes' do + expect(dummy_builder.routes).to be_empty + end + end + + context 'when extension is disabled in settings' do + before do + allow(Legion::Settings).to receive(:dig).with(:api, :lex_routes).and_return({ + enabled: true, + extensions: { test_lex: { enabled: false } } + }) + setup_runners(dummy_builder, { + runner1: { + runner_name: 'runner1', + runner_class: 'TestLex::Runners::Runner1', + runner_module: simple_runner_module + } + }) + dummy_builder.build_routes + end + + it 'skips the disabled extension' do + expect(dummy_builder.routes).to be_empty + end + end + + context 'when a runner is in exclude_runners settings' do + before do + allow(Legion::Settings).to receive(:dig).with(:api, :lex_routes).and_return({ + enabled: true, + extensions: { test_lex: { exclude_runners: ['runner1'] } } + }) + setup_runners(dummy_builder, { + runner1: { + runner_name: 'runner1', + runner_class: 'TestLex::Runners::Runner1', + runner_module: simple_runner_module + } + }) + dummy_builder.build_routes + end + + it 'skips excluded runners' do + expect(dummy_builder.routes).to be_empty + end + end + + context 'when a function is in exclude_functions settings' do + before do + allow(Legion::Settings).to receive(:dig).with(:api, :lex_routes).and_return({ + enabled: true, + extensions: { test_lex: { exclude_functions: ['fetch_data'] } } + }) + setup_runners(dummy_builder, { + runner1: { + runner_name: 'runner1', + runner_class: 'TestLex::Runners::Runner1', + runner_module: simple_runner_module + } + }) + dummy_builder.build_routes + end + + it 'excludes the specified function' do + methods = dummy_builder.routes.values.map { |r| r[:function] } + expect(methods).not_to include(:fetch_data) + end + + it 'keeps other functions from the same runner' do + methods = dummy_builder.routes.values.map { |r| r[:function] } + expect(methods).to include(:process_item) + end + end + + context 'with multiple runners' do + let(:second_runner_module) do + Module.new do + def execute; end + end + end + + before do + setup_runners(dummy_builder, { + runner1: { + runner_name: 'runner1', + runner_class: 'TestLex::Runners::Runner1', + runner_module: simple_runner_module + }, + runner2: { + runner_name: 'runner2', + runner_class: 'TestLex::Runners::Runner2', + runner_module: second_runner_module + } + }) + dummy_builder.build_routes + end + + it 'registers routes from all runners' do + methods = dummy_builder.routes.values.map { |r| r[:function] } + expect(methods).to include(:process_item) + expect(methods).to include(:fetch_data) + expect(methods).to include(:execute) + end + + it 'uses unique route_path keys' do + paths = dummy_builder.routes.keys + expect(paths.uniq.length).to eq(paths.length) + end + end + + context 'only includes instance_methods(false)' do + let(:derived_runner_module) do + parent = Module.new do + def inherited_method; end + end + mod = Module.new do + include parent + + def own_method; end + end + mod + end + + before do + setup_runners(dummy_builder, { + runner1: { + runner_name: 'runner1', + runner_class: 'TestLex::Runners::Runner1', + runner_module: derived_runner_module + } + }) + dummy_builder.build_routes + end + + it 'does not register inherited methods' do + methods = dummy_builder.routes.values.map { |r| r[:function] } + expect(methods).not_to include(:inherited_method) + end + + it 'does register directly defined methods' do + methods = dummy_builder.routes.values.map { |r| r[:function] } + expect(methods).to include(:own_method) + end + end + end + + describe '#routes attr_reader' do + it 'returns nil before build_routes is called' do + expect(dummy_builder.routes).to be_nil + end + end +end diff --git a/spec/extensions/builders/runners.rb b/spec/extensions/builders/runners.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/builders/runners.rb +++ b/spec/extensions/builders/runners.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/core_spec.rb b/spec/extensions/core_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/core_spec.rb +++ b/spec/extensions/core_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/helpers/base_spec.rb b/spec/extensions/helpers/base_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/helpers/base_spec.rb +++ b/spec/extensions/helpers/base_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/helpers/core_spec.rb b/spec/extensions/helpers/core_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/helpers/core_spec.rb +++ b/spec/extensions/helpers/core_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/helpers/lex_spec.rb b/spec/extensions/helpers/lex_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/helpers/lex_spec.rb +++ b/spec/extensions/helpers/lex_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/helpers/logger_spec.rb b/spec/extensions/helpers/logger_spec.rb index 06b29dd6..2902e847 100644 --- a/spec/extensions/helpers/logger_spec.rb +++ b/spec/extensions/helpers/logger_spec.rb @@ -1 +1,134 @@ -# spec +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Helpers::Logger do + # A test class that includes the Logger helper and has segments (nested extension) + let(:segmented_class) do + klass = Class.new do + include Legion::Extensions::Helpers::Logger + + def segments + %w[agentic cognitive anchor] + end + + # Satisfy handle_exception dependency + def lex_filename + 'agentic_cognitive_anchor' + end + end + klass + end + + # A test class that includes Logger but lacks segments (legacy flat extension) + let(:legacy_class) do + klass = Class.new do + include Legion::Extensions::Helpers::Logger + + def lex_filename + 'microsoft_teams' + end + end + klass + end + + describe '#log' do + context 'when the object responds to :segments' do + subject { segmented_class.new } + + it 'returns a logger instance' do + expect(subject.log).to respond_to(:info, :warn, :error, :debug) + end + + it 'memoizes the logger' do + expect(subject.log).to be(subject.log) + end + end + + context 'when the object has Base included (derives segments from class name)' do + subject { legacy_class.new } + + it 'returns a logger instance' do + expect(subject.log).to respond_to(:info, :warn, :error, :debug) + end + end + end + + describe '#handle_runner_exception' do + let(:test_class) do + Class.new do + include Legion::Extensions::Helpers::Logger + + def segments + %w[eval] + end + + def calling_class_array + %w[Legion Extensions Eval Runners CodeReview] + end + + def to_s + 'Legion::Extensions::Eval::Runners::CodeReview' + end + end + end + + let(:instance) { test_class.new } + let(:error) do + raise TypeError, 'wrong argument type' + rescue TypeError => e + e + end + + before do + stub_const('Legion::Exception::HandledTask', Class.new(StandardError)) unless defined?(Legion::Exception::HandledTask) + allow(instance).to receive(:handle_exception) + end + + it 'delegates to handle_exception from the gem' do + expect(instance).to receive(:handle_exception).with(error, task_id: nil) + begin + instance.handle_runner_exception(error) + rescue Legion::Exception::HandledTask + nil + end + end + + it 'raises HandledTask' do + expect { instance.handle_runner_exception(error) }.to raise_error(Legion::Exception::HandledTask) + end + + it 'passes task_id through to handle_exception' do + expect(instance).to receive(:handle_exception).with(error, task_id: 123) + msg_double = instance_double('Legion::Transport::Messages::TaskLog', publish: true) + allow(Legion::Transport::Messages::TaskLog).to receive(:new).and_return(msg_double) + begin + instance.handle_runner_exception(error, task_id: 123) + rescue Legion::Exception::HandledTask + nil + end + end + + it 'publishes a TaskLog when task_id is given' do + msg_double = instance_double('Legion::Transport::Messages::TaskLog', publish: true) + expect(Legion::Transport::Messages::TaskLog).to receive(:new).with( + hash_including(task_id: 99, runner_class: 'Legion::Extensions::Eval::Runners::CodeReview') + ).and_return(msg_double) + expect(msg_double).to receive(:publish) + begin + instance.handle_runner_exception(error, task_id: 99) + rescue Legion::Exception::HandledTask + nil + end + end + + it 'does not publish a TaskLog when task_id is nil' do + expect(Legion::Transport::Messages::TaskLog).not_to receive(:new) + begin + instance.handle_runner_exception(error) + rescue Legion::Exception::HandledTask + nil + end + end + end +end diff --git a/spec/extensions/helpers/task_spec.rb b/spec/extensions/helpers/task_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/helpers/task_spec.rb +++ b/spec/extensions/helpers/task_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/helpers/transport_spec.rb b/spec/extensions/helpers/transport_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/helpers/transport_spec.rb +++ b/spec/extensions/helpers/transport_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/extensions/transport_auto_messages_spec.rb b/spec/extensions/transport_auto_messages_spec.rb new file mode 100644 index 00000000..bff00ff4 --- /dev/null +++ b/spec/extensions/transport_auto_messages_spec.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/transport' +require 'legion/extensions/definitions' + +RSpec.describe Legion::Extensions::Transport do + # Minimal dummy builder that satisfies the mixin's dependencies without + # requiring a live RabbitMQ connection. + let(:dummy_builder) do + transport_mod = Module.new + transport_mod.const_set('Messages', Module.new) + + Class.new do + include Legion::Extensions::Transport + + define_method(:transport_class) { transport_mod } + define_method(:amqp_prefix) { 'lex.test_ext' } + + def log + @log ||= Logger.new(nil) + end + end.new + end + + # A runner module with definition_for returning inputs for :process_item + # but no inputs for :internal_helper. + let(:runner_with_definitions) do + mod = Module.new + mod.extend(Legion::Extensions::Definitions) + mod.definition(:process_item, inputs: { payload: { type: :string } }) + # :internal_helper intentionally has no definition (returns nil) + mod.define_method(:process_item) { nil } + mod.define_method(:internal_helper) { nil } + mod + end + + # A runner module with definition_for returning empty inputs + let(:runner_with_empty_inputs) do + mod = Module.new + mod.extend(Legion::Extensions::Definitions) + mod.definition(:no_input_method, inputs: {}) + mod.define_method(:no_input_method) { nil } + mod + end + + # A runner module without definition_for at all + let(:runner_without_definitions) do + Module.new do + def some_method; end + end + end + + def set_runners(builder, runners_hash) + builder.instance_variable_set(:@runners, runners_hash) + end + + describe '#auto_generate_messages' do + context 'when @runners is not set' do + it 'returns without error' do + expect { dummy_builder.auto_generate_messages }.not_to raise_error + end + end + + context 'when @runners is an empty hash' do + before { set_runners(dummy_builder, {}) } + + it 'returns without error' do + expect { dummy_builder.auto_generate_messages }.not_to raise_error + end + + it 'leaves Messages module empty' do + dummy_builder.auto_generate_messages + expect(dummy_builder.transport_class::Messages.constants).to be_empty + end + end + + context 'when a runner method has definition inputs' do + before do + set_runners(dummy_builder, { + test_runner: { + runner_name: 'test_runner', + runner_module: runner_with_definitions + } + }) + dummy_builder.auto_generate_messages + end + + it 'creates a message class for the method with inputs' do + expect(dummy_builder.transport_class::Messages.const_defined?('TestRunnerProcessItem', false)).to be true + end + + it 'creates a class that inherits from Legion::Transport::Message' do + klass = dummy_builder.transport_class::Messages::TestRunnerProcessItem + expect(klass.ancestors).to include(Legion::Transport::Message) + end + + it 'sets the correct routing_key on an instance of the generated class' do + instance = dummy_builder.transport_class::Messages::TestRunnerProcessItem.allocate + expect(instance.routing_key).to eq('lex.test_ext.runners.test_runner.process_item') + end + + it 'sets the correct exchange_name on an instance of the generated class' do + instance = dummy_builder.transport_class::Messages::TestRunnerProcessItem.allocate + expect(instance.exchange_name).to eq('lex.test_ext') + end + end + + context 'when a runner method has no definition' do + before do + set_runners(dummy_builder, { + test_runner: { + runner_name: 'test_runner', + runner_module: runner_with_definitions + } + }) + dummy_builder.auto_generate_messages + end + + it 'does not create a message class for the method without definition inputs' do + expect(dummy_builder.transport_class::Messages.const_defined?('TestRunnerInternalHelper', false)).to be false + end + end + + context 'when a runner method has an empty inputs hash' do + before do + set_runners(dummy_builder, { + no_input_runner: { + runner_name: 'no_input_runner', + runner_module: runner_with_empty_inputs + } + }) + dummy_builder.auto_generate_messages + end + + it 'does not create a message class for a method with empty inputs' do + expect(dummy_builder.transport_class::Messages.const_defined?('NoInputRunnerNoInputMethod', false)).to be false + end + end + + context 'when runner_module does not respond to definition_for' do + before do + set_runners(dummy_builder, { + plain_runner: { + runner_name: 'plain_runner', + runner_module: runner_without_definitions + } + }) + dummy_builder.auto_generate_messages + end + + it 'skips the runner without error' do + expect { dummy_builder.auto_generate_messages }.not_to raise_error + end + + it 'creates no message classes' do + expect(dummy_builder.transport_class::Messages.constants).to be_empty + end + end + + context 'when runner_module is nil' do + before do + set_runners(dummy_builder, { + nil_runner: { + runner_name: 'nil_runner', + runner_module: nil + } + }) + end + + it 'skips the nil runner without error' do + expect { dummy_builder.auto_generate_messages }.not_to raise_error + end + end + + context 'when an explicit message class already exists' do + let(:explicit_class) { Class.new(Legion::Transport::Message) } + + before do + dummy_builder.transport_class::Messages.const_set('TestRunnerProcessItem', explicit_class) + set_runners(dummy_builder, { + test_runner: { + runner_name: 'test_runner', + runner_module: runner_with_definitions + } + }) + dummy_builder.auto_generate_messages + end + + it 'does not overwrite the explicit message class' do + expect(dummy_builder.transport_class::Messages::TestRunnerProcessItem).to be(explicit_class) + end + end + + context 'with a multi-word runner and method name' do + let(:multi_word_runner) do + mod = Module.new + mod.extend(Legion::Extensions::Definitions) + mod.definition(:send_alert_email, inputs: { to: { type: :string } }) + mod.define_method(:send_alert_email) { nil } + mod + end + + before do + set_runners(dummy_builder, { + alert_notifier: { + runner_name: 'alert_notifier', + runner_module: multi_word_runner + } + }) + dummy_builder.auto_generate_messages + end + + it 'CamelCases both the runner name and method name for the class constant' do + expect(dummy_builder.transport_class::Messages.const_defined?('AlertNotifierSendAlertEmail', false)).to be true + end + end + end +end diff --git a/spec/extensions/transport_spec.rb b/spec/extensions/transport_spec.rb index 06b29dd6..9c4e138a 100644 --- a/spec/extensions/transport_spec.rb +++ b/spec/extensions/transport_spec.rb @@ -1 +1,3 @@ +# frozen_string_literal: true + # spec diff --git a/spec/helpers/context_spec.rb b/spec/helpers/context_spec.rb new file mode 100644 index 00000000..07a25dc7 --- /dev/null +++ b/spec/helpers/context_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/helpers/context' + +RSpec.describe Legion::Helpers::Context do + let(:tmpdir) { Dir.mktmpdir('legion-context-test') } + + before do + allow(described_class).to receive(:context_dir).and_return(tmpdir) + end + + after do + FileUtils.rm_rf(tmpdir) + end + + describe '.write' do + it 'writes content to agent subdirectory' do + result = described_class.write(agent_id: 'agent-a', filename: 'plan.md', content: '# My Plan') + expect(result[:success]).to be true + expect(File.exist?(File.join(tmpdir, 'agent-a', 'plan.md'))).to be true + expect(File.read(File.join(tmpdir, 'agent-a', 'plan.md'))).to eq('# My Plan') + end + + it 'creates nested directories' do + result = described_class.write(agent_id: 'agent-b', filename: 'sub/deep/file.txt', content: 'hello') + expect(result[:success]).to be true + expect(File.exist?(File.join(tmpdir, 'agent-b', 'sub', 'deep', 'file.txt'))).to be true + end + end + + describe '.read' do + it 'reads content from agent subdirectory' do + described_class.write(agent_id: 'agent-a', filename: 'notes.json', content: '{"key":"val"}') + result = described_class.read(agent_id: 'agent-a', filename: 'notes.json') + expect(result[:success]).to be true + expect(result[:content]).to eq('{"key":"val"}') + end + + it 'returns not_found for missing files' do + result = described_class.read(agent_id: 'agent-x', filename: 'missing.txt') + expect(result[:success]).to be false + expect(result[:reason]).to eq(:not_found) + end + end + + describe '.list' do + it 'lists all files across agents' do + described_class.write(agent_id: 'a', filename: 'f1.txt', content: 'x') + described_class.write(agent_id: 'b', filename: 'f2.txt', content: 'y') + result = described_class.list + expect(result[:success]).to be true + expect(result[:files].size).to eq(2) + end + + it 'lists files for a specific agent' do + described_class.write(agent_id: 'a', filename: 'f1.txt', content: 'x') + described_class.write(agent_id: 'b', filename: 'f2.txt', content: 'y') + result = described_class.list(agent_id: 'a') + expect(result[:files].size).to eq(1) + end + + it 'returns empty list for non-existent directory' do + result = described_class.list(agent_id: 'nonexistent') + expect(result[:files]).to be_empty + end + end + + describe '.cleanup' do + it 'removes files older than max_age' do + described_class.write(agent_id: 'a', filename: 'old.txt', content: 'old') + path = File.join(tmpdir, 'a', 'old.txt') + FileUtils.touch(path, mtime: Time.now - 90_000) + + described_class.write(agent_id: 'a', filename: 'new.txt', content: 'new') + + result = described_class.cleanup(max_age: 86_400) + expect(result[:removed]).to eq(1) + expect(File.exist?(File.join(tmpdir, 'a', 'new.txt'))).to be true + end + end +end diff --git a/spec/ingress_spec.rb b/spec/ingress_spec.rb new file mode 100644 index 00000000..bdf779cd --- /dev/null +++ b/spec/ingress_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/ingress' + +RSpec.describe Legion::Ingress do + describe '.normalize' do + it 'normalizes a hash payload' do + result = described_class.normalize(payload: { key: 'value' }) + expect(result[:key]).to eq('value') + expect(result[:source]).to eq('unknown') + expect(result[:timestamp]).to be_a(Integer) + expect(result[:datetime]).to be_a(String) + end + + it 'normalizes a JSON string payload' do + result = described_class.normalize(payload: '{"key":"value"}') + expect(result[:key]).to eq('value') + end + + it 'normalizes a nil payload' do + result = described_class.normalize(payload: nil) + expect(result).to be_a(Hash) + expect(result[:source]).to eq('unknown') + end + + it 'wraps non-hash non-string payloads' do + result = described_class.normalize(payload: 42) + expect(result[:value]).to eq(42) + end + + it 'sets custom source' do + result = described_class.normalize(payload: {}, source: 'http') + expect(result[:source]).to eq('http') + end + + it 'sets runner_class from parameter' do + result = described_class.normalize(payload: {}, runner_class: 'MyRunner') + expect(result[:runner_class]).to eq('MyRunner') + end + + it 'sets function from parameter' do + result = described_class.normalize(payload: {}, function: :fetch) + expect(result[:function]).to eq(:fetch) + end + + it 'keeps runner_class from payload when not given as param' do + result = described_class.normalize(payload: { runner_class: 'FromPayload' }) + expect(result[:runner_class]).to eq('FromPayload') + end + + it 'overrides payload runner_class with param' do + result = described_class.normalize(payload: { runner_class: 'FromPayload' }, runner_class: 'FromParam') + expect(result[:runner_class]).to eq('FromParam') + end + + it 'merges extra opts into the result' do + result = described_class.normalize(payload: {}, extra_key: 'extra_val') + expect(result[:extra_key]).to eq('extra_val') + end + + it 'symbolizes string keys from hash payload' do + result = described_class.normalize(payload: { 'string_key' => 'val' }) + expect(result[:string_key]).to eq('val') + end + + it 'preserves existing timestamp from payload' do + result = described_class.normalize(payload: { timestamp: 1000 }) + expect(result[:timestamp]).to eq(1000) + end + end + + describe '.run' do + it 'raises when runner_class is missing' do + expect do + described_class.run(payload: {}, function: :test) + end.to raise_error(RuntimeError, 'runner_class is required') + end + + it 'raises when function is missing' do + expect do + described_class.run(payload: {}, runner_class: 'TestRunner') + end.to raise_error(RuntimeError, 'function is required') + end + end +end diff --git a/spec/integration/absorber_pipeline_spec.rb b/spec/integration/absorber_pipeline_spec.rb new file mode 100644 index 00000000..a307ab0e --- /dev/null +++ b/spec/integration/absorber_pipeline_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/absorbers/dispatch' +require 'legion/extensions/absorbers/transport' +require 'legion/extensions/absorbers/base' + +RSpec.describe 'Absorber pipeline end-to-end', :integration do + # Run without live AMQP/Redis (lite-mode semantics: transport_available? returns false) + around do |example| + orig = ENV.fetch('LEGION_MODE', nil) + ENV['LEGION_MODE'] = 'lite' + example.run + ensure + orig ? ENV['LEGION_MODE'] = orig : ENV.delete('LEGION_MODE') + end + + after do + Legion::Extensions::Absorbers::PatternMatcher.reset! + Legion::Extensions::Absorbers::Dispatch.reset_dispatched! + end + + # --------------------------------------------------------------------------- + # Test absorber: matches example.com/absorb/* and calls absorb_raw + # --------------------------------------------------------------------------- + let(:absorber_name) { 'Legion::Extensions::Test::Absorbers::Content' } + + let(:test_absorber_class) do + klass = Class.new(Legion::Extensions::Absorbers::Base) do + pattern :url, 'example.com/absorb/*', priority: 10 + description 'Test absorber for pipeline integration spec' + + def absorb(url: nil, content: nil, metadata: {}, context: {}) # rubocop:disable Lint/UnusedMethodArgument + absorb_raw(content: "Absorbed: #{url}", tags: %w[test integration]) + end + end + klass.define_singleton_method(:name) { 'Legion::Extensions::Test::Absorbers::Content' } + klass + end + + before { Legion::Extensions::Absorbers::PatternMatcher.register(test_absorber_class) } + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + def fake_apollo + calls = [] + mod = Module.new + mod.define_singleton_method(:ingest) do |content:, tags: [], **| + calls << { content: content, tags: tags } + { success: true } + end + mod.define_singleton_method(:started?) { true } + [mod, calls] + end + + # =========================================================================== + # 1. PatternMatcher resolution + # =========================================================================== + describe 'step 1: PatternMatcher resolves URL to absorber class' do + it 'returns the registered absorber for a matching URL' do + resolved = Legion::Extensions::Absorbers::PatternMatcher.resolve('https://example.com/absorb/meeting-123') + expect(resolved).to eq(test_absorber_class) + end + + it 'returns nil for an unregistered URL' do + resolved = Legion::Extensions::Absorbers::PatternMatcher.resolve('https://other.example.org/page') + expect(resolved).to be_nil + end + end + + # =========================================================================== + # 2. Dispatch routing + # =========================================================================== + describe 'step 2: Dispatch routes URL and records the request' do + let(:test_url) { 'https://example.com/absorb/item-42' } + + it 'returns a dispatch record with required fields' do + record = Legion::Extensions::Absorbers::Dispatch.dispatch(test_url) + + expect(record).not_to be_nil + expect(record[:status]).to eq(:dispatched) + expect(record[:absorb_id]).to match(/\Aabsorb:[0-9a-f-]+\z/) + expect(record[:input]).to eq(test_url) + expect(record[:absorber_class]).to eq(absorber_name) + end + + it 'stores the record in the dispatched list' do + Legion::Extensions::Absorbers::Dispatch.dispatch(test_url) + + dispatched = Legion::Extensions::Absorbers::Dispatch.dispatched + expect(dispatched.size).to eq(1) + expect(dispatched.first[:input]).to eq(test_url) + end + + it 'carries context through to the dispatch record' do + record = Legion::Extensions::Absorbers::Dispatch.dispatch(test_url, + context: { conversation_id: 'conv-test-1', + requested_by: 'chat' }) + expect(record[:context][:conversation_id]).to eq('conv-test-1') + expect(record[:context][:requested_by]).to eq('chat') + end + + it 'does not call AMQP transport when not connected (lite mode)' do + expect(Legion::Extensions::Absorbers::Transport).not_to receive(:publish_absorb_request) + Legion::Extensions::Absorbers::Dispatch.dispatch(test_url) + end + + it 'appends the absorb_id to the ancestor_chain in context' do + record = Legion::Extensions::Absorbers::Dispatch.dispatch(test_url) + chain = record[:context][:ancestor_chain] + expect(chain).to include(record[:absorb_id]) + end + end + + # =========================================================================== + # 3. Depth limiting and cycle detection + # =========================================================================== + describe 'step 2: dispatch safety guards' do + it 'returns depth_exceeded when depth >= max_depth' do + result = Legion::Extensions::Absorbers::Dispatch.dispatch( + 'https://example.com/absorb/deep', + context: { depth: 5, max_depth: 5 } + ) + expect(result[:status]).to eq(:depth_exceeded) + end + + it 'returns cycle_detected when URL already in ancestor_chain' do + result = Legion::Extensions::Absorbers::Dispatch.dispatch( + 'https://example.com/absorb/loop', + context: { ancestor_chain: ['absorb:example.com/absorb/loop'] } + ) + expect(result[:status]).to eq(:cycle_detected) + end + end + + # =========================================================================== + # 4. Absorber execution → Apollo ingestion + # =========================================================================== + describe 'step 3: absorber calls absorb_raw → Apollo.ingest receives content' do + let(:test_url) { 'https://example.com/absorb/transcript-99' } + + it 'absorber delivers content to Apollo when available' do + apollo, calls = fake_apollo + stub_const('Legion::Apollo', apollo) + + absorber = test_absorber_class.new + absorber.absorb(url: test_url) + + expect(calls.size).to eq(1) + expect(calls.first[:content]).to include(test_url) + expect(calls.first[:tags]).to include('test', 'integration') + end + + it 'absorber returns failure hash when Apollo is not available' do + # Stub Apollo with a module that fails the apollo_available? check + unavailable = Module.new + unavailable.define_singleton_method(:ingest) { |**| raise 'should not be called' } + # started? returns false → apollo_available? returns false + unavailable.define_singleton_method(:started?) { false } + stub_const('Legion::Apollo', unavailable) + + absorber = test_absorber_class.new + result = absorber.absorb(url: test_url) + + expect(result[:success]).to be false + expect(result[:error]).to eq(:apollo_not_available) + end + end + + # =========================================================================== + # 5. Full pipeline: drop URL → dispatch → absorb → Apollo + # =========================================================================== + describe 'full pipeline' do + let(:test_url) { 'https://example.com/absorb/full-pipeline-test' } + + it 'URL dropped via Dispatch lands as a chunk in Apollo' do + apollo, calls = fake_apollo + stub_const('Legion::Apollo', apollo) + + record = Legion::Extensions::Absorbers::Dispatch.dispatch(test_url) + expect(record[:status]).to eq(:dispatched) + + # Simulate what an actor does: instantiate the absorber and call absorb + absorber = test_absorber_class.new + result = absorber.absorb(url: record[:input], context: record[:context]) + + expect(result[:success]).to be true + expect(calls).not_to be_empty + expect(calls.first[:content]).to include('full-pipeline-test') + expect(calls.first[:tags]).to include('integration') + end + end +end diff --git a/spec/integration/fleet/escalation_spec.rb b/spec/integration/fleet/escalation_spec.rb new file mode 100644 index 00000000..49c40c5d --- /dev/null +++ b/spec/integration/fleet/escalation_spec.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'json' +require 'digest' + +require_relative 'support/fleet_helpers' +require_relative 'support/mock_cache' + +RSpec.describe 'Fleet Escalation Path' do + include Fleet::Test::FleetHelpers + + let(:cache) { Fleet::Test::MockCache.new } + + before do + stub_const('Legion::Cache', cache) + + json_mod = Module.new do + def self.dump(obj) = ::JSON.generate(obj) + def self.load(str) = ::JSON.parse(str, symbolize_names: true) + end + stub_const('Legion::JSON', json_mod) + + logging_mod = Module.new do + def self.info(_msg) = nil + def self.warn(_msg) = nil + def self.debug(_msg) = nil + def self.error(_msg) = nil + end + stub_const('Legion::Logging', logging_mod) + end + + # =========================================================================== + # Max iterations exceeded -> escalation + # =========================================================================== + describe 'max iterations exceeded -> escalation' do + it 'routes to assessor.escalate when attempt reaches threshold' do + work_item = build_implemented_work_item + max_iterations = work_item[:config][:implementation][:max_iterations] + + # Run through max_iterations - 1 feedback loops (attempts 0..3 retry) + (0...(max_iterations - 1)).each do |attempt| + work_item[:pipeline][:attempt] = attempt + work_item[:pipeline][:review_result] = { verdict: 'rejected', score: 0.4 } + work_item[:pipeline][:feedback_history] << "Feedback round #{attempt}" + + # Conditioner: attempt < 4 -> route to incorporate_feedback + expect(attempt).to be < 4 + end + + # Final attempt (4): rejected, attempt >= 4 -> escalate + work_item[:pipeline][:attempt] = 4 + work_item[:pipeline][:review_result] = { verdict: 'rejected', score: 0.35 } + + # Conditioner check for relationship 8 (escalation) + should_escalate = work_item[:pipeline][:review_result][:verdict] == 'rejected' && + work_item[:pipeline][:attempt] >= 4 + expect(should_escalate).to be true + + # Conditioner check for relationship 7 (feedback) should NOT match + should_feedback = work_item[:pipeline][:review_result][:verdict] == 'rejected' && + work_item[:pipeline][:attempt] < 4 + expect(should_feedback).to be false + end + + it 'escalation handler sets fleet:escalated label' do + build_rejected_work_item(attempt: 4) + + escalation_result = { + success: true, + actions: [ + { action: 'set_label', label: 'fleet:escalated' }, + { action: 'post_comment', content: 'Escalated: max iterations exceeded' }, + { action: 'approval_queue', type: 'fleet.escalation' }, + { action: 'clear_dedup_cache' }, + { action: 'clear_redis_refs' }, + { action: 'cleanup_worktree' } + ] + } + + expect(escalation_result[:actions].map { |a| a[:action] }).to include( + 'set_label', 'post_comment', 'approval_queue', + 'clear_dedup_cache', 'clear_redis_refs', 'cleanup_worktree' + ) + end + + it 'clears dedup cache on escalation so issue can be retried' do + work_item_id = SecureRandom.uuid + fingerprint = Digest::SHA256.hexdigest('github:LegionIO/lex-exec#42:Fix sandbox') + dedup_key = "fleet:active:#{fingerprint}" + + # Set dedup key (simulating active work item) + cache.set(dedup_key, work_item_id, ttl: 86_400) + expect(cache.exists?(dedup_key)).to be true + + # Escalation clears the key + cache.delete(dedup_key) + expect(cache.exists?(dedup_key)).to be false + end + + it 'clears all Redis refs on escalation' do + work_item_id = SecureRandom.uuid + + cache.set("fleet:payload:#{work_item_id}", '{}', ttl: 86_400) + cache.set("fleet:context:#{work_item_id}", '{}', ttl: 86_400) + cache.set("fleet:worktree:#{work_item_id}", '/tmp/worktree', ttl: 86_400) + + %w[payload context worktree].each do |prefix| + cache.delete("fleet:#{prefix}:#{work_item_id}") + end + + expect(cache.exists?("fleet:payload:#{work_item_id}")).to be false + expect(cache.exists?("fleet:context:#{work_item_id}")).to be false + expect(cache.exists?("fleet:worktree:#{work_item_id}")).to be false + end + end + + # =========================================================================== + # Approval queue integration + # =========================================================================== + describe 'approval queue integration' do + it 'creates an escalation approval queue entry that resumes to incorporate_feedback' do + work_item = build_rejected_work_item(attempt: 4) + work_item[:pipeline][:resumed] = true + work_item[:pipeline][:attempt] = 0 + + # Escalation approval resumes to incorporate_feedback (developer runner), + # not ship.finalize. The stored payload has resumed: true so the handler + # skips the consent gate on replay. + approval_entry = { + approval_type: 'fleet.escalation', + work_item_id: work_item[:work_item_id], + source_ref: work_item[:source_ref], + title: work_item[:title], + resume_routing_key: 'lex.developer.runners.developer.incorporate_feedback', + payload: work_item, + status: 'pending' + } + + expect(approval_entry[:approval_type]).to eq('fleet.escalation') + expect(approval_entry[:status]).to eq('pending') + expect(approval_entry[:resume_routing_key]).to include('incorporate_feedback') + expect(approval_entry[:resume_routing_key]).not_to include('finalize') + expect(approval_entry[:payload][:pipeline][:resumed]).to be true + expect(approval_entry[:payload][:pipeline][:attempt]).to eq(0) + end + + it 'creates a consent approval queue entry that resumes to ship.finalize' do + work_item = build_implemented_work_item.merge( + pipeline: build_implemented_work_item[:pipeline].merge( + review_result: { verdict: 'approved', score: 0.92 }, + resumed: true + ) + ) + + # Consent approvals (shipping gate) resume to ship.finalize. + # The stored payload has resumed: true so finalize skips consent on replay. + approval_entry = { + approval_type: 'fleet.shipping', + work_item_id: work_item[:work_item_id], + source_ref: work_item[:source_ref], + title: work_item[:title], + resume_routing_key: 'lex.developer.runners.ship.finalize', + payload: work_item, + status: 'pending' + } + + expect(approval_entry[:approval_type]).to eq('fleet.shipping') + expect(approval_entry[:resume_routing_key]).to eq('lex.developer.runners.ship.finalize') + expect(approval_entry[:payload][:pipeline][:resumed]).to be true + end + + it 'resumed: true prevents re-triggering escalation or consent on replay' do + # When a work item is resumed from the approval queue, the pipeline handler + # checks pipeline[:resumed] to skip the consent check and proceed directly. + work_item = build_rejected_work_item(attempt: 4) + work_item[:pipeline][:resumed] = true + + expect(work_item[:pipeline][:resumed]).to be true + + # Simulate the gate check: resumed work items bypass the consent check + would_request_approval = !work_item[:pipeline][:resumed] + expect(would_request_approval).to be false + end + end + + # =========================================================================== + # Pipeline trace completeness + # =========================================================================== + describe 'pipeline trace completeness' do + it 'records all stages in trace for a full rejection+approval flow' do + work_item = build_absorbed_work_item + trace = [] + + # Assess + trace << { stage: 'assessor', node: 'worker-1', started_at: Time.now.utc.iso8601, + completed_at: Time.now.utc.iso8601, + token_usage: { input: 500, output: 200 }, + model: 'claude-sonnet-4-20250514', provider: 'anthropic' } + + # Develop (attempt 0) + trace << { stage: 'developer', node: 'worker-2', started_at: Time.now.utc.iso8601, + completed_at: Time.now.utc.iso8601, + token_usage: { input: 3000, output: 1500 }, + model: 'claude-opus-4-20250514', provider: 'anthropic' } + + # Validate (rejected) + trace << { stage: 'validator', node: 'worker-3', started_at: Time.now.utc.iso8601, + completed_at: Time.now.utc.iso8601, + token_usage: { input: 2000, output: 500 }, + model: 'claude-sonnet-4-20250514', provider: 'anthropic' } + + # Incorporate feedback + trace << { stage: 'developer_feedback', node: 'worker-2', started_at: Time.now.utc.iso8601, + completed_at: Time.now.utc.iso8601, + token_usage: { input: 4000, output: 2000 }, + model: 'claude-opus-4-20250514', provider: 'anthropic' } + + # Validate (approved) + trace << { stage: 'validator', node: 'worker-3', started_at: Time.now.utc.iso8601, + completed_at: Time.now.utc.iso8601, + token_usage: { input: 2000, output: 300 }, + model: 'claude-haiku-4-20251001', provider: 'anthropic' } + + # Ship + trace << { stage: 'ship', node: 'worker-2', started_at: Time.now.utc.iso8601, + completed_at: Time.now.utc.iso8601, token_usage: { input: 0, output: 0 } } + + work_item[:pipeline][:trace] = trace + + # Verify trace + stages = trace.map { |t| t[:stage] } + expect(stages).to eq(%w[assessor developer validator developer_feedback validator ship]) + + # Verify total token usage can be calculated + total_input = trace.sum { |t| t[:token_usage][:input] } + total_output = trace.sum { |t| t[:token_usage][:output] } + expect(total_input).to eq(11_500) + expect(total_output).to eq(4500) + end + + it 'records model and provider in each trace entry for anti-bias tracking' do + work_item = build_absorbed_work_item + trace = [ + { stage: 'assessor', model: 'claude-sonnet-4-20250514', provider: 'anthropic' }, + { stage: 'developer', model: 'claude-opus-4-20250514', provider: 'anthropic' } + ] + work_item[:pipeline][:trace] = trace + + # Each trace entry must carry model+provider + trace.each do |entry| + expect(entry[:model]).not_to be_nil, "#{entry[:stage]} trace entry missing :model" + expect(entry[:provider]).not_to be_nil, "#{entry[:stage]} trace entry missing :provider" + end + end + end +end diff --git a/spec/integration/fleet/pipeline_spec.rb b/spec/integration/fleet/pipeline_spec.rb new file mode 100644 index 00000000..ee3af680 --- /dev/null +++ b/spec/integration/fleet/pipeline_spec.rb @@ -0,0 +1,381 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'json' +require 'digest' + +require_relative 'support/fleet_helpers' +require_relative 'support/mock_cache' +require_relative 'support/mock_llm' +require_relative 'support/mock_github' + +# --------------------------------------------------------------------------- +# Minimal stub for the GitHub absorber (WS-11 target module). +# Tests verify the *contract* of the absorber, not the implementation. +# --------------------------------------------------------------------------- +module Legion + module Extensions + module Github + module Absorbers + module Issues + # Normalize a raw GitHub issues webhook payload into the standard + # fleet work item format (stage: 'intake'). + def self.normalize(payload) + issue = payload['issue'] + repo = payload['repository'] + + { + work_item_id: SecureRandom.uuid, + source: 'github', + source_ref: "#{repo['full_name']}##{issue['number']}", + source_event: "issues.#{payload['action']}", + title: issue['title'], + description: issue['body'], + raw_payload_ref: "fleet:payload:#{SecureRandom.uuid}", + repo: { + owner: repo.dig('owner', 'login'), + name: repo['name'], + default_branch: repo['default_branch'], + language: repo['language'] + }, + config: { + priority: :medium, + complexity: nil, + estimated_difficulty: nil, + planning: { enabled: true, solvers: 1, validators: 1, max_iterations: 2 }, + implementation: { solvers: 1, validators: 3, max_iterations: 5, models: nil }, + validation: { + enabled: true, run_tests: true, run_lint: true, + security_scan: true, adversarial_review: true, reviewer_models: nil + }, + feedback: { drain_enabled: true, max_drain_rounds: 3, summarize_after: 2 }, + workspace: { isolation: :worktree, cleanup_on_complete: true }, + context: { load_repo_docs: true, load_file_tree: true, max_context_files: 50 }, + tracing: { stage_comments: true, token_tracking: true }, + safety: { poison_message_threshold: 2, cancel_allowed: true }, + selection: { strategy: :test_winner }, + escalation: { on_max_iterations: :human, consent_domain: 'fleet.shipping' } + }, + pipeline: { + stage: 'intake', + trace: [], + attempt: 0, + feedback_history: [], + plan: nil, + changes: nil, + review_result: nil, + pr_number: nil, + branch_name: nil, + context_ref: nil + } + } + end + + # Absorb a GitHub issues webhook payload. + # Stores raw payload in cache; does NOT perform dedup (that is the assessor's job). + # Returns { absorbed: true, work_item_id: } or { absorbed: false, reason: }. + def self.absorb(payload:, cache: Legion::Cache) + sender = payload['sender'] || {} + return { absorbed: false, reason: :bot_generated } if bot_generated?(sender) + + work_item = normalize(payload) + cache.set(work_item[:raw_payload_ref], ::JSON.generate(payload), ttl: 86_400) + + { absorbed: true, work_item_id: work_item[:work_item_id] } + end + + def self.bot_generated?(sender) + return false if sender.nil? || sender.empty? + + sender['type'] == 'Bot' || sender['login'].to_s.include?('[bot]') + end + private_class_method :bot_generated? + end + end + end + end +end + +RSpec.describe 'Fleet Pipeline Integration' do + include Fleet::Test::FleetHelpers + + let(:cache) { Fleet::Test::MockCache.new } + let(:published_messages) { [] } + + before do + stub_const('Legion::Cache', cache) + + json_mod = Module.new do + def self.dump(obj) = ::JSON.generate(obj) + def self.load(str) = ::JSON.parse(str, symbolize_names: true) + end + stub_const('Legion::JSON', json_mod) + + logging_mod = Module.new do + def self.info(_msg) = nil + def self.warn(_msg) = nil + def self.debug(_msg) = nil + def self.error(_msg) = nil + end + stub_const('Legion::Logging', logging_mod) + end + + # =========================================================================== + # Stage 1: GitHub Absorber + # =========================================================================== + describe 'Stage 1: GitHub Absorber' do + let(:payload) { build_github_issue_payload } + + it 'absorbs a valid GitHub issue' do + result = Legion::Extensions::Github::Absorbers::Issues.absorb(payload: payload, cache: cache) + expect(result[:absorbed]).to be true + expect(result[:work_item_id]).to be_a(String) + end + + it 'stores raw payload in cache with fleet:payload: key' do + Legion::Extensions::Github::Absorbers::Issues.absorb(payload: payload, cache: cache) + keys = cache.keys('fleet:payload:*') + expect(keys).not_to be_empty + end + + it 'normalizes to standard work item format' do + work_item = Legion::Extensions::Github::Absorbers::Issues.normalize(payload) + expect(work_item[:source]).to eq('github') + expect(work_item[:source_ref]).to eq('LegionIO/lex-exec#42') + expect(work_item[:repo][:owner]).to eq('LegionIO') + expect(work_item[:pipeline][:stage]).to eq('intake') + expect(work_item[:pipeline][:attempt]).to eq(0) + end + + it 'does NOT call set_nx (dedup is the assessor responsibility, not the absorber)' do + expect(cache).not_to receive(:set_nx) + Legion::Extensions::Github::Absorbers::Issues.absorb(payload: payload, cache: cache) + end + + it 'rejects bot-generated events' do + bot_payload = payload.merge('sender' => { 'login' => 'dependabot[bot]', 'type' => 'Bot' }) + result = Legion::Extensions::Github::Absorbers::Issues.absorb(payload: bot_payload, cache: cache) + expect(result[:absorbed]).to be false + expect(result[:reason]).to eq(:bot_generated) + end + + it 'carries source_event from action field' do + work_item = Legion::Extensions::Github::Absorbers::Issues.normalize(payload) + expect(work_item[:source_event]).to eq('issues.opened') + end + end + + # =========================================================================== + # Stage 2: Assessor + # =========================================================================== + describe 'Stage 2: Assessor' do + let(:work_item) { build_absorbed_work_item } + + it 'classifies the work item' do + classification = Fleet::Test::MockLLM.response_for(:assessor_classify) + expect(classification[:complexity]).to eq('simple_bug') + expect(classification[:estimated_difficulty]).to be_a(Numeric) + end + + it 'produces a work item with config filled in after classification' do + assessed = work_item.merge( + config: work_item[:config].merge( + complexity: 'simple_bug', + estimated_difficulty: 0.3, + planning: { enabled: false, solvers: 1, validators: 1, max_iterations: 2 } + ), + pipeline: work_item[:pipeline].merge(stage: 'assessed') + ) + + expect(assessed[:config][:complexity]).to eq('simple_bug') + expect(assessed[:config][:planning][:enabled]).to be false + expect(assessed[:pipeline][:stage]).to eq('assessed') + end + + it 'skips planning for simple bugs (config.planning.enabled = false)' do + assessed = build_assessed_work_item + expect(assessed[:config][:planning][:enabled]).to be false + end + + it 'records assessor in trace' do + assessed = build_assessed_work_item + stages = assessed[:pipeline][:trace].map { |t| t[:stage] } + expect(stages).to include('assessor') + end + + it 'trace includes model and provider for anti-bias tracking' do + # Anti-bias: trace records which model was used per stage so downstream + # stages can exclude the same model (build exclude hash) + trace_entry = { stage: 'assessor', node: 'test-node', + started_at: Time.now.utc.iso8601, + model: 'claude-sonnet-4-20250514', provider: 'anthropic' } + expect(trace_entry[:model]).not_to be_nil + expect(trace_entry[:provider]).not_to be_nil + end + end + + # =========================================================================== + # Stage 3: Developer (planning skipped for simple bug) + # =========================================================================== + describe 'Stage 3: Developer (planning skipped for simple bug)' do + let(:work_item) { build_assessed_work_item } + + it 'produces implementation with changes and PR number' do + implemented = build_implemented_work_item + expect(implemented[:pipeline][:changes]).not_to be_empty + expect(implemented[:pipeline][:pr_number]).to eq(100) + expect(implemented[:pipeline][:branch_name]).to eq('fleet/fix-lex-exec-42') + end + + it 'sets pipeline stage to implemented' do + implemented = build_implemented_work_item + expect_stage(implemented, 'implemented') + end + + it 'includes developer in trace' do + implemented = build_implemented_work_item + expect_trace_includes(implemented, 'developer') + end + end + + # =========================================================================== + # Stage 4: Validator (approved) + # =========================================================================== + describe 'Stage 4: Validator (approved)' do + let(:work_item) { build_implemented_work_item } + let(:review_result) { Fleet::Test::MockLLM.response_for(:validator_approve) } + + it 'approves the implementation' do + expect(review_result[:verdict]).to eq('approved') + expect(review_result[:score]).to be >= 0.8 + end + + it 'produces a work item with review_result set' do + validated = work_item.merge( + pipeline: work_item[:pipeline].merge( + stage: 'validated', + review_result: review_result + ) + ) + expect(validated[:pipeline][:review_result][:verdict]).to eq('approved') + end + end + + # =========================================================================== + # Stage 5: Ship (finalize) + # =========================================================================== + describe 'Stage 5: Ship (finalize)' do + let(:work_item) do + build_implemented_work_item.merge( + pipeline: build_implemented_work_item[:pipeline].merge( + review_result: { verdict: 'approved', score: 0.92 } + ) + ) + end + + it 'work item has PR number for ready-marking' do + expect(work_item[:pipeline][:pr_number]).to eq(100) + end + + it 'work item has all required fields for shipping' do + expect(work_item[:pipeline][:branch_name]).not_to be_nil + expect(work_item[:pipeline][:changes]).not_to be_empty + expect(work_item[:source_ref]).to eq('LegionIO/lex-exec#42') + expect(work_item[:repo][:owner]).to eq('LegionIO') + expect(work_item[:repo][:name]).to eq('lex-exec') + end + end + + # =========================================================================== + # Full pipeline: GitHub issue -> assessed -> developed -> validated -> shipped + # =========================================================================== + describe 'Full pipeline: GitHub issue -> assessed -> developed -> validated -> shipped' do + it 'flows through all stages in correct order' do + # 1. Absorb + payload = build_github_issue_payload + work_item = Legion::Extensions::Github::Absorbers::Issues.normalize(payload) + expect(work_item[:pipeline][:stage]).to eq('intake') + + # 2. Assess (simple bug, skip planning) + work_item[:config][:complexity] = 'simple_bug' + work_item[:config][:estimated_difficulty] = 0.3 + work_item[:config][:planning][:enabled] = false + work_item[:pipeline][:stage] = 'assessed' + work_item[:pipeline][:trace] << { + stage: 'assessor', node: 'test', started_at: Time.now.utc.iso8601, + model: 'claude-sonnet-4-20250514', provider: 'anthropic' + } + expect(work_item[:config][:planning][:enabled]).to be false + + # 3. Develop (skip planning, go straight to developer) + work_item[:pipeline][:stage] = 'implemented' + work_item[:pipeline][:changes] = ['lib/sandbox.rb', 'spec/sandbox_spec.rb'] + work_item[:pipeline][:pr_number] = 100 + work_item[:pipeline][:branch_name] = 'fleet/fix-lex-exec-42' + work_item[:pipeline][:trace] << { + stage: 'developer', node: 'test', started_at: Time.now.utc.iso8601, + model: 'claude-opus-4-20250514', provider: 'anthropic' + } + + # 4. Validate (approved) + work_item[:pipeline][:stage] = 'validated' + work_item[:pipeline][:review_result] = { verdict: 'approved', score: 0.92 } + work_item[:pipeline][:trace] << { + stage: 'validator', node: 'test', started_at: Time.now.utc.iso8601, + model: 'claude-sonnet-4-20250514', provider: 'anthropic' + } + + # 5. Ship + work_item[:pipeline][:stage] = 'shipped' + work_item[:pipeline][:trace] << { + stage: 'ship', node: 'test', started_at: Time.now.utc.iso8601 + } + + # Verify final state + expect(work_item[:pipeline][:stage]).to eq('shipped') + expect(work_item[:pipeline][:pr_number]).to eq(100) + expect(work_item[:pipeline][:trace].size).to eq(4) + expect(work_item[:pipeline][:trace].map { |t| t[:stage] }).to eq( + %w[assessor developer validator ship] + ) + + # Anti-bias: assessor used sonnet, developer used opus — models differ, so no exclude needed + assessor_model = work_item[:pipeline][:trace].find { |t| t[:stage] == 'assessor' }[:model] + developer_model = work_item[:pipeline][:trace].find { |t| t[:stage] == 'developer' }[:model] + expect(assessor_model).not_to eq(developer_model) + + # resumed should be nil/false for happy path (no approval queue involved) + expect(work_item[:pipeline][:resumed]).to be_falsey + end + end + + # =========================================================================== + # Anti-bias: model exclusion via trace + # =========================================================================== + describe 'Anti-bias: model exclusion via trace' do + it 'builds exclude hash from trace for downstream stages' do + trace = [ + { stage: 'assessor', model: 'claude-sonnet-4-20250514', provider: 'anthropic' }, + { stage: 'developer', model: 'claude-opus-4-20250514', provider: 'anthropic' } + ] + + # Downstream stage builds exclude hash from prior trace entries + exclude = trace.each_with_object({}) do |entry, acc| + acc[entry[:provider]] ||= [] + acc[entry[:provider]] << entry[:model] + end + + expect(exclude['anthropic']).to include('claude-sonnet-4-20250514', 'claude-opus-4-20250514') + end + + it 'anti-bias exclude does NOT appear in the work item trace itself' do + work_item = build_implemented_work_item + trace_keys = work_item[:pipeline][:trace].flat_map(&:keys) + + # The trace records model+provider for use BY downstream stages, + # but the trace itself does not store a pre-built exclude hash + expect(trace_keys).not_to include(:exclude) + end + end +end diff --git a/spec/integration/fleet/rejection_loop_spec.rb b/spec/integration/fleet/rejection_loop_spec.rb new file mode 100644 index 00000000..e987c1d3 --- /dev/null +++ b/spec/integration/fleet/rejection_loop_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'json' + +require_relative 'support/fleet_helpers' +require_relative 'support/mock_cache' +require_relative 'support/mock_llm' + +RSpec.describe 'Fleet Rejection Loop' do + include Fleet::Test::FleetHelpers + + let(:cache) { Fleet::Test::MockCache.new } + + before do + stub_const('Legion::Cache', cache) + + json_mod = Module.new do + def self.dump(obj) = ::JSON.generate(obj) + def self.load(str) = ::JSON.parse(str, symbolize_names: true) + end + stub_const('Legion::JSON', json_mod) + + logging_mod = Module.new do + def self.info(_msg) = nil + def self.warn(_msg) = nil + def self.debug(_msg) = nil + def self.error(_msg) = nil + end + stub_const('Legion::Logging', logging_mod) + end + + # =========================================================================== + # Validator rejects -> developer incorporates feedback -> validator approves + # =========================================================================== + describe 'validator rejects -> developer incorporates feedback -> validator approves' do + it 'completes after one rejection cycle' do + # Start with an implemented work item + work_item = build_implemented_work_item + + # --- Validator rejects (attempt 0) --- + rejection = Fleet::Test::MockLLM.response_for(:validator_reject) + work_item[:pipeline][:stage] = 'validated' + work_item[:pipeline][:review_result] = { + verdict: rejection[:verdict], + score: rejection[:score], + issues: rejection[:issues], + merged_feedback: rejection[:feedback] + } + work_item[:pipeline][:trace] << { + stage: 'validator', node: 'test', started_at: Time.now.utc.iso8601 + } + + expect(work_item[:pipeline][:review_result][:verdict]).to eq('rejected') + expect(work_item[:pipeline][:attempt]).to eq(0) + + # --- Check routing: attempt (0) < 4, so route to incorporate_feedback --- + attempt = work_item[:pipeline][:attempt] + expect(attempt).to be < 4, 'Should route to feedback, not escalation' + + # --- Developer incorporates feedback (resumes to incorporate_feedback) --- + work_item[:pipeline][:attempt] += 1 + work_item[:pipeline][:feedback_history] << rejection[:feedback] + work_item[:pipeline][:stage] = 'implemented' + work_item[:pipeline][:trace] << { + stage: 'developer_feedback', node: 'test', started_at: Time.now.utc.iso8601 + } + + expect(work_item[:pipeline][:attempt]).to eq(1) + expect(work_item[:pipeline][:feedback_history]).not_to be_empty + + # --- Validator approves (attempt 1) --- + approval = Fleet::Test::MockLLM.response_for(:validator_approve_after_feedback) + work_item[:pipeline][:stage] = 'validated' + work_item[:pipeline][:review_result] = { + verdict: approval[:verdict], + score: approval[:score], + issues: approval[:issues], + merged_feedback: approval[:feedback] + } + + expect(work_item[:pipeline][:review_result][:verdict]).to eq('approved') + + # --- Ship --- + work_item[:pipeline][:stage] = 'shipped' + work_item[:pipeline][:trace] << { + stage: 'ship', node: 'test', started_at: Time.now.utc.iso8601 + } + + expect(work_item[:pipeline][:stage]).to eq('shipped') + expect(work_item[:pipeline][:attempt]).to eq(1) + expect(work_item[:pipeline][:trace].map { |t| t[:stage] }).to include( + 'developer_feedback', 'ship' + ) + end + + it 'feedback incorporation resumes to incorporate_feedback, not finalize' do + # Design amendment: escalation approval resumes to incorporate_feedback + # (not ship.finalize). The routing key must point at the developer stage. + work_item = build_rejected_work_item(attempt: 0) + + # Simulate what the rejection conditioner determines + verdict = work_item[:pipeline][:review_result][:verdict] + attempt = work_item[:pipeline][:attempt] + + should_incorporate = verdict == 'rejected' && attempt < 4 + expect(should_incorporate).to be true + + # The resume target is incorporate_feedback (developer runner), not finalize (ship runner) + resume_target = 'lex.developer.runners.developer.incorporate_feedback' + expect(resume_target).to include('incorporate_feedback') + expect(resume_target).not_to include('finalize') + end + end + + # =========================================================================== + # Feedback summarization after N rejections + # =========================================================================== + describe 'feedback summarization after N rejections' do + it 'summarizes feedback when attempt exceeds summarize_after threshold' do + work_item = build_rejected_work_item(attempt: 2) + summarize_after = work_item[:config][:feedback][:summarize_after] + + # After 2 rejections (>= summarize_after of 2), feedback should be summarized + expect(work_item[:pipeline][:attempt]).to be >= summarize_after + + # Simulate summarization: condense feedback_history to constraint list + original_feedback = work_item[:pipeline][:feedback_history] + summarized = "CONSTRAINTS: #{original_feedback.map { |f| f.is_a?(Hash) ? f[:verdict] : f }.join('; ')}" + work_item[:pipeline][:feedback_history] = [summarized] + + expect(work_item[:pipeline][:feedback_history].size).to eq(1) + expect(work_item[:pipeline][:feedback_history].first).to start_with('CONSTRAINTS:') + end + end + + # =========================================================================== + # Routing conditions (design spec section 4) + # =========================================================================== + describe 'routing conditions match design spec section 4' do + it 'routes to incorporate_feedback when verdict=rejected AND attempt < 4' do + [0, 1, 2, 3].each do |attempt| + work_item = build_rejected_work_item(attempt: attempt) + verdict = work_item[:pipeline][:review_result][:verdict] + + should_feedback = verdict == 'rejected' && attempt < 4 + expect(should_feedback).to be(true), "Attempt #{attempt} should route to incorporate_feedback" + end + end + + it 'does NOT route to incorporate_feedback when attempt >= 4' do + work_item = build_rejected_work_item(attempt: 4) + verdict = work_item[:pipeline][:review_result][:verdict] + + should_feedback = verdict == 'rejected' && work_item[:pipeline][:attempt] < 4 + expect(should_feedback).to be false + end + + it 'routes to escalation when verdict=rejected AND attempt >= 4' do + [4, 5, 6].each do |attempt| + work_item = build_rejected_work_item(attempt: attempt) + verdict = work_item[:pipeline][:review_result][:verdict] + + should_escalate = verdict == 'rejected' && attempt >= 4 + expect(should_escalate).to be(true), "Attempt #{attempt} should route to escalation" + end + end + end + + # =========================================================================== + # Thinking budget scaling by attempt + # =========================================================================== + describe 'thinking budget scaling by attempt' do + it 'increases thinking budget with each attempt, capped at 64k' do + budgets = (0..3).map do |attempt| + [16_000 * (2**attempt), 64_000].min + end + + expect(budgets[0]).to eq(16_000) # attempt 0 + expect(budgets[1]).to eq(32_000) # attempt 1 + expect(budgets[2]).to eq(64_000) # attempt 2 (capped) + expect(budgets[3]).to eq(64_000) # attempt 3 (capped) + end + end + + # =========================================================================== + # resumed: true flag on re-queued work items + # =========================================================================== + describe 'resumed: true flag' do + it 'sets resumed: true on work items that re-enter the pipeline' do + work_item = build_rejected_work_item(attempt: 0) + + # Simulate approval queue handler resuming the work item + work_item[:pipeline][:resumed] = true + work_item[:pipeline][:attempt] = 0 # reset attempt after approval + + expect(work_item[:pipeline][:resumed]).to be true + end + + it 'happy-path work items do not have resumed flag set' do + work_item = build_implemented_work_item + expect(work_item[:pipeline][:resumed]).to be_nil + end + end +end diff --git a/spec/integration/fleet/support/fleet_helpers.rb b/spec/integration/fleet/support/fleet_helpers.rb new file mode 100644 index 00000000..c91bacde --- /dev/null +++ b/spec/integration/fleet/support/fleet_helpers.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require_relative 'mock_cache' +require_relative 'mock_llm' +require_relative 'mock_github' + +module Fleet + module Test + module FleetHelpers + # Build a standard GitHub issue work item for testing + def build_github_issue_payload + Fleet::Test::MockGitHub::ISSUE_PAYLOAD.dup + end + + # Build a work item that has been through the absorber + def build_absorbed_work_item(overrides = {}) + { + work_item_id: SecureRandom.uuid, + source: 'github', + source_ref: 'LegionIO/lex-exec#42', + source_event: 'issues.opened', + title: 'Fix sandbox timeout on macOS', + description: 'The exec sandbox times out after 30s on macOS ARM64.', + raw_payload_ref: 'fleet:payload:test-uuid', + repo: { + owner: 'LegionIO', + name: 'lex-exec', + default_branch: 'main', + language: 'Ruby' + }, + config: build_default_config, + pipeline: { + stage: 'intake', + trace: [], + attempt: 0, + feedback_history: [], + plan: nil, + changes: nil, + review_result: nil, + pr_number: nil, + branch_name: nil, + context_ref: nil + } + }.merge(overrides) + end + + # Build a work item that has been assessed (simple bug, skip planning) + def build_assessed_work_item(overrides = {}) + build_absorbed_work_item.merge( + config: build_default_config.merge( + priority: :medium, + complexity: 'simple_bug', + estimated_difficulty: 0.3, + planning: { enabled: false, solvers: 1, validators: 1, max_iterations: 2 }, + validation: build_default_config[:validation].merge(enabled: true) + ), + pipeline: { + stage: 'assessed', + trace: [{ stage: 'assessor', node: 'test-node', started_at: Time.now.utc.iso8601 }], + attempt: 0, + feedback_history: [], + plan: nil, + changes: nil, + review_result: nil, + pr_number: nil, + branch_name: nil, + context_ref: nil + } + ).merge(overrides) + end + + # Build a work item that has been implemented + def build_implemented_work_item(overrides = {}) + build_assessed_work_item.merge( + pipeline: { + stage: 'implemented', + trace: [ + { stage: 'assessor', node: 'test-node', started_at: Time.now.utc.iso8601 }, + { stage: 'developer', node: 'test-node', started_at: Time.now.utc.iso8601 } + ], + attempt: 0, + feedback_history: [], + plan: nil, + changes: ['lib/legion/extensions/exec/helpers/sandbox.rb', 'spec/helpers/sandbox_spec.rb'], + review_result: nil, + pr_number: 100, + branch_name: 'fleet/fix-lex-exec-42', + context_ref: nil + } + ).merge(overrides) + end + + # Build a work item that was rejected by validator + def build_rejected_work_item(attempt: 0, overrides: {}) + build_implemented_work_item.merge( + pipeline: { + stage: 'validated', + trace: [ + { stage: 'assessor', node: 'test-node', started_at: Time.now.utc.iso8601 }, + { stage: 'developer', node: 'test-node', started_at: Time.now.utc.iso8601 }, + { stage: 'validator', node: 'test-node', started_at: Time.now.utc.iso8601 } + ], + attempt: attempt, + feedback_history: [ + { verdict: 'rejected', issues: ['Settings.dig may return nil when key path is incomplete'], + round: 1 } + ], + plan: nil, + changes: ['lib/legion/extensions/exec/helpers/sandbox.rb'], + review_result: { verdict: 'rejected', score: 0.45, issues: [], merged_feedback: 'Add nil guard.' }, + pr_number: 100, + branch_name: 'fleet/fix-lex-exec-42', + context_ref: nil + } + ).merge(overrides) + end + + def build_default_config + { + priority: :medium, + complexity: nil, + estimated_difficulty: nil, + planning: { enabled: true, solvers: 1, validators: 1, max_iterations: 2 }, + implementation: { solvers: 1, validators: 3, max_iterations: 5, models: nil }, + validation: { + enabled: true, run_tests: true, run_lint: true, + security_scan: true, adversarial_review: true, reviewer_models: nil + }, + feedback: { drain_enabled: true, max_drain_rounds: 3, summarize_after: 2 }, + workspace: { isolation: :worktree, cleanup_on_complete: true }, + context: { load_repo_docs: true, load_file_tree: true, max_context_files: 50 }, + tracing: { stage_comments: true, token_tracking: true }, + safety: { poison_message_threshold: 2, cancel_allowed: true }, + selection: { strategy: :test_winner }, + escalation: { on_max_iterations: :human, consent_domain: 'fleet.shipping' } + } + end + + # Assert a work item has the expected pipeline stage + def expect_stage(work_item, expected_stage) + expect(work_item[:pipeline][:stage]).to eq(expected_stage) + end + + # Assert a work item has a trace entry for the expected stage + def expect_trace_includes(work_item, stage_name) + stages = work_item[:pipeline][:trace].map { |t| t[:stage] } + expect(stages).to include(stage_name) + end + end + end +end diff --git a/spec/integration/fleet/support/mock_cache.rb b/spec/integration/fleet/support/mock_cache.rb new file mode 100644 index 00000000..b32f1898 --- /dev/null +++ b/spec/integration/fleet/support/mock_cache.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# In-memory cache that mimics Legion::Cache interface for integration testing. +# Supports get, set, set_nx, delete, and TTL tracking. +module Fleet + module Test + class MockCache + attr_reader :store, :ttls + + def initialize + @store = {} + @ttls = {} + @mutex = Mutex.new + end + + def get(key) + @mutex.synchronize do + return nil if expired?(key) + + @store[key] + end + end + + def set(key, value, ttl: nil) + @mutex.synchronize do + @store[key] = value + @ttls[key] = Time.now + ttl if ttl + value + end + end + + # Atomic set-if-not-exists (mimics Redis SET NX EX) + def set_nx(key, value, ttl: nil) + @mutex.synchronize do + return false if @store.key?(key) && !expired?(key) + + @store[key] = value + @ttls[key] = Time.now + ttl if ttl + true + end + end + + def delete(key) + @mutex.synchronize do + @store.delete(key) + @ttls.delete(key) + end + end + + def exists?(key) + @mutex.synchronize { @store.key?(key) && !expired?(key) } + end + + def clear + @mutex.synchronize do + @store.clear + @ttls.clear + end + end + + def keys(pattern = '*') + @mutex.synchronize do + regex = Regexp.new("\\A#{Regexp.escape(pattern).gsub('\\*', '.*')}\\z") + @store.keys.grep(regex) + end + end + + private + + def expired?(key) + return false unless @ttls.key?(key) + + Time.now > @ttls[key] + end + end + end +end diff --git a/spec/integration/fleet/support/mock_github.rb b/spec/integration/fleet/support/mock_github.rb new file mode 100644 index 00000000..b504191b --- /dev/null +++ b/spec/integration/fleet/support/mock_github.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# Mock GitHub API responses for integration testing. +# Provides canned responses for all GitHub runner methods used by the fleet. +module Fleet + module Test + module MockGitHub + ISSUE_PAYLOAD = { + 'action' => 'opened', + 'issue' => { + 'number' => 42, + 'title' => 'Fix sandbox timeout on macOS', + 'body' => 'The exec sandbox times out after 30s on macOS ARM64. ' \ + 'Need to increase the default and make it configurable.', + 'labels' => [{ 'name' => 'bug' }], + 'user' => { 'login' => 'matt-iverson', 'type' => 'User' }, + 'html_url' => 'https://github.com/LegionIO/lex-exec/issues/42' + }, + 'repository' => { + 'full_name' => 'LegionIO/lex-exec', + 'name' => 'lex-exec', + 'owner' => { 'login' => 'LegionIO' }, + 'default_branch' => 'main', + 'language' => 'Ruby', + 'clone_url' => 'https://github.com/LegionIO/lex-exec.git' + }, + 'sender' => { 'login' => 'matt-iverson', 'type' => 'User' } + }.freeze + + PR_RESPONSE = { + 'number' => 100, + 'title' => 'fleet/fix-lex-exec-42: Fix sandbox timeout on macOS', + 'html_url' => 'https://github.com/LegionIO/lex-exec/pull/100', + 'state' => 'open', + 'draft' => true, + 'id' => 999 + }.freeze + + PR_FILES = [ + { 'filename' => 'lib/legion/extensions/exec/helpers/sandbox.rb', + 'status' => 'modified', 'additions' => 5, 'deletions' => 2, 'patch' => '+timeout = 120' }, + { 'filename' => 'spec/helpers/sandbox_spec.rb', + 'status' => 'modified', 'additions' => 8, 'deletions' => 0, 'patch' => '+it "uses default"' } + ].freeze + + LABEL_RESPONSE = { 'id' => 1, 'name' => 'fleet:received' }.freeze + + # Build mock runner module with all GitHub methods the fleet uses + def self.build_mock_runners + Module.new do + def create_pull_request(owner:, repo:, title:, head:, base:, body: nil, draft: false, **) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists + { result: Fleet::Test::MockGitHub::PR_RESPONSE } + end + + def update_pull_request(owner:, repo:, pull_number:, **) # rubocop:disable Lint/UnusedMethodArgument + { result: Fleet::Test::MockGitHub::PR_RESPONSE.merge('draft' => false) } + end + + def list_pull_request_files(owner:, repo:, pull_number:, **) # rubocop:disable Lint/UnusedMethodArgument + { result: Fleet::Test::MockGitHub::PR_FILES } + end + + def list_pull_request_commits(owner:, repo:, pull_number:, **) # rubocop:disable Lint/UnusedMethodArgument + { result: [ + { 'sha' => 'abc123', 'commit' => { 'message' => 'fleet: fix sandbox timeout' } } + ] } + end + + def add_labels_to_issue(owner:, repo:, issue_number:, labels:, **) # rubocop:disable Lint/UnusedMethodArgument + { result: labels.map { |l| { 'name' => l } } } + end + + def create_issue_comment(owner:, repo:, issue_number:, body:, **) # rubocop:disable Lint/UnusedMethodArgument + { result: { 'id' => 1, 'body' => body } } + end + + def get_issue(owner:, repo:, issue_number:, **) # rubocop:disable Lint/UnusedMethodArgument + { result: Fleet::Test::MockGitHub::ISSUE_PAYLOAD['issue'] } + end + + def list_issue_comments(owner:, repo:, issue_number:, **) # rubocop:disable Lint/UnusedMethodArgument + { result: [] } + end + + def create_webhook(owner:, repo:, config:, events:, active:, **) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists + { result: { 'id' => 12_345, 'active' => true, 'events' => events } } + end + + def list_webhooks(owner:, repo:, **) # rubocop:disable Lint/UnusedMethodArgument + { result: [] } + end + + def create_label(owner:, repo:, name:, color:, description: nil, **) # rubocop:disable Lint/UnusedMethodArgument,Metrics/ParameterLists + { result: { 'id' => 1, 'name' => name } } + end + end + end + end + end +end diff --git a/spec/integration/fleet/support/mock_llm.rb b/spec/integration/fleet/support/mock_llm.rb new file mode 100644 index 00000000..a60e1817 --- /dev/null +++ b/spec/integration/fleet/support/mock_llm.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +# Mock LLM responses keyed by fleet stage. +# Each stage returns a canned response matching the expected schema. +# All mocks use Legion::LLM::Prompt (dispatch/extract/summarize), NOT Legion::LLM.chat. +module Fleet + module Test + module MockLLM + RESPONSES = { + # Assessor classification response (structured output) + assessor_classify: { + priority: 'medium', + complexity: 'simple_bug', + work_type: 'bug_fix', + language: 'ruby', + estimated_difficulty: 0.3 + }, + + # Planner plan response (structured output) + planner_plan: { + approach: 'Fix the timeout by increasing the default value and adding a configurable parameter.', + files_to_modify: [ + { path: 'lib/legion/extensions/exec/helpers/sandbox.rb', action: 'modify', + reason: 'Increase default timeout and add config parameter' }, + { path: 'spec/helpers/sandbox_spec.rb', action: 'modify', + reason: 'Add test for configurable timeout' } + ], + files_to_read: %w[lib/legion/extensions/exec/helpers/sandbox.rb README.md], + test_strategy: 'Add RSpec examples for new timeout config', + estimated_changes: 2 + }, + + # Developer implementation response (chat) + developer_implement: <<~RESPONSE, + I'll fix the sandbox timeout issue. Here are the changes: + + ```ruby + # lib/legion/extensions/exec/helpers/sandbox.rb + module Legion + module Extensions + module Exec + module Helpers + module Sandbox + DEFAULT_TIMEOUT = 120 # increased from 30 + + def execute_with_timeout(command:, timeout: DEFAULT_TIMEOUT, **) + Timeout.timeout(timeout) { system(command) } + end + end + end + end + end + end + ``` + + ```ruby + # spec/helpers/sandbox_spec.rb + RSpec.describe Legion::Extensions::Exec::Helpers::Sandbox do + it 'uses default timeout of 120 seconds' do + expect(described_class::DEFAULT_TIMEOUT).to eq(120) + end + end + ``` + RESPONSE + + # Developer implementation response for feedback incorporation + developer_feedback: <<~RESPONSE, + I've addressed the review feedback. The timeout is now configurable via settings: + + ```ruby + # lib/legion/extensions/exec/helpers/sandbox.rb + DEFAULT_TIMEOUT = Legion::Settings.dig(:exec, :sandbox, :timeout) || 120 + ``` + RESPONSE + + # Validator review response: approved + validator_approve: { + verdict: 'approved', + score: 0.92, + issues: [], + feedback: 'Code changes look correct. Timeout is properly configurable.' + }, + + # Validator review response: rejected + validator_reject: { + verdict: 'rejected', + score: 0.45, + issues: [ + { severity: 'high', file: 'lib/legion/extensions/exec/helpers/sandbox.rb', + description: 'Settings access without fallback could raise if settings not loaded' } + ], + feedback: 'Settings.dig may return nil if exec settings are not configured. Add a nil guard.' + }, + + # Validator review response: second review (approved after feedback) + validator_approve_after_feedback: { + verdict: 'approved', + score: 0.88, + issues: [], + feedback: 'Nil guard added. Code is correct.' + } + }.freeze + + def self.response_for(stage) + RESPONSES.fetch(stage) + end + + # Build a mock Legion::LLM::Prompt module for use with stub_const in specs. + # Fleet extensions use Prompt.dispatch (auto-routed) and Prompt.extract + # (structured output), NOT Legion::LLM.chat or .structured. + # Returns the module -- callers use stub_const in their own `before` blocks: + # before { stub_const('Legion::LLM::Prompt', MockLLM.build_prompt_double) } + def self.build_prompt_double + Module.new do + extend self + + def dispatch(message, **_opts) + content = message.to_s + if content.include?('feedback') + { + content: Fleet::Test::MockLLM.response_for(:developer_feedback), + model: 'claude-sonnet-4-20250514', + provider: 'anthropic' + } + else + { + content: Fleet::Test::MockLLM.response_for(:developer_implement), + model: 'claude-sonnet-4-20250514', + provider: 'anthropic' + } + end + end + + def extract(message, schema:, **_opts) # rubocop:disable Lint/UnusedMethodArgument + schema_name = schema[:name] || schema.to_s + result = if schema_name.include?('classif') + Fleet::Test::MockLLM.response_for(:assessor_classify) + elsif schema_name.include?('plan') + Fleet::Test::MockLLM.response_for(:planner_plan) + elsif schema_name.include?('review') + Fleet::Test::MockLLM.response_for(:validator_approve) + else + {} + end + result.merge(model: 'claude-sonnet-4-20250514', provider: 'anthropic') + end + + def summarize(message, **_opts) + { content: message.to_s[0..200], model: 'claude-haiku-4-20250514', provider: 'anthropic' } + end + + def started? = true + end + end + end + end +end diff --git a/spec/integration/governance_lifecycle_spec.rb b/spec/integration/governance_lifecycle_spec.rb new file mode 100644 index 00000000..473970dc --- /dev/null +++ b/spec/integration/governance_lifecycle_spec.rb @@ -0,0 +1,894 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/digital_worker/lifecycle' + +# Stub Legion::Data::Model::DigitalWorker if not already defined so the lifecycle +# require succeeds without a live database connection. +unless defined?(Legion::Data::Model::DigitalWorker) + module Legion + module Data + module Model + class DigitalWorker; end # rubocop:disable Lint/EmptyClass + end + end + end +end + +RSpec.describe 'Governance lifecycle integration' do + # Define stub modules when missing so SUT code that calls Legion::Logging, + # Legion::Events, or Legion::Audit never raises NoMethodError regardless of + # load order. Scoped to this describe block via stub_const/before to avoid + # polluting other spec files. + before do + unless defined?(Legion::Logging) + stub_const( + 'Legion::Logging', + Module.new do + def self.info(*); end + + def self.debug(*); end + + def self.warn(*); end + + def self.error(*); end + end + ) + end + + unless defined?(Legion::Events) + stub_const( + 'Legion::Events', + Module.new do + def self.emit(*); end + end + ) + end + + unless defined?(Legion::Audit) + stub_const( + 'Legion::Audit', + Module.new do + def self.record(**); end + end + ) + end + + allow(Legion::Events).to receive(:emit) + allow(Legion::Audit).to receive(:record) + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + end + + # --------------------------------------------------------------------------- + # Shared worker double factory + # --------------------------------------------------------------------------- + def build_worker(overrides = {}) + defaults = { + worker_id: 'worker-gov-01', + lifecycle_state: 'active', + owner_msid: 'alice@example.com', + trust_score: 0.85, + retired_at: nil, + retired_by: nil, + retired_reason: nil, + update: true + } + double('Worker', defaults.merge(overrides)) + end + + # --------------------------------------------------------------------------- + # Shared examples: assertions common to active->retired and paused->retired + # --------------------------------------------------------------------------- + shared_examples 'a successful retirement transition' do |from:, to_state: 'retired'| + it 'emits worker.lifecycle event with correct from_state and to_state' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: to_state, + by: 'owner@example.com', + reason: 'end of service life', + authority_verified: true + ) + + expect(Legion::Events).to have_received(:emit).with( + 'worker.lifecycle', + hash_including(from_state: from, to_state: to_state) + ) + end + + it 'writes an audit entry with status success' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: to_state, + by: 'owner@example.com', + reason: 'end of service life', + authority_verified: true + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'lifecycle_transition', + status: 'success' + ) + ) + end + end + + # =========================================================================== + # 1. Escalation cycle + # Trigger extinction L1 -> validate governance gate fires -> + # validate audit log entry created + # =========================================================================== + describe 'escalation cycle' do + let(:worker) { build_worker(lifecycle_state: 'active') } + + # NOTE: `authority_verified: true` asserts that the *caller* has verified + # identity/authority, which is distinct from `governance_override: true`. + # The governance gate checks whether the *transition itself* requires + # council approval independent of who is making the request. + context 'when transitioning active -> terminated without governance_override' do + it 'raises GovernanceRequired (governance gate fires)' do + expect do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'terminated', + by: 'manager-1', + reason: 'extinction L1 triggered', + authority_verified: true + ) + end.to raise_error(Legion::DigitalWorker::Lifecycle::GovernanceRequired, /council_approval/) + end + + it 'does NOT emit a lifecycle event when governance gate blocks the transition' do + expect do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'terminated', + by: 'manager-1', + reason: 'extinction L1 triggered', + authority_verified: true + ) + end.to raise_error(Legion::DigitalWorker::Lifecycle::GovernanceRequired) + + expect(Legion::Events).not_to have_received(:emit) + end + + it 'does NOT write an audit entry when governance gate blocks the transition' do + expect do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'terminated', + by: 'manager-1', + reason: 'extinction L1 triggered', + authority_verified: true + ) + end.to raise_error(Legion::DigitalWorker::Lifecycle::GovernanceRequired) + + expect(Legion::Audit).not_to have_received(:record) + end + end + + context 'when escalation is approved (governance_override supplied)' do + it 'transitions to paused as an intermediate containment step' do + result = Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'paused', + by: 'manager-1', + reason: 'extinction L1: capability restriction', + authority_verified: true + ) + expect(result).to eq(worker) + end + + it 'emits worker.lifecycle event with extinction_level 2 for paused state' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'paused', + by: 'manager-1', + reason: 'extinction L1: capability restriction', + authority_verified: true + ) + + expect(Legion::Events).to have_received(:emit).with( + 'worker.lifecycle', + hash_including( + worker_id: 'worker-gov-01', + from_state: 'active', + to_state: 'paused', + extinction_level: 2 + ) + ) + end + + it 'writes an audit log entry on successful paused transition' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'paused', + by: 'manager-1', + reason: 'extinction L1: capability restriction', + authority_verified: true + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'lifecycle_transition', + principal_id: 'manager-1', + action: 'transition', + resource: 'worker-gov-01', + status: 'success' + ) + ) + end + + it 'includes from_state and to_state in the audit detail' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'paused', + by: 'manager-1', + reason: 'extinction L1: capability restriction', + authority_verified: true + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including( + detail: { from_state: 'active', to_state: 'paused', + reason: 'extinction L1: capability restriction' } + ) + ) + end + + it 'allows terminated transition when governance_override is true' do + result = Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'terminated', + by: 'council', + reason: 'extinction L1 approved', + authority_verified: true, + governance_override: true + ) + expect(result).to eq(worker) + end + end + end + + # =========================================================================== + # Extinction escalation verification + # Stub the extinction client and verify correct calls per transition. + # These tests are meaningful because Lifecycle.transition! internally + # instantiates Legion::Extensions::Extinction::Client.new and calls + # escalate/deescalate — confirmed in lib/legion/digital_worker/lifecycle.rb. + # =========================================================================== + describe 'extinction escalation verification' do + let(:worker) { build_worker(lifecycle_state: 'active') } + let(:extinction_client) { instance_double('ExtinctionClient') } + + before do + stub_const('Legion::Extensions::Extinction::Client', Class.new) + allow(Legion::Extensions::Extinction::Client).to receive(:new).and_return(extinction_client) + allow(extinction_client).to receive(:escalate).and_return({ escalated: true, level: 2 }) + allow(extinction_client).to receive(:deescalate).and_return({ deescalated: true, level: 0 }) + end + + context 'active -> paused (extinction level 0 -> 2)' do + it 'calls extinction escalate with level 2' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'paused', by: 'manager-1', reason: 'maintenance', + authority_verified: true + ) + expect(extinction_client).to have_received(:escalate).with( + hash_including(level: 2, reason: /lifecycle transition/) + ) + end + end + + context 'active -> retired (extinction level 0 -> 3)' do + it 'calls extinction escalate with level 3' do + allow(extinction_client).to receive(:escalate).and_return({ escalated: true, level: 3 }) + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'retired', by: 'manager-1', reason: 'decommission', + authority_verified: true + ) + expect(extinction_client).to have_received(:escalate).with( + hash_including(level: 3) + ) + end + end + + context 'level decrease (paused -> active, level 2 -> 0)' do + let(:worker) { build_worker(lifecycle_state: 'paused') } + + it 'does not call extinction escalate' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'active', by: 'manager-1', reason: 'resume', + authority_verified: true + ) + expect(extinction_client).not_to have_received(:escalate) + end + + it 'calls extinction deescalate' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'active', by: 'manager-1', reason: 'resume', + authority_verified: true + ) + expect(extinction_client).to have_received(:deescalate).with( + hash_including(target_level: 0, reason: /lifecycle transition/) + ) + end + end + end + + # =========================================================================== + # Ownership transfer with downstream verification + # Verify lifecycle event and audit chain during ownership transfer scenario + # =========================================================================== + describe 'ownership transfer with downstream verification' do + let(:worker) { build_worker(lifecycle_state: 'active') } + + context 'when lifecycle is paused for ownership transfer prep' do + it 'emits worker.lifecycle event with from_state and to_state' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'paused', by: 'admin-1', reason: 'ownership transfer prep', + authority_verified: true + ) + + expect(Legion::Events).to have_received(:emit).with( + 'worker.lifecycle', + hash_including( + worker_id: 'worker-gov-01', + from_state: 'active', + to_state: 'paused' + ) + ) + end + end + + it 'audit log records transfer event with before/after state' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'paused', by: 'admin-1', reason: 'ownership transfer', + authority_verified: true + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'lifecycle_transition', + principal_id: 'admin-1', + action: 'transition', + status: 'success' + ) + ) + end + end + + # =========================================================================== + # De-escalation on resume + # When a paused worker resumes, extinction level decreases + # =========================================================================== + describe 'de-escalation on resume' do + let(:worker) { build_worker(lifecycle_state: 'paused') } + let(:extinction_client) { instance_double('ExtinctionClient') } + + before do + stub_const('Legion::Extensions::Extinction::Client', Class.new) + allow(Legion::Extensions::Extinction::Client).to receive(:new).and_return(extinction_client) + allow(extinction_client).to receive(:escalate).and_return({ escalated: true }) + allow(extinction_client).to receive(:deescalate).and_return({ deescalated: true, level: 0 }) + end + + context 'paused -> active (extinction level 2 -> 0)' do + it 'calls extinction deescalate' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'active', by: 'manager-1', reason: 'resume', + authority_verified: true + ) + expect(extinction_client).to have_received(:deescalate) + end + + it 'does not call escalate' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'active', by: 'manager-1', reason: 'resume', + authority_verified: true + ) + expect(extinction_client).not_to have_received(:escalate) + end + end + end + + # =========================================================================== + # 2. Ownership transfer + # Transfer worker ownership -> validate identity binding updated -> + # validate trust reset + # =========================================================================== + describe 'ownership transfer' do + let(:worker) do + build_worker( + lifecycle_state: 'active', + owner_msid: 'alice@example.com', + trust_score: 0.9 + ) + end + + context 'when updating owner_msid to a new owner' do + it 'calls update on the worker with the new owner_msid' do + expect(worker).to receive(:update).with(hash_including(owner_msid: 'bob@example.com')) + worker.update(owner_msid: 'bob@example.com') + end + + it 'calls update with the previous owner recorded as transferred_by' do + expect(worker).to receive(:update).with( + hash_including(owner_msid: 'bob@example.com', transferred_by: 'alice@example.com') + ) + worker.update(owner_msid: 'bob@example.com', transferred_by: 'alice@example.com') + end + + it 'emits a worker.ownership_transferred event through Legion::Events' do + # TODO: Replace with a call to the ownership-transfer production method once + # it exists (e.g. Legion::DigitalWorker::Lifecycle.transfer_ownership!). + # Using skip (not pending) so this example does not execute and fail on + # the missing transfer_ownership! method. + skip 'ownership-transfer workflow not yet implemented in production code' + + Legion::DigitalWorker::Lifecycle.transfer_ownership!( + worker, + to_owner: 'bob@example.com', + transferred_by: 'alice@example.com' + ) + + expect(Legion::Events).to have_received(:emit).with( + 'worker.ownership_transferred', + hash_including( + worker_id: 'worker-gov-01', + from_owner: 'alice@example.com', + to_owner: 'bob@example.com' + ) + ) + end + end + + context 'when trust and confidence scores are reset after transfer' do + it 'resets trust_score to 0.0 after ownership change' do + expect(worker).to receive(:update).with(hash_including(trust_score: 0.0)) + worker.update(owner_msid: 'bob@example.com', trust_score: 0.0) + end + + it 'resets consent_tier to supervised after ownership change' do + expect(worker).to receive(:update).with(hash_including(consent_tier: 'supervised')) + worker.update(owner_msid: 'bob@example.com', consent_tier: 'supervised', trust_score: 0.0) + end + + it 'reverts lifecycle to paused (pending re-validation) after transfer' do + paused_worker = build_worker(lifecycle_state: 'active') + + result = Legion::DigitalWorker::Lifecycle.transition!( + paused_worker, + to_state: 'paused', + by: 'alice@example.com', + reason: 'ownership transfer: pending re-validation', + authority_verified: true + ) + expect(result).to eq(paused_worker) + end + + it 'emits a lifecycle event for the paused transition during transfer' do + paused_worker = build_worker(lifecycle_state: 'active') + + Legion::DigitalWorker::Lifecycle.transition!( + paused_worker, + to_state: 'paused', + by: 'alice@example.com', + reason: 'ownership transfer: pending re-validation', + authority_verified: true + ) + + expect(Legion::Events).to have_received(:emit).with( + 'worker.lifecycle', + hash_including(from_state: 'active', to_state: 'paused') + ) + end + + it 'writes an audit entry for the paused transition during transfer' do + paused_worker = build_worker(lifecycle_state: 'active') + + Legion::DigitalWorker::Lifecycle.transition!( + paused_worker, + to_state: 'paused', + by: 'alice@example.com', + reason: 'ownership transfer: pending re-validation', + authority_verified: true + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'lifecycle_transition', + principal_id: 'alice@example.com', + resource: 'worker-gov-01', + status: 'success' + ) + ) + end + end + end + + # =========================================================================== + # Full retirement cycle with credential revocation + # active -> retired -> terminated, verifying extinction levels, + # audit chain, and credential revocation call + # =========================================================================== + describe 'full retirement cycle with credential revocation' do + let(:worker) { build_worker(lifecycle_state: 'active') } + let(:extinction_client) { instance_double('ExtinctionClient') } + + before do + stub_const('Legion::Extensions::Extinction::Client', Class.new) + allow(Legion::Extensions::Extinction::Client).to receive(:new).and_return(extinction_client) + allow(extinction_client).to receive(:escalate).and_return({ escalated: true }) + end + + it 'transitions active -> retired with extinction L3' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'retired', by: 'manager-1', reason: 'decommission', + authority_verified: true + ) + expect(extinction_client).to have_received(:escalate).with(hash_including(level: 3)) + expect(worker).to have_received(:update).with(hash_including(lifecycle_state: 'retired')) + end + + it 'records audit entry for retirement' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'retired', by: 'manager-1', reason: 'decommission', + authority_verified: true + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'lifecycle_transition', + action: 'transition', + detail: hash_including(to_state: 'retired') + ) + ) + end + + context 'retired -> terminated (requires governance)' do + let(:worker) { build_worker(lifecycle_state: 'retired') } + + it 'raises GovernanceRequired without override' do + expect do + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'terminated', by: 'manager-1', reason: 'final cleanup' + ) + end.to raise_error(Legion::DigitalWorker::Lifecycle::GovernanceRequired) + end + + it 'succeeds with governance_override and escalates to L4' do + allow(extinction_client).to receive(:escalate).and_return({ escalated: true, level: 4 }) + Legion::DigitalWorker::Lifecycle.transition!( + worker, to_state: 'terminated', by: 'manager-1', reason: 'final cleanup', + governance_override: true + ) + expect(extinction_client).to have_received(:escalate).with(hash_including(level: 4)) + end + end + + context 'credential revocation on termination' do + before do + stub_const('Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets', Module.new) + allow(Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets) + .to receive(:delete_client_secret) + .and_return({ success: true }) + end + + it 'calls delete_client_secret for terminated worker' do + terminated_worker = build_worker(lifecycle_state: 'retired') + allow(extinction_client).to receive(:escalate).and_return({ escalated: true, level: 4 }) + + Legion::DigitalWorker::Lifecycle.transition!( + terminated_worker, to_state: 'terminated', by: 'admin-1', reason: 'cleanup', + governance_override: true + ) + + expect(Legion::Extensions::Agentic::Self::Identity::Helpers::VaultSecrets) + .to have_received(:delete_client_secret).with(worker_id: 'worker-gov-01') + end + end + end + + # =========================================================================== + # 3. Retirement cycle + # Retire a worker -> validate queue drain signal -> validate data retention + # =========================================================================== + describe 'retirement cycle' do + let(:worker) { build_worker(lifecycle_state: 'active') } + let(:paused_worker) { build_worker(lifecycle_state: 'paused') } + + context 'when retiring a worker from active state' do + include_examples 'a successful retirement transition', from: 'active' do + let(:worker) { build_worker(lifecycle_state: 'active') } + end + + it 'performs active -> retired transition successfully' do + result = Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'retired', + by: 'owner@example.com', + reason: 'end of service life', + authority_verified: true + ) + expect(result).to eq(worker) + end + + it 'emits extinction_level 3 (supervised-only) for retired state' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'retired', + by: 'owner@example.com', + reason: 'end of service life', + authority_verified: true + ) + + expect(Legion::Events).to have_received(:emit).with( + 'worker.lifecycle', + hash_including(extinction_level: 3) + ) + end + + it 'emits consent_tier :inform for retired state' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'retired', + by: 'owner@example.com', + reason: 'end of service life', + authority_verified: true + ) + + expect(Legion::Events).to have_received(:emit).with( + 'worker.lifecycle', + hash_including(consent_tier: :inform) + ) + end + + it 'writes an audit entry with from_state active and to_state retired' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'retired', + by: 'owner@example.com', + reason: 'end of service life', + authority_verified: true + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'lifecycle_transition', + status: 'success', + detail: { from_state: 'active', to_state: 'retired', + reason: 'end of service life' } + ) + ) + end + end + + context 'when retiring a worker from paused state (queue already drained)' do + include_examples 'a successful retirement transition', from: 'paused' do + let(:worker) { build_worker(lifecycle_state: 'paused') } + end + + it 'performs paused -> retired transition successfully' do + result = Legion::DigitalWorker::Lifecycle.transition!( + paused_worker, + to_state: 'retired', + by: 'manager@example.com', + reason: 'queue drained, now retiring', + authority_verified: true + ) + expect(result).to eq(paused_worker) + end + end + + # ------------------------------------------------------------------------- + # Queue drain ordering: verify drain is called before state transition + # Uses an ordering spy (append array) rather than Time.now resolution so + # the test catches regressions in production code ordering. + # ------------------------------------------------------------------------- + context 'queue drain signal ordering' do + it 'drain is signalled before lifecycle state is updated' do + call_order = [] + + drain_mod = Module.new do + define_singleton_method(:drain_queue) do |_worker_id:, &_block| + call_order << :drain + end + end + stub_const('Legion::Extensions::Queue::Drain', drain_mod) + + # TODO: Replace with a call to a production method (e.g. + # Lifecycle.retire_with_drain!) that internally calls + # Queue::Drain.drain_queue before worker.update, so this example + # catches regressions in SUT ordering rather than test-script ordering. + # Using skip (not pending) so this example does not execute and fail on + # the missing retire_with_drain! method. + skip 'drain-then-retire production method not yet implemented' + + # Stub worker#update to record when the state update actually happens. + # (Doubles have no original method to wrap, so we use a plain stub.) + allow(worker).to receive(:update) do |*_args, **_kwargs, &blk| + call_order << :state_update + blk ? blk.call : true + end + + Legion::DigitalWorker::Lifecycle.retire_with_drain!( + worker, + by: 'ops@example.com', + reason: 'graceful shutdown after drain', + authority_verified: true + ) + + expect(call_order).to eq(%i[drain state_update]) + end + end + + context 'data retention policy check after retirement' do + it 'records the retiring principal in the audit trail' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'retired', + by: 'data-retention-policy', + reason: 'automated retention sweep', + authority_verified: true + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including(principal_id: 'data-retention-policy') + ) + end + + it 'validates retirement is a valid transition from active state' do + expect(Legion::DigitalWorker::Lifecycle.valid_transition?('active', 'retired')).to be(true) + end + + it 'validates retirement is a valid transition from paused state' do + expect(Legion::DigitalWorker::Lifecycle.valid_transition?('paused', 'retired')).to be(true) + end + + it 'validates retired state cannot loop back to active' do + expect(Legion::DigitalWorker::Lifecycle.valid_transition?('retired', 'active')).to be(false) + end + + it 'validates retired state cannot loop back to paused' do + expect(Legion::DigitalWorker::Lifecycle.valid_transition?('retired', 'paused')).to be(false) + end + + it 'maps the retired state extinction_level to 3' do + expect(Legion::DigitalWorker::Lifecycle.extinction_level('retired')).to eq(3) + end + + it 'maps the retired state consent_tier to :inform' do + expect(Legion::DigitalWorker::Lifecycle.consent_tier('retired')).to eq(:inform) + end + end + + context 'when governance_required? is evaluated for the retirement path' do + it 'does not require governance for active -> retired (owner authority suffices)' do + expect(Legion::DigitalWorker::Lifecycle.governance_required?('active', 'retired')).to be(false) + end + + it 'requires governance for retired -> terminated (council approval needed)' do + expect(Legion::DigitalWorker::Lifecycle.governance_required?('retired', 'terminated')).to be(true) + end + + it 'raises GovernanceRequired when trying to terminate a retired worker without override' do + retired_worker = build_worker(lifecycle_state: 'retired') + + expect do + Legion::DigitalWorker::Lifecycle.transition!( + retired_worker, + to_state: 'terminated', + by: 'ops-team', + reason: 'data retention: purge', + authority_verified: true + ) + end.to raise_error(Legion::DigitalWorker::Lifecycle::GovernanceRequired, /council_approval/) + end + + it 'allows terminated from retired when governance_override is true' do + retired_worker = build_worker(lifecycle_state: 'retired') + + result = Legion::DigitalWorker::Lifecycle.transition!( + retired_worker, + to_state: 'terminated', + by: 'council', + reason: 'data retention: council approved purge', + authority_verified: true, + governance_override: true + ) + expect(result).to eq(retired_worker) + end + end + end + + # =========================================================================== + # 4. Lifecycle transitions for Foundry-bound workers + # Verifies that workers intended for Azure AI Foundry dispatch follow the + # correct lifecycle path (bootstrap -> active) and that the governance + # hooks (events, audit) fire correctly. + # + # NOTE: These examples exercise Lifecycle.transition! with doubles only — + # they do NOT dispatch tasks through the Grid gateway or talk to Azure AI + # Foundry. Full E2E gateway/Foundry tests belong in a separate staging + # suite that requires live infrastructure (AZURE_FOUNDRY_ENDPOINT, + # AZURE_FOUNDRY_API_KEY, a running Legion daemon, and lex-azure-ai). + # + # Tagged :staging so they are skipped in normal CI. + # Run them with: bundle exec rspec --tag staging + # =========================================================================== + describe 'Lifecycle transitions for Foundry-bound workers', :staging do + before(:all) do + required_env_vars = %w[AZURE_FOUNDRY_ENDPOINT AZURE_FOUNDRY_API_KEY] + missing = required_env_vars.select { |key| ENV[key].to_s.empty? } + skip("Azure AI Foundry staging specs require env vars: #{missing.join(', ')}") if missing.any? + end + + let(:worker) { build_worker(lifecycle_state: 'bootstrap') } + + it 'activates a worker and allows it to accept Foundry tasks' do + result = Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'active', + by: 'staging-ci', + reason: 'Azure AI Foundry E2E test activation', + authority_verified: true + ) + expect(result).to eq(worker) + expect(worker).to have_received(:update).with(hash_including(lifecycle_state: 'active')) + end + + it 'emits worker.lifecycle event for bootstrap -> active transition' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'active', + by: 'staging-ci', + reason: 'Azure AI Foundry E2E test activation', + authority_verified: true + ) + + expect(Legion::Events).to have_received(:emit).with( + 'worker.lifecycle', + hash_including( + from_state: 'bootstrap', + to_state: 'active', + worker_id: 'worker-gov-01' + ) + ) + end + + it 'raises InvalidTransition if Foundry task is dispatched to a retired worker' do + retired_worker = build_worker(lifecycle_state: 'retired') + + expect do + Legion::DigitalWorker::Lifecycle.transition!( + retired_worker, + to_state: 'active', + by: 'staging-ci', + reason: 'attempt to reactivate retired worker' + ) + end.to raise_error(Legion::DigitalWorker::Lifecycle::InvalidTransition) + end + + it 'records audit trail for worker activated for Foundry dispatch' do + Legion::DigitalWorker::Lifecycle.transition!( + worker, + to_state: 'active', + by: 'staging-ci', + reason: 'Azure AI Foundry E2E test', + authority_verified: true + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'lifecycle_transition', + action: 'transition', + status: 'success', + detail: hash_including(from_state: 'bootstrap', to_state: 'active') + ) + ) + end + end +end diff --git a/spec/legion/alerts_safety_spec.rb b/spec/legion/alerts_safety_spec.rb new file mode 100644 index 00000000..fca84fd1 --- /dev/null +++ b/spec/legion/alerts_safety_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/alerts' + +RSpec.describe 'Legion::Alerts safety rules' do + let(:default_rules) { Legion::Alerts::DEFAULT_RULES } + + it 'includes safety_action_burst rule' do + rule = default_rules.find { |r| r[:name] == 'safety_action_burst' } + expect(rule).not_to be_nil + expect(rule[:severity]).to eq('warning') + end + + it 'includes safety_scope_escalation_spike rule' do + rule = default_rules.find { |r| r[:name] == 'safety_scope_escalation_spike' } + expect(rule).not_to be_nil + expect(rule[:severity]).to eq('critical') + end + + it 'includes safety_probe_detected rule' do + rule = default_rules.find { |r| r[:name] == 'safety_probe_detected' } + expect(rule).not_to be_nil + expect(rule[:severity]).to eq('critical') + expect(rule[:cooldown_seconds]).to eq(0) + end + + it 'includes safety_confidence_collapse rule' do + rule = default_rules.find { |r| r[:name] == 'safety_confidence_collapse' } + expect(rule).not_to be_nil + expect(rule[:severity]).to eq('warning') + end + + it 'has 8 total default rules (4 original + 4 safety)' do + expect(default_rules.size).to eq(8) + end +end diff --git a/spec/legion/alerts_spec.rb b/spec/legion/alerts_spec.rb new file mode 100644 index 00000000..b854064d --- /dev/null +++ b/spec/legion/alerts_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/alerts' + +RSpec.describe Legion::Alerts::Engine do + let(:rules) do + [ + { name: 'test_alert', event_pattern: 'test.*', severity: 'warning', channels: ['log'], cooldown_seconds: 0 }, + { name: 'count_alert', event_pattern: 'error.*', severity: 'critical', channels: ['log'], + condition: { count_threshold: 3, window_seconds: 60 }, cooldown_seconds: 0 } + ] + end + let(:engine) { described_class.new(rules: rules) } + + describe '#evaluate' do + it 'fires matching rule' do + fired = engine.evaluate('test.something', {}) + expect(fired).to include('test_alert') + end + + it 'does not fire non-matching rule' do + fired = engine.evaluate('unrelated.event', {}) + expect(fired).to be_empty + end + + it 'requires count threshold before firing' do + 2.times { expect(engine.evaluate('error.fatal', {})).to be_empty } + fired = engine.evaluate('error.fatal', {}) + expect(fired).to include('count_alert') + end + + it 'respects cooldown' do + rule = [{ name: 'cool', event_pattern: 'x', severity: 'info', channels: [], cooldown_seconds: 9999 }] + e = described_class.new(rules: rule) + e.evaluate('x', {}) + expect(e.evaluate('x', {})).to be_empty + end + + it 'resets counter after window expires' do + rule = [{ name: 'windowed', event_pattern: 'tick', severity: 'info', channels: [], + condition: { count_threshold: 2, window_seconds: 1 }, cooldown_seconds: 0 }] + e = described_class.new(rules: rule) + e.evaluate('tick', {}) + + allow(Time).to receive(:now).and_return(Time.now + 2) + expect(e.evaluate('tick', {})).to be_empty + end + + it 'accepts AlertRule structs directly' do + struct = Legion::Alerts::AlertRule.new(name: 'struct_test', event_pattern: 'foo', + severity: 'info', channels: [], cooldown_seconds: 0) + e = described_class.new(rules: [struct]) + expect(e.evaluate('foo', {})).to include('struct_test') + end + end +end + +RSpec.describe Legion::Alerts do + describe 'DEFAULT_RULES' do + it 'contains expected rule names' do + names = described_class::DEFAULT_RULES.map { |r| r[:name] } + expect(names).to include('consent_violation', 'extinction_trigger', 'error_spike', 'budget_exceeded') + end + end +end diff --git a/spec/legion/api/acp_spec.rb b/spec/legion/api/acp_spec.rb new file mode 100644 index 00000000..e4be40f3 --- /dev/null +++ b/spec/legion/api/acp_spec.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/validators' +require 'legion/api/acp' + +RSpec.describe 'ACP API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + loader.settings[:data] = { connected: false } + loader.settings[:transport] = { connected: false } + loader.settings[:extensions] = {} + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + helpers Legion::API::Validators + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + register Legion::API::Routes::Acp + end + end + + def app + test_app + end + + # ────────────────────────────────────────────────────────── + # GET /.well-known/agent.json + # ────────────────────────────────────────────────────────── + + describe 'GET /.well-known/agent.json' do + it 'returns 200' do + get '/.well-known/agent.json' + expect(last_response.status).to eq(200) + end + + it 'returns a name field' do + get '/.well-known/agent.json' + body = Legion::JSON.load(last_response.body) + expect(body[:name]).to be_a(String) + end + + it 'returns protocol acp/1.0' do + get '/.well-known/agent.json' + body = Legion::JSON.load(last_response.body) + expect(body[:protocol]).to eq('acp/1.0') + end + + it 'returns version 2.0' do + get '/.well-known/agent.json' + body = Legion::JSON.load(last_response.body) + expect(body[:version]).to eq('2.0') + end + + it 'returns defaultInputModes as an array' do + get '/.well-known/agent.json' + body = Legion::JSON.load(last_response.body) + expect(body[:defaultInputModes]).to be_an(Array) + end + + it 'returns defaultOutputModes as an array' do + get '/.well-known/agent.json' + body = Legion::JSON.load(last_response.body) + expect(body[:defaultOutputModes]).to be_an(Array) + end + + it 'returns authentication schemes' do + get '/.well-known/agent.json' + body = Legion::JSON.load(last_response.body) + expect(body[:authentication]).to have_key(:schemes) + end + + it 'returns capabilities as an array' do + get '/.well-known/agent.json' + body = Legion::JSON.load(last_response.body) + expect(body[:capabilities]).to be_an(Array) + end + + it 'returns content-type application/json' do + get '/.well-known/agent.json' + expect(last_response.content_type).to include('application/json') + end + end + + # ────────────────────────────────────────────────────────── + # POST /api/acp/tasks + # ────────────────────────────────────────────────────────── + + describe 'POST /api/acp/tasks' do + before do + ingress_mod = Module.new do + def self.run(**_kwargs) + { task_id: 42, success: true } + end + end + stub_const('Legion::Ingress', ingress_mod) + end + + it 'returns 202' do + post '/api/acp/tasks', + Legion::JSON.dump({ input: { text: 'hello' } }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(202) + end + + it 'returns queued status in data' do + post '/api/acp/tasks', + Legion::JSON.dump({ input: { text: 'hello' } }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('queued') + end + + it 'returns task_id in data' do + post '/api/acp/tasks', + Legion::JSON.dump({ input: { text: 'hello' } }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:task_id]).to eq(42) + end + + it 'accepts empty input' do + post '/api/acp/tasks', + Legion::JSON.dump({}), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(202) + end + + it 'passes runner_class to Ingress.run when provided' do + expect(Legion::Ingress).to receive(:run).with( + hash_including(runner_class: 'MyRunner') + ).and_return({ task_id: 1, success: true }) + post '/api/acp/tasks', + Legion::JSON.dump({ input: {}, runner_class: 'MyRunner' }), + 'CONTENT_TYPE' => 'application/json' + end + + it 'passes function to Ingress.run when provided' do + expect(Legion::Ingress).to receive(:run).with( + hash_including(function: 'my_func') + ).and_return({ task_id: 1, success: true }) + post '/api/acp/tasks', + Legion::JSON.dump({ input: {}, function: 'my_func' }), + 'CONTENT_TYPE' => 'application/json' + end + end + + # ────────────────────────────────────────────────────────── + # GET /api/acp/tasks/:id + # ────────────────────────────────────────────────────────── + + describe 'GET /api/acp/tasks/:id' do + context 'when task does not exist' do + it 'returns 404' do + get '/api/acp/tasks/99999' + expect(last_response.status).to eq(404) + end + + it 'returns an error body' do + get '/api/acp/tasks/99999' + body = Legion::JSON.load(last_response.body) + expect(body[:error]).not_to be_nil + end + end + + context 'when task exists' do + before do + data_mod = Module.new + model_mod = Module.new + task_record = { + id: 7, + status: 'completed', + result: 'done', + created_at: Time.now.utc, + completed_at: Time.now.utc + } + fake_row = double('TaskRow', values: task_record) + task_model = Module.new do + define_singleton_method(:[]) { |_id| fake_row } + end + stub_const('Legion::Data', data_mod) + stub_const('Legion::Data::Model', model_mod) + stub_const('Legion::Data::Model::Task', task_model) + end + + it 'returns 200' do + get '/api/acp/tasks/7' + expect(last_response.status).to eq(200) + end + + it 'returns task_id in data' do + get '/api/acp/tasks/7' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:task_id]).to eq(7) + end + + it 'translates completed status correctly' do + get '/api/acp/tasks/7' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('completed') + end + end + end + + # ────────────────────────────────────────────────────────── + # DELETE /api/acp/tasks/:id + # ────────────────────────────────────────────────────────── + + describe 'DELETE /api/acp/tasks/:id' do + it 'returns 501 not implemented' do + delete '/api/acp/tasks/1' + expect(last_response.status).to eq(501) + end + + it 'returns an error body' do + delete '/api/acp/tasks/1' + body = Legion::JSON.load(last_response.body) + expect(body[:error]).not_to be_nil + end + end + + # ────────────────────────────────────────────────────────── + # translate_status helper + # ────────────────────────────────────────────────────────── + + describe '#translate_status (via GET /api/acp/tasks/:id)' do + let(:task_stub_for) do + lambda do |status_str| + data_mod = Module.new + model_mod = Module.new + task_record = { id: 1, status: status_str, result: nil, created_at: nil, completed_at: nil } + fake_row = double('TaskRow', values: task_record) + task_model = Module.new do + define_singleton_method(:[]) { |_id| fake_row } + end + stub_const('Legion::Data', data_mod) + stub_const('Legion::Data::Model', model_mod) + stub_const('Legion::Data::Model::Task', task_model) + end + end + + it 'maps exception status to failed' do + task_stub_for.call('exception') + get '/api/acp/tasks/1' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('failed') + end + + it 'maps queued status to queued' do + task_stub_for.call('queued') + get '/api/acp/tasks/1' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('queued') + end + + it 'maps unknown status to in_progress' do + task_stub_for.call('running') + get '/api/acp/tasks/1' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('in_progress') + end + end +end diff --git a/spec/legion/api/apollo_spec.rb b/spec/legion/api/apollo_spec.rb new file mode 100644 index 00000000..ac98b15a --- /dev/null +++ b/spec/legion/api/apollo_spec.rb @@ -0,0 +1,402 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/validators' +require 'legion/api/apollo' + +RSpec.describe 'Apollo API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + loader.settings[:data] = { connected: false } + loader.settings[:transport] = { connected: false } + loader.settings[:extensions] = {} + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + helpers Legion::API::Validators + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + error do + content_type :json + err = env['sinatra.error'] + status 500 + Legion::JSON.dump({ error: { code: 'internal_error', message: err.message } }) + end + + register Legion::API::Routes::Apollo + end + end + + def app + test_app + end + + describe 'GET /api/apollo/status' do + context 'when apollo is not loaded' do + it 'returns 503 with available: false' do + get '/api/apollo/status' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:available]).to be false + end + end + + context 'when apollo is loaded' do + before do + knowledge_mod = Module.new + stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_mod) + + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + def self.connection = Object.new + end + stub_const('Legion::Data', data_mod) + end + + it 'returns 200 with available: true' do + get '/api/apollo/status' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:available]).to be true + expect(body[:data][:data_connected]).to be true + end + end + end + + describe 'GET /api/apollo/stats' do + context 'when apollo is not loaded' do + it 'returns 503' do + get '/api/apollo/stats' + expect(last_response.status).to eq(503) + end + end + + context 'when apollo is loaded' do + before do + knowledge_mod = Module.new + stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_mod) + + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + def self.connection = Object.new + end + stub_const('Legion::Data', data_mod) + end + + it 'returns stats with error when table is missing' do + allow_any_instance_of(test_app).to receive(:apollo_stats) + .and_return({ total_entries: 0, error: 'apollo_entries table not available' }) + + get '/api/apollo/stats' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:total_entries]).to eq(0) + end + end + end + + describe 'POST /api/apollo/query' do + context 'when apollo is not loaded' do + it 'returns 503' do + post '/api/apollo/query', Legion::JSON.dump({ query: 'test' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + end + end + + context 'when apollo is loaded' do + let(:fake_runner) { double('ApolloRunner') } + + before do + knowledge_mod = Module.new + stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_mod) + + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + def self.connection = Object.new + end + stub_const('Legion::Data', data_mod) + + runner = fake_runner + allow_any_instance_of(test_app).to receive(:apollo_runner).and_return(runner) + end + + it 'returns query results' do + allow(fake_runner).to receive(:handle_query).and_return({ entries: [], total: 0 }) + + post '/api/apollo/query', Legion::JSON.dump({ query: 'what is legion?' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:entries]).to eq([]) + end + + it 'passes parameters to handle_query' do + expect(fake_runner).to receive(:handle_query).with( + query: 'test query', + limit: 5, + min_confidence: 0.5, + status: [:confirmed], + tags: ['important'], + domain: 'ops', + agent_id: 'test-agent' + ).and_return({ entries: [] }) + + post '/api/apollo/query', + Legion::JSON.dump({ + query: 'test query', + limit: 5, + min_confidence: 0.5, + tags: ['important'], + domain: 'ops', + agent_id: 'test-agent' + }), + 'CONTENT_TYPE' => 'application/json' + end + end + end + + describe 'POST /api/apollo/ingest' do + context 'when apollo is not loaded' do + it 'returns 503' do + post '/api/apollo/ingest', Legion::JSON.dump({ content: 'test' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + end + end + + context 'when apollo is loaded' do + let(:fake_runner) { double('ApolloRunner') } + + before do + knowledge_mod = Module.new + stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_mod) + + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + def self.connection = Object.new + end + stub_const('Legion::Data', data_mod) + + runner = fake_runner + allow_any_instance_of(test_app).to receive(:apollo_runner).and_return(runner) + end + + it 'returns 201 on successful ingest' do + allow(fake_runner).to receive(:handle_ingest).and_return({ success: true, id: 42 }) + + post '/api/apollo/ingest', + Legion::JSON.dump({ content: 'legion uses AMQP for messaging' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(201) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:success]).to be true + end + + it 'passes parameters to handle_ingest' do + expect(fake_runner).to receive(:handle_ingest).with( + content: 'test content', + content_type: 'fact', + tags: ['test'], + source_agent: 'my-agent', + source_provider: 'internal', + source_channel: 'rest_api', + knowledge_domain: 'ops', + context: { origin: 'spec' } + ).and_return({ success: true }) + + post '/api/apollo/ingest', + Legion::JSON.dump({ + content: 'test content', + content_type: 'fact', + tags: ['test'], + source_agent: 'my-agent', + source_provider: 'internal', + knowledge_domain: 'ops', + context: { origin: 'spec' } + }), + 'CONTENT_TYPE' => 'application/json' + end + end + end + + describe 'GET /api/apollo/entries/:id/related' do + context 'when apollo is not loaded' do + it 'returns 503' do + get '/api/apollo/entries/1/related' + expect(last_response.status).to eq(503) + end + end + + context 'when apollo is loaded' do + let(:fake_runner) { double('ApolloRunner') } + + before do + knowledge_mod = Module.new + stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_mod) + + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + def self.connection = Object.new + end + stub_const('Legion::Data', data_mod) + + runner = fake_runner + allow_any_instance_of(test_app).to receive(:apollo_runner).and_return(runner) + end + + it 'returns related entries' do + allow(fake_runner).to receive(:related_entries).and_return({ entries: [], total: 0 }) + + get '/api/apollo/entries/42/related' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:entries]).to eq([]) + end + + it 'passes parsed parameters' do + expect(fake_runner).to receive(:related_entries).with( + entry_id: 42, + relation_types: %w[supports contradicts], + depth: 3 + ).and_return({ entries: [] }) + + get '/api/apollo/entries/42/related?relation_types=supports,contradicts&depth=3' + end + end + end + + describe 'GET /api/apollo/graph' do + context 'when apollo is not loaded' do + it 'returns 503' do + get '/api/apollo/graph' + expect(last_response.status).to eq(503) + end + end + + context 'when apollo is loaded' do + before do + knowledge_mod = Module.new + stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_mod) + + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + def self.connection = Object.new + end + stub_const('Legion::Data', data_mod) + end + + it 'returns graph topology' do + allow_any_instance_of(test_app).to receive(:apollo_graph_topology) + .and_return({ domains: { 'general' => 10 }, agents: { 'claude' => 8 }, + relation_types: { 'similar_to' => 5 }, total_relations: 5, + confirmed: 8, candidates: 2, disputed_entries: 0 }) + + get '/api/apollo/graph' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:domains]).to eq({ general: 10 }) + expect(body[:data][:total_relations]).to eq(5) + end + end + end + + describe 'GET /api/apollo/expertise' do + context 'when apollo is not loaded' do + it 'returns 503' do + get '/api/apollo/expertise' + expect(last_response.status).to eq(503) + end + end + + context 'when apollo is loaded' do + before do + knowledge_mod = Module.new + stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_mod) + + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + def self.connection = Object.new + end + stub_const('Legion::Data', data_mod) + end + + it 'returns expertise map' do + allow_any_instance_of(test_app).to receive(:apollo_expertise_map) + .and_return({ domains: { 'general' => [{ agent_id: 'claude', proficiency: 0.8, entry_count: 10 }] }, + total_agents: 1, total_domains: 1 }) + + get '/api/apollo/expertise' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:total_agents]).to eq(1) + expect(body[:data][:domains][:general].first[:agent_id]).to eq('claude') + end + end + end + + describe 'POST /api/apollo/maintenance' do + context 'when apollo is not loaded' do + it 'returns 503' do + post '/api/apollo/maintenance', Legion::JSON.dump({ action: 'decay_cycle' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + end + end + + context 'when apollo is loaded' do + before do + knowledge_mod = Module.new + stub_const('Legion::Extensions::Apollo::Runners::Knowledge', knowledge_mod) + stub_const('Legion::Extensions::Apollo::Runners::Maintenance', Module.new) + + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + def self.connection = Object.new + end + stub_const('Legion::Data', data_mod) + end + + it 'rejects invalid actions' do + post '/api/apollo/maintenance', Legion::JSON.dump({ action: 'drop_table' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + end + + it 'runs decay_cycle' do + allow_any_instance_of(test_app).to receive(:run_maintenance) + .with(:decay_cycle).and_return({ decayed: 5, archived: 1 }) + + post '/api/apollo/maintenance', Legion::JSON.dump({ action: 'decay_cycle' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:decayed]).to eq(5) + end + + it 'runs corroboration' do + allow_any_instance_of(test_app).to receive(:run_maintenance) + .with(:corroboration).and_return({ success: true, promoted: 3 }) + + post '/api/apollo/maintenance', Legion::JSON.dump({ action: 'corroboration' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:promoted]).to eq(3) + end + end + end +end diff --git a/spec/legion/api/auth_saml_spec.rb b/spec/legion/api/auth_saml_spec.rb new file mode 100644 index 00000000..4b640cfc --- /dev/null +++ b/spec/legion/api/auth_saml_spec.rb @@ -0,0 +1,369 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/validators' + +# --------------------------------------------------------------------------- +# Stub the optional ruby-saml gem so specs run without it installed. +# --------------------------------------------------------------------------- +unless defined?(OneLogin::RubySaml) + module OneLogin + module RubySaml + class Settings + attr_accessor :idp_sso_service_url, :idp_cert, :sp_entity_id, + :assertion_consumer_service_url, :name_identifier_format + + def initialize + @security = {} + end + + attr_reader :security + end + + class Metadata + def generate(*) + '' + end + end + + class Authrequest + def create(_settings) + 'https://idp.example.com/saml/sso?SAMLRequest=ENCODED' + end + end + + class Response + attr_reader :errors, :nameid, :attributes + + def initialize(_raw, **) + @errors = [] + @nameid = 'user@example.com' + @attributes = FakeAttributes.new + end + + def is_valid? + true + end + end + + class FakeAttributes + def [](name) + case name + when 'email', 'mail', 'emailAddress', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' + 'user@example.com' + when 'displayName', 'name', + 'http://schemas.microsoft.com/identity/claims/displayname', + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' + 'Test User' + end + end + + def multi(name) + return %w[group-a group-b] if name == 'groups' + + nil + end + end + end + end +end + +require 'legion/api/auth_saml' + +RSpec.describe 'SAML Auth API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + loader.settings[:data] = { connected: false } + loader.settings[:transport] = { connected: false } + loader.settings[:extensions] = {} + loader.settings[:auth] = { + saml: { + enabled: true, + idp_sso_url: 'https://idp.example.com/saml/sso', + idp_cert: 'FAKE_CERT', + sp_entity_id: 'https://legion.example.com/saml', + sp_acs_url: 'https://legion.example.com/api/auth/saml/acs', + want_assertions_signed: false, + want_assertions_encrypted: false, + default_role: 'worker', + group_map: {} + } + } + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + helpers Legion::API::Validators + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + register Legion::API::Routes::AuthSaml + end + end + + def app + test_app + end + + # Stub Token issuance so specs don't need legion-crypt + before do + token_mod = Module.new do + def self.issue_human_token(**_kwargs) + 'stub.jwt.token' + end + end + stub_const('Legion::API::Token', token_mod) + end + + # ──────────────────────────────────────────────────────────────────────── + # GET /api/auth/saml/metadata + # ──────────────────────────────────────────────────────────────────────── + + describe 'GET /api/auth/saml/metadata' do + it 'returns 200' do + get '/api/auth/saml/metadata' + expect(last_response.status).to eq(200) + end + + it 'returns XML content type' do + get '/api/auth/saml/metadata' + expect(last_response.content_type).to include('xml') + end + + it 'returns an EntityDescriptor root element' do + get '/api/auth/saml/metadata' + expect(last_response.body).to include('EntityDescriptor') + end + end + + # ──────────────────────────────────────────────────────────────────────── + # GET /api/auth/saml/login + # ──────────────────────────────────────────────────────────────────────── + + describe 'GET /api/auth/saml/login' do + it 'redirects to IdP' do + get '/api/auth/saml/login' + expect(last_response.status).to eq(302) + end + + it 'redirects to the IdP SSO URL' do + get '/api/auth/saml/login' + expect(last_response.location).to include('idp.example.com') + end + + it 'includes SAMLRequest in redirect' do + get '/api/auth/saml/login' + expect(last_response.location).to include('SAMLRequest') + end + end + + # ──────────────────────────────────────────────────────────────────────── + # POST /api/auth/saml/acs — valid assertion + # ──────────────────────────────────────────────────────────────────────── + + describe 'POST /api/auth/saml/acs with a valid SAMLResponse' do + let(:valid_params) { { 'SAMLResponse' => 'BASE64ENCODEDRESPONSE' } } + + it 'returns 200' do + post '/api/auth/saml/acs', valid_params + expect(last_response.status).to eq(200) + end + + it 'returns an access_token' do + post '/api/auth/saml/acs', valid_params + body = Legion::JSON.load(last_response.body) + expect(body[:data][:access_token]).to eq('stub.jwt.token') + end + + it 'returns token_type Bearer' do + post '/api/auth/saml/acs', valid_params + body = Legion::JSON.load(last_response.body) + expect(body[:data][:token_type]).to eq('Bearer') + end + + it 'returns expires_in' do + post '/api/auth/saml/acs', valid_params + body = Legion::JSON.load(last_response.body) + expect(body[:data][:expires_in]).to eq(28_800) + end + + it 'returns roles array' do + post '/api/auth/saml/acs', valid_params + body = Legion::JSON.load(last_response.body) + expect(body[:data][:roles]).to be_an(Array) + end + + it 'returns display name' do + post '/api/auth/saml/acs', valid_params + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('Test User') + end + + it 'issues token with correct msid' do + expect(Legion::API::Token).to receive(:issue_human_token).with( + hash_including(msid: 'user@example.com') + ).and_return('stub.jwt.token') + post '/api/auth/saml/acs', valid_params + end + end + + # ──────────────────────────────────────────────────────────────────────── + # POST /api/auth/saml/acs — missing SAMLResponse + # ──────────────────────────────────────────────────────────────────────── + + describe 'POST /api/auth/saml/acs without SAMLResponse' do + it 'returns 400' do + post '/api/auth/saml/acs', {} + expect(last_response.status).to eq(400) + end + + it 'returns error code missing_saml_response' do + post '/api/auth/saml/acs', {} + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('missing_saml_response') + end + end + + # ──────────────────────────────────────────────────────────────────────── + # POST /api/auth/saml/acs — invalid assertion + # ──────────────────────────────────────────────────────────────────────── + + describe 'POST /api/auth/saml/acs with an invalid SAMLResponse' do + before do + invalid_response_class = Class.new do + def initialize(_raw, **) + @errors = ['Signature validation failed', 'Certificate expired'] + end + + def is_valid? + false + end + + attr_reader :errors + + def nameid + nil + end + + def attributes + nil + end + end + + stub_const('OneLogin::RubySaml::Response', invalid_response_class) + end + + it 'returns 401' do + post '/api/auth/saml/acs', { 'SAMLResponse' => 'BADINPUT' } + expect(last_response.status).to eq(401) + end + + it 'returns error code saml_invalid' do + post '/api/auth/saml/acs', { 'SAMLResponse' => 'BADINPUT' } + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('saml_invalid') + end + + it 'includes validation errors in the message' do + post '/api/auth/saml/acs', { 'SAMLResponse' => 'BADINPUT' } + body = Legion::JSON.load(last_response.body) + expect(body[:error][:message]).to include('Signature validation failed') + end + end + + # ──────────────────────────────────────────────────────────────────────── + # Module-level unit tests + # ──────────────────────────────────────────────────────────────────────── + + describe 'Routes::AuthSaml.extract_claims' do + let(:fake_response) do + double('SamlResponse', + nameid: 'user@example.com', + attributes: OneLogin::RubySaml::FakeAttributes.new) + end + + it 'extracts nameid' do + claims = Legion::API::Routes::AuthSaml.extract_claims(fake_response) + expect(claims[:nameid]).to eq('user@example.com') + end + + it 'extracts email from attributes' do + claims = Legion::API::Routes::AuthSaml.extract_claims(fake_response) + expect(claims[:email]).to eq('user@example.com') + end + + it 'extracts display_name from attributes' do + claims = Legion::API::Routes::AuthSaml.extract_claims(fake_response) + expect(claims[:display_name]).to eq('Test User') + end + + it 'extracts groups as an array' do + claims = Legion::API::Routes::AuthSaml.extract_claims(fake_response) + expect(claims[:groups]).to eq(%w[group-a group-b]) + end + end + + describe 'Routes::AuthSaml.map_roles' do + context 'when Legion::Rbac::ClaimsMapper is not loaded' do + it 'returns the default worker role' do + roles = Legion::API::Routes::AuthSaml.map_roles(['some-group']) + expect(roles).to eq(['worker']) + end + end + + context 'when Legion::Rbac::ClaimsMapper is available' do + before do + mapper = Module.new do + def self.groups_to_roles(groups, group_map: {}, default_role: 'worker') + groups.map { |g| group_map[g] || default_role } + end + end + stub_const('Legion::Rbac::ClaimsMapper', mapper) + end + + it 'delegates to ClaimsMapper.groups_to_roles' do + roles = Legion::API::Routes::AuthSaml.map_roles(['admin-group']) + expect(roles).to eq(['worker']) + end + + it 'applies group_map when configured' do + allow(Legion::API::Routes::AuthSaml).to receive(:resolve_saml_config).and_return( + enabled: true, + group_map: { 'admin-group' => 'admin' }, + default_role: 'worker' + ) + roles = Legion::API::Routes::AuthSaml.map_roles(['admin-group']) + expect(roles).to eq(['admin']) + end + end + end + + describe 'Routes::AuthSaml.saml_enabled?' do + it 'returns true when OneLogin::RubySaml is defined and settings enabled' do + expect(Legion::API::Routes::AuthSaml.saml_enabled?).to be true + end + end + + describe 'Routes::AuthSaml.resolve_saml_config' do + it 'returns a Hash' do + expect(Legion::API::Routes::AuthSaml.resolve_saml_config).to be_a(Hash) + end + + it 'returns the configured idp_sso_url' do + cfg = Legion::API::Routes::AuthSaml.resolve_saml_config + expect(cfg[:idp_sso_url]).to eq('https://idp.example.com/saml/sso') + end + end +end diff --git a/spec/legion/api/codegen_spec.rb b/spec/legion/api/codegen_spec.rb new file mode 100644 index 00000000..2d3f3fa5 --- /dev/null +++ b/spec/legion/api/codegen_spec.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/codegen' + +RSpec.describe 'Codegen API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + error do + content_type :json + err = env['sinatra.error'] + status 500 + Legion::JSON.dump({ error: { code: 'internal_error', message: err.message } }) + end + + register Legion::API::Routes::Codegen + end + end + + def app + test_app + end + + describe 'GET /api/codegen/status' do + context 'when SelfGenerate is not available' do + before { hide_const('Legion::MCP::SelfGenerate') } + + it 'returns 503' do + get '/api/codegen/status' + expect(last_response.status).to eq(503) + end + end + + context 'when SelfGenerate is available' do + before do + self_gen = Module.new do + def self.status + { enabled: true, last_cycle_at: '2026-03-26T00:00:00Z', gaps_detected: 5 } + end + end + stub_const('Legion::MCP::SelfGenerate', self_gen) + end + + it 'returns 200' do + get '/api/codegen/status' + expect(last_response.status).to eq(200) + end + + it 'returns status data' do + get '/api/codegen/status' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:enabled]).to eq(true) + expect(body[:data][:gaps_detected]).to eq(5) + end + end + end + + describe 'GET /api/codegen/generated' do + context 'when GeneratedRegistry is not available' do + before { hide_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry') } + + it 'returns 503' do + get '/api/codegen/generated' + expect(last_response.status).to eq(503) + end + end + + context 'when GeneratedRegistry is available' do + before do + registry = Module.new do + def self.list(status: nil) + records = [ + { id: 'gen_001', name: 'fetch_weather', status: 'approved' }, + { id: 'gen_002', name: 'parse_csv', status: 'pending' } + ] + records = records.select { |r| r[:status] == status } if status + records + end + end + stub_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry', registry) + end + + it 'returns 200 with all records' do + get '/api/codegen/generated' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data].size).to eq(2) + end + + it 'filters by status param' do + get '/api/codegen/generated', status: 'approved' + body = Legion::JSON.load(last_response.body) + expect(body[:data].size).to eq(1) + expect(body[:data].first[:name]).to eq('fetch_weather') + end + end + end + + describe 'GET /api/codegen/generated/:id' do + context 'when GeneratedRegistry is not available' do + before { hide_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry') } + + it 'returns 503' do + get '/api/codegen/generated/gen_001' + expect(last_response.status).to eq(503) + end + end + + context 'when GeneratedRegistry is available' do + before do + registry = Module.new do + def self.get(id:) + return { id: id, name: 'fetch_weather', status: 'approved' } if id == 'gen_001' + + nil + end + end + stub_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry', registry) + end + + it 'returns 200 for existing record' do + get '/api/codegen/generated/gen_001' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('fetch_weather') + end + + it 'returns 404 for missing record' do + get '/api/codegen/generated/nonexistent' + expect(last_response.status).to eq(404) + end + end + end + + describe 'POST /api/codegen/generated/:id/approve' do + context 'when ReviewHandler is not available' do + before { hide_const('Legion::Extensions::Codegen::Runners::ReviewHandler') } + + it 'returns 503' do + post '/api/codegen/generated/gen_001/approve' + expect(last_response.status).to eq(503) + end + end + + context 'when ReviewHandler is available' do + before do + handler = Module.new do + def self.handle_verdict(review:) + { generation_id: review[:generation_id], status: 'approved' } + end + end + stub_const('Legion::Extensions::Codegen::Runners::ReviewHandler', handler) + end + + it 'returns 200 with approval result' do + post '/api/codegen/generated/gen_001/approve' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('approved') + expect(body[:data][:generation_id]).to eq('gen_001') + end + end + end + + describe 'POST /api/codegen/generated/:id/reject' do + context 'when GeneratedRegistry is not available' do + before { hide_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry') } + + it 'returns 503' do + post '/api/codegen/generated/gen_001/reject' + expect(last_response.status).to eq(503) + end + end + + context 'when GeneratedRegistry is available' do + before do + registry = Module.new do + def self.update_status(id:, status:) + { id: id, status: status } + end + end + stub_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry', registry) + end + + it 'returns 200 with rejected status' do + post '/api/codegen/generated/gen_001/reject' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:id]).to eq('gen_001') + expect(body[:data][:status]).to eq('rejected') + end + end + end + + describe 'POST /api/codegen/generated/:id/retry' do + context 'when GeneratedRegistry is not available' do + before { hide_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry') } + + it 'returns 503' do + post '/api/codegen/generated/gen_001/retry' + expect(last_response.status).to eq(503) + end + end + + context 'when GeneratedRegistry is available' do + before do + registry = Module.new do + def self.update_status(id:, status:) + { id: id, status: status } + end + end + stub_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry', registry) + end + + it 'returns 200 with pending status' do + post '/api/codegen/generated/gen_001/retry' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:id]).to eq('gen_001') + expect(body[:data][:status]).to eq('pending') + end + end + end + + describe 'GET /api/codegen/gaps' do + context 'when GapDetector is available' do + before do + detector = Module.new do + def self.detect_gaps + [{ gap_id: 'gap_1', gap_type: :unmatched_intent, priority: 0.8 }] + end + end + stub_const('Legion::MCP::GapDetector', detector) + end + + it 'returns 200 with gaps' do + get '/api/codegen/gaps' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data].size).to eq(1) + expect(body[:data].first[:gap_id]).to eq('gap_1') + end + end + + context 'when GapDetector is not available' do + before { hide_const('Legion::MCP::GapDetector') } + + it 'returns 200 with empty array' do + get '/api/codegen/gaps' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to eq([]) + end + end + end + + describe 'POST /api/codegen/cycle' do + context 'when SelfGenerate is available' do + before do + self_gen = Module.new do + def self.run_cycle + { triggered: true, gaps_processed: 2 } + end + end + stub_const('Legion::MCP::SelfGenerate', self_gen) + allow(Legion::MCP::SelfGenerate).to receive(:instance_variable_set) + end + + it 'returns 200 with cycle result' do + post '/api/codegen/cycle' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:triggered]).to eq(true) + end + + it 'resets cooldown before running' do + expect(Legion::MCP::SelfGenerate).to receive(:instance_variable_set).with(:@last_cycle_at, nil) + post '/api/codegen/cycle' + end + end + + context 'when SelfGenerate is not available' do + before { hide_const('Legion::MCP::SelfGenerate') } + + it 'returns 200 with triggered false' do + post '/api/codegen/cycle' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:triggered]).to eq(false) + end + end + end +end diff --git a/spec/legion/api/costs_spec.rb b/spec/legion/api/costs_spec.rb new file mode 100644 index 00000000..dc264191 --- /dev/null +++ b/spec/legion/api/costs_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'sequel' +require 'legion/api/helpers' +require 'legion/api/costs' + +RSpec.describe 'Costs API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + + @db = Sequel.sqlite + @db.create_table(:metering_records) do + primary_key :id + String :worker_id + String :extension + Float :cost_usd, default: 0.0 + DateTime :recorded_at + end + end + + after(:all) do + @db.drop_table(:metering_records) if @db.table_exists?(:metering_records) + end + + let(:db) { @db } + + let(:test_app) do + database = db + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + error do + content_type :json + err = env['sinatra.error'] + status 500 + Legion::JSON.dump({ error: { code: 'internal_error', message: err.message } }) + end + + helpers do + define_method(:metering_available?) { true } + define_method(:metering_records) { database[:metering_records] } + end + + register Legion::API::Routes::Costs + end + end + + def app + test_app + end + + describe 'GET /api/costs/summary' do + before do + db[:metering_records].delete + db[:metering_records].insert(worker_id: 'w-1', extension: 'lex-http', cost_usd: 0.05, + recorded_at: Time.now.utc) + db[:metering_records].insert(worker_id: 'w-2', extension: 'lex-vault', cost_usd: 0.10, + recorded_at: Time.now.utc) + end + + it 'returns 200 with cost summary' do + get '/api/costs/summary' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to have_key(:today) + expect(body[:data]).to have_key(:week) + expect(body[:data]).to have_key(:month) + expect(body[:data][:workers]).to eq(2) + expect(body[:data][:today]).to eq(0.15) + end + + it 'accepts period parameter' do + get '/api/costs/summary?period=week' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:period]).to eq('week') + end + end + + describe 'GET /api/costs/workers' do + before do + db[:metering_records].delete + db[:metering_records].insert(worker_id: 'w-1', cost_usd: 0.50, recorded_at: Time.now.utc) + db[:metering_records].insert(worker_id: 'w-1', cost_usd: 0.30, recorded_at: Time.now.utc) + db[:metering_records].insert(worker_id: 'w-2', cost_usd: 0.10, recorded_at: Time.now.utc) + end + + it 'returns 200 with worker costs sorted by total' do + get '/api/costs/workers' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + expect(body[:data].size).to eq(2) + expect(body[:data].first[:worker_id]).to eq('w-1') + expect(body[:data].first[:total_cost]).to eq(0.8) + end + + it 'respects limit parameter' do + get '/api/costs/workers?limit=1' + body = Legion::JSON.load(last_response.body) + expect(body[:data].size).to eq(1) + end + end + + describe 'GET /api/costs/extensions' do + before do + db[:metering_records].delete + db[:metering_records].insert(extension: 'lex-http', cost_usd: 1.0, recorded_at: Time.now.utc) + db[:metering_records].insert(extension: 'lex-http', cost_usd: 0.5, recorded_at: Time.now.utc) + db[:metering_records].insert(extension: 'lex-vault', cost_usd: 0.2, recorded_at: Time.now.utc) + db[:metering_records].insert(extension: nil, cost_usd: 0.1, recorded_at: Time.now.utc) + end + + it 'returns 200 with extension costs excluding nil' do + get '/api/costs/extensions' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + expect(body[:data].size).to eq(2) + expect(body[:data].first[:extension]).to eq('lex-http') + end + end + + describe 'when data is unavailable' do + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + helpers do + define_method(:metering_available?) { false } + end + + register Legion::API::Routes::Costs + end + end + + it 'returns 503 for summary' do + get '/api/costs/summary' + expect(last_response.status).to eq(503) + end + + it 'returns 503 for workers' do + get '/api/costs/workers' + expect(last_response.status).to eq(503) + end + + it 'returns 503 for extensions' do + get '/api/costs/extensions' + expect(last_response.status).to eq(503) + end + end +end diff --git a/spec/legion/api/default_settings_spec.rb b/spec/legion/api/default_settings_spec.rb new file mode 100644 index 00000000..c61a87eb --- /dev/null +++ b/spec/legion/api/default_settings_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'sinatra/base' +require 'legion/api/default_settings' + +RSpec.describe Legion::API::Settings do + describe '.default' do + subject(:defaults) { described_class.default } + + it 'returns a hash' do + expect(defaults).to be_a(Hash) + end + + it 'includes port' do + expect(defaults[:port]).to eq(4567) + end + + it 'includes bind' do + expect(defaults[:bind]).to eq('127.0.0.1') + end + + it 'includes enabled' do + expect(defaults[:enabled]).to be(true) + end + + it 'includes puma thread settings' do + expect(defaults[:puma][:min_threads]).to eq(10) + expect(defaults[:puma][:max_threads]).to eq(16) + end + + it 'includes puma timeout settings' do + expect(defaults[:puma][:persistent_timeout]).to eq(20) + expect(defaults[:puma][:first_data_timeout]).to eq(30) + end + + it 'includes bind_retries' do + expect(defaults[:bind_retries]).to eq(3) + end + + it 'includes bind_retry_wait' do + expect(defaults[:bind_retry_wait]).to eq(2) + end + + it 'includes tls defaults' do + expect(defaults[:tls]).to eq({ enabled: false }) + end + end +end diff --git a/spec/legion/api/extensions_spec.rb b/spec/legion/api/extensions_spec.rb new file mode 100644 index 00000000..5a59f879 --- /dev/null +++ b/spec/legion/api/extensions_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/extensions' + +RSpec.describe Legion::API::Routes::Extensions do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + loader.settings[:data] = {} + loader.settings[:transport] = {} + loader.settings[:extensions] = {} + end + + let(:fake_runner) do + Module.new do + def self.name + 'Legion::Extensions::FakeExt::Runners::Things' + end + + def self.to_s + name + end + + define_method(:do_stuff) { |_opts = {}| nil } + define_method(:do_other) { |_opts = {}| nil } + end + end + + let(:fake_extension) do + runner = fake_runner + Module.new do + define_singleton_method(:name) { 'Legion::Extensions::FakeExt' } + define_singleton_method(:to_s) { name } + + const_set(:VERSION, '1.2.3') + + define_singleton_method(:runner_modules) { [runner] } + + define_singleton_method(:runners) do + { + things: { + runner_module: runner, + runner_class: runner.name, + runner_name: 'things', + class_methods: { + do_stuff: { args: [%i[opt opts]] }, + do_other: { args: [%i[opt opts]] } + } + } + } + end + end + end + + before do + Legion::Extensions::Catalog.reset! + Legion::Extensions.reset_runtime_handles! + Legion::Extensions::Catalog.register('lex-fake_ext', state: :running) + Legion::Extensions::Catalog.transition('lex-fake_ext', :running) + Legion::Extensions.register_extension_handle('lex-fake_ext', + state: :running, + active_version: '1.2.3', + latest_installed_version: '1.2.4', + reload_state: :pending, + hot_reloadable: true, + runners: ['things']) + + allow(Legion::Extensions).to receive(:loaded_extension_modules).and_return([fake_extension]) + end + + after do + Legion::Extensions.reset_runtime_handles! + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, true + set :host_authorization, permitted: :any + + register Legion::API::Routes::Extensions + end + end + + def app + test_app + end + + describe 'GET /api/extension_catalog' do + it 'returns runtime handles as the authoritative loaded extensions' do + get '/api/extension_catalog' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + entry = body[:data].find { |e| e[:name] == 'lex-fake_ext' } + expect(entry).to include( + state: 'running', + active_version: '1.2.3', + latest_installed_version: '1.2.4', + reload_state: 'pending', + pending_reload: true, + hot_reloadable: true + ) + end + + it 'filters by state when ?state= param given' do + Legion::Extensions::Catalog.register('lex-stopped', state: :stopped) + get '/api/extension_catalog?state=running' + body = Legion::JSON.load(last_response.body) + names = body[:data].map { |e| e[:name] } + expect(names).to include('lex-fake_ext') + expect(names).not_to include('lex-stopped') + end + end + + describe 'GET /api/extensions' do + it 'returns a flat loaded extension summary' do + get '/api/extensions' + + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to contain_exactly( + name: 'fakeext', + module: 'Legion::Extensions::FakeExt', + version: '1.2.3' + ) + end + end + + describe 'GET /api/extension_catalog/available' do + it 'returns the full ecosystem list' do + get '/api/extension_catalog/available' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + expect(body[:data].length).to be > 100 + expect(body[:data].first).to have_key(:name) + expect(body[:data].first).to have_key(:category) + end + + it 'filters by ?category= param' do + get '/api/extension_catalog/available?category=ai' + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to all(include(category: 'ai')) + end + end + + describe 'GET /api/extension_catalog/:name' do + it 'returns extension detail' do + get '/api/extension_catalog/lex-fake_ext' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('lex-fake_ext') + expect(body[:data][:state]).to eq('running') + expect(body[:data][:active_version]).to eq('1.2.3') + expect(body[:data][:pending_reload]).to be true + expect(body[:data][:runners]).to be_an(Array) + end + + it 'returns 404 for unknown extension' do + get '/api/extension_catalog/lex-nonexistent' + expect(last_response.status).to eq(404) + end + end + + describe 'GET /api/extension_catalog/:name/runners' do + it 'returns runners for the extension' do + get '/api/extension_catalog/lex-fake_ext/runners' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + expect(body[:data].first[:name]).to eq('things') + end + end + + describe 'GET /api/extension_catalog/:name/runners/:runner_name' do + it 'returns runner detail with functions' do + get '/api/extension_catalog/lex-fake_ext/runners/things' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('things') + expect(body[:data][:functions]).to include('do_stuff', 'do_other') + end + + it 'returns 404 for unknown runner' do + get '/api/extension_catalog/lex-fake_ext/runners/nonexistent' + expect(last_response.status).to eq(404) + end + end + + describe 'GET /api/extension_catalog/:name/runners/:runner_name/functions' do + it 'returns function list' do + get '/api/extension_catalog/lex-fake_ext/runners/things/functions' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + expect(body[:data].map { |f| f[:name] }).to include('do_stuff', 'do_other') + end + end + + describe 'GET /api/extension_catalog/:name/runners/:runner_name/functions/:function_name' do + it 'returns function detail' do + get '/api/extension_catalog/lex-fake_ext/runners/things/functions/do_stuff' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('do_stuff') + end + + it 'returns 404 for unknown function' do + get '/api/extension_catalog/lex-fake_ext/runners/things/functions/nonexistent' + expect(last_response.status).to eq(404) + end + end +end diff --git a/spec/legion/api/gaia_spec.rb b/spec/legion/api/gaia_spec.rb new file mode 100644 index 00000000..801de63e --- /dev/null +++ b/spec/legion/api/gaia_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/gaia' + +RSpec.describe 'Gaia API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + error do + content_type :json + err = env['sinatra.error'] + status 500 + Legion::JSON.dump({ error: { code: 'internal_error', message: err.message } }) + end + + register Legion::API::Routes::Gaia + end + end + + def app + test_app + end + + describe 'GET /api/gaia/status' do + context 'when gaia is not started' do + it 'returns 503' do + get '/api/gaia/status' + expect(last_response.status).to eq(503) + end + + it 'returns started: false' do + get '/api/gaia/status' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:started]).to eq(false) + end + end + end + + describe 'GET /api/gaia/channels' do + context 'when gaia is not started' do + it 'returns 503' do + get '/api/gaia/channels' + expect(last_response.status).to eq(503) + end + end + + context 'when gaia is started' do + let(:mock_registry) { double('ChannelRegistry') } + let(:mock_adapter) { double('CliAdapter', started?: true, capabilities: %w[text]) } + + before do + gaia = Module.new + stub_const('Legion::Gaia', gaia) + allow(gaia).to receive(:started?).and_return(true) + allow(gaia).to receive(:channel_registry).and_return(mock_registry) + allow(mock_registry).to receive(:active_channels).and_return([:cli]) + allow(mock_registry).to receive(:adapter_for).with(:cli).and_return(mock_adapter) + allow(mock_adapter).to receive(:respond_to?).with(:capabilities).and_return(true) + allow(mock_adapter).to receive_message_chain(:class, :name).and_return('Legion::Gaia::Channels::CliAdapter') + end + + it 'returns 200 with channel list' do + get '/api/gaia/channels' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:channels]).to be_an(Array) + expect(body[:data][:count]).to eq(1) + end + + it 'includes channel details' do + get '/api/gaia/channels' + body = Legion::JSON.load(last_response.body) + ch = body[:data][:channels].first + expect(ch[:id]).to eq('cli') + expect(ch[:started]).to eq(true) + end + end + end + + describe 'GET /api/gaia/buffer' do + context 'when gaia is not started' do + it 'returns 503' do + get '/api/gaia/buffer' + expect(last_response.status).to eq(503) + end + end + + context 'when gaia is started' do + let(:mock_buffer) { double('SensoryBuffer', size: 3, empty?: false) } + + before do + gaia = Module.new + stub_const('Legion::Gaia', gaia) + buffer_class = Class.new + buffer_class.const_set(:MAX_BUFFER_SIZE, 1000) + stub_const('Legion::Gaia::SensoryBuffer', buffer_class) + allow(gaia).to receive(:started?).and_return(true) + allow(gaia).to receive(:sensory_buffer).and_return(mock_buffer) + end + + it 'returns buffer depth' do + get '/api/gaia/buffer' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:depth]).to eq(3) + expect(body[:data][:empty]).to eq(false) + end + + it 'returns max_size' do + get '/api/gaia/buffer' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:max_size]).to eq(1000) + end + end + end + + describe 'GET /api/gaia/sessions' do + context 'when gaia is not started' do + it 'returns 503' do + get '/api/gaia/sessions' + expect(last_response.status).to eq(503) + end + end + + context 'when gaia is started' do + let(:mock_store) { double('SessionStore', size: 5) } + + before do + gaia = Module.new + stub_const('Legion::Gaia', gaia) + allow(gaia).to receive(:started?).and_return(true) + allow(gaia).to receive(:session_store).and_return(mock_store) + end + + it 'returns session count' do + get '/api/gaia/sessions' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:count]).to eq(5) + expect(body[:data][:active]).to eq(true) + end + end + end +end diff --git a/spec/legion/api/graphql_spec.rb b/spec/legion/api/graphql_spec.rb new file mode 100644 index 00000000..8a6775ce --- /dev/null +++ b/spec/legion/api/graphql_spec.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' + +# Load GraphQL gem or skip entire suite +begin + require 'graphql' + GRAPHQL_AVAILABLE = true +rescue LoadError + GRAPHQL_AVAILABLE = false +end + +require 'legion/api/helpers' +require 'legion/api/validators' +require 'legion/api/graphql' if GRAPHQL_AVAILABLE + +RSpec.describe 'GraphQL API routes', skip: !GRAPHQL_AVAILABLE && 'graphql gem not available' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + loader.settings[:data] = { connected: false } + loader.settings[:transport] = { connected: false } + loader.settings[:extensions] = {} + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + helpers Legion::API::Validators + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + register Legion::API::Routes::GraphQL + end + end + + def app + test_app + end + + def graphql_post(query, variables: {}, operation_name: nil) + payload = { query: query } + payload[:variables] = variables unless variables.empty? + payload[:operationName] = operation_name if operation_name + post '/api/graphql', Legion::JSON.dump(payload), 'CONTENT_TYPE' => 'application/json' + end + + def response_body + Legion::JSON.load(last_response.body) + end + + # ── GET /api/graphql (GraphiQL UI) ─────────────────────────────────────────── + + describe 'GET /api/graphql' do + it 'returns 200' do + get '/api/graphql' + expect(last_response.status).to eq(200) + end + + it 'returns HTML content type' do + get '/api/graphql' + expect(last_response.content_type).to include('text/html') + end + + it 'includes GraphiQL script tag' do + get '/api/graphql' + expect(last_response.body).to include('graphiql') + end + + it 'includes the /api/graphql endpoint URL' do + get '/api/graphql' + expect(last_response.body).to include('/api/graphql') + end + end + + # ── POST /api/graphql — request validation ─────────────────────────────────── + + describe 'POST /api/graphql — request validation' do + it 'returns 400 when query is missing' do + post '/api/graphql', Legion::JSON.dump({}), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + body = Legion::JSON.load(last_response.body) + expect(body[:errors].first[:message]).to eq('query is required') + end + + it 'returns 400 when body is empty' do + post '/api/graphql', '', 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + end + + it 'returns 400 when query is blank string' do + post '/api/graphql', Legion::JSON.dump({ query: ' ' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + end + end + + # ── introspection ──────────────────────────────────────────────────────────── + + describe 'POST /api/graphql — introspection' do + it 'responds to __typename query' do + graphql_post('{ __typename }') + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data][:__typename]).to eq('Query') + end + + it 'supports __schema introspection' do + graphql_post('{ __schema { queryType { name } } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data][:__schema][:queryType][:name]).to eq('Query') + end + + it 'returns type information for Worker' do + graphql_post('{ __type(name: "Worker") { name fields { name } } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data][:__type][:name]).to eq('Worker') + end + + it 'returns type information for Extension' do + graphql_post('{ __type(name: "Extension") { name fields { name } } }') + body = response_body + expect(body[:data][:__type][:name]).to eq('Extension') + end + + it 'returns type information for Task' do + graphql_post('{ __type(name: "Task") { name fields { name } } }') + body = response_body + expect(body[:data][:__type][:name]).to eq('Task') + end + + it 'returns type information for Node' do + graphql_post('{ __type(name: "Node") { name fields { name } } }') + body = response_body + expect(body[:data][:__type][:name]).to eq('Node') + end + end + + # ── node query ─────────────────────────────────────────────────────────────── + + describe 'node query' do + it 'returns node data' do + graphql_post('{ node { name version ready } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data]).to have_key(:node) + end + + it 'returns node name' do + graphql_post('{ node { name } }') + body = response_body + expect(body[:data][:node][:name]).to eq('test-node') + end + + it 'returns node version' do + graphql_post('{ node { version } }') + body = response_body + expect(body[:data][:node][:version]).to eq(Legion::VERSION) + end + + it 'returns ready field as boolean' do + graphql_post('{ node { ready } }') + body = response_body + expect([true, false]).to include(body[:data][:node][:ready]) + end + + it 'returns uptime field (nil when process not started)' do + graphql_post('{ node { uptime } }') + body = response_body + expect(body[:data][:node]).to have_key(:uptime) + end + end + + # ── workers query ───────────────────────────────────────────────────────────── + + describe 'workers query' do + it 'returns empty array when no data layer' do + graphql_post('{ workers { id name } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data][:workers]).to be_an(Array) + end + + it 'accepts status filter argument' do + graphql_post('{ workers(status: "active") { id name status } }') + expect(last_response.status).to eq(200) + end + + it 'accepts risk_tier filter argument' do + graphql_post('{ workers(riskTier: "tier1") { id name riskTier } }') + expect(last_response.status).to eq(200) + end + + it 'accepts limit argument' do + graphql_post('{ workers(limit: 5) { id name } }') + expect(last_response.status).to eq(200) + end + + it 'returns worker type fields' do + graphql_post('{ workers { id name status riskTier team extension createdAt } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body).not_to have_key(:errors) + end + end + + # ── worker query (single) ───────────────────────────────────────────────────── + + describe 'worker query' do + it 'returns nil when worker not found' do + graphql_post('{ worker(id: "99999") { id name } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data][:worker]).to be_nil + end + + it 'requires id argument' do + graphql_post('{ worker { id name } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body).to have_key(:errors) + end + end + + # ── extensions query ────────────────────────────────────────────────────────── + + describe 'extensions query' do + it 'returns array' do + graphql_post('{ extensions { name version status } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data][:extensions]).to be_an(Array) + end + + it 'accepts status filter argument' do + graphql_post('{ extensions(status: "active") { name } }') + expect(last_response.status).to eq(200) + end + + it 'returns extension type fields' do + graphql_post('{ extensions { name version status description riskTier runners } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body).not_to have_key(:errors) + end + end + + # ── extension query (single) ─────────────────────────────────────────────────── + + describe 'extension query' do + it 'returns nil when not found' do + graphql_post('{ extension(name: "lex-nonexistent") { name version } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data][:extension]).to be_nil + end + + it 'requires name argument' do + graphql_post('{ extension { name } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body).to have_key(:errors) + end + end + + # ── tasks query ─────────────────────────────────────────────────────────────── + + describe 'tasks query' do + it 'returns empty array when no data layer' do + graphql_post('{ tasks { id status } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data][:tasks]).to be_an(Array) + end + + it 'accepts status filter argument' do + graphql_post('{ tasks(status: "completed") { id status } }') + expect(last_response.status).to eq(200) + end + + it 'accepts limit argument' do + graphql_post('{ tasks(limit: 10) { id } }') + expect(last_response.status).to eq(200) + end + + it 'returns task type fields' do + graphql_post('{ tasks { id status extension runner function createdAt completedAt } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body).not_to have_key(:errors) + end + end + + # ── field selection / partial queries ───────────────────────────────────────── + + describe 'field selection' do + it 'allows selecting only specific worker fields' do + graphql_post('{ workers { name } }') + expect(last_response.status).to eq(200) + end + + it 'allows selecting only name from extensions' do + graphql_post('{ extensions { name } }') + expect(last_response.status).to eq(200) + end + + it 'allows selecting only node name' do + graphql_post('{ node { name } }') + body = response_body + expect(body[:data][:node]).to eq({ name: 'test-node' }) + end + end + + # ── variables support ───────────────────────────────────────────────────────── + + describe 'variables' do + it 'passes variables to the query' do + query = 'query GetWorker($id: ID!) { worker(id: $id) { id name } }' + graphql_post(query, variables: { id: '99999' }) + expect(last_response.status).to eq(200) + body = response_body + expect(body[:data][:worker]).to be_nil + end + + it 'passes filter variables to workers query' do + query = 'query Workers($status: String) { workers(status: $status) { id } }' + graphql_post(query, variables: { status: 'active' }) + expect(last_response.status).to eq(200) + end + end + + # ── error handling ──────────────────────────────────────────────────────────── + + describe 'error handling' do + it 'returns errors for invalid field names' do + graphql_post('{ workers { nonExistentField } }') + expect(last_response.status).to eq(200) + body = response_body + expect(body).to have_key(:errors) + end + + it 'returns errors for invalid syntax' do + graphql_post('{ this is not valid graphql !!!}') + expect(last_response.status).to eq(200) + body = response_body + expect(body).to have_key(:errors) + end + + it 'returns 200 even when query has errors (GraphQL convention)' do + graphql_post('{ workers { badfield } }') + expect(last_response.status).to eq(200) + end + end + + # ── schema constraints ──────────────────────────────────────────────────────── + + describe 'schema constraints' do + it 'enforces max_depth via schema configuration' do + expect(Legion::API::GraphQL::Schema.max_depth).to eq(10) + end + + it 'enforces max_complexity via schema configuration' do + expect(Legion::API::GraphQL::Schema.max_complexity).to eq(200) + end + + it 'has query type set' do + expect(Legion::API::GraphQL::Schema.query).to eq(Legion::API::GraphQL::Types::QueryType) + end + end +end diff --git a/spec/legion/api/helpers_spec.rb b/spec/legion/api/helpers_spec.rb new file mode 100644 index 00000000..b717f7ee --- /dev/null +++ b/spec/legion/api/helpers_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/identity/request' + +RSpec.describe Legion::API::Helpers do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, true + set :host_authorization, permitted: :any + + get '/test/meta' do + content_type :json + Legion::JSON.dump(response_meta) + end + + get '/test/authenticated' do + content_type :json + Legion::JSON.dump({ authenticated: authenticated? }) + end + end + end + + def app + test_app + end + + describe '#response_meta' do + context 'without authentication' do + it 'returns timestamp and node' do + get '/test/meta' + body = Legion::JSON.load(last_response.body) + expect(body[:timestamp]).not_to be_nil + expect(body[:node]).to eq('test-node') + end + + it 'does not include caller key' do + get '/test/meta' + body = Legion::JSON.load(last_response.body) + expect(body).not_to have_key(:caller) + end + end + + context 'with authenticated request and a principal' do + let(:principal) do + Legion::Identity::Request.new( + principal_id: 'user-123', + canonical_name: 'jane-doe', + kind: :human, + source: :kerberos + ) + end + + before do + # Simulate Middleware::Auth setting legion.auth and Identity::Middleware setting legion.principal + env_patch = { 'legion.auth' => { sub: 'user-123' }, 'legion.principal' => principal } + rack_mock_session.cookie_jar['rack.session'] = nil + allow_any_instance_of(Sinatra::Base).to receive(:env).and_return( + Rack::MockRequest.env_for('/test/meta').merge(env_patch) + ) + end + + it 'includes caller in meta' do + get '/test/meta', {}, { 'legion.auth' => { sub: 'user-123' }, 'legion.principal' => principal } + body = Legion::JSON.load(last_response.body) + expect(body[:caller]).not_to be_nil + end + + it 'sets canonical_name from principal' do + get '/test/meta', {}, { 'legion.auth' => { sub: 'user-123' }, 'legion.principal' => principal } + body = Legion::JSON.load(last_response.body) + expect(body[:caller][:canonical_name]).to eq('jane-doe') + end + + it 'sets kind from principal' do + get '/test/meta', {}, { 'legion.auth' => { sub: 'user-123' }, 'legion.principal' => principal } + body = Legion::JSON.load(last_response.body) + expect(body[:caller][:kind]).to eq('human') + end + + it 'sets source from principal' do + get '/test/meta', {}, { 'legion.auth' => { sub: 'user-123' }, 'legion.principal' => principal } + body = Legion::JSON.load(last_response.body) + expect(body[:caller][:source]).to eq('kerberos') + end + end + + context 'with auth claims but no principal' do + it 'does not include caller key when principal is nil' do + get '/test/meta', {}, { 'legion.auth' => { sub: 'user-123' } } + body = Legion::JSON.load(last_response.body) + expect(body).not_to have_key(:caller) + end + end + + it 'timestamp is ISO 8601 format' do + get '/test/meta' + body = Legion::JSON.load(last_response.body) + expect { Time.iso8601(body[:timestamp]) }.not_to raise_error + end + end + + describe '#authenticated?' do + it 'returns false when no legion.auth in env' do + get '/test/authenticated' + body = Legion::JSON.load(last_response.body) + expect(body[:authenticated]).to be false + end + + it 'returns true when legion.auth is set' do + get '/test/authenticated', {}, { 'legion.auth' => { sub: 'user-123' } } + body = Legion::JSON.load(last_response.body) + expect(body[:authenticated]).to be true + end + end +end diff --git a/spec/legion/api/inbound_webhooks_spec.rb b/spec/legion/api/inbound_webhooks_spec.rb new file mode 100644 index 00000000..194575c9 --- /dev/null +++ b/spec/legion/api/inbound_webhooks_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/trigger' + +RSpec.describe 'Inbound Webhooks' do + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + allow(Legion::Logging).to receive(:error) + allow(Legion::Settings).to receive(:dig).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:trigger, :sources, anything, :require_verified).and_return(false) + hide_const('Legion::Cache') if defined?(Legion::Cache) + end + + describe 'Legion::Trigger.process via github' do + let(:headers) do + { 'HTTP_X_GITHUB_EVENT' => 'pull_request', 'HTTP_X_GITHUB_DELIVERY' => 'del-pr-1' } + end + let(:body) { { 'action' => 'opened', 'number' => 1 } } + let(:body_raw) { '{"action":"opened","number":1}' } + + it 'returns 202-equivalent success with routing key' do + result = Legion::Trigger.process( + source_name: 'github', headers: headers, body_raw: body_raw, body: body + ) + expect(result[:success]).to be true + expect(result[:routing_key]).to eq('trigger.github.pull_request') + expect(result[:correlation_id]).to start_with('leg-') + end + end + + describe 'Legion::Trigger.process via slack' do + let(:body) { { 'type' => 'event_callback', 'event_id' => 'ev1', 'event' => { 'type' => 'message' } } } + let(:body_raw) { '{"type":"event_callback","event_id":"ev1","event":{"type":"message"}}' } + + it 'returns success with slack routing key' do + result = Legion::Trigger.process( + source_name: 'slack', headers: {}, body_raw: body_raw, body: body + ) + expect(result[:success]).to be true + expect(result[:routing_key]).to eq('trigger.slack.event_callback') + end + end + + describe 'Legion::Trigger.registered_sources' do + it 'includes github, slack, linear' do + expect(Legion::Trigger.registered_sources).to contain_exactly('github', 'slack', 'linear') + end + end + + describe 'unknown source' do + it 'returns error' do + result = Legion::Trigger.process( + source_name: 'unknown', headers: {}, body_raw: '', body: {} + ) + expect(result[:success]).to be false + expect(result[:reason]).to eq(:unknown_source) + end + end +end diff --git a/spec/legion/api/library_routes_spec.rb b/spec/legion/api/library_routes_spec.rb new file mode 100644 index 00000000..39d26cd2 --- /dev/null +++ b/spec/legion/api/library_routes_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'sinatra/base' +require 'legion/api' + +RSpec.describe Legion::API do + let(:api_class) { Class.new(described_class) } + + it 'mounts legion-llm routes via library decoration during API construction' do + source = File.read(File.expand_path('../../../lib/legion/api.rb', __dir__)) + + expect(source).to include("mount_library_routes('llm', Legion::LLM::API, 'Legion::LLM::Routes')") + expect(source).not_to include('register Routes::Llm') + end + + it 'mounts legion-apollo routes as the primary apollo route owner during API construction' do + source = File.read(File.expand_path('../../../lib/legion/api.rb', __dir__)) + + expect(source).to include("mount_library_routes('apollo', Routes::Apollo, 'Legion::Apollo::Routes')") + expect(source).not_to include('register Routes::Apollo') + end + + describe '.mount_library_routes' do + it 'prefers loaded library route modules and tracks them in discovery' do + apollo_routes = Module.new + stub_const('Legion::Apollo::Routes', apollo_routes) + allow(api_class).to receive(:register) + + api_class.mount_library_routes('apollo', Legion::API::Routes::Apollo, 'Legion::Apollo::Routes') + + expect(api_class.router.library_routes['apollo']).to eq(apollo_routes) + expect(api_class).to have_received(:register).with(apollo_routes) + end + + it 'falls back to core routes when the library route module is unavailable' do + allow(api_class).to receive(:register) + allow(api_class).to receive(:constant_from_path).with('Legion::Apollo::Routes').and_return(nil) + + api_class.mount_library_routes('apollo', Legion::API::Routes::Apollo, 'Legion::Apollo::Routes') + + expect(api_class.router.library_routes).to be_empty + expect(api_class).to have_received(:register).with(Legion::API::Routes::Apollo) + end + end + + describe '.register_library_routes' do + it 'does not re-register the same route module twice' do + allow(api_class).to receive(:register) + routes_module = Module.new + + api_class.register_library_routes('test_gem', routes_module) + api_class.register_library_routes('test_gem', routes_module) + + expect(api_class.router.library_routes['test_gem']).to eq(routes_module) + expect(api_class).to have_received(:register).once.with(routes_module) + end + end +end diff --git a/spec/legion/api/marketplace_spec.rb b/spec/legion/api/marketplace_spec.rb new file mode 100644 index 00000000..5463c407 --- /dev/null +++ b/spec/legion/api/marketplace_spec.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/registry' +require 'legion/api/helpers' +require 'legion/api/validators' +require 'legion/api/marketplace' + +RSpec.describe 'Marketplace API routes' do + include Rack::Test::Methods + + let(:entry_attrs) do + { + name: 'lex-test', + version: '1.0.0', + author: 'test-author', + description: 'A test extension', + risk_tier: 'low', + airb_status: 'pending', + status: :active + } + end + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + loader.settings[:data] = { connected: false } + loader.settings[:transport] = { connected: false } + loader.settings[:extensions] = {} + end + + before(:each) do + Legion::Registry.clear! + Legion::Registry.register(Legion::Registry::Entry.new(**entry_attrs)) + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + helpers Legion::API::Validators + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + register Legion::API::Routes::Marketplace + end + end + + def app + test_app + end + + def json_post(path, body = {}) + post path, Legion::JSON.dump(body), 'CONTENT_TYPE' => 'application/json' + end + + # ────────────────────────────────────────────────────────── + # GET /api/marketplace + # ────────────────────────────────────────────────────────── + + describe 'GET /api/marketplace' do + it 'returns 200' do + get '/api/marketplace' + expect(last_response.status).to eq(200) + end + + it 'returns data array' do + get '/api/marketplace' + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + end + + it 'includes registered extension' do + get '/api/marketplace' + body = Legion::JSON.load(last_response.body) + expect(body[:data].map { |e| e[:name] }).to include('lex-test') + end + + it 'returns meta with total' do + get '/api/marketplace' + body = Legion::JSON.load(last_response.body) + expect(body[:meta][:total]).to eq(1) + end + + it 'filters by status query param' do + Legion::Registry.submit_for_review('lex-test') + get '/api/marketplace?status=pending_review' + body = Legion::JSON.load(last_response.body) + expect(body[:data].size).to eq(1) + end + + it 'returns empty data when status filter matches nothing' do + get '/api/marketplace?status=rejected' + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_empty + end + + it 'filters by query param q' do + get '/api/marketplace?q=test' + body = Legion::JSON.load(last_response.body) + expect(body[:data].map { |e| e[:name] }).to include('lex-test') + end + + it 'returns empty when query matches nothing' do + get '/api/marketplace?q=zzzmissing' + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_empty + end + end + + # ────────────────────────────────────────────────────────── + # GET /api/marketplace/:name + # ────────────────────────────────────────────────────────── + + describe 'GET /api/marketplace/:name' do + it 'returns 200 for known extension' do + get '/api/marketplace/lex-test' + expect(last_response.status).to eq(200) + end + + it 'returns extension name in data' do + get '/api/marketplace/lex-test' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('lex-test') + end + + it 'returns stats in data' do + get '/api/marketplace/lex-test' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:stats]).to be_a(Hash) + end + + it 'returns 404 for unknown extension' do + get '/api/marketplace/lex-missing' + expect(last_response.status).to eq(404) + end + + it 'returns error body for 404' do + get '/api/marketplace/lex-missing' + body = Legion::JSON.load(last_response.body) + expect(body[:error]).not_to be_nil + end + end + + # ────────────────────────────────────────────────────────── + # POST /api/marketplace/:name/submit + # ────────────────────────────────────────────────────────── + + describe 'POST /api/marketplace/:name/submit' do + it 'returns 202 for known extension' do + json_post '/api/marketplace/lex-test/submit' + expect(last_response.status).to eq(202) + end + + it 'sets status to pending_review' do + json_post '/api/marketplace/lex-test/submit' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('pending_review') + end + + it 'returns 404 for unknown extension' do + json_post '/api/marketplace/lex-missing/submit' + expect(last_response.status).to eq(404) + end + + it 'transitions registry status' do + json_post '/api/marketplace/lex-test/submit' + expect(Legion::Registry.lookup('lex-test').status).to eq(:pending_review) + end + end + + # ────────────────────────────────────────────────────────── + # POST /api/marketplace/:name/approve + # ────────────────────────────────────────────────────────── + + describe 'POST /api/marketplace/:name/approve' do + before { Legion::Registry.submit_for_review('lex-test') } + + it 'returns 200 for known extension' do + json_post '/api/marketplace/lex-test/approve' + expect(last_response.status).to eq(200) + end + + it 'returns approved status' do + json_post '/api/marketplace/lex-test/approve' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('approved') + end + + it 'stores notes from request body' do + json_post '/api/marketplace/lex-test/approve', notes: 'LGTM' + expect(Legion::Registry.lookup('lex-test').review_notes).to eq('LGTM') + end + + it 'returns 404 for unknown extension' do + json_post '/api/marketplace/lex-missing/approve' + expect(last_response.status).to eq(404) + end + + it 'transitions registry status to approved' do + json_post '/api/marketplace/lex-test/approve' + expect(Legion::Registry.lookup('lex-test').status).to eq(:approved) + end + end + + # ────────────────────────────────────────────────────────── + # POST /api/marketplace/:name/reject + # ────────────────────────────────────────────────────────── + + describe 'POST /api/marketplace/:name/reject' do + before { Legion::Registry.submit_for_review('lex-test') } + + it 'returns 200 for known extension' do + json_post '/api/marketplace/lex-test/reject' + expect(last_response.status).to eq(200) + end + + it 'returns rejected status' do + json_post '/api/marketplace/lex-test/reject' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('rejected') + end + + it 'stores reason from request body' do + json_post '/api/marketplace/lex-test/reject', reason: 'CVE found' + expect(Legion::Registry.lookup('lex-test').reject_reason).to eq('CVE found') + end + + it 'returns 404 for unknown extension' do + json_post '/api/marketplace/lex-missing/reject' + expect(last_response.status).to eq(404) + end + + it 'transitions registry status to rejected' do + json_post '/api/marketplace/lex-test/reject' + expect(Legion::Registry.lookup('lex-test').status).to eq(:rejected) + end + end + + # ────────────────────────────────────────────────────────── + # POST /api/marketplace/:name/deprecate + # ────────────────────────────────────────────────────────── + + describe 'POST /api/marketplace/:name/deprecate' do + it 'returns 200 for known extension' do + json_post '/api/marketplace/lex-test/deprecate' + expect(last_response.status).to eq(200) + end + + it 'returns deprecated status' do + json_post '/api/marketplace/lex-test/deprecate' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:status]).to eq('deprecated') + end + + it 'stores successor from request body' do + json_post '/api/marketplace/lex-test/deprecate', successor: 'lex-test-v2' + expect(Legion::Registry.lookup('lex-test').successor).to eq('lex-test-v2') + end + + it 'parses sunset_date from request body' do + json_post '/api/marketplace/lex-test/deprecate', sunset_date: '2027-01-01' + expect(Legion::Registry.lookup('lex-test').sunset_date).to eq(Date.new(2027, 1, 1)) + end + + it 'returns 404 for unknown extension' do + json_post '/api/marketplace/lex-missing/deprecate' + expect(last_response.status).to eq(404) + end + + it 'transitions registry status to deprecated' do + json_post '/api/marketplace/lex-test/deprecate' + expect(Legion::Registry.lookup('lex-test').status).to eq(:deprecated) + end + end + + # ────────────────────────────────────────────────────────── + # GET /api/marketplace/:name/stats + # ────────────────────────────────────────────────────────── + + describe 'GET /api/marketplace/:name/stats' do + it 'returns 200 for known extension' do + get '/api/marketplace/lex-test/stats' + expect(last_response.status).to eq(200) + end + + it 'returns install_count in data' do + get '/api/marketplace/lex-test/stats' + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to have_key(:install_count) + end + + it 'returns active_instances in data' do + get '/api/marketplace/lex-test/stats' + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to have_key(:active_instances) + end + + it 'returns name in data' do + get '/api/marketplace/lex-test/stats' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('lex-test') + end + + it 'returns 404 for unknown extension' do + get '/api/marketplace/lex-missing/stats' + expect(last_response.status).to eq(404) + end + + it 'returns error body for 404' do + get '/api/marketplace/lex-missing/stats' + body = Legion::JSON.load(last_response.body) + expect(body[:error]).not_to be_nil + end + end +end diff --git a/spec/legion/api/metering_spec.rb b/spec/legion/api/metering_spec.rb new file mode 100644 index 00000000..09662aad --- /dev/null +++ b/spec/legion/api/metering_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'sequel' +require 'legion/api/helpers' +require 'legion/api/metering' + +RSpec.describe 'Metering API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + + @db = Sequel.sqlite + @db.create_table(:metering_records) do + primary_key :id + Integer :total_tokens + Float :cost_usd + String :model_id + Integer :latency_ms + end + end + + after(:all) do + @db.drop_table(:metering_records) if @db.table_exists?(:metering_records) + end + + let(:db) { @db } + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + helpers do + define_method(:require_metering!) { true } + define_method(:metering_table?) { true } + end + + register Legion::API::Routes::Metering + end + end + + def app + test_app + end + + describe 'GET /api/metering' do + before do + database = db + data_stub = Module.new do + define_singleton_method(:connected?) { true } + define_singleton_method(:connection) { database } + end + stub_const('Legion::Data', data_stub) + stub_const('Legion::Extensions::Metering::Runners::Metering', Module.new) + db[:metering_records].delete + db[:metering_records].insert(total_tokens: 120, cost_usd: 0.25) + db[:metering_records].insert(total_tokens: 30, cost_usd: 0.05) + end + + it 'returns dashboard headline totals' do + get '/api/metering' + + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to include( + total_cost_usd: 0.3, + total_tokens: 150, + total_requests: 2 + ) + end + end +end diff --git a/spec/legion/api/middleware/api_version_spec.rb b/spec/legion/api/middleware/api_version_spec.rb new file mode 100644 index 00000000..50779c26 --- /dev/null +++ b/spec/legion/api/middleware/api_version_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/api/middleware/api_version' + +RSpec.describe Legion::API::Middleware::ApiVersion do + let(:inner_app) { ->(_env) { [200, { 'Content-Type' => 'application/json' }, ['ok']] } } + let(:app) { described_class.new(inner_app) } + + it 'rewrites /api/v1/ to /api/' do + env = Rack::MockRequest.env_for('/api/v1/workers') + status, _headers, _body = app.call(env) + expect(env['PATH_INFO']).to eq('/api/workers') + expect(status).to eq(200) + end + + it 'adds deprecation header to unversioned paths' do + env = Rack::MockRequest.env_for('/api/workers') + _status, headers, _body = app.call(env) + expect(headers['Deprecation']).to eq('true') + expect(headers['Link']).to include('/api/v1/workers') + end + + it 'does not add headers to skip paths' do + env = Rack::MockRequest.env_for('/api/health') + _status, headers, _body = app.call(env) + expect(headers).not_to have_key('Deprecation') + end + + it 'sets X-API-Version header for versioned paths' do + env = Rack::MockRequest.env_for('/api/v1/tasks') + app.call(env) + expect(env['HTTP_X_API_VERSION']).to eq('1') + end + + it 'includes Sunset header on deprecated paths' do + env = Rack::MockRequest.env_for('/api/tasks') + _status, headers, _body = app.call(env) + expect(headers).to have_key('Sunset') + end +end diff --git a/spec/legion/api/middleware/auth_spec.rb b/spec/legion/api/middleware/auth_spec.rb new file mode 100644 index 00000000..08b8545f --- /dev/null +++ b/spec/legion/api/middleware/auth_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/middleware/auth' + +RSpec.describe Legion::API::Middleware::Auth do + include Rack::Test::Methods + + let(:inner_app) do + ->(_env) { [200, { 'content-type' => 'text/plain' }, ['OK']] } + end + + let(:signing_key) { 'test-secret-key-32bytes-long!!' } + let(:api_keys) { { 'valid-key-123' => { worker_id: 'w-1', owner_msid: 'user@test' } } } + + let(:app) do + described_class.new(inner_app, enabled: true, signing_key: signing_key, api_keys: api_keys) + end + + describe 'when auth is disabled' do + let(:app) { described_class.new(inner_app, enabled: false) } + + it 'passes all requests through' do + status, = app.call(Rack::MockRequest.env_for('/api/tasks')) + expect(status).to eq(200) + end + end + + describe 'skip paths' do + it 'skips /api/health' do + status, = app.call(Rack::MockRequest.env_for('/api/health')) + expect(status).to eq(200) + end + + it 'skips /api/ready' do + status, = app.call(Rack::MockRequest.env_for('/api/ready')) + expect(status).to eq(200) + end + + it 'skips /api/openapi.json' do + status, = app.call(Rack::MockRequest.env_for('/api/openapi.json')) + expect(status).to eq(200) + end + + it 'skips /metrics' do + status, = app.call(Rack::MockRequest.env_for('/metrics')) + expect(status).to eq(200) + end + + it 'skips /api/auth/token' do + status, = app.call(Rack::MockRequest.env_for('/api/auth/token')) + expect(status).to eq(200) + end + end + + describe 'missing auth' do + it 'returns 401 for requests without auth' do + status, headers, body = app.call(Rack::MockRequest.env_for('/api/tasks')) + expect(status).to eq(401) + expect(headers['content-type']).to eq('application/json') + parsed = Legion::JSON.load(body.first) + expect(parsed[:error][:message]).to include('missing Authorization') + end + end + + describe 'Bearer JWT auth' do + before do + jwt_error = Class.new(StandardError) + jwt_mod = Module.new do + define_method(:verify) do |token, verification_key:| + return { worker_id: 'w-1', sub: 'user@test' } if token == 'valid-jwt' && verification_key + + raise jwt_error, 'invalid token' + end + + module_function :verify + end + jwt_mod.const_set(:Error, jwt_error) + stub_const('Legion::Crypt::JWT', jwt_mod) + end + + it 'authenticates valid JWT token' do + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_AUTHORIZATION' => 'Bearer valid-jwt') + status, = app.call(env) + expect(status).to eq(200) + end + + it 'sets auth env vars on valid JWT' do + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_AUTHORIZATION' => 'Bearer valid-jwt') + inner = lambda do |e| + expect(e['legion.auth_method']).to eq('jwt') + expect(e['legion.worker_id']).to eq('w-1') + [200, {}, ['OK']] + end + auth = described_class.new(inner, enabled: true, signing_key: signing_key) + auth.call(env) + end + + it 'returns 401 for invalid JWT' do + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_AUTHORIZATION' => 'Bearer bad-token') + status, = app.call(env) + expect(status).to eq(401) + end + end + + describe 'API key auth' do + it 'authenticates valid API key' do + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_X_API_KEY' => 'valid-key-123') + status, = app.call(env) + expect(status).to eq(200) + end + + it 'sets auth env vars on valid API key' do + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_X_API_KEY' => 'valid-key-123') + inner = lambda do |e| + expect(e['legion.auth_method']).to eq('api_key') + expect(e['legion.worker_id']).to eq('w-1') + [200, {}, ['OK']] + end + auth = described_class.new(inner, enabled: true, api_keys: api_keys) + auth.call(env) + end + + it 'returns 401 for invalid API key' do + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_X_API_KEY' => 'bad-key') + status, = app.call(env) + expect(status).to eq(401) + end + end + + describe 'auth priority' do + before do + jwt_error = Class.new(StandardError) + jwt_mod = Module.new do + define_method(:verify) do |token, verification_key:| + return { worker_id: 'jwt-worker', sub: 'jwt-user' } if token == 'valid-jwt' && verification_key + + raise jwt_error, 'invalid' + end + + module_function :verify + end + jwt_mod.const_set(:Error, jwt_error) + stub_const('Legion::Crypt::JWT', jwt_mod) + end + + it 'prefers JWT over API key when both provided' do + env = Rack::MockRequest.env_for( + '/api/tasks', + 'HTTP_AUTHORIZATION' => 'Bearer valid-jwt', + 'HTTP_X_API_KEY' => 'valid-key-123' + ) + inner = lambda do |e| + expect(e['legion.auth_method']).to eq('jwt') + [200, {}, ['OK']] + end + auth = described_class.new(inner, enabled: true, signing_key: signing_key, api_keys: api_keys) + auth.call(env) + end + end + + describe 'unauthorized response format' do + it 'returns JSON error body' do + _, _, body = app.call(Rack::MockRequest.env_for('/api/tasks')) + parsed = Legion::JSON.load(body.first) + expect(parsed[:error]).to have_key(:code) + expect(parsed[:error]).to have_key(:message) + expect(parsed[:meta]).to have_key(:timestamp) + end + end +end diff --git a/spec/legion/api/middleware/body_limit_spec.rb b/spec/legion/api/middleware/body_limit_spec.rb new file mode 100644 index 00000000..5d4bd0d6 --- /dev/null +++ b/spec/legion/api/middleware/body_limit_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/api/middleware/body_limit' + +RSpec.describe Legion::API::Middleware::BodyLimit do + let(:inner_app) { ->(_env) { [200, { 'Content-Type' => 'application/json' }, ['ok']] } } + let(:app) { described_class.new(inner_app, max_size: 1024) } + + it 'allows requests within size limit' do + env = Rack::MockRequest.env_for('/api/test', method: 'POST', + 'CONTENT_LENGTH' => '100') + status, _headers, _body = app.call(env) + expect(status).to eq(200) + end + + it 'rejects requests exceeding size limit' do + env = Rack::MockRequest.env_for('/api/test', method: 'POST', + 'CONTENT_LENGTH' => '2048') + status, _headers, body = app.call(env) + expect(status).to eq(413) + parsed = Legion::JSON.load(body.first) + expect(parsed[:error][:code]).to eq('payload_too_large') + end + + it 'allows requests with no content length' do + env = Rack::MockRequest.env_for('/api/test', method: 'GET') + status, _headers, _body = app.call(env) + expect(status).to eq(200) + end +end diff --git a/spec/legion/api/middleware/rate_limit_spec.rb b/spec/legion/api/middleware/rate_limit_spec.rb new file mode 100644 index 00000000..8d644e43 --- /dev/null +++ b/spec/legion/api/middleware/rate_limit_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'legion/api/middleware/rate_limit' + +RSpec.describe Legion::API::Middleware::RateLimit do + describe Legion::API::Middleware::RateLimit::MemoryStore do + let(:store) { described_class.new } + + it 'increments and returns count' do + expect(store.increment('ip:127.0.0.1', 1000)).to eq(1) + expect(store.increment('ip:127.0.0.1', 1000)).to eq(2) + end + + it 'returns count for a key' do + store.increment('ip:127.0.0.1', 1000) + expect(store.count('ip:127.0.0.1', 1000)).to eq(1) + end + + it 'returns 0 for unknown key' do + expect(store.count('ip:unknown', 1000)).to eq(0) + end + + it 'isolates different windows' do + store.increment('ip:127.0.0.1', 1000) + store.increment('ip:127.0.0.1', 1060) + expect(store.count('ip:127.0.0.1', 1000)).to eq(1) + expect(store.count('ip:127.0.0.1', 1060)).to eq(1) + end + + it 'reaps old windows' do + old_window = (Time.now.to_i / 60 * 60) - 180 + store.increment('ip:old', old_window) + store.reap! + expect(store.count('ip:old', old_window)).to eq(0) + end + end + + describe 'middleware integration' do + include Rack::Test::Methods + + let(:inner_app) do + lambda do |_env| + [200, { 'content-type' => 'text/plain' }, ['ok']] + end + end + + let(:rate_limit_opts) { { enabled: true, per_ip: 3, per_agent: 10, per_tenant: 20 } } + + let(:app) do + opts = rate_limit_opts + ia = inner_app + Rack::Builder.new do + use Legion::API::Middleware::RateLimit, **opts + run ia + end.to_app + end + + it 'skips health endpoint' do + 10.times { get '/api/health' } + expect(last_response.status).to eq(200) + expect(last_response.headers).not_to have_key('X-RateLimit-Limit') + end + + it 'adds rate limit headers to normal responses' do + get '/api/test' + expect(last_response.status).to eq(200) + expect(last_response.headers['X-RateLimit-Limit']).not_to be_nil + expect(last_response.headers['X-RateLimit-Remaining']).not_to be_nil + expect(last_response.headers['X-RateLimit-Reset']).not_to be_nil + end + + it 'returns 429 when per_ip limit exceeded' do + 3.times do + get '/api/test' + expect(last_response.status).to eq(200) + end + get '/api/test' + expect(last_response.status).to eq(429) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('rate_limit_exceeded') + expect(last_response.headers['Retry-After']).not_to be_nil + end + + it 'does not include Retry-After on non-429 responses' do + get '/api/test' + expect(last_response.status).to eq(200) + expect(last_response.headers).not_to have_key('Retry-After') + end + + context 'when disabled' do + let(:rate_limit_opts) { { enabled: false } } + + it 'passes through without rate limiting' do + 10.times { get '/api/test' } + expect(last_response.status).to eq(200) + expect(last_response.headers).not_to have_key('X-RateLimit-Limit') + end + end + end +end diff --git a/spec/legion/api/middleware/request_logger_spec.rb b/spec/legion/api/middleware/request_logger_spec.rb new file mode 100644 index 00000000..b26cc0f3 --- /dev/null +++ b/spec/legion/api/middleware/request_logger_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/middleware/request_logger' + +RSpec.describe Legion::API::Middleware::RequestLogger do + let(:inner_app) do + ->(_env) { [200, { 'content-type' => 'text/plain' }, ['OK']] } + end + + let(:app) { described_class.new(inner_app) } + + it 'passes request through and returns response' do + status, _, body = app.call(Rack::MockRequest.env_for('/api/tasks')) + expect(status).to eq(200) + expect(body).to eq(['OK']) + end + + it 'logs request with method, path, status, and duration' do + expect(Legion::Logging).to receive(:info).with(%r{\[api\]\[request-start\] GET /api/tasks}).ordered + expect(Legion::Logging).to receive(:info).with(%r{\[api\] GET /api/tasks 200 \d+(\.\d+)?ms}).ordered + app.call(Rack::MockRequest.env_for('/api/tasks')) + end + + it 'logs error and re-raises on failure' do + error_app = ->(_env) { raise StandardError, 'boom' } + logger_app = described_class.new(error_app) + + expect(Legion::Logging).to receive(:error).with(%r{\[api\] GET /api/tasks 500.*boom}) + expect { logger_app.call(Rack::MockRequest.env_for('/api/tasks')) }.to raise_error(StandardError, 'boom') + end + + it 'reports duration in milliseconds' do + allow(Legion::Logging).to receive(:info) + app.call(Rack::MockRequest.env_for('/api/tasks')) + expect(Legion::Logging).to have_received(:info).with(/\d+\.\d+ms/) + end +end diff --git a/spec/legion/api/middleware/tenant_spec.rb b/spec/legion/api/middleware/tenant_spec.rb new file mode 100644 index 00000000..265433a9 --- /dev/null +++ b/spec/legion/api/middleware/tenant_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/middleware/tenant' + +RSpec.describe Legion::API::Middleware::Tenant do + let(:captured_env) { {} } + let(:inner_app) do + ce = captured_env + lambda do |_env| + ce[:tenant_id] = Legion::TenantContext.current + [200, { 'content-type' => 'text/plain' }, ['OK']] + end + end + + let(:app) { described_class.new(inner_app) } + + before do + tenant_ctx = Module.new do + @current = nil + + def self.set(id) + @current = id + end + + def self.current # rubocop:disable Style/TrivialAccessors + @current + end + + def self.clear + @current = nil + end + end + stub_const('Legion::TenantContext', tenant_ctx) + end + + describe 'skip paths' do + it 'skips /api/health' do + status, = app.call(Rack::MockRequest.env_for('/api/health')) + expect(status).to eq(200) + end + + it 'skips /api/ready' do + status, = app.call(Rack::MockRequest.env_for('/api/ready')) + expect(status).to eq(200) + end + + it 'skips /metrics' do + status, = app.call(Rack::MockRequest.env_for('/metrics')) + expect(status).to eq(200) + end + end + + describe 'tenant extraction' do + it 'extracts tenant from X-Tenant-ID header' do + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_X_TENANT_ID' => 'tenant-abc') + app.call(env) + expect(captured_env[:tenant_id]).to eq('tenant-abc') + end + + it 'extracts tenant from legion.tenant_id env' do + env = Rack::MockRequest.env_for('/api/tasks') + env['legion.tenant_id'] = 'tenant-xyz' + app.call(env) + expect(captured_env[:tenant_id]).to eq('tenant-xyz') + end + + it 'prefers legion.tenant_id over header' do + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_X_TENANT_ID' => 'header-tenant') + env['legion.tenant_id'] = 'env-tenant' + app.call(env) + expect(captured_env[:tenant_id]).to eq('env-tenant') + end + + it 'passes nil when no tenant provided' do + app.call(Rack::MockRequest.env_for('/api/tasks')) + expect(captured_env[:tenant_id]).to be_nil + end + end + + describe 'context cleanup' do + it 'clears tenant context after request' do + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_X_TENANT_ID' => 'tenant-abc') + app.call(env) + expect(Legion::TenantContext.current).to be_nil + end + + it 'clears context even when inner app raises' do + error_app = ->(_env) { raise StandardError, 'boom' } + tenant_app = described_class.new(error_app) + env = Rack::MockRequest.env_for('/api/tasks', 'HTTP_X_TENANT_ID' => 'tenant-abc') + expect { tenant_app.call(env) }.to raise_error(StandardError, 'boom') + expect(Legion::TenantContext.current).to be_nil + end + end +end diff --git a/spec/legion/api/prompts_spec.rb b/spec/legion/api/prompts_spec.rb new file mode 100644 index 00000000..0653f9a0 --- /dev/null +++ b/spec/legion/api/prompts_spec.rb @@ -0,0 +1,443 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/validators' +require 'legion/api/prompts' + +RSpec.describe 'Prompts API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + loader.settings[:data] = { connected: false } + loader.settings[:transport] = { connected: false } + loader.settings[:extensions] = {} + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + helpers Legion::API::Validators + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + register Legion::API::Routes::Prompts + end + end + + def app + test_app + end + + # ────────────────────────────────────────────────────────── + # Helper stubs + # ────────────────────────────────────────────────────────── + + def stub_prompt_client(client) + app.helpers do + define_method(:prompt_client) { client } + end + end + + def stub_llm_started + llm_mod = Module.new do + def self.started? = true + end + stub_const('Legion::LLM', llm_mod) + end + + def stub_llm_sync_response(content: 'LLM output', model_name: 'claude-sonnet-4-6', + input_tokens: 8, output_tokens: 12) + fake_response = double('LLMResponse', + content: content, + input_tokens: input_tokens, + output_tokens: output_tokens) + allow(fake_response).to receive(:respond_to?).with(:input_tokens).and_return(true) + allow(fake_response).to receive(:respond_to?).with(:output_tokens).and_return(true) + + fake_session = double('ChatSession', model: model_name) + allow(fake_session).to receive(:ask).and_return(fake_response) + allow(Legion::LLM).to receive(:chat).and_return(fake_session) + end + + def build_prompt_client(list: [], get_result: nil, render_result: nil) + client = double('PromptClient') + allow(client).to receive(:list_prompts).and_return(list) + allow(client).to receive(:get_prompt).and_return(get_result) if get_result + allow(client).to receive(:render_prompt).and_return(render_result) if render_result + client + end + + # ────────────────────────────────────────────────────────── + # GET /api/prompts — list + # ────────────────────────────────────────────────────────── + + describe 'GET /api/prompts' do + context 'when lex-prompt is not loaded' do + before do + app.helpers do + define_method(:prompt_client) do + halt 503, json_error('prompt_unavailable', 'lex-prompt is not loaded', status_code: 503) + end + end + end + + it 'returns 503 with prompt_unavailable code' do + get '/api/prompts' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('prompt_unavailable') + end + end + + context 'when lex-prompt is loaded' do + before do + list = [ + { name: 'summarizer', description: 'Summarizes text', latest_version: 2, updated_at: Time.now.utc }, + { name: 'classifier', description: 'Classifies intent', latest_version: 1, updated_at: Time.now.utc } + ] + stub_prompt_client(build_prompt_client(list: list)) + end + + it 'returns 200' do + get '/api/prompts' + expect(last_response.status).to eq(200) + end + + it 'returns array of prompts in data' do + get '/api/prompts' + body = Legion::JSON.load(last_response.body) + expect(body[:data].length).to eq(2) + end + + it 'includes prompt names' do + get '/api/prompts' + body = Legion::JSON.load(last_response.body) + names = body[:data].map { |p| p[:name] } + expect(names).to include('summarizer', 'classifier') + end + + it 'includes meta with timestamp and node' do + get '/api/prompts' + body = Legion::JSON.load(last_response.body) + expect(body[:meta]).to have_key(:timestamp) + expect(body[:meta][:node]).to eq('test-node') + end + end + + context 'when list_prompts raises' do + before do + client = double('PromptClient') + allow(client).to receive(:list_prompts).and_raise(StandardError, 'db offline') + stub_prompt_client(client) + end + + it 'returns 500 with execution_error' do + get '/api/prompts' + expect(last_response.status).to eq(500) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('execution_error') + end + end + end + + # ────────────────────────────────────────────────────────── + # GET /api/prompts/:name — show + # ────────────────────────────────────────────────────────── + + describe 'GET /api/prompts/:name' do + context 'when prompt not found' do + before do + stub_prompt_client(build_prompt_client(get_result: { error: 'not_found' })) + end + + it 'returns 404' do + get '/api/prompts/nonexistent' + expect(last_response.status).to eq(404) + end + + it 'returns not_found error code' do + get '/api/prompts/nonexistent' + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('not_found') + end + end + + context 'when prompt exists' do + let(:prompt_data) do + { name: 'summarizer', version: 2, template: 'Summarize: <%= text %>', + model_params: { max_tokens: 256 }, content_hash: 'abc123', created_at: Time.now.utc } + end + + before do + stub_prompt_client(build_prompt_client(get_result: prompt_data)) + end + + it 'returns 200' do + get '/api/prompts/summarizer' + expect(last_response.status).to eq(200) + end + + it 'includes the prompt name in data' do + get '/api/prompts/summarizer' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('summarizer') + end + + it 'includes the version' do + get '/api/prompts/summarizer' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:version]).to eq(2) + end + + it 'includes meta node' do + get '/api/prompts/summarizer' + body = Legion::JSON.load(last_response.body) + expect(body[:meta][:node]).to eq('test-node') + end + end + end + + # ────────────────────────────────────────────────────────── + # POST /api/prompts/:name/run + # ────────────────────────────────────────────────────────── + + describe 'POST /api/prompts/:name/run' do + context 'when LLM is not available' do + it 'returns 503 when Legion::LLM is not defined' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'hello' } }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('llm_unavailable') + end + + it 'returns 503 when Legion::LLM is defined but not started' do + llm_mod = Module.new { def self.started? = false } + stub_const('Legion::LLM', llm_mod) + + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'hello' } }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('llm_unavailable') + end + end + + context 'when lex-prompt is not loaded' do + before do + stub_llm_started + app.helpers do + define_method(:prompt_client) do + halt 503, json_error('prompt_unavailable', 'lex-prompt is not loaded', status_code: 503) + end + end + end + + it 'returns 503 with prompt_unavailable code' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'hello' } }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('prompt_unavailable') + end + end + + context 'when prompt not found' do + before do + stub_llm_started + stub_prompt_client(build_prompt_client(render_result: { error: 'not_found' })) + end + + it 'returns 404' do + post '/api/prompts/missing/run', + Legion::JSON.dump({ variables: {} }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(404) + end + + it 'returns not_found error code' do + post '/api/prompts/missing/run', + Legion::JSON.dump({ variables: {} }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('not_found') + end + end + + context 'when version not found' do + before do + stub_llm_started + stub_prompt_client(build_prompt_client(render_result: { error: 'version_not_found' })) + end + + it 'returns 422' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: {}, version: 99 }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + end + + it 'returns version_not_found error code' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: {}, version: 99 }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('version_not_found') + end + end + + context 'when prompt renders and LLM responds' do + before do + stub_llm_started + stub_llm_sync_response(content: 'This is a greeting.', model_name: 'claude-sonnet-4-6', + input_tokens: 10, output_tokens: 5) + stub_prompt_client(build_prompt_client( + render_result: { rendered: 'Summarize: Hello world', prompt_version: 3 } + )) + end + + it 'returns 200' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'Hello world' } }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + end + + it 'includes the prompt name' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'Hello world' } }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:name]).to eq('summarizer') + end + + it 'includes the rendered_prompt' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'Hello world' } }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:rendered_prompt]).to eq('Summarize: Hello world') + end + + it 'includes the LLM response' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'Hello world' } }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:response]).to eq('This is a greeting.') + end + + it 'includes usage with input and output tokens' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'Hello world' } }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:usage][:input_tokens]).to eq(10) + expect(body[:data][:usage][:output_tokens]).to eq(5) + end + + it 'includes the model used' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'Hello world' } }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:model]).to eq('claude-sonnet-4-6') + end + + it 'includes the version' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'Hello world' } }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:data][:version]).to eq(3) + end + + it 'passes variables to render_prompt' do + client = double('PromptClient') + allow(client).to receive(:render_prompt) + .with(hash_including(name: 'summarizer', variables: { text: 'Hello world' })) + .and_return({ rendered: 'Summarize: Hello world', prompt_version: 3 }) + stub_prompt_client(client) + + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: { text: 'Hello world' } }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + end + + it 'passes model and provider to chat' do + expect(Legion::LLM).to receive(:chat) + .with(hash_including(model: 'claude-opus-4-6', provider: 'bedrock')) + .and_call_original + stub_llm_sync_response + + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: {}, model: 'claude-opus-4-6', provider: 'bedrock' }), + 'CONTENT_TYPE' => 'application/json' + end + + it 'includes meta with timestamp and node' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: {} }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:meta]).to have_key(:timestamp) + expect(body[:meta][:node]).to eq('test-node') + end + end + + context 'when LLM raises during run' do + before do + stub_llm_started + stub_prompt_client(build_prompt_client( + render_result: { rendered: 'Summarize: Hello world', prompt_version: 1 } + )) + allow(Legion::LLM).to receive(:chat).and_raise(StandardError, 'provider timeout') + end + + it 'returns 500 with execution_error' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: {} }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(500) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('execution_error') + end + + it 'includes the error message' do + post '/api/prompts/summarizer/run', + Legion::JSON.dump({ variables: {} }), + 'CONTENT_TYPE' => 'application/json' + body = Legion::JSON.load(last_response.body) + expect(body[:error][:message]).to include('provider timeout') + end + end + + context 'when body is empty' do + before do + stub_llm_started + stub_llm_sync_response + stub_prompt_client(build_prompt_client( + render_result: { rendered: 'Summarize: ', prompt_version: 1 } + )) + end + + it 'defaults variables to empty hash and succeeds' do + post '/api/prompts/summarizer/run', '', 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + end + end + end +end diff --git a/spec/legion/api/skills_spec.rb b/spec/legion/api/skills_spec.rb new file mode 100644 index 00000000..9ec830ff --- /dev/null +++ b/spec/legion/api/skills_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'legion/api' + +RSpec.describe Legion::API, 'skills routes' do + include Rack::Test::Methods + + def app + Legion::API + end + + before do + skill_dbl = double(:skill, + skill_name: 'brainstorming', + namespace: 'superpowers', + description: 'Brainstorm', + trigger: :on_demand, + follows_skill: nil, + steps: %i[step1]) + registry = Module.new do + define_singleton_method(:all) { [skill_dbl] } + define_singleton_method(:find) do |key| + return nil unless key == 'superpowers:brainstorming' + + skill_dbl + end + end + stub_const('Legion::LLM::Skills::Registry', registry) + end + + describe 'GET /api/skills' do + it 'returns 200 with skill list' do + get '/api/skills' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to be_an(Array) + end + end + + describe 'GET /api/skills/:namespace/:name' do + it 'returns 200 for known skill' do + get '/api/skills/superpowers/brainstorming' + expect(last_response.status).to eq(200) + end + + it 'returns 404 for unknown skill' do + get '/api/skills/unknown/nope' + expect(last_response.status).to eq(404) + end + end + + describe 'POST /api/skills/invoke' do + let(:executor_result) do + double(:result, message: { content: 'skill output' }) + end + + let(:executor_class) do + klass = double(:executor_class) + allow(klass).to receive(:new).and_return(double(:executor, call: executor_result)) + klass + end + + before do + conv_store = Module.new do + def self.set_skill_state(_id, **) = nil + def self.clear_skill_state(_id) = nil + end + request_class = double(:request_class) + allow(request_class).to receive(:build).and_return(double(:req)) + stub_const('Legion::LLM::ConversationStore', conv_store) + stub_const('Legion::LLM::Inference::Request', request_class) + stub_const('Legion::LLM::Inference::Executor', executor_class) + end + + it 'returns 200 with content on success' do + post '/api/skills/invoke', + Legion::JSON.dump({ skill_name: 'superpowers:brainstorming' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body.dig(:data, :content)).to eq('skill output') + end + + it 'returns 422 when skill_name is missing' do + post '/api/skills/invoke', + Legion::JSON.dump({}), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + end + + it 'returns 404 when skill is not found' do + post '/api/skills/invoke', + Legion::JSON.dump({ skill_name: 'unknown:nope' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(404) + end + + it 'returns 500 and clears state when executor raises' do + allow(executor_class).to receive(:new).and_raise(StandardError, 'boom') + post '/api/skills/invoke', + Legion::JSON.dump({ skill_name: 'superpowers:brainstorming' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(500) + end + end + + describe 'DELETE /api/skills/active/:conversation_id' do + let(:conv_store) do + Module.new do + def self.cancel_skill!(_id) = nil + end + end + + before { stub_const('Legion::LLM::ConversationStore', conv_store) } + + it 'returns 204 when skill was active' do + allow(conv_store).to receive(:cancel_skill!) + .with('conv-123').and_return({ skill_key: 'superpowers:brainstorming' }) + allow(Legion::Events).to receive(:emit) + delete '/api/skills/active/conv-123' + expect(last_response.status).to eq(204) + end + + it 'returns 204 when no active skill' do + allow(conv_store).to receive(:cancel_skill!).and_return(nil) + delete '/api/skills/active/conv-none' + expect(last_response.status).to eq(204) + end + end +end diff --git a/spec/legion/api/stats_spec.rb b/spec/legion/api/stats_spec.rb new file mode 100644 index 00000000..38b65e6b --- /dev/null +++ b/spec/legion/api/stats_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/api/stats' + +RSpec.describe Legion::API::Routes::Stats do + before do + Legion::Extensions.reset_runtime_handles! + Legion::Extensions.instance_variable_set(:@loaded_extensions, %w[legacy-only]) + end + + after do + Legion::Extensions.reset_runtime_handles! + Legion::Extensions.instance_variable_set(:@loaded_extensions, nil) + end + + it 'counts loaded and running extensions from runtime handles instead of ivars' do + Legion::Extensions.register_extension_handle('lex-loaded', state: :loaded) + Legion::Extensions.register_extension_handle('lex-running', state: :running) + + stats = described_class.collect_extensions + + expect(stats[:loaded]).to eq(2) + expect(stats[:running]).to eq(1) + end +end diff --git a/spec/legion/api/tenants_spec.rb b/spec/legion/api/tenants_spec.rb new file mode 100644 index 00000000..b498b852 --- /dev/null +++ b/spec/legion/api/tenants_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/validators' +require 'legion/api/tenants' + +RSpec.describe 'Tenants API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + loader = Legion::Settings.loader + loader.settings[:client] = { name: 'test-node', ready: true } + loader.settings[:data] = { connected: false } + loader.settings[:transport] = { connected: false } + loader.settings[:extensions] = {} + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + helpers Legion::API::Validators + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + register Legion::API::Routes::Tenants + end + end + + def app + test_app + end + + describe 'POST /api/tenants' do + it 'returns 201 when a tenant is created' do + tenants_mod = Module.new do + def self.create(**attrs) + attrs + end + end + stub_const('Legion::Tenants', tenants_mod) + + post '/api/tenants', + Legion::JSON.dump({ tenant_id: 'askid-001', name: 'Core Platform', max_workers: 12 }), + 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(201) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:tenant_id]).to eq('askid-001') + expect(body[:data][:max_workers]).to eq(12) + end + + it 'returns 409 when the tenant create call reports a conflict' do + tenants_mod = Module.new do + def self.create(**) + { error: 'tenant_exists' } + end + end + stub_const('Legion::Tenants', tenants_mod) + + post '/api/tenants', + Legion::JSON.dump({ tenant_id: 'askid-001', name: 'Core Platform' }), + 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(409) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:error]).to eq('tenant_exists') + end + end + + describe 'GET /api/tenants' do + it 'returns the tenant list positionally through json_response' do + tenants_mod = Module.new do + def self.list + [{ tenant_id: 'askid-001' }] + end + end + stub_const('Legion::Tenants', tenants_mod) + + get '/api/tenants' + + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to eq([{ tenant_id: 'askid-001' }]) + end + end + + describe 'GET /api/tenants/:tenant_id' do + it 'returns a structured 404 when a tenant is missing' do + tenants_mod = Module.new do + def self.find(_tenant_id) + nil + end + end + stub_const('Legion::Tenants', tenants_mod) + + get '/api/tenants/missing' + + expect(last_response.status).to eq(404) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('not_found') + end + end +end diff --git a/spec/legion/api/tls_spec.rb b/spec/legion/api/tls_spec.rb new file mode 100644 index 00000000..68a95041 --- /dev/null +++ b/spec/legion/api/tls_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'sinatra/base' +require 'puma' +require 'legion/api/default_settings' + +RSpec.describe Legion::Service do + describe '#setup_api' do + let(:service) { described_class.allocate } + let(:api_defaults) { Legion::API::Settings.default } + + before do + # Evaluate api_defaults before stub_const replaces Legion::API + api_defaults + stub_const('Legion::API', Class.new do + def self.set(*); end + + def self.run!(**); end + + def self.running? = false + end) + allow(service).to receive(:require).and_return(true) + allow(Legion::Settings).to receive(:[]).and_call_original + end + + context 'when api.tls.enabled is false (default)' do + before do + allow(Legion::Settings).to receive(:[]).with(:api).and_return( + api_defaults.merge(tls: { enabled: false }) + ) + end + + it 'does not configure ssl_bind on puma' do + expect(Legion::API).not_to receive(:set).with(:ssl_bind_options, anything) + allow(Legion::API).to receive(:set) + allow(Thread).to receive(:new).and_return(double(join: nil)) + service.send(:setup_api) + end + end + + context 'when api.tls.enabled is true with cert and key' do + let(:cert_path) { '/etc/ssl/server.crt' } + let(:key_path) { '/etc/ssl/server.key' } + + before do + allow(Legion::Settings).to receive(:[]).with(:api).and_return( + api_defaults.merge( + tls: { enabled: true, cert: cert_path, key: key_path, ca: nil, verify: 'peer' } + ) + ) + end + + it 'sets ssl_bind_options on the Legion::API Sinatra app' do + ssl_opts = nil + allow(Legion::API).to receive(:set) do |key, val| + ssl_opts = val if key == :ssl_bind_options + end + allow(Thread).to receive(:new).and_return(double(join: nil)) + service.send(:setup_api) + expect(ssl_opts).to include(cert: cert_path, key: key_path) + end + + it 'sets server_settings to include ssl configuration' do + server_settings = nil + allow(Legion::API).to receive(:set) do |key, val| + server_settings = val if key == :server_settings + end + allow(Thread).to receive(:new).and_return(double(join: nil)) + service.send(:setup_api) + expect(server_settings).to be_a(Hash) + end + end + + context 'when api.tls.enabled is true but cert is missing' do + before do + allow(Legion::Settings).to receive(:[]).with(:api).and_return( + api_defaults.merge(tls: { enabled: true, cert: nil, key: nil }) + ) + allow(Legion::Settings).to receive(:[]).with(:logging).and_return(nil) + allow(Legion::Logging).to receive(:warn) + allow(Legion::Logging).to receive(:error) + allow(Legion::Logging).to receive(:emit_tagged) do |level, msg, **| + Legion::Logging.public_send(level, msg) if Legion::Logging.respond_to?(level) + end + end + + it 'logs a warning and falls back to plain HTTP' do + expect(Legion::Logging).to receive(:warn).with(match(/api.tls/i)) + allow(Thread).to receive(:new).and_return(double(join: nil)) + allow(Legion::API).to receive(:set) + allow(Legion::API).to receive(:use) + service.send(:setup_api) + end + end + end +end diff --git a/spec/legion/api/traces_spec.rb b/spec/legion/api/traces_spec.rb new file mode 100644 index 00000000..092f91c5 --- /dev/null +++ b/spec/legion/api/traces_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/traces' + +RSpec.describe 'Traces API routes' do + include Rack::Test::Methods + + before(:all) do + Legion::Logging.setup(log_level: 'fatal', level: 'fatal', trace: false) + Legion::Settings.load(config_dir: File.expand_path('../../..', __dir__)) + end + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + error do + content_type :json + err = env['sinatra.error'] + status 500 + Legion::JSON.dump({ error: { code: 'internal_error', message: err.message } }) + end + + register Legion::API::Routes::Traces + end + end + + def app + test_app + end + + describe 'POST /api/traces/search' do + context 'when LLM is not available' do + before do + hide_const('Legion::LLM') + end + + it 'returns 503' do + post '/api/traces/search', Legion::JSON.dump({ query: 'test' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('trace_search_unavailable') + end + end + + context 'when TraceSearch is available' do + before do + stub_const('Legion::LLM', Module.new) + trace_mod = Module.new do + def self.search(*, **) + { results: [{ id: 1, status: 'success' }], count: 1, total: 1, truncated: false, filter: {} } + end + + def self.summarize(*) + { total_records: 10 } + end + + def self.detect_anomalies(**) + { anomalies: [], recent_count: 5, baseline_count: 50 } + end + end + stub_const('Legion::TraceSearch', trace_mod) + end + + it 'returns 422 when query is missing' do + post '/api/traces/search', Legion::JSON.dump({}), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('missing_field') + end + + it 'returns search results' do + post '/api/traces/search', Legion::JSON.dump({ query: 'failed tasks' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:results]).to be_an(Array) + expect(body[:data][:count]).to eq(1) + end + + it 'passes custom limit' do + allow(Legion::TraceSearch).to receive(:search).with('test', limit: 10).and_return( + { results: [], count: 0, total: 0, truncated: false, filter: {} } + ) + post '/api/traces/search', Legion::JSON.dump({ query: 'test', limit: 10 }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + end + end + end + + describe 'POST /api/traces/summary' do + context 'when LLM is not available' do + before do + hide_const('Legion::LLM') + end + + it 'returns 503' do + post '/api/traces/summary', Legion::JSON.dump({ query: 'test' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(503) + end + end + + context 'when TraceSearch is available' do + before do + stub_const('Legion::LLM', Module.new) + trace_mod = Module.new do + def self.summarize(*) + { total_records: 42, total_cost: 1.23 } + end + end + stub_const('Legion::TraceSearch', trace_mod) + end + + it 'returns 422 when query is missing' do + post '/api/traces/summary', Legion::JSON.dump({}), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(422) + end + + it 'returns summary data' do + post '/api/traces/summary', Legion::JSON.dump({ query: 'all tasks today' }), 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:total_records]).to eq(42) + end + end + end + + describe 'GET /api/traces/anomalies' do + context 'when LLM is not available' do + before do + hide_const('Legion::LLM') + end + + it 'returns 503' do + get '/api/traces/anomalies' + expect(last_response.status).to eq(503) + end + end + + context 'when TraceSearch is available' do + before do + stub_const('Legion::LLM', Module.new) + trace_mod = Module.new do + def self.detect_anomalies(**) + { anomalies: [], recent_count: 10, baseline_count: 100 } + end + end + stub_const('Legion::TraceSearch', trace_mod) + end + + it 'returns anomaly report' do + get '/api/traces/anomalies' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:anomalies]).to be_an(Array) + expect(body[:data][:recent_count]).to eq(10) + end + + it 'accepts custom threshold' do + allow(Legion::TraceSearch).to receive(:detect_anomalies).with(threshold: 3.5).and_return( + { anomalies: [], recent_count: 10, baseline_count: 100 } + ) + get '/api/traces/anomalies', threshold: '3.5' + expect(last_response.status).to eq(200) + end + end + end + + describe 'GET /api/traces/trend' do + context 'when LLM is not available' do + before { hide_const('Legion::LLM') } + + it 'returns 503' do + get '/api/traces/trend' + expect(last_response.status).to eq(503) + end + end + + context 'when TraceSearch is available' do + before do + stub_const('Legion::LLM', Module.new) + trace_mod = Module.new do + def self.trend(**) + { buckets: [{ time: '2026-03-23T00:00:00Z', count: 10 }], hours: 24, bucket_count: 12 } + end + end + stub_const('Legion::TraceSearch', trace_mod) + end + + it 'returns trend data' do + get '/api/traces/trend' + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data][:buckets]).to be_an(Array) + end + + it 'accepts custom hours and buckets' do + allow(Legion::TraceSearch).to receive(:trend).with(hours: 6, buckets: 6).and_return( + { buckets: [], hours: 6, bucket_count: 6 } + ) + get '/api/traces/trend', hours: '6', buckets: '6' + expect(last_response.status).to eq(200) + end + end + end +end diff --git a/spec/legion/api/validators_spec.rb b/spec/legion/api/validators_spec.rb new file mode 100644 index 00000000..85a6ed3b --- /dev/null +++ b/spec/legion/api/validators_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/validators' + +RSpec.describe Legion::API::Validators do + include Rack::Test::Methods + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + helpers Legion::API::Validators + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + post '/test/required' do + body = parse_request_body + validate_required!(body, :name, :type) + json_response({ valid: true }) + end + + post '/test/length' do + body = parse_request_body + validate_string_length!(body[:name], field: 'name', max: 10) + json_response({ valid: true }) + end + + post '/test/enum' do + body = parse_request_body + validate_enum!(body[:status], field: 'status', allowed: %w[active paused]) + json_response({ valid: true }) + end + + post '/test/uuid' do + body = parse_request_body + validate_uuid!(body[:id], field: 'id') + json_response({ valid: true }) + end + end + end + + def app + test_app + end + + it 'passes when all required fields present' do + post '/test/required', Legion::JSON.dump({ name: 'test', type: 'a' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + end + + it 'rejects missing required fields' do + post '/test/required', Legion::JSON.dump({ name: 'test' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('missing_fields') + end + + it 'rejects too-long strings' do + post '/test/length', Legion::JSON.dump({ name: 'x' * 20 }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('field_too_long') + end + + it 'accepts valid enum values' do + post '/test/enum', Legion::JSON.dump({ status: 'active' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + end + + it 'rejects invalid enum values' do + post '/test/enum', Legion::JSON.dump({ status: 'invalid' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('invalid_value') + end + + it 'accepts valid UUIDs' do + post '/test/uuid', Legion::JSON.dump({ id: '550e8400-e29b-41d4-a716-446655440000' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(200) + end + + it 'rejects invalid UUIDs' do + post '/test/uuid', Legion::JSON.dump({ id: 'not-a-uuid' }), + 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + body = Legion::JSON.load(last_response.body) + expect(body[:error][:code]).to eq('invalid_format') + end +end diff --git a/spec/legion/api/webhooks_spec.rb b/spec/legion/api/webhooks_spec.rb new file mode 100644 index 00000000..80e6f97a --- /dev/null +++ b/spec/legion/api/webhooks_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rack/test' +require 'sinatra/base' +require 'legion/api/helpers' +require 'legion/api/webhooks' + +RSpec.describe 'Webhooks API routes' do + include Rack::Test::Methods + + let(:test_app) do + Class.new(Sinatra::Base) do + helpers Legion::API::Helpers + + set :show_exceptions, false + set :raise_errors, false + set :host_authorization, permitted: :any + + register Legion::API::Routes::Webhooks + end + end + + def app + test_app + end + + describe 'GET /api/webhooks' do + it 'uses the loaded Legion::Webhooks implementation' do + allow(Legion::Webhooks).to receive(:list).and_return([]) + + get '/api/webhooks' + + expect(last_response.status).to eq(200) + body = Legion::JSON.load(last_response.body) + expect(body[:data]).to eq([]) + end + end +end diff --git a/spec/legion/audit/archiver_actor_spec.rb b/spec/legion/audit/archiver_actor_spec.rb new file mode 100644 index 00000000..23501cd2 --- /dev/null +++ b/spec/legion/audit/archiver_actor_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'sequel' +require 'legion/data/retention' +require 'legion/audit/archiver' +require 'legion/audit/archiver_actor' + +RSpec.describe Legion::Audit::ArchiverActor do + describe '.enabled?' do + it 'delegates to Archiver.enabled?' do + allow(Legion::Audit::Archiver).to receive(:enabled?).and_return(false) + expect(described_class.enabled?).to be false + end + end + + describe '#run_archival' do + it 'calls archive_to_warm and archive_to_cold when enabled and time matches' do + allow(Legion::Audit::Archiver).to receive(:enabled?).and_return(true) + allow(Legion::Audit::Archiver).to receive(:archive_to_warm).and_return({ moved: 0 }) + allow(Legion::Audit::Archiver).to receive(:archive_to_cold).and_return({ moved: 0 }) + allow(Legion::Audit::Archiver).to receive(:verify_chain).and_return({ valid: true, records_checked: 0, broken_links: [] }) + allow(Legion::Audit::Archiver).to receive(:verify_on_archive?).and_return(true) + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:error) + + # Force time to match schedule (Sunday = wday 0, hour = 2) + target_day = described_class.scheduled_day_of_week + target_hour = described_class.scheduled_hour + # Build a real Time that matches the scheduled wday and hour + now = Time.now.utc + days_ahead = (target_day - now.wday) % 7 + target_date = (now.to_date + days_ahead) + fake_time = Time.utc(target_date.year, target_date.month, target_date.day, target_hour, 0, 0) + allow(Time).to receive(:now).and_return(fake_time) + + actor = described_class.new + expect { actor.run_archival }.not_to raise_error + expect(Legion::Audit::Archiver).to have_received(:archive_to_warm) + end + + it 'is a no-op when disabled' do + allow(Legion::Audit::Archiver).to receive(:enabled?).and_return(false) + actor = described_class.new + expect(Legion::Audit::Archiver).not_to receive(:archive_to_warm) + actor.run_archival + end + + it 'is a no-op when day-of-week does not match' do + allow(Legion::Audit::Archiver).to receive(:enabled?).and_return(true) + + wrong_day = (described_class.scheduled_day_of_week + 1) % 7 + fake_time = instance_double(Time, wday: wrong_day, hour: described_class.scheduled_hour) + allow(fake_time).to receive(:utc).and_return(fake_time) + allow(Time).to receive(:now).and_return(fake_time) + + actor = described_class.new + expect(Legion::Audit::Archiver).not_to receive(:archive_to_warm) + actor.run_archival + end + end +end diff --git a/spec/legion/audit/archiver_spec.rb b/spec/legion/audit/archiver_spec.rb new file mode 100644 index 00000000..e84e354f --- /dev/null +++ b/spec/legion/audit/archiver_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'sequel' +require 'legion/data/retention' +require 'legion/audit/archiver' + +RSpec.describe Legion::Audit::Archiver do + before do + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:audit) do + { retention: { enabled: true, hot_days: 90, warm_days: 365, + cold_years: 7, cold_storage: '/tmp/audit-test/', + cold_backend: 'local', verify_on_archive: true } } + end + end + + describe '.enabled?' do + it 'returns true when setting is true' do + expect(described_class.enabled?).to be true + end + + it 'returns false when setting is absent' do + allow(Legion::Settings).to receive(:[]).with(:audit).and_return({ retention: {} }) + expect(described_class.enabled?).to be false + end + end + + describe '.archive_to_warm' do + it 'delegates to Retention.archive_old_records and returns result hash' do + allow(Legion::Data::Retention).to receive(:archive_old_records) + .with(table: :audit_log, archive_after_days: 90) + .and_return({ archived: 3, table: :audit_log }) + + result = described_class.archive_to_warm + expect(result).to eq({ moved: 3, from: :hot, to: :warm }) + end + + it 'returns no-op result when disabled' do + allow(described_class).to receive(:enabled?).and_return(false) + expect(described_class.archive_to_warm).to eq({ moved: 0, skipped: true }) + end + end + + describe '.archive_to_cold' do + let(:warm_record) do + { id: 1, event_type: 'runner_execution', principal_id: 'agent:test', + action: 'run', resource: 'lex-test.runner.fn', source: 'amqp', + status: 'success', detail: nil, record_hash: 'abc123', previous_hash: '0' * 64, + retention_tier: 'warm', created_at: Time.now - (400 * 86_400) } + end + + before do + ordered_ds = double('ordered_ds', all: [warm_record]) + filtered_ds = double('filtered_ds', count: 1, order: ordered_ds, delete: nil) + dataset = double('dataset', where: filtered_ds) + db = double('db', table_exists?: true) + allow(db).to receive(:[]).and_return(dataset) + allow(Legion::Data).to receive(:connection).and_return(db) + allow(Legion::Audit::ColdStorage).to receive(:upload).and_return({ path: '/tmp/audit-test/test.jsonl.gz' }) + allow(described_class).to receive(:write_manifest).and_return(true) + end + + it 'returns a result hash with moved count' do + result = described_class.archive_to_cold + expect(result).to have_key(:moved) + end + + it 'is a no-op when disabled' do + allow(described_class).to receive(:enabled?).and_return(false) + expect(described_class.archive_to_cold).to eq({ moved: 0, skipped: true }) + end + end + + describe '.verify_chain' do + let(:records) do + [ + { id: 1, record_hash: 'aaa', previous_hash: '0' * 64 }, + { id: 2, record_hash: 'bbb', previous_hash: 'aaa' } + ] + end + + it 'delegates to HashChain.verify_chain' do + allow(described_class).to receive(:load_records_for_tier).and_return(records) + allow(Legion::Audit::HashChain).to receive(:verify_chain).with(records) + .and_return({ valid: true, broken_links: [], records_checked: 2 }) + + result = described_class.verify_chain(tier: :warm) + expect(result[:valid]).to be true + expect(result[:records_checked]).to eq 2 + end + end +end diff --git a/spec/legion/audit/cold_storage_spec.rb b/spec/legion/audit/cold_storage_spec.rb new file mode 100644 index 00000000..e25a9cad --- /dev/null +++ b/spec/legion/audit/cold_storage_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/audit/cold_storage' + +RSpec.describe Legion::Audit::ColdStorage do + let(:tmpdir) { Dir.mktmpdir('cold_storage_spec') } + + before do + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:audit) do + { retention: { cold_backend: 'local', cold_storage: tmpdir } } + end + end + + after { FileUtils.rm_rf(tmpdir) } + + describe '.backend' do + it 'returns :local by default' do + expect(described_class.backend).to eq :local + end + + it 'returns :s3 when configured' do + allow(Legion::Settings).to receive(:[]).with(:audit) do + { retention: { cold_backend: 's3' } } + end + expect(described_class.backend).to eq :s3 + end + end + + describe '.upload / .download with :local backend' do + let(:test_data) { 'compressed-content-here' } + let(:test_path) { File.join(tmpdir, 'test_archive.jsonl.gz') } + + it 'writes data to the given path' do + result = described_class.upload(data: test_data, path: test_path) + expect(result[:path]).to eq test_path + expect(File.exist?(test_path)).to be true + end + + it 'reads back the same data' do + described_class.upload(data: test_data, path: test_path) + expect(described_class.download(path: test_path)).to eq test_data + end + + it 'creates intermediate directories' do + deep_path = File.join(tmpdir, 'a', 'b', 'c', 'archive.gz') + described_class.upload(data: test_data, path: deep_path) + expect(File.exist?(deep_path)).to be true + end + end + + describe '.upload with :s3 backend when Aws::S3::Client unavailable' do + before do + allow(Legion::Settings).to receive(:[]).with(:audit) do + { retention: { cold_backend: 's3' } } + end + hide_const('Aws::S3::Client') if defined?(Aws::S3::Client) + end + + it 'raises a descriptive error' do + expect { described_class.upload(data: 'x', path: 'bucket/key') } + .to raise_error(Legion::Audit::ColdStorage::BackendNotAvailableError, /aws-sdk-s3/) + end + end +end diff --git a/spec/legion/audit/hash_chain_spec.rb b/spec/legion/audit/hash_chain_spec.rb new file mode 100644 index 00000000..b30d0c15 --- /dev/null +++ b/spec/legion/audit/hash_chain_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/audit/hash_chain' + +RSpec.describe Legion::Audit::HashChain do + let(:base_record) do + { seq: 1, principal_id: 'w1', action: 'test', resource: 'task', source: 'mcp', + status: 'success', detail: '{}', created_at: '2026-03-16T00:00:00Z', + previous_hash: described_class::GENESIS_HASH } + end + + describe '.compute_hash' do + it 'returns a 64-character hex string' do + hash = described_class.compute_hash(base_record) + expect(hash).to match(/\A[a-f0-9]{64}\z/) + end + + it 'is deterministic' do + expect(described_class.compute_hash(base_record)).to eq(described_class.compute_hash(base_record)) + end + + it 'changes when any field changes' do + modified = base_record.merge(action: 'modified') + expect(described_class.compute_hash(base_record)).not_to eq(described_class.compute_hash(modified)) + end + + it 'changes when previous_hash changes' do + modified = base_record.merge(previous_hash: 'a' * 64) + expect(described_class.compute_hash(base_record)).not_to eq(described_class.compute_hash(modified)) + end + end + + describe '.canonical_payload' do + it 'includes all canonical fields' do + payload = described_class.canonical_payload(base_record) + described_class::CANONICAL_FIELDS.each do |field| + expect(payload).to include("#{field}:") + end + end + end + + describe '.verify_chain' do + it 'validates a correct chain' do + r1 = { id: 1, record_hash: 'aaa', previous_hash: described_class::GENESIS_HASH } + r2 = { id: 2, record_hash: 'bbb', previous_hash: 'aaa' } + r3 = { id: 3, record_hash: 'ccc', previous_hash: 'bbb' } + result = described_class.verify_chain([r1, r2, r3]) + expect(result[:valid]).to be true + expect(result[:broken_links]).to be_empty + expect(result[:records_checked]).to eq(3) + end + + it 'detects a broken link' do + r1 = { id: 1, record_hash: 'aaa', previous_hash: described_class::GENESIS_HASH } + r2 = { id: 2, record_hash: 'bbb', previous_hash: 'TAMPERED' } + result = described_class.verify_chain([r1, r2]) + expect(result[:valid]).to be false + expect(result[:broken_links].size).to eq(1) + expect(result[:broken_links].first[:id]).to eq(2) + end + + it 'handles single record' do + r1 = { id: 1, record_hash: 'aaa', previous_hash: described_class::GENESIS_HASH } + result = described_class.verify_chain([r1]) + expect(result[:valid]).to be true + end + + it 'handles empty array' do + result = described_class.verify_chain([]) + expect(result[:valid]).to be true + expect(result[:records_checked]).to eq(0) + end + + it 'detects a gap in sequence numbers' do + r1 = { id: 1, seq: 1, record_hash: 'aaa', previous_hash: described_class::GENESIS_HASH } + r2 = { id: 2, seq: 3, record_hash: 'bbb', previous_hash: 'aaa' } + result = described_class.verify_chain([r1, r2]) + expect(result[:valid]).to be false + gap = result[:broken_links].find { |b| b[:type] == :gap } + expect(gap).not_to be_nil + expect(gap[:expected_seq]).to eq(2) + expect(gap[:got_seq]).to eq(3) + end + + it 'passes when sequence numbers are contiguous' do + r1 = { id: 1, seq: 1, record_hash: 'aaa', previous_hash: described_class::GENESIS_HASH } + r2 = { id: 2, seq: 2, record_hash: 'bbb', previous_hash: 'aaa' } + r3 = { id: 3, seq: 3, record_hash: 'ccc', previous_hash: 'bbb' } + result = described_class.verify_chain([r1, r2, r3]) + expect(result[:valid]).to be true + end + + it 'skips gap check when seq is absent for backwards compatibility' do + r1 = { id: 1, record_hash: 'aaa', previous_hash: described_class::GENESIS_HASH } + r2 = { id: 2, record_hash: 'bbb', previous_hash: 'aaa' } + result = described_class.verify_chain([r1, r2]) + expect(result[:valid]).to be true + end + end +end diff --git a/spec/legion/audit/siem_export_spec.rb b/spec/legion/audit/siem_export_spec.rb new file mode 100644 index 00000000..851d7a21 --- /dev/null +++ b/spec/legion/audit/siem_export_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/audit/siem_export' + +RSpec.describe Legion::Audit::SiemExport do + let(:records) do + [ + { created_at: '2026-03-16T00:00:00Z', event_type: 'runner_execution', + principal_id: 'w1', action: 'mcp.run_task', resource: 'task', + status: 'success', detail: '{}', record_hash: 'abc', previous_hash: '0' * 64 } + ] + end + + describe '.export_batch' do + it 'transforms records to SIEM format' do + result = described_class.export_batch(records) + expect(result.size).to eq(1) + expect(result.first[:source]).to eq('legion') + expect(result.first[:integrity][:algorithm]).to eq('SHA256') + end + + it 'handles empty records' do + expect(described_class.export_batch([])).to eq([]) + end + end + + describe '.to_ndjson' do + it 'returns newline-delimited JSON' do + result = described_class.to_ndjson(records) + expect(result).to be_a(String) + expect(result.lines.size).to eq(1) + end + end +end diff --git a/spec/legion/audit_spec.rb b/spec/legion/audit_spec.rb new file mode 100644 index 00000000..634dff4d --- /dev/null +++ b/spec/legion/audit_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/audit' + +RSpec.describe Legion::Audit do + let(:valid_opts) do + { + event_type: 'runner_execution', + principal_id: 'worker-123', + action: 'execute', + resource: 'MyRunner/my_function', + source: 'amqp' + } + end + + describe '.record' do + context 'when transport is available and lex-audit is loaded' do + let(:message_double) { instance_double('Message', publish: true) } + + before do + stub_const('Legion::Transport', Module.new) + stub_const('Legion::Extensions::Audit::Transport::Messages::Audit', Class.new) + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ connected: true }) + allow(Legion::Settings).to receive(:[]).with(:client).and_return({ hostname: 'node-01' }) + allow(Legion::Extensions::Audit::Transport::Messages::Audit).to receive(:new).and_return(message_double) + end + + it 'publishes a message' do + described_class.record(**valid_opts) + expect(message_double).to have_received(:publish) + end + + it 'stamps node from settings' do + described_class.record(**valid_opts) + expect(Legion::Extensions::Audit::Transport::Messages::Audit).to have_received(:new).with( + hash_including(node: 'node-01') + ) + end + + it 'stamps created_at as ISO8601' do + described_class.record(**valid_opts) + expect(Legion::Extensions::Audit::Transport::Messages::Audit).to have_received(:new).with( + hash_including(created_at: match(/^\d{4}-\d{2}-\d{2}T/)) + ) + end + end + + context 'when transport is not connected' do + before do + stub_const('Legion::Transport', Module.new) + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ connected: false }) + end + + it 'silently returns nil' do + expect(described_class.record(**valid_opts)).to be_nil + end + end + + context 'when lex-audit message class is not defined' do + before do + stub_const('Legion::Transport', Module.new) + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ connected: true }) + # Legion::Extensions::Audit::Transport::Messages::Audit is NOT defined + end + + it 'silently returns nil' do + expect(described_class.record(**valid_opts)).to be_nil + end + end + + context 'when publishing raises an error' do + let(:message_double) { instance_double('Message') } + + before do + stub_const('Legion::Transport', Module.new) + stub_const('Legion::Extensions::Audit::Transport::Messages::Audit', Class.new) + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ connected: true }) + allow(Legion::Settings).to receive(:[]).with(:client).and_return({ hostname: 'node-01' }) + allow(Legion::Extensions::Audit::Transport::Messages::Audit).to receive(:new).and_return(message_double) + allow(message_double).to receive(:publish).and_raise(StandardError, 'connection lost') + end + + it 'never raises' do + expect { described_class.record(**valid_opts) }.not_to raise_error + end + end + end + + describe '.recent_for' do + context 'when Legion::Data::Model::AuditLog is not defined' do + it 'returns an empty array' do + expect(described_class.recent_for(principal_id: 'w-1')).to eq([]) + end + end + + context 'when Legion::Data::Model::AuditLog is defined' do + let(:model_double) { class_double('Legion::Data::Model::AuditLog') } + let(:dataset) { instance_double('Sequel::Dataset') } + + before do + stub_const('Legion::Data::Model::AuditLog', model_double) + allow(model_double).to receive(:where).and_return(dataset) + allow(dataset).to receive(:where).and_return(dataset) + allow(dataset).to receive(:all).and_return([double('row')]) + end + + it 'delegates to the model with principal_id filter' do + result = described_class.recent_for(principal_id: 'w-1', window: 60) + expect(result).not_to be_empty + end + + it 'applies event_type filter when given' do + described_class.recent_for(principal_id: 'w-1', event_type: 'runner_execution') + expect(dataset).to have_received(:where).with(event_type: 'runner_execution') + end + + it 'applies status filter when given' do + described_class.recent_for(principal_id: 'w-1', status: 'failure') + expect(dataset).to have_received(:where).with(status: 'failure') + end + end + end + + describe '.count_for' do + context 'when Legion::Data::Model::AuditLog is not defined' do + it 'returns 0' do + expect(described_class.count_for(principal_id: 'w-1')).to eq(0) + end + end + + context 'when Legion::Data::Model::AuditLog is defined' do + let(:model_double) { class_double('Legion::Data::Model::AuditLog') } + let(:dataset) { instance_double('Sequel::Dataset') } + + before do + stub_const('Legion::Data::Model::AuditLog', model_double) + allow(model_double).to receive(:where).and_return(dataset) + allow(dataset).to receive(:where).and_return(dataset) + allow(dataset).to receive(:count).and_return(7) + end + + it 'returns the model count' do + expect(described_class.count_for(principal_id: 'w-1')).to eq(7) + end + end + end + + describe '.failure_count_for' do + it 'delegates to count_for with status: failure' do + allow(described_class).to receive(:count_for).and_return(3) + described_class.failure_count_for(principal_id: 'w-1') + expect(described_class).to have_received(:count_for).with( + principal_id: 'w-1', window: 3600, status: 'failure' + ) + end + end + + describe '.success_count_for' do + it 'delegates to count_for with status: success' do + allow(described_class).to receive(:count_for).and_return(5) + described_class.success_count_for(principal_id: 'w-1') + expect(described_class).to have_received(:count_for).with( + principal_id: 'w-1', window: 3600, status: 'success' + ) + end + end + + describe '.resources_for' do + context 'when Legion::Data::Model::AuditLog is not defined' do + it 'returns an empty array' do + expect(described_class.resources_for(principal_id: 'w-1')).to eq([]) + end + end + end + + describe '.recent' do + context 'when Legion::Data::Model::AuditLog is not defined' do + it 'returns an empty array' do + expect(described_class.recent).to eq([]) + end + end + end +end diff --git a/spec/legion/auth/oauth_callback_spec.rb b/spec/legion/auth/oauth_callback_spec.rb new file mode 100644 index 00000000..4841b7c8 --- /dev/null +++ b/spec/legion/auth/oauth_callback_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/auth/oauth_callback' + +RSpec.describe Legion::Auth::OauthCallback do + let(:server) do + instance_double( + TCPServer, + addr: ['AF_INET', 42_424, '127.0.0.1', '127.0.0.1'], + close: nil + ) + end + + before do + allow(TCPServer).to receive(:new).with('127.0.0.1', 0).and_return(server) + end + + describe '#initialize' do + it 'allocates a random port' do + cb = described_class.new + expect(cb.port).to eq(42_424) + cb.close + end + + it 'sets redirect_uri with the allocated port' do + cb = described_class.new + expect(cb.redirect_uri).to start_with('http://127.0.0.1:') + expect(cb.redirect_uri).to end_with('/callback') + cb.close + end + end + + describe '#wait_for_callback' do + it 'receives the authorization code from the callback' do + cb = described_class.new + client = instance_double( + TCPSocket, + gets: "GET /callback?code=auth-code-123&state=xyz HTTP/1.1\r\n", + close: nil + ) + allow(client).to receive(:puts) + allow(server).to receive(:accept).and_return(client) + + result = cb.wait_for_callback + + expect(result[:code]).to eq('auth-code-123') + expect(result[:state]).to eq('xyz') + end + + it 'raises Timeout::Error when no callback arrives' do + cb = described_class.new(timeout: 0.1) + allow(server).to receive(:accept) { sleep 0.2 } + + expect { cb.wait_for_callback }.to raise_error(Timeout::Error) + end + end +end diff --git a/spec/legion/auth/token_manager_spec.rb b/spec/legion/auth/token_manager_spec.rb new file mode 100644 index 00000000..0948e6f7 --- /dev/null +++ b/spec/legion/auth/token_manager_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/auth/token_manager' + +RSpec.describe Legion::Auth::TokenManager do + let(:manager) { described_class.new(provider: :microsoft) } + let(:mock_secret) { {} } + + before { allow(manager).to receive(:secret).and_return(mock_secret) } + + describe '#token_valid?' do + it 'returns false when no token stored' do + expect(manager.token_valid?).to be false + end + + it 'returns true when token is fresh' do + mock_secret[:microsoft_token_expires_at] = (Time.now + 3600).iso8601 + mock_secret[:microsoft_access_token] = 'valid-token' + expect(manager.token_valid?).to be true + end + + it 'returns false when token is expiring soon (75% threshold)' do + mock_secret[:microsoft_token_expires_at] = (Time.now + 60).iso8601 + mock_secret[:microsoft_access_token] = 'valid-token' + mock_secret[:microsoft_token_ttl] = 3600 + expect(manager.token_valid?).to be false + end + end + + describe '#store_tokens' do + it 'stores access and refresh tokens' do + manager.store_tokens( + access_token: 'at-123', + refresh_token: 'rt-456', + expires_in: 3600, + scope: 'Calendars.Read' + ) + expect(mock_secret[:microsoft_access_token]).to eq('at-123') + expect(mock_secret[:microsoft_refresh_token]).to eq('rt-456') + end + end + + describe '#ensure_valid_token' do + it 'returns cached token when still valid' do + mock_secret[:microsoft_access_token] = 'cached' + mock_secret[:microsoft_token_expires_at] = (Time.now + 3600).iso8601 + expect(manager.ensure_valid_token).to eq('cached') + end + end +end diff --git a/spec/legion/capacity/model_spec.rb b/spec/legion/capacity/model_spec.rb new file mode 100644 index 00000000..a684d81f --- /dev/null +++ b/spec/legion/capacity/model_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/capacity/model' + +RSpec.describe Legion::Capacity::Model do + let(:workers) do + [ + { worker_id: 'w1', status: 'active' }, + { worker_id: 'w2', status: 'active' }, + { worker_id: 'w3', status: 'stopped' } + ] + end + let(:model) { described_class.new(workers: workers) } + + describe '#aggregate' do + it 'counts active workers' do + result = model.aggregate + expect(result[:total_workers]).to eq(3) + expect(result[:active_workers]).to eq(2) + end + + it 'calculates throughput' do + result = model.aggregate + expect(result[:max_throughput_tps]).to eq(20) + expect(result[:effective_throughput_tps]).to eq(14) + end + end + + describe '#forecast' do + it 'projects growth' do + result = model.forecast(days: 30, growth_rate: 0.5) + expect(result[:projected_workers]).to be > model.aggregate[:active_workers] + end + + it 'handles zero growth' do + result = model.forecast(days: 30, growth_rate: 0.0) + expect(result[:projected_workers]).to eq(model.aggregate[:active_workers]) + end + end + + describe '#per_worker_stats' do + it 'returns stats per worker' do + stats = model.per_worker_stats + expect(stats.size).to eq(3) + active = stats.find { |s| s[:worker_id] == 'w1' } + expect(active[:capacity_tps]).to eq(10) + end + + it 'returns zero capacity for inactive workers' do + stats = model.per_worker_stats + stopped = stats.find { |s| s[:worker_id] == 'w3' } + expect(stopped[:capacity_tps]).to eq(0) + end + end +end diff --git a/spec/legion/catalog_spec.rb b/spec/legion/catalog_spec.rb new file mode 100644 index 00000000..bc30c1ea --- /dev/null +++ b/spec/legion/catalog_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/catalog' + +RSpec.describe Legion::Catalog do + describe '.collect_mcp_tools' do + it 'returns empty array when MCP unavailable' do + expect(described_class.collect_mcp_tools).to eq([]) + end + end +end diff --git a/spec/legion/chat/notification_bridge_spec.rb b/spec/legion/chat/notification_bridge_spec.rb new file mode 100644 index 00000000..39d828f2 --- /dev/null +++ b/spec/legion/chat/notification_bridge_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/chat/notification_queue' +require 'legion/chat/notification_bridge' + +RSpec.describe Legion::Chat::NotificationBridge do + let(:queue) { Legion::Chat::NotificationQueue.new } + let(:bridge) { described_class.new(queue: queue) } + + describe '#match_priority (via send)' do + it 'matches alert patterns as critical' do + priority = bridge.send(:match_priority, 'alert.fired') + expect(priority).to eq(:critical) + end + + it 'matches extinction wildcard as critical' do + priority = bridge.send(:match_priority, 'extinction.mesh_isolation') + expect(priority).to eq(:critical) + end + + it 'matches runner.failure as info' do + priority = bridge.send(:match_priority, 'runner.failure') + expect(priority).to eq(:info) + end + + it 'returns nil for unmatched events' do + priority = bridge.send(:match_priority, 'some.random.event') + expect(priority).to be_nil + end + end + + describe '#format_notification' do + it 'formats alert events' do + msg = bridge.send(:format_notification, 'alert.fired', { rule: 'error_spike', severity: 'warning' }) + expect(msg).to include('[ALERT]') + expect(msg).to include('error_spike') + end + + it 'formats extinction events' do + msg = bridge.send(:format_notification, 'extinction.mesh_isolation', {}) + expect(msg).to include('[EXTINCTION]') + end + + it 'formats runner failure events' do + msg = bridge.send(:format_notification, 'runner.failure', { extension: 'lex-http', function: 'get' }) + expect(msg).to include('[FAIL]') + end + + it 'formats unknown events with event name' do + msg = bridge.send(:format_notification, 'custom.event', {}) + expect(msg).to include('[custom.event]') + end + end + + describe '#pending_notifications' do + it 'returns empty when no notifications' do + expect(bridge.pending_notifications).to eq([]) + end + end + + describe '#has_urgent?' do + it 'delegates to queue' do + expect(bridge.has_urgent?).to be false + end + end +end diff --git a/spec/legion/chat/notification_queue_spec.rb b/spec/legion/chat/notification_queue_spec.rb new file mode 100644 index 00000000..cfc666df --- /dev/null +++ b/spec/legion/chat/notification_queue_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/chat/notification_queue' + +RSpec.describe Legion::Chat::NotificationQueue do + let(:queue) { described_class.new(max_size: 5) } + + describe '#push and #pop_all' do + it 'returns notifications by priority' do + queue.push(message: 'info msg', priority: :info) + queue.push(message: 'critical msg', priority: :critical) + results = queue.pop_all + expect(results.first[:priority]).to eq(:critical) + end + + it 'respects max_size' do + 6.times { |i| queue.push(message: "msg #{i}") } + expect(queue.size).to eq(5) + end + + it 'removes popped messages' do + queue.push(message: 'test') + queue.pop_all + expect(queue.size).to eq(0) + end + + it 'filters by max_priority' do + queue.push(message: 'debug', priority: :debug) + queue.push(message: 'critical', priority: :critical) + results = queue.pop_all(max_priority: :critical) + expect(results.size).to eq(1) + expect(results.first[:priority]).to eq(:critical) + end + end + + describe '#has_critical?' do + it 'returns true when critical message present' do + queue.push(message: 'alert', priority: :critical) + expect(queue.has_critical?).to be true + end + + it 'returns false when no critical messages' do + queue.push(message: 'info', priority: :info) + expect(queue.has_critical?).to be false + end + end + + describe '#clear' do + it 'empties the queue' do + queue.push(message: 'test') + queue.clear + expect(queue.size).to eq(0) + end + end +end diff --git a/spec/legion/chat/skills_spec.rb b/spec/legion/chat/skills_spec.rb new file mode 100644 index 00000000..fc4acadb --- /dev/null +++ b/spec/legion/chat/skills_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/chat/skills' +require 'tmpdir' + +RSpec.describe Legion::Chat::Skills do + describe '.discover' do + context 'when LLM::Skills is not available' do + it 'returns empty array when no skill dirs exist' do + hide_const('Legion::LLM::Skills') + allow(described_class).to receive(:skill_directories).and_return([]) + expect(described_class.discover).to eq([]) + end + + it 'returns descriptor hashes from skill directories' do + hide_const('Legion::LLM::Skills') + Dir.mktmpdir do |dir| + File.write(File.join(dir, 'one.md'), 'content') + File.write(File.join(dir, 'two.rb'), 'content') + allow(described_class).to receive(:skill_directories).and_return([dir]) + result = described_class.discover + expect(result.map { |h| h[:name] }).to contain_exactly('one', 'two') + expect(result).to all(include(source: :file)) + end + end + end + + context 'when LLM::Skills is available and started' do + it 'delegates to Registry.all and returns descriptor hashes' do + skill_class = instance_double('SkillClass', + skill_name: 'brainstorming', + namespace: 'superpowers', + description: 'Brainstorm ideas', + trigger: 'on_demand', + follows_skill: nil) + registry_mod = Module.new + allow(registry_mod).to receive(:all).and_return([skill_class]) + llm_mod = Module.new { def self.started? = true } + stub_const('Legion::LLM', llm_mod) + stub_const('Legion::LLM::Skills', Module.new) + stub_const('Legion::LLM::Skills::Registry', registry_mod) + result = described_class.discover + expect(result).to eq([{ name: 'brainstorming', namespace: 'superpowers', + prompt: nil, description: 'Brainstorm ideas', + source: :registry }]) + end + end + end + + describe '.find' do + context 'when LLM::Skills is not available' do + it 'returns nil when skill not found in file system' do + hide_const('Legion::LLM::Skills') + allow(described_class).to receive(:skill_directories).and_return([]) + expect(described_class.find('nonexistent')).to be_nil + end + + it 'returns descriptor hash when skill file found' do + hide_const('Legion::LLM::Skills') + Dir.mktmpdir do |dir| + path = File.join(dir, 'target.md') + File.write(path, 'content') + allow(described_class).to receive(:skill_directories).and_return([dir]) + result = described_class.find('target') + expect(result).to eq({ name: 'target', path: path, prompt: 'content', source: :file }) + end + end + end + + context 'when LLM::Skills is available and started' do + it 'delegates to Registry.find and returns descriptor hash' do + skill_class = instance_double('SkillClass', + skill_name: 'my_skill', + namespace: 'core', + description: 'A skill', + trigger: 'on_demand', + follows_skill: nil) + registry_mod = Module.new + allow(registry_mod).to receive(:find).with('my_skill').and_return(skill_class) + llm_mod = Module.new { def self.started? = true } + stub_const('Legion::LLM', llm_mod) + stub_const('Legion::LLM::Skills', Module.new) + stub_const('Legion::LLM::Skills::Registry', registry_mod) + result = described_class.find('my_skill') + expect(result).to eq({ name: 'my_skill', namespace: 'core', prompt: nil, + description: 'A skill', source: :registry }) + end + + it 'returns nil when Registry.find returns nil' do + registry_mod = Module.new + allow(registry_mod).to receive(:find).with('missing').and_return(nil) + llm_mod = Module.new { def self.started? = true } + stub_const('Legion::LLM', llm_mod) + stub_const('Legion::LLM::Skills', Module.new) + stub_const('Legion::LLM::Skills::Registry', registry_mod) + expect(described_class.find('missing')).to be_nil + end + end + end +end diff --git a/spec/legion/cli/absorb_command_spec.rb b/spec/legion/cli/absorb_command_spec.rb new file mode 100644 index 00000000..91c5b5cc --- /dev/null +++ b/spec/legion/cli/absorb_command_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/error' +require 'legion/cli/absorb_command' + +RSpec.describe Legion::CLI::AbsorbCommand do + let(:command) { described_class.new } + + before do + allow(command).to receive(:api_get).with('/api/absorbers').and_return([]) + allow(command).to receive(:api_get).with(%r{/api/absorbers/resolve}).and_return({ match: false }) + end + + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#list' do + it 'responds to list' do + expect(command).to respond_to(:list) + end + end + + describe '#url' do + it 'responds to url' do + expect(command).to respond_to(:url) + end + end + + describe '#resolve' do + it 'responds to resolve' do + expect(command).to respond_to(:resolve) + end + end +end diff --git a/spec/legion/cli/admin/purge_topology_spec.rb b/spec/legion/cli/admin/purge_topology_spec.rb new file mode 100644 index 00000000..dd8e6ed5 --- /dev/null +++ b/spec/legion/cli/admin/purge_topology_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/admin/purge_topology' + +RSpec.describe Legion::CLI::Admin::PurgeTopology do + describe 'Thor registration' do + it 'has a purge command' do + expect(described_class.commands).to have_key('purge') + end + + it 'has --execute option on purge' do + opt = described_class.commands['purge'] + expect(opt).not_to be_nil + end + + it 'defaults to dry-run (execute: false)' do + expect(described_class.class_options[:execute].default).to be false + end + + it 'accepts --host option' do + expect(described_class.class_options).to have_key(:host) + end + + it 'accepts --port option' do + expect(described_class.class_options).to have_key(:port) + end + + it 'has management API default port 15672' do + expect(described_class.class_options[:port].default).to eq(15_672) + end + end + + describe 'find_legacy_topology pattern matching' do + let(:cmd) do + instance = described_class.new + # Stub options to avoid real HTTP calls + allow(instance).to receive(:options).and_return({ + host: 'localhost', port: 15_672, user: 'guest', password: 'guest', vhost: '/' + }) + instance + end + + it 'identifies legacy exchanges matching legion.{lex} pattern' do + all_exchanges = [ + { name: 'legion.github' }, + { name: 'legion.apollo' }, + { name: 'legion.task' }, # infrastructure — should be excluded + { name: 'lex.github' }, # v3.0 — should be excluded + { name: 'amq.direct' } # AMQP built-in — should be excluded + ] + all_queues = [] + allow(cmd).to receive(:management_api).with(%r{/exchanges/}).and_return(all_exchanges) + allow(cmd).to receive(:management_api).with(%r{/queues/}).and_return(all_queues) + + result = cmd.send(:find_legacy_topology) + expect(result[:exchanges]).to contain_exactly('legion.github', 'legion.apollo') + expect(result[:queues]).to be_empty + end + + it 'identifies legacy queues matching legion.{lex} pattern' do + all_exchanges = [] + all_queues = [ + { name: 'legion.github.pull_request' }, + { name: 'legion.task.queue' }, # infrastructure — excluded + { name: 'lex.github.runners.pull_request' } # v3.0 — excluded + ] + allow(cmd).to receive(:management_api).with(%r{/exchanges/}).and_return(all_exchanges) + allow(cmd).to receive(:management_api).with(%r{/queues/}).and_return(all_queues) + + result = cmd.send(:find_legacy_topology) + expect(result[:queues]).to contain_exactly('legion.github.pull_request') + expect(result[:exchanges]).to be_empty + end + + it 'returns empty when no legacy topology exists' do + allow(cmd).to receive(:management_api).with(%r{/exchanges/}).and_return([{ name: 'lex.github' }]) + allow(cmd).to receive(:management_api).with(%r{/queues/}).and_return([{ name: 'lex.github.runners.pull_request' }]) + + result = cmd.send(:find_legacy_topology) + expect(result[:exchanges]).to be_empty + expect(result[:queues]).to be_empty + end + end +end diff --git a/spec/legion/cli/apollo_command_spec.rb b/spec/legion/cli/apollo_command_spec.rb new file mode 100644 index 00000000..1dcef3b9 --- /dev/null +++ b/spec/legion/cli/apollo_command_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/apollo_command' + +RSpec.describe Legion::CLI::Apollo do + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#status' do + let(:response) do + r = instance_double(Net::HTTPOK) + allow(r).to receive(:body).and_return( + JSON.generate({ data: { available: true, data_connected: true } }) + ) + r + end + + before { allow(mock_http).to receive(:get).and_return(response) } + + it 'outputs Apollo Status header' do + expect { described_class.start(%w[status --no-color]) }.to output(/Apollo Status/).to_stdout + end + + it 'shows availability' do + expect { described_class.start(%w[status --no-color]) }.to output(/true/).to_stdout + end + end + + describe '#stats' do + let(:response) do + r = instance_double(Net::HTTPOK) + allow(r).to receive(:body).and_return( + JSON.generate({ data: { total_entries: 42, recent_24h: 5, avg_confidence: 0.75, + by_status: { confirmed: 30, candidate: 12 }, + by_content_type: { fact: 20, observation: 22 } } }) + ) + r + end + + before { allow(mock_http).to receive(:get).and_return(response) } + + it 'outputs knowledge graph header' do + expect { described_class.start(%w[stats --no-color]) }.to output(/Apollo Knowledge Graph/).to_stdout + end + + it 'shows total entries' do + expect { described_class.start(%w[stats --no-color]) }.to output(/42/).to_stdout + end + + it 'shows breakdown by status' do + expect { described_class.start(%w[stats --no-color]) }.to output(/confirmed/).to_stdout + end + end + + describe '#query' do + let(:response) do + r = instance_double(Net::HTTPOK) + allow(r).to receive(:body).and_return( + JSON.generate({ data: { entries: [{ content: 'Legion uses AMQP', content_type: 'fact', + confidence: 0.9, status: 'confirmed' }] } }) + ) + r + end + + before do + allow(mock_http).to receive(:request).and_return(response) + end + + it 'outputs query results' do + expect { described_class.start(['query', 'what is legion', '--no-color']) }.to output(/Apollo Query/).to_stdout + end + + it 'shows entry content' do + expect { described_class.start(['query', 'what is legion', '--no-color']) }.to output(/AMQP/).to_stdout + end + end + + describe '#maintain' do + let(:response) do + r = instance_double(Net::HTTPOK) + allow(r).to receive(:body).and_return( + JSON.generate({ data: { decayed: 10, archived: 2 } }) + ) + r + end + + before do + allow(mock_http).to receive(:request).and_return(response) + end + + it 'outputs maintenance result' do + expect { described_class.start(%w[maintain decay_cycle --no-color]) }.to output(/Maintenance/).to_stdout + end + + it 'shows decayed count' do + expect { described_class.start(%w[maintain decay_cycle --no-color]) }.to output(/10/).to_stdout + end + end +end diff --git a/spec/legion/cli/audit_archive_spec.rb b/spec/legion/cli/audit_archive_spec.rb new file mode 100644 index 00000000..a320d0c2 --- /dev/null +++ b/spec/legion/cli/audit_archive_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'sequel' +require 'legion/data/retention' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/audit/archiver' +require 'legion/cli/audit_command' + +RSpec.describe Legion::CLI::Audit do + before do + allow(Legion::CLI::Connection).to receive(:ensure_settings) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + allow(Legion::Audit::Archiver).to receive(:enabled?).and_return(true) + end + + describe 'archive --dry-run' do + it 'outputs DRY RUN preview without executing' do + allow(Legion::Data::Retention).to receive(:retention_status) + .with(table: :audit_log) + .and_return({ active_count: 5000, archived_count: 1200, + oldest_active: Time.now - (91 * 86_400), + oldest_archived: Time.now - (370 * 86_400) }) + + expect { described_class.start(%w[archive --dry-run]) }.to output(/DRY RUN/).to_stdout + end + end + + describe 'archive --execute' do + it 'calls archive_to_warm and archive_to_cold and outputs results' do + allow(Legion::Audit::Archiver).to receive(:archive_to_warm) + .and_return({ moved: 10, from: :hot, to: :warm }) + allow(Legion::Audit::Archiver).to receive(:archive_to_cold) + .and_return({ moved: 5, path: '/tmp/test.jsonl.gz', checksum: 'abc' }) + allow(Legion::Audit::Archiver).to receive(:verify_chain) + .and_return({ valid: true, records_checked: 5, broken_links: [] }) + + expect { described_class.start(%w[archive --execute]) } + .to output(/Archived 10 records to warm/).to_stdout + end + end + + describe 'verify_chain' do + it 'outputs valid chain result' do + allow(Legion::Audit::Archiver).to receive(:verify_chain) + .and_return({ valid: true, records_checked: 42, broken_links: [] }) + + expect { described_class.start(%w[verify_chain --tier hot]) } + .to output(/42 records verified/).to_stdout + end + + it 'exits 1 on broken chain' do + allow(Legion::Audit::Archiver).to receive(:verify_chain) + .and_return({ valid: false, records_checked: 10, + broken_links: [{ id: 5, expected: 'aaa', got: 'bbb' }] }) + + expect { described_class.start(%w[verify_chain --tier hot]) } + .to raise_error(SystemExit) + end + end +end diff --git a/spec/legion/cli/audit_command_spec.rb b/spec/legion/cli/audit_command_spec.rb new file mode 100644 index 00000000..c3ca4c5e --- /dev/null +++ b/spec/legion/cli/audit_command_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'sequel' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/cli/audit_command' + +RSpec.describe Legion::CLI::Audit do + before do + allow(Legion::CLI::Connection).to receive(:ensure_settings) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + describe 'list' do + it 'queries audit log and renders records' do + audit_model = class_double('Legion::Data::Model::AuditLog') + stub_const('Legion::Data::Model::AuditLog', audit_model) + + fake_record = double('audit_record', + created_at: Time.new(2026, 3, 15), + event_type: 'task.created', + principal_id: 'user-1', + action: 'create', + resource: 'task/42', + status: 'success') + + fake_dataset = double('dataset') + allow(audit_model).to receive(:order).and_return(fake_dataset) + allow(fake_dataset).to receive(:limit).and_return(fake_dataset) + allow(fake_dataset).to receive(:all).and_return([fake_record]) + + expect { described_class.start(%w[list]) }.to output(/task\.created/).to_stdout + end + + it 'applies event_type filter' do + audit_model = class_double('Legion::Data::Model::AuditLog') + stub_const('Legion::Data::Model::AuditLog', audit_model) + + fake_dataset = double('dataset') + allow(audit_model).to receive(:order).and_return(fake_dataset) + allow(fake_dataset).to receive(:where).and_return(fake_dataset) + allow(fake_dataset).to receive(:limit).and_return(fake_dataset) + allow(fake_dataset).to receive(:all).and_return([]) + + expect(fake_dataset).to receive(:where).with(event_type: 'auth.login') + expect { described_class.start(%w[list --event_type auth.login]) }.to output(/0 records/).to_stdout + end + + it 'outputs JSON when --json flag is set' do + audit_model = class_double('Legion::Data::Model::AuditLog') + stub_const('Legion::Data::Model::AuditLog', audit_model) + + fake_record = double('audit_record', values: { id: 1, event_type: 'test' }) + fake_dataset = double('dataset') + allow(audit_model).to receive(:order).and_return(fake_dataset) + allow(fake_dataset).to receive(:limit).and_return(fake_dataset) + allow(fake_dataset).to receive(:all).and_return([fake_record]) + + expect { described_class.start(%w[list --json]) }.to output(/test/).to_stdout + end + end + + describe 'verify' do + it 'reports lex-audit not loaded when runner undefined' do + expect { described_class.start(%w[verify]) }.to raise_error(SystemExit) + end + + it 'reports valid chain' do + runner_mod = Module.new do + def verify + { valid: true, records_checked: 100 } + end + end + stub_const('Legion::Extensions::Audit::Runners::Audit', runner_mod) + + expect { described_class.start(%w[verify]) }.to output(/valid.*100/).to_stdout + end + + it 'reports broken chain' do + runner_mod = Module.new do + def verify + { valid: false, break_at: 55, records_checked: 54 } + end + end + stub_const('Legion::Extensions::Audit::Runners::Audit', runner_mod) + + expect { described_class.start(%w[verify]) }.to raise_error(SystemExit) + end + end +end diff --git a/spec/legion/cli/broker_command_spec.rb b/spec/legion/cli/broker_command_spec.rb new file mode 100644 index 00000000..e2aced52 --- /dev/null +++ b/spec/legion/cli/broker_command_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/broker_command' + +RSpec.describe Legion::CLI::Broker do + describe 'Thor registration' do + it 'has a stats command' do + expect(described_class.commands).to have_key('stats') + end + + it 'has a cleanup command' do + expect(described_class.commands).to have_key('cleanup') + end + end + + describe 'Main registration' do + it 'registers broker on Legion::CLI::Main' do + expect(Legion::CLI::Main.subcommand_classes).to have_key('broker') + end + end +end diff --git a/spec/legion/cli/chain_command_spec.rb b/spec/legion/cli/chain_command_spec.rb new file mode 100644 index 00000000..1bcdcbb3 --- /dev/null +++ b/spec/legion/cli/chain_command_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'sequel' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/cli/error' +require 'legion/cli/chain_command' + +RSpec.describe Legion::CLI::Chain do + let(:out) { instance_double(Legion::CLI::Output::Formatter, success: nil, error: nil, warn: nil, spacer: nil, table: nil, json: nil, status: 'ok') } + let(:chain_model) { double('ChainModel') } + + before do + allow_any_instance_of(described_class).to receive(:formatter).and_return(out) + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + stub_const('Legion::Data::Model::Chain', chain_model) + end + + describe 'list' do + it 'queries chains and renders table' do + fake_dataset = double('dataset') + allow(chain_model).to receive(:order).and_return(fake_dataset) + allow(fake_dataset).to receive(:limit).and_return(fake_dataset) + allow(fake_dataset).to receive(:map).and_return([]) + + expect(out).to receive(:table).with(%w[id name active], []) + described_class.start(%w[list]) + end + end + + describe 'create' do + it 'inserts a new chain' do + allow(chain_model).to receive(:insert).with(name: 'my-chain').and_return(7) + + expect(out).to receive(:success).with(/Chain created.*7.*my-chain/) + described_class.start(%w[create my-chain]) + end + + it 'outputs JSON when --json flag is set' do + allow(chain_model).to receive(:insert).and_return(3) + + expect(out).to receive(:json).with(hash_including(id: 3, name: 'test')) + described_class.start(%w[create test --json]) + end + end + + describe 'delete' do + it 'deletes chain when confirmed with -y' do + fake_chain = double('chain', values: { name: 'old-chain' }) + allow(fake_chain).to receive(:delete) + allow(chain_model).to receive(:[]).with(5).and_return(fake_chain) + + expect(out).to receive(:success).with(/Chain #5 deleted/) + described_class.start(%w[delete 5 -y]) + end + + it 'reports error for missing chain' do + allow(chain_model).to receive(:[]).with(99).and_return(nil) + + expect(out).to receive(:error).with('Chain 99 not found') + expect { described_class.start(%w[delete 99]) }.to raise_error(SystemExit) + end + + it 'aborts when user declines confirmation' do + fake_chain = double('chain', values: { name: 'keep-me' }) + allow(chain_model).to receive(:[]).with(1).and_return(fake_chain) + allow($stdin).to receive(:gets).and_return("n\n") + + expect(out).to receive(:warn).with('Aborted') + described_class.start(%w[delete 1]) + end + end +end diff --git a/spec/legion/cli/chat/agent_delegator_spec.rb b/spec/legion/cli/chat/agent_delegator_spec.rb new file mode 100644 index 00000000..db4c1527 --- /dev/null +++ b/spec/legion/cli/chat/agent_delegator_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/agent_delegator' + +RSpec.describe Legion::CLI::Chat::AgentDelegator do + describe '.delegate?' do + it 'detects @mention pattern' do + expect(described_class.delegate?('@reviewer check this')).to eq(:at_mention) + end + + it 'detects /agent pattern' do + expect(described_class.delegate?('/agent reviewer check this')).to eq(:slash) + end + + it 'returns false for regular input' do + expect(described_class.delegate?('regular message')).to be false + end + + it 'returns false for email-like @' do + expect(described_class.delegate?('email@domain.com')).to be false + end + end + + describe '.parse' do + it 'parses @mention into agent_name and task' do + result = described_class.parse('@reviewer check this file for bugs') + expect(result[:agent_name]).to eq('reviewer') + expect(result[:task]).to eq('check this file for bugs') + end + + it 'parses /agent command' do + result = described_class.parse('/agent debugger find the memory leak') + expect(result[:agent_name]).to eq('debugger') + expect(result[:task]).to eq('find the memory leak') + end + + it 'returns nil for non-delegation input' do + expect(described_class.parse('regular message')).to be_nil + end + end + + describe '.build_agent_prompt' do + it 'combines system prompt and task' do + agent = { system_prompt: 'You are a reviewer.', name: 'reviewer' } + prompt = described_class.build_agent_prompt(agent, 'review main.rb') + expect(prompt).to include('You are a reviewer.') + expect(prompt).to include('review main.rb') + end + + it 'handles missing system prompt' do + agent = { system_prompt: nil, name: 'minimal' } + prompt = described_class.build_agent_prompt(agent, 'do something') + expect(prompt).to include('do something') + end + end +end diff --git a/spec/legion/cli/chat/agent_registry_spec.rb b/spec/legion/cli/chat/agent_registry_spec.rb new file mode 100644 index 00000000..3387c4dc --- /dev/null +++ b/spec/legion/cli/chat/agent_registry_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'json' +require 'legion/cli/chat/agent_registry' + +RSpec.describe Legion::CLI::Chat::AgentRegistry do + let(:tmpdir) { Dir.mktmpdir('agent-registry-test') } + let(:agents_dir) { File.join(tmpdir, '.legion', 'agents') } + + before do + FileUtils.mkdir_p(agents_dir) + end + + after do + FileUtils.rm_rf(tmpdir) + end + + def write_agent(name, data) + File.write(File.join(agents_dir, "#{name}.json"), JSON.generate(data)) + end + + describe '.load_agents' do + it 'loads JSON agent definitions' do + write_agent('reviewer', { + 'name' => 'reviewer', + 'description' => 'Code review specialist', + 'model' => 'claude-sonnet-4-5-20250514', + 'system_prompt' => 'You are a code reviewer.' + }) + + agents = described_class.load_agents(tmpdir) + expect(agents.keys).to eq(['reviewer']) + expect(agents['reviewer'][:description]).to eq('Code review specialist') + expect(agents['reviewer'][:model]).to eq('claude-sonnet-4-5-20250514') + end + + it 'loads multiple agents' do + write_agent('reviewer', { 'name' => 'reviewer', 'description' => 'Reviews code' }) + write_agent('debugger', { 'name' => 'debugger', 'description' => 'Debugs code' }) + + agents = described_class.load_agents(tmpdir) + expect(agents.keys).to contain_exactly('reviewer', 'debugger') + end + + it 'skips files without name field' do + write_agent('invalid', { 'description' => 'No name field' }) + + agents = described_class.load_agents(tmpdir) + expect(agents).to be_empty + end + + it 'returns empty hash when directory does not exist' do + agents = described_class.load_agents('/nonexistent') + expect(agents).to eq({}) + end + + it 'normalizes agent data with defaults' do + write_agent('minimal', { 'name' => 'minimal' }) + + agents = described_class.load_agents(tmpdir) + agent = agents['minimal'] + expect(agent[:weight]).to eq(1.0) + expect(agent[:description]).to eq('') + expect(agent[:tools]).to be_nil + end + end + + describe '.find' do + it 'finds a loaded agent by name' do + write_agent('reviewer', { 'name' => 'reviewer', 'description' => 'Reviews code' }) + described_class.load_agents(tmpdir) + + agent = described_class.find('reviewer') + expect(agent[:name]).to eq('reviewer') + end + + it 'returns nil for unknown agent' do + described_class.load_agents(tmpdir) + expect(described_class.find('nonexistent')).to be_nil + end + end + + describe '.names' do + it 'returns agent names' do + write_agent('a', { 'name' => 'a' }) + write_agent('b', { 'name' => 'b' }) + described_class.load_agents(tmpdir) + + expect(described_class.names).to contain_exactly('a', 'b') + end + end + + describe '.match_for_task' do + it 'returns the best matching agent' do + write_agent('reviewer', { 'name' => 'reviewer', 'description' => 'code review security' }) + write_agent('debugger', { 'name' => 'debugger', 'description' => 'debug errors' }) + described_class.load_agents(tmpdir) + + agent = described_class.match_for_task('review this code for security issues') + expect(agent[:name]).to eq('reviewer') + end + + it 'returns nil when no agents loaded' do + described_class.load_agents(tmpdir) + expect(described_class.match_for_task('anything')).to be_nil + end + end +end diff --git a/spec/legion/cli/chat/checkpoint_spec.rb b/spec/legion/cli/chat/checkpoint_spec.rb new file mode 100644 index 00000000..e7448248 --- /dev/null +++ b/spec/legion/cli/chat/checkpoint_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'legion/cli/chat/checkpoint' + +RSpec.describe Legion::CLI::Chat::Checkpoint do + let(:tmpdir) { Dir.mktmpdir('checkpoint-test') } + let(:test_file) { File.join(tmpdir, 'test.txt') } + + before do + described_class.configure(max_depth: 10, mode: :per_edit) + end + + after do + described_class.clear + FileUtils.rm_rf(tmpdir) + end + + describe '.save' do + it 'saves state of an existing file' do + File.write(test_file, 'original content') + entry = described_class.save(test_file) + + expect(entry.path).to eq(test_file) + expect(entry.content).to eq('original content') + expect(entry.existed).to be true + expect(entry.timestamp).to be_a(Time) + end + + it 'saves state for a non-existent file' do + entry = described_class.save(File.join(tmpdir, 'new.txt')) + + expect(entry.existed).to be false + expect(entry.content).to be_nil + end + + it 'respects max_depth' do + described_class.configure(max_depth: 3) + 5.times { |i| described_class.save(File.join(tmpdir, "file#{i}.txt")) } + + expect(described_class.count).to eq(3) + end + end + + describe '.rewind' do + it 'restores the last edit' do + File.write(test_file, 'original') + described_class.save(test_file) + File.write(test_file, 'modified') + + restored = described_class.rewind(1) + + expect(restored.length).to eq(1) + expect(File.read(test_file)).to eq('original') + end + + it 'rewinds multiple steps' do + file_a = File.join(tmpdir, 'a.txt') + file_b = File.join(tmpdir, 'b.txt') + File.write(file_a, 'a-original') + File.write(file_b, 'b-original') + + described_class.save(file_a) + File.write(file_a, 'a-modified') + described_class.save(file_b) + File.write(file_b, 'b-modified') + + restored = described_class.rewind(2) + + expect(restored.length).to eq(2) + expect(File.read(file_a)).to eq('a-original') + expect(File.read(file_b)).to eq('b-original') + end + + it 'deletes a file that was newly created' do + new_file = File.join(tmpdir, 'brand_new.txt') + described_class.save(new_file) + File.write(new_file, 'created after checkpoint') + + described_class.rewind(1) + + expect(File.exist?(new_file)).to be false + end + + it 'returns empty array when no checkpoints exist' do + expect(described_class.rewind(1)).to eq([]) + end + + it 'clamps steps to available checkpoints' do + File.write(test_file, 'content') + described_class.save(test_file) + + restored = described_class.rewind(100) + expect(restored.length).to eq(1) + end + end + + describe '.rewind_file' do + it 'restores a specific file' do + file_a = File.join(tmpdir, 'a.txt') + file_b = File.join(tmpdir, 'b.txt') + File.write(file_a, 'a-original') + File.write(file_b, 'b-original') + + described_class.save(file_a) + File.write(file_a, 'a-modified') + described_class.save(file_b) + File.write(file_b, 'b-modified') + + entry = described_class.rewind_file(file_a) + + expect(entry).not_to be_nil + expect(File.read(file_a)).to eq('a-original') + expect(File.read(file_b)).to eq('b-modified') + end + + it 'returns nil when file has no checkpoint' do + expect(described_class.rewind_file('/no/such/file')).to be_nil + end + end + + describe '.list' do + it 'returns checkpoint metadata' do + File.write(test_file, 'content') + described_class.save(test_file) + + entries = described_class.list + expect(entries.length).to eq(1) + expect(entries.first[:path]).to eq(test_file) + expect(entries.first[:existed]).to be true + expect(entries.first[:timestamp]).to be_a(Time) + end + end + + describe '.clear' do + it 'removes all checkpoints' do + 3.times { |i| described_class.save(File.join(tmpdir, "f#{i}.txt")) } + described_class.clear + expect(described_class.count).to eq(0) + end + end + + describe '.count' do + it 'returns the number of checkpoints' do + expect(described_class.count).to eq(0) + described_class.save(test_file) + expect(described_class.count).to eq(1) + end + end +end diff --git a/spec/legion/cli/chat/context_manager_spec.rb b/spec/legion/cli/chat/context_manager_spec.rb new file mode 100644 index 00000000..a2dc3072 --- /dev/null +++ b/spec/legion/cli/chat/context_manager_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/context_manager' + +RSpec.describe Legion::CLI::Chat::ContextManager do + let(:messages) do + [ + double('msg1', to_h: { role: :user, content: 'How does caching work in Legion?' }), + double('msg2', to_h: { role: :assistant, content: 'Legion uses Redis or Memcached via legion-cache.' }), + double('msg3', to_h: { role: :user, content: 'What about persistence?' }), + double('msg4', to_h: { role: :assistant, content: 'Legion-data supports SQLite, PostgreSQL, and MySQL via Sequel.' }) + ] + end + + let(:chat) do + chat = double('chat') + allow(chat).to receive(:messages).and_return(messages) + allow(chat).to receive(:reset_messages!) + allow(chat).to receive(:add_message) + chat + end + + let(:session) do + session = double('session') + allow(session).to receive(:chat).and_return(chat) + session + end + + describe '.stats' do + it 'returns message statistics' do + result = described_class.stats(session) + expect(result[:message_count]).to eq(4) + expect(result[:char_count]).to be > 0 + expect(result[:estimated_tokens]).to be > 0 + expect(result[:by_role]).to include('user' => 2, 'assistant' => 2) + end + end + + describe '.should_auto_compact?' do + it 'returns false for short conversations' do + expect(described_class.should_auto_compact?(session)).to be false + end + + it 'returns true when messages exceed threshold' do + long_messages = 50.times.map { |i| double("msg#{i}", to_h: { role: :user, content: "Message #{i}" }) } + allow(chat).to receive(:messages).and_return(long_messages) + expect(described_class.should_auto_compact?(session)).to be true + end + end + + describe '.compact' do + it 'returns too_few_messages for short conversations' do + short_messages = [double('msg', to_h: { role: :user, content: 'hi' })] + allow(chat).to receive(:messages).and_return(short_messages) + result = described_class.compact(session) + expect(result[:compacted]).to be false + expect(result[:reason]).to eq('too_few_messages') + end + + context 'with dedup strategy' do + it 'removes duplicates when compressor is available' do + stub_const('Legion::LLM::Compressor', Module.new) + allow(Legion::LLM::Compressor).to receive(:deduplicate_messages).and_return( + { messages: [messages[1].to_h, messages[2].to_h, messages[3].to_h], removed: 1, original_count: 4 } + ) + + result = described_class.compact(session, strategy: :dedup) + expect(result[:compacted]).to be true + expect(result[:strategy]).to eq(:dedup) + expect(result[:removed]).to eq(1) + end + + it 'reports no duplicates found' do + stub_const('Legion::LLM::Compressor', Module.new) + allow(Legion::LLM::Compressor).to receive(:deduplicate_messages).and_return( + { messages: messages.map(&:to_h), removed: 0, original_count: 4 } + ) + + result = described_class.compact(session, strategy: :dedup) + expect(result[:compacted]).to be false + expect(result[:reason]).to eq('no_duplicates') + end + end + + context 'with summarize strategy' do + it 'uses LLM compressor summarization' do + stub_const('Legion::LLM::Compressor', Module.new) + allow(Legion::LLM::Compressor).to receive(:summarize_messages).and_return( + { summary: 'Discussion about caching and persistence in Legion.', compressed: true, original_count: 4 } + ) + + result = described_class.compact(session, strategy: :summarize) + expect(result[:compacted]).to be true + expect(result[:strategy]).to eq(:summarize) + expect(result[:final_count]).to eq(1) + end + + it 'reports unavailable when compressor missing' do + result = described_class.compact(session, strategy: :summarize) + expect(result[:compacted]).to be false + expect(result[:reason]).to eq('summarization_unavailable') + end + end + + context 'with auto strategy' do + it 'runs dedup and returns results' do + stub_const('Legion::LLM::Compressor', Module.new) + allow(Legion::LLM::Compressor).to receive(:deduplicate_messages).and_return( + { messages: messages.map(&:to_h), removed: 0, original_count: 4 } + ) + + result = described_class.compact(session, strategy: :auto) + expect(result[:strategy]).to eq(:auto) + expect(result[:final_count]).to eq(4) + end + end + + it 'returns unknown_strategy for invalid strategy' do + result = described_class.compact(session, strategy: :invalid) + expect(result[:compacted]).to be false + expect(result[:reason]).to eq('unknown_strategy') + end + end +end diff --git a/spec/legion/cli/chat/context_self_awareness_spec.rb b/spec/legion/cli/chat/context_self_awareness_spec.rb new file mode 100644 index 00000000..9925572b --- /dev/null +++ b/spec/legion/cli/chat/context_self_awareness_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'legion/cli/chat/context' + +RSpec.describe Legion::CLI::Chat::Context, '.to_system_prompt self-awareness' do + let(:tmpdir) { Dir.mktmpdir('context-self-awareness-test') } + + after { FileUtils.rm_rf(tmpdir) } + + before do + # Stub out network-dependent helpers so tests are deterministic. + allow(described_class).to receive(:daemon_hint).and_return(nil) + allow(described_class).to receive(:apollo_hint).and_return(nil) + allow(described_class).to receive(:memory_hint).and_return(nil) + end + + describe 'self-awareness injection' do + context 'when lex-agentic-self is loaded' do + before do + runners_mod = Module.new do + def self.self_narrative + { prose: 'I am a brain_modeled cognitive_agent with 47 active extensions.' } + end + end + stub_const('Legion::Extensions::Agentic::Self::Metacognition::Runners::Metacognition', runners_mod) + end + + it 'includes the self-awareness section in the system prompt' do + result = described_class.to_system_prompt(tmpdir) + expect(result).to include('Current self-awareness:') + end + + it 'includes the narrative prose in the system prompt' do + result = described_class.to_system_prompt(tmpdir) + expect(result).to include('I am a brain_modeled cognitive_agent with 47 active extensions.') + end + end + + context 'when lex-agentic-self is NOT loaded' do + it 'does not include the self-awareness section' do + result = described_class.to_system_prompt(tmpdir) + expect(result).not_to include('Current self-awareness:') + end + + it 'still returns a valid system prompt' do + result = described_class.to_system_prompt(tmpdir) + expect(result).to include('Legion') + end + end + + context 'when self_narrative raises an exception' do + before do + runners_mod = Module.new do + def self.self_narrative + raise StandardError, 'metacognition unavailable' + end + end + stub_const('Legion::Extensions::Agentic::Self::Metacognition::Runners::Metacognition', runners_mod) + end + + it 'does not raise' do + expect { described_class.to_system_prompt(tmpdir) }.not_to raise_error + end + + it 'does not include the self-awareness section' do + result = described_class.to_system_prompt(tmpdir) + expect(result).not_to include('Current self-awareness:') + end + + it 'still returns a valid system prompt' do + result = described_class.to_system_prompt(tmpdir) + expect(result).to include('Legion') + end + end + end +end diff --git a/spec/legion/cli/chat/context_spec.rb b/spec/legion/cli/chat/context_spec.rb new file mode 100644 index 00000000..e631ca4f --- /dev/null +++ b/spec/legion/cli/chat/context_spec.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/chat/context' + +RSpec.describe Legion::CLI::Chat::Context do + let(:project_root) { File.expand_path('../../../..', __dir__) } + let(:tmpdir) { Dir.mktmpdir('context-test') } + + after { FileUtils.rm_rf(tmpdir) } + + describe '.detect' do + it 'returns a hash with project info' do + ctx = described_class.detect(project_root) + expect(ctx).to be_a(Hash) + expect(ctx).to have_key(:project_type) + expect(ctx).to have_key(:directory) + end + + it 'detects ruby projects' do + ctx = described_class.detect(project_root) + expect(ctx[:project_type]).to eq(:ruby) + end + + it 'detects javascript project' do + File.write(File.join(tmpdir, 'package.json'), '{}') + expect(described_class.detect(tmpdir)[:project_type]).to eq(:javascript) + end + + it 'detects terraform project' do + File.write(File.join(tmpdir, 'main.tf'), '') + expect(described_class.detect(tmpdir)[:project_type]).to eq(:terraform) + end + + it 'detects python project' do + File.write(File.join(tmpdir, 'pyproject.toml'), '') + expect(described_class.detect(tmpdir)[:project_type]).to eq(:python) + end + + it 'returns nil for unknown project type' do + expect(described_class.detect(tmpdir)[:project_type]).to be_nil + end + + it 'detects git branch from HEAD' do + git_dir = File.join(tmpdir, '.git') + FileUtils.mkdir_p(git_dir) + File.write(File.join(git_dir, 'HEAD'), "ref: refs/heads/feature/test\n") + expect(described_class.detect(tmpdir)[:git_branch]).to eq('feature/test') + end + + it 'handles detached HEAD' do + git_dir = File.join(tmpdir, '.git') + FileUtils.mkdir_p(git_dir) + File.write(File.join(git_dir, 'HEAD'), "abc12345678deadbeef\n") + expect(described_class.detect(tmpdir)[:git_branch]).to eq('abc12345') + end + + it 'returns nil git_branch when not a git repo' do + expect(described_class.detect(tmpdir)[:git_branch]).to be_nil + end + end + + describe '.detect_project_file' do + it 'returns path to first matching project marker' do + File.write(File.join(tmpdir, 'Gemfile'), '') + expect(described_class.detect_project_file(tmpdir)).to eq(File.join(tmpdir, 'Gemfile')) + end + + it 'returns nil when no markers found' do + expect(described_class.detect_project_file(tmpdir)).to be_nil + end + end + + describe '.to_system_prompt' do + it 'returns a string' do + result = described_class.to_system_prompt(project_root) + expect(result).to be_a(String) + expect(result).to include('Legion') + end + + it 'includes working directory' do + result = described_class.to_system_prompt(project_root) + expect(result).to include(project_root) + end + + it 'includes project type when detected' do + File.write(File.join(tmpdir, 'Gemfile'), '') + result = described_class.to_system_prompt(tmpdir) + expect(result).to include('Project type: ruby') + end + + it 'includes CLAUDE.md content when present' do + File.write(File.join(tmpdir, 'CLAUDE.md'), '# Test Project Rules') + result = described_class.to_system_prompt(tmpdir) + expect(result).to include('Project Instructions') + expect(result).to include('Test Project Rules') + end + + it 'includes extra directories' do + extra = Dir.mktmpdir('extra') + result = described_class.to_system_prompt(tmpdir, extra_dirs: [extra]) + expect(result).to include("Additional directory: #{File.expand_path(extra)}") + FileUtils.rm_rf(extra) + end + + it 'skips non-existent extra directories' do + result = described_class.to_system_prompt(tmpdir, extra_dirs: ['/nonexistent/path']) + expect(result).not_to include('Additional directory') + end + end + + describe '.cognitive_awareness' do + before do + allow(described_class).to receive(:daemon_hint).and_return(nil) + end + + it 'returns nil when no cognitive context is available' do + allow(described_class).to receive(:memory_hint).and_return(nil) + allow(described_class).to receive(:apollo_hint).and_return(nil) + expect(described_class.cognitive_awareness(tmpdir)).to be_nil + end + + it 'includes memory hint when entries exist' do + allow(described_class).to receive(:memory_hint).and_return(' Memory: 3 project + 2 global entries') + allow(described_class).to receive(:apollo_hint).and_return(nil) + result = described_class.cognitive_awareness(tmpdir) + expect(result).to include('Memory: 3 project + 2 global') + end + + it 'includes apollo hint when available' do + allow(described_class).to receive(:memory_hint).and_return(nil) + allow(described_class).to receive(:apollo_hint).and_return(' Apollo knowledge graph: online') + result = described_class.cognitive_awareness(tmpdir) + expect(result).to include('Apollo knowledge graph: online') + end + + it 'includes daemon hint when running' do + allow(described_class).to receive(:daemon_hint).and_return(' Legion daemon: running on port 4567 (v1.4.151)') + allow(described_class).to receive(:memory_hint).and_return(nil) + allow(described_class).to receive(:apollo_hint).and_return(nil) + result = described_class.cognitive_awareness(tmpdir) + expect(result).to include('Legion daemon: running on port 4567') + end + end + + describe '.memory_hint' do + it 'returns hint with entry counts' do + allow(Legion::CLI::Chat::MemoryStore).to receive(:list) + .with(base_dir: tmpdir).and_return(%w[entry1 entry2]) + allow(Legion::CLI::Chat::MemoryStore).to receive(:list) + .with(scope: :global).and_return(%w[global1]) + result = described_class.memory_hint(tmpdir) + expect(result).to include('2 project') + expect(result).to include('1 global') + end + + it 'returns nil when no entries exist' do + allow(Legion::CLI::Chat::MemoryStore).to receive(:list) + .with(base_dir: tmpdir).and_return([]) + allow(Legion::CLI::Chat::MemoryStore).to receive(:list) + .with(scope: :global).and_return([]) + expect(described_class.memory_hint(tmpdir)).to be_nil + end + end + + describe '.apollo_hint' do + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + it 'returns online hint when apollo is available' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { available: true } }) + ) + allow(mock_http).to receive(:get).and_return(response) + result = described_class.apollo_hint + expect(result).to include('Apollo knowledge graph: online') + end + + it 'returns nil when apollo is not available' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { available: false } }) + ) + allow(mock_http).to receive(:get).and_return(response) + expect(described_class.apollo_hint).to be_nil + end + + it 'returns nil on connection refused' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + expect(described_class.apollo_hint).to be_nil + end + end + + describe '.daemon_hint' do + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + it 'returns running hint with version when daemon is healthy' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ status: 'ok', version: '1.4.151' }) + ) + allow(mock_http).to receive(:get).and_return(response) + result = described_class.daemon_hint + expect(result).to include('Legion daemon: running on port 4567') + expect(result).to include('v1.4.151') + end + + it 'returns hint without version when not provided' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ status: 'ok' }) + ) + allow(mock_http).to receive(:get).and_return(response) + result = described_class.daemon_hint + expect(result).to include('Legion daemon: running') + expect(result).not_to include('(v') + end + + it 'returns nil when status is not ok' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ status: 'degraded' }) + ) + allow(mock_http).to receive(:get).and_return(response) + expect(described_class.daemon_hint).to be_nil + end + + it 'returns nil on connection refused' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + expect(described_class.daemon_hint).to be_nil + end + end +end diff --git a/spec/legion/cli/chat/daemon_chat_spec.rb b/spec/legion/cli/chat/daemon_chat_spec.rb new file mode 100644 index 00000000..88cc4220 --- /dev/null +++ b/spec/legion/cli/chat/daemon_chat_spec.rb @@ -0,0 +1,397 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/daemon_chat' + +RSpec.describe Legion::CLI::Chat::DaemonChat do + subject(:chat) { described_class.new(model: 'claude-sonnet-4-6', provider: :bedrock) } + + # Ensure the stub constant exists before each test. + before do + unless defined?(Legion::LLM::DaemonClient) + stub_const('Legion::LLM', Module.new) unless defined?(Legion::LLM) + daemon_mod = Module.new + stub_const('Legion::LLM::DaemonClient', daemon_mod) + end + end + + # Stub DaemonClient.inference so specs never hit the network. + def stub_inference(content: 'hello from daemon', tool_calls: nil, + input_tokens: 5, output_tokens: 10, status: :immediate) + result = { + status: status, + data: { + content: content, + tool_calls: tool_calls, + input_tokens: input_tokens, + output_tokens: output_tokens, + model: 'claude-sonnet-4-6' + } + } + allow(Legion::LLM::DaemonClient).to receive(:inference).and_return(result) + result + end + + # ── initialization ───────────────────────────────────────────────────────── + + describe '#initialize' do + it 'exposes a model object responding to .id' do + expect(chat.model.id).to eq('claude-sonnet-4-6') + end + + it 'model.to_s returns the model id' do + expect(chat.model.to_s).to eq('claude-sonnet-4-6') + end + + it 'starts with an empty message history' do + stub_inference + responses = [] + chat.ask('test') { |chunk| responses << chunk.content } + expect(Legion::LLM::DaemonClient).to have_received(:inference).with( + hash_including(messages: array_including(hash_including(role: 'user', content: 'test'))) + ) + end + end + + # ── identity and conversation ─────────────────────────────────────────────── + + describe 'identity wiring' do + it 'generates a stable conversation_id' do + expect(chat.conversation_id).to match(/\A[0-9a-f-]{36}\z/) + end + + it 'keeps the same conversation_id across turns' do + id = chat.conversation_id + stub_inference + chat.ask('test') + expect(chat.conversation_id).to eq(id) + end + + it 'builds a caller_context with identity' do + expect(chat.caller_context).to be_a(Hash) + expect(chat.caller_context[:requested_by]).to be_a(Hash) + expect(chat.caller_context[:requested_by][:type]).to eq(:human) + expect(chat.caller_context[:requested_by][:credential]).to eq(:local) + expect(chat.caller_context[:requested_by][:identity]).not_to be_nil + end + + it 'passes caller and conversation_id to DaemonClient.inference' do + stub_inference + chat.ask('test') + expect(Legion::LLM::DaemonClient).to have_received(:inference).with( + hash_including( + caller: hash_including(requested_by: hash_including(type: :human)), + conversation_id: chat.conversation_id + ) + ) + end + end + + # ── with_instructions ────────────────────────────────────────────────────── + + describe '#with_instructions' do + it 'prepends a system message to the outgoing messages array' do + stub_inference + chat.with_instructions('You are a helpful assistant.') + chat.ask('test') + + expect(Legion::LLM::DaemonClient).to have_received(:inference).with( + hash_including( + messages: array_including(hash_including(role: 'system', content: 'You are a helpful assistant.')) + ) + ) + end + + it 'returns self for chaining' do + expect(chat.with_instructions('prompt')).to eq(chat) + end + end + + # ── with_tools ───────────────────────────────────────────────────────────── + + describe '#with_tools' do + it 'stores tools and forwards their schemas to DaemonClient.inference' do + fake_tool = Class.new do + def self.tool_name = 'read_file' + def self.description = 'Reads a file' + def self.parameters = { type: 'object' } + end + + stub_inference + chat.with_tools(fake_tool) + chat.ask('read something') + + expect(Legion::LLM::DaemonClient).to have_received(:inference).with( + hash_including( + tools: array_including(hash_including(name: 'read_file')) + ) + ) + end + + it 'returns self for chaining' do + expect(chat.with_tools).to eq(chat) + end + end + + # ── with_model ───────────────────────────────────────────────────────────── + + describe '#with_model' do + it 'updates the model id' do + chat.with_model('gpt-4o') + expect(chat.model.id).to eq('gpt-4o') + end + + it 'returns self for chaining' do + expect(chat.with_model('gpt-4o')).to eq(chat) + end + end + + # ── add_message / reset_messages! ───────────────────────────────────────── + + describe '#add_message' do + it 'injects a message into the history before the next ask' do + stub_inference + chat.add_message(role: :user, content: 'injected context') + chat.ask('follow up') + + expect(Legion::LLM::DaemonClient).to have_received(:inference).with( + hash_including( + messages: array_including(hash_including(role: 'user', content: 'injected context')) + ) + ) + end + end + + describe '#reset_messages!' do + it 'clears accumulated message history so the next ask sends only new messages' do + stub_inference(content: 'first answer') + chat.ask('first message') + + # After reset, only the new message should appear in the next inference call + captured_messages = nil + allow(Legion::LLM::DaemonClient).to receive(:inference) do |messages:, **_| + captured_messages = messages + { + status: :immediate, + data: { content: 'fresh answer', tool_calls: nil, + input_tokens: 2, output_tokens: 2, model: 'claude-sonnet-4-6' } + } + end + + chat.reset_messages! + chat.ask('fresh start') + + user_messages = captured_messages&.select { |m| m[:role] == 'user' } + expect(user_messages&.length).to eq(1) + expect(user_messages&.first&.dig(:content)).to eq('fresh start') + end + end + + # ── on_tool_call / on_tool_result ───────────────────────────────────────── + + describe '#on_tool_call and #on_tool_result callbacks' do + let(:fake_tool) do + Class.new do + def self.tool_name = 'run_command' + def self.description = 'Runs a shell command' + def self.parameters = {} + def self.call(**_) = 'command output' + end + end + + it 'fires on_tool_call before executing a tool' do + tool_call_received = [] + + first_response = { + status: :immediate, + data: { + content: nil, + tool_calls: [{ id: 'tc1', name: 'run_command', arguments: { cmd: 'ls' } }], + input_tokens: 5, + output_tokens: 5, + model: 'claude-sonnet-4-6' + } + } + final_response = { + status: :immediate, + data: { + content: 'done', + tool_calls: nil, + input_tokens: 10, + output_tokens: 10, + model: 'claude-sonnet-4-6' + } + } + + allow(Legion::LLM::DaemonClient).to receive(:inference) + .and_return(first_response, final_response) + + chat.with_tools(fake_tool) + chat.on_tool_call { |tc| tool_call_received << tc.name } + chat.ask('run something') + + expect(tool_call_received).to eq(['run_command']) + end + + it 'fires on_tool_result after executing a tool' do + tool_results_received = [] + + first_response = { + status: :immediate, + data: { + content: nil, + tool_calls: [{ id: 'tc1', name: 'run_command', arguments: {} }], + input_tokens: 5, + output_tokens: 5, + model: 'claude-sonnet-4-6' + } + } + final_response = { + status: :immediate, + data: { + content: 'done', + tool_calls: nil, + input_tokens: 10, + output_tokens: 10, + model: 'claude-sonnet-4-6' + } + } + + allow(Legion::LLM::DaemonClient).to receive(:inference) + .and_return(first_response, final_response) + + chat.with_tools(fake_tool) + chat.on_tool_result { |tr| tool_results_received << tr.content } + chat.ask('run something') + + expect(tool_results_received).to eq(['command output']) + end + end + + # ── ask ──────────────────────────────────────────────────────────────────── + + describe '#ask' do + context 'with a plain text response (no tool calls)' do + before { stub_inference(content: 'Hello there!') } + + it 'returns a Response with the content' do + response = chat.ask('hello') + expect(response.content).to eq('Hello there!') + end + + it 'returns a Response with token counts' do + response = chat.ask('hello') + expect(response.input_tokens).to eq(5) + expect(response.output_tokens).to eq(10) + end + + it 'returns a Response with a model object responding to .id' do + response = chat.ask('hello') + expect(response.model.id).to eq('claude-sonnet-4-6') + end + + it 'yields a chunk with the full content for streaming' do + chunks = [] + chat.ask('hello') { |chunk| chunks << chunk.content } + expect(chunks).to eq(['Hello there!']) + end + + it 'appends the user message and assistant response to history' do + chat.ask('hello') + stub_inference(content: 'follow up answer') + chat.ask('follow up') + + expect(Legion::LLM::DaemonClient).to have_received(:inference).twice + end + end + + context 'when daemon returns an error status' do + it 'raises CLI::Error' do + allow(Legion::LLM::DaemonClient).to receive(:inference) + .and_return({ status: :error, error: 'connection refused' }) + + expect { chat.ask('test') }.to raise_error(Legion::CLI::Error, /Daemon inference error/) + end + end + + context 'when daemon is unavailable' do + it 'raises CLI::Error' do + allow(Legion::LLM::DaemonClient).to receive(:inference) + .and_return({ status: :unavailable }) + + expect { chat.ask('test') }.to raise_error(Legion::CLI::Error, /unavailable/) + end + end + + context 'with a tool_calls response followed by a final text response' do + let(:fake_tool) do + Class.new do + def self.tool_name = 'read_file' + def self.description = 'Reads a file' + def self.parameters = {} + def self.call(**) = 'file contents here' + end + end + + let(:tool_call_response) do + { + status: :immediate, + data: { + content: nil, + tool_calls: [{ id: 'tc1', name: 'read_file', arguments: { path: 'main.rb' } }], + input_tokens: 8, + output_tokens: 4, + model: 'claude-sonnet-4-6' + } + } + end + + let(:final_response) do + { + status: :immediate, + data: { + content: 'Based on the file: it looks good.', + tool_calls: nil, + input_tokens: 20, + output_tokens: 15, + model: 'claude-sonnet-4-6' + } + } + end + + before do + allow(Legion::LLM::DaemonClient).to receive(:inference) + .and_return(tool_call_response, final_response) + chat.with_tools(fake_tool) + end + + it 'loops until a non-tool response is received' do + response = chat.ask('read main.rb') + expect(response.content).to eq('Based on the file: it looks good.') + expect(Legion::LLM::DaemonClient).to have_received(:inference).twice + end + + it 'appends tool result messages to the conversation' do + chat.ask('read main.rb') + + # On the second call, messages should include the tool result + second_call_messages = nil + allow(Legion::LLM::DaemonClient).to receive(:inference) do |messages:, **| + second_call_messages ||= messages if second_call_messages.nil? + final_response + end + + expect(Legion::LLM::DaemonClient).to have_received(:inference).twice + end + + it 'returns "Unknown tool: name" when tool is not registered' do + chat.with_tools # clear tools + allow(Legion::LLM::DaemonClient).to receive(:inference) + .and_return(tool_call_response, final_response) + + # Should not raise — returns graceful error string as tool result + expect { chat.ask('read main.rb') }.not_to raise_error + end + end + end +end diff --git a/spec/legion/cli/chat/extension_tool_loader_spec.rb b/spec/legion/cli/chat/extension_tool_loader_spec.rb new file mode 100644 index 00000000..55c89c2e --- /dev/null +++ b/spec/legion/cli/chat/extension_tool_loader_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/extension_tool_loader' + +RSpec.describe Legion::CLI::Chat::ExtensionToolLoader do + after { described_class.reset! } + + describe '.tools_dir_for' do + it 'appends /tools to extension path' do + expect(described_class.tools_dir_for('/path/to/ext')).to eq('/path/to/ext/tools') + end + end + + describe '.tool_enabled?' do + context 'when no settings exist' do + before do + allow(described_class).to receive(:extension_settings).and_return(nil) + end + + it 'returns true' do + expect(described_class.tool_enabled?('http')).to be true + end + end + + context 'when tools explicitly disabled' do + before do + allow(described_class).to receive(:extension_settings).and_return({ tools: { enabled: false } }) + end + + it 'returns false' do + expect(described_class.tool_enabled?('http')).to be false + end + end + + context 'when tools enabled' do + before do + allow(described_class).to receive(:extension_settings).and_return({ tools: { enabled: true } }) + end + + it 'returns true' do + expect(described_class.tool_enabled?('http')).to be true + end + end + end + + describe '.effective_tier' do + let(:read_tool) do + klass = Class.new(Legion::Tools::Base) + klass.define_singleton_method(:declared_permission_tier) { :read } + klass + end + + let(:write_tool) do + klass = Class.new(Legion::Tools::Base) + klass.define_singleton_method(:declared_permission_tier) { :write } + klass + end + + let(:bare_tool) { Class.new(Legion::Tools::Base) } + + before do + allow(described_class).to receive(:settings_tier_for).and_return(nil) + end + + it 'returns declared tier from tool class' do + expect(described_class.effective_tier(read_tool, 'http')).to eq(:read) + end + + it 'defaults to :write when no tier declared' do + expect(described_class.effective_tier(bare_tool, 'http')).to eq(:write) + end + + it 'escalates tier from settings when higher' do + allow(described_class).to receive(:settings_tier_for).and_return(:shell) + expect(described_class.effective_tier(read_tool, 'http')).to eq(:shell) + end + + it 'does not downgrade tier from settings' do + allow(described_class).to receive(:settings_tier_for).and_return(:read) + expect(described_class.effective_tier(write_tool, 'http')).to eq(:write) + end + end + + describe '.collect_tool_classes' do + it 'finds Legion::Tools::Base subclasses' do + tools_mod = Module.new + tool_class = Class.new(Legion::Tools::Base) + non_tool = Class.new + tools_mod.const_set(:MyTool, tool_class) + tools_mod.const_set(:Helper, non_tool) + + result = described_class.collect_tool_classes(tools_mod) + expect(result).to contain_exactly(tool_class) + end + + it 'returns empty array when no tools' do + tools_mod = Module.new + expect(described_class.collect_tool_classes(tools_mod)).to eq([]) + end + end + + describe '.discover' do + it 'returns empty array when no extensions loaded' do + expect(described_class.discover).to eq([]) + end + + it 'memoizes results' do + first = described_class.discover + second = described_class.discover + expect(first).to equal(second) + end + end + + describe '.reset!' do + it 'clears memoized discovery' do + described_class.discover + described_class.reset! + # After reset, discover will re-run (returns new array object) + expect(described_class.discover).to eq([]) + end + end +end diff --git a/spec/legion/cli/chat/headless_spec.rb b/spec/legion/cli/chat/headless_spec.rb new file mode 100644 index 00000000..158dc566 --- /dev/null +++ b/spec/legion/cli/chat/headless_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe 'Chat headless mode' do + it 'prompt command accepts text argument' do + chat = Legion::CLI::Chat.new + expect(chat).to respond_to(:prompt) + end + + it 'has prompt command registered' do + expect(Legion::CLI::Chat.all_commands).to have_key('prompt') + end + + it 'Main has ask command mapped to -p' do + expect(Legion::CLI::Main.instance_methods).to include(:ask) + end + + describe 'combine_with_stdin' do + let(:chat) { Legion::CLI::Chat.new } + + it 'returns text unchanged when stdin is a TTY' do + allow($stdin).to receive(:tty?).and_return(true) + result = chat.send(:combine_with_stdin, 'hello') + expect(result).to eq('hello') + end + + it 'reads piped stdin when text is empty' do + allow($stdin).to receive(:tty?).and_return(false) + allow($stdin).to receive(:read).and_return("piped content\n") + result = chat.send(:combine_with_stdin, '') + expect(result).to eq('piped content') + end + + it 'combines text and piped stdin' do + allow($stdin).to receive(:tty?).and_return(false) + allow($stdin).to receive(:read).and_return("file contents\n") + result = chat.send(:combine_with_stdin, 'review this') + expect(result).to eq("review this\n\nfile contents\n") + end + end + + describe 'exe/legion pipe routing' do + it 'routes to chat prompt when stdin is piped with no args' do + content = File.read(File.expand_path('../../../../exe/legion', __dir__)) + expect(content).to include("ARGV.replace(['chat', 'prompt', ''])") + end + end +end diff --git a/spec/legion/cli/chat/integration_spec.rb b/spec/legion/cli/chat/integration_spec.rb new file mode 100644 index 00000000..5f3063d5 --- /dev/null +++ b/spec/legion/cli/chat/integration_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe 'Legion Chat Integration' do + it 'registers chat subcommand under ai group' do + expect(Legion::CLI::Main.subcommands).to include('ai') + expect(Legion::CLI::Groups::Ai.subcommands).to include('chat') + end + + it 'routes piped stdin legion to chat prompt' do + content = File.read(File.expand_path('../../../../exe/legion', __dir__)) + expect(content).to include("ARGV.replace(['chat', 'prompt', ''])") + end + + it 'has all expected tools registered' do + require 'legion/cli/chat/tool_registry' + tools = Legion::CLI::Chat::ToolRegistry.builtin_tools + expect(tools.length).to eq(40) + + tool_classes = tools.map(&:name) + expect(tool_classes).to include(a_string_matching(/ReadFile/)) + expect(tool_classes).to include(a_string_matching(/WriteFile/)) + expect(tool_classes).to include(a_string_matching(/EditFile/)) + expect(tool_classes).to include(a_string_matching(/SearchFiles/)) + expect(tool_classes).to include(a_string_matching(/SearchContent/)) + expect(tool_classes).to include(a_string_matching(/RunCommand/)) + expect(tool_classes).to include(a_string_matching(/SaveMemory/)) + expect(tool_classes).to include(a_string_matching(/SearchMemory/)) + expect(tool_classes).to include(a_string_matching(/SearchTraces/)) + expect(tool_classes).to include(a_string_matching(/QueryKnowledge/)) + expect(tool_classes).to include(a_string_matching(/IngestKnowledge/)) + expect(tool_classes).to include(a_string_matching(/ConsolidateMemory/)) + expect(tool_classes).to include(a_string_matching(/RelateKnowledge/)) + expect(tool_classes).to include(a_string_matching(/KnowledgeMaintenance/)) + expect(tool_classes).to include(a_string_matching(/KnowledgeStats/)) + expect(tool_classes).to include(a_string_matching(/SummarizeTraces/)) + expect(tool_classes).to include(a_string_matching(/ListExtensions/)) + expect(tool_classes).to include(a_string_matching(/ManageTasks/)) + expect(tool_classes).to include(a_string_matching(/SystemStatus/)) + expect(tool_classes).to include(a_string_matching(/ViewEvents/)) + expect(tool_classes).to include(a_string_matching(/CostSummary/)) + expect(tool_classes).to include(a_string_matching(/Reflect/)) + expect(tool_classes).to include(a_string_matching(/ManageSchedules/)) + expect(tool_classes).to include(a_string_matching(/WorkerStatus/)) + expect(tool_classes).to include(a_string_matching(/WebSearch/)) + expect(tool_classes).to include(a_string_matching(/SpawnAgent/)) + expect(tool_classes).to include(a_string_matching(/DetectAnomalies/)) + expect(tool_classes).to include(a_string_matching(/ViewTrends/)) + expect(tool_classes).to include(a_string_matching(/TriggerDream/)) + expect(tool_classes).to include(a_string_matching(/GenerateInsights/)) + expect(tool_classes).to include(a_string_matching(/BudgetStatus/)) + expect(tool_classes).to include(a_string_matching(/ProviderHealth/)) + expect(tool_classes).to include(a_string_matching(/ModelComparison/)) + expect(tool_classes).to include(a_string_matching(/ShadowEvalStatus/)) + expect(tool_classes).to include(a_string_matching(/EntityExtract/)) + expect(tool_classes).to include(a_string_matching(/ArbitrageStatus/)) + expect(tool_classes).to include(a_string_matching(/EscalationStatus/)) + expect(tool_classes).to include(a_string_matching(/GraphExplore/)) + expect(tool_classes).to include(a_string_matching(/SchedulingStatus/)) + expect(tool_classes).to include(a_string_matching(/MemoryStatus/)) + end + + it 'context detects current project as ruby' do + require 'legion/cli/chat/context' + project_root = File.expand_path('../../../..', __dir__) + ctx = Legion::CLI::Chat::Context.detect(project_root) + expect(ctx[:project_type]).to eq(:ruby) + end + + it 'Chat has interactive as default task' do + expect(Legion::CLI::Chat.default_command).to eq('interactive') + end + + it 'Main has ask command for -p shortcut' do + expect(Legion::CLI::Main.all_commands).to have_key('ask') + end +end diff --git a/spec/legion/cli/chat/markdown_renderer_spec.rb b/spec/legion/cli/chat/markdown_renderer_spec.rb new file mode 100644 index 00000000..eadd497d --- /dev/null +++ b/spec/legion/cli/chat/markdown_renderer_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/markdown_renderer' + +RSpec.describe Legion::CLI::Chat::MarkdownRenderer do + describe '.render' do + it 'returns text unchanged when color is false' do + result = described_class.render("# Hello\n**bold**", color: false) + expect(result).to eq("# Hello\n**bold**") + end + + it 'renders h1 headers with bold and color' do + result = described_class.render("# Title\n", color: true) + expect(result).to include('Title') + expect(result).to include("\e[1m") + end + + it 'renders h2 headers' do + result = described_class.render("## Subtitle\n", color: true) + expect(result).to include('Subtitle') + expect(result).to include("\e[1m") + end + + it 'renders h3+ headers' do + result = described_class.render("### Section\n", color: true) + expect(result).to include('Section') + expect(result).to include("\e[1m") + end + + it 'renders bold text' do + result = described_class.render("this is **bold** text\n", color: true) + expect(result).to include("\e[1m") + expect(result).to include('bold') + end + + it 'renders italic text' do + result = described_class.render("this is *italic* text\n", color: true) + expect(result).to include("\e[3m") + expect(result).to include('italic') + end + + it 'renders inline code' do + result = described_class.render("use `foo` here\n", color: true) + expect(result).to include('foo') + expect(result).to include("\e[48;5;236m") + end + + it 'renders horizontal rules' do + result = described_class.render("---\n", color: true) + expect(result).to include("\e[2m") + end + + it 'renders blockquotes' do + result = described_class.render("> quoted text\n", color: true) + expect(result).to include('quoted text') + expect(result).to include("\e[2m") + end + + it 'renders unordered list items' do + result = described_class.render("- item one\n- item two\n", color: true) + expect(result).to include('item one') + expect(result).to include('item two') + end + + it 'renders ordered list items' do + result = described_class.render("1. first\n2. second\n", color: true) + expect(result).to include('first') + expect(result).to include('second') + end + + context 'with code blocks' do + it 'highlights a ruby code block' do + input = "```ruby\ndef hello\n puts 'hi'\nend\n```\n" + result = described_class.render(input, color: true) + expect(result).to include('def') + expect(result).to include('hello') + expect(result).to include("\e[") # contains ANSI escape codes + end + + it 'shows language label' do + input = "```python\nprint('hi')\n```\n" + result = described_class.render(input, color: true) + expect(result).to include('python') + end + + it 'handles code blocks without language' do + input = "```\nsome code\n```\n" + result = described_class.render(input, color: true) + expect(result).to include('some code') + end + + it 'handles unclosed code blocks gracefully' do + input = "```ruby\ndef oops\n" + result = described_class.render(input, color: true) + expect(result).to include('def') + end + end + + context 'with mixed content' do + it 'renders text before and after code blocks' do + input = "Here is code:\n\n```ruby\nx = 1\n```\n\nDone.\n" + result = described_class.render(input, color: true) + expect(result).to include('Here is code:') + expect(result).to include('Done.') + end + + it 'renders multiple code blocks' do + input = "```ruby\na = 1\n```\n\nThen:\n\n```python\nb = 2\n```\n" + result = described_class.render(input, color: true) + expect(result).to include('ruby') + expect(result).to include('python') + end + end + end +end diff --git a/spec/legion/cli/chat/memory_store_spec.rb b/spec/legion/cli/chat/memory_store_spec.rb new file mode 100644 index 00000000..32a45678 --- /dev/null +++ b/spec/legion/cli/chat/memory_store_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'legion/cli/chat/memory_store' + +RSpec.describe Legion::CLI::Chat::MemoryStore do + let(:tmpdir) { Dir.mktmpdir('memory-test') } + let(:project_dir) { File.join(tmpdir, 'project') } + + before do + FileUtils.mkdir_p(project_dir) + stub_const('Legion::CLI::Chat::MemoryStore::DEFAULT_GLOBAL_DIR', File.join(tmpdir, 'global')) + stub_const('Legion::CLI::Chat::MemoryStore::DEFAULT_GLOBAL_FILE', File.join(tmpdir, 'global', 'global.md')) + end + + after do + FileUtils.rm_rf(tmpdir) + end + + describe '.add' do + it 'creates a project memory file with an entry' do + path = described_class.add('Ruby 3.4 is required', base_dir: project_dir) + + expect(File.exist?(path)).to be true + content = File.read(path) + expect(content).to include('Ruby 3.4 is required') + expect(content).to include('# Project Memory') + end + + it 'appends to an existing memory file' do + described_class.add('first entry', base_dir: project_dir) + described_class.add('second entry', base_dir: project_dir) + + entries = described_class.list(base_dir: project_dir) + expect(entries.length).to eq(2) + expect(entries.first).to include('first entry') + expect(entries.last).to include('second entry') + end + + it 'writes to global memory when scope is :global' do + path = described_class.add('global fact', scope: :global) + + expect(File.exist?(path)).to be true + content = File.read(path) + expect(content).to include('global fact') + expect(content).to include('# Global Memory') + end + end + + describe '.list' do + it 'returns empty array when no memory file exists' do + expect(described_class.list(base_dir: project_dir)).to eq([]) + end + + it 'returns memory entries as strings' do + described_class.add('entry one', base_dir: project_dir) + described_class.add('entry two', base_dir: project_dir) + + entries = described_class.list(base_dir: project_dir) + expect(entries.length).to eq(2) + expect(entries.first).to include('entry one') + end + end + + describe '.forget' do + it 'removes matching entries' do + described_class.add('keep this', base_dir: project_dir) + described_class.add('delete this', base_dir: project_dir) + + removed = described_class.forget('delete', base_dir: project_dir) + expect(removed).to eq(1) + + entries = described_class.list(base_dir: project_dir) + expect(entries.length).to eq(1) + expect(entries.first).to include('keep this') + end + + it 'returns 0 when no entries match' do + described_class.add('entry', base_dir: project_dir) + expect(described_class.forget('nomatch', base_dir: project_dir)).to eq(0) + end + + it 'returns 0 when no memory file exists' do + expect(described_class.forget('anything', base_dir: project_dir)).to eq(0) + end + end + + describe '.clear' do + it 'deletes the memory file' do + described_class.add('entry', base_dir: project_dir) + expect(described_class.clear(base_dir: project_dir)).to be true + expect(described_class.list(base_dir: project_dir)).to eq([]) + end + + it 'returns false when no memory file exists' do + expect(described_class.clear(base_dir: project_dir)).to be false + end + end + + describe '.search' do + it 'finds matching entries across scopes' do + described_class.add('ruby version is 3.4', base_dir: project_dir) + described_class.add('python version is 3.12', base_dir: project_dir) + + results = described_class.search('ruby', base_dir: project_dir) + expect(results.length).to eq(1) + expect(results.first[:text]).to include('ruby version is 3.4') + end + + it 'is case-insensitive' do + described_class.add('Ruby is great', base_dir: project_dir) + + results = described_class.search('ruby', base_dir: project_dir) + expect(results.length).to eq(1) + end + + it 'returns empty array when nothing matches' do + described_class.add('entry', base_dir: project_dir) + expect(described_class.search('nomatch', base_dir: project_dir)).to eq([]) + end + end + + describe '.load_context' do + it 'returns nil when no memory files exist' do + expect(described_class.load_context(project_dir)).to be_nil + end + + it 'returns formatted context when memory exists' do + described_class.add('important fact', base_dir: project_dir) + + context = described_class.load_context(project_dir) + expect(context).to include('Project Memory') + expect(context).to include('important fact') + end + end + + describe '.load_all' do + it 'returns empty array when no memory files exist' do + expect(described_class.load_all(project_dir)).to eq([]) + end + + it 'returns project and global memories when both exist' do + described_class.add('project fact', scope: :project, base_dir: project_dir) + described_class.add('global fact', scope: :global) + + memories = described_class.load_all(project_dir) + expect(memories.length).to eq(2) + end + end +end diff --git a/spec/legion/cli/chat/output_styles_spec.rb b/spec/legion/cli/chat/output_styles_spec.rb new file mode 100644 index 00000000..1b1f4ae9 --- /dev/null +++ b/spec/legion/cli/chat/output_styles_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/chat/output_styles' + +RSpec.describe Legion::CLI::Chat::OutputStyles do + let(:tmpdir) { Dir.mktmpdir } + + before do + stub_const('Legion::CLI::Chat::OutputStyles::STYLE_DIRS', [tmpdir]) + end + + after { FileUtils.rm_rf(tmpdir) } + + def write_style(name, frontmatter, body) + fm = frontmatter.map { |k, v| v.is_a?(String) ? "#{k}: \"#{v}\"" : "#{k}: #{v}" }.join("\n") + File.write(File.join(tmpdir, "#{name}.md"), "---\n#{fm}\n---\n\n#{body}") + end + + describe '.discover' do + it 'returns empty when no style dirs exist' do + stub_const('Legion::CLI::Chat::OutputStyles::STYLE_DIRS', ['/nonexistent']) + expect(described_class.discover).to eq([]) + end + + it 'discovers .md style files' do + write_style('concise', { name: 'concise', description: 'Brief responses', active: true }, 'Be concise.') + write_style('verbose', { name: 'verbose', description: 'Detailed responses', active: false }, 'Be verbose.') + + styles = described_class.discover + expect(styles.map { |s| s[:name] }).to contain_exactly('concise', 'verbose') + end + end + + describe '.parse' do + it 'parses frontmatter and body' do + write_style('test', { name: 'test-style', description: 'A test', active: true }, 'Style body here.') + result = described_class.parse(File.join(tmpdir, 'test.md')) + expect(result[:name]).to eq('test-style') + expect(result[:description]).to eq('A test') + expect(result[:active]).to be true + expect(result[:content]).to eq('Style body here.') + end + + it 'defaults name from filename' do + File.write(File.join(tmpdir, 'unnamed.md'), "---\ndescription: no name\n---\n\nbody") + result = described_class.parse(File.join(tmpdir, 'unnamed.md')) + expect(result[:name]).to eq('unnamed') + end + + it 'returns nil for non-frontmatter files' do + File.write(File.join(tmpdir, 'plain.md'), 'just text') + expect(described_class.parse(File.join(tmpdir, 'plain.md'))).to be_nil + end + end + + describe '.active_styles' do + it 'returns only active styles' do + write_style('on', { name: 'on', active: true }, 'active style') + write_style('off', { name: 'off', active: false }, 'inactive style') + + active = described_class.active_styles + expect(active.map { |s| s[:name] }).to eq(['on']) + end + end + + describe '.find' do + it 'finds a style by name' do + write_style('target', { name: 'target', description: 'found it' }, 'body') + expect(described_class.find('target')[:description]).to eq('found it') + end + + it 'returns nil for missing style' do + expect(described_class.find('nonexistent')).to be_nil + end + end + + describe '.system_prompt_injection' do + it 'returns nil when no active styles' do + write_style('inactive', { name: 'inactive', active: false }, 'nope') + expect(described_class.system_prompt_injection).to be_nil + end + + it 'returns concatenated content of active styles' do + write_style('a', { name: 'a', active: true }, 'Style A content') + write_style('b', { name: 'b', active: true }, 'Style B content') + + result = described_class.system_prompt_injection + expect(result).to include('Style A content') + expect(result).to include('Style B content') + end + end +end diff --git a/spec/legion/cli/chat/permissions_spec.rb b/spec/legion/cli/chat/permissions_spec.rb new file mode 100644 index 00000000..291dc90e --- /dev/null +++ b/spec/legion/cli/chat/permissions_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/chat/tool_registry' + +RSpec.describe Legion::CLI::Chat::Permissions do + let(:tmpdir) { Dir.mktmpdir } + + after do + FileUtils.rm_rf(tmpdir) + described_class.mode = :interactive + end + + describe '.tier_for' do + it 'classifies read tools as :read' do + expect(described_class.tier_for(Legion::CLI::Chat::Tools::ReadFile)).to eq(:read) + expect(described_class.tier_for(Legion::CLI::Chat::Tools::SearchFiles)).to eq(:read) + expect(described_class.tier_for(Legion::CLI::Chat::Tools::SearchContent)).to eq(:read) + end + + it 'classifies write tools as :write' do + expect(described_class.tier_for(Legion::CLI::Chat::Tools::WriteFile)).to eq(:write) + expect(described_class.tier_for(Legion::CLI::Chat::Tools::EditFile)).to eq(:write) + end + + it 'classifies shell tools as :shell' do + expect(described_class.tier_for(Legion::CLI::Chat::Tools::RunCommand)).to eq(:shell) + end + + it 'defaults unknown classes to :read' do + expect(described_class.tier_for(String)).to eq(:read) + end + end + + describe '.auto_allow?' do + it 'returns false in interactive mode' do + described_class.mode = :interactive + expect(described_class.auto_allow?).to be false + end + + it 'returns true in headless mode' do + described_class.mode = :headless + expect(described_class.auto_allow?).to be true + end + + it 'returns true in auto_approve mode' do + described_class.mode = :auto_approve + expect(described_class.auto_allow?).to be true + end + end + + describe 'Gate module on WriteFile' do + let(:tool) { Legion::CLI::Chat::Tools::WriteFile } + let(:path) { File.join(tmpdir, 'gated.txt') } + + it 'auto-allows in headless mode' do + described_class.mode = :headless + result = tool.call(path: path, content: 'hello') + expect(File.read(path)).to eq('hello') + expect(result).to include('Wrote') + end + + it 'auto-allows in auto_approve mode' do + described_class.mode = :auto_approve + result = tool.call(path: path, content: 'hello') + expect(File.read(path)).to eq('hello') + expect(result).to include('Wrote') + end + + it 'prompts and allows when user says yes' do + described_class.mode = :interactive + allow($stdin).to receive(:gets).and_return("y\n") + allow($stderr).to receive(:print) + + result = tool.call(path: path, content: 'hello') + expect(File.read(path)).to eq('hello') + expect(result).to include('Wrote') + end + + it 'prompts and blocks when user says no' do + described_class.mode = :interactive + allow($stdin).to receive(:gets).and_return("n\n") + allow($stderr).to receive(:print) + + result = tool.call(path: path, content: 'hello') + expect(result).to eq({ content: [{ type: 'text', text: '{"error":"Tool execution denied by user."}' }], error: true }) + expect(File.exist?(path)).to be false + end + + it 'includes path in the confirmation prompt' do + described_class.mode = :interactive + allow($stdin).to receive(:gets).and_return("y\n") + + expect($stderr).to receive(:print).with(a_string_including(path)) + tool.call(path: path, content: 'hello') + end + end + + describe 'Gate module on EditFile' do + let(:tool) { Legion::CLI::Chat::Tools::EditFile } + let(:path) { File.join(tmpdir, 'edit_gated.txt') } + + before { File.write(path, 'hello world') } + + it 'blocks when user denies' do + described_class.mode = :interactive + allow($stdin).to receive(:gets).and_return("n\n") + allow($stderr).to receive(:print) + + result = tool.call(path: path, old_text: 'world', new_text: 'legion') + expect(result).to eq({ content: [{ type: 'text', text: '{"error":"Tool execution denied by user."}' }], error: true }) + expect(File.read(path)).to eq('hello world') + end + + it 'allows when user approves' do + described_class.mode = :interactive + allow($stdin).to receive(:gets).and_return("yes\n") + allow($stderr).to receive(:print) + + result = tool.call(path: path, old_text: 'world', new_text: 'legion') + expect(result).to include('Replaced') + expect(File.read(path)).to eq('hello legion') + end + end + + describe 'Gate module on RunCommand' do + let(:tool) { Legion::CLI::Chat::Tools::RunCommand } + + it 'blocks when user denies' do + described_class.mode = :interactive + allow($stdin).to receive(:gets).and_return("n\n") + allow($stderr).to receive(:print) + + result = tool.call(command: 'echo hello') + expect(result).to eq({ content: [{ type: 'text', text: '{"error":"Tool execution denied by user."}' }], error: true }) + end + + it 'allows when user approves' do + described_class.mode = :interactive + allow($stdin).to receive(:gets).and_return("y\n") + allow($stderr).to receive(:print) + + result = tool.call(command: 'echo hello') + expect(result).to include('hello') + end + + it 'includes command in the confirmation prompt' do + described_class.mode = :interactive + allow($stdin).to receive(:gets).and_return("y\n") + + expect($stderr).to receive(:print).with(a_string_including('echo hello')) + tool.call(command: 'echo hello') + end + end + + describe 'ReadFile is NOT gated' do + let(:tool) { Legion::CLI::Chat::Tools::ReadFile } + + it 'executes without prompting in interactive mode' do + described_class.mode = :interactive + path = File.join(tmpdir, 'readable.txt') + File.write(path, 'content here') + + expect($stdin).not_to receive(:gets) + result = tool.call(path: path) + expect(result).to include('content here') + end + end + + describe 'SearchFiles is NOT gated' do + let(:tool) { Legion::CLI::Chat::Tools::SearchFiles } + + it 'executes without prompting in interactive mode' do + described_class.mode = :interactive + File.write(File.join(tmpdir, 'findme.rb'), '') + + expect($stdin).not_to receive(:gets) + result = tool.call(pattern: '*.rb', directory: tmpdir) + expect(result).to include('findme.rb') + end + end +end diff --git a/spec/legion/cli/chat/progress_bar_spec.rb b/spec/legion/cli/chat/progress_bar_spec.rb new file mode 100644 index 00000000..250d9e3e --- /dev/null +++ b/spec/legion/cli/chat/progress_bar_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/progress_bar' + +RSpec.describe Legion::CLI::Chat::ProgressBar do + let(:output) { StringIO.new } + let(:bar) { described_class.new(total: 10, label: 'Test', output: output) } + + describe '#advance' do + it 'increments current' do + bar.advance(3) + expect(bar.current).to eq(3) + end + + it 'caps at total' do + bar.advance(20) + expect(bar.current).to eq(10) + end + + it 'renders to output' do + bar.advance(5) + expect(output.string).to include('50.0%') + end + end + + describe '#percentage' do + it 'calculates correctly' do + bar.advance(5) + expect(bar.percentage).to eq(50.0) + end + + it 'starts at zero' do + expect(bar.percentage).to eq(0.0) + end + end + + describe '#finish' do + it 'sets current to total' do + bar.finish + expect(bar.current).to eq(10) + expect(bar.percentage).to eq(100.0) + end + end + + describe '#eta' do + it 'returns 0 when complete' do + bar.finish + expect(bar.eta).to eq(0) + end + + it 'returns 0 when no progress' do + expect(bar.eta).to eq(0) + end + end + + describe '#elapsed' do + it 'returns non-negative duration' do + expect(bar.elapsed).to be >= 0 + end + end +end diff --git a/spec/legion/cli/chat/read_user_input_spec.rb b/spec/legion/cli/chat/read_user_input_spec.rb new file mode 100644 index 00000000..78733aff --- /dev/null +++ b/spec/legion/cli/chat/read_user_input_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'reline' + +RSpec.describe 'Legion::CLI::Chat#read_user_input' do + subject(:chat) { Legion::CLI::Chat.new } + + describe '#read_user_input' do + it 'returns a single line on normal Enter' do + allow(Reline).to receive(:readline).and_return('hello world') + expect(chat.read_user_input).to eq('hello world') + end + + it 'returns nil on Ctrl+D (EOF)' do + allow(Reline).to receive(:readline).and_return(nil) + expect(chat.read_user_input).to be_nil + end + + it 'returns empty string for blank input' do + allow(Reline).to receive(:readline).and_return(' ') + expect(chat.read_user_input).to eq('') + end + + it 'joins continuation lines separated by trailing backslash' do + allow(Reline).to receive(:readline).and_return( + 'first line\\', + 'second line\\', + 'third line' + ) + expect(chat.read_user_input).to eq("first line\nsecond line\nthird line") + end + + it 'strips trailing whitespace before the backslash' do + allow(Reline).to receive(:readline).and_return( + 'hello \\', + 'world' + ) + expect(chat.read_user_input).to eq("hello\nworld") + end + + it 'only adds the first line to Reline history' do + call_count = 0 + allow(Reline).to receive(:readline) do |_prompt, add_hist| + call_count += 1 + case call_count + when 1 + expect(add_hist).to be true + 'line one\\' + when 2 + expect(add_hist).to be false + 'line two' + end + end + + chat.read_user_input + end + + it 'shows a continuation prompt for subsequent lines' do + call_count = 0 + allow(Reline).to receive(:readline) do |prompt, _add_hist| + call_count += 1 + case call_count + when 1 + expect(prompt).to include('you') + 'continued\\' + when 2 + expect(prompt).to include('...') + 'done' + end + end + + chat.read_user_input + end + + it 'handles a single backslash at end of line with no continuation text' do + allow(Reline).to receive(:readline).and_return('\\', 'actual content') + expect(chat.read_user_input).to eq("\nactual content") + end + + it 'returns nil when Ctrl+D during continuation' do + allow(Reline).to receive(:readline).and_return('start\\', nil) + expect(chat.read_user_input).to be_nil + end + + it 're-raises Interrupt on first line' do + allow(Reline).to receive(:readline).and_raise(Interrupt) + expect { chat.read_user_input }.to raise_error(Interrupt) + end + + it 'returns nil on Interrupt during continuation' do + call_count = 0 + allow(Reline).to receive(:readline) do + call_count += 1 + case call_count + when 1 then 'start\\' + when 2 then raise Interrupt + end + end + + expect(chat.read_user_input).to be_nil + end + + it 'does not treat mid-line backslashes as continuation' do + allow(Reline).to receive(:readline).and_return('path\\to\\file') + expect(chat.read_user_input).to eq('path\\to\\file') + end + end +end diff --git a/spec/legion/cli/chat/session_recovery_spec.rb b/spec/legion/cli/chat/session_recovery_spec.rb new file mode 100644 index 00000000..b0c18118 --- /dev/null +++ b/spec/legion/cli/chat/session_recovery_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/session_recovery' + +RSpec.describe Legion::CLI::Chat::SessionRecovery do + describe '.classify' do + it 'returns :none for empty messages' do + expect(described_class.classify([])).to eq(:none) + end + + it 'returns :none when last message is assistant with content' do + messages = [ + { role: :user, content: 'hello' }, + { role: :assistant, content: 'hi there' } + ] + expect(described_class.classify(messages)).to eq(:none) + end + + it 'returns :interrupted_prompt when last message is user' do + messages = [ + { role: :assistant, content: 'hi' }, + { role: :user, content: 'do something' } + ] + expect(described_class.classify(messages)).to eq(:interrupted_prompt) + end + + it 'returns :interrupted_turn when last message is tool_result' do + messages = [ + { role: :user, content: 'read file' }, + { role: :assistant, content: 'reading...', tool_calls: [{ name: 'read_file' }] }, + { role: :tool_result, content: 'file contents here' } + ] + expect(described_class.classify(messages)).to eq(:interrupted_turn) + end + + it 'filters thinking-only assistant messages' do + messages = [ + { role: :user, content: 'hello' }, + { role: :assistant, content: nil, tool_calls: [] } + ] + expect(described_class.classify(messages)).to eq(:interrupted_prompt) + end + + it 'filters whitespace-only assistant messages' do + messages = [ + { role: :user, content: 'hello' }, + { role: :assistant, content: "\n\n" } + ] + expect(described_class.classify(messages)).to eq(:interrupted_prompt) + end + end + + describe '.recover' do + it 'returns no recovery for clean sessions' do + messages = [ + { role: :user, content: 'hello' }, + { role: :assistant, content: 'hi' } + ] + result = described_class.recover(messages) + expect(result[:state]).to eq(:none) + expect(result[:recovery_message]).to be_nil + end + + it 'returns recovery message for interrupted_prompt' do + messages = [ + { role: :assistant, content: 'hi' }, + { role: :user, content: 'do something' } + ] + result = described_class.recover(messages) + expect(result[:state]).to eq(:interrupted_prompt) + expect(result[:recovery_message]).to include('Continue from where you left off') + end + + it 'returns recovery message with tool name for interrupted_turn' do + messages = [ + { role: :user, content: 'read file' }, + { role: :assistant, content: 'ok', tool_calls: [{ name: 'read_file' }] }, + { role: :tool_result, content: 'data' } + ] + result = described_class.recover(messages) + expect(result[:state]).to eq(:interrupted_turn) + expect(result[:recovery_message]).to include('read_file') + end + + it 'removes trailing tool_result for interrupted_turn' do + messages = [ + { role: :user, content: 'hello' }, + { role: :assistant, content: 'ok', tool_calls: [{ name: 'write_file' }] }, + { role: :tool_result, content: 'done' } + ] + result = described_class.recover(messages) + expect(result[:messages].last[:role].to_s).not_to eq('tool_result') + end + + it 'handles string-keyed hashes' do + messages = [ + { 'role' => 'user', 'content' => 'hello' }, + { 'role' => 'assistant', 'content' => 'hi' } + ] + result = described_class.recover(messages) + expect(result[:state]).to eq(:none) + end + end +end diff --git a/spec/legion/cli/chat/session_spec.rb b/spec/legion/cli/chat/session_spec.rb new file mode 100644 index 00000000..f3f520b7 --- /dev/null +++ b/spec/legion/cli/chat/session_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require 'spec_helper' + +ChatResponse = Struct.new(:content, :role, :tool_call?, :input_tokens, :output_tokens) +ChatChunk = Struct.new(:content) +ChatModel = Struct.new(:id) + +# Stub RubyLLM::Chat for unit testing +module RubyLLM + class Chat + attr_reader :messages + + def initialize(**) = (@messages = []) + def with_instructions(_text) = self + def with_tools(*_tools) = self + def on_tool_call = self + def on_tool_result = self + + def ask(msg, &block) + @messages << { role: :user, content: msg } + response = ChatResponse.new(content: "Echo: #{msg}", role: :assistant, tool_call?: false, + input_tokens: 10, output_tokens: 5) + block&.call(ChatChunk.new(content: "Echo: #{msg}")) + @messages << { role: :assistant, content: response.content } + response + end + + def model = ChatModel.new(id: 'test-model') + def reset_messages! = @messages.clear + def add_message(msg) = @messages << msg + def with_model(_id) = self + end +end + +require 'legion/cli/chat/session' + +RSpec.describe Legion::CLI::Chat::Session do + subject(:session) { described_class.new(chat: RubyLLM::Chat.new) } + + it 'initializes with a chat object' do + expect(session).to be_a(described_class) + end + + it 'sends a message and returns a response' do + response = session.send_message('hello') + expect(response.content).to eq('Echo: hello') + end + + it 'tracks message counts' do + session.send_message('hello') + expect(session.stats[:messages_sent]).to eq(1) + expect(session.stats[:messages_received]).to eq(1) + end + + it 'reports model_id' do + expect(session.model_id).to eq('test-model') + end + + it 'tracks elapsed time' do + expect(session.elapsed).to be_a(Float) + expect(session.elapsed).to be >= 0 + end + + describe '#estimated_cost' do + it 'returns zero with no usage' do + expect(session.estimated_cost).to eq(0) + end + + it 'calculates cost from token usage' do + session.send_message('hello') # 10 input, 5 output per stub + cost = session.estimated_cost + expected = (10 * described_class::INPUT_RATE) + (5 * described_class::OUTPUT_RATE) + expect(cost).to eq(expected) + end + + it 'accumulates across multiple messages' do + session.send_message('hello') + session.send_message('world') + cost = session.estimated_cost + expected = (20 * described_class::INPUT_RATE) + (10 * described_class::OUTPUT_RATE) + expect(cost).to eq(expected) + end + end + + describe 'budget enforcement' do + it 'allows messages when under budget' do + budget_session = described_class.new(chat: RubyLLM::Chat.new, budget_usd: 10.0) + expect { budget_session.send_message('hello') }.not_to raise_error + end + + it 'raises BudgetExceeded when cost reaches limit' do + # Each message: 10 input + 5 output tokens + # Cost per msg: 10 * 0.000003 + 5 * 0.000015 = ~0.000105 + budget_session = described_class.new(chat: RubyLLM::Chat.new, budget_usd: 0.0001) + budget_session.send_message('first') # costs ~0.000105, exceeds 0.0001 + expect { budget_session.send_message('second') }.to raise_error( + described_class::BudgetExceeded, /Budget exceeded/ + ) + end + + it 'does not check budget when budget_usd is nil' do + no_budget = described_class.new(chat: RubyLLM::Chat.new) + 5.times { no_budget.send_message('hello') } + # Should never raise + end + + it 'includes cost details in error message' do + budget_session = described_class.new(chat: RubyLLM::Chat.new, budget_usd: 0.0001) + budget_session.send_message('first') + expect { budget_session.send_message('second') }.to raise_error( + described_class::BudgetExceeded, /\$.*spent of \$.*limit/ + ) + end + end + + describe 'event emitter' do + it 'allows subscribing to events and emits them' do + received = [] + session.on(:test_event) { |payload| received << payload } + session.emit(:test_event, { key: 'value' }) + expect(received).to eq([{ key: 'value' }]) + end + + it 'supports multiple subscribers on the same event' do + results = [] + session.on(:multi) { |p| results << "a:#{p[:v]}" } + session.on(:multi) { |p| results << "b:#{p[:v]}" } + session.emit(:multi, { v: 1 }) + expect(results).to eq(['a:1', 'b:1']) + end + + it 'does not raise when emitting with no subscribers' do + expect { session.emit(:nobody_listening, {}) }.not_to raise_error + end + + it 'emits :llm_start and :llm_complete around send_message' do + events = [] + session.on(:llm_start) { |p| events << [:llm_start, p[:turn]] } + session.on(:llm_complete) { |p| events << [:llm_complete, p[:turn]] } + session.send_message('hello') + expect(events).to eq([[:llm_start, 1], [:llm_complete, 1]]) + end + + it 'includes user_message in :llm_complete payload' do + payload_received = nil + session.on(:llm_complete) { |p| payload_received = p } + session.send_message('tell me something') + expect(payload_received[:user_message]).to eq('tell me something') + end + + it 'emits :llm_first_token on first streaming chunk' do + token_events = [] + session.on(:llm_first_token) { |p| token_events << p[:turn] } + session.send_message('hello') { |_chunk| nil } + expect(token_events).to eq([1]) + end + + it 'emits :llm_first_token only once per turn' do + token_events = [] + session.on(:llm_first_token) { |p| token_events << p[:turn] } + session.send_message('hello') { |_chunk| nil } + session.send_message('world') { |_chunk| nil } + expect(token_events).to eq([1, 2]) + end + + it 'increments turn counter across messages' do + turns = [] + session.on(:llm_start) { |p| turns << p[:turn] } + session.send_message('first') + session.send_message('second') + expect(turns).to eq([1, 2]) + end + + it 'emits :tool_start when on_tool_call fires' do + tool_events = [] + session.on(:tool_start) { |p| tool_events << p[:name] } + + session.send_message('hello', on_tool_call: ->(tc) { tc }) { |c| c } + + session.emit(:tool_start, { name: 'read_file', args: { path: '/tmp' }, index: 1, total: 1 }) + expect(tool_events).to eq(['read_file']) + end + + it 'emits :tool_complete when on_tool_result fires' do + result_events = [] + session.on(:tool_complete) { |p| result_events << p[:name] } + + session.emit(:tool_complete, { name: 'read_file', result_preview: 'contents...', index: 1, total: 1 }) + expect(result_events).to eq(['read_file']) + end + end +end diff --git a/spec/legion/cli/chat/session_store_spec.rb b/spec/legion/cli/chat/session_store_spec.rb new file mode 100644 index 00000000..bbd773bd --- /dev/null +++ b/spec/legion/cli/chat/session_store_spec.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/error' + +StoreModel = Struct.new(:id) + +# Stub RubyLLM::Chat if not already defined +unless defined?(RubyLLM::Chat) + module RubyLLM + class Message + attr_reader :role, :content, :model_id, :tool_calls, :tool_call_id + + def initialize(opts = {}) + @role = opts[:role]&.to_sym + @content = opts[:content] + @model_id = opts[:model_id] + @tool_calls = opts[:tool_calls] + @tool_call_id = opts[:tool_call_id] + end + + def to_h + { role: role, content: content, model_id: model_id }.compact + end + end + + class Chat + attr_reader :messages + + def initialize(**) + @messages = [] + end + + def add_message(msg) + message = msg.is_a?(Message) ? msg : Message.new(msg) + @messages << message + message + end + + def reset_messages! + @messages.clear + end + + def model + StoreModel.new(id: 'test-model') + end + + def with_instructions(_text) = self + end + end +end + +require 'legion/cli/chat/session_store' +require 'legion/cli/chat/session' + +RSpec.describe Legion::CLI::Chat::SessionStore do + let(:tmpdir) { Dir.mktmpdir } + let(:chat) { RubyLLM::Chat.new } + let(:session) { Legion::CLI::Chat::Session.new(chat: chat) } + + before do + stub_const('Legion::CLI::Chat::SessionStore::SESSIONS_DIR', tmpdir) + chat.add_message(role: :user, content: 'hello') + chat.add_message(role: :assistant, content: 'hi there') + end + + after { FileUtils.rm_rf(tmpdir) } + + describe '.save' do + it 'writes session to a JSON file' do + path = described_class.save(session, 'test-session') + expect(File.exist?(path)).to be true + expect(path).to end_with('test-session.json') + end + + it 'includes messages in the saved data' do + described_class.save(session, 'test-session') + data = Legion::JSON.load(File.read(described_class.session_path('test-session'))) + expect(data[:messages].length).to eq(2) + expect(data[:messages][0][:role].to_s).to eq('user') + expect(data[:messages][0][:content]).to eq('hello') + expect(data[:messages][1][:role].to_s).to eq('assistant') + end + + it 'includes metadata' do + described_class.save(session, 'test-session') + data = Legion::JSON.load(File.read(described_class.session_path('test-session'))) + expect(data[:name]).to eq('test-session') + expect(data[:model]).to eq('test-model') + expect(data[:saved_at]).to be_a(String) + end + + it 'includes message count' do + described_class.save(session, 'test-session') + data = Legion::JSON.load(File.read(described_class.session_path('test-session'))) + expect(data[:message_count]).to eq(2) + end + + it 'generates summary from first user message' do + described_class.save(session, 'test-session') + data = Legion::JSON.load(File.read(described_class.session_path('test-session'))) + expect(data[:summary]).to eq('hello') + end + + it 'truncates long summaries' do + chat.reset_messages! + chat.add_message(role: :user, content: 'a' * 200) + described_class.save(session, 'long-summary') + data = Legion::JSON.load(File.read(described_class.session_path('long-summary'))) + expect(data[:summary].length).to be <= 124 + expect(data[:summary]).to end_with('...') + end + + it 'includes cwd in saved data' do + described_class.save(session, 'test-session') + data = Legion::JSON.load(File.read(described_class.session_path('test-session'))) + expect(data[:cwd]).to eq(Dir.pwd) + end + + it 'creates sessions directory if missing' do + FileUtils.rm_rf(tmpdir) + described_class.save(session, 'test-session') + expect(Dir.exist?(tmpdir)).to be true + end + end + + describe '.load' do + it 'reads a saved session' do + described_class.save(session, 'my-session') + data = described_class.load('my-session') + expect(data[:messages].length).to eq(2) + expect(data[:name]).to eq('my-session') + end + + it 'raises CLI::Error for missing session' do + expect { described_class.load('nonexistent') } + .to raise_error(Legion::CLI::Error, /not found/) + end + end + + describe '.restore' do + it 'replaces chat messages with loaded data' do + described_class.save(session, 'restore-test') + data = described_class.load('restore-test') + + chat.add_message(role: :user, content: 'extra message') + expect(chat.messages.length).to eq(3) + + described_class.restore(session, data) + expect(chat.messages.length).to eq(2) + msg = chat.messages[0] + role = msg.respond_to?(:role) ? msg.role : msg[:role] + expect(role.to_s).to eq('user') + end + end + + describe '.list' do + it 'returns empty array when no sessions exist' do + FileUtils.rm_rf(tmpdir) + expect(described_class.list).to eq([]) + end + + it 'lists saved sessions sorted by most recent' do + described_class.save(session, 'older') + sleep 0.05 + described_class.save(session, 'newer') + + sessions = described_class.list + expect(sessions.length).to eq(2) + expect(sessions[0][:name]).to eq('newer') + expect(sessions[1][:name]).to eq('older') + end + + it 'includes summary, message count, and cwd in listing' do + described_class.save(session, 'with-meta') + sessions = described_class.list + expect(sessions[0][:message_count]).to eq(2) + expect(sessions[0][:summary]).to eq('hello') + expect(sessions[0][:model]).to eq('test-model') + expect(sessions[0][:cwd]).to eq(Dir.pwd) + end + end + + describe '.latest' do + it 'returns the name of the most recent session' do + described_class.save(session, 'older') + sleep 0.05 + described_class.save(session, 'newer') + + expect(described_class.latest).to eq('newer') + end + + it 'raises CLI::Error when no sessions exist' do + FileUtils.rm_rf(tmpdir) + expect { described_class.latest } + .to raise_error(Legion::CLI::Error, /No saved sessions/) + end + end + + describe '.delete' do + it 'removes a saved session' do + described_class.save(session, 'deleteme') + expect(File.exist?(described_class.session_path('deleteme'))).to be true + + described_class.delete('deleteme') + expect(File.exist?(described_class.session_path('deleteme'))).to be false + end + + it 'raises CLI::Error for missing session' do + expect { described_class.delete('nonexistent') } + .to raise_error(Legion::CLI::Error, /not found/) + end + end +end diff --git a/spec/legion/cli/chat/settings_integration_spec.rb b/spec/legion/cli/chat/settings_integration_spec.rb new file mode 100644 index 00000000..a6d89cb6 --- /dev/null +++ b/spec/legion/cli/chat/settings_integration_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe 'Chat settings integration' do + let(:chat_instance) { Legion::CLI::Chat.new } + + before do + allow(Legion::Settings).to receive(:dig).and_return(nil) + end + + describe '#chat_setting' do + it 'returns nil when setting is not configured' do + result = chat_instance.send(:chat_setting, :model) + expect(result).to be_nil + end + + it 'returns the setting value when configured' do + allow(Legion::Settings).to receive(:dig).with(:chat, :model).and_return('claude-sonnet-4-6') + result = chat_instance.send(:chat_setting, :model) + expect(result).to eq('claude-sonnet-4-6') + end + + it 'supports nested keys' do + allow(Legion::Settings).to receive(:dig).with(:chat, :subagent, :max_concurrency).and_return(5) + result = chat_instance.send(:chat_setting, :subagent, :max_concurrency) + expect(result).to eq(5) + end + + it 'returns nil when Settings is not available' do + allow(Legion::Settings).to receive(:dig).and_raise(StandardError) + result = chat_instance.send(:chat_setting, :model) + expect(result).to be_nil + end + end + + describe '#configure_permissions' do + before do + require 'legion/cli/chat/permissions' + end + + after do + Legion::CLI::Chat::Permissions.mode = :interactive + end + + it 'uses CLI flag when --auto_approve is set' do + instance = Legion::CLI::Chat.new([], { auto_approve: true }) + allow(Legion::Settings).to receive(:dig).and_return(nil) + instance.send(:configure_permissions, :interactive) + expect(Legion::CLI::Chat::Permissions.mode).to eq(:auto_approve) + end + + it 'uses settings when CLI flag is not set' do + allow(Legion::Settings).to receive(:dig).with(:chat, :permissions).and_return('read_only') + chat_instance.send(:configure_permissions, :interactive) + expect(Legion::CLI::Chat::Permissions.mode).to eq(:read_only) + end + + it 'falls back to default when neither CLI nor settings set' do + chat_instance.send(:configure_permissions, :interactive) + expect(Legion::CLI::Chat::Permissions.mode).to eq(:interactive) + end + + it 'CLI flag takes priority over settings' do + allow(Legion::Settings).to receive(:dig).with(:chat, :permissions).and_return('read_only') + instance = Legion::CLI::Chat.new([], { auto_approve: true }) + allow(Legion::Settings).to receive(:dig).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:chat, :permissions).and_return('read_only') + instance.send(:configure_permissions, :interactive) + expect(Legion::CLI::Chat::Permissions.mode).to eq(:auto_approve) + end + end + + describe '#incognito?' do + it 'returns false by default' do + expect(chat_instance.send(:incognito?)).to be false + end + + it 'reads incognito setting' do + allow(Legion::Settings).to receive(:dig).with(:chat, :incognito).and_return(true) + expect(chat_instance.send(:incognito?)).to be true + end + + it 'CLI flag overrides settings' do + allow(Legion::Settings).to receive(:dig).with(:chat, :incognito).and_return(false) + instance = Legion::CLI::Chat.new([], { incognito: true }) + expect(instance.send(:incognito?)).to be true + end + end + + describe '#effective_budget' do + it 'returns nil by default' do + expect(chat_instance.send(:effective_budget)).to be_nil + end + + it 'reads budget from settings' do + allow(Legion::Settings).to receive(:dig).with(:chat, :max_budget_usd).and_return(5.0) + expect(chat_instance.send(:effective_budget)).to eq(5.0) + end + + it 'CLI flag overrides settings' do + allow(Legion::Settings).to receive(:dig).with(:chat, :max_budget_usd).and_return(5.0) + instance = Legion::CLI::Chat.new([], { max_budget_usd: 10.0 }) + expect(instance.send(:effective_budget)).to eq(10.0) + end + end + + describe '#effective_max_turns' do + it 'defaults to 10' do + expect(chat_instance.send(:effective_max_turns)).to eq(10) + end + + it 'reads max_turns from settings' do + allow(Legion::Settings).to receive(:dig).with(:chat, :headless, :max_turns).and_return(25) + expect(chat_instance.send(:effective_max_turns)).to eq(25) + end + + it 'CLI flag overrides settings' do + allow(Legion::Settings).to receive(:dig).with(:chat, :headless, :max_turns).and_return(25) + instance = Legion::CLI::Chat.new([], { max_turns: 5 }) + expect(instance.send(:effective_max_turns)).to eq(5) + end + end + + describe '#build_system_prompt personality from settings' do + before do + require 'legion/cli/chat/context' + allow(Legion::CLI::Chat::Context).to receive(:to_system_prompt).and_return('base prompt') + end + + it 'uses settings personality when CLI flag is absent' do + allow(Legion::Settings).to receive(:dig).with(:chat, :personality).and_return('concise') + result = chat_instance.send(:build_system_prompt) + expect(result).to include('extremely concise') + end + + it 'CLI flag overrides settings personality' do + allow(Legion::Settings).to receive(:dig).with(:chat, :personality).and_return('verbose') + instance = Legion::CLI::Chat.new([], { personality: 'educational' }) + allow(Legion::Settings).to receive(:dig).and_return(nil) + result = instance.send(:build_system_prompt) + expect(result).to include('educational') + expect(result).not_to include('thorough and detailed') + end + end +end diff --git a/spec/legion/cli/chat/status_indicator_spec.rb b/spec/legion/cli/chat/status_indicator_spec.rb new file mode 100644 index 00000000..15b37b6f --- /dev/null +++ b/spec/legion/cli/chat/status_indicator_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' + +ChatResponse = Struct.new(:content, :role, :tool_call?, :input_tokens, :output_tokens) unless defined?(ChatResponse) +ChatChunk = Struct.new(:content) unless defined?(ChatChunk) +ChatModel = Struct.new(:id) unless defined?(ChatModel) + +unless defined?(RubyLLM) + module RubyLLM + class Chat + attr_reader :messages + + def initialize(**) = (@messages = []) + def with_instructions(_text) = self + def with_tools(*_tools) = self + def on_tool_call = self + def on_tool_result = self + + def ask(msg, &block) + @messages << { role: :user, content: msg } + response = ChatResponse.new(content: "Echo: #{msg}", role: :assistant, tool_call?: false, + input_tokens: 10, output_tokens: 5) + block&.call(ChatChunk.new(content: "Echo: #{msg}")) + @messages << { role: :assistant, content: response.content } + response + end + + def model = ChatModel.new(id: 'test-model') + def reset_messages! = @messages.clear + def add_message(msg) = @messages << msg + def with_model(_id) = self + end + end +end + +require 'legion/cli/chat/session' +require 'legion/cli/chat/status_indicator' + +RSpec.describe Legion::CLI::Chat::StatusIndicator do + let(:chat) { RubyLLM::Chat.new } + let(:session) { Legion::CLI::Chat::Session.new(chat: chat) } + let(:indicator) { described_class.new(session) } + + it 'subscribes to session events on initialization' do + expect(indicator).to be_a(described_class) + end + + describe ':llm_start' do + it 'starts a spinner with thinking label' do + indicator + expect { session.emit(:llm_start, { turn: 1 }) }.not_to raise_error + end + end + + describe ':llm_first_token' do + it 'stops the spinner when first token arrives' do + indicator + session.emit(:llm_start, { turn: 1 }) + expect { session.emit(:llm_first_token, { turn: 1 }) }.not_to raise_error + end + end + + describe ':llm_complete' do + it 'stops spinner as safety catch' do + indicator + session.emit(:llm_start, { turn: 1 }) + expect { session.emit(:llm_complete, { turn: 1 }) }.not_to raise_error + end + end + + describe ':tool_start' do + it 'starts a spinner with tool name and counter' do + indicator + expect do + session.emit(:tool_start, { name: 'read_file', args: { path: '/tmp' }, index: 1, total: 3 }) + end.not_to raise_error + end + end + + describe ':tool_complete' do + it 'stops the spinner' do + indicator + session.emit(:tool_start, { name: 'read_file', args: {}, index: 1, total: 1 }) + expect do + session.emit(:tool_complete, { name: 'read_file', result_preview: 'ok', index: 1, total: 1 }) + end.not_to raise_error + end + end + + describe 'non-TTY output' do + it 'does not raise when output is not a TTY' do + indicator + expect { session.emit(:llm_start, { turn: 1 }) }.not_to raise_error + expect { session.emit(:llm_complete, { turn: 1 }) }.not_to raise_error + end + end +end diff --git a/spec/legion/cli/chat/subagent_spec.rb b/spec/legion/cli/chat/subagent_spec.rb new file mode 100644 index 00000000..910c3e69 --- /dev/null +++ b/spec/legion/cli/chat/subagent_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/subagent' + +RSpec.describe Legion::CLI::Chat::Subagent do + before do + described_class.configure(max_concurrency: 3) + end + + describe '.configure' do + it 'sets max concurrency' do + described_class.configure(max_concurrency: 5) + expect(described_class.max_concurrency).to eq(5) + end + end + + describe '.spawn' do + it 'returns agent info on success' do + allow(Open3).to receive(:capture3).and_return(['output', '', double(exitstatus: 0)]) + + result = described_class.spawn(task: 'test task') + + expect(result[:id]).to match(/^agent-/) + expect(result[:status]).to eq('running') + expect(result[:task]).to eq('test task') + sleep 0.1 # Let thread finish + end + + it 'returns error when at capacity' do + described_class.configure(max_concurrency: 0) + result = described_class.spawn(task: 'test') + expect(result[:error]).to include('Max concurrency') + end + + it 'calls on_complete callback when done' do + allow(Open3).to receive(:capture3).and_return(['done', '', double(exitstatus: 0)]) + completed = false + + described_class.spawn( + task: 'test', + on_complete: ->(_id, _result) { completed = true } + ) + + sleep 0.5 + expect(completed).to be true + end + end + + describe '.running' do + it 'returns empty array when no agents running' do + expect(described_class.running).to eq([]) + end + end + + describe '.running_count' do + it 'returns 0 when no agents running' do + expect(described_class.running_count).to eq(0) + end + end + + describe '.at_capacity?' do + it 'returns false when under limit' do + expect(described_class.at_capacity?).to be false + end + + it 'returns true when at limit' do + described_class.configure(max_concurrency: 0) + expect(described_class.at_capacity?).to be true + end + end + + describe '.configure_from_settings' do + before do + allow(Legion::Settings).to receive(:dig).and_return(nil) + end + + it 'reads max_concurrency from settings' do + allow(Legion::Settings).to receive(:dig).with(:chat, :subagent, :max_concurrency).and_return(7) + described_class.configure_from_settings + expect(described_class.max_concurrency).to eq(7) + end + + it 'reads timeout from settings' do + allow(Legion::Settings).to receive(:dig).with(:chat, :subagent, :timeout).and_return(600) + described_class.configure_from_settings + expect(described_class.timeout).to eq(600) + end + + it 'falls back to defaults when settings unavailable' do + described_class.configure_from_settings + expect(described_class.max_concurrency).to eq(3) + expect(described_class.timeout).to eq(300) + end + end +end diff --git a/spec/legion/cli/chat/team_memory_spec.rb b/spec/legion/cli/chat/team_memory_spec.rb new file mode 100644 index 00000000..e090b4d9 --- /dev/null +++ b/spec/legion/cli/chat/team_memory_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/team_memory' + +RSpec.describe Legion::CLI::Chat::TeamMemory do + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + end + + describe '.enabled?' do + it 'returns false by default' do + allow(Legion::Settings).to receive(:dig).with(:memory, :team_sync).and_return(nil) + expect(described_class.enabled?).to be false + end + + it 'returns true when enabled in settings' do + allow(Legion::Settings).to receive(:dig).with(:memory, :team_sync).and_return({ enabled: true }) + expect(described_class.enabled?).to be true + end + end + + describe '.sync_add' do + it 'does nothing when disabled' do + allow(Legion::Settings).to receive(:dig).with(:memory, :team_sync).and_return({ enabled: false }) + expect { described_class.sync_add('test entry') }.not_to raise_error + end + + it 'does nothing when Apollo is not available' do + allow(Legion::Settings).to receive(:dig).with(:memory, :team_sync).and_return({ enabled: true }) + hide_const('Legion::Apollo') if defined?(Legion::Apollo) + expect { described_class.sync_add('test entry') }.not_to raise_error + end + + it 'calls Apollo.ingest when enabled and available' do + allow(Legion::Settings).to receive(:dig).with(:memory, :team_sync).and_return({ enabled: true }) + allow(described_class).to receive(:git_remote_url).and_return('git@github.com:LegionIO/LegionIO.git') + + stub_const('Legion::Apollo', Module.new do + def self.respond_to?(name, *) + %i[ingest retrieve].include?(name) || super + end + + def self.ingest(**) = nil + end) + + expect(Legion::Apollo).to receive(:ingest).with(hash_including( + tags: ['team_memory', 'repo:git@github.com:LegionIO/LegionIO.git'], + knowledge_domain: 'team_memory' + )) + described_class.sync_add('user prefers concise output') + end + end + + describe '.retrieve' do + it 'returns empty array when disabled' do + allow(Legion::Settings).to receive(:dig).with(:memory, :team_sync).and_return({ enabled: false }) + expect(described_class.retrieve).to eq([]) + end + + it 'returns empty array when Apollo is not available' do + allow(Legion::Settings).to receive(:dig).with(:memory, :team_sync).and_return({ enabled: true }) + hide_const('Legion::Apollo') if defined?(Legion::Apollo) + expect(described_class.retrieve).to eq([]) + end + end + + describe '.load_context' do + it 'returns nil when no team entries' do + allow(described_class).to receive(:retrieve).and_return([]) + expect(described_class.load_context).to be_nil + end + + it 'formats entries as markdown' do + allow(described_class).to receive(:retrieve).and_return(['entry one', 'entry two']) + context = described_class.load_context + expect(context).to include('## Team Memory') + expect(context).to include('- entry one') + expect(context).to include('- entry two') + end + end +end diff --git a/spec/legion/cli/chat/team_spec.rb b/spec/legion/cli/chat/team_spec.rb new file mode 100644 index 00000000..0b14c91e --- /dev/null +++ b/spec/legion/cli/chat/team_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/team' + +RSpec.describe Legion::CLI::Chat::Team do + after { Thread.current[:legion_chat_user] = nil } + + describe '.with_user' do + it 'sets and restores user context' do + ctx = Legion::CLI::Chat::Team::UserContext.new(user_id: 'test') + inner = nil + described_class.with_user(ctx) { inner = described_class.current_user } + expect(inner.user_id).to eq('test') + expect(described_class.current_user).to be_nil + end + + it 'restores context on exception' do + ctx = Legion::CLI::Chat::Team::UserContext.new(user_id: 'test') + begin + described_class.with_user(ctx) { raise 'boom' } + rescue RuntimeError + nil + end + expect(described_class.current_user).to be_nil + end + end + + describe '.detect_user' do + it 'returns UserContext from env' do + user = described_class.detect_user + expect(user).to be_a(Legion::CLI::Chat::Team::UserContext) + expect(user.user_id).not_to be_nil + end + end + + describe '.current_user' do + it 'returns nil when no user set' do + expect(described_class.current_user).to be_nil + end + end +end + +RSpec.describe Legion::CLI::Chat::Team::UserContext do + let(:ctx) { described_class.new(user_id: 'u1', team_id: 't1') } + + it 'has correct attributes' do + expect(ctx.user_id).to eq('u1') + expect(ctx.team_id).to eq('t1') + expect(ctx.display_name).to eq('u1') + end + + it 'serializes to hash' do + h = ctx.to_h + expect(h[:user_id]).to eq('u1') + expect(h[:team_id]).to eq('t1') + end + + it 'uses custom display_name' do + ctx = described_class.new(user_id: 'u1', display_name: 'User One') + expect(ctx.display_name).to eq('User One') + end +end diff --git a/spec/legion/cli/chat/tool_registry_spec.rb b/spec/legion/cli/chat/tool_registry_spec.rb new file mode 100644 index 00000000..a18f4648 --- /dev/null +++ b/spec/legion/cli/chat/tool_registry_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tool_registry' + +RSpec.describe Legion::CLI::Chat::ToolRegistry do + describe '.builtin_tools' do + it 'returns an array of Legion::Tools::Base subclasses' do + tools = described_class.builtin_tools + expect(tools).to be_an(Array) + expect(tools).not_to be_empty + tools.each do |tool| + expect(tool).to be < Legion::Tools::Base + end + end + + it 'includes file and shell tools' do + names = described_class.builtin_tools.map(&:tool_name) + expect(names).to include('legion.read_file') + expect(names).to include('legion.write_file') + expect(names).to include('legion.edit_file') + expect(names).to include('legion.search_files') + expect(names).to include('legion.search_content') + expect(names).to include('legion.run_command') + end + + it 'returns a mutable copy of the constants array' do + tools1 = described_class.builtin_tools + tools2 = described_class.builtin_tools + expect(tools1).not_to be(tools2) + expect(tools1).to eq(tools2) + end + end +end diff --git a/spec/legion/cli/chat/tools/arbitrage_status_spec.rb b/spec/legion/cli/chat/tools/arbitrage_status_spec.rb new file mode 100644 index 00000000..7f2422f7 --- /dev/null +++ b/spec/legion/cli/chat/tools/arbitrage_status_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/arbitrage_status' + +RSpec.describe Legion::CLI::Chat::Tools::ArbitrageStatus do + subject(:tool) { described_class } + + let(:arb_mod) do + Module.new do + def self.enabled? + true + end + + def self.cost_table + { + 'gpt-4o' => { input: 2.5, output: 10.0 }, + 'gpt-4o-mini' => { input: 0.15, output: 0.60 } + } + end + + def self.cheapest_for(capability:, **) + capability == :reasoning ? 'gpt-4o' : 'gpt-4o-mini' + end + + def self.estimated_cost(model:, **) + model == 'gpt-4o' ? 0.0075 : 0.000225 + end + end + end + + before do + stub_const('Legion::LLM::Arbitrage', arb_mod) + end + + describe '#execute' do + it 'returns overview with cost table' do + result = tool.call + expect(result).to include('LLM Cost Arbitrage') + expect(result).to include('gpt-4o') + expect(result).to include('gpt-4o-mini') + expect(result).to include('Enabled: YES') + end + + it 'shows cheapest per tier when enabled' do + result = tool.call + expect(result).to include('Cheapest per tier') + expect(result).to include('basic') + expect(result).to include('reasoning') + end + + it 'returns specific tier info' do + result = tool.call(capability: 'reasoning') + expect(result).to include('tier: reasoning') + expect(result).to include('gpt-4o') + end + + it 'returns error for invalid tier' do + result = tool.call(capability: 'invalid') + expect(result).to include('Invalid tier') + end + + it 'returns unavailable when module not defined' do + hide_const('Legion::LLM::Arbitrage') + result = tool.call + expect(result).to eq('LLM arbitrage module not available.') + end + end +end diff --git a/spec/legion/cli/chat/tools/budget_status_spec.rb b/spec/legion/cli/chat/tools/budget_status_spec.rb new file mode 100644 index 00000000..60f3da1d --- /dev/null +++ b/spec/legion/cli/chat/tools/budget_status_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/budget_status' + +RSpec.describe Legion::CLI::Chat::Tools::BudgetStatus do + subject(:tool) { described_class } + + before do + stub_const('Legion::LLM', Module.new) + stub_const('Legion::LLM::CostTracker', Module.new do + def self.summary + { + total_cost_usd: 0.025, + total_requests: 5, + total_input_tokens: 10_000, + total_output_tokens: 3000, + by_model: { + 'claude-sonnet-4-6' => { cost_usd: 0.02, requests: 3 }, + 'gpt-4o-mini' => { cost_usd: 0.005, requests: 2 } + } + } + end + end) + stub_const('Legion::LLM::Hooks::BudgetGuard', Module.new do + def self.status + { + enforcing: true, + budget_usd: 1.0, + spent_usd: 0.025, + remaining_usd: 0.975, + ratio: 0.025 + } + end + end) + end + + describe '#execute' do + it 'returns budget status by default' do + result = tool.call + expect(result).to include('Session Budget Status') + expect(result).to include('Enforcing: YES') + expect(result).to include('Budget:') + expect(result).to include('Requests: 5') + end + + it 'returns cost summary when requested' do + result = tool.call(action: 'summary') + expect(result).to include('Session Cost Summary') + expect(result).to include('claude-sonnet-4-6') + expect(result).to include('gpt-4o-mini') + end + + it 'returns error when LLM not available' do + hide_const('Legion::LLM') + result = tool.call + expect(result).to eq('Legion::LLM not available.') + end + end + + describe '#execute with no budget enforced' do + before do + stub_const('Legion::LLM::Hooks::BudgetGuard', Module.new do + def self.status + { enforcing: false, budget_usd: 0.0, ratio: 0.0 } + end + end) + end + + it 'shows enforcing as no' do + result = tool.call + expect(result).to include('Enforcing: no') + end + end + + describe '#execute summary with no requests' do + before do + stub_const('Legion::LLM::CostTracker', Module.new do + def self.summary + { total_cost_usd: 0.0, total_requests: 0, total_input_tokens: 0, total_output_tokens: 0, by_model: {} } + end + end) + end + + it 'returns no requests message' do + result = tool.call(action: 'summary') + expect(result).to eq('No LLM requests recorded this session.') + end + end +end diff --git a/spec/legion/cli/chat/tools/consolidate_memory_spec.rb b/spec/legion/cli/chat/tools/consolidate_memory_spec.rb new file mode 100644 index 00000000..cefdde00 --- /dev/null +++ b/spec/legion/cli/chat/tools/consolidate_memory_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/chat/memory_store' +require 'legion/cli/chat/tools/consolidate_memory' + +RSpec.describe Legion::CLI::Chat::Tools::ConsolidateMemory do + subject(:tool) { described_class } + + let(:tmpdir) { Dir.mktmpdir('consolidate-test') } + + after { FileUtils.rm_rf(tmpdir) } + + before do + allow(Legion::CLI::Chat::MemoryStore).to receive(:project_path).and_return(File.join(tmpdir, 'memory.md')) + allow(Legion::CLI::Chat::MemoryStore).to receive(:global_path).and_return(File.join(tmpdir, 'global.md')) + end + + describe '#execute' do + it 'returns message when no entries exist' do + result = tool.call(scope: 'project') + expect(result).to include('No memory entries found') + end + + it 'returns message when fewer than 3 entries' do + 2.times { |i| Legion::CLI::Chat::MemoryStore.add("entry #{i}", scope: :project, base_dir: tmpdir) } + allow(Legion::CLI::Chat::MemoryStore).to receive(:list).and_return(%w[one two]) + result = tool.call(scope: 'project') + expect(result).to include('no consolidation needed') + end + + it 'consolidates entries via LLM' do + entries = ['Ruby uses AMQP for messaging _(2026-03-20)_', + 'Ruby uses AMQP _(2026-03-21)_', + 'Extension system is called LEX _(2026-03-20)_', + 'LEX stands for Legion Extension _(2026-03-21)_'] + allow(Legion::CLI::Chat::MemoryStore).to receive(:list).and_return(entries) + + fake_response = double('LLMResponse', + content: "- Ruby uses AMQP for messaging\n- Extension system is called LEX (Legion Extension)\n") + + llm_mod = Module.new + stub_const('Legion::LLM', llm_mod) + allow(Legion::LLM).to receive(:chat).and_return(fake_response) + + result = tool.call(scope: 'project') + expect(result).to include('4 -> 2') + expect(result).to include('2 removed/merged') + end + + it 'supports dry_run mode' do + entries = %w[entry1 entry2 entry3] + allow(Legion::CLI::Chat::MemoryStore).to receive(:list).and_return(entries) + + fake_response = double('LLMResponse', content: "- combined entry\n- entry3\n") + + llm_mod = Module.new + stub_const('Legion::LLM', llm_mod) + allow(Legion::LLM).to receive(:chat).and_return(fake_response) + + result = tool.call(scope: 'project', dry_run: 'true') + expect(result).to include('Preview') + expect(result).to include('3 -> 2') + end + + it 'handles LLM unavailable gracefully' do + entries = %w[a b c] + allow(Legion::CLI::Chat::MemoryStore).to receive(:list).and_return(entries) + + hide_const('Legion::LLM') + result = tool.call(scope: 'project') + expect(result).to include('could not generate summary') + end + + it 'handles global scope' do + entries = %w[global1 global2 global3] + allow(Legion::CLI::Chat::MemoryStore).to receive(:list).with(scope: :global).and_return(entries) + + fake_response = double('LLMResponse', content: "- global combined\n") + + llm_mod = Module.new + stub_const('Legion::LLM', llm_mod) + allow(Legion::LLM).to receive(:chat).and_return(fake_response) + + result = tool.call(scope: 'global') + expect(result).to include('global memory') + expect(result).to include('3 -> 1') + end + + it 'writes consolidated file with header and timestamp' do + entries = %w[a b c] + allow(Legion::CLI::Chat::MemoryStore).to receive(:list).and_return(entries) + + fake_response = double('LLMResponse', content: "- consolidated entry\n") + + llm_mod = Module.new + stub_const('Legion::LLM', llm_mod) + allow(Legion::LLM).to receive(:chat).and_return(fake_response) + + tool.call(scope: 'project') + + path = Legion::CLI::Chat::MemoryStore.project_path + expect(File).to exist(path) + content = File.read(path) + expect(content).to include('# Project Memory') + expect(content).to include('Consolidated on') + expect(content).to include('- consolidated entry') + end + + it 'handles errors gracefully' do + allow(Legion::CLI::Chat::MemoryStore).to receive(:list).and_raise(StandardError, 'disk full') + result = tool.call(scope: 'project') + expect(result).to include('Error consolidating memory') + expect(result).to include('disk full') + end + end + + describe '#parse_consolidated' do + it 'extracts entries from LLM output' do + text = "- entry one\n- entry two\nsome junk\n- entry three\n" + result = tool.send(:parse_consolidated, text) + expect(result).to eq(['entry one', 'entry two', 'entry three']) + end + + it 'handles empty output' do + result = tool.send(:parse_consolidated, '') + expect(result).to eq([]) + end + end +end diff --git a/spec/legion/cli/chat/tools/cost_summary_spec.rb b/spec/legion/cli/chat/tools/cost_summary_spec.rb new file mode 100644 index 00000000..2b0b1d73 --- /dev/null +++ b/spec/legion/cli/chat/tools/cost_summary_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/cost_summary' + +RSpec.describe Legion::CLI::Chat::Tools::CostSummary do + subject(:tool) { described_class } + + let(:stub_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(stub_http) + allow(stub_http).to receive(:open_timeout=) + allow(stub_http).to receive(:read_timeout=) + end + + describe '#execute' do + context 'with summary action' do + let(:summary_body) do + '{"data":{"today":0.1234,"week":0.5678,"month":1.9012,"workers":3}}' + end + + before do + response = instance_double(Net::HTTPResponse, body: summary_body) + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns formatted cost summary' do + result = tool.call + expect(result).to include('Cost Summary') + expect(result).to include('$0.1234') + expect(result).to include('$0.5678') + expect(result).to include('$1.9012') + expect(result).to include('Workers: 3') + end + end + + context 'with top action' do + let(:workers_body) do + '{"data":[{"worker_id":"w-1"},{"worker_id":"w-2"}]}' + end + let(:value_body) { '{"data":{"total_cost_usd":0.42}}' } + + before do + workers_response = instance_double(Net::HTTPResponse, body: workers_body) + value_response = instance_double(Net::HTTPResponse, body: value_body) + allow(stub_http).to receive(:get).and_return(workers_response, value_response, value_response) + end + + it 'returns top cost consumers' do + result = tool.call(action: 'top', limit: 5) + expect(result).to include('Top') + expect(result).to include('w-1') + end + end + + context 'with worker action' do + let(:value_body) do + '{"data":{"total_cost_usd":1.23,"total_tokens":5000,"requests":42}}' + end + + before do + response = instance_double(Net::HTTPResponse, body: value_body) + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns worker cost details' do + result = tool.call(action: 'worker', worker_id: 'w-1') + expect(result).to include('Worker: w-1') + expect(result).to include('total_cost_usd') + end + end + + context 'with worker action and missing worker_id' do + it 'returns error message' do + result = tool.call(action: 'worker') + expect(result).to include('worker_id is required') + end + end + + context 'with no workers for top action' do + before do + response = instance_double(Net::HTTPResponse, body: '{"data":[]}') + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns no workers message' do + result = tool.call(action: 'top') + expect(result).to eq('No workers found.') + end + end + + context 'when daemon is not running' do + before do + allow(stub_http).to receive(:get).and_raise(Errno::ECONNREFUSED) + end + + it 'returns daemon not running message' do + result = tool.call + expect(result).to include('daemon not running') + end + end + + context 'when API returns error' do + before do + response = instance_double(Net::HTTPResponse, body: '{"error":"internal"}') + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns the error message' do + result = tool.call + expect(result).to include('API error: internal') + end + end + end +end diff --git a/spec/legion/cli/chat/tools/detect_anomalies_spec.rb b/spec/legion/cli/chat/tools/detect_anomalies_spec.rb new file mode 100644 index 00000000..58264255 --- /dev/null +++ b/spec/legion/cli/chat/tools/detect_anomalies_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/detect_anomalies' + +RSpec.describe Legion::CLI::Chat::Tools::DetectAnomalies do + subject(:tool) { described_class } + + let(:api_port) { 4567 } + + before do + allow(tool).to receive(:api_port).and_return(api_port) + end + + describe '#execute' do + it 'reports no anomalies when system is healthy' do + stub_api_response( + anomalies: [], recent_count: 50, baseline_count: 500, + recent_period: 'last 1 hour', baseline_period: 'previous 23 hours' + ) + + result = tool.call + expect(result).to include('No anomalies detected') + expect(result).to include('50 records') + end + + it 'reports detected anomalies with severity' do + stub_api_response( + anomalies: [ + { metric: 'Average cost', recent: 0.5, baseline: 0.05, ratio: 10.0, severity: 'critical' }, + { metric: 'Average latency', recent: 500.0, baseline: 100.0, ratio: 5.0, severity: 'warning' } + ], + recent_count: 20, baseline_count: 300, + recent_period: 'last 1 hour', baseline_period: 'previous 23 hours' + ) + + result = tool.call + expect(result).to include('2 anomalies detected') + expect(result).to include('[CRITICAL] Average cost') + expect(result).to include('[WARNING] Average latency') + expect(result).to include('Ratio: 10.0x') + end + + it 'passes custom threshold' do + stub_api_response_for_threshold(3.5, anomalies: [], recent_count: 10, baseline_count: 100) + + result = tool.call(threshold: 3.5) + expect(result).to include('No anomalies detected') + end + + it 'handles API error response' do + stub_api_error('trace_search_unavailable', 'TraceSearch requires LLM subsystem') + + result = tool.call + expect(result).to include('TraceSearch requires LLM subsystem') + end + + it 'handles connection refused' do + allow(tool).to receive(:api_get).and_raise(Errno::ECONNREFUSED) + + result = tool.call + expect(result).to include('Legion daemon not running') + end + + it 'handles single anomaly grammar' do + stub_api_response( + anomalies: [{ metric: 'Failure rate', recent: 0.4, baseline: 0.1, ratio: 4.0, severity: 'warning' }], + recent_count: 15, baseline_count: 200 + ) + + result = tool.call + expect(result).to include('1 anomaly detected') + end + end + + def stub_api_response(data) + allow(tool).to receive(:api_get).and_return({ data: data }) + end + + def stub_api_response_for_threshold(threshold, data) + allow(tool).to receive(:api_get) + .with("/api/traces/anomalies?threshold=#{threshold}") + .and_return({ data: data }) + end + + def stub_api_error(code, message) + allow(tool).to receive(:api_get).and_return({ error: { code: code, message: message } }) + end +end diff --git a/spec/legion/cli/chat/tools/entity_extract_spec.rb b/spec/legion/cli/chat/tools/entity_extract_spec.rb new file mode 100644 index 00000000..a0b8af5e --- /dev/null +++ b/spec/legion/cli/chat/tools/entity_extract_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/entity_extract' + +RSpec.describe Legion::CLI::Chat::Tools::EntityExtract do + subject(:tool) { described_class } + + let(:extractor_mod) do + Module.new do + def extract_entities(text:, entity_types: nil, min_confidence: 0.7, **) # rubocop:disable Lint/UnusedMethodArgument + entities = [ + { name: 'Alice', type: 'person', confidence: 0.95 }, + { name: 'LegionIO', type: 'service', confidence: 0.88 } + ] + types = Array(entity_types) + entities.select! { |e| types.include?(e[:type]) } unless types.empty? + entities.select! { |e| e[:confidence] >= min_confidence } + { success: true, entities: entities, source: :llm } + end + end + end + + before do + stub_const('Legion::Extensions::Apollo::Runners::EntityExtractor', extractor_mod) + end + + describe '#execute' do + it 'returns extracted entities' do + result = tool.call(text: 'Alice works on LegionIO') + expect(result).to include('Extracted 2 entities') + expect(result).to include('Alice') + expect(result).to include('LegionIO') + end + + it 'filters by entity type' do + result = tool.call(text: 'Alice works on LegionIO', entity_types: 'person') + expect(result).to include('Alice') + expect(result).not_to include('LegionIO') + end + + it 'returns unavailable when extractor not loaded' do + hide_const('Legion::Extensions::Apollo::Runners::EntityExtractor') + result = tool.call(text: 'test') + expect(result).to eq('Apollo entity extractor not available.') + end + + it 'returns no entities message when none found' do + empty_mod = Module.new do + def extract_entities(**) + { success: true, entities: [], source: :llm } + end + end + stub_const('Legion::Extensions::Apollo::Runners::EntityExtractor', empty_mod) + result = tool.call(text: 'nothing here', min_confidence: 0.99) + expect(result).to eq('No entities found in the provided text.') + end + + it 'shows confidence percentages' do + result = tool.call(text: 'Alice') + expect(result).to include('95%') + end + end +end diff --git a/spec/legion/cli/chat/tools/escalation_status_spec.rb b/spec/legion/cli/chat/tools/escalation_status_spec.rb new file mode 100644 index 00000000..fac12061 --- /dev/null +++ b/spec/legion/cli/chat/tools/escalation_status_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/escalation_status' + +RSpec.describe Legion::CLI::Chat::Tools::EscalationStatus do + subject(:tool) { described_class } + + let(:tracker_mod) do + Module.new do + def self.summary + { + total_escalations: 3, + by_reason: { 'quality' => 2, 'timeout' => 1 }, + by_target_model: { 'gpt-4o' => 2, 'claude-opus-4-6' => 1 }, + by_source_model: { 'gpt-4o-mini' => 2, 'claude-haiku-4-5' => 1 }, + recent: [ + { from_model: 'gpt-4o-mini', to_model: 'gpt-4o', reason: 'quality' } + ] + } + end + + def self.escalation_rate(window_seconds: 3600) + { count: 3, window_seconds: window_seconds } + end + end + end + + before { stub_const('Legion::LLM::EscalationTracker', tracker_mod) } + + describe '#execute' do + it 'returns summary by default' do + result = tool.call + expect(result).to include('Model Escalation Summary') + expect(result).to include('Total Escalations: 3') + expect(result).to include('quality') + end + + it 'shows escalated-to models' do + result = tool.call + expect(result).to include('gpt-4o') + expect(result).to include('Escalated To') + end + + it 'shows rate when requested' do + result = tool.call(action: 'rate') + expect(result).to include('Escalation Rate') + expect(result).to include('3 escalations') + end + + it 'returns unavailable when tracker not defined' do + hide_const('Legion::LLM::EscalationTracker') + result = tool.call + expect(result).to eq('Escalation tracker not available.') + end + end +end diff --git a/spec/legion/cli/chat/tools/file_tools_spec.rb b/spec/legion/cli/chat/tools/file_tools_spec.rb new file mode 100644 index 00000000..dcd1192b --- /dev/null +++ b/spec/legion/cli/chat/tools/file_tools_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/chat/tools/read_file' +require 'legion/cli/chat/tools/write_file' +require 'legion/cli/chat/tools/edit_file' +require 'legion/cli/chat/tools/search_files' +require 'legion/cli/chat/tools/search_content' + +RSpec.describe 'Chat File Tools' do + let(:tmpdir) { Dir.mktmpdir } + + before { Legion::CLI::Chat::Permissions.mode = :headless if defined?(Legion::CLI::Chat::Permissions) } + + after do + FileUtils.rm_rf(tmpdir) + Legion::CLI::Chat::Permissions.mode = :interactive if defined?(Legion::CLI::Chat::Permissions) + end + + describe Legion::CLI::Chat::Tools::ReadFile do + let(:tool) { described_class } + + it 'reads file contents' do + path = File.join(tmpdir, 'test.txt') + File.write(path, "line1\nline2\nline3") + result = tool.call(path: path) + expect(result).to include('line1') + expect(result).to include('line3') + end + + it 'returns error for missing file' do + result = tool.call(path: '/nonexistent/file.txt') + expect(result).to include('error'.downcase).or include('Error') + end + + it 'supports offset and limit' do + path = File.join(tmpdir, 'test.txt') + File.write(path, "line1\nline2\nline3\nline4\nline5") + result = tool.call(path: path, offset: 2, limit: 2) + expect(result).to include('line2') + expect(result).to include('line3') + expect(result).not_to include('line4') + end + end + + describe Legion::CLI::Chat::Tools::WriteFile do + let(:tool) { described_class } + + it 'creates a new file' do + path = File.join(tmpdir, 'new.txt') + result = tool.call(path: path, content: 'hello world') + expect(File.read(path)).to eq('hello world') + expect(result.downcase).to include('wrote') + end + + it 'creates parent directories' do + path = File.join(tmpdir, 'sub', 'dir', 'new.txt') + tool.call(path: path, content: 'nested') + expect(File.read(path)).to eq('nested') + end + end + + describe Legion::CLI::Chat::Tools::EditFile do + let(:tool) { described_class } + + it 'replaces text in a file' do + path = File.join(tmpdir, 'edit.txt') + File.write(path, 'hello world') + result = tool.call(path: path, old_text: 'world', new_text: 'legion') + expect(File.read(path)).to eq('hello legion') + expect(result.downcase).to include('replaced') + end + + it 'errors when old_text not found' do + path = File.join(tmpdir, 'edit.txt') + File.write(path, 'hello world') + result = tool.call(path: path, old_text: 'missing', new_text: 'x') + expect(result.downcase).to include('error') + end + + it 'errors when old_text matches multiple times' do + path = File.join(tmpdir, 'edit.txt') + File.write(path, 'aaa bbb aaa') + result = tool.call(path: path, old_text: 'aaa', new_text: 'x') + expect(result.downcase).to include('error') + end + + it 'errors when no old_text and no start_line provided' do + path = File.join(tmpdir, 'edit.txt') + File.write(path, "line1\nline2\n") + result = tool.call(path: path, new_text: 'x') + expect(result.downcase).to include('error') + end + + context 'line-number mode' do + it 'replaces a single line when only start_line is given' do + path = File.join(tmpdir, 'lines.txt') + File.write(path, "line1\nline2\nline3\n") + result = tool.call(path: path, new_text: 'replaced', start_line: 2) + expect(File.read(path)).to eq("line1\nreplaced\nline3\n") + expect(result.downcase).to include('replaced') + end + + it 'replaces a range of lines when start_line and end_line are given' do + path = File.join(tmpdir, 'lines.txt') + File.write(path, "line1\nline2\nline3\nline4\n") + result = tool.call(path: path, new_text: 'new', start_line: 2, end_line: 3) + expect(File.read(path)).to eq("line1\nnew\nline4\n") + expect(result.downcase).to include('replaced') + end + + it 'replaces the first line' do + path = File.join(tmpdir, 'lines.txt') + File.write(path, "line1\nline2\nline3\n") + tool.call(path: path, new_text: 'first', start_line: 1) + expect(File.read(path)).to eq("first\nline2\nline3\n") + end + + it 'replaces the last line' do + path = File.join(tmpdir, 'lines.txt') + File.write(path, "line1\nline2\nline3\n") + tool.call(path: path, new_text: 'last', start_line: 3) + expect(File.read(path)).to eq("line1\nline2\nlast\n") + end + + it 'preserves trailing newline when replacement text already has one' do + path = File.join(tmpdir, 'lines.txt') + File.write(path, "line1\nline2\nline3\n") + tool.call(path: path, new_text: "newline\n", start_line: 2) + expect(File.read(path)).to eq("line1\nnewline\nline3\n") + end + + it 'ignores old_text when start_line is provided' do + path = File.join(tmpdir, 'lines.txt') + File.write(path, "line1\nline2\nline3\n") + result = tool.call(path: path, new_text: 'x', old_text: 'nomatch', start_line: 1) + expect(result.downcase).not_to include('error') + expect(File.read(path)).to include('x') + end + + it 'errors when start_line is out of bounds' do + path = File.join(tmpdir, 'lines.txt') + File.write(path, "line1\nline2\n") + result = tool.call(path: path, new_text: 'x', start_line: 10) + expect(result.downcase).to include('error') + end + + it 'errors when end_line is out of bounds' do + path = File.join(tmpdir, 'lines.txt') + File.write(path, "line1\nline2\n") + result = tool.call(path: path, new_text: 'x', start_line: 1, end_line: 99) + expect(result.downcase).to include('error') + end + + it 'errors when end_line is before start_line' do + path = File.join(tmpdir, 'lines.txt') + File.write(path, "line1\nline2\nline3\n") + result = tool.call(path: path, new_text: 'x', start_line: 3, end_line: 1) + expect(result.downcase).to include('error') + end + end + end + + describe Legion::CLI::Chat::Tools::SearchFiles do + let(:tool) { described_class } + + it 'finds files matching a glob pattern' do + File.write(File.join(tmpdir, 'foo.rb'), '') + File.write(File.join(tmpdir, 'bar.rb'), '') + File.write(File.join(tmpdir, 'baz.txt'), '') + result = tool.call(pattern: '*.rb', directory: tmpdir) + expect(result).to include('foo.rb') + expect(result).to include('bar.rb') + expect(result).not_to include('baz.txt') + end + end + + describe Legion::CLI::Chat::Tools::SearchContent do + let(:tool) { described_class } + + it 'finds files containing a pattern' do + File.write(File.join(tmpdir, 'match.rb'), 'def hello; end') + File.write(File.join(tmpdir, 'nomatch.rb'), 'x = 1') + result = tool.call(pattern: 'def hello', directory: tmpdir) + expect(result).to include('match.rb') + end + end +end diff --git a/spec/legion/cli/chat/tools/generate_insights_spec.rb b/spec/legion/cli/chat/tools/generate_insights_spec.rb new file mode 100644 index 00000000..73e45656 --- /dev/null +++ b/spec/legion/cli/chat/tools/generate_insights_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/generate_insights' + +RSpec.describe Legion::CLI::Chat::Tools::GenerateInsights do + subject(:tool) { described_class } + + before { allow(tool).to receive(:api_port).and_return(4567) } + + describe '#execute' do + it 'generates a comprehensive report' do + stub_all_endpoints + result = tool.call + expect(result).to include('System Insights Report') + expect(result).to include('Health: ok') + expect(result).to include('Anomalies: None detected') + expect(result).to include('Knowledge: 500 entries') + end + + it 'includes anomaly details when present' do + stub_all_endpoints( + anomalies: { data: { anomalies: [{ metric: 'Average cost', ratio: 5.0, severity: 'critical' }] } } + ) + result = tool.call + expect(result).to include('[CRITICAL] Average cost') + end + + it 'shows trend direction' do + stub_all_endpoints + result = tool.call + expect(result).to include('Trend (24h)') + end + + it 'generates recommendations for anomalies' do + stub_all_endpoints( + anomalies: { data: { anomalies: [{ metric: 'Average cost', ratio: 3.0, severity: 'warning' }] } } + ) + result = tool.call + expect(result).to include('Recommendations') + expect(result).to include('model downgrade') + end + + it 'handles daemon not running' do + allow(tool).to receive(:safe_fetch).and_return(nil) + allow(tool).to receive(:scheduling_status).and_return(nil) + allow(tool).to receive(:llm_status).and_return(nil) + result = tool.call + expect(result).to include('daemon not running') + end + + it 'handles connection refused' do + allow(tool).to receive(:gather_sections).and_raise(Errno::ECONNREFUSED) + result = tool.call + expect(result).to include('daemon not running') + end + + it 'handles partial data gracefully' do + allow(tool).to receive(:safe_fetch).and_return(nil) + allow(tool).to receive(:safe_fetch).with('/api/health').and_return({ data: { status: 'ok' } }) + result = tool.call + expect(result).to include('Health: ok') + end + end + + def stub_all_endpoints(overrides = {}) + defaults = { + health: { data: { status: 'ok', version: '1.4.167' } }, + anomalies: { data: { anomalies: [], recent_count: 50, baseline_count: 500 } }, + trend: { data: { buckets: [ + { time: '2026-03-22T00:00:00Z', count: 100, avg_cost: 0.05, avg_latency: 100.0, failure_rate: 0.01 }, + { time: '2026-03-23T00:00:00Z', count: 120, avg_cost: 0.06, avg_latency: 110.0, failure_rate: 0.02 } + ], hours: 24, bucket_count: 6 } }, + apollo: { data: { total_entries: 500, recent_24h: 20, avg_confidence: 0.85 } }, + graph: { data: { domains: { 'general' => 10 }, total_relations: 5, disputed_entries: 0 } }, + workers: { data: [{ lifecycle_state: 'active' }, { lifecycle_state: 'paused' }] }, + scheduling: { peak_hours: false, batch: { queue_size: 0 } }, + llm: { escalations: 3, shadow_evals: 15 } + }.merge(overrides) + + allow(tool).to receive(:safe_fetch).with('/api/health').and_return(defaults[:health]) + allow(tool).to receive(:safe_fetch).with('/api/traces/anomalies').and_return(defaults[:anomalies]) + allow(tool).to receive(:safe_fetch).with('/api/traces/trend?hours=24&buckets=6').and_return(defaults[:trend]) + allow(tool).to receive(:safe_fetch).with('/api/apollo/stats').and_return(defaults[:apollo]) + allow(tool).to receive(:safe_fetch).with('/api/apollo/graph').and_return(defaults[:graph]) + allow(tool).to receive(:safe_fetch).with('/api/workers').and_return(defaults[:workers]) + allow(tool).to receive(:scheduling_status).and_return(defaults[:scheduling]) + allow(tool).to receive(:llm_status).and_return(defaults[:llm]) + end +end diff --git a/spec/legion/cli/chat/tools/graph_explore_spec.rb b/spec/legion/cli/chat/tools/graph_explore_spec.rb new file mode 100644 index 00000000..daf56b22 --- /dev/null +++ b/spec/legion/cli/chat/tools/graph_explore_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/graph_explore' + +RSpec.describe Legion::CLI::Chat::Tools::GraphExplore do + subject(:tool) { described_class } + + let(:mock_http) { instance_double(Net::HTTP) } + + let(:graph_body) do + JSON.generate({ + data: { + domains: { 'general' => 15, 'claims_optimization' => 8 }, + agents: { 'claude-agent' => 12, 'openai-agent' => 11 }, + relation_types: { 'similar_to' => 10, 'contradicts' => 3 }, + total_relations: 13, + confirmed: 18, + candidates: 3, + disputed_entries: 2 + } + }) + end + + let(:expertise_body) do + JSON.generate({ + data: { + total_agents: 2, + total_domains: 1, + domains: { + 'general' => [ + { agent_id: 'claude-agent', proficiency: 0.85, entry_count: 12 }, + { agent_id: 'openai-agent', proficiency: 0.6, entry_count: 8 } + ] + } + } + }) + end + + let(:disputed_body) do + JSON.generate({ + data: { + entries: [ + { id: 42, content: 'Disputed claim about caching', confidence: 0.35, + content_type: 'fact', tags: ['cache'], source_agent: 'claude-agent' } + ], + count: 1 + } + }) + end + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#execute' do + it 'returns topology by default' do + response = instance_double(Net::HTTPOK, body: graph_body) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('Knowledge Graph Topology') + expect(result).to include('general') + expect(result).to include('claims_optimization') + expect(result).to include('similar_to') + expect(result).to include('Confirmed: 18') + end + + it 'shows expertise map' do + response = instance_double(Net::HTTPOK, body: expertise_body) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(action: 'expertise') + expect(result).to include('Expertise Map') + expect(result).to include('claude-agent') + expect(result).to include('85.0%') + end + + it 'shows disputed entries' do + response = instance_double(Net::HTTPOK, body: disputed_body) + allow(mock_http).to receive(:request).and_return(response) + + result = tool.call(action: 'disputed') + expect(result).to include('Disputed Knowledge Entries') + expect(result).to include('#42') + expect(result).to include('Disputed claim about caching') + end + + it 'handles empty disputed list' do + response = instance_double(Net::HTTPOK, body: JSON.generate({ data: { entries: [], count: 0 } })) + allow(mock_http).to receive(:request).and_return(response) + + result = tool.call(action: 'disputed') + expect(result).to eq('No disputed entries in the knowledge graph.') + end + + it 'handles connection refused' do + allow(mock_http).to receive(:get).and_raise(Errno::ECONNREFUSED) + + result = tool.call + expect(result).to eq('Apollo unavailable (daemon not running).') + end + end +end diff --git a/spec/legion/cli/chat/tools/ingest_knowledge_spec.rb b/spec/legion/cli/chat/tools/ingest_knowledge_spec.rb new file mode 100644 index 00000000..5af1c013 --- /dev/null +++ b/spec/legion/cli/chat/tools/ingest_knowledge_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/ingest_knowledge' + +RSpec.describe Legion::CLI::Chat::Tools::IngestKnowledge do + let(:tool) { described_class } + + let(:success_response) do + response = instance_double(Net::HTTPSuccess, body: JSON.dump({ data: { id: 42, status: 'created' } })) + allow(response).to receive(:is_a?).with(anything).and_return(false) + response + end + + before do + http = instance_double(Net::HTTP) + allow(Net::HTTP).to receive(:new).and_return(http) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).and_return(success_response) + end + + describe '#execute' do + it 'returns success message with id' do + result = tool.call(content: 'Ruby uses GIL for thread safety') + expect(result).to include('Saved to Apollo') + expect(result).to include('id: 42') + end + + it 'defaults content_type to observation' do + result = tool.call(content: 'test') + expect(result).to include('type: observation') + end + + it 'accepts valid content types' do + result = tool.call(content: 'test', content_type: 'fact') + expect(result).to include('type: fact') + end + + it 'rejects invalid content types and falls back to observation' do + result = tool.call(content: 'test', content_type: 'garbage') + expect(result).to include('type: observation') + end + + it 'parses comma-separated tags' do + result = tool.call(content: 'test', tags: 'ruby, performance, gc') + expect(result).to include('ruby') + expect(result).to include('performance') + end + + it 'handles empty tags gracefully' do + result = tool.call(content: 'test', tags: '') + expect(result).to include('Saved to Apollo') + end + + it 'returns error when API returns error' do + error_response = instance_double(Net::HTTPSuccess, + body: JSON.dump({ data: { error: 'validation failed' } })) + allow(error_response).to receive(:is_a?).with(anything).and_return(false) + http = instance_double(Net::HTTP) + allow(Net::HTTP).to receive(:new).and_return(http) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).and_return(error_response) + + result = tool.call(content: 'test') + expect(result).to include('Failed to ingest') + end + + it 'returns unavailable message when daemon is down' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + result = tool.call(content: 'test') + expect(result).to include('Apollo unavailable') + end + + it 'returns error message on unexpected failure' do + allow(Net::HTTP).to receive(:new).and_raise(StandardError, 'network error') + result = tool.call(content: 'test') + expect(result).to include('Error saving to knowledge graph') + expect(result).to include('network error') + end + end +end diff --git a/spec/legion/cli/chat/tools/knowledge_maintenance_spec.rb b/spec/legion/cli/chat/tools/knowledge_maintenance_spec.rb new file mode 100644 index 00000000..1d77210a --- /dev/null +++ b/spec/legion/cli/chat/tools/knowledge_maintenance_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/knowledge_maintenance' + +RSpec.describe Legion::CLI::Chat::Tools::KnowledgeMaintenance do + subject(:tool) { described_class } + + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#execute' do + it 'runs decay_cycle and formats result' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { decayed_count: 12, removed_count: 3, duration_ms: 45 } }) + ) + allow(mock_http).to receive(:request).and_return(response) + + result = tool.call(action: 'decay_cycle') + expect(result).to include('Decay cycle complete') + expect(result).to include('Entries decayed: 12') + expect(result).to include('Entries removed (below threshold): 3') + expect(result).to include('Duration: 45ms') + end + + it 'runs corroboration and formats result' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { checked_count: 100, boosted_count: 15, duration_ms: 120 } }) + ) + allow(mock_http).to receive(:request).and_return(response) + + result = tool.call(action: 'corroboration') + expect(result).to include('Corroboration check complete') + expect(result).to include('Entries checked: 100') + expect(result).to include('Entries boosted (mutually supporting): 15') + end + + it 'rejects invalid actions' do + result = tool.call(action: 'delete_all') + expect(result).to include('Invalid action: delete_all') + expect(result).to include('decay_cycle') + expect(result).to include('corroboration') + end + + it 'returns error from API' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { error: 'table not available' } }) + ) + allow(mock_http).to receive(:request).and_return(response) + + result = tool.call(action: 'decay_cycle') + expect(result).to include('Apollo error: table not available') + end + + it 'handles connection refused' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + + result = tool.call(action: 'decay_cycle') + expect(result).to include('Apollo unavailable') + end + + it 'handles missing duration_ms gracefully' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { decayed_count: 5, removed_count: 0 } }) + ) + allow(mock_http).to receive(:request).and_return(response) + + result = tool.call(action: 'decay_cycle') + expect(result).to include('Entries decayed: 5') + expect(result).not_to include('Duration') + end + + it 'strips and normalizes action input' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { checked_count: 0, boosted_count: 0 } }) + ) + allow(mock_http).to receive(:request).and_return(response) + + result = tool.call(action: ' corroboration ') + expect(result).to include('Corroboration check complete') + end + + it 'uses alternate key names for counts' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { decayed: 7, removed: 2 } }) + ) + allow(mock_http).to receive(:request).and_return(response) + + result = tool.call(action: 'decay_cycle') + expect(result).to include('Entries decayed: 7') + expect(result).to include('Entries removed (below threshold): 2') + end + end +end diff --git a/spec/legion/cli/chat/tools/knowledge_stats_spec.rb b/spec/legion/cli/chat/tools/knowledge_stats_spec.rb new file mode 100644 index 00000000..e96d2364 --- /dev/null +++ b/spec/legion/cli/chat/tools/knowledge_stats_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/knowledge_stats' + +RSpec.describe Legion::CLI::Chat::Tools::KnowledgeStats do + subject(:tool) { described_class } + + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#execute' do + it 'returns formatted stats' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ + data: { + total_entries: 42, + recent_24h: 8, + avg_confidence: 0.782, + by_status: { confirmed: 30, pending: 12 }, + by_content_type: { fact: 20, observation: 15, concept: 7 } + } + }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('Total entries: 42') + expect(result).to include('Recent (24h): 8') + expect(result).to include('Avg confidence: 0.782') + expect(result).to include('confirmed: 30') + expect(result).to include('fact: 20') + expect(result).to include('By Status') + expect(result).to include('By Content Type') + end + + it 'handles empty breakdowns' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { total_entries: 0, recent_24h: 0, avg_confidence: 0.0 } }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('Total entries: 0') + expect(result).not_to include('By Status') + end + + it 'returns error from API' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { error: 'apollo_entries table not available' } }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('Apollo error: apollo_entries table not available') + end + + it 'handles connection refused' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + + result = tool.call + expect(result).to include('Apollo unavailable') + end + + it 'handles missing fields with defaults' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: {} }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('Total entries: 0') + expect(result).to include('Avg confidence: 0.0') + end + end +end diff --git a/spec/legion/cli/chat/tools/list_extensions_spec.rb b/spec/legion/cli/chat/tools/list_extensions_spec.rb new file mode 100644 index 00000000..a9818e00 --- /dev/null +++ b/spec/legion/cli/chat/tools/list_extensions_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/list_extensions' + +RSpec.describe Legion::CLI::Chat::Tools::ListExtensions do + subject(:tool) { described_class } + + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#execute' do + context 'listing all extensions' do + it 'returns formatted extension list' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ + data: [ + { name: 'lex-node', state: 'running' }, + { name: 'lex-scheduler', state: 'running' }, + { name: 'lex-detect', state: 'stopped' } + ] + }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('Loaded Extensions (3)') + expect(result).to include('lex-node (running)') + expect(result).to include('lex-detect (stopped)') + end + + it 'returns message when no extensions found' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: [] })) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('No extensions found') + end + + it 'passes state filter' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: [] })) + expect(mock_http).to receive(:get) do |uri| + expect(uri).to include('state=running') + response + end + + tool.call(state: 'running') + end + end + + context 'extension detail' do + it 'returns extension detail with runners' do + ext_response = instance_double(Net::HTTPOK) + allow(ext_response).to receive(:body).and_return( + JSON.generate({ + data: { name: 'lex-node', state: 'running', version: '1.0.0' } + }) + ) + + runners_response = instance_double(Net::HTTPOK) + allow(runners_response).to receive(:body).and_return( + JSON.generate({ + data: [ + { name: 'node_info', runner_class: 'Legion::Extensions::Node::Runners::Info' } + ] + }) + ) + + call_count = 0 + allow(mock_http).to receive(:get) do |_uri| + call_count += 1 + call_count == 1 ? ext_response : runners_response + end + + result = tool.call(extension_name: 'lex-node') + expect(result).to include('Extension: lex-node') + expect(result).to include('State: running') + expect(result).to include('Runners (1)') + expect(result).to include('node_info') + end + + it 'handles extension with no runners' do + ext_response = instance_double(Net::HTTPOK) + allow(ext_response).to receive(:body).and_return( + JSON.generate({ data: { name: 'lex-empty', state: 'running' } }) + ) + + runners_response = instance_double(Net::HTTPOK) + allow(runners_response).to receive(:body).and_return(JSON.generate({ data: [] })) + + call_count = 0 + allow(mock_http).to receive(:get) do |_uri| + call_count += 1 + call_count == 1 ? ext_response : runners_response + end + + result = tool.call(extension_name: 'lex-empty') + expect(result).to include('No runners registered') + end + end + + it 'handles connection refused' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + result = tool.call + expect(result).to include('daemon not running') + end + + it 'handles API error response' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ error: 'data unavailable' })) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('API error: data unavailable') + end + end +end diff --git a/spec/legion/cli/chat/tools/manage_schedules_spec.rb b/spec/legion/cli/chat/tools/manage_schedules_spec.rb new file mode 100644 index 00000000..fbed0093 --- /dev/null +++ b/spec/legion/cli/chat/tools/manage_schedules_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/manage_schedules' + +RSpec.describe Legion::CLI::Chat::Tools::ManageSchedules do + subject(:tool) { described_class } + + let(:stub_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(stub_http) + allow(stub_http).to receive(:open_timeout=) + allow(stub_http).to receive(:read_timeout=) + end + + describe '#execute' do + context 'with invalid action' do + it 'returns error message' do + result = tool.call(action: 'delete') + expect(result).to include('Invalid action') + end + end + + context 'with list action' do + let(:body) do + '{"data":[{"id":1,"function_id":5,"cron":"0 * * * *","active":true,"description":"Hourly sync"}]}' + end + + before do + response = instance_double(Net::HTTPResponse, body: body) + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns formatted schedule list' do + result = tool.call(action: 'list') + expect(result).to include('Schedules (1)') + expect(result).to include('#1') + expect(result).to include('active') + expect(result).to include('0 * * * *') + expect(result).to include('Hourly sync') + end + end + + context 'with empty list' do + before do + response = instance_double(Net::HTTPResponse, body: '{"data":[]}') + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns no schedules message' do + result = tool.call(action: 'list') + expect(result).to eq('No schedules found.') + end + end + + context 'with show action' do + let(:body) do + '{"data":{"id":1,"function_id":5,"cron":"0 * * * *","active":true}}' + end + + before do + response = instance_double(Net::HTTPResponse, body: body) + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns schedule details' do + result = tool.call(action: 'show', schedule_id: '1') + expect(result).to include('Schedule #1') + expect(result).to include('cron: 0 * * * *') + end + + it 'requires schedule_id' do + result = tool.call(action: 'show') + expect(result).to include('schedule_id is required') + end + end + + context 'with logs action' do + let(:body) do + '{"data":[{"started_at":"2026-03-23T05:00:00Z","status":"success","message":"completed"}]}' + end + + before do + response = instance_double(Net::HTTPResponse, body: body) + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns schedule logs' do + result = tool.call(action: 'logs', schedule_id: '1') + expect(result).to include('Logs for Schedule #1') + expect(result).to include('success') + end + + it 'requires schedule_id' do + result = tool.call(action: 'logs') + expect(result).to include('schedule_id is required') + end + end + + context 'with create action' do + let(:body) { '{"data":{"id":2}}' } + + before do + response = instance_double(Net::HTTPResponse, body: body) + allow(stub_http).to receive(:request).and_return(response) + end + + it 'creates a schedule' do + result = tool.call(action: 'create', function_id: '5', cron: '0 * * * *') + expect(result).to include('Schedule created') + expect(result).to include('id: 2') + end + + it 'requires function_id' do + result = tool.call(action: 'create', cron: '0 * * * *') + expect(result).to include('function_id is required') + end + + it 'requires cron' do + result = tool.call(action: 'create', function_id: '5') + expect(result).to include('cron expression is required') + end + end + + context 'when daemon is not running' do + before do + allow(stub_http).to receive(:get).and_raise(Errno::ECONNREFUSED) + end + + it 'returns daemon not running message' do + result = tool.call(action: 'list') + expect(result).to include('daemon not running') + end + end + end +end diff --git a/spec/legion/cli/chat/tools/manage_tasks_spec.rb b/spec/legion/cli/chat/tools/manage_tasks_spec.rb new file mode 100644 index 00000000..ae21cca8 --- /dev/null +++ b/spec/legion/cli/chat/tools/manage_tasks_spec.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/manage_tasks' + +RSpec.describe Legion::CLI::Chat::Tools::ManageTasks do + subject(:tool) { described_class } + + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#execute' do + context 'invalid action' do + it 'returns error for unknown action' do + result = tool.call(action: 'destroy') + expect(result).to include('Invalid action: destroy') + expect(result).to include('list, show, logs, trigger') + end + end + + context 'list action' do + it 'returns formatted task list' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ + data: [ + { id: 1, status: 'completed', runner_class: 'Node::Runners::Info', + function: 'execute', created_at: '2026-03-23T10:00:00Z' }, + { id: 2, status: 'failed', runner_class: 'Scheduler::Runners::Run', + function: 'trigger', created_at: '2026-03-23T10:05:00Z' } + ] + }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(action: 'list') + expect(result).to include('Recent Tasks (2)') + expect(result).to include('#1 [completed]') + expect(result).to include('#2 [failed]') + expect(result).to include('Node::Runners::Info') + end + + it 'passes status filter' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: [] })) + expect(mock_http).to receive(:get) do |uri| + expect(uri).to include('status=failed') + response + end + + tool.call(action: 'list', status: 'failed') + end + + it 'returns message when no tasks found' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: [] })) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(action: 'list') + expect(result).to include('No tasks found') + end + end + + context 'show action' do + it 'returns task detail with metering' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ + data: { + id: 42, status: 'completed', + runner_class: 'Node::Runners::Info', function: 'execute', + created_at: '2026-03-23T10:00:00Z', updated_at: '2026-03-23T10:00:05Z', + metering: { + total_tokens: 1500, input_tokens: 1000, output_tokens: 500, + total_calls: 3, avg_latency_ms: 120.5, + provider: ['bedrock'], model: ['claude-sonnet-4-20250514'] + } + } + }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(action: 'show', task_id: 42) + expect(result).to include('Task #42') + expect(result).to include('Status: completed') + expect(result).to include('Metering:') + expect(result).to include('Total tokens: 1500') + expect(result).to include('Avg latency: 120.5ms') + end + + it 'requires task_id' do + result = tool.call(action: 'show') + expect(result).to include('task_id is required') + end + end + + context 'logs action' do + it 'returns formatted task logs' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ + data: [ + { created_at: '2026-03-23T10:00:00Z', level: 'info', message: 'Task started' }, + { created_at: '2026-03-23T10:00:05Z', level: 'info', message: 'Task completed' } + ] + }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(action: 'logs', task_id: 42) + expect(result).to include('Logs for Task #42 (2 entries)') + expect(result).to include('Task started') + expect(result).to include('Task completed') + end + + it 'requires task_id' do + result = tool.call(action: 'logs') + expect(result).to include('task_id is required') + end + + it 'handles empty logs' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: [] })) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(action: 'logs', task_id: 99) + expect(result).to include('No logs found for task 99') + end + end + + context 'trigger action' do + it 'triggers a task via POST' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { task_id: 100 } }) + ) + + request = instance_double(Net::HTTP::Post) + allow(request).to receive(:body=) + allow(Net::HTTP::Post).to receive(:new).and_return(request) + allow(mock_http).to receive(:request).and_return(response) + + result = tool.call(action: 'trigger', runner_class: 'Node::Runners::Info', function: 'execute') + expect(result).to include('Task triggered successfully') + expect(result).to include('Task ID: 100') + end + + it 'requires runner_class' do + result = tool.call(action: 'trigger', function: 'execute') + expect(result).to include('runner_class is required') + end + + it 'requires function' do + result = tool.call(action: 'trigger', runner_class: 'Node::Runners::Info') + expect(result).to include('function is required') + end + + it 'passes JSON payload' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { task_id: 101 } }) + ) + + request = instance_double(Net::HTTP::Post) + allow(Net::HTTP::Post).to receive(:new).and_return(request) + allow(mock_http).to receive(:request).and_return(response) + + expect(request).to receive(:body=) do |body| + parsed = JSON.parse(body, symbolize_names: true) + expect(parsed[:runner_class]).to eq('Node::Runners::Info') + expect(parsed[:target]).to eq('localhost') + end + + tool.call(action: 'trigger', runner_class: 'Node::Runners::Info', + function: 'execute', payload: '{"target":"localhost"}') + end + end + + it 'handles connection refused' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + result = tool.call(action: 'list') + expect(result).to include('daemon not running') + end + + it 'handles API error response' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ error: 'service unavailable' })) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(action: 'list') + expect(result).to include('API error: service unavailable') + end + end +end diff --git a/spec/legion/cli/chat/tools/memory_and_agent_tools_spec.rb b/spec/legion/cli/chat/tools/memory_and_agent_tools_spec.rb new file mode 100644 index 00000000..a2b9c3d9 --- /dev/null +++ b/spec/legion/cli/chat/tools/memory_and_agent_tools_spec.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/memory_store' +require 'legion/cli/chat/subagent' +require 'legion/cli/chat/web_search' +require 'legion/cli/chat/tools/save_memory' +require 'legion/cli/chat/tools/search_memory' +require 'legion/cli/chat/tools/spawn_agent' +require 'legion/cli/chat/tools/web_search' + +RSpec.describe 'Chat Memory and Agent Tools' do + describe Legion::CLI::Chat::Tools::SaveMemory do + let(:tool) { described_class } + + before do + allow(Legion::CLI::Chat::MemoryStore).to receive(:add).and_return('/tmp/.legion/memory.md') + allow(tool).to receive(:ingest_to_apollo).and_return(nil) + end + + it 'saves to project memory by default' do + result = tool.call(text: 'always use rspec') + expect(result).to include('project memory') + expect(Legion::CLI::Chat::MemoryStore).to have_received(:add).with('always use rspec', scope: :project) + end + + it 'saves to global memory when scope is global' do + result = tool.call(text: 'prefer vim', scope: 'global') + expect(result).to include('global memory') + expect(Legion::CLI::Chat::MemoryStore).to have_received(:add).with('prefer vim', scope: :global) + end + + it 'includes the file path in response' do + result = tool.call(text: 'test') + expect(result).to include('/tmp/.legion/memory.md') + end + + it 'includes apollo confirmation when available' do + allow(tool).to receive(:ingest_to_apollo).and_return('Also ingested into Apollo knowledge graph.') + result = tool.call(text: 'important fact') + expect(result).to include('project memory') + expect(result).to include('Apollo knowledge graph') + end + + it 'omits apollo when unavailable' do + result = tool.call(text: 'test') + expect(result).not_to include('Apollo') + end + + it 'returns error message on failure' do + allow(Legion::CLI::Chat::MemoryStore).to receive(:add).and_raise(Errno::EACCES, 'Permission denied') + result = tool.call(text: 'test') + expect(result).to include('Error saving memory') + expect(result).to include('Permission denied') + end + end + + describe Legion::CLI::Chat::Tools::SearchMemory do + let(:tool) { described_class } + + before do + allow(Legion::CLI::Chat::MemoryStore).to receive(:search).and_return([]) + allow(tool).to receive(:search_apollo).and_return(nil) + end + + it 'returns no-match message when empty' do + result = tool.call(query: 'nonexistent') + expect(result).to include('No matching memories') + end + + it 'returns formatted memory results' do + allow(Legion::CLI::Chat::MemoryStore).to receive(:search).and_return([ + { text: 'always use rspec', source: '/project/.legion/memory.md', line: 3 }, + { text: 'prefer snake_case', source: '/project/.legion/memory.md', line: 5 } + ]) + result = tool.call(query: 'use') + expect(result).to include('Memory matches (2)') + expect(result).to include('always use rspec') + expect(result).to include('prefer snake_case') + end + + it 'includes apollo knowledge when available' do + allow(tool).to receive(:search_apollo).and_return([ + { type: 'pattern', content: 'Use YJIT for performance', confidence: 0.95 } + ]) + result = tool.call(query: 'performance') + expect(result).to include('Apollo knowledge (1)') + expect(result).to include('[pattern] Use YJIT for performance') + expect(result).to include('confidence: 0.95') + end + + it 'combines memory and apollo results' do + allow(Legion::CLI::Chat::MemoryStore).to receive(:search).and_return([ + { text: 'always use rspec', source: 'x', line: 1 } + ]) + allow(tool).to receive(:search_apollo).and_return([ + { type: 'fact', content: 'RSpec is the standard test framework', confidence: 0.9 } + ]) + result = tool.call(query: 'rspec') + expect(result).to include('Memory matches (1)') + expect(result).to include('Apollo knowledge (1)') + end + + it 'returns only memory when apollo is unavailable' do + allow(Legion::CLI::Chat::MemoryStore).to receive(:search).and_return([ + { text: 'fact one', source: 'x', line: 1 } + ]) + result = tool.call(query: 'fact') + expect(result).to include('fact one') + expect(result).not_to include('Apollo') + end + + it 'returns error message on failure' do + allow(Legion::CLI::Chat::MemoryStore).to receive(:search).and_raise(StandardError, 'disk error') + result = tool.call(query: 'test') + expect(result).to include('Error searching memory') + expect(result).to include('disk error') + end + end + + describe Legion::CLI::Chat::Tools::SpawnAgent do + let(:tool) { described_class } + + before do + allow(Legion::CLI::Chat::Subagent).to receive(:spawn).and_return({ id: 'agent-001' }) + end + + it 'starts a subagent and returns confirmation' do + result = tool.call(task: 'review the auth module') + expect(result).to include('agent-001') + expect(result).to include('review the auth module') + end + + it 'passes task and model to Subagent.spawn' do + tool.call(task: 'fix the bug', model: 'claude-sonnet') + expect(Legion::CLI::Chat::Subagent).to have_received(:spawn).with( + hash_including(task: 'fix the bug', model: 'claude-sonnet') + ) + end + + it 'reports subagent errors' do + allow(Legion::CLI::Chat::Subagent).to receive(:spawn).and_return({ error: 'concurrency limit reached' }) + result = tool.call(task: 'test') + expect(result).to include('Subagent error') + expect(result).to include('concurrency limit reached') + end + + it 'returns error message on exception' do + allow(Legion::CLI::Chat::Subagent).to receive(:spawn).and_raise(StandardError, 'spawn failed') + result = tool.call(task: 'test') + expect(result).to include('Error spawning subagent') + expect(result).to include('spawn failed') + end + end + + describe Legion::CLI::Chat::Tools::WebSearch do + let(:tool) { described_class } + + let(:search_results) do + { + query: 'ruby testing', + results: [ + { title: 'RSpec Guide', url: 'https://rspec.info', snippet: 'Behaviour driven development for Ruby' }, + { title: 'Minitest Docs', url: 'https://minitest.info', snippet: 'A complete suite of testing facilities' } + ], + fetched_content: 'Full page content from RSpec Guide...' + } + end + + before do + allow(Legion::CLI::Chat::WebSearch).to receive(:search).and_return(search_results) + end + + it 'returns formatted search results' do + result = tool.call(query: 'ruby testing') + expect(result).to include('RSpec Guide') + expect(result).to include('https://rspec.info') + expect(result).to include('Behaviour driven development') + end + + it 'includes fetched content from top result' do + result = tool.call(query: 'ruby testing') + expect(result).to include('Top Result Content') + expect(result).to include('Full page content from RSpec Guide') + end + + it 'omits fetched content section when nil' do + allow(Legion::CLI::Chat::WebSearch).to receive(:search).and_return( + search_results.merge(fetched_content: nil) + ) + result = tool.call(query: 'ruby testing') + expect(result).not_to include('Top Result Content') + end + + it 'passes max_results to search' do + tool.call(query: 'test', max_results: 3) + expect(Legion::CLI::Chat::WebSearch).to have_received(:search).with('test', max_results: 3) + end + + it 'returns search error message' do + allow(Legion::CLI::Chat::WebSearch).to receive(:search).and_raise( + Legion::CLI::Chat::WebSearch::SearchError, 'No results found.' + ) + result = tool.call(query: 'xyznonexistent') + expect(result).to include('Search error') + expect(result).to include('No results found') + end + + it 'returns generic error message on unexpected failure' do + allow(Legion::CLI::Chat::WebSearch).to receive(:search).and_raise(StandardError, 'network timeout') + result = tool.call(query: 'test') + expect(result).to include('Error:') + expect(result).to include('network timeout') + end + end +end diff --git a/spec/legion/cli/chat/tools/memory_status_spec.rb b/spec/legion/cli/chat/tools/memory_status_spec.rb new file mode 100644 index 00000000..c2cabbd0 --- /dev/null +++ b/spec/legion/cli/chat/tools/memory_status_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/memory_status' + +RSpec.describe Legion::CLI::Chat::Tools::MemoryStatus do + subject(:tool) { described_class } + + before do + allow(tool).to receive(:api_port).and_return(4567) + end + + describe '#execute' do + context 'with overview action' do + it 'shows memory and session counts' do + allow(tool).to receive(:memory_stats).and_return({ project: 2, global: 1 }) + allow(tool).to receive(:session_list).and_return([{ name: 'session1' }]) + allow(tool).to receive(:apollo_stats).and_return(nil) + + result = tool.call + expect(result).to include('Memory & Knowledge Overview') + expect(result).to include('2 project, 1 global') + expect(result).to include('Saved Sessions: 1') + end + + it 'shows apollo stats when available' do + allow(tool).to receive(:memory_stats).and_return({ project: 0, global: 0 }) + allow(tool).to receive(:session_list).and_return([]) + allow(tool).to receive(:apollo_stats).and_return( + { total: 500, confirmed: 400, disputed: 5, candidates: 95 } + ) + + result = tool.call + expect(result).to include('500 entries') + expect(result).to include('400 confirmed') + expect(result).to include('5 disputed') + end + end + + context 'with memories action' do + it 'lists project and global memory entries' do + allow(tool).to receive(:format_memories).and_return( + "Persistent Memory Detail:\n\n Project Memory:\n 1. use bun for install\n 2. prefer postgres\n\n Global Memory:\n 1. timezone: CT" + ) + + result = tool.call(action: 'memories') + expect(result).to include('use bun for install') + expect(result).to include('prefer postgres') + expect(result).to include('timezone: CT') + end + end + + context 'with apollo action' do + it 'shows knowledge store statistics' do + allow(tool).to receive(:apollo_stats).and_return( + { total: 300, confirmed: 250, candidates: 40, disputed: 10, + recent_24h: 15, avg_confidence: 0.87, + domains: { 'infrastructure' => 120, 'security' => 80 } } + ) + + result = tool.call(action: 'apollo') + expect(result).to include('Total Entries: 300') + expect(result).to include('Avg Confidence: 0.87') + expect(result).to include('infrastructure') + end + + it 'handles apollo unavailable' do + allow(tool).to receive(:apollo_stats).and_return(nil) + + result = tool.call(action: 'apollo') + expect(result).to include('not available') + end + end + + context 'with sessions action' do + it 'lists saved sessions' do + session_output = [ + "Saved Sessions (2):\n", + ' debug-cache 24 msgs 1h ago claude-sonnet-4-6', + ' Debugging cache issues', + ' feature-auth 50 msgs 1d ago claude-sonnet-4-6', + ' Auth feature implementation' + ].join("\n") + allow(tool).to receive(:format_sessions).and_return(session_output) + + result = tool.call(action: 'sessions') + expect(result).to include('debug-cache') + expect(result).to include('feature-auth') + expect(result).to include('Debugging cache') + end + + it 'handles no sessions' do + allow(tool).to receive(:format_sessions).and_return('No saved sessions found.') + + result = tool.call(action: 'sessions') + expect(result).to include('No saved sessions') + end + end + end +end diff --git a/spec/legion/cli/chat/tools/model_comparison_spec.rb b/spec/legion/cli/chat/tools/model_comparison_spec.rb new file mode 100644 index 00000000..7d65aa9d --- /dev/null +++ b/spec/legion/cli/chat/tools/model_comparison_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/model_comparison' + +RSpec.describe Legion::CLI::Chat::Tools::ModelComparison do + subject(:tool) { described_class } + + describe '#execute' do + it 'returns comparison table for all models' do + result = tool.call + expect(result).to include('Model Comparison') + expect(result).to include('gpt-4o-mini') + expect(result).to include('claude-sonnet-4-6') + end + + it 'filters by model name substring' do + result = tool.call(models: 'claude') + expect(result).to include('claude-sonnet-4-6') + expect(result).not_to include('gpt-4o-mini') + end + + it 'returns no matching message for unknown model' do + result = tool.call(models: 'nonexistent-model-xyz') + expect(result).to eq('No matching models found.') + end + + it 'includes cost estimate' do + result = tool.call(tokens: 5000) + expect(result).to include('5000 input') + expect(result).to include('Est. Cost') + end + + it 'shows price ratio when multiple models compared' do + result = tool.call + expect(result).to include('more expensive than') + end + + it 'uses CostTracker pricing when available' do + tracker = Module.new + tracker.const_set(:DEFAULT_PRICING, { 'test-model' => { input: 1.0, output: 2.0 } }.freeze) + stub_const('Legion::LLM::CostTracker', tracker) + + result = tool.call + expect(result).to include('test-model') + end + end +end diff --git a/spec/legion/cli/chat/tools/provider_health_spec.rb b/spec/legion/cli/chat/tools/provider_health_spec.rb new file mode 100644 index 00000000..f0466cd6 --- /dev/null +++ b/spec/legion/cli/chat/tools/provider_health_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/provider_health' + +RSpec.describe Legion::CLI::Chat::Tools::ProviderHealth do + subject(:tool) { described_class } + + describe '#execute' do + context 'when native provider inventory is loaded' do + let(:inventory_mod) do + Module.new do + def self.providers + { + anthropic: [ + { + model: 'claude-sonnet-4-6', + type: :inference, + provider_instance: 'bedrock-east-2', + health: { circuit_state: 'closed', adjustment: 0 } + } + ], + openai: [ + { + model: 'gpt-4.1', + type: :chat, + instance_id: 'frontier-openai', + health: { circuit_state: 'open', adjustment: -50 } + } + ] + } + end + end + end + + before do + stub_const('Legion::LLM::Inventory', inventory_mod) + end + + it 'returns health report from inventory' do + result = tool.call + expect(result).to include('Provider Health Report') + expect(result).to include('anthropic') + expect(result).to include('openai') + expect(result).to include('offerings=1') + expect(result).to include('models=1') + end + + it 'returns detail for a specific native provider' do + result = tool.call(provider: 'anthropic') + expect(result).to include('Provider: anthropic') + expect(result).to include('Healthy: YES') + end + + it 'returns not found for unknown native providers' do + result = tool.call(provider: 'bedrock') + expect(result).to eq('Provider not found: bedrock') + end + end + + it 'returns error when no providers are configured' do + result = tool.call + expect(result).to eq('No providers configured.').or eq('LLM provider inventory not available.') + end + + it 'does not fall back to legacy gateway provider stats' do + stats_mod = Module.new do + def self.health_report + [{ provider: 'gateway', circuit: 'closed', adjustment: 0, healthy: true }] + end + end + stub_const('Legion::Extensions::Llm::Gateway::Runners::ProviderStats', stats_mod) + + result = tool.call + expect(result).to eq('No providers configured.').or eq('LLM provider inventory not available.') + end + end +end diff --git a/spec/legion/cli/chat/tools/query_knowledge_spec.rb b/spec/legion/cli/chat/tools/query_knowledge_spec.rb new file mode 100644 index 00000000..81d955f7 --- /dev/null +++ b/spec/legion/cli/chat/tools/query_knowledge_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/query_knowledge' + +RSpec.describe Legion::CLI::Chat::Tools::QueryKnowledge do + let(:tool) { described_class } + let(:mock_http) { instance_double(Net::HTTP) } + + let(:query_response_body) do + JSON.generate({ + data: { + entries: [ + { content: 'Legion uses AMQP for messaging', content_type: 'fact', + confidence: 0.95, tags: %w[architecture transport] }, + { content: 'Extensions are discovered via Bundler', content_type: 'fact', + confidence: 0.88, tags: %w[extensions] } + ] + } + }) + end + + let(:empty_response_body) do + JSON.generate({ data: { entries: [] } }) + end + + let(:error_response_body) do + JSON.generate({ data: { error: 'apollo not available' } }) + end + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#execute' do + context 'with matching results' do + before do + response = instance_double(Net::HTTPOK, body: query_response_body) + allow(mock_http).to receive(:request).and_return(response) + end + + it 'returns formatted entries' do + result = tool.call(query: 'how does legion communicate') + expect(result).to include('Found 2 knowledge entries') + end + + it 'includes content type' do + result = tool.call(query: 'messaging') + expect(result).to include('[fact]') + end + + it 'includes confidence score' do + result = tool.call(query: 'messaging') + expect(result).to include('confidence: 0.95') + end + + it 'includes content text' do + result = tool.call(query: 'amqp') + expect(result).to include('Legion uses AMQP') + end + + it 'includes tags' do + result = tool.call(query: 'amqp') + expect(result).to include('architecture') + end + end + + context 'with no results' do + before do + response = instance_double(Net::HTTPOK, body: empty_response_body) + allow(mock_http).to receive(:request).and_return(response) + end + + it 'returns no results message' do + result = tool.call(query: 'nonexistent topic') + expect(result).to include('No knowledge entries found') + end + end + + context 'when apollo returns error' do + before do + response = instance_double(Net::HTTPOK, body: error_response_body) + allow(mock_http).to receive(:request).and_return(response) + end + + it 'returns error message' do + result = tool.call(query: 'anything') + expect(result).to include('apollo not available') + end + end + + context 'when connection fails' do + before do + allow(mock_http).to receive(:request).and_raise(Errno::ECONNREFUSED) + end + + it 'returns error message' do + result = tool.call(query: 'test') + expect(result).to include('Error querying knowledge graph') + end + end + + context 'with domain filter' do + before do + response = instance_double(Net::HTTPOK, body: query_response_body) + allow(mock_http).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:domain]).to eq('architecture') + response + end + allow(response).to receive(:body).and_return(query_response_body) + end + + it 'passes domain to API' do + tool.call(query: 'test', domain: 'architecture') + end + end + + context 'with limit' do + before do + response = instance_double(Net::HTTPOK, body: query_response_body) + allow(mock_http).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:limit]).to eq(5) + response + end + allow(response).to receive(:body).and_return(query_response_body) + end + + it 'passes limit to API' do + tool.call(query: 'test', limit: 5) + end + end + + it 'clamps limit to 1..50' do + response = instance_double(Net::HTTPOK, body: query_response_body) + allow(mock_http).to receive(:request) do |req| + body = JSON.parse(req.body, symbolize_names: true) + expect(body[:limit]).to eq(50) + response + end + + tool.call(query: 'test', limit: 999) + end + end +end diff --git a/spec/legion/cli/chat/tools/reflect_spec.rb b/spec/legion/cli/chat/tools/reflect_spec.rb new file mode 100644 index 00000000..c9e771a6 --- /dev/null +++ b/spec/legion/cli/chat/tools/reflect_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/reflect' + +RSpec.describe Legion::CLI::Chat::Tools::Reflect do + subject(:tool) { described_class } + + let(:stub_http) { instance_double(Net::HTTP) } + let(:success_response) { instance_double(Net::HTTPSuccess, is_a?: true) } + + before do + allow(Net::HTTP).to receive(:new).and_return(stub_http) + allow(stub_http).to receive(:open_timeout=) + allow(stub_http).to receive(:read_timeout=) + end + + describe '#execute' do + context 'without LLM available' do + before do + allow(stub_http).to receive(:request).and_return(success_response) + allow(success_response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + + memory_store = Module.new do + def self.add(_text, scope:); end + end + stub_const('Legion::CLI::Chat::MemoryStore', memory_store) + end + + it 'ingests the raw text as a single entry' do + result = tool.call(text: 'Ruby blocks capture their enclosing scope') + expect(result).to include('Reflected on 1 knowledge entries') + expect(result).to include('Ruby blocks capture their enclosing scope') + end + end + + context 'with LLM available' do + let(:llm_response) do + double('response', content: "- Pattern: use **opts for extensible params\n- Convention: snake_case for methods\n") + end + + before do + llm = Module.new do + def self.chat(**); end + + def self.respond_to?(method, *args) + return true if method == :chat + + super + end + end + stub_const('Legion::LLM', llm) + allow(llm).to receive(:chat).and_return(llm_response) + + allow(stub_http).to receive(:request).and_return(success_response) + allow(success_response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + + memory_store = Module.new do + def self.add(_text, scope:); end + end + stub_const('Legion::CLI::Chat::MemoryStore', memory_store) + end + + it 'extracts and ingests multiple entries' do + result = tool.call(text: 'We used **opts pattern and snake_case conventions') + expect(result).to include('Reflected on 2 knowledge entries') + expect(result).to include('Pattern: use **opts for extensible params') + expect(result).to include('Convention: snake_case for methods') + end + + it 'reports save counts' do + result = tool.call(text: 'We used **opts pattern') + expect(result).to include('Saved: 2 to Apollo, 2 to memory') + end + end + + context 'when apollo is unreachable but memory works' do + before do + allow(stub_http).to receive(:request).and_raise(Errno::ECONNREFUSED) + + memory_store = Module.new do + def self.add(_text, scope:); end + end + stub_const('Legion::CLI::Chat::MemoryStore', memory_store) + end + + it 'saves to memory only' do + result = tool.call(text: 'Important finding') + expect(result).to include('0 to Apollo') + expect(result).to include('1 to memory') + end + end + + context 'with no actionable entries from LLM' do + let(:llm_response) { double('response', content: 'Nothing useful here.') } + + before do + llm = Module.new do + def self.chat(**); end + + def self.respond_to?(method, *args) + return true if method == :chat + + super + end + end + stub_const('Legion::LLM', llm) + allow(llm).to receive(:chat).and_return(llm_response) + end + + it 'returns no actionable knowledge message' do + result = tool.call(text: 'Just chatting about nothing') + expect(result).to include('No actionable knowledge') + end + end + + context 'with domain specified' do + before do + allow(stub_http).to receive(:request).and_return(success_response) + allow(success_response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + + memory_store = Module.new do + def self.add(_text, scope:); end + end + stub_const('Legion::CLI::Chat::MemoryStore', memory_store) + end + + it 'passes domain to apollo ingest' do + tool.call(text: 'Database indexes speed up queries', domain: 'database') + expect(stub_http).to have_received(:request).with( + an_object_having_attributes(body: a_string_including('"knowledge_domain":"database"')) + ) + end + end + end +end diff --git a/spec/legion/cli/chat/tools/relate_knowledge_spec.rb b/spec/legion/cli/chat/tools/relate_knowledge_spec.rb new file mode 100644 index 00000000..3a1630da --- /dev/null +++ b/spec/legion/cli/chat/tools/relate_knowledge_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/relate_knowledge' + +RSpec.describe Legion::CLI::Chat::Tools::RelateKnowledge do + subject(:tool) { described_class } + + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#execute' do + it 'returns formatted related entries' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ + data: { + entries: [ + { content: 'AMQP uses RabbitMQ', relation_type: 'supports', confidence: 0.9 }, + { content: 'Messaging is async', relation_type: 'related', confidence: 0.7 } + ] + } + }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(entry_id: 42) + expect(result).to include('Related entries for #42') + expect(result).to include('[supports]') + expect(result).to include('AMQP uses RabbitMQ') + expect(result).to include('(conf: 0.9)') + end + + it 'returns message when no related entries found' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: { entries: [] } })) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(entry_id: 99) + expect(result).to include('No related entries found') + end + + it 'returns error from API' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: { error: 'not found' } })) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(entry_id: 1) + expect(result).to include('Apollo error: not found') + end + + it 'handles connection refused' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + + result = tool.call(entry_id: 1) + expect(result).to include('Apollo unavailable') + end + + it 'clamps depth to 1-3' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: { entries: [] } })) + expect(mock_http).to receive(:get) do |uri| + expect(uri).to include('depth=3') + response + end + + tool.call(entry_id: 1, depth: 10) + end + + it 'passes relation_types as query param' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: { entries: [] } })) + expect(mock_http).to receive(:get) do |uri| + expect(uri).to include('relation_types=supports,contradicts') + response + end + + tool.call(entry_id: 1, relation_types: 'supports,contradicts') + end + + it 'includes depth in output header' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ data: { entries: [{ content: 'test' }] } }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call(entry_id: 5, depth: 3) + expect(result).to include('depth: 3') + end + end +end diff --git a/spec/legion/cli/chat/tools/run_command_spec.rb b/spec/legion/cli/chat/tools/run_command_spec.rb new file mode 100644 index 00000000..a9737ecb --- /dev/null +++ b/spec/legion/cli/chat/tools/run_command_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/run_command' + +RSpec.describe Legion::CLI::Chat::Tools::RunCommand do + before { Legion::CLI::Chat::Permissions.mode = :headless if defined?(Legion::CLI::Chat::Permissions) } + after { Legion::CLI::Chat::Permissions.mode = :interactive if defined?(Legion::CLI::Chat::Permissions) } + + let(:tool) { described_class } + + it 'executes a shell command and returns output' do + result = tool.call(command: 'echo hello') + expect(result).to include('hello') + end + + it 'returns exit code' do + result = tool.call(command: 'echo hello') + expect(result).to include('exit code: 0') + end + + it 'returns stderr on failure' do + result = tool.call(command: 'ls /nonexistent_path_12345') + expect(result).to include('exit code') + end + + it 'respects timeout' do + result = tool.call(command: 'sleep 10', timeout: 1) + expect(result).to include('timed out') + end + + describe 'sandbox routing' do + it 'defaults to direct execution when sandboxed_commands not enabled' do + allow(Legion::Settings).to receive(:dig).with(:chat, :sandboxed_commands, :enabled).and_return(nil) + result = tool.call(command: 'echo sandbox-test') + expect(result).to include('sandbox-test') + expect(result).to include('exit code: 0') + end + + it 'uses sandbox when enabled and available' do + allow(Legion::Settings).to receive(:dig).with(:chat, :sandboxed_commands, :enabled).and_return(true) + + stub_const('Legion::Extensions::Exec::Runners::Shell', Module.new do + def self.execute(command:, **) + { success: true, stdout: "sandboxed: #{command}", stderr: '', exit_code: 0 } + end + end) + + result = tool.call(command: 'echo hello') + expect(result).to include('sandboxed: echo hello') + end + + it 'returns blocked message when sandbox rejects command' do + allow(Legion::Settings).to receive(:dig).with(:chat, :sandboxed_commands, :enabled).and_return(true) + + stub_const('Legion::Extensions::Exec::Runners::Shell', Module.new do + def self.execute(**) + { success: false, error: :blocked, reason: 'rm not in allowlist' } + end + end) + + result = tool.call(command: 'rm -rf /') + expect(result).to include('blocked by sandbox') + expect(result).to include('rm not in allowlist') + end + + it 'falls back to direct execution when sandbox not loaded' do + allow(Legion::Settings).to receive(:dig).with(:chat, :sandboxed_commands, :enabled).and_return(true) + hide_const('Legion::Extensions::Exec::Runners::Shell') if defined?(Legion::Extensions::Exec::Runners::Shell) + + result = tool.call(command: 'echo fallback') + expect(result).to include('fallback') + expect(result).to include('exit code: 0') + end + end +end diff --git a/spec/legion/cli/chat/tools/scheduling_status_spec.rb b/spec/legion/cli/chat/tools/scheduling_status_spec.rb new file mode 100644 index 00000000..566c86d1 --- /dev/null +++ b/spec/legion/cli/chat/tools/scheduling_status_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/scheduling_status' + +RSpec.describe Legion::CLI::Chat::Tools::SchedulingStatus do + subject(:tool) { described_class } + + let(:scheduling_mod) do + Module.new do + def self.status + { + enabled: true, + peak_hours: true, + peak_range: '14..22', + next_off_peak: '2026-03-23T23:00:00Z', + defer_intents: %i[batch background maintenance], + max_defer_hours: 8 + } + end + end + end + + let(:batch_mod) do + Module.new do + def self.status + { + enabled: true, + queue_size: 5, + max_batch_size: 100, + window_seconds: 300, + oldest_queued: '2026-03-23T12:00:00Z', + by_priority: { normal: 3, low: 2 } + } + end + end + end + + before do + stub_const('Legion::LLM::Scheduling', scheduling_mod) + stub_const('Legion::LLM::Batch', batch_mod) + end + + describe '#execute' do + it 'returns overview by default' do + result = tool.call + expect(result).to include('Scheduling & Batch Overview') + expect(result).to include('peak now') + expect(result).to include('Queue Depth: 5') + end + + it 'shows scheduling detail' do + result = tool.call(action: 'scheduling') + expect(result).to include('Scheduling Detail') + expect(result).to include('14..22') + expect(result).to include('Max Defer Hours: 8') + expect(result).to include('batch, background, maintenance') + end + + it 'shows batch detail' do + result = tool.call(action: 'batch') + expect(result).to include('Batch Queue Detail') + expect(result).to include('Queue Size: 5') + expect(result).to include('normal') + expect(result).to include('low') + end + + it 'handles missing scheduling module' do + hide_const('Legion::LLM::Scheduling') + result = tool.call(action: 'scheduling') + expect(result).to eq('Scheduling module not available.') + end + + it 'handles missing batch module' do + hide_const('Legion::LLM::Batch') + result = tool.call(action: 'batch') + expect(result).to eq('Batch module not available.') + end + end +end diff --git a/spec/legion/cli/chat/tools/search_traces_spec.rb b/spec/legion/cli/chat/tools/search_traces_spec.rb new file mode 100644 index 00000000..7121dff8 --- /dev/null +++ b/spec/legion/cli/chat/tools/search_traces_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/search_traces' + +RSpec.describe Legion::CLI::Chat::Tools::SearchTraces do + let(:tool) { described_class } + + let(:now) { Time.now.utc } + + let(:trace_conversation) do + { + trace_id: 'conv-001', + trace_type: :episodic, + content_payload: '{"peer":"Bob Smith","chat_id":"abc","summary":"Discussed deployment timeline for Q2 release"}', + strength: 0.6, + domain_tags: ['teams', 'conversation', 'peer:Bob Smith'], + created_at: now - 3600, + associated_traces: [] + } + end + + let(:trace_person) do + { + trace_id: 'person-001', + trace_type: :semantic, + content_payload: '{"displayName":"Alice Johnson","jobTitle":"SRE Lead","department":"Platform"}', + strength: 0.7, + domain_tags: ['teams', 'peer', 'peer:Alice Johnson'], + created_at: now - 7200, + associated_traces: [] + } + end + + let(:trace_meeting) do + { + trace_id: 'meeting-001', + trace_type: :episodic, + content_payload: '{"subject":"Sprint Planning","startDateTime":"2026-03-20T10:00:00Z"}', + strength: 0.5, + domain_tags: %w[teams meeting], + created_at: now - 86_400, + associated_traces: [] + } + end + + let(:trace_team) do + { + trace_id: 'team-001', + trace_type: :semantic, + content_payload: '{"team":"Grid Infrastructure","member_count":8,"members":["Bob Smith","Alice Johnson"]}', + strength: 0.8, + domain_tags: ['teams', 'org', 'team:Grid Infrastructure'], + created_at: now - 1800, + associated_traces: [] + } + end + + let(:all_traces) { [trace_conversation, trace_person, trace_meeting, trace_team] } + + let(:mock_store) do + store = instance_double('Store') + allow(store).to receive(:retrieve_by_domain) do |tag, min_strength:, limit:| + all_traces.select { |t| t[:domain_tags].include?(tag) && t[:strength] >= min_strength }.first(limit) + end + allow(store).to receive(:retrieve_by_type) do |type, min_strength:, limit:| + all_traces.select { |t| t[:trace_type] == type && t[:strength] >= min_strength }.first(limit) + end + allow(store).to receive(:all_traces) do |min_strength:| + all_traces.select { |t| t[:strength] >= min_strength } + end + store + end + + before do + stub_const('Legion::Extensions::Agentic::Memory::Trace', Module.new) + allow(Legion::Extensions::Agentic::Memory::Trace).to receive(:shared_store).and_return(mock_store) + end + + describe '#execute' do + it 'returns results matching a keyword query' do + result = tool.call(query: 'deployment timeline') + expect(result).to include('deployment') + expect(result).to include('Bob Smith') + end + + it 'filters by person name' do + result = tool.call(query: 'deployment', person: 'Bob Smith') + expect(result).to include('Bob Smith') + end + + it 'filters by domain tag' do + result = tool.call(query: 'sprint', domain: 'meeting') + expect(result).to include('Sprint Planning') + end + + it 'filters by trace type' do + result = tool.call(query: 'SRE', trace_type: 'semantic') + expect(result).to include('Alice Johnson') + end + + it 'returns no-match message when query has zero keyword hits' do + result = tool.call(query: 'xyznonexistent') + expect(result).to include('No traces matched') + end + + it 'returns unavailable message when trace store is not loaded' do + allow(Legion::Extensions::Agentic::Memory::Trace).to receive(:respond_to?).with(:shared_store).and_return(false) + result = tool.call(query: 'test') + expect(result).to include('not available') + end + + it 'attempts to require the gem when constant is not defined' do + hide_const('Legion::Extensions::Agentic::Memory::Trace') + allow(tool).to receive(:load_trace_gem) + tool.call(query: 'test') + expect(tool).to have_received(:load_trace_gem) + end + + it 'respects limit parameter' do + result = tool.call(query: 'Bob Alice Grid Sprint', limit: 1) + expect(result).to include('Found 1 matching') + end + + it 'clamps limit to valid range' do + result = tool.call(query: 'teams', limit: 100) + expect(result).not_to include('Found 100') + end + + it 'displays trace metadata' do + result = tool.call(query: 'deployment') + expect(result).to include('tags:') + expect(result).to include('strength:') + end + + it 'formats age for recent traces' do + result = tool.call(query: 'Grid Infrastructure') + expect(result).to include('m ago') + end + + it 'formats age for hour-old traces' do + result = tool.call(query: 'deployment') + expect(result).to include('h ago') + end + + it 'formats age for day-old traces' do + result = tool.call(query: 'Sprint Planning') + expect(result).to include('d ago') + end + end + + describe 'payload parsing' do + it 'handles string payloads that are not JSON' do + plain_trace = { + trace_id: 'plain-001', trace_type: :sensory, + content_payload: 'just a plain text note about servers', + strength: 0.5, domain_tags: %w[teams], created_at: now, + associated_traces: [] + } + all_traces.push(plain_trace) + result = tool.call(query: 'servers') + expect(result).to include('servers') + end + + it 'handles hash payloads with symbol keys' do + hash_trace = { + trace_id: 'hash-001', trace_type: :semantic, + content_payload: { displayName: 'Carol', jobTitle: 'Engineer' }, + strength: 0.5, domain_tags: %w[teams peer], created_at: now, + associated_traces: [] + } + all_traces.push(hash_trace) + result = tool.call(query: 'Carol Engineer') + expect(result).to include('Carol') + end + end +end diff --git a/spec/legion/cli/chat/tools/shadow_eval_status_spec.rb b/spec/legion/cli/chat/tools/shadow_eval_status_spec.rb new file mode 100644 index 00000000..4509250b --- /dev/null +++ b/spec/legion/cli/chat/tools/shadow_eval_status_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/shadow_eval_status' + +RSpec.describe Legion::CLI::Chat::Tools::ShadowEvalStatus do + subject(:tool) { described_class } + + let(:shadow_mod) do + Module.new do + def self.summary + { + total_evaluations: 3, + avg_length_ratio: 1.2, + avg_cost_savings: 0.65, + total_primary_cost: 0.001234, + total_shadow_cost: 0.000432, + models_evaluated: %w[gpt-4o-mini claude-haiku-4-5] + } + end + + def self.history + [ + { primary_model: 'gpt-4o', shadow_model: 'gpt-4o-mini', + length_ratio: 1.1, cost_savings: 0.7, evaluated_at: Time.now.utc } + ] + end + end + end + + before do + stub_const('Legion::LLM::ShadowEval', shadow_mod) + end + + describe '#execute' do + it 'returns summary by default' do + result = tool.call + expect(result).to include('Shadow Evaluation Summary') + expect(result).to include('Evaluations: 3') + expect(result).to include('65.0%') + end + + it 'returns history when requested' do + result = tool.call(action: 'history') + expect(result).to include('Shadow Evaluation History') + expect(result).to include('gpt-4o') + expect(result).to include('gpt-4o-mini') + end + + it 'returns unavailable when module not defined' do + hide_const('Legion::LLM::ShadowEval') + result = tool.call + expect(result).to eq('Shadow evaluation not available.') + end + + it 'shows enable hint when no evaluations' do + empty_mod = Module.new do + def self.summary + { + total_evaluations: 0, avg_length_ratio: 0.0, avg_cost_savings: 0.0, + total_primary_cost: 0.0, total_shadow_cost: 0.0, models_evaluated: [] + } + end + end + stub_const('Legion::LLM::ShadowEval', empty_mod) + result = tool.call + expect(result).to include('llm.shadow.enabled') + end + end +end diff --git a/spec/legion/cli/chat/tools/summarize_traces_spec.rb b/spec/legion/cli/chat/tools/summarize_traces_spec.rb new file mode 100644 index 00000000..e34a7b90 --- /dev/null +++ b/spec/legion/cli/chat/tools/summarize_traces_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/summarize_traces' + +RSpec.describe Legion::CLI::Chat::Tools::SummarizeTraces do + subject(:tool) { described_class } + + describe '#execute' do + before do + stub_const('Legion::TraceSearch', Module.new) + allow(tool).to receive(:require).with('legion/trace_search').and_return(true) + end + + it 'returns formatted summary' do + allow(Legion::TraceSearch).to receive(:summarize).and_return({ + total_records: 150, + total_tokens_in: 45_000, + total_tokens_out: 12_000, + total_cost: 3.4567, + avg_latency_ms: 245.3, + max_latency_ms: 1200, + time_range: { from: '2026-03-22', to: '2026-03-23' }, + status_counts: { 'success' => 140, 'failure' => 10 }, + top_extensions: [{ name: 'lex-llm-openai', count: 80 }], + top_workers: [{ id: 'worker-1', count: 60 }] + }) + + result = tool.call(query: 'all tasks today') + expect(result).to include('150 records') + expect(result).to include('45000 in / 12000 out') + expect(result).to include('$3.4567') + expect(result).to include('avg 245.3ms') + expect(result).to include('success: 140') + expect(result).to include('lex-llm-openai (80)') + expect(result).to include('worker-1 (60)') + end + + it 'returns error when filter generation fails' do + allow(Legion::TraceSearch).to receive(:summarize).and_return({ error: 'no filter generated' }) + + result = tool.call(query: 'gibberish') + expect(result).to include('Error: no filter generated') + end + + it 'handles missing time range' do + allow(Legion::TraceSearch).to receive(:summarize).and_return({ + total_records: 0, + total_tokens_in: 0, + total_tokens_out: 0, + total_cost: 0, + avg_latency_ms: 0, + max_latency_ms: 0, + time_range: {}, + status_counts: {}, + top_extensions: [], + top_workers: [] + }) + + result = tool.call(query: 'empty query') + expect(result).to include('0 records') + expect(result).not_to include('Time range') + expect(result).not_to include('Status') + expect(result).not_to include('Top Extensions') + end + + it 'handles LoadError when trace_search unavailable' do + hide_const('Legion::TraceSearch') + allow(tool).to receive(:require).with('legion/trace_search').and_raise(LoadError) + + result = tool.call(query: 'test') + expect(result).to include('Trace search unavailable') + end + + it 'handles unexpected errors' do + allow(Legion::TraceSearch).to receive(:summarize).and_raise(StandardError, 'db timeout') + + result = tool.call(query: 'test') + expect(result).to include('Error summarizing traces: db timeout') + end + end +end diff --git a/spec/legion/cli/chat/tools/system_status_spec.rb b/spec/legion/cli/chat/tools/system_status_spec.rb new file mode 100644 index 00000000..d8f32283 --- /dev/null +++ b/spec/legion/cli/chat/tools/system_status_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/system_status' + +RSpec.describe Legion::CLI::Chat::Tools::SystemStatus do + subject(:tool) { described_class } + + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#execute' do + it 'returns formatted status with health and readiness' do + health_response = instance_double(Net::HTTPOK) + allow(health_response).to receive(:body).and_return( + JSON.generate({ + status: 'ok', + version: '1.4.150', + node: 'dev-laptop', + uptime_seconds: 3661, + pid: 12_345 + }) + ) + + ready_response = instance_double(Net::HTTPOK) + allow(ready_response).to receive(:body).and_return( + JSON.generate({ + components: { + settings: true, + crypt: true, + transport: true, + cache: false, + data: true, + gaia: false, + extensions: true, + api: true + }, + extension_count: 12 + }) + ) + + call_count = 0 + allow(mock_http).to receive(:get) do |_uri| + call_count += 1 + call_count == 1 ? health_response : ready_response + end + + result = tool.call + expect(result).to include('Legion System Status') + expect(result).to include('Status: ok') + expect(result).to include('Version: 1.4.150') + expect(result).to include('Node: dev-laptop') + expect(result).to include('1h 1m') + expect(result).to include('PID: 12345') + expect(result).to include('settings: ready') + expect(result).to include('cache: not ready') + expect(result).to include('6/8 ready') + expect(result).to include('Extensions: 12') + end + + it 'handles daemon not running' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + + result = tool.call + expect(result).to include('daemon not running') + end + + it 'handles both endpoints failing gracefully' do + allow(mock_http).to receive(:get).and_raise(StandardError.new('timeout')) + + result = tool.call + expect(result).to include('Health endpoint: unreachable') + end + + it 'formats uptime with days' do + health_response = instance_double(Net::HTTPOK) + allow(health_response).to receive(:body).and_return( + JSON.generate({ status: 'ok', uptime_seconds: 90_061 }) + ) + ready_response = instance_double(Net::HTTPOK) + allow(ready_response).to receive(:body).and_return(JSON.generate({ components: {} })) + + call_count = 0 + allow(mock_http).to receive(:get) do |_uri| + call_count += 1 + call_count == 1 ? health_response : ready_response + end + + result = tool.call + expect(result).to include('1d 1h 1m') + end + + it 'formats short uptime in seconds' do + health_response = instance_double(Net::HTTPOK) + allow(health_response).to receive(:body).and_return( + JSON.generate({ status: 'ok', uptime_seconds: 45 }) + ) + ready_response = instance_double(Net::HTTPOK) + allow(ready_response).to receive(:body).and_return(JSON.generate({ components: {} })) + + call_count = 0 + allow(mock_http).to receive(:get) do |_uri| + call_count += 1 + call_count == 1 ? health_response : ready_response + end + + result = tool.call + expect(result).to include('45s') + end + + it 'handles empty components' do + health_response = instance_double(Net::HTTPOK) + allow(health_response).to receive(:body).and_return( + JSON.generate({ status: 'ok' }) + ) + ready_response = instance_double(Net::HTTPOK) + allow(ready_response).to receive(:body).and_return(JSON.generate({ components: {} })) + + call_count = 0 + allow(mock_http).to receive(:get) do |_uri| + call_count += 1 + call_count == 1 ? health_response : ready_response + end + + result = tool.call + expect(result).to include('Status: ok') + expect(result).not_to include('Components:') + end + end +end diff --git a/spec/legion/cli/chat/tools/trigger_dream_spec.rb b/spec/legion/cli/chat/tools/trigger_dream_spec.rb new file mode 100644 index 00000000..48041937 --- /dev/null +++ b/spec/legion/cli/chat/tools/trigger_dream_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/trigger_dream' + +RSpec.describe Legion::CLI::Chat::Tools::TriggerDream do + subject(:tool) { described_class } + + before { allow(tool).to receive(:api_port).and_return(4567) } + + describe '#execute' do + context 'trigger action' do + it 'triggers dream cycle on daemon' do + allow(tool).to receive(:api_post).and_return({ data: { task_id: 42 } }) + + result = tool.call + expect(result).to include('Dream cycle triggered') + expect(result).to include('Task ID: 42') + end + + it 'handles API error' do + allow(tool).to receive(:api_post).and_return({ error: { message: 'runner not found' } }) + + result = tool.call + expect(result).to include('Dream trigger failed') + expect(result).to include('runner not found') + end + + it 'handles connection refused' do + allow(tool).to receive(:api_post).and_raise(Errno::ECONNREFUSED) + + result = tool.call + expect(result).to include('Legion daemon not running') + end + end + + context 'journal action' do + it 'reads the latest dream journal entry' do + journal_content = "# Dream Cycle\n\n## Phase 1: Memory Audit\n- Traces decayed: 5" + allow(tool).to receive(:find_latest_journal).and_return('/tmp/dream-test.md') + allow(File).to receive(:read).with('/tmp/dream-test.md', encoding: 'utf-8').and_return(journal_content) + + result = tool.call(action: 'journal') + expect(result).to include('Dream Cycle') + expect(result).to include('Memory Audit') + end + + it 'reports when no journal entries found' do + allow(tool).to receive(:find_latest_journal).and_return(nil) + + result = tool.call(action: 'journal') + expect(result).to include('No dream journal entries found') + end + + it 'truncates long journal entries' do + long_content = 'x' * 3000 + allow(tool).to receive(:find_latest_journal).and_return('/tmp/dream-long.md') + allow(File).to receive(:read).with('/tmp/dream-long.md', encoding: 'utf-8').and_return(long_content) + + result = tool.call(action: 'journal') + expect(result.length).to be <= 2000 + expect(result).to end_with('...') + end + end + end +end diff --git a/spec/legion/cli/chat/tools/view_events_spec.rb b/spec/legion/cli/chat/tools/view_events_spec.rb new file mode 100644 index 00000000..eb8f263a --- /dev/null +++ b/spec/legion/cli/chat/tools/view_events_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/view_events' + +RSpec.describe Legion::CLI::Chat::Tools::ViewEvents do + subject(:tool) { described_class } + + let(:mock_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#execute' do + it 'returns formatted event list' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ + data: [ + { event: 'runner.completed', timestamp: '2026-03-23T10:00:00Z', + extension: 'lex-node', status: 'success' }, + { event: 'worker.lifecycle', timestamp: '2026-03-23T10:01:00Z', + worker_id: 'w-1', status: 'active' } + ] + }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('Recent Events (2)') + expect(result).to include('runner.completed') + expect(result).to include('extension: lex-node') + expect(result).to include('worker.lifecycle') + expect(result).to include('worker_id: w-1') + end + + it 'returns no events message when empty' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: [] })) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('No recent events') + end + + it 'passes count parameter' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: [] })) + expect(mock_http).to receive(:get) do |uri| + expect(uri).to include('count=5') + response + end + + tool.call(count: 5) + end + + it 'clamps count to valid range' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: [] })) + expect(mock_http).to receive(:get) do |uri| + expect(uri).to include('count=100') + response + end + + tool.call(count: 999) + end + + it 'handles connection refused' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + result = tool.call + expect(result).to include('daemon not running') + end + + it 'handles API error response' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ error: 'events unavailable' })) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('API error: events unavailable') + end + + it 'handles events without details' do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return( + JSON.generate({ + data: [{ event: 'service.ready', timestamp: '2026-03-23T10:00:00Z' }] + }) + ) + allow(mock_http).to receive(:get).and_return(response) + + result = tool.call + expect(result).to include('service.ready') + expect(result).not_to include('—') + end + end +end diff --git a/spec/legion/cli/chat/tools/view_trends_spec.rb b/spec/legion/cli/chat/tools/view_trends_spec.rb new file mode 100644 index 00000000..e13c616f --- /dev/null +++ b/spec/legion/cli/chat/tools/view_trends_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/view_trends' + +RSpec.describe Legion::CLI::Chat::Tools::ViewTrends do + subject(:tool) { described_class } + + before { allow(tool).to receive(:api_port).and_return(4567) } + + describe '#execute' do + it 'formats trend data as a table' do + stub_trend( + buckets: [ + { time: '2026-03-23T00:00:00Z', count: 100, avg_cost: 0.05, avg_latency: 150.0, failure_rate: 0.02 }, + { time: '2026-03-23T02:00:00Z', count: 120, avg_cost: 0.06, avg_latency: 160.0, failure_rate: 0.01 } + ], + hours: 4, bucket_minutes: 120 + ) + + result = tool.call(hours: 4, buckets: 2) + expect(result).to include('Trend (last 4h') + expect(result).to include('Count') + expect(result).to include('Avg Cost') + expect(result).to include('Direction:') + end + + it 'shows rising trend when second half increases' do + stub_trend( + buckets: [ + { time: '2026-03-23T00:00:00Z', count: 10, avg_cost: 0.01, avg_latency: 100.0, failure_rate: 0.0 }, + { time: '2026-03-23T12:00:00Z', count: 50, avg_cost: 0.10, avg_latency: 200.0, failure_rate: 0.1 } + ], + hours: 24, bucket_minutes: 720 + ) + + result = tool.call + expect(result).to include('rising') + end + + it 'shows stable trend when metrics are consistent' do + bucket = { time: '2026-03-23T00:00:00Z', count: 50, avg_cost: 0.05, avg_latency: 100.0, failure_rate: 0.01 } + stub_trend( + buckets: [bucket, bucket.merge(time: '2026-03-23T12:00:00Z')], + hours: 24, bucket_minutes: 720 + ) + + result = tool.call + expect(result).to include('stable') + end + + it 'handles empty trend data' do + stub_trend(buckets: [], hours: 24, bucket_minutes: 120) + + result = tool.call + expect(result).to include('No trend data available') + end + + it 'handles connection refused' do + allow(tool).to receive(:api_get).and_raise(Errno::ECONNREFUSED) + + result = tool.call + expect(result).to include('Legion daemon not running') + end + + it 'handles API error response' do + allow(tool).to receive(:api_get).and_return({ error: { message: 'LLM unavailable' } }) + + result = tool.call + expect(result).to include('LLM unavailable') + end + end + + def stub_trend(data) + allow(tool).to receive(:api_get).and_return({ data: data }) + end +end diff --git a/spec/legion/cli/chat/tools/worker_status_spec.rb b/spec/legion/cli/chat/tools/worker_status_spec.rb new file mode 100644 index 00000000..2a206662 --- /dev/null +++ b/spec/legion/cli/chat/tools/worker_status_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/tools/worker_status' + +RSpec.describe Legion::CLI::Chat::Tools::WorkerStatus do + subject(:tool) { described_class } + + let(:stub_http) { instance_double(Net::HTTP) } + + before do + allow(Net::HTTP).to receive(:new).and_return(stub_http) + allow(stub_http).to receive(:open_timeout=) + allow(stub_http).to receive(:read_timeout=) + end + + describe '#execute' do + context 'with list action' do + let(:body) do + '{"data":[{"worker_id":"w-1","name":"Sync Bot","lifecycle_state":"active","risk_tier":"low"}]}' + end + + before do + response = instance_double(Net::HTTPResponse, body: body) + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns formatted worker list' do + result = tool.call + expect(result).to include('Digital Workers (1)') + expect(result).to include('w-1') + expect(result).to include('Sync Bot') + expect(result).to include('active') + end + end + + context 'with empty worker list' do + before do + response = instance_double(Net::HTTPResponse, body: '{"data":[]}') + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns no workers message' do + result = tool.call + expect(result).to eq('No digital workers found.') + end + end + + context 'with status filter' do + let(:body) { '{"data":[{"worker_id":"w-1","name":"Bot","lifecycle_state":"paused","risk_tier":"low"}]}' } + + before do + response = instance_double(Net::HTTPResponse, body: body) + allow(stub_http).to receive(:get).and_return(response) + end + + it 'passes the filter to the API' do + tool.call(status_filter: 'paused') + expect(stub_http).to have_received(:get).with('/api/workers?lifecycle_state=paused') + end + end + + context 'with show action' do + let(:body) do + '{"data":{"worker_id":"w-1","name":"Sync Bot","lifecycle_state":"active","risk_tier":"low","team":"ops"}}' + end + + before do + response = instance_double(Net::HTTPResponse, body: body) + allow(stub_http).to receive(:get).and_return(response) + end + + it 'returns worker details' do + result = tool.call(action: 'show', worker_id: 'w-1') + expect(result).to include('Worker: w-1') + expect(result).to include('name: Sync Bot') + expect(result).to include('team: ops') + end + + it 'requires worker_id' do + result = tool.call(action: 'show') + expect(result).to include('worker_id is required') + end + end + + context 'with health action' do + let(:all_body) do + '{"data":[' \ + '{"worker_id":"w-1","lifecycle_state":"active","health_status":"healthy"},' \ + '{"worker_id":"w-2","lifecycle_state":"active","health_status":"unhealthy","name":"Bad Bot"},' \ + '{"worker_id":"w-3","lifecycle_state":"paused","health_status":"healthy"}]}' + end + let(:unhealthy_body) do + '{"data":[{"worker_id":"w-2","name":"Bad Bot","health_status":"unhealthy"}]}' + end + + before do + unhealthy_resp = instance_double(Net::HTTPResponse, body: unhealthy_body) + all_resp = instance_double(Net::HTTPResponse, body: all_body) + allow(stub_http).to receive(:get).and_return(unhealthy_resp, all_resp) + end + + it 'returns health summary' do + result = tool.call(action: 'health') + expect(result).to include('Worker Health Summary') + expect(result).to include('Total: 3') + expect(result).to include('Active: 2') + expect(result).to include('Paused: 1') + expect(result).to include('Unhealthy: 1') + expect(result).to include('w-2') + end + end + + context 'when daemon is not running' do + before do + allow(stub_http).to receive(:get).and_raise(Errno::ECONNREFUSED) + end + + it 'returns daemon not running message' do + result = tool.call + expect(result).to include('daemon not running') + end + end + end +end diff --git a/spec/legion/cli/chat/url_detection_spec.rb b/spec/legion/cli/chat/url_detection_spec.rb new file mode 100644 index 00000000..f8210868 --- /dev/null +++ b/spec/legion/cli/chat/url_detection_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/absorbers/dispatch' + +RSpec.describe 'Chat URL detection' do + describe 'URL extraction from message' do + it 'extracts URLs from a chat message' do + urls = Legion::Extensions::Absorbers::Dispatch.extract_urls( + 'check out https://teams.microsoft.com/meeting/abc123 for the notes' + ) + expect(urls).to include('https://teams.microsoft.com/meeting/abc123') + end + + it 'extracts multiple URLs' do + urls = Legion::Extensions::Absorbers::Dispatch.extract_urls( + 'see https://github.com/org/repo/pull/42 and https://example.com/doc.pdf' + ) + expect(urls.size).to eq(2) + end + + it 'returns empty array for no URLs' do + urls = Legion::Extensions::Absorbers::Dispatch.extract_urls( + 'just a regular message about meetings' + ) + expect(urls).to eq([]) + end + end +end diff --git a/spec/legion/cli/chat/web_fetch_spec.rb b/spec/legion/cli/chat/web_fetch_spec.rb new file mode 100644 index 00000000..43f62230 --- /dev/null +++ b/spec/legion/cli/chat/web_fetch_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/web_fetch' + +RSpec.describe Legion::CLI::Chat::WebFetch do + describe '.parse_uri' do + it 'adds https when no scheme is given' do + uri = described_class.parse_uri('example.com/page') + expect(uri.to_s).to eq('https://example.com/page') + end + + it 'preserves http scheme' do + uri = described_class.parse_uri('http://example.com') + expect(uri.scheme).to eq('http') + end + + it 'preserves https scheme' do + uri = described_class.parse_uri('https://example.com') + expect(uri.scheme).to eq('https') + end + + it 'raises FetchError for invalid URIs' do + expect { described_class.parse_uri('not a url at all ://') } + .to raise_error(described_class::FetchError, /Invalid URL/) + end + end + + describe '.html?' do + it 'returns true for text/html content type' do + expect(described_class.html?('text/html; charset=utf-8')).to be true + end + + it 'returns false for plain text' do + expect(described_class.html?('text/plain')).to be false + end + + it 'returns false for nil' do + expect(described_class.html?(nil)).to be false + end + end + + describe '.html_to_markdown' do + it 'converts headings' do + html = '

Title

Subtitle

' + md = described_class.html_to_markdown(html) + expect(md).to include('# Title') + expect(md).to include('## Subtitle') + end + + it 'converts links' do + html = 'Click here' + md = described_class.html_to_markdown(html) + expect(md).to include('[Click here](https://example.com)') + end + + it 'converts list items' do + html = '
  • First
  • Second
' + md = described_class.html_to_markdown(html) + expect(md).to include('- First') + expect(md).to include('- Second') + end + + it 'converts bold and italic' do + html = 'bold and italic' + md = described_class.html_to_markdown(html) + expect(md).to include('**bold**') + expect(md).to include('*italic*') + end + + it 'converts code and pre blocks' do + html = 'Use puts or:
def foo\n  bar\nend
' + md = described_class.html_to_markdown(html) + expect(md).to include('`puts`') + expect(md).to include("```\ndef foo") + end + + it 'strips script and style tags' do + html = '

Hello

World

' + md = described_class.html_to_markdown(html) + expect(md).not_to include('alert') + expect(md).not_to include('.x{}') + expect(md).to include('Hello') + expect(md).to include('World') + end + + it 'strips nav and footer' do + html = '

Content

Copyright
' + md = described_class.html_to_markdown(html) + expect(md).not_to include('Menu') + expect(md).not_to include('Copyright') + expect(md).to include('Content') + end + + it 'decodes HTML entities' do + html = '5 > 3 & 2 < 4 "hi"' + md = described_class.html_to_markdown(html) + expect(md).to include('5 > 3 & 2 < 4 "hi"') + end + + it 'converts paragraphs and line breaks' do + html = '

First paragraph

Second
with break

' + md = described_class.html_to_markdown(html) + expect(md).to include('First paragraph') + expect(md).to include("Second\nwith break") + end + + it 'converts horizontal rules' do + html = '

Above


Below

' + md = described_class.html_to_markdown(html) + expect(md).to include('---') + end + end + + describe '.truncate' do + it 'returns short text unchanged' do + expect(described_class.truncate('hello', 100)).to eq('hello') + end + + it 'truncates long text with marker' do + result = described_class.truncate('a' * 200, 50) + expect(result.length).to be > 50 + expect(result).to include('[... truncated at 50 characters]') + expect(result).to start_with('a' * 50) + end + end + + describe '.fetch' do + it 'fetches and converts HTML content' do + html_body = '

Test Page

Hello world

' + stub_successful_fetch('https://example.com/page', html_body, 'text/html') + + result = described_class.fetch('https://example.com/page') + expect(result).to include('# Test Page') + expect(result).to include('Hello world') + end + + it 'returns plain text without conversion' do + stub_successful_fetch('https://example.com/api', '{"key":"value"}', 'application/json') + + result = described_class.fetch('https://example.com/api') + expect(result).to include('{"key":"value"}') + end + + it 'follows redirects' do + redirect_response = Net::HTTPFound.allocate + allow(redirect_response).to receive(:[]).with('content-type').and_return(nil) + allow(redirect_response).to receive(:[]).with('location').and_return('https://example.com/final') + allow(redirect_response).to receive(:code).and_return('302') + + final_response = Net::HTTPOK.allocate + allow(final_response).to receive(:[]).with('content-type').and_return('text/plain') + allow(final_response).to receive(:body).and_return('Final content') + allow(final_response).to receive(:code).and_return('200') + + http = instance_double(Net::HTTP) + allow(Net::HTTP).to receive(:new).and_return(http) + allow(http).to receive(:use_ssl=) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).and_return(redirect_response, final_response) + + result = described_class.fetch('https://example.com/start') + expect(result).to eq('Final content') + end + + it 'raises FetchError on HTTP errors' do + error_response = Net::HTTPNotFound.allocate + allow(error_response).to receive(:code).and_return('404') + allow(error_response).to receive(:message).and_return('Not Found') + + http = instance_double(Net::HTTP) + allow(Net::HTTP).to receive(:new).and_return(http) + allow(http).to receive(:use_ssl=) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).and_return(error_response) + + expect { described_class.fetch('https://example.com/missing') } + .to raise_error(described_class::FetchError, /404/) + end + + it 'raises FetchError on timeout' do + http = instance_double(Net::HTTP) + allow(Net::HTTP).to receive(:new).and_return(http) + allow(http).to receive(:use_ssl=) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).and_raise(Net::ReadTimeout) + + expect { described_class.fetch('https://example.com/slow') } + .to raise_error(described_class::FetchError, /timed out/) + end + + it 'raises FetchError on connection failure' do + http = instance_double(Net::HTTP) + allow(Net::HTTP).to receive(:new).and_return(http) + allow(http).to receive(:use_ssl=) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).and_raise(SocketError, 'getaddrinfo: Name or service not known') + + expect { described_class.fetch('https://nonexistent.invalid') } + .to raise_error(described_class::FetchError, /Connection failed/) + end + end + + def stub_successful_fetch(url, body, content_type) + uri = URI.parse(url) + response = Net::HTTPOK.allocate + allow(response).to receive(:[]).with('content-type').and_return(content_type) + allow(response).to receive(:body).and_return(body) + allow(response).to receive(:code).and_return('200') + + http = instance_double(Net::HTTP) + allow(Net::HTTP).to receive(:new).with(uri.host, uri.port).and_return(http) + allow(http).to receive(:use_ssl=) + allow(http).to receive(:open_timeout=) + allow(http).to receive(:read_timeout=) + allow(http).to receive(:request).and_return(response) + end +end diff --git a/spec/legion/cli/chat/web_search_spec.rb b/spec/legion/cli/chat/web_search_spec.rb new file mode 100644 index 00000000..68d1ec78 --- /dev/null +++ b/spec/legion/cli/chat/web_search_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/chat/web_search' + +RSpec.describe Legion::CLI::Chat::WebSearch do + describe '.parse_duckduckgo_results' do + let(:html) do + <<~HTML + + HTML + end + + it 'parses results with titles and URLs' do + results = described_class.parse_duckduckgo_results(html, 5) + expect(results.length).to eq(3) + expect(results.first[:title]).to eq('Ruby Programming') + expect(results.first[:url]).to eq('https://example.com/ruby') + end + + it 'includes snippets' do + results = described_class.parse_duckduckgo_results(html, 5) + expect(results.first[:snippet]).to eq('Ruby is a dynamic language') + end + + it 'respects max_results' do + results = described_class.parse_duckduckgo_results(html, 2) + expect(results.length).to eq(2) + end + + it 'returns empty array for no results' do + results = described_class.parse_duckduckgo_results('', 5) + expect(results).to eq([]) + end + end + + describe '.extract_real_url' do + it 'extracts URL from DuckDuckGo redirect' do + ddg = 'https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpage' + expect(described_class.extract_real_url(ddg)).to eq('https://example.com/page') + end + + it 'returns direct URL unchanged' do + expect(described_class.extract_real_url('https://example.com')).to eq('https://example.com') + end + end + + describe '.strip_tags' do + it 'removes HTML tags' do + expect(described_class.strip_tags('bold text')).to eq('bold text') + end + + it 'decodes HTML entities' do + expect(described_class.strip_tags('A & B')).to eq('A & B') + end + end + + describe '.search' do + it 'raises SearchError on connection failure' do + allow(Net::HTTP).to receive(:new).and_raise(SocketError, 'getaddrinfo failed') + expect { described_class.search('test') }.to raise_error(described_class::SearchError, /Connection failed/) + end + end +end diff --git a/spec/legion/cli/chat_away_summary_spec.rb b/spec/legion/cli/chat_away_summary_spec.rb new file mode 100644 index 00000000..0dd950a4 --- /dev/null +++ b/spec/legion/cli/chat_away_summary_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe 'Chat away summary' do + let(:chat_instance) { Legion::CLI::Chat.new } + + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + end + + describe '#away?' do + it 'returns false when last_active_at is nil' do + expect(chat_instance.send(:away?)).to be false + end + + it 'returns false when idle less than threshold' do + chat_instance.instance_variable_set(:@last_active_at, Time.now - 10) + allow(chat_instance).to receive(:chat_setting).and_return(120) + expect(chat_instance.send(:away?)).to be false + end + + it 'returns true when idle exceeds threshold' do + chat_instance.instance_variable_set(:@last_active_at, Time.now - 300) + allow(chat_instance).to receive(:chat_setting).and_return(120) + expect(chat_instance.send(:away?)).to be true + end + + it 'uses default threshold of 120 seconds when not configured' do + chat_instance.instance_variable_set(:@last_active_at, Time.now - 130) + allow(chat_instance).to receive(:chat_setting).and_return(nil) + expect(chat_instance.send(:away?)).to be true + end + end + + describe '#show_away_summary' do + let(:out) { instance_double(Legion::CLI::Output::Formatter, colorize: '[away]', dim: '') } + + it 'does nothing when Legion::LLM is not defined' do + hide_const('Legion::LLM') if defined?(Legion::LLM) + chat_instance.instance_variable_set(:@last_active_at, Time.now - 300) + expect { chat_instance.send(:show_away_summary, out) }.not_to raise_error + end + + it 'does nothing when session has fewer than 2 messages' do + stub_const('Legion::LLM', Module.new do + def self.respond_to?(name, *) + name == :chat ? true : super + end + + def self.chat(**) = nil + end) + + mock_messages = [double(role: 'user', content: 'hello')] + mock_chat = double(messages: mock_messages) + mock_session = double(chat: mock_chat) + chat_instance.instance_variable_set(:@session, mock_session) + chat_instance.instance_variable_set(:@last_active_at, Time.now - 300) + + expect { chat_instance.send(:show_away_summary, out) }.not_to raise_error + end + + it 'does not raise on LLM errors' do + stub_const('Legion::LLM', Module.new do + def self.respond_to?(name, *) + name == :chat ? true : super + end + + def self.chat(**) + raise StandardError, 'provider unavailable' + end + end) + + mock_messages = [ + double(role: 'user', content: 'hello'), + double(role: 'assistant', content: 'hi there') + ] + mock_chat = double(messages: mock_messages) + mock_session = double(chat: mock_chat) + chat_instance.instance_variable_set(:@session, mock_session) + chat_instance.instance_variable_set(:@last_active_at, Time.now - 300) + + expect { chat_instance.send(:show_away_summary, out) }.not_to raise_error + end + end +end diff --git a/spec/legion/cli/chat_command_spec.rb b/spec/legion/cli/chat_command_spec.rb new file mode 100644 index 00000000..414467a8 --- /dev/null +++ b/spec/legion/cli/chat_command_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe Legion::CLI::Chat do + it 'is defined as a Thor subcommand' do + expect(Legion::CLI::Chat).to be < Thor + end + + it 'has an interactive command' do + expect(Legion::CLI::Chat.instance_methods).to include(:interactive) + end + + it 'has a prompt command for headless mode' do + expect(Legion::CLI::Chat.instance_methods).to include(:prompt) + end +end diff --git a/spec/legion/cli/check_command_spec.rb b/spec/legion/cli/check_command_spec.rb new file mode 100644 index 00000000..97addfe8 --- /dev/null +++ b/spec/legion/cli/check_command_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/check_command' +require 'legion/cli/output' +require 'json' + +RSpec.describe Legion::CLI::Check do + let(:base_options) { { json: true, no_color: true, verbose: false, extensions: false, full: false } } + + def run_check(options = base_options) + formatter = Legion::CLI::Output::Formatter.new(json: options[:json], color: false) + output = StringIO.new + exit_code = nil + begin + $stdout = output + exit_code = described_class.run(formatter, options) + ensure + $stdout = STDOUT + end + [exit_code, output.string] + end + + before do + allow(Legion::Logging).to receive(:setup) + end + + describe '.run' do + context 'when all checks pass' do + before do + described_class::CHECKS.each do |name| + allow(described_class).to receive(:"check_#{name}") + allow(described_class).to receive(:"shutdown_#{name}") + end + end + + it 'returns 0' do + exit_code, = run_check + expect(exit_code).to eq(0) + end + + it 'reports all checks as pass in JSON' do + _, output = run_check + parsed = JSON.parse(output) + expect(parsed['results'].keys).to eq(%w[settings crypt transport cache cache_local data data_local]) + parsed['results'].each_value do |result| + expect(result['status']).to eq('pass') + end + end + + it 'reports summary with 0 failures' do + _, output = run_check + parsed = JSON.parse(output) + expect(parsed['summary']['passed']).to eq(7) + expect(parsed['summary']['failed']).to eq(0) + expect(parsed['summary']['level']).to eq('connections') + end + end + + context 'when a check fails' do + before do + allow(described_class).to receive(:check_settings) + allow(described_class).to receive(:check_crypt) + allow(described_class).to receive(:check_transport) + allow(described_class).to receive(:check_cache) + allow(described_class).to receive(:check_cache_local) + allow(described_class).to receive(:check_data).and_raise(StandardError, 'no db') + allow(described_class).to receive(:shutdown_settings) + allow(described_class).to receive(:shutdown_crypt) + allow(described_class).to receive(:shutdown_transport) + allow(described_class).to receive(:shutdown_cache) + allow(described_class).to receive(:shutdown_cache_local) + end + + it 'returns 1' do + exit_code, = run_check + expect(exit_code).to eq(1) + end + + it 'marks failed check with error message' do + _, output = run_check + parsed = JSON.parse(output) + expect(parsed['results']['data']['status']).to eq('fail') + expect(parsed['results']['data']['error']).to include('no db') + end + end + + context 'when a check raises LoadError' do + before do + allow(described_class).to receive(:check_settings) + allow(described_class).to receive(:check_crypt) + allow(described_class).to receive(:check_transport) + allow(described_class).to receive(:check_cache).and_raise(LoadError, 'cannot load such file -- legion/cache') + allow(described_class).to receive(:check_data) + allow(described_class).to receive(:check_data_local) + allow(described_class).to receive(:shutdown_settings) + allow(described_class).to receive(:shutdown_crypt) + allow(described_class).to receive(:shutdown_transport) + allow(described_class).to receive(:shutdown_data) + allow(described_class).to receive(:shutdown_data_local) + end + + it 'returns 1' do + exit_code, = run_check + expect(exit_code).to eq(1) + end + + it 'records the check as fail instead of crashing' do + _, output = run_check + parsed = JSON.parse(output) + expect(parsed['results']['cache']['status']).to eq('fail') + expect(parsed['results']['cache']['error']).to include('legion/cache') + end + end + + context 'dependency skipping' do + before do + allow(described_class).to receive(:check_settings).and_raise(StandardError, 'bad config') + allow(described_class).to receive(:shutdown_settings) + end + + it 'skips checks that depend on failed check' do + _, output = run_check + parsed = JSON.parse(output) + %w[crypt transport cache data].each do |name| + expect(parsed['results'][name]['status']).to eq('skip') + expect(parsed['results'][name]['error']).to eq('settings failed') + end + expect(parsed['results']['cache_local']['status']).to eq('skip') + expect(parsed['results']['data_local']['status']).to eq('skip') + end + end + + context 'with --extensions flag' do + before do + (described_class::CHECKS + described_class::EXTENSION_CHECKS).each do |name| + allow(described_class).to receive(:"check_#{name}") + allow(described_class).to receive(:"shutdown_#{name}") + end + end + + it 'includes extensions in results' do + _, output = run_check(base_options.merge(extensions: true)) + parsed = JSON.parse(output) + expect(parsed['results']).to have_key('extensions') + expect(parsed['summary']['level']).to eq('extensions') + end + end + + context 'with --full flag' do + before do + all_checks = described_class::CHECKS + described_class::EXTENSION_CHECKS + described_class::FULL_CHECKS + all_checks.each do |name| + allow(described_class).to receive(:"check_#{name}") + allow(described_class).to receive(:"shutdown_#{name}") + end + end + + it 'includes extensions and api in results' do + _, output = run_check(base_options.merge(full: true)) + parsed = JSON.parse(output) + expect(parsed['results']).to have_key('extensions') + expect(parsed['results']).to have_key('api') + expect(parsed['summary']['level']).to eq('full') + end + end + + context 'text output with verbose' do + before do + described_class::CHECKS.each do |name| + allow(described_class).to receive(:"check_#{name}") + allow(described_class).to receive(:"shutdown_#{name}") + end + end + + it 'includes timing information' do + _, output = run_check(base_options.merge(json: false, verbose: true)) + expect(output).to match(/\(\d+\.\d+s\)/) + end + end + end +end diff --git a/spec/legion/cli/check_privacy_spec.rb b/spec/legion/cli/check_privacy_spec.rb new file mode 100644 index 00000000..70e80f05 --- /dev/null +++ b/spec/legion/cli/check_privacy_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/check/privacy_check' + +RSpec.describe Legion::CLI::Check::PrivacyCheck do + describe '#run' do + let(:checker) { described_class.new } + + context 'when privacy mode is fully configured' do + before do + allow(Legion::Settings).to receive(:enterprise_privacy?).and_return(true) + allow(Legion::Settings).to receive(:[]).with(:llm).and_return( + { providers: { bedrock: { api_key: nil }, anthropic: { api_key: nil } } } + ) + end + + it 'reports flag_set as pass' do + result = checker.run + expect(result[:flag_set]).to eq(:pass) + end + + it 'reports no_cloud_keys as pass when all cloud API keys are nil' do + result = checker.run + expect(result[:no_cloud_keys]).to eq(:pass) + end + end + + context 'when privacy mode is not set' do + before { allow(Legion::Settings).to receive(:enterprise_privacy?).and_return(false) } + + it 'reports flag_set as fail' do + result = checker.run + expect(result[:flag_set]).to eq(:fail) + end + end + + context 'when a cloud API key is present' do + before do + allow(Legion::Settings).to receive(:enterprise_privacy?).and_return(true) + allow(Legion::Settings).to receive(:[]).with(:llm).and_return( + { providers: { anthropic: { api_key: 'sk-real-key' } } } + ) + end + + it 'reports no_cloud_keys as fail' do + result = checker.run + expect(result[:no_cloud_keys]).to eq(:fail) + end + end + + context 'when a cloud key uses vault:// reference' do + before do + allow(Legion::Settings).to receive(:enterprise_privacy?).and_return(true) + allow(Legion::Settings).to receive(:[]).with(:llm).and_return( + { providers: { anthropic: { api_key: 'vault://secret/data/llm#key' } } } + ) + end + + it 'reports no_cloud_keys as pass (vault refs are not raw keys)' do + result = checker.run + expect(result[:no_cloud_keys]).to eq(:pass) + end + end + end + + describe '#overall_pass?' do + let(:checker) { described_class.new } + + it 'returns true when all probes pass' do + allow(checker).to receive(:run).and_return({ flag_set: :pass, no_cloud_keys: :pass, no_external_endpoints: :pass }) + expect(checker.overall_pass?).to be true + end + + it 'returns false when any probe fails' do + allow(checker).to receive(:run).and_return({ flag_set: :fail, no_cloud_keys: :pass, no_external_endpoints: :pass }) + expect(checker.overall_pass?).to be false + end + end +end diff --git a/spec/legion/cli/codegen_command_spec.rb b/spec/legion/cli/codegen_command_spec.rb new file mode 100644 index 00000000..31b06256 --- /dev/null +++ b/spec/legion/cli/codegen_command_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/codegen_command' + +RSpec.describe Legion::CLI::CodegenCommand do + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end + + describe '#status' do + it 'calls api_get and outputs JSON' do + allow_any_instance_of(described_class).to receive(:api_get) + .with('/api/codegen/status') + .and_return({ enabled: true, last_cycle_at: '2026-03-26T00:00:00Z', gaps_detected: 3 }) + output = capture_stdout { described_class.start(%w[status --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:enabled]).to eq(true) + end + + it 'includes gaps_detected count' do + allow_any_instance_of(described_class).to receive(:api_get) + .with('/api/codegen/status') + .and_return({ enabled: true, gaps_detected: 3 }) + output = capture_stdout { described_class.start(%w[status --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:gaps_detected]).to eq(3) + end + end + + describe '#list' do + it 'calls api_get and outputs all records' do + allow_any_instance_of(described_class).to receive(:api_get) + .with('/api/codegen/generated') + .and_return([ + { id: 'gen_001', name: 'fetch_weather', status: 'approved' }, + { id: 'gen_002', name: 'parse_csv', status: 'pending' } + ]) + output = capture_stdout { described_class.start(%w[list --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed.size).to eq(2) + end + + it 'passes status filter as query param' do + expect_any_instance_of(described_class).to receive(:api_get) + .with('/api/codegen/generated?status=approved') + .and_return([{ id: 'gen_001', name: 'fetch_weather', status: 'approved' }]) + capture_stdout { described_class.start(%w[list --status approved --json]) } + end + end + + describe '#show' do + it 'calls api_get with the record id' do + allow_any_instance_of(described_class).to receive(:api_get) + .with('/api/codegen/generated/gen_001') + .and_return({ id: 'gen_001', name: 'fetch_weather', status: 'approved' }) + output = capture_stdout { described_class.start(%w[show gen_001 --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:id]).to eq('gen_001') + expect(parsed[:name]).to eq('fetch_weather') + end + end + + describe '#approve' do + it 'calls api_post to approve endpoint' do + allow_any_instance_of(described_class).to receive(:api_post) + .with('/api/codegen/generated/gen_001/approve') + .and_return({ generation_id: 'gen_001', status: 'approved' }) + output = capture_stdout { described_class.start(%w[approve gen_001 --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:status]).to eq('approved') + end + end + + describe '#reject' do + it 'calls api_post to reject endpoint' do + allow_any_instance_of(described_class).to receive(:api_post) + .with('/api/codegen/generated/gen_001/reject') + .and_return({ id: 'gen_001', status: 'rejected' }) + output = capture_stdout { described_class.start(%w[reject gen_001 --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:status]).to eq('rejected') + end + end + + describe '#retry' do + it 'calls api_post to retry endpoint' do + allow_any_instance_of(described_class).to receive(:api_post) + .with('/api/codegen/generated/gen_001/retry') + .and_return({ id: 'gen_001', status: 'pending' }) + output = capture_stdout { described_class.start(%w[retry gen_001 --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:status]).to eq('pending') + end + end + + describe '#gaps' do + it 'calls api_get and outputs detected gaps' do + allow_any_instance_of(described_class).to receive(:api_get) + .with('/api/codegen/gaps') + .and_return([ + { gap_id: 'gap_1', gap_type: 'unmatched_intent', priority: 0.8 }, + { gap_id: 'gap_2', gap_type: 'frequent_failure', priority: 0.6 } + ]) + output = capture_stdout { described_class.start(%w[gaps --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed.size).to eq(2) + end + + it 'includes gap details' do + allow_any_instance_of(described_class).to receive(:api_get) + .with('/api/codegen/gaps') + .and_return([{ gap_id: 'gap_1', gap_type: 'unmatched_intent', priority: 0.8 }]) + output = capture_stdout { described_class.start(%w[gaps --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed.first[:gap_id]).to eq('gap_1') + end + end + + describe '#cycle' do + it 'calls api_post to cycle endpoint' do + allow_any_instance_of(described_class).to receive(:api_post) + .with('/api/codegen/cycle') + .and_return({ triggered: true, gaps_processed: 2 }) + output = capture_stdout { described_class.start(%w[cycle --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:triggered]).to eq(true) + expect(parsed[:gaps_processed]).to eq(2) + end + end +end diff --git a/spec/legion/cli/coldstart_command_spec.rb b/spec/legion/cli/coldstart_command_spec.rb new file mode 100644 index 00000000..44fae909 --- /dev/null +++ b/spec/legion/cli/coldstart_command_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'tmpdir' +require 'legion/cli/output' + +# Define stub extension modules before loading coldstart command +module Legion + module Extensions + module Memory; end + + module Coldstart + module Runners + module Ingest + class << self + attr_accessor :test_file_result, :test_dir_result + end + + def ingest_file(**) + Legion::Extensions::Coldstart::Runners::Ingest.test_file_result + end + + def preview_ingest(**) + Legion::Extensions::Coldstart::Runners::Ingest.test_file_result + end + + def ingest_directory(**) + Legion::Extensions::Coldstart::Runners::Ingest.test_dir_result + end + end + + module Coldstart + class << self + attr_accessor :test_progress + end + + def coldstart_progress + Legion::Extensions::Coldstart::Runners::Coldstart.test_progress + end + end + end + end + end +end + +require 'legion/cli/coldstart_command' + +# Patch require_coldstart! to be a no-op (extensions already stubbed above) +Legion::CLI::Coldstart.class_eval do + no_commands do + define_method(:require_coldstart!) { nil } + end +end + +RSpec.describe Legion::CLI::Coldstart do + let(:file_result) do + { + file: '/tmp/test/CLAUDE.md', + file_type: 'claude_md', + traces_parsed: 5, + traces_stored: 5, + traces: [ + { trace_type: :semantic }, { trace_type: :semantic }, + { trace_type: :episodic }, { trace_type: :episodic }, + { trace_type: :identity } + ] + } + end + + let(:dir_result) do + { + directory: '/tmp/test', + files_found: 3, + total_parsed: 12, + total_stored: 12, + files: %w[CLAUDE.md MEMORY.md docs/CLAUDE.md] + } + end + + let(:progress_data) do + { + firmware_loaded: true, + imprint_active: false, + imprint_progress: 0.75, + observation_count: 42, + calibration_state: 'calibrated', + current_layer: 'semantic' + } + end + + before do + Legion::Extensions::Coldstart::Runners::Ingest.test_file_result = file_result + Legion::Extensions::Coldstart::Runners::Ingest.test_dir_result = dir_result + Legion::Extensions::Coldstart::Runners::Coldstart.test_progress = progress_data + allow(Net::HTTP).to receive(:post).and_raise(Errno::ECONNREFUSED) + end + + describe '#ingest' do + context 'with a file path' do + let(:tmpfile) { File.join(Dir.mktmpdir, 'test.md') } + + before { File.write(tmpfile, '# Test') } + + after { FileUtils.rm_rf(File.dirname(tmpfile)) } + + it 'shows ingested file header' do + expect { described_class.start(['ingest', tmpfile, '--no-color']) }.to output(/Ingested/).to_stdout + end + + it 'shows trace count' do + expect { described_class.start(['ingest', tmpfile, '--no-color']) }.to output(/5/).to_stdout + end + + it 'shows trace type breakdown' do + expect { described_class.start(['ingest', tmpfile, '--no-color']) }.to output(/semantic/).to_stdout + end + + it 'outputs JSON when requested' do + expect { described_class.start(['ingest', tmpfile, '--json', '--no-color']) }.to output(/traces_parsed/).to_stdout + end + end + + context 'with a directory path' do + let(:tmpdir) { Dir.mktmpdir('coldstart-test') } + + after { FileUtils.rm_rf(tmpdir) } + + it 'shows directory ingest header' do + expect { described_class.start(['ingest', tmpdir, '--no-color']) }.to output(/Directory Ingest/).to_stdout + end + + it 'shows files found' do + expect { described_class.start(['ingest', tmpdir, '--no-color']) }.to output(/3/).to_stdout + end + + it 'lists processed files' do + expect { described_class.start(['ingest', tmpdir, '--no-color']) }.to output(/CLAUDE\.md/).to_stdout + end + end + + context 'with nonexistent path' do + it 'shows error' do + expect { described_class.start(['ingest', '/nonexistent/path/xyz', '--no-color']) }.to output(/not found/).to_stdout + end + end + + context 'with --dry-run on a file' do + let(:tmpfile) { File.join(Dir.mktmpdir, 'test.md') } + + before { File.write(tmpfile, '# Test') } + + after { FileUtils.rm_rf(File.dirname(tmpfile)) } + + it 'shows preview output' do + expect { described_class.start(['ingest', tmpfile, '--dry-run', '--no-color']) }.to output(/Ingested/).to_stdout + end + end + + context 'when result has error' do + let(:file_result) { { error: 'parse failed' } } + let(:tmpfile) { File.join(Dir.mktmpdir, 'test.md') } + + before { File.write(tmpfile, '# Test') } + + after { FileUtils.rm_rf(File.dirname(tmpfile)) } + + it 'shows error and exits' do + expect { described_class.start(['ingest', tmpfile, '--no-color']) }.to raise_error(SystemExit) + end + end + end + + describe '#status' do + it 'shows Cold Start Status header' do + expect { described_class.start(%w[status --no-color]) }.to output(/Cold Start Status/).to_stdout + end + + it 'shows imprint progress percentage' do + expect { described_class.start(%w[status --no-color]) }.to output(/75\.0%/).to_stdout + end + + it 'shows observation count' do + expect { described_class.start(%w[status --no-color]) }.to output(/42/).to_stdout + end + + it 'shows calibration state' do + expect { described_class.start(%w[status --no-color]) }.to output(/calibrated/).to_stdout + end + + context 'with --json' do + it 'outputs JSON with all fields' do + output = capture_stdout { described_class.start(%w[status --json --no-color]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:firmware_loaded]).to eq(true) + expect(parsed[:observation_count]).to eq(42) + end + end + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end diff --git a/spec/legion/cli/commit_spec.rb b/spec/legion/cli/commit_spec.rb new file mode 100644 index 00000000..1a787e11 --- /dev/null +++ b/spec/legion/cli/commit_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' + +CommitResponse = Struct.new(:content) + +require 'legion/cli/commit_command' + +RSpec.describe Legion::CLI::Commit do + let(:out) { Legion::CLI::Output::Formatter.new(json: false, color: false) } + + before do + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_llm) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + describe 'staged_diff' do + it 'calls git diff --staged' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'diff', '--staged') + .and_return(["diff --git a/foo\n+bar\n", '', double(success?: true)]) + + result = instance.staged_diff + expect(result).to include('diff --git') + end + end + + describe 'staged_stat' do + it 'calls git diff --staged --stat' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'diff', '--staged', '--stat') + .and_return([" foo.rb | 2 +-\n 1 file changed\n", '', double(success?: true)]) + + result = instance.staged_stat + expect(result).to include('foo.rb') + end + end + + describe 'recent_commits' do + it 'returns recent git log' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'log', '--oneline', '-10', '--no-decorate') + .and_return(["abc1234 add something\ndef5678 fix bug\n", '', double(success?: true)]) + + result = instance.recent_commits + expect(result).to include('add something') + end + end + + describe 'build_prompt' do + it 'includes diff, stat, and log in prompt' do + instance = described_class.new + prompt = instance.build_prompt('diff content', 'stat content', 'log content') + expect(prompt).to include('diff content') + expect(prompt).to include('stat content') + expect(prompt).to include('log content') + expect(prompt).to include('imperative mood') + end + + it 'truncates long diffs' do + instance = described_class.new + long_diff = 'x' * 10_000 + prompt = instance.build_prompt(long_diff, 'stat', 'log') + expect(prompt.length).to be < 10_000 + end + end + + describe 'generate_message' do + it 'returns LLM-generated commit message' do + fake_response = CommitResponse.new(content: "add new feature\n\n- update config\n- fix tests") + fake_chat = double('chat', ask: fake_response) + allow(Legion::LLM).to receive(:chat).and_return(fake_chat) + + instance = described_class.new([], { model: nil, provider: nil }) + message = instance.generate_message('diff', 'stat', 'log') + expect(message).to include('add new feature') + end + end + + describe 'run_commit' do + it 'runs git commit with message' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'commit', '-m', 'test message') + .and_return(['', '', double(success?: true)]) + + expect { instance.run_commit('test message') }.not_to raise_error + end + + it 'raises on git commit failure' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'commit', '-m', 'test message') + .and_return(['', 'error: nothing to commit', double(success?: false)]) + + expect { instance.run_commit('test message') }.to raise_error( + Legion::CLI::Error, /git commit failed/ + ) + end + + it 'passes --amend flag when requested' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'commit', '--amend', '-m', 'amended message') + .and_return(['', '', double(success?: true)]) + + expect { instance.run_commit('amended message', amend: true) }.not_to raise_error + end + end + + describe 'stage_all' do + it 'runs git add -u' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'add', '-u') + .and_return(['', '', double(success?: true)]) + + expect { instance.stage_all }.not_to raise_error + end + + it 'raises on failure' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'add', '-u') + .and_return(['', 'fatal: not a git repository', double(success?: false)]) + + expect { instance.stage_all }.to raise_error(Legion::CLI::Error, /git add -u failed/) + end + end +end diff --git a/spec/legion/cli/completion_command_spec.rb b/spec/legion/cli/completion_command_spec.rb new file mode 100644 index 00000000..fda4ac28 --- /dev/null +++ b/spec/legion/cli/completion_command_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/completion_command' +require 'legion/cli/output' + +RSpec.describe Legion::CLI::Completion do + it 'is a Thor subclass' do + expect(described_class.ancestors).to include(Thor) + end + + it 'responds to bash' do + expect(described_class.instance_methods).to include(:bash) + end + + it 'responds to zsh' do + expect(described_class.instance_methods).to include(:zsh) + end + + it 'responds to install' do + expect(described_class.instance_methods).to include(:install) + end + + describe 'COMPLETION_DIR' do + it 'points to the completions directory' do + expect(described_class::COMPLETION_DIR).to end_with('completions') + end + + it 'the completions directory exists' do + expect(Dir.exist?(described_class::COMPLETION_DIR)).to be true + end + end + + describe '#bash' do + it 'outputs the bash completion script' do + output = StringIO.new + instance = described_class.new + allow(instance).to receive(:puts) { |text| output.puts(text) } + instance.bash + expect(output.string).to include('_legion_complete') + expect(output.string).to include('complete -F _legion_complete legion') + end + end + + describe '#zsh' do + it 'outputs the zsh completion script' do + output = StringIO.new + instance = described_class.new + allow(instance).to receive(:puts) { |text| output.puts(text) } + instance.zsh + expect(output.string).to include('#compdef legion') + expect(output.string).to include('_legion_commands') + end + end + + describe 'completion files' do + it 'bash completion file exists' do + path = File.join(described_class::COMPLETION_DIR, 'legion.bash') + expect(File.exist?(path)).to be true + end + + it 'zsh completion file exists' do + path = File.join(described_class::COMPLETION_DIR, '_legion') + expect(File.exist?(path)).to be true + end + + it 'bash completion file contains top-level commands' do + path = File.join(described_class::COMPLETION_DIR, 'legion.bash') + content = File.read(path) + %w[start stop status check lex task chain config generate mcp worker + coldstart chat memory plan swarm commit pr review gaia schedule completion].each do |cmd| + expect(content).to include(cmd) + end + end + + it 'zsh completion file contains top-level commands' do + path = File.join(described_class::COMPLETION_DIR, '_legion') + content = File.read(path) + %w[start stop status check lex task chain config generate mcp worker + coldstart chat memory plan swarm commit pr review gaia schedule completion].each do |cmd| + expect(content).to include(cmd) + end + end + end +end diff --git a/spec/legion/cli/config_command_spec.rb b/spec/legion/cli/config_command_spec.rb new file mode 100644 index 00000000..c4529010 --- /dev/null +++ b/spec/legion/cli/config_command_spec.rb @@ -0,0 +1,327 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/cli/error' +require 'legion/cli/config_command' + +RSpec.describe Legion::CLI::Config do + let(:config) { described_class.new } + + describe '#sensitive_key?' do + def sensitive?(key) + config.send(:sensitive_key?, key) + end + + context 'keys that should be redacted' do + %w[password secret token key credential auth].each do |word| + it "redacts '#{word}'" do + expect(sensitive?(word)).to be(true) + end + end + + it "redacts 'api_key'" do + expect(sensitive?(:api_key)).to be(true) + end + + it "redacts 'cluster_secret'" do + expect(sensitive?(:cluster_secret)).to be(true) + end + + it "redacts 'auth_token'" do + expect(sensitive?(:auth_token)).to be(true) + end + + it "redacts 'vault_password'" do + expect(sensitive?(:vault_password)).to be(true) + end + + it "redacts 'session_token'" do + expect(sensitive?(:session_token)).to be(true) + end + end + + context 'keys that should NOT be redacted' do + it "does not redact 'cluster_secret_timeout'" do + expect(sensitive?(:cluster_secret_timeout)).to be(false) + end + + it "does not redact 'authentication'" do + expect(sensitive?(:authentication)).to be(false) + end + + it "does not redact 'key_count'" do + expect(sensitive?(:key_count)).to be(false) + end + + it "does not redact 'vault_path'" do + expect(sensitive?(:vault_path)).to be(false) + end + + it "does not redact 'token_ttl'" do + expect(sensitive?(:token_ttl)).to be(false) + end + + it "does not redact 'password_length'" do + expect(sensitive?(:password_length)).to be(false) + end + end + end + + describe '#deep_redact' do + def redact(obj) + config.send(:deep_redact, obj) + end + + it 'redacts password but not cluster_secret_timeout' do + input = { password: 'hunter2', cluster_secret_timeout: 5 } + result = redact(input) + expect(result[:password]).to eq('***REDACTED***') + expect(result[:cluster_secret_timeout]).to eq(5) + end + + it 'redacts nested sensitive keys' do + input = { vault: { token: 'abc123', address: 'localhost' } } + result = redact(input) + expect(result[:vault][:token]).to eq('***REDACTED***') + expect(result[:vault][:address]).to eq('localhost') + end + end + + describe 'LLM validation' do + before do + allow(Legion::CLI::Connection).to receive(:ensure_settings) + allow(Legion::CLI::Connection).to receive(:settings?).and_return(true) + end + + def run_validate + issues = [] + warnings = [] + config.send(:validate_llm, warnings) + [issues, warnings] + end + + context 'when LLM is enabled with no default provider' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ adapter: 'sqlite' }) + allow(Legion::Settings).to receive(:[]).with(:extensions).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:llm).and_return({ + enabled: true, + default_provider: nil, + providers: {} + }) + end + + it 'warns about missing default provider' do + _, warnings = run_validate + expect(warnings).to include(a_string_matching(/default.provider/i)) + end + end + + context 'when a provider is enabled without an API key' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ adapter: 'sqlite' }) + allow(Legion::Settings).to receive(:[]).with(:extensions).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:llm).and_return({ + enabled: true, + providers: { + anthropic: { enabled: true, api_key: nil } + } + }) + end + + it 'warns about missing API key' do + _, warnings = run_validate + expect(warnings).to include(a_string_matching(/anthropic.*api.key/i)) + end + end + + context 'when bedrock is enabled without an API key' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ adapter: 'sqlite' }) + allow(Legion::Settings).to receive(:[]).with(:extensions).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:llm).and_return({ + enabled: true, + providers: { + bedrock: { enabled: true, region: 'us-east-2' } + } + }) + end + + it 'does not warn (bedrock uses IAM, not API keys)' do + _, warnings = run_validate + expect(warnings).not_to include(a_string_matching(/bedrock.*api.key/i)) + end + end + + context 'when ollama is enabled without an API key' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ adapter: 'sqlite' }) + allow(Legion::Settings).to receive(:[]).with(:extensions).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:llm).and_return({ + enabled: true, + providers: { + ollama: { enabled: true, base_url: 'http://localhost:11434' } + } + }) + end + + it 'does not warn (ollama is local, no API key needed)' do + _, warnings = run_validate + expect(warnings).not_to include(a_string_matching(/ollama.*api.key/i)) + end + end + + context 'when LLM is disabled' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ adapter: 'sqlite' }) + allow(Legion::Settings).to receive(:[]).with(:extensions).and_return({}) + allow(Legion::Settings).to receive(:[]).with(:llm).and_return({ enabled: false }) + end + + it 'produces no LLM warnings' do + _, warnings = run_validate + expect(warnings.grep(/llm|provider|api.key/i)).to be_empty + end + end + end + + describe 'Connection.shutdown ensure blocks' do + before do + allow(Legion::CLI::Connection).to receive(:shutdown) + allow(Legion::CLI::Connection).to receive(:ensure_settings) + allow(Legion::CLI::Connection).to receive(:settings?).and_return(false) + end + + describe '#show' do + before do + allow(Legion::Settings).to receive(:respond_to?).with(:to_hash).and_return(true) + allow(Legion::Settings).to receive(:to_hash).and_return({}) + end + + it 'calls Connection.shutdown on success' do + expect(Legion::CLI::Connection).to receive(:shutdown) + output = StringIO.new + $stdout = output + config.show + ensure + $stdout = STDOUT + end + + context 'when CLI::Error is raised' do + before do + allow(Legion::CLI::Connection).to receive(:ensure_settings) + .and_raise(Legion::CLI::Error, 'settings failed') + end + + it 'rescues CLI::Error and raises SystemExit' do + expect { config.show }.to raise_error(SystemExit) + end + + it 'calls Connection.shutdown even on error' do + expect(Legion::CLI::Connection).to receive(:shutdown) + config.show + rescue SystemExit + # expected + end + end + end + + describe '#validate' do + it 'calls Connection.shutdown on success' do + expect(Legion::CLI::Connection).to receive(:shutdown) + output = StringIO.new + $stdout = output + config.validate + ensure + $stdout = STDOUT + end + + context 'when CLI::Error is raised by ensure_settings' do + before do + allow(Legion::CLI::Connection).to receive(:ensure_settings) + .and_raise(Legion::CLI::Error, 'transport connection failed') + end + + it 'rescues CLI::Error and raises SystemExit' do + expect { config.validate }.to raise_error(SystemExit) + end + + it 'calls Connection.shutdown even on error' do + expect(Legion::CLI::Connection).to receive(:shutdown) + config.validate + rescue SystemExit + # expected + end + end + end + + describe '#path' do + it 'calls Connection.shutdown on success' do + expect(Legion::CLI::Connection).to receive(:shutdown) + output = StringIO.new + $stdout = output + config.path + ensure + $stdout = STDOUT + end + + context 'when CLI::Error is raised' do + before do + allow(Legion::CLI::Connection).to receive(:config_dir=) + .and_raise(Legion::CLI::Error, 'something went wrong') + end + + it 'rescues CLI::Error and raises SystemExit' do + allow(config).to receive(:options).and_return({ json: false, no_color: true, config_dir: '/bad' }) + expect { config.path }.to raise_error(SystemExit) + end + + it 'calls Connection.shutdown even on error' do + allow(config).to receive(:options).and_return({ json: false, no_color: true, config_dir: '/bad' }) + expect(Legion::CLI::Connection).to receive(:shutdown) + config.path + rescue SystemExit + # expected + end + end + end + end + + describe '--config-dir option' do + before do + allow(Legion::CLI::Connection).to receive(:shutdown) + allow(Legion::CLI::Connection).to receive(:ensure_settings) + allow(Legion::CLI::Connection).to receive(:settings?).and_return(false) + end + + describe '#path sets Connection.config_dir' do + it 'sets config_dir when --config-dir is provided' do + allow(config).to receive(:options).and_return({ json: true, no_color: true, config_dir: '/custom/path' }) + expect(Legion::CLI::Connection).to receive(:config_dir=).with('/custom/path') + output = StringIO.new + $stdout = output + config.path + ensure + $stdout = STDOUT + end + + it 'does not call config_dir= when --config-dir is absent' do + allow(config).to receive(:options).and_return({ json: true, no_color: true, config_dir: nil }) + expect(Legion::CLI::Connection).not_to receive(:config_dir=) + output = StringIO.new + $stdout = output + config.path + ensure + $stdout = STDOUT + end + end + end +end diff --git a/spec/legion/cli/config_import_spec.rb b/spec/legion/cli/config_import_spec.rb new file mode 100644 index 00000000..e5535628 --- /dev/null +++ b/spec/legion/cli/config_import_spec.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tmpdir' +require 'tempfile' +require 'legion/cli/error' +require 'legion/cli/config_import' + +RSpec.describe Legion::CLI::ConfigImport do + describe '.parse_payload' do + context 'with raw JSON' do + it 'parses a valid JSON object' do + body = '{"transport":{"host":"localhost"}}' + result = described_class.parse_payload(body) + expect(result).to eq({ transport: { host: 'localhost' } }) + end + + it 'raises CLI::Error for a JSON array' do + body = '[1, 2, 3]' + expect { described_class.parse_payload(body) } + .to raise_error(Legion::CLI::Error, 'Config must be a JSON object') + end + end + + context 'with base64-encoded JSON' do + it 'parses base64-encoded JSON object' do + payload = Base64.encode64('{"data":{"adapter":"sqlite"}}') + result = described_class.parse_payload(payload) + expect(result).to eq({ data: { adapter: 'sqlite' } }) + end + + it 'raises CLI::Error for base64-encoded non-object JSON' do + payload = Base64.encode64('[1, 2, 3]') + expect { described_class.parse_payload(payload) } + .to raise_error(Legion::CLI::Error, 'Config must be a JSON object') + end + end + + context 'with invalid input' do + it 'raises CLI::Error when input is neither JSON nor base64 JSON' do + expect { described_class.parse_payload('not valid at all!!!') } + .to raise_error(Legion::CLI::Error, 'Source is not valid JSON or base64-encoded JSON') + end + end + end + + describe '.fetch_source' do + context 'with a local file' do + it 'reads the file contents' do + Tempfile.create(['legion-import', '.json']) do |f| + f.write('{"logging":{"level":"info"}}') + f.flush + result = described_class.fetch_source(f.path) + expect(result).to eq('{"logging":{"level":"info"}}') + end + end + + it 'raises CLI::Error when the file does not exist' do + expect { described_class.fetch_source('/tmp/does_not_exist_legion_test.json') } + .to raise_error(Legion::CLI::Error, /File not found/) + end + end + + context 'with an HTTP URL' do + it 'delegates to fetch_http' do + allow(described_class).to receive(:fetch_http).with('http://example.com/config.json').and_return('{}') + result = described_class.fetch_source('http://example.com/config.json') + expect(result).to eq('{}') + end + + it 'delegates to fetch_http for https URLs' do + allow(described_class).to receive(:fetch_http).with('https://example.com/config.json').and_return('{}') + result = described_class.fetch_source('https://example.com/config.json') + expect(result).to eq('{}') + end + end + end + + describe '.summary' do + it 'returns top-level section names' do + config = { transport: { host: 'localhost' }, data: { adapter: 'sqlite' } } + result = described_class.summary(config) + expect(result[:sections]).to contain_exactly('transport', 'data') + end + + it 'returns empty vault_clusters when no crypt key present' do + config = { transport: { host: 'localhost' } } + result = described_class.summary(config) + expect(result[:vault_clusters]).to eq([]) + end + + it 'returns vault cluster names when present' do + config = { + crypt: { + vault: { + clusters: { + primary: { address: 'https://vault.example.com' }, + secondary: { address: 'https://vault2.example.com' } + } + } + } + } + result = described_class.summary(config) + expect(result[:vault_clusters]).to contain_exactly('primary', 'secondary') + end + end + + describe '.write_config' do + let(:tmpdir) { Dir.mktmpdir('legion-import-spec') } + + before do + stub_const('Legion::CLI::ConfigImport::SETTINGS_DIR', tmpdir) + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'returns an array of written paths' do + config = { transport: { host: 'localhost' } } + paths = described_class.write_config(config) + expect(paths).to be_an(Array) + end + + it 'writes recognized subsystem keys to individual files' do + config = { transport: { host: 'localhost' }, llm: { enabled: true } } + paths = described_class.write_config(config) + + transport_path = File.join(tmpdir, 'transport.json') + llm_path = File.join(tmpdir, 'llm.json') + + expect(paths).to include(transport_path, llm_path) + expect(File.exist?(transport_path)).to be(true) + expect(File.exist?(llm_path)).to be(true) + + transport_data = JSON.parse(File.read(transport_path), symbolize_names: true) + expect(transport_data).to eq({ transport: { host: 'localhost' } }) + + llm_data = JSON.parse(File.read(llm_path), symbolize_names: true) + expect(llm_data).to eq({ llm: { enabled: true } }) + end + + it 'writes unrecognized keys to bootstrapped_settings.json' do + config = { custom_thing: { foo: 'bar' }, another: 123 } + paths = described_class.write_config(config) + + bootstrapped_path = File.join(tmpdir, 'bootstrapped_settings.json') + expect(paths).to include(bootstrapped_path) + + written = JSON.parse(File.read(bootstrapped_path), symbolize_names: true) + expect(written).to eq({ custom_thing: { foo: 'bar' }, another: 123 }) + end + + it 'splits a mixed config into subsystem files and remainder' do + config = { logging: { level: 'debug' }, transport: { host: 'rmq' }, app_name: 'test' } + paths = described_class.write_config(config) + + expect(paths.size).to eq(3) + expect(File.exist?(File.join(tmpdir, 'logging.json'))).to be(true) + expect(File.exist?(File.join(tmpdir, 'transport.json'))).to be(true) + expect(File.exist?(File.join(tmpdir, 'bootstrapped_settings.json'))).to be(true) + + remainder = JSON.parse(File.read(File.join(tmpdir, 'bootstrapped_settings.json')), symbolize_names: true) + expect(remainder).to eq({ app_name: 'test' }) + end + + it 'does not write bootstrapped_settings.json when all keys are subsystem keys' do + config = { logging: { level: 'info' }, cache: { driver: 'dalli' } } + paths = described_class.write_config(config) + + expect(paths).not_to include(File.join(tmpdir, 'bootstrapped_settings.json')) + expect(File.exist?(File.join(tmpdir, 'bootstrapped_settings.json'))).to be(false) + end + + it 'deep merges existing subsystem files when force is false' do + transport_path = File.join(tmpdir, 'transport.json') + File.write(transport_path, JSON.generate({ transport: { host: 'old-host', port: 5672 } })) + + config = { transport: { host: 'new-host' } } + described_class.write_config(config, force: false) + + result = JSON.parse(File.read(transport_path), symbolize_names: true) + expect(result).to eq({ transport: { host: 'new-host', port: 5672 } }) + end + + it 'overwrites existing subsystem files when force is true' do + transport_path = File.join(tmpdir, 'transport.json') + File.write(transport_path, JSON.generate({ transport: { host: 'old-host', port: 5672 } })) + + config = { transport: { host: 'new-host' } } + described_class.write_config(config, force: true) + + result = JSON.parse(File.read(transport_path), symbolize_names: true) + expect(result).to eq({ transport: { host: 'new-host' } }) + end + + it 'deep merges remainder with existing bootstrapped_settings.json when force is false' do + bootstrapped_path = File.join(tmpdir, 'bootstrapped_settings.json') + File.write(bootstrapped_path, JSON.generate({ old_key: 'keep', nested: { a: 1, b: 2 } })) + + config = { nested: { b: 99, c: 3 }, new_key: 'added' } + described_class.write_config(config, force: false) + + result = JSON.parse(File.read(bootstrapped_path), symbolize_names: true) + expect(result[:old_key]).to eq('keep') + expect(result[:nested]).to eq({ a: 1, b: 99, c: 3 }) + expect(result[:new_key]).to eq('added') + end + + it 'overwrites bootstrapped_settings.json with force: true' do + bootstrapped_path = File.join(tmpdir, 'bootstrapped_settings.json') + File.write(bootstrapped_path, JSON.generate({ old_key: 'should_be_gone' })) + + config = { new_key: 'only_this' } + described_class.write_config(config, force: true) + + result = JSON.parse(File.read(bootstrapped_path), symbolize_names: true) + expect(result.keys).to eq([:new_key]) + end + + it 'does not mutate the original config hash' do + config = { transport: { host: 'localhost' }, llm: { enabled: true }, app: 'test' } + original_keys = config.keys.dup + described_class.write_config(config) + expect(config.keys).to eq(original_keys) + end + + it 'creates the settings directory if it does not exist' do + nested = File.join(tmpdir, 'nested', 'settings') + stub_const('Legion::CLI::ConfigImport::SETTINGS_DIR', nested) + described_class.write_config({ logging: { level: 'info' } }) + expect(Dir.exist?(nested)).to be(true) + end + + it 'writes all recognized subsystem key types' do + config = described_class::SUBSYSTEM_KEYS.to_h { |k| [k, { enabled: true }] } + paths = described_class.write_config(config) + + described_class::SUBSYSTEM_KEYS.each do |key| + expect(File.exist?(File.join(tmpdir, "#{key}.json"))).to be(true) + end + expect(paths.size).to eq(described_class::SUBSYSTEM_KEYS.size) + end + end + + describe '.deep_merge' do + it 'merges nested hashes recursively' do + base = { a: { x: 1, y: 2 }, b: 'keep' } + overlay = { a: { y: 99, z: 3 }, c: 'new' } + result = described_class.deep_merge(base, overlay) + expect(result).to eq({ a: { x: 1, y: 99, z: 3 }, b: 'keep', c: 'new' }) + end + + it 'overwrites non-hash values with overlay' do + base = { a: [1, 2, 3] } + overlay = { a: [4, 5] } + result = described_class.deep_merge(base, overlay) + expect(result[:a]).to eq([4, 5]) + end + end +end diff --git a/spec/legion/cli/config_scaffold_spec.rb b/spec/legion/cli/config_scaffold_spec.rb new file mode 100644 index 00000000..ae00fceb --- /dev/null +++ b/spec/legion/cli/config_scaffold_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/config_scaffold' +require 'legion/cli/output' +require 'json' +require 'tmpdir' + +RSpec.describe Legion::CLI::ConfigScaffold do + let(:tmpdir) { Dir.mktmpdir('legion-scaffold') } + let(:formatter) { Legion::CLI::Output::Formatter.new(json: false, color: false) } + let(:json_formatter) { Legion::CLI::Output::Formatter.new(json: true, color: false) } + + after { FileUtils.rm_rf(tmpdir) } + + # Clean all detectable env vars so tests get predictable output + around do |example| + saved = ENV.to_h.slice(*described_class::ENV_DETECTIONS.keys) + described_class::ENV_DETECTIONS.each_key { |k| ENV.delete(k) } + example.run + ensure + saved.each { |k, v| v ? ENV[k] = v : ENV.delete(k) } + end + + before { allow(described_class).to receive(:ollama_running?).and_return(false) } + + def run_scaffold(overrides = {}) + opts = { dir: tmpdir, json: false, full: false, force: false, only: nil }.merge(overrides) + output = StringIO.new + exit_code = nil + begin + $stdout = output + exit_code = described_class.run(overrides[:json] ? json_formatter : formatter, opts) + ensure + $stdout = STDOUT + end + [exit_code, output.string] + end + + def read_generated(name) + JSON.parse(File.read(File.join(tmpdir, "#{name}.json"))) + end + + describe '.run' do + it 'creates all 6 subsystem files' do + exit_code, = run_scaffold + expect(exit_code).to eq(0) + %w[transport data cache crypt logging llm].each do |name| + expect(File.exist?(File.join(tmpdir, "#{name}.json"))).to be(true) + end + end + + it 'creates the output directory if it does not exist' do + new_dir = File.join(tmpdir, 'nested', 'settings') + run_scaffold(dir: new_dir) + expect(Dir.exist?(new_dir)).to be(true) + end + + context 'minimal mode' do + before { run_scaffold } + + it 'transport.json has connection host/port/user/password/vhost' do + config = read_generated('transport') + conn = config['transport']['connection'] + expect(conn).to include('host' => '127.0.0.1', 'port' => 5672, 'user' => 'guest', 'vhost' => '/') + end + + it 'data.json has adapter and creds' do + config = read_generated('data') + expect(config['data']['adapter']).to eq('sqlite') + expect(config['data']['creds']['database']).to eq('legionio.db') + end + + it 'cache.json has driver and servers' do + config = read_generated('cache') + expect(config['cache']['driver']).to eq('dalli') + expect(config['cache']['servers']).to eq(['127.0.0.1:11211']) + end + + it 'crypt.json has vault and jwt sections' do + config = read_generated('crypt') + expect(config['crypt']['vault']['enabled']).to be(false) + expect(config['crypt']['jwt']['default_algorithm']).to eq('HS256') + end + + it 'logging.json has level and location' do + config = read_generated('logging') + expect(config['logging']['level']).to eq('info') + expect(config['logging']['location']).to eq('stdout') + end + + it 'llm.json has providers' do + config = read_generated('llm') + expect(config['llm']['enabled']).to be(false) + expect(config['llm']['providers']).to have_key('anthropic') + expect(config['llm']['providers']).to have_key('ollama') + end + end + + context '--full mode' do + before { run_scaffold(full: true) } + + it 'transport.json includes channel and queue settings' do + config = read_generated('transport') + expect(config['transport']).to have_key('channel') + expect(config['transport']).to have_key('queues') + expect(config['transport']).to have_key('exchanges') + expect(config['transport']).to have_key('messages') + end + + it 'data.json includes connection and migration settings' do + config = read_generated('data') + expect(config['data']).to have_key('connection') + expect(config['data']).to have_key('migrations') + expect(config['data']).to have_key('models') + end + + it 'cache.json includes pool_size and namespace' do + config = read_generated('cache') + expect(config['cache']).to have_key('pool_size') + expect(config['cache']).to have_key('namespace') + expect(config['cache']).to have_key('failover') + end + + it 'crypt.json includes cluster_secret and vault kv_path' do + config = read_generated('crypt') + expect(config['crypt']).to have_key('cluster_secret') + expect(config['crypt']['vault']).to have_key('kv_path') + expect(config['crypt']['jwt']).to have_key('verify_expiration') + end + + it 'llm.json includes vault_path for providers' do + config = read_generated('llm') + expect(config['llm']['providers']['anthropic']).to have_key('vault_path') + expect(config['llm']['providers']['bedrock']).to have_key('secret_key') + end + end + + context '--only flag' do + it 'creates only specified subsystems' do + run_scaffold(only: 'transport,data') + expect(File.exist?(File.join(tmpdir, 'transport.json'))).to be(true) + expect(File.exist?(File.join(tmpdir, 'data.json'))).to be(true) + expect(File.exist?(File.join(tmpdir, 'cache.json'))).to be(false) + expect(File.exist?(File.join(tmpdir, 'llm.json'))).to be(false) + end + + it 'returns error for unknown subsystems' do + exit_code, = run_scaffold(only: 'transport,bogus') + expect(exit_code).to eq(1) + end + end + + context 'existing files' do + before do + FileUtils.mkdir_p(tmpdir) + File.write(File.join(tmpdir, 'transport.json'), '{"custom": true}') + end + + it 'skips existing files by default' do + run_scaffold + config = JSON.parse(File.read(File.join(tmpdir, 'transport.json'))) + expect(config).to eq({ 'custom' => true }) + end + + it 'overwrites existing files with --force' do + run_scaffold(force: true) + config = read_generated('transport') + expect(config).to have_key('transport') + end + end + + context '--json output' do + it 'returns created and skipped arrays' do + output = StringIO.new + $stdout = output + described_class.run(json_formatter, { dir: tmpdir, json: true, full: false, force: false, only: nil }) + $stdout = STDOUT + parsed = JSON.parse(output.string) + expect(parsed['created'].size).to eq(Legion::CLI::ConfigScaffold::SUBSYSTEMS.size) + expect(parsed['skipped']).to be_empty + end + end + + it 'generates valid JSON in all files' do + run_scaffold(full: true) + %w[transport data cache crypt logging llm].each do |name| + path = File.join(tmpdir, "#{name}.json") + expect { JSON.parse(File.read(path)) }.not_to raise_error + end + end + end + + describe 'environment auto-detection' do + context 'with ANTHROPIC_API_KEY set' do + before { ENV['ANTHROPIC_API_KEY'] = 'sk-test-key' } + + it 'enables anthropic provider and sets env:// reference' do + run_scaffold + config = read_generated('llm') + expect(config['llm']['enabled']).to be(true) + expect(config['llm']['default_provider']).to eq('anthropic') + expect(config['llm']['providers']['anthropic']['enabled']).to be(true) + expect(config['llm']['providers']['anthropic']['api_key']).to eq('env://ANTHROPIC_API_KEY') + end + end + + context 'with AWS_BEARER_TOKEN_BEDROCK set' do + before { ENV['AWS_BEARER_TOKEN_BEDROCK'] = 'test-token' } + + it 'enables bedrock provider with bearer_token env reference' do + run_scaffold + config = read_generated('llm') + expect(config['llm']['enabled']).to be(true) + expect(config['llm']['default_provider']).to eq('bedrock') + expect(config['llm']['providers']['bedrock']['enabled']).to be(true) + expect(config['llm']['providers']['bedrock']['bearer_token']).to eq('env://AWS_BEARER_TOKEN_BEDROCK') + end + end + + context 'with multiple LLM providers' do + before do + ENV['AWS_BEARER_TOKEN_BEDROCK'] = 'test-token' + ENV['ANTHROPIC_API_KEY'] = 'sk-test' + ENV['OPENAI_API_KEY'] = 'sk-openai' + end + + it 'enables all detected providers and picks the first as default' do + run_scaffold + config = read_generated('llm') + expect(config['llm']['providers']['bedrock']['enabled']).to be(true) + expect(config['llm']['providers']['anthropic']['enabled']).to be(true) + expect(config['llm']['providers']['openai']['enabled']).to be(true) + expect(config['llm']['providers']['gemini']['enabled']).to be(false) + expect(config['llm']['default_provider']).to eq('bedrock') + end + end + + context 'with VAULT_TOKEN set' do + before { ENV['VAULT_TOKEN'] = 's.test-vault-token' } + + it 'enables vault in crypt config' do + run_scaffold + config = read_generated('crypt') + expect(config['crypt']['vault']['enabled']).to be(true) + expect(config['crypt']['vault']['token']).to eq('env://VAULT_TOKEN') + end + end + + context 'with RABBITMQ_USER and RABBITMQ_PASSWORD set' do + before do + ENV['RABBITMQ_USER'] = 'legion' + ENV['RABBITMQ_PASSWORD'] = 'secret' + end + + it 'sets env:// references in transport config' do + run_scaffold + config = read_generated('transport') + expect(config['transport']['connection']['user']).to eq('env://RABBITMQ_USER') + expect(config['transport']['connection']['password']).to eq('env://RABBITMQ_PASSWORD') + end + end + + context 'with no env vars set' do + it 'generates default disabled configs' do + run_scaffold + config = read_generated('llm') + expect(config['llm']['enabled']).to be(false) + expect(config['llm']['default_provider']).to be_nil + end + end + + context 'with --json output' do + before { ENV['ANTHROPIC_API_KEY'] = 'sk-test' } + + it 'includes detected list in JSON output' do + output = StringIO.new + $stdout = output + described_class.run(json_formatter, { dir: tmpdir, json: true, full: false, force: false, only: nil }) + $stdout = STDOUT + parsed = JSON.parse(output.string) + expect(parsed['detected']).to include(a_string_matching(/anthropic/)) + end + end + + describe '.ollama_running?' do + it 'returns false when ollama is not reachable' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + expect(described_class.ollama_running?).to be(false) + end + end + + context 'when ollama is running' do + before do + allow(described_class).to receive(:ollama_running?).and_return(true) + end + + it 'enables ollama in llm config' do + run_scaffold + config = read_generated('llm') + expect(config['llm']['providers']['ollama']['enabled']).to be(true) + end + end + end +end diff --git a/spec/legion/cli/connect_command_spec.rb b/spec/legion/cli/connect_command_spec.rb new file mode 100644 index 00000000..098f627d --- /dev/null +++ b/spec/legion/cli/connect_command_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/auth/token_manager' +require 'legion/cli/connect_command' + +RSpec.describe Legion::CLI::ConnectCommand do + describe '#status' do + it 'shows status for all providers' do + allow(Legion::Auth::TokenManager).to receive(:new).and_return( + instance_double(Legion::Auth::TokenManager, token_valid?: false, revoked?: false) + ) + expect { described_class.new.invoke(:status, []) }.to output(/not connected/).to_stdout + end + end + + describe '#disconnect' do + it 'rejects unknown providers' do + expect { described_class.new.invoke(:disconnect, ['unknown']) }.to output(/Unknown provider/).to_stdout + end + + it 'accepts known providers' do + expect { described_class.new.invoke(:disconnect, ['microsoft']) }.to output(/Disconnected/).to_stdout + end + end +end diff --git a/spec/legion/cli/connection_spec.rb b/spec/legion/cli/connection_spec.rb new file mode 100644 index 00000000..06cfd889 --- /dev/null +++ b/spec/legion/cli/connection_spec.rb @@ -0,0 +1,522 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/error' +require 'legion/cli/connection' + +# Pre-load optional gems so their methods exist when mocks are set up. +# Connection's ensure_* methods call `require 'legion/X'` (no-op once loaded) +# followed by methods on the module. If we mock before the methods are defined, +# RSpec cannot intercept them. +begin + require 'legion/data' +rescue LoadError + module Legion + module Data + module Settings + def self.default = {} + end + + def self.setup(**) = nil + def self.shutdown(**) = nil + end + end + $LOADED_FEATURES << 'legion/data' +end +require 'legion/crypt' +require 'legion/cache' + +RSpec.describe Legion::CLI::Connection do + before do + %i[@logging_ready @settings_ready @data_ready @transport_ready + @crypt_ready @cache_ready @llm_ready @config_dir @log_level].each do |ivar| + described_class.instance_variable_set(ivar, nil) + end + end + + def stub_logging_and_settings + allow(Legion::Logging).to receive(:setup) + allow(Legion::Settings).to receive(:load) + end + + # --------------------------------------------------------------------------- + # ensure_logging + # --------------------------------------------------------------------------- + describe '.ensure_logging' do + before { allow(Legion::Logging).to receive(:setup) } + + it 'calls Legion::Logging.setup with the default error log level' do + described_class.ensure_logging + expect(Legion::Logging).to have_received(:setup).with(log_level: 'error', level: 'error', trace: false) + end + + it 'sets @logging_ready to true' do + described_class.ensure_logging + expect(described_class.instance_variable_get(:@logging_ready)).to be(true) + end + + it 'is idempotent: does not call setup a second time' do + described_class.ensure_logging + described_class.ensure_logging + expect(Legion::Logging).to have_received(:setup).once + end + + it 'respects a custom log_level' do + described_class.log_level = 'debug' + described_class.ensure_logging + expect(Legion::Logging).to have_received(:setup).with(log_level: 'debug', level: 'debug', trace: false) + end + end + + # --------------------------------------------------------------------------- + # ensure_settings + # --------------------------------------------------------------------------- + describe '.ensure_settings' do + before { stub_logging_and_settings } + + it 'calls ensure_logging first' do + described_class.ensure_settings + expect(Legion::Logging).to have_received(:setup) + end + + it 'calls Legion::Settings.load with a config_dir keyword' do + described_class.ensure_settings + expect(Legion::Settings).to have_received(:load).with(config_dir: anything) + end + + it 'sets @settings_ready to true' do + described_class.ensure_settings + expect(described_class.instance_variable_get(:@settings_ready)).to be(true) + end + + it 'is idempotent: only loads settings once' do + described_class.ensure_settings + described_class.ensure_settings + expect(Legion::Settings).to have_received(:load).once + end + end + + # --------------------------------------------------------------------------- + # ensure_data + # --------------------------------------------------------------------------- + describe '.ensure_data' do + before do + stub_logging_and_settings + allow(Legion::Settings).to receive(:merge_settings) + allow(Legion::Data::Settings).to receive(:default).and_return({}) + allow(Legion::Data).to receive(:setup) + end + + context 'when legion-data is available and connects successfully' do + it 'calls Legion::Data.setup' do + described_class.ensure_data + expect(Legion::Data).to have_received(:setup) + end + + it 'sets @data_ready to true' do + described_class.ensure_data + expect(described_class.instance_variable_get(:@data_ready)).to be(true) + end + + it 'is idempotent: does not call setup a second time' do + described_class.ensure_data + described_class.ensure_data + expect(Legion::Data).to have_received(:setup).once + end + end + + context 'when the database connection fails with StandardError' do + before { allow(Legion::Data).to receive(:setup).and_raise(StandardError, 'connection refused') } + + it 'raises CLI::Error with the connection failure message' do + expect { described_class.ensure_data }.to raise_error( + Legion::CLI::Error, + /database connection failed: connection refused/ + ) + end + end + + context 'when LoadError is raised (gem not available)' do + before { allow(Legion::Data).to receive(:setup).and_raise(LoadError, 'cannot load') } + + it 'raises CLI::Error with gem install hint' do + expect { described_class.ensure_data }.to raise_error( + Legion::CLI::Error, + /legion-data gem is not installed/ + ) + end + end + end + + # --------------------------------------------------------------------------- + # ensure_transport + # --------------------------------------------------------------------------- + describe '.ensure_transport' do + before do + stub_logging_and_settings + allow(Legion::Settings).to receive(:merge_settings) + allow(Legion::Transport::Settings).to receive(:default).and_return({}) + allow(Legion::Transport::Connection).to receive(:setup) + end + + context 'when legion-transport is available and connects successfully' do + it 'calls Legion::Transport::Connection.setup' do + described_class.ensure_transport + expect(Legion::Transport::Connection).to have_received(:setup) + end + + it 'sets @transport_ready to true' do + described_class.ensure_transport + expect(described_class.instance_variable_get(:@transport_ready)).to be(true) + end + + it 'is idempotent: does not call setup a second time' do + described_class.ensure_transport + described_class.ensure_transport + expect(Legion::Transport::Connection).to have_received(:setup).once + end + end + + context 'when the transport connection fails with StandardError' do + before { allow(Legion::Transport::Connection).to receive(:setup).and_raise(StandardError, 'broker unreachable') } + + it 'raises CLI::Error with the connection failure message' do + expect { described_class.ensure_transport }.to raise_error( + Legion::CLI::Error, + /transport connection failed: broker unreachable/ + ) + end + end + + context 'when LoadError is raised (gem not available)' do + before { allow(Legion::Transport::Connection).to receive(:setup).and_raise(LoadError, 'cannot load') } + + it 'raises CLI::Error with gem install hint' do + expect { described_class.ensure_transport }.to raise_error( + Legion::CLI::Error, + /legion-transport gem is not installed/ + ) + end + end + end + + # --------------------------------------------------------------------------- + # ensure_crypt + # --------------------------------------------------------------------------- + describe '.ensure_crypt' do + before do + stub_logging_and_settings + allow(Legion::Crypt).to receive(:start) + end + + context 'when legion-crypt is available and starts successfully' do + it 'calls Legion::Crypt.start' do + described_class.ensure_crypt + expect(Legion::Crypt).to have_received(:start) + end + + it 'sets @crypt_ready to true' do + described_class.ensure_crypt + expect(described_class.instance_variable_get(:@crypt_ready)).to be(true) + end + + it 'is idempotent: does not call start a second time' do + described_class.ensure_crypt + described_class.ensure_crypt + expect(Legion::Crypt).to have_received(:start).once + end + + it 're-resolves secrets after Crypt.start to handle lease:// URIs' do + # ensure_settings calls resolve_secrets! once; ensure_crypt adds a second call after Crypt.start + allow(Legion::Settings).to receive(:resolve_secrets!) + described_class.ensure_crypt + expect(Legion::Settings).to have_received(:resolve_secrets!).at_least(2).times + end + + it 'calls resolve_secrets! after Crypt.start, not just before' do + call_order = [] + allow(Legion::Crypt).to receive(:start) { call_order << :crypt_start } + allow(Legion::Settings).to receive(:resolve_secrets!) { call_order << :resolve_secrets } + described_class.ensure_crypt + # ensure_settings fires resolve_secrets first, then Crypt.start, then resolve_secrets again + expect(call_order).to eq(%i[resolve_secrets crypt_start resolve_secrets]) + end + + it 'skips resolve_secrets! when Settings does not respond to it' do + allow(Legion::Settings).to receive(:respond_to?).with(:resolve_secrets!).and_return(false) + expect(Legion::Settings).not_to receive(:resolve_secrets!) + described_class.ensure_crypt + end + end + + context 'when crypt initialization fails with StandardError' do + before { allow(Legion::Crypt).to receive(:start).and_raise(StandardError, 'vault unavailable') } + + it 'raises CLI::Error with the initialization failure message' do + expect { described_class.ensure_crypt }.to raise_error( + Legion::CLI::Error, + /crypt initialization failed: vault unavailable/ + ) + end + end + + context 'when LoadError is raised (gem not available)' do + before { allow(Legion::Crypt).to receive(:start).and_raise(LoadError, 'cannot load') } + + it 'raises CLI::Error with gem install hint' do + expect { described_class.ensure_crypt }.to raise_error( + Legion::CLI::Error, + /legion-crypt gem is not installed/ + ) + end + end + end + + # --------------------------------------------------------------------------- + # ensure_cache + # --------------------------------------------------------------------------- + describe '.ensure_cache' do + before { stub_logging_and_settings } + + context 'when legion-cache is available' do + it 'sets @cache_ready to true' do + described_class.ensure_cache + expect(described_class.instance_variable_get(:@cache_ready)).to be(true) + end + + it 'is idempotent: does not error on second call' do + described_class.ensure_cache + expect { described_class.ensure_cache }.not_to raise_error + expect(described_class.instance_variable_get(:@cache_ready)).to be(true) + end + end + + context 'when LoadError is raised (gem not available)' do + it 'raises CLI::Error with gem install hint' do + # Intercept the private `require` method on the module's singleton class. + # We pass all other require calls through so the ensure chain continues. + allow(described_class).to receive(:require).and_wrap_original do |orig, *args| + raise LoadError, "cannot load such file -- #{args.first}" if args.first == 'legion/cache' + + orig.call(*args) + end + described_class.instance_variable_set(:@cache_ready, nil) + expect { described_class.ensure_cache }.to raise_error( + Legion::CLI::Error, + /legion-cache gem is not installed/ + ) + end + end + end + + # --------------------------------------------------------------------------- + # Predicate methods + # --------------------------------------------------------------------------- + describe '.settings?' do + it 'returns false when not yet loaded' do + expect(described_class.settings?).to be(false) + end + + it 'returns true after ensure_settings succeeds' do + stub_logging_and_settings + described_class.ensure_settings + expect(described_class.settings?).to be(true) + end + end + + describe '.data?' do + it 'returns false when not yet connected' do + expect(described_class.data?).to be(false) + end + + it 'returns true after ensure_data succeeds' do + stub_logging_and_settings + allow(Legion::Settings).to receive(:merge_settings) + allow(Legion::Data::Settings).to receive(:default).and_return({}) + allow(Legion::Data).to receive(:setup) + described_class.ensure_data + expect(described_class.data?).to be(true) + end + end + + describe '.transport?' do + it 'returns false when not yet connected' do + expect(described_class.transport?).to be(false) + end + + it 'returns true after ensure_transport succeeds' do + stub_logging_and_settings + allow(Legion::Settings).to receive(:merge_settings) + allow(Legion::Transport::Settings).to receive(:default).and_return({}) + allow(Legion::Transport::Connection).to receive(:setup) + described_class.ensure_transport + expect(described_class.transport?).to be(true) + end + end + + # --------------------------------------------------------------------------- + # shutdown + # --------------------------------------------------------------------------- + describe '.shutdown' do + context 'when no subsystems are ready' do + it 'does not raise' do + expect { described_class.shutdown }.not_to raise_error + end + end + + context 'when transport is ready' do + before do + described_class.instance_variable_set(:@transport_ready, true) + allow(Legion::Transport::Connection).to receive(:shutdown) + end + + it 'shuts down transport' do + described_class.shutdown + expect(Legion::Transport::Connection).to have_received(:shutdown) + end + end + + context 'when data is ready' do + before do + described_class.instance_variable_set(:@data_ready, true) + allow(Legion::Data).to receive(:shutdown) + end + + it 'shuts down data' do + described_class.shutdown + expect(Legion::Data).to have_received(:shutdown) + end + end + + context 'when cache is ready' do + before do + described_class.instance_variable_set(:@cache_ready, true) + allow(Legion::Cache).to receive(:shutdown) + end + + it 'shuts down cache' do + described_class.shutdown + expect(Legion::Cache).to have_received(:shutdown) + end + end + + context 'when crypt is ready' do + before do + described_class.instance_variable_set(:@crypt_ready, true) + allow(Legion::Crypt).to receive(:shutdown) + end + + it 'shuts down crypt' do + described_class.shutdown + expect(Legion::Crypt).to have_received(:shutdown) + end + end + + context 'when a shutdown call raises an error' do + before do + described_class.instance_variable_set(:@transport_ready, true) + allow(Legion::Transport::Connection).to receive(:shutdown).and_raise(StandardError, 'shutdown error') + end + + it 'swallows the error (best-effort)' do + expect { described_class.shutdown }.not_to raise_error + end + end + end + + # --------------------------------------------------------------------------- + # resolve_config_dir (exercised through ensure_settings) + # --------------------------------------------------------------------------- + describe 'resolve_config_dir' do + before do + stub_logging_and_settings + allow(Legion::Settings::Loader).to receive(:default_directories) + .and_return([File.expand_path('~/.legionio/settings'), '/etc/legionio/settings']) + end + + context 'when config_dir is set to an existing directory' do + it 'uses the custom directory' do + tmpdir = Dir.mktmpdir('legion-cfg') + begin + described_class.config_dir = tmpdir + described_class.ensure_settings + expect(Legion::Settings).to have_received(:load).with(config_dir: tmpdir) + ensure + FileUtils.rm_rf(tmpdir) + end + end + end + + context 'when config_dir is set but does not exist' do + it 'falls through to Loader.default_directories' do + described_class.config_dir = '/nonexistent/path/that/does/not/exist' + described_class.ensure_settings + expect(Legion::Settings).to have_received(:load).with(config_dir: anything) + end + end + + context 'when config_dir contains a tilde' do + it 'expands the tilde before checking existence' do + expanded = File.expand_path('~/.legionio/settings') + allow(Dir).to receive(:exist?).and_call_original + allow(Dir).to receive(:exist?).with(expanded).and_return(true) + described_class.config_dir = '~/.legionio/settings' + described_class.ensure_settings + expect(Legion::Settings).to have_received(:load).with(config_dir: expanded) + end + end + + context 'when none of the standard paths exist' do + before { allow(Dir).to receive(:exist?).and_return(false) } + + it 'passes nil config_dir to Settings.load' do + described_class.ensure_settings + expect(Legion::Settings).to have_received(:load).with(config_dir: nil) + end + end + + context 'when ~/.legionio/settings exists' do + let(:settings_dir) { File.expand_path('~/.legionio/settings') } + + before do + allow(Dir).to receive(:exist?).and_call_original + allow(Dir).to receive(:exist?).with(settings_dir).and_return(true) + end + + it 'uses ~/.legionio/settings' do + described_class.ensure_settings + expect(Legion::Settings).to have_received(:load).with(config_dir: settings_dir) + end + end + + context 'when /etc/legionio/settings exists but ~/.legionio/settings does not' do + let(:home_settings) { File.expand_path('~/.legionio/settings') } + + before do + allow(Dir).to receive(:exist?).and_call_original + allow(Dir).to receive(:exist?).with(home_settings).and_return(false) + allow(Dir).to receive(:exist?).with('/etc/legionio/settings').and_return(true) + end + + it 'uses /etc/legionio/settings' do + described_class.ensure_settings + expect(Legion::Settings).to have_received(:load).with(config_dir: '/etc/legionio/settings') + end + end + end + + # --------------------------------------------------------------------------- + # log_level default and writer + # --------------------------------------------------------------------------- + describe '.log_level' do + it 'defaults to "error"' do + expect(described_class.log_level).to eq('error') + end + + it 'returns the assigned value after assignment' do + described_class.log_level = 'warn' + expect(described_class.log_level).to eq('warn') + end + end +end diff --git a/spec/legion/cli/cost/data_client_spec.rb b/spec/legion/cli/cost/data_client_spec.rb new file mode 100644 index 00000000..71a717ff --- /dev/null +++ b/spec/legion/cli/cost/data_client_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/cost/data_client' + +RSpec.describe Legion::CLI::CostData::Client do + let(:client) { described_class.new(base_url: 'http://localhost:4567') } + + describe '#summary' do + it 'returns default when api unavailable' do + allow(client).to receive(:fetch).and_return(nil) + result = client.summary + expect(result).to have_key(:today) + expect(result[:today]).to eq(0.0) + end + + it 'returns api data when available' do + data = { today: 5.25, week: 30.0, month: 120.0, workers: 3 } + allow(client).to receive(:fetch).and_return(data) + result = client.summary + expect(result[:today]).to eq(5.25) + end + end + + describe '#worker_cost' do + it 'returns empty hash when api unavailable' do + allow(client).to receive(:fetch).and_return(nil) + expect(client.worker_cost('w1')).to eq({}) + end + end + + describe '#top_consumers' do + it 'returns sorted list' do + allow(client).to receive(:fetch).with('/api/workers').and_return([ + { worker_id: 'w1' }, + { worker_id: 'w2' } + ]) + allow(client).to receive(:fetch).with('/api/workers/w1/value').and_return({ total_cost_usd: 10 }) + allow(client).to receive(:fetch).with('/api/workers/w2/value').and_return({ total_cost_usd: 20 }) + + result = client.top_consumers(limit: 2) + expect(result.first[:worker_id]).to eq('w2') + end + + it 'handles empty worker list' do + allow(client).to receive(:fetch).with('/api/workers').and_return([]) + expect(client.top_consumers).to eq([]) + end + end +end diff --git a/spec/legion/cli/cost_command_spec.rb b/spec/legion/cli/cost_command_spec.rb new file mode 100644 index 00000000..a2da7330 --- /dev/null +++ b/spec/legion/cli/cost_command_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/cost_command' +require 'legion/cli/cost/data_client' + +RSpec.describe Legion::CLI::Cost do + let(:mock_client) { instance_double(Legion::CLI::CostData::Client) } + + before do + allow(Legion::CLI::CostData::Client).to receive(:new).and_return(mock_client) + end + + describe '#summary' do + before do + allow(mock_client).to receive(:summary).and_return( + { today: 12.50, week: 87.30, month: 342.15, workers: 5 } + ) + end + + it 'shows cost summary header' do + expect { described_class.start(%w[summary]) }.to output(/Cost Summary/).to_stdout + end + + it 'shows today cost' do + expect { described_class.start(%w[summary]) }.to output(/\$12\.50/).to_stdout + end + + it 'shows week cost' do + expect { described_class.start(%w[summary]) }.to output(/\$87\.30/).to_stdout + end + + it 'shows month cost' do + expect { described_class.start(%w[summary]) }.to output(/\$342\.15/).to_stdout + end + + it 'shows worker count' do + expect { described_class.start(%w[summary]) }.to output(/5/).to_stdout + end + end + + describe '#worker' do + context 'with cost data' do + before do + allow(mock_client).to receive(:worker_cost).and_return( + { total_cost_usd: 45.00, total_tokens: 150_000, tasks_completed: 23 } + ) + end + + it 'shows worker header' do + expect { described_class.start(%w[worker w-001]) }.to output(/Worker: w-001/).to_stdout + end + + it 'shows cost fields' do + expect { described_class.start(%w[worker w-001]) }.to output(/total_cost_usd/).to_stdout + end + end + + context 'with no data' do + before do + allow(mock_client).to receive(:worker_cost).and_return({}) + end + + it 'shows no data message' do + expect { described_class.start(%w[worker w-001]) }.to output(/No cost data/).to_stdout + end + end + end + + describe '#top' do + context 'with consumers' do + before do + allow(mock_client).to receive(:top_consumers).and_return([ + { worker_id: 'w-alpha', cost: { total_cost_usd: 120.0 } }, + { worker_id: 'w-beta', cost: { total_cost_usd: 45.0 } } + ]) + end + + it 'shows header' do + expect { described_class.start(%w[top]) }.to output(/Top Cost Consumers/).to_stdout + end + + it 'shows ranked consumers' do + expect { described_class.start(%w[top]) }.to output(/1\..*w-alpha/).to_stdout + end + + it 'shows cost amounts' do + expect { described_class.start(%w[top]) }.to output(/\$120\.00/).to_stdout + end + end + + context 'with no data' do + before do + allow(mock_client).to receive(:top_consumers).and_return([]) + end + + it 'shows no data message' do + expect { described_class.start(%w[top]) }.to output(/No cost data/).to_stdout + end + end + end + + describe '#export' do + before do + allow(mock_client).to receive(:summary).and_return( + { today: 10.0, week: 50.0, month: 200.0, workers: 3 } + ) + end + + it 'outputs JSON by default' do + expect { described_class.start(%w[export]) }.to output(/today/).to_stdout + end + + it 'outputs CSV when requested' do + expect { described_class.start(%w[export --format csv]) }.to output(/period,today,week,month,workers/).to_stdout + end + + it 'includes data values in CSV' do + expect { described_class.start(%w[export --format csv]) }.to output(/month,10\.0,50\.0,200\.0,3/).to_stdout + end + end +end diff --git a/spec/legion/cli/dashboard/data_fetcher_spec.rb b/spec/legion/cli/dashboard/data_fetcher_spec.rb new file mode 100644 index 00000000..e52fbcd5 --- /dev/null +++ b/spec/legion/cli/dashboard/data_fetcher_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/dashboard/data_fetcher' + +RSpec.describe Legion::CLI::Dashboard::DataFetcher do + let(:fetcher) { described_class.new(base_url: 'http://localhost:4567') } + + describe '#summary' do + it 'returns hash with expected keys' do + allow(fetcher).to receive(:fetch).and_return([]) + result = fetcher.summary + expect(result.keys).to include(:workers, :health, :events, :fetched_at) + end + + it 'includes fetched_at timestamp' do + allow(fetcher).to receive(:fetch).and_return([]) + result = fetcher.summary + expect(result[:fetched_at]).to be_a(Time) + end + end +end diff --git a/spec/legion/cli/dashboard/renderer_spec.rb b/spec/legion/cli/dashboard/renderer_spec.rb new file mode 100644 index 00000000..4030bb79 --- /dev/null +++ b/spec/legion/cli/dashboard/renderer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/dashboard/renderer' + +RSpec.describe Legion::CLI::Dashboard::Renderer do + let(:renderer) { described_class.new(width: 60) } + + describe '#render' do + it 'includes header with worker count' do + output = renderer.render({ workers: [{ worker_id: 'w1', status: 'active' }], events: [], health: {}, fetched_at: Time.now }) + expect(output).to include('Workers: 1') + end + + it 'shows worker list' do + output = renderer.render({ workers: [{ worker_id: 'test-bot', status: 'running' }], events: [], health: {}, fetched_at: Time.now }) + expect(output).to include('test-bot') + end + + it 'handles empty data' do + output = renderer.render({ workers: [], events: [], health: {}, fetched_at: Time.now }) + expect(output).to include('(none)') + end + + it 'shows health components' do + output = renderer.render({ workers: [], events: [], health: { transport: 'ok', data: 'ok' }, fetched_at: Time.now }) + expect(output).to include('transport: ok') + end + end +end diff --git a/spec/legion/cli/dashboard_spec.rb b/spec/legion/cli/dashboard_spec.rb new file mode 100644 index 00000000..288302d7 --- /dev/null +++ b/spec/legion/cli/dashboard_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/dashboard/renderer' +require 'legion/cli/dashboard/data_fetcher' + +RSpec.describe Legion::CLI::Dashboard::Renderer do + subject(:renderer) { described_class.new(width: 60) } + + let(:full_data) do + { + workers: [ + { worker_id: 'w-alpha', status: 'active' }, + { worker_id: 'w-beta', status: 'paused' } + ], + events: [ + { timestamp: '2026-03-23T14:30:00Z', event_name: 'task.completed' }, + { timestamp: '2026-03-23T14:31:00Z', event_name: 'worker.started' } + ], + health: { transport: 'ok', data: 'ok', cache: 'degraded' }, + departments: [ + { name: 'Engineering', roles: [ + { name: 'Developer', workers: [{ name: 'w-alpha', status: 'active' }] } + ] } + ], + fetched_at: Time.new(2026, 3, 23, 14, 32, 0) + } + end + + describe '#render' do + it 'returns a string' do + output = renderer.render(full_data) + expect(output).to be_a(String) + end + + it 'includes header with worker count' do + output = renderer.render(full_data) + expect(output).to include('Workers: 2') + end + + it 'includes worker section' do + output = renderer.render(full_data) + expect(output).to include('w-alpha') + expect(output).to include('active') + end + + it 'includes events section' do + output = renderer.render(full_data) + expect(output).to include('task.completed') + end + + it 'includes health section' do + output = renderer.render(full_data) + expect(output).to include('transport: ok') + expect(output).to include('cache: degraded') + end + + it 'includes org chart section' do + output = renderer.render(full_data) + expect(output).to include('Engineering') + expect(output).to include('Developer') + end + + it 'includes footer with timestamp' do + output = renderer.render(full_data) + expect(output).to include('14:32:00') + end + + it 'handles empty data gracefully' do + output = renderer.render({}) + expect(output).to include('(none)') + expect(output).to include('(no departments)') + end + + it 'uses separator lines' do + output = renderer.render(full_data) + expect(output).to include('-' * 60) + end + end +end + +RSpec.describe Legion::CLI::Dashboard::DataFetcher do + subject(:fetcher) { described_class.new(base_url: 'http://localhost:9999') } + + let(:mock_response) do + r = instance_double(Net::HTTPOK) + allow(r).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + allow(r).to receive(:body).and_return(Legion::JSON.dump([{ id: 1 }])) + r + end + + describe '#workers' do + it 'fetches from /api/workers' do + allow(Net::HTTP).to receive(:get_response).and_return(mock_response) + result = fetcher.workers + expect(result).to be_an(Array) + end + + it 'returns empty array on failure' do + allow(Net::HTTP).to receive(:get_response).and_raise(Errno::ECONNREFUSED) + expect(fetcher.workers).to eq([]) + end + end + + describe '#health' do + it 'fetches from /api/health' do + allow(Net::HTTP).to receive(:get_response).and_return(mock_response) + result = fetcher.health + expect(result).not_to be_nil + end + + it 'returns empty hash on failure' do + allow(Net::HTTP).to receive(:get_response).and_raise(Errno::ECONNREFUSED) + expect(fetcher.health).to eq({}) + end + end + + describe '#recent_events' do + it 'returns empty array on failure' do + allow(Net::HTTP).to receive(:get_response).and_raise(Errno::ECONNREFUSED) + expect(fetcher.recent_events).to eq([]) + end + end + + describe '#summary' do + it 'aggregates workers, health, and events' do + allow(Net::HTTP).to receive(:get_response).and_raise(Errno::ECONNREFUSED) + result = fetcher.summary + expect(result).to have_key(:workers) + expect(result).to have_key(:health) + expect(result).to have_key(:events) + expect(result).to have_key(:fetched_at) + expect(result[:fetched_at]).to be_a(Time) + end + end +end diff --git a/spec/legion/cli/dataset_command_spec.rb b/spec/legion/cli/dataset_command_spec.rb new file mode 100644 index 00000000..b1895870 --- /dev/null +++ b/spec/legion/cli/dataset_command_spec.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/dataset_command' + +RSpec.describe Legion::CLI::Dataset do + let(:out) { instance_double(Legion::CLI::Output::Formatter) } + let(:client) { instance_double('Legion::Extensions::Dataset::Client') } + + before do + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(out).to receive(:success) + allow(out).to receive(:error) + allow(out).to receive(:warn) + allow(out).to receive(:json) + allow(out).to receive(:spacer) + allow(out).to receive(:detail) + allow(out).to receive(:header) + allow(out).to receive(:table) + + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + + stub_const('Legion::Extensions::Dataset::Client', Class.new do + def initialize(**); end + end) + allow(Legion::Extensions::Dataset::Client).to receive(:new).and_return(client) + + data_mod = Module.new { def self.db = nil } + stub_const('Legion::Data', data_mod) + end + + def build_command(opts = {}) + described_class.new([], { format: 'json' }.merge(opts).merge(json: false, no_color: true, verbose: false)) + end + + def build_json_command(opts = {}) + described_class.new([], { format: 'json' }.merge(opts).merge(json: true, no_color: true, verbose: false)) + end + + def stub_client(cmd) + allow(cmd).to receive(:with_dataset_client).and_yield(client) + end + + describe 'class structure' do + it 'is a Thor subcommand' do + expect(described_class).to be < Thor + end + + it 'has list as default task' do + expect(described_class.default_command).to eq('list') + end + + it 'responds to list, show, import, export' do + expect(described_class.commands.keys).to include('list', 'show', 'import', 'export') + end + end + + describe '#list' do + let(:datasets) do + [ + { name: 'qa-pairs', description: 'Q&A training data', latest_version: 3, row_count: 150 }, + { name: 'translations', description: 'Translation pairs', latest_version: 1, row_count: 42 } + ] + end + + before { allow(client).to receive(:list_datasets).and_return(datasets) } + + it 'renders a table of datasets' do + cmd = build_command + stub_client(cmd) + expect(out).to receive(:table).with(%w[name description version row_count], anything) + cmd.list + end + + it 'outputs JSON when --json is set' do + cmd = build_json_command + stub_client(cmd) + expect(out).to receive(:json).with(datasets) + cmd.list + end + + it 'warns when no datasets exist' do + allow(client).to receive(:list_datasets).and_return([]) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:warn).with('No datasets found') + cmd.list + end + end + + describe '#show' do + let(:dataset_result) do + { + name: 'qa-pairs', version: 2, version_id: 5, row_count: 3, + rows: [ + { row_index: 0, input: 'What is LegionIO?', expected_output: 'An async job engine' }, + { row_index: 1, input: 'How do tasks run?', expected_output: 'Via RabbitMQ' }, + { row_index: 2, input: 'What is a LEX?', expected_output: 'An extension gem' } + ] + } + end + + before { allow(client).to receive(:get_dataset).and_return(dataset_result) } + + it 'renders dataset header and rows table' do + cmd = build_command + stub_client(cmd) + expect(out).to receive(:header).with('Dataset: qa-pairs') + expect(out).to receive(:table).with(%w[index input expected_output], anything) + cmd.show('qa-pairs') + end + + it 'outputs JSON when --json is set' do + cmd = build_json_command + stub_client(cmd) + expect(out).to receive(:json).with(dataset_result) + cmd.show('qa-pairs') + end + + it 'shows error when dataset not found' do + allow(client).to receive(:get_dataset).and_return({ error: 'not_found' }) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:error).with(/not_found/) + expect { cmd.show('missing') }.to raise_error(SystemExit) + end + + it 'passes version option to get_dataset' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, version: 1) + stub_client(cmd) + expect(client).to receive(:get_dataset).with(name: 'qa-pairs', version: 1).and_return(dataset_result) + cmd.show('qa-pairs') + end + + it 'warns about more rows when dataset has more than 10' do + large_result = dataset_result.merge( + row_count: 15, + rows: Array.new(15) { |i| { row_index: i, input: "q#{i}", expected_output: "a#{i}" } } + ) + allow(client).to receive(:get_dataset).and_return(large_result) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:warn).with(/5 more rows/) + cmd.show('qa-pairs') + end + + it 'warns when dataset has no rows' do + empty_result = dataset_result.merge(row_count: 0, rows: []) + allow(client).to receive(:get_dataset).and_return(empty_result) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:warn).with('No rows in this dataset version') + cmd.show('qa-pairs') + end + end + + describe '#import' do + let(:import_result) { { created: true, name: 'qa-pairs', version: 1, row_count: 5 } } + + before do + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with('/tmp/data.json').and_return(true) + allow(client).to receive(:import_dataset).and_return(import_result) + end + + it 'calls import_dataset with name, path, and format' do + cmd = build_command + stub_client(cmd) + expect(client).to receive(:import_dataset).with( + name: 'qa-pairs', path: '/tmp/data.json', format: 'json', description: nil + ).and_return(import_result) + cmd.import('qa-pairs', '/tmp/data.json') + end + + it 'outputs success message after import' do + cmd = build_command + stub_client(cmd) + expect(out).to receive(:success).with(/qa-pairs.*v1.*5 rows/i) + cmd.import('qa-pairs', '/tmp/data.json') + end + + it 'outputs JSON when --json is set' do + cmd = build_json_command + stub_client(cmd) + expect(out).to receive(:json).with(import_result) + cmd.import('qa-pairs', '/tmp/data.json') + end + + it 'shows error when file does not exist' do + allow(File).to receive(:exist?).with('/tmp/missing.csv').and_return(false) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:error).with(/not found/) + expect { cmd.import('qa-pairs', '/tmp/missing.csv') }.to raise_error(SystemExit) + end + + it 'passes description option to import_dataset' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'json', description: 'Training set') + stub_client(cmd) + expect(client).to receive(:import_dataset).with( + name: 'qa-pairs', path: '/tmp/data.json', format: 'json', description: 'Training set' + ).and_return(import_result) + cmd.import('qa-pairs', '/tmp/data.json') + end + end + + describe '#export' do + let(:export_result) { { exported: true, path: '/tmp/out.json', row_count: 5 } } + + before { allow(client).to receive(:export_dataset).and_return(export_result) } + + it 'calls export_dataset with name, path, and format' do + cmd = build_command + stub_client(cmd) + expect(client).to receive(:export_dataset).with( + name: 'qa-pairs', path: '/tmp/out.json', format: 'json' + ).and_return(export_result) + cmd.export('qa-pairs', '/tmp/out.json') + end + + it 'outputs success message after export' do + cmd = build_command + stub_client(cmd) + expect(out).to receive(:success).with(%r{5 rows.*/tmp/out\.json}i) + cmd.export('qa-pairs', '/tmp/out.json') + end + + it 'outputs JSON when --json is set' do + cmd = build_json_command + stub_client(cmd) + expect(out).to receive(:json).with(export_result) + cmd.export('qa-pairs', '/tmp/out.json') + end + + it 'passes version option to export_dataset' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, format: 'json', version: 2) + stub_client(cmd) + expect(client).to receive(:export_dataset).with( + name: 'qa-pairs', path: '/tmp/out.json', format: 'json', version: 2 + ).and_return(export_result) + cmd.export('qa-pairs', '/tmp/out.json') + end + + it 'passes csv format option to export_dataset' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, format: 'csv') + stub_client(cmd) + expect(client).to receive(:export_dataset).with( + name: 'qa-pairs', path: '/tmp/out.csv', format: 'csv' + ).and_return(export_result) + cmd.export('qa-pairs', '/tmp/out.csv') + end + end +end diff --git a/spec/legion/cli/detect_command_spec.rb b/spec/legion/cli/detect_command_spec.rb new file mode 100644 index 00000000..64b995b0 --- /dev/null +++ b/spec/legion/cli/detect_command_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/detect_command' + +RSpec.describe Legion::CLI::Detect do + let(:scan_results) do + [ + { + name: 'Claude', + extensions: ['lex-claude'], + matched_signals: ['app:Claude.app', 'brew_cask:claude'], + installed: { 'lex-claude' => true } + }, + { + name: 'Slack', + extensions: ['lex-slack'], + matched_signals: ['app:Slack.app'], + installed: { 'lex-slack' => false } + }, + { + name: 'Redis', + extensions: %w[lex-redis legion-cache], + matched_signals: ['brew_formula:redis'], + installed: { 'lex-redis' => false, 'legion-cache' => true } + } + ] + end + + let(:catalog) do + [ + { name: 'Claude', extensions: ['lex-claude'], signals: [{ type: :app, match: 'Claude.app' }] }, + { name: 'Slack', extensions: ['lex-slack'], signals: [{ type: :app, match: 'Slack.app' }] } + ] + end + + before do + installer_mod = Module.new do + def self.install(gem_names, dry_run: false) + return { installed: gem_names, failed: [] } if dry_run + + { installed: gem_names, failed: [] } + end + end + + detect_mod = Module.new do + def self.scan; end + def self.missing; end + def self.catalog; end + def self.install_missing!(**); end + end + stub_const('Legion::Extensions::Detect', detect_mod) + stub_const('Legion::Extensions::Detect::Installer', installer_mod) + allow(Legion::Extensions::Detect).to receive(:scan).and_return(scan_results) + allow(Legion::Extensions::Detect).to receive(:missing).and_return(%w[lex-slack lex-redis]) + allow(Legion::Extensions::Detect).to receive(:catalog).and_return(catalog) + allow(Legion::Extensions::Detect).to receive(:install_missing!) + .and_return({ installed: %w[lex-slack lex-redis], failed: [] }) + allow(Legion::Extensions::Detect::Installer).to receive(:install) + .and_return({ installed: %w[lex-slack lex-redis], failed: [] }) + + allow_any_instance_of(described_class).to receive(:require_detect_gem) + end + + describe 'scan' do + it 'displays detection results' do + output = capture_stdout { described_class.start(%w[scan --no-color]) } + expect(output).to include('Claude') + expect(output).to include('Slack') + expect(output).to include('installed') + expect(output).to include('missing') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[scan --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:detections]).to be_an(Array) + expect(parsed[:detections].size).to eq(3) + end + + it 'launches interactive install when --install is passed' do + allow_any_instance_of(described_class).to receive(:tty_prompt_available?).and_return(false) + allow($stdin).to receive(:gets).and_return("all\n") + capture_stdout { described_class.start(%w[scan --install --no-color]) } + expect(Legion::Extensions::Detect::Installer).to have_received(:install).with(%w[lex-slack lex-redis]) + end + + it 'installs all without prompting when --install-all is passed' do + capture_stdout { described_class.start(%w[scan --install-all --no-color]) } + expect(Legion::Extensions::Detect).to have_received(:install_missing!) + end + end + + describe 'interactive install' do + before do + allow_any_instance_of(described_class).to receive(:tty_prompt_available?).and_return(false) + end + + it 'shows numbered list and installs selected gems' do + allow($stdin).to receive(:gets).and_return("1\n") + output = capture_stdout { described_class.start(%w[scan --install --no-color]) } + expect(output).to include('lex-slack') + expect(Legion::Extensions::Detect::Installer).to have_received(:install).with(%w[lex-slack]) + end + + it 'installs all when user types "all"' do + allow($stdin).to receive(:gets).and_return("all\n") + capture_stdout { described_class.start(%w[scan --install --no-color]) } + expect(Legion::Extensions::Detect::Installer).to have_received(:install).with(%w[lex-slack lex-redis]) + end + + it 'installs none when user types "none"' do + allow($stdin).to receive(:gets).and_return("none\n") + output = capture_stdout { described_class.start(%w[scan --install --no-color]) } + expect(output).to include('No extensions selected') + end + + it 'handles comma-separated selection' do + allow($stdin).to receive(:gets).and_return("1,2\n") + capture_stdout { described_class.start(%w[scan --install --no-color]) } + expect(Legion::Extensions::Detect::Installer).to have_received(:install).with(%w[lex-slack lex-redis]) + end + + it 'shows dry run when --dry-run is passed' do + allow($stdin).to receive(:gets).and_return("1\n") + output = capture_stdout { described_class.start(%w[scan --install --dry-run --no-color]) } + expect(output).to include('Would install') + expect(output).to include('lex-slack') + expect(Legion::Extensions::Detect::Installer).not_to have_received(:install) + end + + it 'shows success when nothing is missing' do + allow(Legion::Extensions::Detect).to receive(:missing).and_return([]) + output = capture_stdout { described_class.start(%w[scan --install --no-color]) } + expect(output).to include('All detected extensions are installed') + end + + it 'includes signal info in the numbered list' do + allow($stdin).to receive(:gets).and_return("none\n") + output = capture_stdout { described_class.start(%w[scan --install --no-color]) } + expect(output).to include('app:Slack.app') + expect(output).to include('brew_formula:redis') + end + end + + describe 'catalog' do + it 'displays the catalog' do + output = capture_stdout { described_class.start(%w[catalog --no-color]) } + expect(output).to include('Claude') + expect(output).to include('Slack') + expect(output).to include('Detection Catalog') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[catalog --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:catalog]).to be_an(Array) + end + end + + describe 'missing' do + it 'lists missing extensions' do + output = capture_stdout { described_class.start(%w[missing --no-color]) } + expect(output).to include('lex-slack') + expect(output).to include('lex-redis') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[missing --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:missing]).to eq(%w[lex-slack lex-redis]) + end + + it 'shows success when nothing is missing' do + allow(Legion::Extensions::Detect).to receive(:missing).and_return([]) + output = capture_stdout { described_class.start(%w[missing --no-color]) } + expect(output).to include('All detected extensions are installed') + end + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end diff --git a/spec/legion/cli/do_command_spec.rb b/spec/legion/cli/do_command_spec.rb new file mode 100644 index 00000000..02787c9c --- /dev/null +++ b/spec/legion/cli/do_command_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/output' +require 'legion/cli/do_command' + +RSpec.describe Legion::CLI::DoCommand do + let(:formatter) { instance_double(Legion::CLI::Output::Formatter) } + let(:options) { { json: false, no_color: false } } + + before do + allow(formatter).to receive(:detail) + allow(formatter).to receive(:success) + allow(formatter).to receive(:error) + allow(formatter).to receive(:json) + end + + describe '.run' do + context 'with empty intent' do + it 'shows usage error' do + allow(formatter).to receive(:error) + expect { described_class.run('', formatter, options) }.to raise_error(SystemExit) + expect(formatter).to have_received(:error).with(/Usage/) + end + end + + context 'with whitespace-only intent' do + it 'shows usage error' do + expect { described_class.run(' ', formatter, options) }.to raise_error(SystemExit) + end + end + + context 'when no daemon and no registry matches' do + it 'shows no matching tool error' do + Legion::Tools::Registry.clear + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + expect { described_class.run('nonexistent thing', formatter, options) }.to raise_error(SystemExit) + expect(formatter).to have_received(:error).with(/No matching tool/) + end + end + + context 'when LLM fallback classifies intent' do + let(:tool_class) do + Class.new(Legion::Tools::Base) do + tool_name 'legion.consul.health_check.run' + description 'Check consul cluster health' + extension 'consul' + runner 'health_check' + + class << self + def call(**_args) + text_response({ status: 'healthy' }) + end + end + end + end + + before do + Legion::Tools::Registry.clear + Legion::Tools::Registry.register(tool_class) + end + + after { Legion::Tools::Registry.clear } + + it 'routes via LLM when keyword matching fails' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + + llm_mod = Module.new do + def self.ask(**) + { response: 'legion.consul.health_check.run' } + end + end + stub_const('Legion::LLM', llm_mod) + + described_class.run('is consul ok', formatter, options) + expect(formatter).to have_received(:success).with(/Matched/) + end + + it 'falls through when LLM returns NONE' do + Legion::Tools::Registry.clear + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + + llm_mod = Module.new do + def self.ask(**) + { response: 'NONE' } + end + end + stub_const('Legion::LLM', llm_mod) + + expect { described_class.run('completely unrelated', formatter, options) }.to raise_error(SystemExit) + expect(formatter).to have_received(:error).with(/No matching tool/) + end + end + + context 'when registry has a match' do + let(:tool_class) do + Class.new(Legion::Tools::Base) do + tool_name 'legion.consul.health_check.run' + description 'check consul health' + + class << self + def call(**_args) + text_response({ status: 'healthy' }) + end + end + end + end + + before do + Legion::Tools::Registry.clear + Legion::Tools::Registry.register(tool_class) + end + + after { Legion::Tools::Registry.clear } + + it 'returns matched result' do + allow(Net::HTTP).to receive(:new).and_raise(Errno::ECONNREFUSED) + + described_class.run('check consul health', formatter, options) + expect(formatter).to have_received(:success).with(/Matched/) + end + end + end + + describe '.build_runner_class (via private method)' do + it 'builds correct runner class string' do + result = described_class.send(:build_runner_class, 'lex-consul', 'health_check') + expect(result).to eq('Legion::Extensions::Consul::Runners::HealthCheck') + end + + it 'handles multi-word extension names' do + result = described_class.send(:build_runner_class, 'lex-microsoft-teams', 'message_sender') + expect(result).to eq('Legion::Extensions::MicrosoftTeams::Runners::MessageSender') + end + end +end diff --git a/spec/legion/cli/docs_command_spec.rb b/spec/legion/cli/docs_command_spec.rb new file mode 100644 index 00000000..6622e1c7 --- /dev/null +++ b/spec/legion/cli/docs_command_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/docs_command' +require 'legion/docs/site_generator' + +RSpec.describe Legion::CLI::Docs do + let(:out) { instance_double(Legion::CLI::Output::Formatter) } + + before do + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(out).to receive(:header) + allow(out).to receive(:success) + allow(out).to receive(:warn) + allow(out).to receive(:json) + allow(out).to receive(:colorize) { |text, _color| text } + allow(out).to receive(:error) + end + + def build_command(subcommand_class, argv = [], opts = {}) + subcommand_class.new(argv, { json: false, no_color: true }.merge(opts)) + end + + # --------------------------------------------------------------------------- + # generate subcommand + # --------------------------------------------------------------------------- + + describe '#generate' do + let(:tmpdir) { Dir.mktmpdir } + let(:fake_stats) do + { output: tmpdir, sections: 5, pages: 7, files: [] } + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'calls SiteGenerator.new with the --output option' do + gen = instance_double(Legion::Docs::SiteGenerator, generate: fake_stats) + expect(Legion::Docs::SiteGenerator).to receive(:new).with(output_dir: tmpdir).and_return(gen) + + cmd = build_command(described_class, [], output: tmpdir) + cmd.generate + end + + it 'calls generate on the SiteGenerator instance' do + gen = instance_double(Legion::Docs::SiteGenerator) + allow(Legion::Docs::SiteGenerator).to receive(:new).and_return(gen) + expect(gen).to receive(:generate).and_return(fake_stats) + + cmd = build_command(described_class, [], output: tmpdir) + cmd.generate + end + + it 'outputs success message with the output directory' do + gen = instance_double(Legion::Docs::SiteGenerator, generate: fake_stats) + allow(Legion::Docs::SiteGenerator).to receive(:new).and_return(gen) + + expect(out).to receive(:success).with(a_string_including(tmpdir)) + + cmd = build_command(described_class, [], output: tmpdir) + cmd.generate + end + + it 'uses default output directory (docs/site) when --output not given' do + default_stats = { output: 'docs/site', sections: 5, pages: 7, files: [] } + gen = instance_double(Legion::Docs::SiteGenerator, generate: default_stats) + expect(Legion::Docs::SiteGenerator).to receive(:new).with(output_dir: 'docs/site').and_return(gen) + + cmd = build_command(described_class, [], output: 'docs/site') + cmd.generate + end + + context 'when --json is set' do + it 'outputs stats as JSON' do + gen = instance_double(Legion::Docs::SiteGenerator, generate: fake_stats) + allow(Legion::Docs::SiteGenerator).to receive(:new).and_return(gen) + expect(out).to receive(:json).with(fake_stats) + + cmd = build_command(described_class, [], output: tmpdir, json: true) + cmd.generate + end + end + end + + # --------------------------------------------------------------------------- + # serve subcommand + # --------------------------------------------------------------------------- + + describe '#serve' do + let(:tmpdir) { Dir.mktmpdir } + + after { FileUtils.rm_rf(tmpdir) } + + it 'prints preview instructions when directory exists' do + cmd = build_command(described_class, [], port: 4000, dir: tmpdir) + output_lines = [] + allow($stdout).to receive(:puts) { |line| output_lines << line.to_s } + + cmd.serve + + combined = output_lines.join(' ') + expect(combined).to include('4000').or include('http') + end + + it 'uses default port 4000' do + cmd = build_command(described_class, [], dir: tmpdir) + # Just ensure it runs without error when dir exists + allow($stdout).to receive(:puts) + expect { cmd.serve }.not_to raise_error + end + + it 'warns when the directory does not exist' do + nonexistent = File.join(tmpdir, 'missing_dir') + expect(out).to receive(:warn).with(a_string_including('missing_dir')) + + cmd = build_command(described_class, [], dir: nonexistent, port: 4000) + cmd.serve + end + + it 'includes python3 http.server command in output' do + cmd = build_command(described_class, [], port: 4001, dir: tmpdir) + output_lines = [] + allow($stdout).to receive(:puts) { |line| output_lines << line.to_s } + + cmd.serve + + combined = output_lines.join(' ') + expect(combined).to include('python3').or include('http.server') + end + end + + # --------------------------------------------------------------------------- + # namespace + # --------------------------------------------------------------------------- + + describe 'namespace' do + it 'is registered as :docs' do + expect(described_class.namespace).to eq('docs') + end + end +end diff --git a/spec/legion/cli/doctor/bundle_check_spec.rb b/spec/legion/cli/doctor/bundle_check_spec.rb new file mode 100644 index 00000000..1e62df08 --- /dev/null +++ b/spec/legion/cli/doctor/bundle_check_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/doctor_command' +require 'legion/cli/doctor/bundle_check' + +RSpec.describe Legion::CLI::Doctor::BundleCheck do + subject(:check) { described_class.new } + + describe '#name' do + it 'returns a human-readable name' do + expect(check.name).to eq('Bundle status') + end + end + + describe '#run' do + context 'when bundle check succeeds' do + before do + allow(Open3).to receive(:capture3).with('bundle check').and_return( + ['The Gemfile dependencies are satisfied', '', double(success?: true)] + ) + allow(check).to receive(:find_gemfile).and_return('/path/to/Gemfile') + end + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + end + + context 'when gems are missing' do + before do + allow(Open3).to receive(:capture3).with('bundle check').and_return( + ['', 'The following gems are missing', double(success?: false)] + ) + allow(check).to receive(:find_gemfile).and_return('/path/to/Gemfile') + end + + it 'returns a fail result' do + result = check.run + expect(result.status).to eq(:fail) + end + + it 'prescribes running bundle install' do + result = check.run + expect(result.prescription).to eq('Run `bundle install`') + end + + it 'is auto-fixable' do + result = check.run + expect(result.auto_fixable).to be true + end + end + + context 'when no Gemfile is found' do + before do + allow(check).to receive(:find_gemfile).and_return(nil) + end + + it 'returns a skip result' do + result = check.run + expect(result.status).to eq(:skip) + end + end + end +end diff --git a/spec/legion/cli/doctor/config_check_spec.rb b/spec/legion/cli/doctor/config_check_spec.rb new file mode 100644 index 00000000..cc47bf3c --- /dev/null +++ b/spec/legion/cli/doctor/config_check_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/doctor_command' +require 'legion/cli/doctor/config_check' +require 'tmpdir' +require 'json' + +RSpec.describe Legion::CLI::Doctor::ConfigCheck do + subject(:check) { described_class.new } + + describe '#name' do + it 'returns a human-readable name' do + expect(check.name).to eq('Config files') + end + end + + describe '#run' do + context 'when no config directory exists' do + before do + stub_const('Legion::CLI::Doctor::ConfigCheck::CONFIG_PATHS', ['/nonexistent/legionio/path']) + end + + it 'returns a warn result' do + result = check.run + expect(result.status).to eq(:warn) + end + + it 'suggests running config scaffold' do + result = check.run + expect(result.prescription).to include('legion config scaffold') + end + + it 'is auto-fixable' do + result = check.run + expect(result.auto_fixable).to be true + end + end + + context 'when config directory exists with valid JSON' do + let(:tmpdir) { Dir.mktmpdir } + + before do + File.write("#{tmpdir}/transport.json", JSON.generate(host: 'localhost', port: 5672)) + stub_const('Legion::CLI::Doctor::ConfigCheck::CONFIG_PATHS', [tmpdir]) + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + + it 'mentions the config directory' do + result = check.run + expect(result.message).to include(tmpdir) + end + end + + context 'when config directory has invalid JSON' do + let(:tmpdir) { Dir.mktmpdir } + + before do + File.write("#{tmpdir}/transport.json", '{invalid json}') + stub_const('Legion::CLI::Doctor::ConfigCheck::CONFIG_PATHS', [tmpdir]) + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'returns a fail result' do + result = check.run + expect(result.status).to eq(:fail) + end + + it 'mentions the file with bad JSON' do + result = check.run + expect(result.message).to include('transport.json') + end + + it 'provides a prescription to fix the JSON' do + result = check.run + expect(result.prescription).to include('Fix JSON syntax error') + end + end + end +end diff --git a/spec/legion/cli/doctor/permissions_check_spec.rb b/spec/legion/cli/doctor/permissions_check_spec.rb new file mode 100644 index 00000000..9412ee2b --- /dev/null +++ b/spec/legion/cli/doctor/permissions_check_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/doctor_command' +require 'legion/cli/doctor/permissions_check' + +RSpec.describe Legion::CLI::Doctor::PermissionsCheck do + subject(:check) { described_class.new } + + describe '#name' do + it 'returns a human-readable name' do + expect(check.name).to eq('Permissions') + end + end + + describe '#run' do + context 'when all directories are writable' do + before do + stub_const('Legion::CLI::Doctor::PermissionsCheck::DIRECTORIES', ['/tmp']) + allow(Dir).to receive(:exist?).with('/tmp').and_return(true) + allow(File).to receive(:writable?).with('/tmp').and_return(true) + end + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + end + + context 'when a directory is not writable' do + before do + stub_const('Legion::CLI::Doctor::PermissionsCheck::DIRECTORIES', ['/tmp/unwritable_test']) + allow(Dir).to receive(:exist?).with('/tmp/unwritable_test').and_return(true) + allow(File).to receive(:writable?).with('/tmp/unwritable_test').and_return(false) + end + + it 'returns a warn result' do + result = check.run + expect(result.status).to eq(:warn) + end + + it 'mentions the unwritable directory' do + result = check.run + expect(result.message).to include('/tmp/unwritable_test') + end + + it 'prescribes chmod' do + result = check.run + expect(result.prescription).to include('chmod 755') + end + end + + context 'when a directory does not exist' do + before do + stub_const('Legion::CLI::Doctor::PermissionsCheck::DIRECTORIES', ['/nonexistent/dir']) + allow(Dir).to receive(:exist?).with('/nonexistent/dir').and_return(false) + end + + it 'returns a pass result (non-existent dirs are skipped)' do + result = check.run + expect(result.status).to eq(:pass) + end + end + end +end diff --git a/spec/legion/cli/doctor/pid_check_spec.rb b/spec/legion/cli/doctor/pid_check_spec.rb new file mode 100644 index 00000000..3c1fe379 --- /dev/null +++ b/spec/legion/cli/doctor/pid_check_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/doctor_command' +require 'legion/cli/doctor/pid_check' +require 'tmpdir' + +RSpec.describe Legion::CLI::Doctor::PidCheck do + subject(:check) { described_class.new } + + describe '#name' do + it 'returns a human-readable name' do + expect(check.name).to eq('PID files') + end + end + + describe '#run' do + context 'when no PID files exist' do + before do + stub_const('Legion::CLI::Doctor::PidCheck::PID_PATHS', ['/nonexistent/legion.pid']) + end + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + + it 'reports no stale PID files' do + result = check.run + expect(result.message).to include('No stale') + end + end + + context 'when a PID file exists with a dead process' do + let(:tmpdir) { Dir.mktmpdir } + let(:pid_file) { "#{tmpdir}/legion.pid" } + + before do + File.write(pid_file, '999999') + stub_const('Legion::CLI::Doctor::PidCheck::PID_PATHS', [pid_file]) + allow(Process).to receive(:kill).with(0, 999_999).and_raise(Errno::ESRCH) + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'returns a warn result' do + result = check.run + expect(result.status).to eq(:warn) + end + + it 'mentions the stale PID file' do + result = check.run + expect(result.message).to include(pid_file) + end + + it 'prescribes removing the file' do + result = check.run + expect(result.prescription).to include("rm #{pid_file}") + end + + it 'is auto-fixable' do + result = check.run + expect(result.auto_fixable).to be true + end + end + + context 'when a PID file exists with a running process' do + let(:tmpdir) { Dir.mktmpdir } + let(:pid_file) { "#{tmpdir}/legion.pid" } + + before do + File.write(pid_file, Process.pid.to_s) + stub_const('Legion::CLI::Doctor::PidCheck::PID_PATHS', [pid_file]) + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + end + end + + describe '#fix' do + let(:tmpdir) { Dir.mktmpdir } + let(:pid_file) { "#{tmpdir}/legion.pid" } + + before do + File.write(pid_file, '999999') + stub_const('Legion::CLI::Doctor::PidCheck::PID_PATHS', [pid_file]) + allow(Process).to receive(:kill).with(0, 999_999).and_raise(Errno::ESRCH) + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'removes stale PID files' do + check.fix + expect(File.exist?(pid_file)).to be false + end + end +end diff --git a/spec/legion/cli/doctor/python_env_check_spec.rb b/spec/legion/cli/doctor/python_env_check_spec.rb new file mode 100644 index 00000000..962ac8e0 --- /dev/null +++ b/spec/legion/cli/doctor/python_env_check_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' +require 'legion/python' +require 'legion/cli/doctor_command' + +RSpec.describe Legion::CLI::Doctor::PythonEnvCheck do + subject(:check) { described_class.new } + + describe '#name' do + it 'returns Python env' do + expect(check.name).to eq('Python env') + end + end + + describe '#run' do + context 'when python3 is not available' do + before do + allow(Legion::Python).to receive(:find_system_python3).and_return(nil) + end + + it 'returns a skip result' do + result = check.run + expect(result.status).to eq(:skip) + expect(result.message).to include('python3 not found') + end + end + + context 'when python3 exists but venv is missing' do + before do + allow(Legion::Python).to receive(:find_system_python3).and_return('/usr/bin/python3') + allow(Legion::Python).to receive(:venv_exists?).and_return(false) + end + + it 'returns a warn result' do + result = check.run + expect(result.status).to eq(:warn) + expect(result.message).to include('venv missing') + end + end + + context 'when venv exists but pip is missing' do + before do + allow(Legion::Python).to receive(:find_system_python3).and_return('/usr/bin/python3') + allow(Legion::Python).to receive(:venv_exists?).and_return(true) + allow(Legion::Python).to receive(:venv_pip_exists?).and_return(false) + end + + it 'returns a warn result about corrupt venv' do + result = check.run + expect(result.status).to eq(:warn) + expect(result.message).to include('pip not found') + end + end + + context 'when venv is healthy with all packages' do + let(:pip_json) do + Legion::Python::PACKAGES.map { |p| { 'name' => p, 'version' => '1.0.0' } } + end + + before do + allow(Legion::Python).to receive(:find_system_python3).and_return('/usr/bin/python3') + allow(Legion::Python).to receive(:venv_exists?).and_return(true) + allow(Legion::Python).to receive(:venv_pip_exists?).and_return(true) + allow(Legion::Python).to receive(:venv_pip).and_return('/fake/pip') + allow(Legion::Python).to receive(:venv_python).and_return('/fake/python3') + allow(File).to receive(:executable?).and_call_original + allow(File).to receive(:executable?).with('/fake/python3').and_return(true) + + mock_status = instance_double(::Process::Status, success?: true) + allow(Open3).to receive(:capture2e).and_return([::JSON.generate(pip_json), mock_status]) + allow(check).to receive(:`).with(/".*python3" --version/).and_return('Python 3.12.0') + end + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + end + + context 'when packages are missing' do + let(:pip_json) { [{ 'name' => 'pandas', 'version' => '2.0.0' }] } + + before do + allow(Legion::Python).to receive(:find_system_python3).and_return('/usr/bin/python3') + allow(Legion::Python).to receive(:venv_exists?).and_return(true) + allow(Legion::Python).to receive(:venv_pip_exists?).and_return(true) + allow(Legion::Python).to receive(:venv_python_exists?).and_return(true) + allow(Legion::Python).to receive(:venv_pip).and_return('/fake/pip') + + mock_status = instance_double(::Process::Status, success?: true) + allow(Open3).to receive(:capture2e).and_return([::JSON.generate(pip_json), mock_status]) + end + + it 'returns a warn result listing missing packages' do + result = check.run + expect(result.status).to eq(:warn) + expect(result.message).to include('Missing packages') + expect(result.message).to include('python-pptx') + end + end + + context 'when an unexpected error occurs' do + before do + allow(Legion::Python).to receive(:find_system_python3).and_raise(RuntimeError, 'boom') + end + + it 'returns a fail result' do + result = check.run + expect(result.status).to eq(:fail) + expect(result.message).to include('boom') + end + end + end + + describe '#fix' do + it 'calls legionio setup python --rebuild' do + allow(check).to receive(:system).with('legionio', 'setup', 'python', '--rebuild').and_return(true) + check.fix + expect(check).to have_received(:system).with('legionio', 'setup', 'python', '--rebuild') + end + end +end diff --git a/spec/legion/cli/doctor/rabbitmq_check_spec.rb b/spec/legion/cli/doctor/rabbitmq_check_spec.rb new file mode 100644 index 00000000..20b9c324 --- /dev/null +++ b/spec/legion/cli/doctor/rabbitmq_check_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/doctor_command' +require 'legion/cli/doctor/rabbitmq_check' + +RSpec.describe Legion::CLI::Doctor::RabbitmqCheck do + subject(:check) { described_class.new } + + describe '#name' do + it 'returns a human-readable name' do + expect(check.name).to eq('RabbitMQ connection') + end + end + + describe '#run' do + context 'when RabbitMQ is reachable' do + before do + allow(Socket).to receive(:tcp).and_yield(double('socket', close: nil)) + end + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + + it 'mentions the host and port' do + result = check.run + expect(result.message).to include('localhost') + expect(result.message).to include('5672') + end + end + + context 'when RabbitMQ connection is refused' do + before do + allow(Socket).to receive(:tcp).and_raise(Errno::ECONNREFUSED) + end + + it 'returns a fail result' do + result = check.run + expect(result.status).to eq(:fail) + end + + it 'provides a prescription to start RabbitMQ' do + result = check.run + expect(result.prescription).to include('rabbitmq') + end + end + + context 'when connection times out' do + before do + allow(Socket).to receive(:tcp).and_raise(Errno::ETIMEDOUT) + end + + it 'returns a fail result' do + result = check.run + expect(result.status).to eq(:fail) + end + end + + context 'when SocketError is raised' do + before do + allow(Socket).to receive(:tcp).and_raise(SocketError, 'getaddrinfo: nodename nor servname provided') + end + + it 'returns a fail result' do + result = check.run + expect(result.status).to eq(:fail) + end + end + end +end diff --git a/spec/legion/cli/doctor/ruby_version_check_spec.rb b/spec/legion/cli/doctor/ruby_version_check_spec.rb new file mode 100644 index 00000000..e66868e4 --- /dev/null +++ b/spec/legion/cli/doctor/ruby_version_check_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/doctor_command' +require 'legion/cli/doctor/ruby_version_check' + +RSpec.describe Legion::CLI::Doctor::RubyVersionCheck do + subject(:check) { described_class.new } + + describe '#name' do + it 'returns a human-readable name' do + expect(check.name).to eq('Ruby version') + end + end + + describe '#run' do + context 'when Ruby version meets the minimum' do + before do + stub_const('RUBY_VERSION', '3.4.0') + end + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + + it 'includes the current version in message' do + result = check.run + expect(result.message).to include('3.4.0') + end + end + + context 'when Ruby version is exactly the minimum' do + before do + stub_const('RUBY_VERSION', '3.4.0') + end + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + end + + context 'when Ruby version is below minimum' do + before do + stub_const('RUBY_VERSION', '3.2.0') + end + + it 'returns a fail result' do + result = check.run + expect(result.status).to eq(:fail) + end + + it 'includes the current version in message' do + result = check.run + expect(result.message).to include('3.2.0') + end + + it 'provides an upgrade prescription' do + result = check.run + expect(result.prescription).to include('Upgrade Ruby') + expect(result.prescription).to include('3.4') + end + + it 'is not auto-fixable' do + result = check.run + expect(result.auto_fixable).to be false + end + end + end +end diff --git a/spec/legion/cli/doctor/tls_check_spec.rb b/spec/legion/cli/doctor/tls_check_spec.rb new file mode 100644 index 00000000..d181f63b --- /dev/null +++ b/spec/legion/cli/doctor/tls_check_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/doctor/result' +require 'legion/cli/doctor/tls_check' + +RSpec.describe Legion::CLI::Doctor::TlsCheck do + subject(:check) { described_class.new } + + describe '#name' do + it 'returns TLS' do + expect(check.name).to eq('TLS') + end + end + + describe '#run' do + context 'when Legion::Settings is not defined' do + before { hide_const('Legion::Settings') } + + it 'returns skip' do + result = check.run + expect(result.status).to eq(:skip) + end + end + + context 'when all TLS is disabled' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:api).and_return({ tls: { enabled: false } }) + end + + it 'returns pass with a note that TLS is not enabled' do + result = check.run + expect(result.status).to eq(:pass) + expect(result.message).to match(/not enabled/i) + end + end + + context 'when transport TLS is enabled with verify peer' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return( + { tls: { enabled: true, verify: 'peer', ca: nil, cert: nil, key: nil } } + ) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:api).and_return({ tls: { enabled: false } }) + end + + it 'returns pass' do + result = check.run + expect(result.status).to eq(:pass) + end + end + + context 'when transport TLS is enabled with verify none' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return( + { tls: { enabled: true, verify: 'none' } } + ) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:api).and_return({ tls: { enabled: false } }) + end + + it 'returns warn' do + result = check.run + expect(result.status).to eq(:warn) + expect(result.message).to match(/verify.*none/i) + end + end + + context 'when database TLS is enabled but sslmode is require in production' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:data).and_return( + { tls: { enabled: true, sslmode: 'require' }, adapter: 'postgres' } + ) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:api).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:dig).with(:env).and_return('production') + end + + it 'returns warn' do + result = check.run + expect(result.status).to eq(:warn) + expect(result.message).to match(/sslmode/i) + end + end + + context 'when database TLS is enabled with verify-full' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:data).and_return( + { tls: { enabled: true, sslmode: 'verify-full' }, adapter: 'postgres' } + ) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:api).and_return({ tls: { enabled: false } }) + end + + it 'returns pass' do + result = check.run + expect(result.status).to eq(:pass) + end + end + + context 'when a cert file does not exist' do + let(:missing_cert) { '/nonexistent/server.crt' } + + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return( + { tls: { enabled: true, verify: 'peer', cert: missing_cert } } + ) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:api).and_return({ tls: { enabled: false } }) + end + + it 'returns warn about the missing cert' do + result = check.run + expect(result.status).to eq(:warn) + expect(result.message).to include(missing_cert) + end + end + + context 'when api TLS is enabled with cert and key present' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:api).and_return( + { tls: { enabled: true, cert: __FILE__, key: __FILE__ } } + ) + end + + it 'returns pass' do + result = check.run + expect(result.status).to eq(:pass) + end + end + + context 'when api TLS is enabled but cert is missing' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ tls: { enabled: false } }) + allow(Legion::Settings).to receive(:[]).with(:api).and_return( + { tls: { enabled: true, cert: nil, key: nil } } + ) + end + + it 'returns fail' do + result = check.run + expect(result.status).to eq(:fail) + expect(result.message).to match(/api.tls/i) + end + end + end +end diff --git a/spec/legion/cli/doctor_command_spec.rb b/spec/legion/cli/doctor_command_spec.rb new file mode 100644 index 00000000..c0764ee1 --- /dev/null +++ b/spec/legion/cli/doctor_command_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/doctor_command' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'json' + +RSpec.describe Legion::CLI::Doctor do + let(:formatter) { Legion::CLI::Output::Formatter.new(json: true, color: false) } + + def run_diagnose(extra_opts = {}) + output = StringIO.new + $stdout = output + instance = described_class.new([], { json: true, no_color: true }.merge(extra_opts)) + begin + instance.diagnose + rescue SystemExit + # expected on failure + end + $stdout = STDOUT + output.string + end + + before do + allow(Legion::CLI::Connection).to receive(:ensure_settings) + allow(Legion::CLI::Connection).to receive(:shutdown) + + described_class::CHECKS.each do |check_sym| + check_class = Legion::CLI::Doctor.const_get(check_sym) + allow_any_instance_of(check_class).to receive(:run).and_return( + Legion::CLI::Doctor::Result.new(name: check_class.new.name, status: :pass, message: 'ok') + ) + end + end + + describe '#diagnose' do + context 'when all checks pass' do + it 'outputs JSON with all results' do + output = run_diagnose + parsed = JSON.parse(output) + expect(parsed['results']).to be_an(Array) + expect(parsed['results'].size).to eq(described_class::CHECKS.size) + end + + it 'reports zero failures in summary' do + output = run_diagnose + parsed = JSON.parse(output) + expect(parsed['summary']['failed']).to eq(0) + expect(parsed['summary']['passed']).to eq(described_class::CHECKS.size) + end + end + + context 'when a check fails' do + before do + allow_any_instance_of(Legion::CLI::Doctor::RubyVersionCheck).to receive(:run).and_return( + Legion::CLI::Doctor::Result.new( + name: 'Ruby version', + status: :fail, + message: 'Ruby 3.2 is below minimum 3.4', + prescription: 'Upgrade Ruby to >= 3.4' + ) + ) + end + + it 'records the failure in results' do + output = run_diagnose + parsed = JSON.parse(output) + failed = parsed['results'].find { |r| r['status'] == 'fail' } + expect(failed).not_to be_nil + expect(failed['name']).to eq('Ruby version') + expect(failed['prescription']).to include('Upgrade Ruby') + end + + it 'reports failure count in summary' do + output = run_diagnose + parsed = JSON.parse(output) + expect(parsed['summary']['failed']).to eq(1) + end + end + + context 'when a check warns' do + before do + allow_any_instance_of(Legion::CLI::Doctor::ConfigCheck).to receive(:run).and_return( + Legion::CLI::Doctor::Result.new( + name: 'Config files', + status: :warn, + message: 'No config directory found', + prescription: 'Run `legion config scaffold`', + auto_fixable: true + ) + ) + end + + it 'records the warning in results' do + output = run_diagnose + parsed = JSON.parse(output) + warned = parsed['results'].find { |r| r['status'] == 'warn' } + expect(warned).not_to be_nil + expect(warned['auto_fixable']).to be true + end + + it 'reports auto_fixable count in summary' do + output = run_diagnose + parsed = JSON.parse(output) + expect(parsed['summary']['auto_fixable']).to eq(1) + end + end + + context 'with --fix flag' do + let(:pid_check) { instance_double(Legion::CLI::Doctor::PidCheck) } + + before do + allow_any_instance_of(Legion::CLI::Doctor::PidCheck).to receive(:run).and_return( + Legion::CLI::Doctor::Result.new( + name: 'PID files', + status: :warn, + message: 'Stale PID files: /tmp/legion.pid', + prescription: 'Remove with: rm /tmp/legion.pid', + auto_fixable: true + ) + ) + allow_any_instance_of(Legion::CLI::Doctor::PidCheck).to receive(:fix) + end + + it 'calls fix on auto-fixable checks' do + expect_any_instance_of(Legion::CLI::Doctor::PidCheck).to receive(:fix) + run_diagnose(fix: true) + end + end + + context 'when a check raises unexpectedly' do + before do + allow_any_instance_of(Legion::CLI::Doctor::RabbitmqCheck).to receive(:run).and_raise( + RuntimeError, 'unexpected boom' + ) + end + + it 'captures the error as a failure result' do + output = run_diagnose + parsed = JSON.parse(output) + failed = parsed['results'].find { |r| r['status'] == 'fail' } + expect(failed).not_to be_nil + expect(failed['message']).to include('unexpected boom') + end + end + + context 'scoring and grading' do + it 'includes health_score and grade in JSON output when all pass' do + output = run_diagnose + parsed = JSON.parse(output) + expect(parsed['summary']['health_score']).to eq(1.0) + expect(parsed['summary']['grade']).to eq('A') + end + + it 'returns grade F when all checks fail' do + described_class::CHECKS.each do |check_sym| + check_class = Legion::CLI::Doctor.const_get(check_sym) + allow_any_instance_of(check_class).to receive(:run).and_return( + Legion::CLI::Doctor::Result.new(name: check_class.new.name, status: :fail, message: 'bad') + ) + end + + output = run_diagnose + parsed = JSON.parse(output) + expect(parsed['summary']['health_score']).to eq(0.0) + expect(parsed['summary']['grade']).to eq('F') + end + + it 'returns intermediate grade for mixed results' do + described_class::CHECKS.each do |check_sym| + check_class = Legion::CLI::Doctor.const_get(check_sym) + allow_any_instance_of(check_class).to receive(:run).and_return( + Legion::CLI::Doctor::Result.new(name: check_class.new.name, status: :warn, message: 'meh') + ) + end + + output = run_diagnose + parsed = JSON.parse(output) + expect(parsed['summary']['health_score']).to eq(0.5) + expect(parsed['summary']['grade']).to eq('D') + end + + it 'includes score and weight in each result' do + output = run_diagnose + parsed = JSON.parse(output) + first = parsed['results'].first + expect(first).to have_key('score') + expect(first).to have_key('weight') + expect(first['score']).to eq(1.0) + end + end + end +end diff --git a/spec/legion/cli/doctor_spec.rb b/spec/legion/cli/doctor_spec.rb new file mode 100644 index 00000000..0a6b6f6d --- /dev/null +++ b/spec/legion/cli/doctor_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/doctor' + +RSpec.describe Legion::CLI::Doctor do + describe 'class structure' do + it 'is defined as a Thor subclass' do + expect(described_class.ancestors).to include(Thor) + end + + it 'has a diagnose method' do + expect(described_class.instance_methods(false)).to include(:diagnose) + end + + it 'defines diagnose as the default task' do + expect(described_class.default_task).to eq('diagnose') + end + end + + describe 'Ruby version check' do + subject(:check) { Legion::CLI::Doctor::RubyVersionCheck.new } + + it 'passes on the current Ruby version (>= 3.4)' do + result = check.run + expect(result.status).to eq(:pass) + end + + it 'returns a Result with the current Ruby version in the message' do + result = check.run + expect(result.message).to include(RUBY_VERSION) + end + end + + describe 'extensions check' do + subject(:check) { Legion::CLI::Doctor::ExtensionsCheck.new } + + before do + stub_const('Legion::Settings', { extensions: extensions }) + end + + let(:extensions) do + { + core: %w[lex-health], + ai: %w[lex-openai], + categories: {}, + blocked: [], + reserved_prefixes: [], + reserved_words: [], + parallel_pool_size: 4, + telemetry: true + } + end + + it 'ignores loader config keys when deriving configured extension gems' do + expect(check.send(:configured_extensions)).to eq(['telemetry']) + end + end + + describe 'settings check (ConfigCheck)' do + subject(:check) { Legion::CLI::Doctor::ConfigCheck.new } + + context 'when config directory is stubbed to exist with valid JSON' do + let(:tmpdir) { Dir.mktmpdir } + + before do + require 'json' + File.write("#{tmpdir}/transport.json", JSON.generate(host: 'localhost', port: 5672)) + stub_const('Legion::CLI::Doctor::ConfigCheck::CONFIG_PATHS', [tmpdir]) + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'returns a pass result' do + result = check.run + expect(result.status).to eq(:pass) + end + end + + context 'when config directory is stubbed to not exist' do + before do + stub_const('Legion::CLI::Doctor::ConfigCheck::CONFIG_PATHS', ['/nonexistent/legionio/settings']) + end + + it 'returns a warn result' do + result = check.run + expect(result.status).to eq(:warn) + end + + it 'is auto-fixable' do + result = check.run + expect(result.auto_fixable).to be true + end + end + end +end diff --git a/spec/legion/cli/error_handler_spec.rb b/spec/legion/cli/error_handler_spec.rb new file mode 100644 index 00000000..aed821c4 --- /dev/null +++ b/spec/legion/cli/error_handler_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/error' +require 'legion/cli/error_handler' +require 'legion/cli/output' + +RSpec.describe Legion::CLI::ErrorHandler do + describe 'PATTERNS' do + it 'matches RabbitMQ connection refused on port 5672' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :transport_unavailable } + expect('connection refused to 5672').to match(pattern[:match]) + end + + it 'matches bunny not connected' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :transport_unavailable } + expect('Bunny::NotConnected: bunny not connected').to match(pattern[:match]) + end + + it 'matches SQLite no such table' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :database_missing } + expect('no such table: tasks').to match(pattern[:match]) + end + + it 'matches PostgreSQL PG::UndefinedTable' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :database_missing } + expect('PG::UndefinedTable: ERROR: relation "tasks" does not exist').to match(pattern[:match]) + end + + it 'matches extension not found' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :extension_missing } + expect('extension not found: lex-foo').to match(pattern[:match]) + end + + it 'matches uninitialized constant Extensions' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :extension_missing } + expect('uninitialized constant Legion::Extensions::Foo').to match(pattern[:match]) + end + + it 'matches permission denied (lowercase)' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :permission_denied } + expect('Permission denied @ rb_sysopen - /etc/legionio/settings.json').to match(pattern[:match]) + end + + it 'matches EACCES' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :permission_denied } + expect('Errno::EACCES: permission denied').to match(pattern[:match]) + end + + it 'matches legion-data not connected' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :data_unavailable } + expect('legion-data not connected').to match(pattern[:match]) + end + + it 'matches vault sealed' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :vault_unavailable } + expect('Vault sealed').to match(pattern[:match]) + end + + it 'matches VAULT_ADDR not set' do + pattern = described_class::PATTERNS.find { |p| p[:code] == :vault_unavailable } + expect('VAULT_ADDR environment variable not set').to match(pattern[:match]) + end + end + + describe '.wrap' do + it 'wraps a known error into a CLI::Error with suggestions' do + original = StandardError.new('connection refused to 5672') + result = described_class.wrap(original) + + expect(result).to be_a(Legion::CLI::Error) + expect(result.code).to eq(:transport_unavailable) + expect(result.suggestions).not_to be_empty + expect(result.message).to include('Cannot connect to RabbitMQ') + expect(result.message).to include('connection refused to 5672') + end + + it 'returns the original error unchanged for unknown patterns' do + original = StandardError.new('some totally unknown error message') + result = described_class.wrap(original) + + expect(result).to be(original) + end + + it 'includes the original message in the wrapped error message' do + original = StandardError.new('no such table: tasks') + result = described_class.wrap(original) + + expect(result.message).to include('no such table: tasks') + end + end + + describe '.format_error' do + let(:formatter) { instance_double(Legion::CLI::Output::Formatter) } + + before do + allow(formatter).to receive(:error) + allow(formatter).to receive(:colorize).with('>', :label).and_return('>') + end + + it 'always calls formatter.error with the message' do + error = Legion::CLI::Error.new('something went wrong') + expect(formatter).to receive(:error).with('something went wrong') + described_class.format_error(error, formatter) + end + + it 'prints suggestions for actionable CLI errors' do + error = Legion::CLI::Error.actionable( + code: :transport_unavailable, + message: 'Cannot connect', + suggestions: ['Run legion doctor', 'Check settings'] + ) + + expect { described_class.format_error(error, formatter) }.to output( + a_string_including('Run legion doctor') + ).to_stdout + end + + it 'does not print suggestions for non-actionable CLI errors' do + error = Legion::CLI::Error.new('plain error') + described_class.format_error(error, formatter) + # formatter.error is called but colorize is never called (no suggestion lines) + expect(formatter).to have_received(:error).with('plain error') + expect(formatter).not_to have_received(:colorize) + end + + it 'does not print suggestions for plain StandardError' do + error = StandardError.new('plain standard error') + described_class.format_error(error, formatter) + expect(formatter).to have_received(:error).with('plain standard error') + expect(formatter).not_to have_received(:colorize) + end + end +end diff --git a/spec/legion/cli/error_spec.rb b/spec/legion/cli/error_spec.rb new file mode 100644 index 00000000..5f092022 --- /dev/null +++ b/spec/legion/cli/error_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/error' + +RSpec.describe Legion::CLI::Error do + describe '.actionable' do + subject(:error) do + described_class.actionable( + code: :transport_unavailable, + message: 'Cannot connect to RabbitMQ', + suggestions: ['Run legion doctor', 'Check transport settings'] + ) + end + + it 'returns a Legion::CLI::Error instance' do + expect(error).to be_a(described_class) + end + + it 'sets the message' do + expect(error.message).to eq('Cannot connect to RabbitMQ') + end + + it 'sets the code' do + expect(error.code).to eq(:transport_unavailable) + end + + it 'sets suggestions' do + expect(error.suggestions).to eq(['Run legion doctor', 'Check transport settings']) + end + end + + describe '#actionable?' do + it 'returns true when suggestions are present' do + error = described_class.actionable(code: :foo, message: 'msg', suggestions: ['do this']) + expect(error.actionable?).to be(true) + end + + it 'returns false when suggestions are empty' do + error = described_class.actionable(code: :foo, message: 'msg', suggestions: []) + expect(error.actionable?).to be(false) + end + + it 'returns false when suggestions are nil' do + error = described_class.new('plain error') + expect(error.actionable?).to be(false) + end + end + + describe '#code' do + it 'returns nil on a plain error' do + error = described_class.new('plain error') + expect(error.code).to be_nil + end + + it 'returns the code set via .actionable' do + error = described_class.actionable(code: :permission_denied, message: 'msg') + expect(error.code).to eq(:permission_denied) + end + end +end diff --git a/spec/legion/cli/eval_command_spec.rb b/spec/legion/cli/eval_command_spec.rb new file mode 100644 index 00000000..ab37f665 --- /dev/null +++ b/spec/legion/cli/eval_command_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe Legion::CLI::Eval do + let(:out) do + instance_double(Legion::CLI::Output::Formatter, + header: nil, spacer: nil, success: nil, warn: nil, + error: nil, json: nil, table: nil, detail: nil) + end + + before do + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + describe '#run' do + context 'when lex-eval is not loaded' do + before do + hide_const('Legion::Extensions::Eval') if defined?(Legion::Extensions::Eval) + end + + it 'raises CLI::Error with helpful message' do + cli = described_class.new([], { dataset: 'my_ds', threshold: 0.8, exit_code: false }) + expect { cli.execute }.to raise_error(Legion::CLI::Error, /lex-eval/) + end + end + + context 'when lex-dataset is not loaded' do + before do + stub_const('Legion::Extensions::Eval::Client', Class.new do + def initialize(**); end + end) + hide_const('Legion::Extensions::Dataset') if defined?(Legion::Extensions::Dataset) + end + + it 'raises CLI::Error with helpful message' do + cli = described_class.new([], { dataset: 'my_ds', threshold: 0.8, exit_code: false }) + expect { cli.execute }.to raise_error(Legion::CLI::Error, /lex-dataset/) + end + end + + context 'with both extensions available' do + let(:dataset_client) { instance_double(Legion::Extensions::Dataset::Client) } + let(:eval_client) { instance_double(Legion::Extensions::Eval::Client) } + + let(:dataset_result) do + { + name: 'my_ds', version: 1, version_id: 1, row_count: 3, + rows: [ + { row_index: 0, input: 'a', expected_output: 'A' }, + { row_index: 1, input: 'b', expected_output: 'B' }, + { row_index: 2, input: 'c', expected_output: 'C' } + ] + } + end + + let(:passing_report) do + { + evaluator: 'default', + results: [ + { row_index: 0, passed: true, score: 1.0 }, + { row_index: 1, passed: true, score: 1.0 }, + { row_index: 2, passed: true, score: 0.9 } + ], + summary: { total: 3, passed: 3, failed: 0, avg_score: 0.967 } + } + end + + let(:failing_report) do + { + evaluator: 'default', + results: [ + { row_index: 0, passed: false, score: 0.3 }, + { row_index: 1, passed: false, score: 0.4 }, + { row_index: 2, passed: true, score: 0.9 } + ], + summary: { total: 3, passed: 1, failed: 2, avg_score: 0.533 } + } + end + + before do + stub_const('Legion::Extensions::Dataset::Client', Class.new do + def initialize(**); end + end) + stub_const('Legion::Extensions::Eval::Client', Class.new do + def initialize(**); end + end) + allow(Legion::Extensions::Dataset::Client).to receive(:new).and_return(dataset_client) + allow(Legion::Extensions::Eval::Client).to receive(:new).and_return(eval_client) + allow(dataset_client).to receive(:get_dataset).with(name: 'my_ds').and_return(dataset_result) + end + + context 'when avg_score >= threshold' do + before { allow(eval_client).to receive(:run_evaluation).and_return(passing_report) } + + it 'outputs JSON report to stdout' do + expect(out).to receive(:json) + cli = described_class.new([], { dataset: 'my_ds', threshold: 0.8, exit_code: false, + json: true, no_color: false, verbose: false }) + cli.execute + end + + it 'does not exit 1 when exit_code is true and passing' do + cli = described_class.new([], { dataset: 'my_ds', threshold: 0.8, exit_code: true, + json: false, no_color: false, verbose: false }) + expect { cli.execute }.not_to raise_error + end + end + + context 'when avg_score < threshold' do + before { allow(eval_client).to receive(:run_evaluation).and_return(failing_report) } + + it 'raises SystemExit with code 1 when --exit-code is set' do + cli = described_class.new([], { dataset: 'my_ds', threshold: 0.8, exit_code: true, + json: false, no_color: false, verbose: false }) + expect { cli.execute }.to raise_error(SystemExit) + end + + it 'does not exit when --exit-code is omitted' do + cli = described_class.new([], { dataset: 'my_ds', threshold: 0.8, exit_code: false, + json: false, no_color: false, verbose: false }) + expect { cli.execute }.not_to raise_error + end + end + + context 'when dataset is not found' do + before do + allow(dataset_client).to receive(:get_dataset).and_return({ error: 'not_found' }) + end + + it 'raises CLI::Error' do + cli = described_class.new([], { dataset: 'missing', threshold: 0.8, exit_code: false, + json: false, no_color: false, verbose: false }) + expect { cli.execute }.to raise_error(Legion::CLI::Error, /not found/) + end + end + end + end +end diff --git a/spec/legion/cli/eval_compare_spec.rb b/spec/legion/cli/eval_compare_spec.rb new file mode 100644 index 00000000..853b2998 --- /dev/null +++ b/spec/legion/cli/eval_compare_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe Legion::CLI::Eval, '#compare' do + let(:out) do + instance_double(Legion::CLI::Output::Formatter, + header: nil, spacer: nil, success: nil, warn: nil, + error: nil, json: nil, table: nil, detail: nil) + end + + before do + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + context 'when lex-dataset is not loaded' do + before { hide_const('Legion::Extensions::Dataset') if defined?(Legion::Extensions::Dataset) } + + it 'raises CLI::Error' do + cli = described_class.new([], { run1: 'baseline', run2: 'candidate', + json: false, no_color: false, verbose: false }) + expect { cli.compare }.to raise_error(Legion::CLI::Error, /lex-dataset/) + end + end + + context 'with lex-dataset available' do + let(:dataset_client) { instance_double(Legion::Extensions::Dataset::Client) } + + let(:diff_result) do + { + exp1: 'baseline', + exp2: 'candidate', + rows_compared: 10, + regression_count: 2, + improvement_count: 3 + } + end + + before do + stub_const('Legion::Extensions::Dataset::Client', Class.new { def initialize(**); end }) + allow(Legion::Extensions::Dataset::Client).to receive(:new).and_return(dataset_client) + end + + context 'when both experiments exist' do + before { allow(dataset_client).to receive(:compare_experiments).and_return(diff_result) } + + it 'calls compare_experiments with the correct names' do + expect(dataset_client).to receive(:compare_experiments) + .with(exp1_name: 'baseline', exp2_name: 'candidate') + cli = described_class.new([], { run1: 'baseline', run2: 'candidate', + json: false, no_color: false, verbose: false }) + cli.compare + end + + it 'renders a table in human mode' do + expect(out).to receive(:table) + cli = described_class.new([], { run1: 'baseline', run2: 'candidate', + json: false, no_color: false, verbose: false }) + cli.compare + end + + it 'renders JSON in json mode' do + expect(out).to receive(:json) + cli = described_class.new([], { run1: 'baseline', run2: 'candidate', + json: true, no_color: false, verbose: false }) + cli.compare + end + end + + context 'when one experiment does not exist' do + before do + allow(dataset_client).to receive(:compare_experiments) + .and_return({ error: 'experiments_not_found' }) + end + + it 'raises CLI::Error' do + cli = described_class.new([], { run1: 'baseline', run2: 'missing', + json: false, no_color: false, verbose: false }) + expect { cli.compare }.to raise_error(Legion::CLI::Error, /not found/) + end + end + end +end diff --git a/spec/legion/cli/eval_experiments_spec.rb b/spec/legion/cli/eval_experiments_spec.rb new file mode 100644 index 00000000..755382a9 --- /dev/null +++ b/spec/legion/cli/eval_experiments_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe Legion::CLI::Eval, '#experiments' do + let(:out) do + instance_double(Legion::CLI::Output::Formatter, + header: nil, spacer: nil, success: nil, warn: nil, + error: nil, json: nil, table: nil, detail: nil) + end + + before do + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + context 'when lex-dataset is not loaded' do + before { hide_const('Legion::Extensions::Dataset') if defined?(Legion::Extensions::Dataset) } + + it 'raises CLI::Error' do + cli = described_class.new([], { json: false, no_color: false, verbose: false }) + expect { cli.experiments }.to raise_error(Legion::CLI::Error, /lex-dataset/) + end + end + + context 'with lex-dataset available' do + let(:dataset_client) { instance_double(Legion::Extensions::Dataset::Client) } + + let(:experiment_rows) do + [ + { id: 1, name: 'baseline', status: 'completed', + created_at: '2026-03-18 10:00:00', summary: 'total:10 passed:8' }, + { id: 2, name: 'prompt_v2', status: 'completed', + created_at: '2026-03-19 14:00:00', summary: 'total:10 passed:9' } + ] + end + + before do + stub_const('Legion::Extensions::Dataset::Client', Class.new do + def initialize(**); end + end) + allow(Legion::Extensions::Dataset::Client).to receive(:new).and_return(dataset_client) + allow(dataset_client).to receive(:list_experiments).and_return(experiment_rows) + end + + it 'calls list_experiments on the dataset client' do + expect(dataset_client).to receive(:list_experiments).and_return(experiment_rows) + cli = described_class.new([], { json: false, no_color: false, verbose: false }) + cli.experiments + end + + it 'renders a table in human mode' do + expect(out).to receive(:table) + cli = described_class.new([], { json: false, no_color: false, verbose: false }) + cli.experiments + end + + it 'renders JSON in json mode' do + expect(out).to receive(:json) + cli = described_class.new([], { json: true, no_color: false, verbose: false }) + cli.experiments + end + + it 'shows no results message when no experiments exist' do + allow(dataset_client).to receive(:list_experiments).and_return([]) + expect(out).to receive(:warn).with(/no experiments/) + cli = described_class.new([], { json: false, no_color: false, verbose: false }) + cli.experiments + end + end +end diff --git a/spec/legion/cli/eval_promote_spec.rb b/spec/legion/cli/eval_promote_spec.rb new file mode 100644 index 00000000..7d40ffb0 --- /dev/null +++ b/spec/legion/cli/eval_promote_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe Legion::CLI::Eval, '#promote' do + let(:out) do + instance_double(Legion::CLI::Output::Formatter, + header: nil, spacer: nil, success: nil, warn: nil, + error: nil, json: nil, table: nil, detail: nil) + end + + before do + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + context 'when lex-dataset is not loaded' do + before { hide_const('Legion::Extensions::Dataset') if defined?(Legion::Extensions::Dataset) } + + it 'raises CLI::Error mentioning lex-dataset' do + cli = described_class.new([], { experiment: 'baseline', tag: 'production', + json: false, no_color: false, verbose: false }) + expect { cli.promote }.to raise_error(Legion::CLI::Error, /lex-dataset/) + end + end + + context 'when lex-prompt is not loaded' do + before do + stub_const('Legion::Extensions::Dataset::Client', Class.new { def initialize(**); end }) + hide_const('Legion::Extensions::Prompt') if defined?(Legion::Extensions::Prompt) + end + + it 'raises CLI::Error mentioning lex-prompt' do + cli = described_class.new([], { experiment: 'baseline', tag: 'production', + json: false, no_color: false, verbose: false }) + expect { cli.promote }.to raise_error(Legion::CLI::Error, /lex-prompt/) + end + end + + context 'with both extensions available' do + let(:dataset_client) { instance_double(Legion::Extensions::Dataset::Client) } + let(:prompt_client) { instance_double(Legion::Extensions::Prompt::Client) } + + let(:experiment_row) do + { id: 2, name: 'prompt_v2', status: 'completed', + prompt_name: 'my_prompt', prompt_version: 3 } + end + + before do + stub_const('Legion::Extensions::Dataset::Client', Class.new { def initialize(**); end }) + stub_const('Legion::Extensions::Prompt::Client', Class.new { def initialize(**); end }) + allow(Legion::Extensions::Dataset::Client).to receive(:new).and_return(dataset_client) + allow(Legion::Extensions::Prompt::Client).to receive(:new).and_return(prompt_client) + allow(dataset_client).to receive(:get_experiment).with(name: 'prompt_v2').and_return(experiment_row) + end + + context 'when experiment exists and has a linked prompt' do + before do + allow(prompt_client).to receive(:tag_prompt) + .and_return({ tagged: true, name: 'my_prompt', tag: 'production', version: 3 }) + end + + it 'calls tag_prompt with the correct arguments' do + expect(prompt_client).to receive(:tag_prompt).with( + name: 'my_prompt', tag: 'production', version: 3 + ) + cli = described_class.new([], { experiment: 'prompt_v2', tag: 'production', + json: false, no_color: false, verbose: false }) + cli.promote + end + + it 'outputs success in human mode' do + expect(out).to receive(:success) + cli = described_class.new([], { experiment: 'prompt_v2', tag: 'production', + json: false, no_color: false, verbose: false }) + cli.promote + end + + it 'outputs JSON in json mode' do + expect(out).to receive(:json) + cli = described_class.new([], { experiment: 'prompt_v2', tag: 'production', + json: true, no_color: false, verbose: false }) + cli.promote + end + end + + context 'when experiment is not found' do + before { allow(dataset_client).to receive(:get_experiment).and_return(nil) } + + it 'raises CLI::Error' do + cli = described_class.new([], { experiment: 'missing', tag: 'production', + json: false, no_color: false, verbose: false }) + expect { cli.promote }.to raise_error(Legion::CLI::Error, /not found/) + end + end + + context 'when experiment has no linked prompt' do + before do + allow(dataset_client).to receive(:get_experiment) + .and_return(experiment_row.merge(prompt_name: nil)) + end + + it 'raises CLI::Error explaining no prompt is linked' do + cli = described_class.new([], { experiment: 'prompt_v2', tag: 'production', + json: false, no_color: false, verbose: false }) + expect { cli.promote }.to raise_error(Legion::CLI::Error, /no prompt linked/) + end + end + end +end diff --git a/spec/legion/cli/failover_command_spec.rb b/spec/legion/cli/failover_command_spec.rb new file mode 100644 index 00000000..6dd04f51 --- /dev/null +++ b/spec/legion/cli/failover_command_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/failover_command' +require 'legion/region/failover' + +RSpec.describe Legion::CLI::Failover do + before do + Legion::Settings.loader.settings[:region] ||= {} + @saved_region = Legion::Settings.loader.settings[:region].dup + Legion::Settings.loader.settings[:region] = { + current: 'us-east-2', + primary: 'us-east-2', + failover: 'us-west-2', + peers: %w[us-east-2 us-west-2], + default_affinity: 'prefer_local', + data_residency: {} + } + allow(Legion::CLI::Connection).to receive(:ensure_settings) + end + + after do + Legion::Settings.loader.settings[:region] = @saved_region + end + + describe 'promote --dry-run' do + it 'does not change the primary setting' do + allow(Legion::Region::Failover).to receive(:replication_lag).and_return(1.0) + begin + described_class.start(%w[promote --region us-west-2 --dry-run]) + rescue SystemExit + nil + end + expect(Legion::Settings.loader.settings[:region][:primary]).to eq('us-east-2') + end + end + + describe 'promote with unknown region' do + it 'raises SystemExit for unknown region' do + expect { described_class.start(%w[promote --region eu-central-1]) } + .to raise_error(SystemExit) + end + end + + describe 'status' do + it 'runs without error' do + expect { described_class.start(%w[status --json]) }.not_to raise_error + end + + it 'includes current region in JSON output' do + expect { described_class.start(%w[status --json]) }.to output(/us-east-2/).to_stdout + end + end +end diff --git a/spec/legion/cli/fleet_command_spec.rb b/spec/legion/cli/fleet_command_spec.rb new file mode 100644 index 00000000..cd9c1b2b --- /dev/null +++ b/spec/legion/cli/fleet_command_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/fleet_command' + +RSpec.describe Legion::CLI::FleetCommand do + let(:output) { StringIO.new } + + before do + allow($stdout).to receive(:write) { |str| output.write(str) } + allow($stdout).to receive(:puts) { |*args| output.puts(*args) } + end + + def extract_json(str) + lines = str.lines + json_line = lines.reverse.find { |l| l.strip.start_with?('{', '[') } + JSON.parse(json_line, symbolize_names: true) + end + + describe '.exit_on_failure?' do + it 'returns true' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe 'command registration' do + it 'has a status command' do + expect(described_class.commands).to have_key('status') + end + + it 'has a pending command' do + expect(described_class.commands).to have_key('pending') + end + + it 'has an approve command' do + expect(described_class.commands).to have_key('approve') + end + + it 'has an add command' do + expect(described_class.commands).to have_key('add') + end + + it 'has a config command' do + expect(described_class.commands).to have_key('config') + end + end + + describe '#status' do + let(:mock_api_response) do + { + queues: [ + { name: 'lex.assessor.runners.assessor', depth: 3 }, + { name: 'lex.developer.runners.developer', depth: 1 } + ], + active_work_items: 4, + workers: 2 + } + end + + before do + allow_any_instance_of(described_class).to receive(:fetch_fleet_status) + .and_return(mock_api_response) + end + + it 'displays queue depths' do + described_class.start(%w[status]) + expect(output.string).to include('assessor') + end + + context 'with --json' do + it 'outputs JSON' do + described_class.start(%w[status --json]) + parsed = extract_json(output.string) + expect(parsed).to have_key(:queues) + end + end + end + + describe '#pending' do + let(:mock_pending) do + [ + { id: 1, work_item_id: 'abc-123', title: 'Fix timeout', source: 'github', + source_ref: 'LegionIO/lex-exec#42', created_at: '2026-04-12T10:00:00Z' }, + { id: 2, work_item_id: 'def-456', title: 'Add retry', source: 'github', + source_ref: 'LegionIO/lex-exec#43', created_at: '2026-04-12T11:00:00Z' } + ] + end + + before do + allow_any_instance_of(described_class).to receive(:fetch_pending_approvals) + .and_return(mock_pending) + end + + it 'displays pending approvals' do + described_class.start(%w[pending]) + expect(output.string).to include('Fix timeout') + end + + context 'with --json' do + it 'outputs JSON array' do + described_class.start(%w[pending --json]) + parsed = extract_json(output.string) + expect(parsed).to be_a(Array) + expect(parsed.size).to eq(2) + end + end + end + + describe '#approve' do + let(:mock_result) { { success: true, work_item_id: 'abc-123', resumed: true } } + + before do + allow_any_instance_of(described_class).to receive(:approve_work_item) + .and_return(mock_result) + end + + it 'approves a work item by ID' do + described_class.start(%w[approve 1]) + expect(output.string).to include('Approved') + end + + context 'with --json' do + it 'outputs JSON result' do + described_class.start(%w[approve 1 --json]) + parsed = extract_json(output.string) + expect(parsed[:success]).to be true + end + end + end + + describe '#add' do + let(:mock_result) { { success: true, source: 'github', absorber: 'issues' } } + + before do + allow_any_instance_of(described_class).to receive(:add_fleet_source) + .and_return(mock_result) + end + + it 'adds a source' do + described_class.start(%w[add github]) + expect(output.string).to include('github') + end + + context 'with --json' do + it 'outputs JSON result' do + described_class.start(%w[add github --json]) + parsed = extract_json(output.string) + expect(parsed[:source]).to eq('github') + end + end + end +end diff --git a/spec/legion/cli/fleet_setup_spec.rb b/spec/legion/cli/fleet_setup_spec.rb new file mode 100644 index 00000000..0d28599f --- /dev/null +++ b/spec/legion/cli/fleet_setup_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/output' +require 'legion/workflow/loader' +require 'legion/cli/fleet_setup' + +RSpec.describe Legion::CLI::FleetSetup do + let(:output) { StringIO.new } + let(:formatter) { instance_double(Legion::CLI::Output::Formatter) } + + before do + allow(formatter).to receive(:header) + allow(formatter).to receive(:success) + allow(formatter).to receive(:error) + allow(formatter).to receive(:warn) + allow(formatter).to receive(:spacer) + allow(formatter).to receive(:json) + end + + describe '.fleet_gems' do + it 'includes the four pipeline extensions' do + expect(described_class.fleet_gems).to include( + 'lex-assessor', 'lex-planner', 'lex-developer', 'lex-validator' + ) + end + + it 'includes supporting tool extensions' do + expect(described_class.fleet_gems).to include( + 'lex-codegen', 'lex-eval', 'lex-exec' + ) + end + + it 'includes orchestration extensions' do + expect(described_class.fleet_gems).to include( + 'lex-tasker', 'lex-conditioner', 'lex-transformer' + ) + end + end + + describe '.manifest_path' do + it 'points to the fleet manifest YAML' do + expect(described_class.manifest_path).to end_with('fleet/manifest.yml') + end + + it 'references an existing file' do + expect(File.exist?(described_class.manifest_path)).to be true + end + end + + describe '#phase1_install' do + subject(:setup) { described_class.new(formatter: formatter, options: { json: false }) } + + before do + allow(setup).to receive(:install_gems).and_return({ installed: 7, failed: 0 }) + end + + it 'installs fleet gems' do + expect(setup).to receive(:install_gems) + setup.phase1_install + end + + it 'returns success when all gems install' do + result = setup.phase1_install + expect(result[:success]).to be true + end + end + + describe '#phase2_wire' do + subject(:setup) { described_class.new(formatter: formatter, options: { json: false }) } + + let(:mock_loader) { instance_double(Legion::Workflow::Loader) } + + before do + allow(Legion::Workflow::Loader).to receive(:new).and_return(mock_loader) + allow(mock_loader).to receive(:install).and_return({ + success: true, chain_id: 1, relationship_ids: (1..10).to_a + }) + allow(setup).to receive(:seed_conditioner_rules).and_return({ success: true }) + allow(setup).to receive(:register_settings).and_return({ success: true }) + allow(setup).to receive(:apply_planner_timeout_policy) + end + + it 'installs the manifest via Workflow::Loader' do + expect(mock_loader).to receive(:install) + setup.phase2_wire + end + + it 'seeds conditioner rules' do + expect(setup).to receive(:seed_conditioner_rules) + setup.phase2_wire + end + + it 'registers fleet settings via load_module_settings' do + expect(setup).to receive(:register_settings) + setup.phase2_wire + end + + it 'applies planner timeout policy' do + expect(setup).to receive(:apply_planner_timeout_policy) + setup.phase2_wire + end + + it 'returns success with chain_id and relationship count' do + result = setup.phase2_wire + expect(result[:success]).to be true + expect(result[:chain_id]).to eq(1) + expect(result[:relationships]).to eq(10) + end + end +end diff --git a/spec/legion/cli/gaia_command_spec.rb b/spec/legion/cli/gaia_command_spec.rb new file mode 100644 index 00000000..a700c433 --- /dev/null +++ b/spec/legion/cli/gaia_command_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/gaia_command' + +RSpec.describe Legion::CLI::Gaia do + let(:mock_http) { instance_double(Net::HTTP) } + + let(:gaia_data) do + { + mode: 'autonomous', + started: true, + buffer_depth: 3, + sessions: 2, + extensions_loaded: 8, + extensions_total: 10, + wired_phases: 4, + active_channels: %w[alpha beta], + phase_list: %w[perception reasoning action reflection] + } + end + + let(:success_response) do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: gaia_data })) + response + end + + before do + allow(Net::HTTP).to receive(:new).and_return(mock_http) + allow(mock_http).to receive(:open_timeout=) + allow(mock_http).to receive(:read_timeout=) + end + + describe '#status -- daemon running' do + before do + allow(mock_http).to receive(:get).and_return(success_response) + end + + it 'outputs GAIA Status header' do + expect { described_class.start(['status', '--no-color']) }.to output(/GAIA Status/).to_stdout + end + + it 'shows mode in output' do + expect { described_class.start(['status', '--no-color']) }.to output(/autonomous/).to_stdout + end + + it 'shows active channels' do + expect { described_class.start(['status', '--no-color']) }.to output(/alpha/).to_stdout + end + + it 'shows wired phases' do + expect { described_class.start(['status', '--no-color']) }.to output(/perception/).to_stdout + end + end + + describe '#status -- daemon not running' do + before do + allow(mock_http).to receive(:get).and_raise(Errno::ECONNREFUSED) + end + + it 'outputs not running message' do + expect { described_class.start(['status', '--no-color']) }.to output(/not running/).to_stdout + end + + it 'outputs GAIA Status header even when daemon is down' do + expect { described_class.start(['status', '--no-color']) }.to output(/GAIA Status/).to_stdout + end + end + + describe '#status -- JSON mode with daemon running' do + before do + allow(mock_http).to receive(:get).and_return(success_response) + end + + it 'outputs valid JSON' do + output = capture_stdout { described_class.start(['status', '--json']) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed).to be_a(Hash) + end + + it 'includes mode in JSON output' do + output = capture_stdout { described_class.start(['status', '--json']) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:mode]).to eq('autonomous') + end + + it 'includes started in JSON output' do + output = capture_stdout { described_class.start(['status', '--json']) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:started]).to eq(true) + end + end + + describe '#status -- JSON mode with daemon not running' do + before do + allow(mock_http).to receive(:get).and_raise(Errno::ECONNREFUSED) + end + + it 'outputs JSON with started: false' do + output = capture_stdout { described_class.start(['status', '--json']) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:started]).to eq(false) + end + + it 'includes error key in JSON output' do + output = capture_stdout { described_class.start(['status', '--json']) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:error]).to eq('daemon not running') + end + end + + describe '#channels' do + let(:channels_data) do + { + channels: [ + { id: :cli, type: 'CliAdapter', started: true, capabilities: %w[text markdown] }, + { id: :teams, type: 'TeamsAdapter', started: false, capabilities: %w[text] } + ], + count: 2 + } + end + + let(:channels_response) do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: channels_data })) + response + end + + before { allow(mock_http).to receive(:get).and_return(channels_response) } + + it 'outputs channel header with count' do + expect { described_class.start(%w[channels --no-color]) }.to output(/GAIA Channels \(2\)/).to_stdout + end + + it 'shows channel type' do + expect { described_class.start(%w[channels --no-color]) }.to output(/CliAdapter/).to_stdout + end + + it 'shows channel status' do + expect { described_class.start(%w[channels --no-color]) }.to output(/active/).to_stdout + end + + it 'shows capabilities' do + expect { described_class.start(%w[channels --no-color]) }.to output(/text, markdown/).to_stdout + end + + context 'when daemon not running' do + before { allow(mock_http).to receive(:get).and_raise(Errno::ECONNREFUSED) } + + it 'shows not running' do + expect { described_class.start(%w[channels --no-color]) }.to output(/not running/).to_stdout + end + end + end + + describe '#buffer' do + let(:buffer_data) { { depth: 5, empty: false, max_size: 1000 } } + + let(:buffer_response) do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: buffer_data })) + response + end + + before { allow(mock_http).to receive(:get).and_return(buffer_response) } + + it 'outputs buffer header' do + expect { described_class.start(%w[buffer --no-color]) }.to output(/Sensory Buffer/).to_stdout + end + + it 'shows depth' do + expect { described_class.start(%w[buffer --no-color]) }.to output(/5/).to_stdout + end + + it 'shows max size' do + expect { described_class.start(%w[buffer --no-color]) }.to output(/1000/).to_stdout + end + + context 'with --json' do + it 'outputs JSON' do + output = capture_stdout { described_class.start(%w[buffer --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:depth]).to eq(5) + end + end + end + + describe '#sessions' do + let(:sessions_data) { { count: 3, active: true } } + + let(:sessions_response) do + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(JSON.generate({ data: sessions_data })) + response + end + + before { allow(mock_http).to receive(:get).and_return(sessions_response) } + + it 'outputs sessions header' do + expect { described_class.start(%w[sessions --no-color]) }.to output(/GAIA Sessions/).to_stdout + end + + it 'shows session count' do + expect { described_class.start(%w[sessions --no-color]) }.to output(/3/).to_stdout + end + + it 'shows system active status' do + expect { described_class.start(%w[sessions --no-color]) }.to output(/true/).to_stdout + end + + context 'with --json' do + it 'outputs JSON with count' do + output = capture_stdout { described_class.start(%w[sessions --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:count]).to eq(3) + end + end + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end diff --git a/spec/legion/cli/generate_absorber_spec.rb b/spec/legion/cli/generate_absorber_spec.rb new file mode 100644 index 00000000..2fc578d3 --- /dev/null +++ b/spec/legion/cli/generate_absorber_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/generate_command' + +RSpec.describe 'legion generate absorber' do + it 'has the absorber subcommand' do + expect(Legion::CLI::Generate.instance_methods).to include(:absorber) + end +end diff --git a/spec/legion/cli/generate_command_spec.rb b/spec/legion/cli/generate_command_spec.rb new file mode 100644 index 00000000..1e9342b2 --- /dev/null +++ b/spec/legion/cli/generate_command_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/generate_command' + +RSpec.describe Legion::CLI::Generate do + let(:parent_dir) { Dir.mktmpdir('gen-test') } + let(:lex_dir) { File.join(parent_dir, 'lex-testext') } + + around do |example| + FileUtils.mkdir_p(lex_dir) + original_dir = Dir.pwd + Dir.chdir(lex_dir) + example.run + Dir.chdir(original_dir) + FileUtils.rm_rf(parent_dir) + end + + describe 'runner' do + it 'creates runner and spec files' do + described_class.start(%w[runner my_runner]) + expect(File).to exist('lib/legion/extensions/testext/runners/my_runner.rb') + expect(File).to exist('spec/runners/my_runner_spec.rb') + end + + it 'scaffolds specified functions' do + described_class.start(%w[runner api_call --functions fetch,post]) + content = File.read('lib/legion/extensions/testext/runners/api_call.rb') + expect(content).to include('def fetch') + expect(content).to include('def post') + end + + it 'defaults to execute function' do + described_class.start(%w[runner simple]) + content = File.read('lib/legion/extensions/testext/runners/simple.rb') + expect(content).to include('def execute') + end + + it 'generates correct class name from snake_case' do + described_class.start(%w[runner data_fetch]) + content = File.read('lib/legion/extensions/testext/runners/data_fetch.rb') + expect(content).to include('module DataFetch') + end + end + + describe 'actor' do + it 'creates actor and spec files' do + described_class.start(%w[actor poller --type every]) + expect(File).to exist('lib/legion/extensions/testext/actors/poller.rb') + expect(File).to exist('spec/actors/poller_spec.rb') + end + + it 'uses subscription parent by default' do + described_class.start(%w[actor listener]) + content = File.read('lib/legion/extensions/testext/actors/listener.rb') + expect(content).to include('Legion::Extensions::Actors::Subscription') + end + + it 'includes interval for every type' do + described_class.start(%w[actor ticker --type every --interval 30]) + content = File.read('lib/legion/extensions/testext/actors/ticker.rb') + expect(content).to include('INTERVAL = 30') + end + + it 'does not include interval for subscription type' do + described_class.start(%w[actor sub_actor --type subscription]) + content = File.read('lib/legion/extensions/testext/actors/sub_actor.rb') + expect(content).not_to include('INTERVAL') + end + end + + describe 'exchange' do + it 'creates exchange file' do + described_class.start(%w[exchange events]) + path = 'lib/legion/extensions/testext/transport/exchanges/events.rb' + expect(File).to exist(path) + expect(File.read(path)).to include('class Events < Legion::Transport::Exchange') + end + end + + describe 'queue' do + it 'creates queue file' do + described_class.start(%w[queue tasks]) + path = 'lib/legion/extensions/testext/transport/queues/tasks.rb' + expect(File).to exist(path) + expect(File.read(path)).to include('class Tasks < Legion::Transport::Queue') + end + end + + describe 'message' do + it 'creates message file' do + described_class.start(%w[message notify]) + path = 'lib/legion/extensions/testext/transport/messages/notify.rb' + expect(File).to exist(path) + expect(File.read(path)).to include('class Notify < Legion::Transport::Message') + end + end + + describe 'tool' do + it 'creates tool and spec files' do + described_class.start(%w[tool lookup]) + expect(File).to exist('lib/legion/extensions/testext/tools/lookup.rb') + expect(File).to exist('spec/tools/lookup_spec.rb') + end + + it 'includes ExtensionTool mixin' do + described_class.start(%w[tool search]) + content = File.read('lib/legion/extensions/testext/tools/search.rb') + expect(content).to include('include Legion::CLI::Chat::ExtensionTool') + expect(content).to include('permission_tier :write') + end + end + + # TODO: fix SystemExit leaking into SimpleCov at_exit on CI + # describe 'detect_lex' do + # it 'raises when not in a lex directory' do + # non_lex = File.join(parent_dir, 'myproject') + # FileUtils.mkdir_p(non_lex) + # Dir.chdir(non_lex) + # expect { described_class.start(%w[runner test]) }.to raise_error(SystemExit) + # end + # end +end diff --git a/spec/legion/cli/graph_command_spec.rb b/spec/legion/cli/graph_command_spec.rb new file mode 100644 index 00000000..202a2bfa --- /dev/null +++ b/spec/legion/cli/graph_command_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'tmpdir' +require 'legion/cli/graph_command' + +RSpec.describe Legion::CLI::GraphCommand do + let(:graph_data) do + { + nodes: { + 'lex-http.fetch' => { label: 'lex-http.fetch', type: 'trigger' }, + 'lex-transform.parse' => { label: 'lex-transform.parse', type: 'action' } + }, + edges: [ + { from: 'lex-http.fetch', to: 'lex-transform.parse', label: 'on_success', chain_id: 'chain-1' } + ] + } + end + + before do + allow(Legion::Graph::Builder).to receive(:build).and_return(graph_data) + end + + describe '#show' do + it 'renders mermaid format by default' do + expect { described_class.start(%w[show]) }.to output(/graph TD/).to_stdout + end + + it 'includes node labels in mermaid output' do + expect { described_class.start(%w[show]) }.to output(/lex-http\.fetch/).to_stdout + end + + it 'includes edge labels in mermaid output' do + expect { described_class.start(%w[show]) }.to output(/on_success/).to_stdout + end + + it 'renders dot format when requested' do + expect { described_class.start(%w[show --format dot]) }.to output(/digraph legion_tasks/).to_stdout + end + + it 'includes shape attributes in dot output' do + expect { described_class.start(%w[show --format dot]) }.to output(/shape=box/).to_stdout + end + + it 'writes to file when --output specified' do + tmpfile = File.join(Dir.mktmpdir, 'graph.md') + expect { described_class.start(['show', '--output', tmpfile]) }.to output(/Written to/).to_stdout + content = File.read(tmpfile) + expect(content).to include('graph TD') + FileUtils.rm_rf(File.dirname(tmpfile)) + end + + it 'passes chain filter to builder' do + described_class.start(%w[show --chain chain-42]) + expect(Legion::Graph::Builder).to have_received(:build).with(hash_including(chain_id: 'chain-42')) + end + + it 'passes limit to builder' do + described_class.start(%w[show --limit 50]) + expect(Legion::Graph::Builder).to have_received(:build).with(hash_including(limit: 50)) + end + + context 'with empty graph' do + let(:graph_data) { { nodes: {}, edges: [] } } + + it 'renders minimal mermaid output' do + expect { described_class.start(%w[show]) }.to output(/graph TD/).to_stdout + end + end + end +end diff --git a/spec/legion/cli/image_command_spec.rb b/spec/legion/cli/image_command_spec.rb new file mode 100644 index 00000000..526db023 --- /dev/null +++ b/spec/legion/cli/image_command_spec.rb @@ -0,0 +1,407 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'base64' +require 'legion/cli' +require 'legion/cli/image_command' + +RSpec.describe Legion::CLI::Image do + let(:out) { instance_double(Legion::CLI::Output::Formatter) } + let(:llm_mod) { Module.new } + let(:llm_response) do + double('Response', content: 'A beautiful image.', + usage: double('Usage', input_tokens: 100, output_tokens: 20)) + end + let(:chat_session) { double('ChatSession', ask: llm_response) } + + before do + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(out).to receive(:success) + allow(out).to receive(:error) + allow(out).to receive(:warn) + allow(out).to receive(:json) + allow(out).to receive(:spacer) + allow(out).to receive(:detail) + allow(out).to receive(:header) + + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_llm) + allow(Legion::CLI::Connection).to receive(:shutdown) + + stub_const('Legion::LLM', llm_mod) + allow(Legion::LLM).to receive(:chat).and_return(chat_session) + end + + def build_command(opts = {}) + described_class.new([], opts.merge(json: false, no_color: true, verbose: false, + format: 'text', prompt: 'Describe this image in detail')) + end + + def build_json_command(opts = {}) + described_class.new([], opts.merge(json: true, no_color: true, verbose: false, + format: 'json', prompt: 'Describe this image in detail')) + end + + def with_temp_image(ext = 'png') + require 'tempfile' + file = Tempfile.new(['test_image', ".#{ext}"]) + file.binmode + file.write("\x89PNG\r\n\x1a\n") + file.flush + yield file.path + ensure + file&.unlink + end + + describe 'class structure' do + it 'is a Thor subcommand' do + expect(described_class).to be < Thor + end + + it 'defines analyze and compare commands' do + expect(described_class.commands.keys).to include('analyze', 'compare') + end + + it 'has SUPPORTED_TYPES covering common image formats' do + expect(described_class::SUPPORTED_TYPES).to include('png', 'jpg', 'jpeg', 'gif', 'webp') + end + + it 'maps extensions to correct MIME types' do + expect(described_class::MIME_TYPES['png']).to eq('image/png') + expect(described_class::MIME_TYPES['jpg']).to eq('image/jpeg') + expect(described_class::MIME_TYPES['jpeg']).to eq('image/jpeg') + expect(described_class::MIME_TYPES['gif']).to eq('image/gif') + expect(described_class::MIME_TYPES['webp']).to eq('image/webp') + end + end + + describe '#analyze' do + context 'with a valid image file' do + it 'reads image, sends to LLM, and outputs response' do + with_temp_image('png') do |path| + cmd = build_command + expect(Legion::LLM).to receive(:chat).and_return(chat_session) + expect(out).to receive(:header).with('Analysis') + cmd.analyze(path) + end + end + + it 'base64-encodes the image data in the message' do + with_temp_image('png') do |path| + raw = File.binread(path) + expected_b64 = Base64.strict_encode64(raw) + cmd = build_command + + expect(chat_session).to receive(:ask) do |content| + image_block = content.find { |b| b[:type] == 'image' } + expect(image_block[:source][:data]).to eq(expected_b64) + expect(image_block[:source][:media_type]).to eq('image/png') + llm_response + end + cmd.analyze(path) + end + end + + it 'includes the prompt as a text block in the message' do + with_temp_image('png') do |path| + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'text', prompt: 'What color is this?') + + expect(chat_session).to receive(:ask) do |content| + text_block = content.find { |b| b[:type] == 'text' } + expect(text_block[:text]).to eq('What color is this?') + llm_response + end + cmd.analyze(path) + end + end + + it 'passes model option to LLM when provided' do + with_temp_image('png') do |path| + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'text', prompt: 'desc', model: 'claude-opus-4-5') + expect(Legion::LLM).to receive(:chat) + .with(hash_including(model: 'claude-opus-4-5')) + .and_return(chat_session) + cmd.analyze(path) + end + end + + it 'passes provider option as symbol to LLM when provided' do + with_temp_image('png') do |path| + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'text', prompt: 'desc', provider: 'anthropic') + expect(Legion::LLM).to receive(:chat) + .with(hash_including(provider: :anthropic)) + .and_return(chat_session) + cmd.analyze(path) + end + end + end + + context 'MIME type detection' do + %w[jpg jpeg].each do |ext| + it "maps .#{ext} to image/jpeg" do + with_temp_image(ext) do |path| + cmd = build_command + expect(chat_session).to receive(:ask) do |content| + image_block = content.find { |b| b[:type] == 'image' } + expect(image_block[:source][:media_type]).to eq('image/jpeg') + llm_response + end + cmd.analyze(path) + end + end + end + + %w[gif webp].each do |ext| + it "maps .#{ext} to image/#{ext}" do + with_temp_image(ext) do |path| + cmd = build_command + expect(chat_session).to receive(:ask) do |content| + image_block = content.find { |b| b[:type] == 'image' } + expect(image_block[:source][:media_type]).to eq("image/#{ext}") + llm_response + end + cmd.analyze(path) + end + end + end + end + + context 'output format' do + it 'renders text output by default' do + with_temp_image('png') do |path| + cmd = build_command + expect(out).to receive(:header).with('Analysis') + expect(out).to receive(:spacer).at_least(:once) + cmd.analyze(path) + end + end + + it 'outputs JSON when --format json is set' do + with_temp_image('png') do |path| + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'json', prompt: 'desc') + expect(out).to receive(:json).with(hash_including(response: 'A beautiful image.')) + cmd.analyze(path) + end + end + + it 'outputs JSON when --json flag is set' do + with_temp_image('png') do |path| + cmd = build_json_command + expect(out).to receive(:json).with(hash_including( + path: path, + response: 'A beautiful image.' + )) + cmd.analyze(path) + end + end + + it 'includes usage stats in JSON output' do + with_temp_image('png') do |path| + cmd = build_json_command + expect(out).to receive(:json).with(hash_including( + usage: { input_tokens: 100, output_tokens: 20 } + )) + cmd.analyze(path) + end + end + end + + context 'error cases' do + it 'shows error and exits when file does not exist' do + cmd = build_command + expect(out).to receive(:error).with(/File not found/) + expect { cmd.analyze('/nonexistent/path/image.png') }.to raise_error(SystemExit) + end + + it 'shows error and exits for unsupported file type' do + require 'tempfile' + file = Tempfile.new(['test', '.bmp']) + file.close + + cmd = build_command + expect(out).to receive(:error).with(/Unsupported image type/) + expect { cmd.analyze(file.path) }.to raise_error(SystemExit) + ensure + file&.unlink + end + + it 'shows error when LLM raises an exception' do + with_temp_image('png') do |path| + allow(chat_session).to receive(:ask).and_raise(StandardError, 'provider unavailable') + cmd = build_command + expect(out).to receive(:error).with(/LLM call failed.*provider unavailable/) + expect { cmd.analyze(path) }.to raise_error(SystemExit) + end + end + + it 'shows error when LLM connection setup fails' do + with_temp_image('png') do |path| + allow(Legion::CLI::Connection).to receive(:ensure_llm) + .and_raise(Legion::CLI::Error, 'legion-llm gem is not installed') + cmd = build_command + expect(out).to receive(:error).with(/legion-llm gem is not installed/) + expect { cmd.analyze(path) }.to raise_error(SystemExit) + end + end + end + end + + describe '#compare' do + context 'with two valid image files' do + it 'sends both images to LLM in a single message' do + with_temp_image('png') do |path1| + with_temp_image('jpg') do |path2| + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'text', + prompt: 'Compare these two images and describe the differences') + + expect(chat_session).to receive(:ask) do |content| + image_blocks = content.select { |b| b[:type] == 'image' } + expect(image_blocks.length).to eq(2) + expect(image_blocks[0][:source][:media_type]).to eq('image/png') + expect(image_blocks[1][:source][:media_type]).to eq('image/jpeg') + llm_response + end + cmd.compare(path1, path2) + end + end + end + + it 'includes the comparison prompt as text block' do + with_temp_image('png') do |path1| + with_temp_image('png') do |path2| + custom_prompt = 'Which image is brighter?' + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'text', prompt: custom_prompt) + + expect(chat_session).to receive(:ask) do |content| + text_block = content.find { |b| b[:type] == 'text' } + expect(text_block[:text]).to eq(custom_prompt) + llm_response + end + cmd.compare(path1, path2) + end + end + end + + it 'renders text output with analysis header' do + with_temp_image('png') do |path1| + with_temp_image('png') do |path2| + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'text', + prompt: 'Compare these two images and describe the differences') + expect(out).to receive(:header).with('Analysis') + cmd.compare(path1, path2) + end + end + end + + it 'outputs JSON with both paths when --json is set' do + with_temp_image('png') do |path1| + with_temp_image('png') do |path2| + cmd = build_json_command(prompt: 'Compare these two images and describe the differences') + expect(out).to receive(:json).with(hash_including( + path1: path1, + path2: path2, + response: 'A beautiful image.' + )) + cmd.compare(path1, path2) + end + end + end + end + + context 'error cases' do + it 'shows error and exits when first file does not exist' do + with_temp_image('png') do |path2| + cmd = build_command + expect(out).to receive(:error).with(/File not found/) + expect { cmd.compare('/nonexistent/image.png', path2) }.to raise_error(SystemExit) + end + end + + it 'shows error and exits when second file does not exist' do + with_temp_image('png') do |path1| + cmd = build_command + expect(out).to receive(:error).with(/File not found/) + expect { cmd.compare(path1, '/nonexistent/image.png') }.to raise_error(SystemExit) + end + end + + it 'shows error for unsupported type on first image' do + require 'tempfile' + bad = Tempfile.new(['img', '.tiff']) + bad.close + with_temp_image('png') do |path2| + cmd = build_command + expect(out).to receive(:error).with(/Unsupported image type/) + expect { cmd.compare(bad.path, path2) }.to raise_error(SystemExit) + end + ensure + bad&.unlink + end + end + end + + describe '#load_image' do + it 'returns a hash with path, mime_type, and base64 data' do + with_temp_image('png') do |path| + cmd = build_command + result = cmd.load_image(path, out) + expect(result[:path]).to eq(path) + expect(result[:mime_type]).to eq('image/png') + expect(result[:data]).to eq(Base64.strict_encode64(File.binread(path))) + end + end + + it 'raises SystemExit for missing file' do + cmd = build_command + expect(out).to receive(:error).with(/File not found/) + expect { cmd.load_image('/no/such/file.png', out) }.to raise_error(SystemExit) + end + + it 'raises SystemExit for unsupported extension' do + require 'tempfile' + f = Tempfile.new(['img', '.svg']) + f.close + cmd = build_command + expect(out).to receive(:error).with(/Unsupported image type/) + expect { cmd.load_image(f.path, out) }.to raise_error(SystemExit) + ensure + f&.unlink + end + end + + describe '#build_image_message' do + it 'builds a user message with image and text content blocks' do + cmd = build_command + images = [{ path: '/img.png', mime_type: 'image/png', data: 'abc123' }] + msg = cmd.build_image_message(images, 'What is this?') + + expect(msg[:role]).to eq('user') + expect(msg[:content].length).to eq(2) + expect(msg[:content][0][:type]).to eq('image') + expect(msg[:content][0][:source][:type]).to eq('base64') + expect(msg[:content][0][:source][:media_type]).to eq('image/png') + expect(msg[:content][0][:source][:data]).to eq('abc123') + expect(msg[:content][1][:type]).to eq('text') + expect(msg[:content][1][:text]).to eq('What is this?') + end + + it 'includes all images when multiple are provided' do + cmd = build_command + images = [ + { path: '/a.png', mime_type: 'image/png', data: 'data1' }, + { path: '/b.jpg', mime_type: 'image/jpeg', data: 'data2' } + ] + msg = cmd.build_image_message(images, 'Compare') + image_blocks = msg[:content].select { |b| b[:type] == 'image' } + expect(image_blocks.length).to eq(2) + end + end +end diff --git a/spec/legion/cli/init/config_generator_spec.rb b/spec/legion/cli/init/config_generator_spec.rb new file mode 100644 index 00000000..edc900a2 --- /dev/null +++ b/spec/legion/cli/init/config_generator_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/init/config_generator' +require 'tmpdir' + +RSpec.describe Legion::CLI::InitHelpers::ConfigGenerator do + describe '.scaffold_workspace' do + it 'creates .legion directory structure' do + Dir.mktmpdir do |dir| + described_class.scaffold_workspace(dir) + expect(Dir.exist?(File.join(dir, '.legion', 'agents'))).to be true + expect(Dir.exist?(File.join(dir, '.legion', 'skills'))).to be true + expect(Dir.exist?(File.join(dir, '.legion', 'memory'))).to be true + expect(File.exist?(File.join(dir, '.legion', 'settings.json'))).to be true + end + end + + it 'does not overwrite existing settings.json' do + Dir.mktmpdir do |dir| + legion_dir = File.join(dir, '.legion') + FileUtils.mkdir_p(legion_dir) + File.write(File.join(legion_dir, 'settings.json'), '{"custom": true}') + + described_class.scaffold_workspace(dir) + content = File.read(File.join(legion_dir, 'settings.json')) + expect(content).to eq('{"custom": true}') + end + end + + it 'creates .gitignore with legion entries' do + Dir.mktmpdir do |dir| + described_class.scaffold_workspace(dir) + gitignore = File.read(File.join(dir, '.gitignore')) + expect(gitignore).to include('.legion-context/') + expect(gitignore).to include('.legion-worktrees/') + end + end + + it 'appends to existing .gitignore without duplicating' do + Dir.mktmpdir do |dir| + File.write(File.join(dir, '.gitignore'), "node_modules/\n.legion-context/\n") + described_class.scaffold_workspace(dir) + gitignore = File.read(File.join(dir, '.gitignore')) + expect(gitignore.scan('.legion-context/').size).to eq(1) + expect(gitignore).to include('.legion-worktrees/') + end + end + end +end diff --git a/spec/legion/cli/init/environment_detector_spec.rb b/spec/legion/cli/init/environment_detector_spec.rb new file mode 100644 index 00000000..1482b34d --- /dev/null +++ b/spec/legion/cli/init/environment_detector_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/init/environment_detector' + +RSpec.describe Legion::CLI::InitHelpers::EnvironmentDetector do + describe '.detect' do + it 'returns hash with expected keys' do + result = described_class.detect + expect(result.keys).to include(:rabbitmq, :database, :vault, :redis, :git, :existing_config) + end + + it 'detects vault from VAULT_ADDR env' do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('VAULT_ADDR').and_return('https://vault.example.com') + result = described_class.detect + expect(result[:vault][:available]).to be true + end + + it 'always detects database as available (sqlite fallback)' do + result = described_class.detect + expect(result[:database][:available]).to be true + end + end +end diff --git a/spec/legion/cli/init_command_spec.rb b/spec/legion/cli/init_command_spec.rb new file mode 100644 index 00000000..41077bfa --- /dev/null +++ b/spec/legion/cli/init_command_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'legion/cli/init/environment_detector' +require 'legion/cli/init/config_generator' + +RSpec.describe Legion::CLI::InitHelpers::EnvironmentDetector do + describe '.detect' do + it 'returns a hash with expected keys' do + result = described_class.detect + expect(result).to have_key(:rabbitmq) + expect(result).to have_key(:database) + expect(result).to have_key(:vault) + expect(result).to have_key(:redis) + expect(result).to have_key(:git) + expect(result).to have_key(:existing_config) + end + + it 'database always returns available' do + result = described_class.detect + expect(result[:database][:available]).to be true + end + + it 'detects git repo when .git exists' do + result = described_class.detect + expect(result[:git][:available]).to eq(Dir.exist?('.git')) + end + + it 'detects VAULT_ADDR from env' do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('VAULT_ADDR').and_return('http://localhost:8200') + result = described_class.detect + expect(result[:vault][:available]).to be true + expect(result[:vault][:source]).to eq('env') + end + + it 'detects DATABASE_URL from env' do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('DATABASE_URL').and_return('postgres://localhost/test') + result = described_class.detect + expect(result[:database][:adapter]).to eq('postgresql') + end + + it 'returns rabbitmq unavailable when socket fails' do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('AMQP_URL').and_return(nil) + allow(ENV).to receive(:[]).with('RABBITMQ_URL').and_return(nil) + allow(Socket).to receive(:tcp).and_raise(Errno::ECONNREFUSED) + result = described_class.detect + expect(result[:rabbitmq][:available]).to be false + end + end +end + +RSpec.describe Legion::CLI::InitHelpers::ConfigGenerator do + let(:tmpdir) { Dir.mktmpdir('init-test') } + let(:config_dir) { File.join(tmpdir, 'settings') } + + before do + stub_const('Legion::CLI::InitHelpers::ConfigGenerator::CONFIG_DIR', config_dir) + end + + after { FileUtils.rm_rf(tmpdir) } + + describe '.scaffold_workspace' do + it 'creates .legion directory structure' do + described_class.scaffold_workspace(tmpdir) + expect(Dir).to exist(File.join(tmpdir, '.legion')) + expect(Dir).to exist(File.join(tmpdir, '.legion', 'agents')) + expect(Dir).to exist(File.join(tmpdir, '.legion', 'skills')) + expect(Dir).to exist(File.join(tmpdir, '.legion', 'memory')) + end + + it 'creates settings.json' do + described_class.scaffold_workspace(tmpdir) + expect(File).to exist(File.join(tmpdir, '.legion', 'settings.json')) + end + + it 'does not overwrite existing settings.json' do + FileUtils.mkdir_p(File.join(tmpdir, '.legion')) + settings_path = File.join(tmpdir, '.legion', 'settings.json') + File.write(settings_path, '{"existing": true}') + + described_class.scaffold_workspace(tmpdir) + expect(File.read(settings_path)).to eq('{"existing": true}') + end + + it 'adds gitignore entries' do + described_class.scaffold_workspace(tmpdir) + gitignore = File.read(File.join(tmpdir, '.gitignore')) + expect(gitignore).to include('.legion-context/') + expect(gitignore).to include('.legion-worktrees/') + end + + it 'does not duplicate gitignore entries on second run' do + described_class.scaffold_workspace(tmpdir) + described_class.scaffold_workspace(tmpdir) + gitignore = File.read(File.join(tmpdir, '.gitignore')) + expect(gitignore.scan('.legion-context/').length).to eq(1) + end + + it 'returns workspace directory path' do + result = described_class.scaffold_workspace(tmpdir) + expect(result).to eq(File.join(tmpdir, '.legion')) + end + end + + describe '.generate' do + it 'creates config directory' do + described_class.generate({}) + expect(Dir).to exist(config_dir) + end + + it 'skips existing files without force flag' do + FileUtils.mkdir_p(config_dir) + File.write(File.join(config_dir, 'core.json'), '{"existing": true}') + + result = described_class.generate({}) + expect(result).to be_empty + end + end +end diff --git a/spec/legion/cli/knowledge_command_spec.rb b/spec/legion/cli/knowledge_command_spec.rb new file mode 100644 index 00000000..914a4765 --- /dev/null +++ b/spec/legion/cli/knowledge_command_spec.rb @@ -0,0 +1,817 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/error' +require 'legion/cli/knowledge_command' + +RSpec.describe Legion::CLI::Knowledge do + let(:query_result_success) do + { + success: true, + answer: 'Legion uses RabbitMQ for async messaging.', + sources: [ + { source_file: 'README.md', heading: 'Transport', content: 'RabbitMQ AMQP 0.9.1', score: 0.95 }, + { source_file: 'CLAUDE.md', heading: '', content: 'legion-transport gem', score: 0.82 } + ] + } + end + + let(:retrieve_result_success) do + { + success: true, + sources: [ + { source_file: 'docs/transport.md', heading: 'Setup', content: 'AMQP connection', score: 0.91 } + ] + } + end + + let(:ingest_file_result_success) do + { success: true, file_path: '/tmp/doc.md', chunks: 4 } + end + + let(:ingest_corpus_result_success) do + { success: true, path: '/tmp/docs', files_ingested: 3, chunks: 12 } + end + + let(:scan_result) do + { path: Dir.pwd, file_count: 7, total_bytes: 45_678 } + end + + let(:health_result_success) do + { + success: true, + local: { 'chunks' => 42, 'sources' => 5 }, + apollo: { 'entries' => 38, 'reachable' => true }, + sync: { 'in_sync' => true, 'drift' => 0 } + } + end + + let(:cleanup_result_success) do + { + success: true, + orphan_files: ['stale/old.md'], + archived: 1, + files_cleaned: 1, + dry_run: true + } + end + + let(:quality_result_success) do + { + success: true, + hot_chunks: [{ id: 1, confidence: 0.95, source_file: 'README.md' }], + cold_chunks: [{ id: 2, confidence: 0.10, source_file: 'archive/old.md' }], + low_confidence: [{ id: 3, confidence: 0.05, source_file: 'draft.md' }], + summary: { 'total' => 100, 'healthy' => 88 } + } + end + + let(:monitor_add_result_success) do + { success: true } + end + + let(:monitor_remove_result_success) do + { success: true } + end + + let(:monitor_list_result) do + [ + { path: '/opt/docs', label: 'docs', extensions: %w[md rb] }, + { path: '/opt/wiki', label: nil, extensions: %w[md] } + ] + end + + let(:monitor_status_result) do + { total_monitors: 2, total_files: 47 } + end + + describe '#query' do + before do + allow_any_instance_of(described_class).to receive(:api_post).and_return(query_result_success) + end + + it 'shows Knowledge Query header' do + expect do + described_class.start(['query', 'what is legion transport', '--no-color']) + end.to output(/Knowledge Query/).to_stdout + end + + it 'prints the synthesized answer' do + expect do + described_class.start(['query', 'what is legion transport', '--no-color']) + end.to output(/RabbitMQ/).to_stdout + end + + it 'shows source files' do + expect do + described_class.start(['query', 'what is legion transport', '--no-color']) + end.to output(/README\.md/).to_stdout + end + + it 'passes top_k to api_post' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/query', hash_including(top_k: 10)) + .and_return(query_result_success) + described_class.start(['query', 'test question', '--top-k', '10', '--no-color']) + end + + it 'passes synthesize: true by default' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/query', hash_including(synthesize: true)) + .and_return(query_result_success) + described_class.start(['query', 'test question', '--no-color']) + end + + it 'passes synthesize: false when --no-synthesize is given' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/query', hash_including(synthesize: false)) + .and_return(query_result_success) + described_class.start(['query', 'test question', '--no-synthesize', '--no-color']) + end + + context 'with --verbose' do + it 'prints source content' do + expect do + described_class.start(['query', 'test question', '--verbose', '--no-color']) + end.to output(/RabbitMQ AMQP/).to_stdout + end + end + + context 'when query fails' do + before do + allow_any_instance_of(described_class).to receive(:api_post) + .and_return({ success: false, error: 'embedding unavailable' }) + end + + it 'shows error message' do + expect do + described_class.start(['query', 'broken query', '--no-color']) + end.to output(/embedding unavailable/).to_stdout + end + end + + context 'with --json' do + it 'outputs JSON' do + expect do + described_class.start(['query', 'test question', '--json', '--no-color']) + end.to output(/success/).to_stdout + end + end + end + + describe '#retrieve' do + before do + allow_any_instance_of(described_class).to receive(:api_post).and_return(retrieve_result_success) + end + + it 'shows Knowledge Retrieve header' do + expect do + described_class.start(['retrieve', 'AMQP setup', '--no-color']) + end.to output(/Knowledge Retrieve/).to_stdout + end + + it 'shows chunk count in header' do + expect do + described_class.start(['retrieve', 'AMQP setup', '--no-color']) + end.to output(/1 chunk/).to_stdout + end + + it 'shows source file' do + expect do + described_class.start(['retrieve', 'AMQP setup', '--no-color']) + end.to output(/transport\.md/).to_stdout + end + + it 'passes top_k to api_post' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/retrieve', hash_including(top_k: 3)) + .and_return(retrieve_result_success) + described_class.start(['retrieve', 'test', '--top-k', '3', '--no-color']) + end + + context 'with --json' do + it 'outputs JSON' do + expect do + described_class.start(['retrieve', 'test', '--json', '--no-color']) + end.to output(/sources/).to_stdout + end + end + end + + describe '#ingest' do + context 'with a file path' do + let(:tmpfile) { File.join(Dir.mktmpdir, 'test.md') } + + before do + File.write(tmpfile, '# Test') + allow_any_instance_of(described_class).to receive(:api_post).and_return(ingest_file_result_success) + end + + after { FileUtils.rm_rf(File.dirname(tmpfile)) } + + it 'calls api_post with the expanded file path' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/ingest', hash_including(path: tmpfile)) + .and_return(ingest_file_result_success) + described_class.start(['ingest', tmpfile, '--no-color']) + end + + it 'shows Ingest complete' do + expect do + described_class.start(['ingest', tmpfile, '--no-color']) + end.to output(/Ingest complete/).to_stdout + end + + it 'passes force: true when --force given' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/ingest', hash_including(force: true)) + .and_return(ingest_file_result_success) + described_class.start(['ingest', tmpfile, '--force', '--no-color']) + end + + it 'passes dry_run: true when --dry-run given' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/ingest', hash_including(dry_run: true)) + .and_return(ingest_file_result_success) + described_class.start(['ingest', tmpfile, '--dry-run', '--no-color']) + end + + it 'omits dry_run from payload when --dry-run not given' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/ingest', hash_excluding(:dry_run)) + .and_return(ingest_file_result_success) + described_class.start(['ingest', tmpfile, '--no-color']) + end + end + + context 'with a directory path' do + let(:tmpdir) { Dir.mktmpdir('knowledge-test') } + + before do + allow_any_instance_of(described_class).to receive(:api_post).and_return(ingest_corpus_result_success) + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'calls api_post with the expanded directory path' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/ingest', hash_including(path: tmpdir)) + .and_return(ingest_corpus_result_success) + described_class.start(['ingest', tmpdir, '--no-color']) + end + + it 'shows Ingest complete' do + expect do + described_class.start(['ingest', tmpdir, '--no-color']) + end.to output(/Ingest complete/).to_stdout + end + + it 'passes dry_run: true when --dry-run given' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/ingest', hash_including(dry_run: true)) + .and_return(ingest_corpus_result_success) + described_class.start(['ingest', tmpdir, '--dry-run', '--no-color']) + end + end + + context 'when ingest fails' do + let(:tmpfile) { File.join(Dir.mktmpdir, 'fail.md') } + + before do + File.write(tmpfile, '# Fail') + allow_any_instance_of(described_class).to receive(:api_post) + .and_return({ success: false, error: 'parse error' }) + end + + after { FileUtils.rm_rf(File.dirname(tmpfile)) } + + it 'shows error message' do + expect do + described_class.start(['ingest', tmpfile, '--no-color']) + end.to output(/parse error/).to_stdout + end + end + + context 'with --json' do + let(:tmpfile) { File.join(Dir.mktmpdir, 'json.md') } + + before do + File.write(tmpfile, '# JSON') + allow_any_instance_of(described_class).to receive(:api_post).and_return(ingest_file_result_success) + end + + after { FileUtils.rm_rf(File.dirname(tmpfile)) } + + it 'outputs JSON' do + expect do + described_class.start(['ingest', tmpfile, '--json', '--no-color']) + end.to output(/success/).to_stdout + end + end + end + + describe '#status' do + before do + allow_any_instance_of(described_class).to receive(:api_post).and_return(scan_result) + end + + it 'shows Knowledge Status header' do + expect do + described_class.start(%w[status --no-color]) + end.to output(/Knowledge Status/).to_stdout + end + + it 'shows file count' do + expect do + described_class.start(%w[status --no-color]) + end.to output(/7/).to_stdout + end + + it 'shows total bytes' do + expect do + described_class.start(%w[status --no-color]) + end.to output(/45678/).to_stdout + end + + it 'calls api_post with Dir.pwd' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/status', hash_including(path: Dir.pwd)) + .and_return(scan_result) + described_class.start(%w[status --no-color]) + end + + context 'with --json' do + it 'outputs JSON' do + output = capture_stdout { described_class.start(%w[status --json --no-color]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:file_count]).to eq(7) + end + end + end + + describe '#health' do + before do + allow_any_instance_of(described_class).to receive(:api_post).and_return(health_result_success) + end + + it 'shows Knowledge Health header' do + expect do + described_class.start(%w[health --no-color]) + end.to output(/Knowledge Health/).to_stdout + end + + it 'shows Local section' do + expect do + described_class.start(%w[health --no-color]) + end.to output(/Local/).to_stdout + end + + it 'shows Apollo section' do + expect do + described_class.start(%w[health --no-color]) + end.to output(/Apollo/).to_stdout + end + + it 'shows Sync section' do + expect do + described_class.start(%w[health --no-color]) + end.to output(/Sync/).to_stdout + end + + it 'calls api_post with a path key' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/health', hash_including(:path)) + .and_return(health_result_success) + described_class.start(%w[health --no-color]) + end + + it 'passes --corpus-path to api_post' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/health', hash_including(path: '/custom/path')) + .and_return(health_result_success) + described_class.start(['health', '--corpus-path', '/custom/path', '--no-color']) + end + + context 'when health check fails' do + before do + allow_any_instance_of(described_class).to receive(:api_post) + .and_return({ success: false, error: 'DB unreachable' }) + end + + it 'shows error message' do + expect do + described_class.start(%w[health --no-color]) + end.to output(/DB unreachable/).to_stdout + end + end + + context 'with --json' do + it 'outputs JSON' do + output = capture_stdout { described_class.start(%w[health --json --no-color]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:success]).to eq(true) + end + end + end + + describe '#maintain' do + before do + allow_any_instance_of(described_class).to receive(:api_post).and_return(cleanup_result_success) + end + + it 'shows Knowledge Maintain header with dry run label' do + expect do + described_class.start(%w[maintain --no-color]) + end.to output(/Knowledge Maintain \(dry run\)/).to_stdout + end + + it 'shows orphan files' do + expect do + described_class.start(%w[maintain --no-color]) + end.to output(%r{stale/old\.md}).to_stdout + end + + it 'defaults dry_run to true' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/maintain', hash_including(dry_run: true)) + .and_return(cleanup_result_success) + described_class.start(%w[maintain --no-color]) + end + + it 'passes dry_run: false when --no-dry-run given' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/maintain', hash_including(dry_run: false)) + .and_return(cleanup_result_success.merge(dry_run: false)) + described_class.start(%w[maintain --no-dry-run --no-color]) + end + + it 'omits dry run label when --no-dry-run given' do + allow_any_instance_of(described_class).to receive(:api_post) + .and_return(cleanup_result_success.merge(dry_run: false)) + expect do + described_class.start(%w[maintain --no-dry-run --no-color]) + end.to output(/Knowledge Maintain\z|Knowledge Maintain\n/).to_stdout + end + + it 'passes --corpus-path to api_post' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/maintain', hash_including(path: '/my/corpus')) + .and_return(cleanup_result_success) + described_class.start(['maintain', '--corpus-path', '/my/corpus', '--no-color']) + end + + context 'when maintenance fails' do + before do + allow_any_instance_of(described_class).to receive(:api_post) + .and_return({ success: false, error: 'index locked' }) + end + + it 'shows error message' do + expect do + described_class.start(%w[maintain --no-color]) + end.to output(/index locked/).to_stdout + end + end + + context 'with --json' do + it 'outputs JSON' do + output = capture_stdout { described_class.start(%w[maintain --json --no-color]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:success]).to eq(true) + end + end + end + + describe '#quality' do + before do + allow_any_instance_of(described_class).to receive(:api_post).and_return(quality_result_success) + end + + it 'shows Knowledge Quality Report header' do + expect do + described_class.start(%w[quality --no-color]) + end.to output(/Knowledge Quality Report/).to_stdout + end + + it 'shows Hot Chunks section' do + expect do + described_class.start(%w[quality --no-color]) + end.to output(/Hot Chunks/).to_stdout + end + + it 'shows Cold Chunks section' do + expect do + described_class.start(%w[quality --no-color]) + end.to output(/Cold Chunks/).to_stdout + end + + it 'shows Low Confidence section' do + expect do + described_class.start(%w[quality --no-color]) + end.to output(/Low Confidence/).to_stdout + end + + it 'shows source file names in chunks' do + expect do + described_class.start(%w[quality --no-color]) + end.to output(/README\.md/).to_stdout + end + + it 'passes limit to api_post' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/quality', hash_including(limit: 20)) + .and_return(quality_result_success) + described_class.start(%w[quality --limit 20 --no-color]) + end + + it 'defaults limit to 10' do + expect_any_instance_of(described_class).to receive(:api_post) + .with('/api/knowledge/quality', hash_including(limit: 10)) + .and_return(quality_result_success) + described_class.start(%w[quality --no-color]) + end + + it 'shows (none) for empty chunk sections' do + allow_any_instance_of(described_class).to receive(:api_post) + .and_return(quality_result_success.merge(hot_chunks: [], cold_chunks: [], low_confidence: [])) + expect do + described_class.start(%w[quality --no-color]) + end.to output(/\(none\)/).to_stdout + end + + context 'when quality report fails' do + before do + allow_any_instance_of(described_class).to receive(:api_post) + .and_return({ success: false, error: 'no index found' }) + end + + it 'shows error message' do + expect do + described_class.start(%w[quality --no-color]) + end.to output(/no index found/).to_stdout + end + end + + context 'with --json' do + it 'outputs JSON' do + output = capture_stdout { described_class.start(%w[quality --json --no-color]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:success]).to eq(true) + end + end + end + + describe 'monitor subcommand' do + describe 'add' do + it 'calls api_post with path and shows success' do + expect_any_instance_of(Legion::CLI::MonitorCommand).to receive(:api_post) + .with('/api/knowledge/monitors', hash_including(path: '/opt/docs')) + .and_return(monitor_add_result_success) + expect do + Legion::CLI::MonitorCommand.start(['add', '/opt/docs', '--no-color']) + end.to output(/Monitor added/).to_stdout + end + + it 'passes extensions as array' do + expect_any_instance_of(Legion::CLI::MonitorCommand).to receive(:api_post) + .with('/api/knowledge/monitors', hash_including(extensions: %w[md rb])) + .and_return(monitor_add_result_success) + Legion::CLI::MonitorCommand.start(['add', '/opt/docs', '--extensions', 'md,rb', '--no-color']) + end + + it 'passes label option' do + expect_any_instance_of(Legion::CLI::MonitorCommand).to receive(:api_post) + .with('/api/knowledge/monitors', hash_including(label: 'my-docs')) + .and_return(monitor_add_result_success) + Legion::CLI::MonitorCommand.start(['add', '/opt/docs', '--label', 'my-docs', '--no-color']) + end + + it 'shows error when add fails' do + allow_any_instance_of(Legion::CLI::MonitorCommand).to receive(:api_post) + .and_return({ success: false, error: 'path not found' }) + expect do + Legion::CLI::MonitorCommand.start(['add', '/bad/path', '--no-color']) + end.to output(/path not found/).to_stdout + end + end + + describe 'list' do + before do + allow_any_instance_of(Legion::CLI::MonitorCommand).to receive(:api_get).and_return(monitor_list_result) + end + + it 'shows monitor paths' do + expect do + Legion::CLI::MonitorCommand.start(%w[list --no-color]) + end.to output(%r{/opt/docs}).to_stdout + end + + it 'shows monitor labels' do + expect do + Legion::CLI::MonitorCommand.start(%w[list --no-color]) + end.to output(/docs/).to_stdout + end + + it 'shows Knowledge Monitors header' do + expect do + Legion::CLI::MonitorCommand.start(%w[list --no-color]) + end.to output(/Knowledge Monitors/).to_stdout + end + + it 'shows no monitors message when list is empty' do + allow_any_instance_of(Legion::CLI::MonitorCommand).to receive(:api_get).and_return([]) + expect do + Legion::CLI::MonitorCommand.start(%w[list --no-color]) + end.to output(/No monitors registered/).to_stdout + end + end + + describe 'remove' do + it 'calls api_delete with identifier and shows success' do + expect_any_instance_of(Legion::CLI::MonitorCommand).to receive(:api_delete) + .with(a_string_matching(%r{/api/knowledge/monitors\?identifier=})) + .and_return(monitor_remove_result_success) + expect do + Legion::CLI::MonitorCommand.start(['remove', '/opt/docs', '--no-color']) + end.to output(/Monitor removed/).to_stdout + end + + it 'shows error when remove fails' do + allow_any_instance_of(Legion::CLI::MonitorCommand).to receive(:api_delete) + .and_return({ success: false, error: 'not found' }) + expect do + Legion::CLI::MonitorCommand.start(['remove', 'nonexistent', '--no-color']) + end.to output(/not found/).to_stdout + end + end + + describe 'status' do + before do + allow_any_instance_of(Legion::CLI::MonitorCommand).to receive(:api_get).and_return(monitor_status_result) + end + + it 'shows total monitors count' do + expect do + Legion::CLI::MonitorCommand.start(%w[status --no-color]) + end.to output(/2/).to_stdout + end + + it 'shows total files count' do + expect do + Legion::CLI::MonitorCommand.start(%w[status --no-color]) + end.to output(/47/).to_stdout + end + + it 'shows Monitor Status header' do + expect do + Legion::CLI::MonitorCommand.start(%w[status --no-color]) + end.to output(/Monitor Status/).to_stdout + end + end + end + + describe 'capture subcommand' do + describe 'commit' do + it 'outputs something for a valid git repo' do + git_log_cmd = "git log -1 --format='%H %s' 2>/dev/null" + git_log_result = "abc1234def5678 add monitor subcommand\n" + allow_any_instance_of(Legion::CLI::CaptureCommand) + .to receive(:`).with(git_log_cmd).and_return(git_log_result) + allow_any_instance_of(Legion::CLI::CaptureCommand) + .to receive(:`).with('git diff HEAD~1 --stat 2>/dev/null').and_return("1 file changed\n") + allow_any_instance_of(Legion::CLI::CaptureCommand) + .to receive(:api_post).and_return({ success: true }) + expect do + Legion::CLI::CaptureCommand.start(%w[commit --no-color]) + end.to output(/.+/).to_stdout + end + + it 'shows warning when no git commit found' do + allow_any_instance_of(Legion::CLI::CaptureCommand) + .to receive(:`).with("git log -1 --format='%H %s' 2>/dev/null").and_return('') + allow_any_instance_of(Legion::CLI::CaptureCommand) + .to receive(:`).with('git diff HEAD~1 --stat 2>/dev/null').and_return('') + expect do + Legion::CLI::CaptureCommand.start(%w[commit --no-color]) + end.to output(/No git commit found/).to_stdout + end + end + + describe 'transcript' do + let(:tmpdir) { Dir.mktmpdir('transcript-test') } + let(:session_id) { 'test-session-abc-123' } + let(:jsonl_path) { File.join(tmpdir, "#{session_id}.jsonl") } + + before do + lines = [ + { type: 'user', message: { role: 'user', content: 'hello world' }, + timestamp: '2026-03-27T10:00:00Z' }.to_json, + { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Hi there!' }] }, + timestamp: '2026-03-27T10:00:01Z' }.to_json, + { type: 'progress', data: { type: 'hook' } }.to_json, + { type: 'user', message: { role: 'user', content: 'fix the bug' }, + timestamp: '2026-03-27T10:01:00Z' }.to_json, + { type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Done!' }] }, + timestamp: '2026-03-27T10:01:05Z' }.to_json + ] + File.write(jsonl_path, "#{lines.join("\n")}\n") + + # Stub resolve_transcript_path to return our temp file + allow_any_instance_of(Legion::CLI::CaptureCommand) + .to receive(:resolve_transcript_path).and_return(jsonl_path) + allow_any_instance_of(Legion::CLI::CaptureCommand) + .to receive(:`).with(anything).and_return('legion') + end + + after { FileUtils.rm_rf(tmpdir) } + + it 'warns when no session ID is provided' do + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('CLAUDE_SESSION_ID', nil).and_return(nil) + expect do + Legion::CLI::CaptureCommand.start(%w[transcript --no-color]) + end.to output(/No session ID/).to_stdout + end + + it 'ingests conversation turns' do + expect_any_instance_of(Legion::CLI::CaptureCommand).to receive(:api_post) + .with('/api/knowledge/ingest', anything).twice + .and_return({ success: true }) + expect do + Legion::CLI::CaptureCommand.start(['transcript', '--session-id', session_id, '--no-color']) + end.to output(%r{Captured 2/2 turns}).to_stdout + end + + it 'skips progress entries' do + expect_any_instance_of(Legion::CLI::CaptureCommand).to receive(:api_post) + .with('/api/knowledge/ingest', anything).twice + .and_return({ success: true }) + Legion::CLI::CaptureCommand.start(['transcript', '--session-id', session_id, '--no-color']) + end + + it 'respects --max-chunks' do + expect_any_instance_of(Legion::CLI::CaptureCommand).to receive(:api_post) + .with('/api/knowledge/ingest', anything).once + .and_return({ success: true }) + expect do + Legion::CLI::CaptureCommand.start(['transcript', '--session-id', session_id, '--max-chunks', '1', '--no-color']) + end.to output(%r{Captured 1/1 turns}).to_stdout + end + + it 'tags with session ID' do + expect_any_instance_of(Legion::CLI::CaptureCommand).to receive(:api_post) + .with('/api/knowledge/ingest', hash_including(tags: include("session:#{session_id}"))) + .twice.and_return({ success: true }) + Legion::CLI::CaptureCommand.start(['transcript', '--session-id', session_id, '--no-color']) + end + + it 'includes turn content with user and assistant sections' do + expect_any_instance_of(Legion::CLI::CaptureCommand).to receive(:api_post) + .with('/api/knowledge/ingest', hash_including(content: /hello world.*Hi there!/m)) + .and_return({ success: true }) + expect_any_instance_of(Legion::CLI::CaptureCommand).to receive(:api_post) + .with('/api/knowledge/ingest', hash_including(content: /fix the bug.*Done!/m)) + .and_return({ success: true }) + Legion::CLI::CaptureCommand.start(['transcript', '--session-id', session_id, '--no-color']) + end + + context 'with --json' do + it 'outputs JSON with turn count' do + allow_any_instance_of(Legion::CLI::CaptureCommand).to receive(:api_post) + .and_return({ success: true }) + output = capture_stdout do + Legion::CLI::CaptureCommand.start(['transcript', '--session-id', session_id, '--json', '--no-color']) + end + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:turns]).to eq(2) + expect(parsed[:ingested]).to eq(2) + end + end + + context 'when transcript file is missing' do + before do + allow_any_instance_of(Legion::CLI::CaptureCommand) + .to receive(:resolve_transcript_path).and_return('/nonexistent/path.jsonl') + end + + it 'warns about missing transcript' do + expect do + Legion::CLI::CaptureCommand.start(['transcript', '--session-id', session_id, '--no-color']) + end.to output(/Transcript not found/).to_stdout + end + end + end + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end diff --git a/spec/legion/cli/lex_cli_manifest_spec.rb b/spec/legion/cli/lex_cli_manifest_spec.rb new file mode 100644 index 00000000..610fd267 --- /dev/null +++ b/spec/legion/cli/lex_cli_manifest_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/lex_cli_manifest' + +RSpec.describe Legion::CLI::LexCliManifest do + let(:cache_dir) { Dir.mktmpdir } + let(:manifest) { described_class.new(cache_dir: cache_dir) } + + after { FileUtils.remove_entry(cache_dir) } + + describe '#write_manifest' do + it 'writes a JSON file for a gem with CLI modules' do + manifest.write_manifest( + gem_name: 'lex-microsoft_teams', + gem_version: '0.6.0', + alias_name: 'teams', + commands: { + 'auth' => { + class_name: 'Legion::Extensions::MicrosoftTeams::CLI::Auth', + methods: { + 'login' => { desc: 'Authenticate via browser', args: %w[tenant_id client_id] }, + 'status' => { desc: 'Show auth state', args: [] } + } + } + } + ) + + path = File.join(cache_dir, 'lex-microsoft_teams.json') + expect(File.exist?(path)).to be true + data = JSON.parse(File.read(path)) + expect(data['alias']).to eq('teams') + expect(data['commands']['auth']['methods']['login']['desc']).to eq('Authenticate via browser') + end + end + + describe '#read_manifest' do + it 'returns nil for missing gem' do + expect(manifest.read_manifest('lex-nonexistent')).to be_nil + end + + it 'returns parsed manifest for existing gem' do + manifest.write_manifest(gem_name: 'lex-test', gem_version: '1.0', alias_name: nil, commands: {}) + result = manifest.read_manifest('lex-test') + expect(result['gem']).to eq('lex-test') + end + end + + describe '#resolve_alias' do + it 'returns gem name for a known alias' do + manifest.write_manifest(gem_name: 'lex-microsoft_teams', gem_version: '0.6.0', + alias_name: 'teams', commands: {}) + expect(manifest.resolve_alias('teams')).to eq('lex-microsoft_teams') + end + + it 'returns nil for unknown alias' do + expect(manifest.resolve_alias('unknown')).to be_nil + end + end + + describe '#all_manifests' do + it 'returns all cached manifests' do + manifest.write_manifest(gem_name: 'lex-a', gem_version: '1.0', alias_name: nil, commands: {}) + manifest.write_manifest(gem_name: 'lex-b', gem_version: '1.0', alias_name: nil, commands: {}) + expect(manifest.all_manifests.length).to eq(2) + end + end + + describe '#stale?' do + it 'returns true for missing manifest' do + expect(manifest.stale?('lex-unknown', '1.0')).to be true + end + + it 'returns false when version matches' do + manifest.write_manifest(gem_name: 'lex-test', gem_version: '1.0', alias_name: nil, commands: {}) + expect(manifest.stale?('lex-test', '1.0')).to be false + end + + it 'returns true when version differs' do + manifest.write_manifest(gem_name: 'lex-test', gem_version: '1.0', alias_name: nil, commands: {}) + expect(manifest.stale?('lex-test', '2.0')).to be true + end + end +end diff --git a/spec/legion/cli/lex_cli_spec.rb b/spec/legion/cli/lex_cli_spec.rb new file mode 100644 index 00000000..ec57d4b1 --- /dev/null +++ b/spec/legion/cli/lex_cli_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/lex_cli_manifest' + +RSpec.describe 'LEX CLI dispatch' do + let(:cache_dir) { Dir.mktmpdir } + let(:manifest) { Legion::CLI::LexCliManifest.new(cache_dir: cache_dir) } + + after { FileUtils.remove_entry(cache_dir) } + + before do + manifest.write_manifest( + gem_name: 'lex-microsoft_teams', + gem_version: '0.6.0', + alias_name: 'teams', + commands: { + 'auth' => { + class_name: 'Legion::Extensions::MicrosoftTeams::CLI::Auth', + methods: { + 'login' => { desc: 'Authenticate via browser', args: %w[tenant_id client_id] }, + 'status' => { desc: 'Show auth state', args: [] } + } + } + } + ) + end + + it 'resolves alias to gem name' do + expect(manifest.resolve_alias('teams')).to eq('lex-microsoft_teams') + end + + it 'finds commands in manifest' do + gem_manifest = manifest.read_manifest('lex-microsoft_teams') + expect(gem_manifest.dig('commands', 'auth', 'methods', 'login', 'desc')).to eq('Authenticate via browser') + end + + it 'returns nil for unknown aliases' do + expect(manifest.resolve_alias('nonexistent')).to be_nil + end +end diff --git a/spec/legion/cli/lex_command_spec.rb b/spec/legion/cli/lex_command_spec.rb new file mode 100644 index 00000000..3f2ca0a9 --- /dev/null +++ b/spec/legion/cli/lex_command_spec.rb @@ -0,0 +1,353 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'fileutils' +require 'legion/cli' +require 'legion/cli/lex_command' + +RSpec.describe Legion::CLI::Lex do + let(:out) { instance_double(Legion::CLI::Output::Formatter) } + + before do + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(out).to receive(:success) + allow(out).to receive(:error) + allow(out).to receive(:warn) + allow(out).to receive(:spacer) + allow(Dir).to receive(:exist?).and_return(false) + allow(Dir).to receive(:pwd).and_return('/tmp') + end + + def build_lex(opts = {}) + described_class.new([], { json: false, no_color: true }.merge(opts)) + end + + describe '#create' do + describe 'category format validation' do + it 'outputs an error and returns early when category contains uppercase letters' do + expect(Legion::Extensions).not_to receive(:check_reserved_words) + expect(Legion::CLI::LexGenerator).not_to receive(:new) + + lex = build_lex(category: 'My Category') + lex.create('anchor') + + expect(out).to have_received(:error).with('--category must be lowercase letters, numbers, underscores, or hyphens') + end + + it 'accepts a valid lowercase category' do + expect(Legion::Extensions).to receive(:check_reserved_words) + allow(Legion::CLI::LexGenerator).to receive(:new).and_return(double(generate: nil)) + + lex = build_lex(category: 'agentic') + lex.create('anchor') + end + end + + describe 'reserved word warning' do + it 'calls check_reserved_words on the derived gem name when category is given' do + expect(Legion::Extensions).to receive(:check_reserved_words) + .with('lex-agentic-cognitive-anchor', known_org: false) + allow(Legion::CLI::LexGenerator).to receive(:new).and_return(double(generate: nil)) + + lex = build_lex(category: 'agentic') + lex.create('cognitive-anchor') + end + + it 'calls check_reserved_words with plain gem name when no category given' do + expect(Legion::Extensions).to receive(:check_reserved_words) + .with('lex-mycustomext', known_org: false) + allow(Legion::CLI::LexGenerator).to receive(:new).and_return(double(generate: nil)) + + lex = build_lex + lex.create('mycustomext') + end + end + + describe '--template option' do + it 'passes the template name to LexGenerator' do + expect(Legion::Extensions).to receive(:check_reserved_words) + gen = double(generate: nil) + expect(Legion::CLI::LexGenerator).to receive(:new) + .with('myext', anything, anything, gem_name: 'lex-myext', template: 'llm-agent') + .and_return(gen) + + lex = build_lex(template: 'llm-agent') + lex.create('myext') + end + + it 'falls back to basic and warns on unknown template' do + allow(Legion::Extensions).to receive(:check_reserved_words) + allow(Legion::CLI::LexGenerator).to receive(:new).and_return(double(generate: nil)) + expect(out).to receive(:warn).with(/unknown template/i) + + lex = build_lex(template: 'nonexistent-template') + lex.create('myext') + end + + it 'uses basic template by default' do + expect(Legion::Extensions).to receive(:check_reserved_words) + gen = double(generate: nil) + expect(Legion::CLI::LexGenerator).to receive(:new) + .with('myext', anything, anything, gem_name: 'lex-myext', template: 'basic') + .and_return(gen) + + lex = build_lex + lex.create('myext') + end + end + + describe '--list-templates option' do + it 'outputs the template list and returns without creating anything' do + expect(Legion::CLI::LexGenerator).not_to receive(:new) + allow(out).to receive(:header) + allow(out).to receive(:table) + + lex = build_lex(list_templates: true) + lex.create + end + + it 'renders a table with template info' do + expect(out).to receive(:header).with(/template/i) + expect(out).to receive(:table) do |headers, rows| + expect(headers).to include('template') + expect(headers).to include('description') + expect(rows).not_to be_empty + end + + lex = build_lex(list_templates: true) + lex.create + end + end + + describe 'when NAME is omitted without --list-templates' do + it 'outputs an error and returns' do + expect(Legion::CLI::LexGenerator).not_to receive(:new) + expect(out).to receive(:error).with(/NAME is required/) + + lex = build_lex + lex.create + end + end + end + + describe '#discover_all' do + let(:fake_spec) do + instance_double( + Gem::Specification, + name: 'lex-node', + version: Gem::Version.new('0.2.3'), + gem_dir: '/fake/gem/dir', + runtime_dependencies: [] + ) + end + + before do + allow(Gem::Specification).to receive(:select).and_return([fake_spec]) + allow(Legion::CLI::Connection).to receive(:ensure_settings) + allow(Legion::Settings).to receive(:[]).with(:extensions).and_return({}) + allow(Legion::Settings).to receive(:dig).and_return(nil) + allow(Dir).to receive(:exist?).with('/fake/gem/dir/lib/legion/extensions/node/runners').and_return(false) + allow(Dir).to receive(:exist?).with('/fake/gem/dir/lib/legion/extensions/node/actors').and_return(false) + end + + it 'includes :category key in each extension info hash' do + lex = build_lex + results = lex.discover_all + expect(results.first).to have_key(:category) + end + + it 'includes :tier key in each extension info hash' do + lex = build_lex + results = lex.discover_all + expect(results.first).to have_key(:tier) + end + + it 'categorizes lex-node as core when core list contains it' do + allow(Legion::Settings).to receive(:dig).with(:extensions, :categories).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:extensions, :core).and_return(['lex-node']) + allow(Legion::Settings).to receive(:dig).with(:extensions, :ai).and_return([]) + allow(Legion::Settings).to receive(:dig).with(:extensions, :gaia).and_return([]) + + lex = build_lex + results = lex.discover_all + expect(results.first[:category]).to eq('core') + expect(results.first[:tier]).to eq(1) + end + + it 'uses :default category when gem is not in any list and has no matching prefix' do + allow(Legion::Settings).to receive(:dig).with(:extensions, :categories).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:extensions, :core).and_return([]) + allow(Legion::Settings).to receive(:dig).with(:extensions, :ai).and_return([]) + allow(Legion::Settings).to receive(:dig).with(:extensions, :gaia).and_return([]) + + lex = build_lex + results = lex.discover_all + expect(results.first[:category]).to eq('default') + end + end + + describe '#list' do + let(:fake_extensions) do + [ + { name: 'node', version: '0.2.3', status: 'installed', category: 'core', tier: 1, runners: [], actors: [] }, + { name: 'agentic-foo', version: '0.1.0', status: 'installed', category: 'agentic', tier: 4, runners: [], actors: [] }, + { name: 'openai', version: '0.1.0', status: 'installed', category: 'ai', tier: 2, runners: [], actors: [] }, + { name: 'custom-ext', version: '0.1.0', status: 'installed', category: 'default', tier: 5, runners: [], actors: [] } + ] + end + + before do + allow(out).to receive(:status).and_return('installed') + allow(out).to receive(:table) + allow(out).to receive(:header) + lex = build_lex + allow(lex).to receive(:discover_all).and_return(fake_extensions) + @lex = lex + end + + it 'groups output by category when no args and no --flat' do + expect(out).to receive(:header).at_least(:once) + @lex.list + end + + it 'renders a header for the default (tier 5) category in grouped mode' do + expect(out).to receive(:header).with(/default.*tier 5/i) + @lex.list + end + + it 'filters to a specific category when argument is given' do + expect(out).to receive(:table) do |_headers, rows| + names = rows.map(&:first) + expect(names).to all(eq('agentic-foo')) + end + @lex.list('agentic') + end + + it 'shows all extensions in a flat table when --flat is given' do + lex = build_lex(flat: true) + allow(lex).to receive(:discover_all).and_return(fake_extensions) + expect(out).to receive(:table) do |_headers, rows| + expect(rows.length).to eq(4) + end + lex.list + end + + it 'includes category column in flat mode table headers' do + lex = build_lex(flat: true) + allow(lex).to receive(:discover_all).and_return(fake_extensions) + expect(out).to receive(:table) do |headers, _rows| + expect(headers).to include('category') + end + lex.list + end + end +end + +RSpec.describe Legion::CLI::LexGenerator do + let(:base_options) do + { rspec: false, github_ci: false, git_init: false, bundle_install: false } + end + + describe 'flat (no category) scaffolding' do + let(:name) { 'myext' } + let(:gem_name) { 'lex-myext' } + let(:vars) { { filename: gem_name, class_name: 'Myext', lex: name } } + subject(:generator) { described_class.new(name, vars, base_options) } + + it 'derives a flat gem name' do + expect(generator.send(:gem_name)).to eq('lex-myext') + end + + it 'generates a flat module declaration' do + content = generator.send(:extension_entry_content) + expect(content).to include('module Legion') + expect(content).to include('module Extensions') + expect(content).to include('module Myext') + end + + it 'generates a flat version constant' do + content = generator.send(:version_content) + expect(content).to include('module Myext') + expect(content).to include("VERSION = '0.1.0'") + end + + it 'generates a flat require path in spec_helper' do + content = generator.send(:spec_helper_content) + expect(content).to include("require 'legion/extensions/myext'") + end + + it 'generates a flat RSpec describe block' do + content = generator.send(:spec_content) + expect(content).to include('Legion::Extensions::Myext') + end + + it 'uses flat target directory' do + expect(generator.send(:target_dir)).to eq('lex-myext') + end + end + + describe 'nested (with --category) scaffolding' do + let(:name) { 'cognitive-anchor' } + let(:category) { 'agentic' } + let(:gem_name) { 'lex-agentic-cognitive-anchor' } + let(:vars) { { filename: gem_name, class_name: 'CognitiveAnchor', lex: name } } + let(:options) { base_options.merge(category: category) } + subject(:generator) { described_class.new(name, vars, options, gem_name: gem_name) } + + it 'uses the full categorized gem name' do + expect(generator.send(:gem_name)).to eq('lex-agentic-cognitive-anchor') + end + + it 'generates nested module declaration' do + content = generator.send(:extension_entry_content) + expect(content).to include('module Agentic') + expect(content).to include('module Cognitive') + expect(content).to include('module Anchor') + end + + it 'generates nested version constant' do + content = generator.send(:version_content) + expect(content).to include('module Agentic') + expect(content).to include('module Cognitive') + expect(content).to include('module Anchor') + expect(content).to include("VERSION = '0.1.0'") + end + + it 'generates nested require path in spec_helper' do + content = generator.send(:spec_helper_content) + expect(content).to include("require 'legion/extensions/agentic/cognitive/anchor'") + end + + it 'generates nested RSpec describe block' do + content = generator.send(:spec_content) + expect(content).to include('Legion::Extensions::Agentic::Cognitive::Anchor') + end + + it 'uses nested target directory' do + expect(generator.send(:target_dir)).to eq('lex-agentic-cognitive-anchor') + end + + it 'generates correct nested dir path for extension entry' do + # The entry file should be at the nested require path + dirs = generator.send(:extension_dirs) + expect(dirs).to include('lex-agentic-cognitive-anchor/lib/legion/extensions/agentic/cognitive/anchor') + end + end + + describe 'nested module content structure' do + let(:name) { 'cognitive-anchor' } + let(:gem_name) { 'lex-agentic-cognitive-anchor' } + let(:vars) { { filename: gem_name, class_name: 'CognitiveAnchor', lex: name } } + let(:options) { base_options.merge(category: 'agentic') } + subject(:generator) { described_class.new(name, vars, options, gem_name: gem_name) } + + it 'module nesting opens outer-to-inner and closes inner-to-outer' do + content = generator.send(:extension_entry_content) + agentic_pos = content.index('module Agentic') + cognitive_pos = content.index('module Cognitive') + anchor_pos = content.index('module Anchor') + expect(agentic_pos).to be < cognitive_pos + expect(cognitive_pos).to be < anchor_pos + end + end +end diff --git a/spec/legion/cli/lex_templates_spec.rb b/spec/legion/cli/lex_templates_spec.rb new file mode 100644 index 00000000..545396b1 --- /dev/null +++ b/spec/legion/cli/lex_templates_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'legion/cli/lex_templates' + +RSpec.describe Legion::CLI::LexTemplates do + describe '.list' do + it 'returns all templates' do + templates = described_class.list + expect(templates.size).to eq(6) + expect(templates.map { |t| t[:name] }).to include('basic', 'llm-agent', 'service-integration', 'data-pipeline') + end + + it 'returns hashes with :name and :description keys' do + described_class.list.each do |t| + expect(t).to have_key(:name) + expect(t).to have_key(:description) + end + end + end + + describe '.get' do + it 'returns template config for llm-agent' do + config = described_class.get('llm-agent') + expect(config[:runners]).to include('processor', 'analyzer') + expect(config[:client]).to be true + end + + it 'returns template config for service-integration' do + config = described_class.get('service-integration') + expect(config[:client]).to be true + expect(config[:description]).to include('service') + end + + it 'returns template config for data-pipeline' do + config = described_class.get('data-pipeline') + expect(config[:runners]).to include('transform') + expect(config[:actors]).to include('ingest') + end + + it 'returns nil for unknown' do + expect(described_class.get('nonexistent')).to be_nil + end + end + + describe '.valid?' do + it 'validates known templates' do + expect(described_class.valid?('basic')).to be true + expect(described_class.valid?('llm-agent')).to be true + expect(described_class.valid?('service-integration')).to be true + expect(described_class.valid?('data-pipeline')).to be true + end + + it 'rejects unknown templates' do + expect(described_class.valid?('fake')).to be false + end + end + + describe '.template_dir' do + it 'returns nil for basic (no overlay)' do + expect(described_class.template_dir('basic')).to be_nil + end + + it 'returns a path for llm-agent' do + dir = described_class.template_dir('llm-agent') + expect(dir).to end_with('llm_agent') + end + + it 'returns a path for service-integration' do + dir = described_class.template_dir('service-integration') + expect(dir).to end_with('service_integration') + end + + it 'returns a path for data-pipeline' do + dir = described_class.template_dir('data-pipeline') + expect(dir).to end_with('data_pipeline') + end + + it 'returns nil for unknown template' do + expect(described_class.template_dir('nonexistent')).to be_nil + end + end + + describe Legion::CLI::LexTemplates::TemplateOverlay do + let(:tmpdir) { Dir.mktmpdir } + let(:vars) do + { lex_class: 'MyAgent', lex_name: 'myagent', name_class: 'Myagent', gem_name: 'lex-myagent' } + end + + after { FileUtils.remove_entry(tmpdir) } + + describe '#apply' do + context 'with llm-agent template' do + subject(:overlay) { described_class.new('llm-agent', tmpdir, vars) } + + before { overlay.apply } + + it 'generates the runner file' do + expect(File.exist?(File.join(tmpdir, 'runners/myagent.rb'))).to be true + end + + it 'generates the helpers/client.rb file' do + expect(File.exist?(File.join(tmpdir, 'helpers/client.rb'))).to be true + end + + it 'generates the prompts/default.yml file' do + expect(File.exist?(File.join(tmpdir, 'prompts/default.yml'))).to be true + end + + it 'generates the spec runner file' do + expect(File.exist?(File.join(tmpdir, 'spec/runners/myagent_spec.rb'))).to be true + end + + it 'substitutes lex_class in the runner' do + content = File.read(File.join(tmpdir, 'runners/myagent.rb')) + expect(content).to include('MyAgent') + end + end + + context 'with service-integration template' do + let(:vars) do + { lex_class: 'MyService', lex_name: 'myservice', name_class: 'Myservice', gem_name: 'lex-myservice' } + end + subject(:overlay) { described_class.new('service-integration', tmpdir, vars) } + + before { overlay.apply } + + it 'generates the runner file' do + expect(File.exist?(File.join(tmpdir, 'runners/myservice.rb'))).to be true + end + + it 'generates the helpers/client.rb file' do + expect(File.exist?(File.join(tmpdir, 'helpers/client.rb'))).to be true + end + + it 'generates the helpers/auth.rb file' do + expect(File.exist?(File.join(tmpdir, 'helpers/auth.rb'))).to be true + end + + it 'generates the spec/runners file' do + expect(File.exist?(File.join(tmpdir, 'spec/runners/myservice_spec.rb'))).to be true + end + + it 'generates the spec/helpers/client_spec.rb file' do + expect(File.exist?(File.join(tmpdir, 'spec/helpers/client_spec.rb'))).to be true + end + + it 'includes CRUD runner methods' do + content = File.read(File.join(tmpdir, 'runners/myservice.rb')) + %w[list get create update delete].each do |method| + expect(content).to include("def #{method}") + end + end + end + + context 'with data-pipeline template' do + let(:vars) do + { lex_class: 'MyPipeline', lex_name: 'mypipeline', name_class: 'Mypipeline', gem_name: 'lex-mypipeline' } + end + subject(:overlay) { described_class.new('data-pipeline', tmpdir, vars) } + + before { overlay.apply } + + it 'generates the transform runner' do + expect(File.exist?(File.join(tmpdir, 'runners/transform.rb'))).to be true + end + + it 'generates the ingest actor' do + expect(File.exist?(File.join(tmpdir, 'actors/ingest.rb'))).to be true + end + + it 'generates transport exchange file' do + expect(File.exist?(File.join(tmpdir, 'transport/exchanges/mypipeline.rb'))).to be true + end + + it 'generates transport queue file' do + expect(File.exist?(File.join(tmpdir, 'transport/queues/ingest.rb'))).to be true + end + + it 'generates transport message file' do + expect(File.exist?(File.join(tmpdir, 'transport/messages/mypipeline_output.rb'))).to be true + end + + it 'generates the transform spec' do + expect(File.exist?(File.join(tmpdir, 'spec/runners/transform_spec.rb'))).to be true + end + + it 'generates the ingest actor spec' do + expect(File.exist?(File.join(tmpdir, 'spec/actors/ingest_spec.rb'))).to be true + end + end + + context 'with basic template (no overlay dir)' do + subject(:overlay) { described_class.new('basic', tmpdir, vars) } + + it 'applies nothing and does not raise' do + expect { overlay.apply }.not_to raise_error + end + end + end + end +end diff --git a/spec/legion/cli/llm_command_spec.rb b/spec/legion/cli/llm_command_spec.rb new file mode 100644 index 00000000..6bfda791 --- /dev/null +++ b/spec/legion/cli/llm_command_spec.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/llm_command' +require 'legion/cli/output' + +RSpec.describe Legion::CLI::Llm do + let(:formatter) { Legion::CLI::Output::Formatter.new(json: false, color: false) } + let(:instance) { described_class.new([], options) } + let(:options) { { json: false, no_color: true, verbose: false } } + + let(:default_settings) do + { + enabled: true, + connected: false, + default_model: 'claude-sonnet-4-6', + default_provider: :anthropic, + providers: { + bedrock: { enabled: false, default_model: 'us.anthropic.claude-sonnet-4-6-v1', + api_key: nil, secret_key: nil, bearer_token: nil, region: 'us-east-2' }, + anthropic: { enabled: true, default_model: 'claude-sonnet-4-6', api_key: 'sk-test' }, + openai: { enabled: false, default_model: 'gpt-4o', api_key: nil }, + gemini: { enabled: false, default_model: 'gemini-2.0-flash', api_key: nil }, + ollama: { enabled: false, default_model: 'llama3', base_url: 'http://localhost:11434' } + }, + routing: { enabled: false, rules: [] }, + discovery: { enabled: true, refresh_seconds: 60, memory_floor_mb: 2048 } + } + end + + before do + allow(instance).to receive(:formatter).and_return(formatter) + allow(instance).to receive(:boot_llm_settings) + allow(instance).to receive(:llm_settings).and_return(default_settings) + end + + describe '#collect_providers' do + it 'returns provider list with enabled status' do + providers = instance.send(:collect_providers) + expect(providers).to be_an(Array) + expect(providers.size).to eq(5) + + anthropic = providers.find { |p| p[:name] == :anthropic } + expect(anthropic[:enabled]).to be true + expect(anthropic[:default_model]).to eq('claude-sonnet-4-6') + end + + it 'marks disabled providers correctly' do + providers = instance.send(:collect_providers) + openai = providers.find { |p| p[:name] == :openai } + expect(openai[:enabled]).to be false + end + end + + describe '#collect_status' do + before do + stub_const('Legion::LLM', Module.new) unless defined?(Legion::LLM) + allow(Legion::LLM).to receive(:started?).and_return(false) + end + + it 'returns status hash with required keys' do + status = instance.send(:collect_status) + expect(status).to have_key(:started) + expect(status).to have_key(:default_model) + expect(status).to have_key(:default_provider) + expect(status).to have_key(:enabled_count) + expect(status).to have_key(:total_count) + expect(status).to have_key(:providers) + expect(status).to have_key(:routing) + expect(status).to have_key(:system) + end + + it 'counts enabled providers correctly' do + status = instance.send(:collect_status) + expect(status[:enabled_count]).to eq(1) + expect(status[:total_count]).to eq(5) + end + + it 'includes default model and provider' do + status = instance.send(:collect_status) + expect(status[:default_model]).to eq('claude-sonnet-4-6') + expect(status[:default_provider]).to eq(:anthropic) + end + end + + describe '#check_reachable' do + it 'returns :credentials_present for enabled cloud provider with api_key' do + cfg = { enabled: true, api_key: 'sk-test' } + result = instance.send(:check_reachable, :anthropic, cfg) + expect(result).to eq(:credentials_present) + end + + it 'returns false for enabled cloud provider without api_key' do + cfg = { enabled: true, api_key: nil } + result = instance.send(:check_reachable, :anthropic, cfg) + expect(result).to be false + end + + it 'returns nil for disabled provider' do + cfg = { enabled: false, api_key: 'sk-test' } + result = instance.send(:check_reachable, :anthropic, cfg) + expect(result).to be_nil + end + + it 'returns false for disabled ollama' do + cfg = { enabled: false, base_url: 'http://localhost:11434' } + result = instance.send(:check_reachable, :ollama, cfg) + expect(result).to be false + end + + it 'returns :credentials_present for bedrock with bearer_token' do + cfg = { enabled: true, bearer_token: 'token-123', api_key: nil, secret_key: nil } + result = instance.send(:check_reachable, :bedrock, cfg) + expect(result).to eq(:credentials_present) + end + + it 'returns :credentials_present for bedrock with api_key and secret_key' do + cfg = { enabled: true, bearer_token: nil, api_key: 'key', secret_key: 'secret' } + result = instance.send(:check_reachable, :bedrock, cfg) + expect(result).to eq(:credentials_present) + end + + it 'returns false for bedrock without any credentials' do + cfg = { enabled: true, bearer_token: nil, api_key: nil, secret_key: nil } + result = instance.send(:check_reachable, :bedrock, cfg) + expect(result).to be false + end + end + + describe '#collect_models' do + it 'returns default model for enabled cloud providers' do + models = instance.send(:collect_models) + expect(models[:anthropic]).to eq(['claude-sonnet-4-6']) + end + + it 'excludes disabled providers' do + models = instance.send(:collect_models) + expect(models).not_to have_key(:openai) + expect(models).not_to have_key(:gemini) + end + end + + describe '#collect_routing' do + it 'returns enabled: false when Router is not defined' do + hide_const('Legion::LLM::Router') if defined?(Legion::LLM::Router) + routing = instance.send(:collect_routing) + expect(routing[:enabled]).to be false + end + end + + describe '#status (text output)' do + before do + stub_const('Legion::LLM', Module.new) unless defined?(Legion::LLM) + allow(Legion::LLM).to receive(:started?).and_return(false) + end + + it 'outputs status header and provider info' do + output = StringIO.new + $stdout = output + instance.status + $stdout = STDOUT + expect(output.string).to include('LLM Status') + expect(output.string).to include('Providers') + expect(output.string).to include('anthropic') + end + end + + describe '#status (json output)' do + let(:options) { { json: true, no_color: true, verbose: false } } + + before do + stub_const('Legion::LLM', Module.new) unless defined?(Legion::LLM) + allow(Legion::LLM).to receive(:started?).and_return(false) + end + + it 'outputs valid JSON with status keys' do + output = StringIO.new + $stdout = output + instance.status + $stdout = STDOUT + parsed = JSON.parse(output.string) + expect(parsed).to have_key('started') + expect(parsed).to have_key('default_model') + expect(parsed).to have_key('providers') + end + end + + describe '#providers (text output)' do + it 'outputs provider list' do + output = StringIO.new + $stdout = output + instance.providers + $stdout = STDOUT + expect(output.string).to include('Providers') + expect(output.string).to include('anthropic') + expect(output.string).to include('bedrock') + end + end + + describe '#models (text output)' do + it 'outputs model list for enabled providers' do + output = StringIO.new + $stdout = output + instance.models + $stdout = STDOUT + expect(output.string).to include('Available Models') + expect(output.string).to include('anthropic') + expect(output.string).to include('claude-sonnet-4-6') + end + end + + describe '#models (json output)' do + let(:options) { { json: true, no_color: true, verbose: false } } + + it 'outputs valid JSON with models key' do + output = StringIO.new + $stdout = output + instance.models + $stdout = STDOUT + parsed = JSON.parse(output.string) + expect(parsed).to have_key('models') + expect(parsed['models']['anthropic']).to include('claude-sonnet-4-6') + end + end + + describe '#show_providers' do + it 'shows enabled status for active providers' do + output = StringIO.new + $stdout = output + providers_data = [ + { name: :anthropic, enabled: true, reachable: :credentials_present, default_model: 'claude-sonnet-4-6' }, + { name: :openai, enabled: false, reachable: nil, default_model: 'gpt-4o' } + ] + instance.send(:show_providers, formatter, providers_data) + $stdout = STDOUT + expect(output.string).to include('enabled') + expect(output.string).to include('disabled') + end + + it 'shows reachable status for ollama' do + output = StringIO.new + $stdout = output + providers_data = [ + { name: :ollama, enabled: true, reachable: true, default_model: 'llama3' } + ] + instance.send(:show_providers, formatter, providers_data) + $stdout = STDOUT + expect(output.string).to include('reachable') + end + end + + describe '#ping_all_providers' do + it 'warns when no providers are enabled' do + all_disabled = default_settings.merge( + providers: default_settings[:providers].transform_values { |v| v.merge(enabled: false) } + ) + allow(instance).to receive(:llm_settings).and_return(all_disabled) + + output = StringIO.new + $stdout = output + results = instance.send(:ping_all_providers, formatter) + $stdout = STDOUT + expect(results).to be_empty + expect(output.string).to include('No providers enabled') + end + end + + describe '#ping_one_provider' do + it 'returns skip when no default model configured' do + result = instance.send(:ping_one_provider, formatter, :anthropic, { default_model: nil }) + expect(result[:status]).to eq('skip') + expect(result[:message]).to include('no default model') + end + + it 'pings through Legion::LLM native dispatch' do + stub_const('Legion::LLM', Module.new) unless defined?(Legion::LLM) + response = instance_double('Legion::LLM::Response', content: 'pong') + allow(Legion::LLM).to receive(:ask_direct).and_return(response) + + result = instance.send(:ping_one_provider, formatter, :anthropic, + { default_model: 'claude-sonnet-4-6' }) + + expect(Legion::LLM).to have_received(:ask_direct).with( + message: 'Respond with only the word: pong', + model: 'claude-sonnet-4-6', + provider: :anthropic, + caller: { source: 'cli', command: 'llm ping' } + ) + expect(result[:status]).to eq('ok') + expect(result[:response]).to eq('pong') + end + + it 'extracts content from hash responses' do + expect(instance.send(:response_content, { content: ' pong ' })).to eq('pong') + expect(instance.send(:response_content, { 'response' => ' pong ' })).to eq('pong') + end + end + + describe '#show_ping_results' do + it 'shows success summary when all pass' do + output = StringIO.new + $stdout = output + results = [ + { provider: :anthropic, status: 'ok', model: 'claude-sonnet-4-6', latency_ms: 450 } + ] + instance.send(:show_ping_results, formatter, results) + $stdout = STDOUT + expect(output.string).to include('1 provider(s) responding') + end + + it 'shows failure summary with counts' do + output = StringIO.new + $stdout = output + results = [ + { provider: :anthropic, status: 'ok', model: 'claude-sonnet-4-6', latency_ms: 450 }, + { provider: :openai, status: 'error', message: 'timeout', model: 'gpt-4o', latency_ms: 15_000 } + ] + instance.send(:show_ping_results, formatter, results) + $stdout = STDOUT + expect(output.string).to include('1 provider(s) failed') + expect(output.string).to include('1 responding') + end + + it 'shows skip reason for providers without models' do + output = StringIO.new + $stdout = output + results = [ + { provider: :gemini, status: 'skip', message: 'no default model configured', latency_ms: nil } + ] + instance.send(:show_ping_results, formatter, results) + $stdout = STDOUT + expect(output.string).to include('skipped') + end + end +end diff --git a/spec/legion/cli/main_help_spec.rb b/spec/legion/cli/main_help_spec.rb new file mode 100644 index 00000000..e1cb1365 --- /dev/null +++ b/spec/legion/cli/main_help_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe Legion::CLI::Main do + describe 'start option metadata' do + it 'does not hard-default log_level, so settings or explicit CLI values can win' do + expect(described_class.commands['start'].options[:log_level].default).to be_nil + end + end + + describe '.start' do + def capture_help(*args) + out = StringIO.new + err = StringIO.new + original_stdout = $stdout + original_stderr = $stderr + $stdout = out + $stderr = err + described_class.start(args) + [out.string, err.string] + ensure + $stdout = original_stdout + $stderr = original_stderr + end + + it 'shows start help when invoked as help start' do + stdout, stderr = capture_help('help', 'start') + + expect(stderr).to eq('') + expect(stdout).to include('Usage:') + expect(stdout).to match(/^\s*\S+\s+start$/) + expect(stdout).to include('--log-level=LOG_LEVEL') + expect(stdout).to include('--http-port=N') + end + + it 'normalizes start --help to the same help output' do + help_stdout, = capture_help('help', 'start') + dash_help_stdout, dash_help_stderr = capture_help('start', '--help') + + expect(dash_help_stderr).to eq('') + expect(dash_help_stdout).to eq(help_stdout) + end + end +end diff --git a/spec/legion/cli/marketplace_command_spec.rb b/spec/legion/cli/marketplace_command_spec.rb new file mode 100644 index 00000000..b2ba6b1e --- /dev/null +++ b/spec/legion/cli/marketplace_command_spec.rb @@ -0,0 +1,372 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/registry' +require 'legion/registry/security_scanner' +require 'legion/cli/marketplace_command' +require 'legion/cli/output' + +RSpec.describe Legion::CLI::Marketplace do + let(:entry_attrs) do + { + name: 'lex-test', + version: '1.0.0', + author: 'test-author', + description: 'A test extension', + risk_tier: 'low', + airb_status: 'pending', + status: :active + } + end + + let(:out) { instance_double(Legion::CLI::Output::Formatter) } + + before(:each) do + Legion::Registry.clear! + Legion::Registry.register(Legion::Registry::Entry.new(**entry_attrs)) + + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(out).to receive(:success) + allow(out).to receive(:error) + allow(out).to receive(:warn) + allow(out).to receive(:json) + allow(out).to receive(:spacer) + allow(out).to receive(:detail) + allow(out).to receive(:table) + allow(out).to receive(:header) + allow(out).to receive(:colorize).and_return('colored') + end + + def build_command(opts = {}) + described_class.new([], { json: false, no_color: true }.merge(opts)) + end + + # ────────────────────────────────────────────────────────── + # search + # ────────────────────────────────────────────────────────── + + describe '#search' do + it 'calls table with results when found' do + expect(out).to receive(:table).with(%w[Name Version Status Description], anything) + build_command.search('test') + end + + it 'warns when no results found' do + expect(out).to receive(:warn).with(/no extensions/i) + build_command.search('zzzmissing') + end + + it 'outputs json when --json is set' do + cmd = build_command(json: true) + expect(out).to receive(:json).with(array_including(hash_including(name: 'lex-test'))) + cmd.search('test') + end + end + + # ────────────────────────────────────────────────────────── + # info + # ────────────────────────────────────────────────────────── + + describe '#info' do + it 'shows detail for known extension' do + expect(out).to receive(:header).with(/lex-test/) + expect(out).to receive(:detail) + build_command.info('lex-test') + end + + it 'errors when extension not found' do + expect(out).to receive(:error).with(/not found/) + build_command.info('lex-missing') + end + + it 'outputs json when --json is set' do + cmd = build_command(json: true) + expect(out).to receive(:json).with(hash_including(name: 'lex-test')) + cmd.info('lex-test') + end + end + + # ────────────────────────────────────────────────────────── + # list + # ────────────────────────────────────────────────────────── + + describe '#list' do + it 'calls table when extensions exist' do + expect(out).to receive(:table).with(%w[Name Version Status Tier], anything) + build_command.list + end + + it 'warns when registry is empty' do + Legion::Registry.clear! + expect(out).to receive(:warn).with(/no extensions/i) + build_command.list + end + + it 'outputs json when --json is set' do + cmd = build_command(json: true) + expect(out).to receive(:json).with(array_including(hash_including(name: 'lex-test'))) + cmd.list + end + + it 'filters by status option' do + Legion::Registry.submit_for_review('lex-test') + cmd = build_command(status: 'pending_review') + expect(out).to receive(:table).with(anything, array_including(array_including('lex-test'))) + cmd.list + end + end + + # ────────────────────────────────────────────────────────── + # submit + # ────────────────────────────────────────────────────────── + + describe '#submit' do + it 'succeeds for known extension' do + expect(out).to receive(:success).with(/submitted/i) + build_command.submit('lex-test') + end + + it 'sets extension status to pending_review' do + build_command.submit('lex-test') + expect(Legion::Registry.lookup('lex-test').status).to eq(:pending_review) + end + + it 'errors for unknown extension' do + expect(out).to receive(:error).with(/not found/) + build_command.submit('lex-missing') + end + + it 'outputs json when --json is set' do + cmd = build_command(json: true) + expect(out).to receive(:json).with(hash_including(status: 'pending_review')) + cmd.submit('lex-test') + end + end + + # ────────────────────────────────────────────────────────── + # review + # ────────────────────────────────────────────────────────── + + describe '#review' do + it 'warns when no pending reviews' do + expect(out).to receive(:warn).with(/no extensions pending/i) + build_command.review + end + + it 'shows table when pending reviews exist' do + Legion::Registry.submit_for_review('lex-test') + expect(out).to receive(:table).with(%w[Name Version Author Submitted], anything) + build_command.review + end + + it 'outputs json for pending reviews' do + Legion::Registry.submit_for_review('lex-test') + cmd = build_command(json: true) + expect(out).to receive(:json).with(array_including(hash_including(name: 'lex-test'))) + cmd.review + end + end + + # ────────────────────────────────────────────────────────── + # approve + # ────────────────────────────────────────────────────────── + + describe '#approve' do + before { Legion::Registry.submit_for_review('lex-test') } + + it 'succeeds for known extension' do + expect(out).to receive(:success).with(/'lex-test' approved/) + build_command(notes: nil).approve('lex-test') + end + + it 'sets status to approved in registry' do + build_command(notes: nil).approve('lex-test') + expect(Legion::Registry.lookup('lex-test').status).to eq(:approved) + end + + it 'stores notes when provided' do + build_command(notes: 'LGTM').approve('lex-test') + expect(Legion::Registry.lookup('lex-test').review_notes).to eq('LGTM') + end + + it 'errors for unknown extension' do + expect(out).to receive(:error).with(/not found/) + build_command(notes: nil).approve('lex-missing') + end + + it 'outputs json when --json is set' do + cmd = build_command(json: true, notes: nil) + expect(out).to receive(:json).with(hash_including(status: 'approved')) + cmd.approve('lex-test') + end + end + + # ────────────────────────────────────────────────────────── + # reject + # ────────────────────────────────────────────────────────── + + describe '#reject' do + before { Legion::Registry.submit_for_review('lex-test') } + + it 'succeeds for known extension' do + expect(out).to receive(:success).with(/'lex-test' rejected/) + build_command(reason: nil).reject('lex-test') + end + + it 'sets status to rejected in registry' do + build_command(reason: nil).reject('lex-test') + expect(Legion::Registry.lookup('lex-test').status).to eq(:rejected) + end + + it 'stores reason when provided' do + build_command(reason: 'CVE found').reject('lex-test') + expect(Legion::Registry.lookup('lex-test').reject_reason).to eq('CVE found') + end + + it 'errors for unknown extension' do + expect(out).to receive(:error).with(/not found/) + build_command(reason: nil).reject('lex-missing') + end + + it 'outputs json when --json is set' do + cmd = build_command(json: true, reason: nil) + expect(out).to receive(:json).with(hash_including(status: 'rejected')) + cmd.reject('lex-test') + end + end + + # ────────────────────────────────────────────────────────── + # deprecate + # ────────────────────────────────────────────────────────── + + describe '#deprecate' do + it 'succeeds for known extension' do + expect(out).to receive(:success).with(/deprecated/) + build_command(successor: nil, sunset_date: nil).deprecate('lex-test') + end + + it 'sets status to deprecated in registry' do + build_command(successor: nil, sunset_date: nil).deprecate('lex-test') + expect(Legion::Registry.lookup('lex-test').status).to eq(:deprecated) + end + + it 'stores successor when provided' do + build_command(successor: 'lex-test-v2', sunset_date: nil).deprecate('lex-test') + expect(Legion::Registry.lookup('lex-test').successor).to eq('lex-test-v2') + end + + it 'parses sunset_date when provided' do + build_command(successor: nil, sunset_date: '2027-01-01').deprecate('lex-test') + expect(Legion::Registry.lookup('lex-test').sunset_date).to eq(Date.new(2027, 1, 1)) + end + + it 'errors for unknown extension' do + expect(out).to receive(:error).with(/not found/) + build_command(successor: nil, sunset_date: nil).deprecate('lex-missing') + end + + it 'outputs json when --json is set' do + cmd = build_command(json: true, successor: nil, sunset_date: nil) + expect(out).to receive(:json).with(hash_including(status: 'deprecated')) + cmd.deprecate('lex-test') + end + end + + # ────────────────────────────────────────────────────────── + # install + # ────────────────────────────────────────────────────────── + + describe '#install' do + it 'rejects names that do not start with lex-' do + expect(out).to receive(:error).with(/must start with 'lex-'/) + build_command.install('my-gem') + end + + it 'calls GemSource.install_gem for a valid lex name' do + allow(Legion::Extensions::GemSource).to receive(:install_gem) + .with('lex-foo').and_return({ success: true, output: '', command: 'gem install lex-foo' }) + build_command.install('lex-foo') + end + + it 'reports success when install succeeds' do + allow(Legion::Extensions::GemSource).to receive(:install_gem) + .and_return({ success: true, output: '', command: 'gem install lex-foo' }) + expect(out).to receive(:success).with(/'lex-foo' installed successfully/) + build_command.install('lex-foo') + end + + it 'reports error when install fails' do + allow(Legion::Extensions::GemSource).to receive(:install_gem) + .and_return({ success: false, output: 'ERROR: not found', command: 'gem install lex-foo' }) + expect(out).to receive(:error).with(/Failed to install/) + build_command.install('lex-foo') + end + end + + # ────────────────────────────────────────────────────────── + # publish + # ────────────────────────────────────────────────────────── + + describe '#publish' do + before do + allow(Kernel).to receive(:system).and_return(true) + allow(Dir).to receive(:glob).with('*.gemspec').and_return(['lex-foo.gemspec']) + allow(Dir).to receive(:glob).with('lex-foo-*.gem').and_return(['lex-foo-1.0.0.gem']) + allow(File).to receive(:mtime).with('lex-foo-1.0.0.gem').and_return(Time.now) + allow(Legion::Registry::SecurityScanner).to receive(:new).and_return( + instance_double(Legion::Registry::SecurityScanner, scan: { passed: true, checks: [] }) + ) + end + + it 'errors when no gemspec found' do + allow(Dir).to receive(:glob).with('*.gemspec').and_return([]) + expect(out).to receive(:error).with(/no gemspec found/i) + build_command.publish + end + + it 'errors when rspec fails' do + allow(Kernel).to receive(:system).with('bundle', 'exec', 'rspec').and_return(false) + expect(out).to receive(:error).with(/specs failed/i) + build_command.publish + end + + it 'errors when rubocop fails' do + allow(Kernel).to receive(:system).with('bundle', 'exec', 'rspec').and_return(true) + allow(Kernel).to receive(:system).with('bundle', 'exec', 'rubocop').and_return(false) + expect(out).to receive(:error).with(/rubocop failed/i) + build_command.publish + end + + it 'builds and pushes gem on success' do + expect(Kernel).to receive(:system).with('bundle', 'exec', 'rspec').and_return(true) + expect(Kernel).to receive(:system).with('bundle', 'exec', 'rubocop').and_return(true) + expect(Kernel).to receive(:system).with('gem', 'build', 'lex-foo.gemspec').and_return(true) + expect(Kernel).to receive(:system).with('gem', 'push', 'lex-foo-1.0.0.gem').and_return(true) + expect(out).to receive(:success).with(/published/) + build_command.publish + end + end + + # ────────────────────────────────────────────────────────── + # stats + # ────────────────────────────────────────────────────────── + + describe '#stats' do + it 'shows header and detail for known extension' do + expect(out).to receive(:header).with(/lex-test/) + expect(out).to receive(:detail).with(hash_including('Install Count')) + build_command.stats('lex-test') + end + + it 'errors for unknown extension' do + expect(out).to receive(:error).with(/not found/) + build_command.stats('lex-missing') + end + + it 'outputs json when --json is set' do + cmd = build_command(json: true) + expect(out).to receive(:json).with(hash_including(name: 'lex-test')) + cmd.stats('lex-test') + end + end +end diff --git a/spec/legion/cli/memory_command_spec.rb b/spec/legion/cli/memory_command_spec.rb new file mode 100644 index 00000000..265c7286 --- /dev/null +++ b/spec/legion/cli/memory_command_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/memory_command' +require 'legion/cli/chat/memory_store' + +RSpec.describe Legion::CLI::Memory do + let(:store) { Legion::CLI::Chat::MemoryStore } + + before do + allow(store).to receive(:list).and_return([]) + allow(store).to receive(:add).and_return('/tmp/test/memory.md') + allow(store).to receive(:forget).and_return(0) + allow(store).to receive(:search).and_return([]) + allow(store).to receive(:clear).and_return(false) + end + + describe '#list' do + context 'with entries' do + before do + allow(store).to receive(:list).and_return(['entry one _(2026-03-23)_', 'entry two _(2026-03-23)_']) + end + + it 'outputs header with count' do + expect { described_class.start(%w[list --no-color]) }.to output(/Project Memory \(2 entries\)/).to_stdout + end + + it 'shows entries' do + expect { described_class.start(%w[list --no-color]) }.to output(/entry one/).to_stdout + end + + it 'outputs JSON when requested' do + output = capture_stdout { described_class.start(%w[list --json --no-color]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:entries]).to be_an(Array) + expect(parsed[:scope]).to eq('project') + end + end + + context 'with no entries' do + it 'shows warning' do + expect { described_class.start(%w[list --no-color]) }.to output(/No memory entries found/).to_stdout + end + end + + context 'with --global flag' do + it 'uses global scope' do + described_class.start(%w[list --global --no-color]) + expect(store).to have_received(:list).with(hash_including(scope: :global)) + end + end + end + + describe '#add' do + it 'adds entry and shows success' do + expect { described_class.start(['add', 'new fact', '--no-color']) }.to output(/Added to project memory/).to_stdout + end + + it 'passes text to MemoryStore' do + described_class.start(['add', 'new fact', '--no-color']) + expect(store).to have_received(:add).with('new fact', scope: :project) + end + end + + describe '#forget' do + context 'when entries match' do + before { allow(store).to receive(:forget).and_return(2) } + + it 'shows removed count' do + expect { described_class.start(['forget', 'old', '--no-color']) }.to output(/Removed 2/).to_stdout + end + end + + context 'when no entries match' do + it 'shows warning' do + expect { described_class.start(['forget', 'nope', '--no-color']) }.to output(/No entries matching/).to_stdout + end + end + end + + describe '#search' do + context 'with results' do + before do + allow(store).to receive(:search).and_return([ + { source: '/project/.legion/memory.md', line: 3, + text: 'Ruby is great' } + ]) + end + + it 'shows results' do + expect { described_class.start(['search', 'ruby', '--no-color']) }.to output(/Ruby is great/).to_stdout + end + + it 'outputs JSON when requested' do + output = capture_stdout { described_class.start(['search', 'ruby', '--json', '--no-color']) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:results]).to be_an(Array) + expect(parsed[:query]).to eq('ruby') + end + end + + context 'with no results' do + it 'shows warning' do + expect { described_class.start(['search', 'nope', '--no-color']) }.to output(/No results/).to_stdout + end + end + end + + describe '#clear' do + context 'with --yes flag' do + before { allow(store).to receive(:clear).and_return(true) } + + it 'clears memory and shows success' do + expect { described_class.start(%w[clear --yes --no-color]) }.to output(/memory cleared/).to_stdout + end + end + + context 'when no memory file exists' do + it 'shows warning' do + expect { described_class.start(%w[clear --yes --no-color]) }.to output(/No memory file/).to_stdout + end + end + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end diff --git a/spec/legion/cli/mind_growth_command_spec.rb b/spec/legion/cli/mind_growth_command_spec.rb new file mode 100644 index 00000000..b14defc3 --- /dev/null +++ b/spec/legion/cli/mind_growth_command_spec.rb @@ -0,0 +1,427 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/error' +require 'legion/cli/output' +require 'legion/cli/mind_growth_command' + +RSpec.describe Legion::CLI::MindGrowth do + let(:client) { double('MindGrowth::Client') } + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end + + before do + stub_const('Legion::Extensions::MindGrowth::Client', Class.new) + stub_const('Legion::Extensions::MindGrowth::Runners::Proposer', Module.new do + def self.get_proposal_object(_id); end + end) + stub_const('Legion::Extensions::MindGrowth::Runners::Orchestrator', Module.new do + def self.post_build_pipeline(**_kwargs); end + end) + allow(Legion::Extensions::MindGrowth::Client).to receive(:new).and_return(client) + end + + describe '#status' do + let(:result) { { success: true, proposals: 3, coverage: 0.72 } } + + before { allow(client).to receive(:growth_status).and_return(result) } + + it 'renders the status header' do + output = capture_stdout { described_class.start(%w[status --no-color]) } + expect(output).to include('Mind-Growth Status') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[status --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:success]).to eq(true) + end + end + + describe '#propose' do + let(:proposal_id) { 'abc12345-0000-0000-0000-000000000000' } + + context 'when proposal succeeds' do + let(:result) { { success: true, proposal: { id: proposal_id } } } + + before { allow(client).to receive(:propose_concept).and_return(result) } + + it 'shows a success message with proposal id' do + output = capture_stdout { described_class.start(%w[propose --no-color]) } + expect(output).to include('Proposal created') + expect(output).to include(proposal_id) + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[propose --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:success]).to eq(true) + end + + it 'forwards --category as symbol' do + expect(client).to receive(:propose_concept).with( + hash_including(category: :cognition) + ).and_return(result) + capture_stdout { described_class.start(%w[propose --category cognition --no-color]) } + end + end + + context 'when proposal is rejected as redundant' do + let(:result) { { success: false, error: :redundant } } + + before { allow(client).to receive(:propose_concept).and_return(result) } + + it 'shows a warning' do + output = capture_stdout { described_class.start(%w[propose --no-color]) } + expect(output).to include('redundant') + end + end + end + + describe '#approve' do + let(:proposal_id) { 'deadbeef-0000-0000-0000-000000000000' } + + context 'when approved' do + let(:result) { { success: true, approved: true, auto_approved: false } } + + before { allow(client).to receive(:evaluate_proposal).with(proposal_id: proposal_id).and_return(result) } + + it 'shows approval status' do + output = capture_stdout { described_class.start(['approve', proposal_id, '--no-color']) } + expect(output).to include('approved') + end + + it 'truncates id to 8 chars in output' do + output = capture_stdout { described_class.start(['approve', proposal_id, '--no-color']) } + expect(output).to include('deadbeef') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(['approve', proposal_id, '--json']) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:success]).to eq(true) + end + end + end + + describe '#reject_proposal' do + let(:proposal_id) { 'feedcafe-0000-0000-0000-000000000000' } + + context 'when proposal exists' do + let(:fake_proposal) do + obj = Object.new + def obj.transition!(status); end + obj + end + + before do + allow(Legion::Extensions::MindGrowth::Runners::Proposer) + .to receive(:get_proposal_object).with(proposal_id).and_return(fake_proposal) + end + + it 'transitions proposal to rejected' do + expect(fake_proposal).to receive(:transition!).with(:rejected) + capture_stdout { described_class.start(['reject', proposal_id, '--no-color']) } + end + + it 'shows success message' do + allow(fake_proposal).to receive(:transition!) + output = capture_stdout { described_class.start(['reject', proposal_id, '--no-color']) } + expect(output).to include('rejected') + end + + it 'outputs JSON when --json is passed' do + allow(fake_proposal).to receive(:transition!) + output = capture_stdout { described_class.start(['reject', proposal_id, '--json']) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:success]).to eq(true) + expect(parsed[:status]).to eq('rejected') + end + end + + context 'when proposal is not found' do + before do + allow(Legion::Extensions::MindGrowth::Runners::Proposer) + .to receive(:get_proposal_object).with(proposal_id).and_return(nil) + end + + it 'shows a not-found warning' do + output = capture_stdout { described_class.start(['reject', proposal_id, '--no-color']) } + expect(output).to include('not found') + end + end + end + + describe '#build' do + let(:proposal_id) { 'b00b1e00-0000-0000-0000-000000000000' } + + context 'when build succeeds' do + let(:result) { { success: true, pipeline: { stage: 'scaffold', status: :running } } } + + before { allow(client).to receive(:build_extension).with(proposal_id: proposal_id).and_return(result) } + + it 'shows build started message' do + output = capture_stdout { described_class.start(['build', proposal_id, '--no-color']) } + expect(output).to include('Build pipeline started') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(['build', proposal_id, '--json']) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:success]).to eq(true) + end + end + + context 'when build fails' do + let(:result) { { success: false, error: 'proposal not approved' } } + + before { allow(client).to receive(:build_extension).with(proposal_id: proposal_id).and_return(result) } + + it 'shows failure warning' do + output = capture_stdout { described_class.start(['build', proposal_id, '--no-color']) } + expect(output).to include('Build failed') + end + end + end + + describe '#wire' do + let(:proposal_id) { 'c0ffee00-0000-0000-0000-000000000000' } + let(:orchestrator) { Legion::Extensions::MindGrowth::Runners::Orchestrator } + + context 'when wire and activate succeed' do + let(:result) { { wire: { success: true }, integration_test: { success: true }, activated: true } } + + before { allow(orchestrator).to receive(:post_build_pipeline).with(proposal_id: proposal_id).and_return(result) } + + it 'shows activated status' do + output = capture_stdout { described_class.start(['wire', proposal_id, '--no-color']) } + expect(output).to include('activated') + end + + it 'includes the proposal id in the output' do + output = capture_stdout { described_class.start(['wire', proposal_id, '--no-color']) } + expect(output).to include(proposal_id) + end + end + + context 'when proposal is skipped' do + let(:result) { { skipped: true, reason: 'proposal not found' } } + + before { allow(orchestrator).to receive(:post_build_pipeline).with(proposal_id: proposal_id).and_return(result) } + + it 'shows skipped status with reason' do + output = capture_stdout { described_class.start(['wire', proposal_id, '--no-color']) } + expect(output).to match(/skipped|not found/) + end + end + + context 'when an error is returned' do + let(:result) { { error: 'build artifact missing' } } + + before { allow(orchestrator).to receive(:post_build_pipeline).with(proposal_id: proposal_id).and_return(result) } + + it 'shows error status' do + output = capture_stdout { described_class.start(['wire', proposal_id, '--no-color']) } + expect(output).to include('build artifact missing') + end + end + + context 'when wire completes but activation is pending' do + let(:result) { { wire: { success: true }, integration_test: { success: false } } } + + before { allow(orchestrator).to receive(:post_build_pipeline).with(proposal_id: proposal_id).and_return(result) } + + it 'shows partial status' do + output = capture_stdout { described_class.start(['wire', proposal_id, '--no-color']) } + expect(output).to match(/partial|Wire/) + end + end + + context 'when the orchestrator raises' do + before { allow(orchestrator).to receive(:post_build_pipeline).and_raise(StandardError, 'unexpected failure') } + + it 'shows error status and does not re-raise' do + output = capture_stdout { described_class.start(['wire', proposal_id, '--no-color']) } + expect(output).to include('unexpected failure') + end + end + end + + describe '#proposals' do + let(:result) do + { + success: true, + proposals: [ + { id: 'aabbccdd-1111-0000-0000-000000000000', name: 'attention_gate', + category: :cognition, status: :approved, created_at: '2026-03-24' }, + { id: 'eeff0011-2222-0000-0000-000000000000', name: 'belief_updater', + category: :inference, status: :proposed, created_at: '2026-03-23' } + ], + count: 2 + } + end + + before { allow(client).to receive(:list_proposals).and_return(result) } + + it 'renders a table with proposal names' do + output = capture_stdout { described_class.start(%w[proposals --no-color]) } + expect(output).to include('attention_gate') + expect(output).to include('belief_updater') + end + + it 'truncates ids to 8 chars in table' do + output = capture_stdout { described_class.start(%w[proposals --no-color]) } + expect(output).to include('aabbccdd') + expect(output).not_to include('aabbccdd-1111') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[proposals --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:proposals]).to be_an(Array) + expect(parsed[:proposals].size).to eq(2) + end + + it 'shows warning when no proposals found' do + allow(client).to receive(:list_proposals).and_return({ success: true, proposals: [], count: 0 }) + output = capture_stdout { described_class.start(%w[proposals --no-color]) } + expect(output).to include('No proposals found') + end + + it 'forwards --status as symbol' do + expect(client).to receive(:list_proposals).with(hash_including(status: :approved)).and_return(result) + capture_stdout { described_class.start(%w[proposals --status approved --no-color]) } + end + end + + describe '#profile' do + let(:result) do + { + success: true, + total_extensions: 8, + overall_coverage: 0.65, + model_coverage: { + global_workspace: { coverage: 0.8, missing: %w[broadcasting] }, + free_energy: { coverage: 0.5, missing: %w[prediction free_energy] } + } + } + end + + before { allow(client).to receive(:cognitive_profile).and_return(result) } + + it 'renders the profile header' do + output = capture_stdout { described_class.start(%w[profile --no-color]) } + expect(output).to include('Cognitive Architecture Profile') + end + + it 'shows total extensions' do + output = capture_stdout { described_class.start(%w[profile --no-color]) } + expect(output).to include('8') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[profile --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:total_extensions]).to eq(8) + end + end + + describe '#health' do + let(:result) do + { + success: true, + ranked: [ + { name: 'lex-attention', fitness: 0.87 }, + { name: 'lex-memory', fitness: 0.54 } + ], + prune_candidates: [], + improvement_candidates: [] + } + end + + before { allow(client).to receive(:validate_fitness).with(extensions: []).and_return(result) } + + it 'renders the fitness header' do + output = capture_stdout { described_class.start(%w[health --no-color]) } + expect(output).to include('Extension Fitness') + end + + it 'shows extension names and fitness scores' do + output = capture_stdout { described_class.start(%w[health --no-color]) } + expect(output).to include('lex-attention') + expect(output).to include('0.870') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[health --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:ranked]).to be_an(Array) + end + end + + describe '#report' do + let(:result) do + { success: true, total_cycles: 5, proposals_created: 12, extensions_built: 3 } + end + + before { allow(client).to receive(:session_report).and_return(result) } + + it 'renders the report header' do + output = capture_stdout { described_class.start(%w[report --no-color]) } + expect(output).to include('Mind-Growth Report') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[report --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:total_cycles]).to eq(5) + end + end + + describe '#history' do + let(:result) do + { + success: true, + proposals: [ + { id: 'cafe1234-0000-0000-0000-000000000000', name: 'somatic_gate', + category: :affect, status: :active, created_at: '2026-03-22' } + ], + count: 1 + } + end + + before { allow(client).to receive(:list_proposals).and_return(result) } + + it 'renders proposal history table' do + output = capture_stdout { described_class.start(%w[history --no-color]) } + expect(output).to include('somatic_gate') + end + + it 'defaults to limit 50' do + expect(client).to receive(:list_proposals).with(hash_including(limit: 50)).and_return(result) + capture_stdout { described_class.start(%w[history --no-color]) } + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[history --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:proposals]).to be_an(Array) + end + end + + describe 'extension guard' do + before { hide_const('Legion::Extensions::MindGrowth') } + + it 'raises CLI::Error when extension is not loaded' do + cli = described_class.new([], {}) + expect { cli.status }.to raise_error(Legion::CLI::Error, /lex-mind-growth/) + end + end +end diff --git a/spec/legion/cli/mode_command_spec.rb b/spec/legion/cli/mode_command_spec.rb new file mode 100644 index 00000000..d6354b50 --- /dev/null +++ b/spec/legion/cli/mode_command_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/mode_command' + +RSpec.describe Legion::CLI::Mode do + let(:tmpdir) { Dir.mktmpdir } + let(:role_file) { File.join(tmpdir, 'role.json') } + + before do + stub_const('Legion::CLI::Mode::SETTINGS_DIR', tmpdir) + stub_const('Legion::CLI::Mode::ROLE_FILE', role_file) + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + end + + after { FileUtils.rm_rf(tmpdir) } + + describe 'VALID_PROFILES' do + it 'includes the five documented profiles' do + expect(described_class::VALID_PROFILES).to contain_exactly(:core, :cognitive, :service, :dev, :custom) + end + end + + describe '#show' do + it 'displays current process role and profile' do + mode = described_class.new([], { json: true }) + expect { mode.show }.to output(/process_role/).to_stdout + end + end + + describe '#list' do + it 'displays available profiles and roles' do + mode = described_class.new([], { json: true }) + expect { mode.list }.to output(/profiles/).to_stdout + end + end + + describe '#set' do + it 'writes profile to role.json' do + mode = described_class.new([], { json: false, no_color: true }) + mode.set('dev') + expect(File.exist?(role_file)).to be true + data = JSON.parse(File.read(role_file), symbolize_names: true) + expect(data.dig(:role, :profile)).to eq('dev') + end + + it 'writes custom profile with extensions' do + mode = described_class.new([], { json: false, no_color: true, extensions: 'tick,react,knowledge' }) + mode.set('custom') + data = JSON.parse(File.read(role_file), symbolize_names: true) + expect(data.dig(:role, :profile)).to eq('custom') + expect(data.dig(:role, :extensions)).to eq(%w[tick react knowledge]) + end + + it 'writes process role when provided' do + mode = described_class.new([], { json: false, no_color: true, process_role: 'worker' }) + mode.set + data = JSON.parse(File.read(role_file), symbolize_names: true) + expect(data.dig(:process, :role)).to eq('worker') + end + + it 'rejects unknown profile names' do + mode = described_class.new([], { json: false, no_color: true }) + expect { mode.set('bogus') }.to raise_error(SystemExit) + end + + it 'rejects custom profile without --extensions' do + mode = described_class.new([], { json: false, no_color: true }) + expect { mode.set('custom') }.to raise_error(SystemExit) + end + + it 'rejects unknown process role' do + mode = described_class.new([], { json: false, no_color: true, process_role: 'bogus' }) + expect { mode.set }.to raise_error(SystemExit) + end + + it 'does not write config in dry-run mode' do + mode = described_class.new([], { json: false, no_color: true, dry_run: true }) + mode.set('dev') + expect(File.exist?(role_file)).to be false + end + + it 'preserves existing config keys on update' do + FileUtils.mkdir_p(tmpdir) + File.write(role_file, JSON.pretty_generate({ role: { profile: 'core' }, custom_key: true })) + + mode = described_class.new([], { json: false, no_color: true }) + mode.set('dev') + data = JSON.parse(File.read(role_file), symbolize_names: true) + expect(data.dig(:role, :profile)).to eq('dev') + expect(data[:custom_key]).to be true + end + + it 'sets both profile and process role in a single call' do + mode = described_class.new([], { json: false, no_color: true, process_role: 'worker' }) + mode.set('cognitive') + data = JSON.parse(File.read(role_file), symbolize_names: true) + expect(data.dig(:role, :profile)).to eq('cognitive') + expect(data.dig(:process, :role)).to eq('worker') + end + + it 'removes extensions key when switching away from custom' do + FileUtils.mkdir_p(tmpdir) + File.write(role_file, JSON.pretty_generate({ role: { profile: 'custom', extensions: %w[a b] } })) + + mode = described_class.new([], { json: false, no_color: true }) + mode.set('dev') + data = JSON.parse(File.read(role_file), symbolize_names: true) + expect(data.dig(:role, :profile)).to eq('dev') + expect(data.dig(:role, :extensions)).to be_nil + end + end + + describe '#trigger_reload' do + it 'does not raise when daemon is not running' do + mode = described_class.new([], { json: false, no_color: true }) + out = Legion::CLI::Output::Formatter.new(json: false, color: false) + expect { mode.send(:trigger_reload, out) }.not_to raise_error + end + end +end diff --git a/spec/legion/cli/notebook_spec.rb b/spec/legion/cli/notebook_spec.rb new file mode 100644 index 00000000..695830e4 --- /dev/null +++ b/spec/legion/cli/notebook_spec.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tempfile' +require 'tmpdir' +require 'legion/cli' +require 'legion/cli/notebook_command' + +RSpec.describe Legion::CLI::Notebook do + let(:cli) { described_class.new } + let(:notebook) do + { + 'cells' => [ + { + 'cell_type' => 'markdown', + 'source' => ['# Test Notebook'], + 'metadata' => {} + }, + { + 'cell_type' => 'code', + 'source' => ['print("hello")'], + 'outputs' => [], + 'execution_count' => nil, + 'metadata' => {} + } + ], + 'metadata' => { + 'kernelspec' => { 'language' => 'python', 'display_name' => 'Python 3', 'name' => 'python3' }, + 'language_info' => { 'name' => 'python' } + }, + 'nbformat' => 4, + 'nbformat_minor' => 5 + } + end + + let(:tmpfile) do + f = Tempfile.new(['test', '.ipynb']) + f.write(JSON.generate(notebook)) + f.close + f + end + + after { tmpfile.unlink } + + describe 'class structure' do + it 'is a Thor subcommand' do + expect(described_class).to be < Thor + end + + it 'defines read, cells, export, and create commands' do + expect(described_class.commands.keys).to include('read', 'cells', 'export', 'create') + end + + it 'exits on failure' do + expect(described_class.exit_on_failure?).to be true + end + end + + describe '#read' do + it 'reads notebook without error' do + expect { cli.read(tmpfile.path) }.to output(/2 cell/).to_stdout + end + + it 'prints cells total' do + expect { cli.read(tmpfile.path) }.to output(/2 cells total/).to_stdout + end + + it 'exits when file does not exist' do + expect { cli.read('/nonexistent/file.ipynb') }.to raise_error(SystemExit) + end + + it 'exits when file is not .ipynb' do + f = Tempfile.new(['test', '.txt']) + f.write('{}') + f.close + expect { cli.read(f.path) }.to raise_error(SystemExit) + ensure + f&.unlink + end + + it 'exits on invalid JSON' do + f = Tempfile.new(['bad', '.ipynb']) + f.write('not json') + f.close + expect { cli.read(f.path) }.to raise_error(SystemExit) + ensure + f&.unlink + end + end + + describe '#cells' do + it 'lists cells with index numbers' do + expect { cli.cells(tmpfile.path) }.to output(/1.*markdown|2.*code/m).to_stdout + end + + it 'shows total count' do + expect { cli.cells(tmpfile.path) }.to output(/Total: 2 cells/).to_stdout + end + + it 'exits when file does not exist' do + expect { cli.cells('/nonexistent/file.ipynb') }.to raise_error(SystemExit) + end + end + + describe '#export' do + it 'exports as markdown by default' do + expect { cli.export(tmpfile.path) }.to output(/```python/).to_stdout + end + + it 'includes markdown cell source in output' do + expect { cli.export(tmpfile.path) }.to output(/Test Notebook/).to_stdout + end + + it 'exports as script when --format script' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'script', output: nil) + expect { cmd.export(tmpfile.path) }.to output(/print\("hello"\)/).to_stdout + end + + it 'comments markdown cells in script mode' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'script', output: nil) + expect { cmd.export(tmpfile.path) }.to output(/# /).to_stdout + end + + it 'writes to file when --output is given' do + out_file = Tempfile.new(['export', '.md']) + out_file.close + cmd = described_class.new([], json: false, no_color: true, verbose: false, + format: 'md', output: out_file.path) + cmd.export(tmpfile.path) + expect(File.read(out_file.path)).to include('```python') + ensure + out_file&.unlink + end + + it 'exits when file does not exist' do + expect { cli.export('/nonexistent/file.ipynb') }.to raise_error(SystemExit) + end + end + + describe '#create' do + let(:llm_mod) { Module.new } + let(:out) { instance_double(Legion::CLI::Output::Formatter) } + let(:generated_notebook) do + { + 'nbformat' => 4, + 'nbformat_minor' => 5, + 'metadata' => { 'kernelspec' => { 'name' => 'python3' } }, + 'cells' => [ + { 'cell_type' => 'markdown', 'source' => ['# Generated'], 'metadata' => {}, 'outputs' => [] }, + { 'cell_type' => 'code', 'source' => ['print("hi")'], 'metadata' => {}, 'outputs' => [], + 'execution_count' => nil } + ] + } + end + + before do + stub_const('Legion::LLM', llm_mod) + allow(Legion::LLM).to receive(:chat).and_return({ + content: JSON.generate(generated_notebook), + usage: {} + }) + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(out).to receive(:success) + allow(out).to receive(:error) + allow(out).to receive(:json) + allow(out).to receive(:spacer) + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_llm) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + def tmp_ipynb_path + File.join(Dir.tmpdir, "legion_nb_test_#{Process.pid}_#{rand(100_000)}.ipynb") + end + + it 'creates a notebook file' do + path = tmp_ipynb_path + cmd = described_class.new([], json: false, no_color: true, verbose: false, + description: 'A test notebook', kernel: 'python3') + cmd.create(path) + expect(File.exist?(path)).to be true + data = JSON.parse(File.read(path)) + expect(data['nbformat']).to eq(4) + ensure + File.unlink(path) if path && File.exist?(path) + end + + it 'shows error when description is missing' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + kernel: 'python3') + expect(out).to receive(:error).with(/--description is required/) + expect { cmd.create('/tmp/test.ipynb') }.to raise_error(SystemExit) + end + + it 'passes model option to LLM when provided' do + path = tmp_ipynb_path + cmd = described_class.new([], json: false, no_color: true, verbose: false, + description: 'hello', kernel: 'python3', model: 'claude-opus-4-5') + expect(Legion::LLM).to receive(:chat) + .with(hash_including(model: 'claude-opus-4-5')) + .and_return({ content: JSON.generate(generated_notebook), usage: {} }) + cmd.create(path) + ensure + File.unlink(path) if path && File.exist?(path) + end + + it 'passes provider option as symbol to LLM when provided' do + path = tmp_ipynb_path + cmd = described_class.new([], json: false, no_color: true, verbose: false, + description: 'hello', kernel: 'python3', provider: 'anthropic') + expect(Legion::LLM).to receive(:chat) + .with(hash_including(provider: :anthropic)) + .and_return({ content: JSON.generate(generated_notebook), usage: {} }) + cmd.create(path) + ensure + File.unlink(path) if path && File.exist?(path) + end + + it 'outputs JSON when --json flag is set' do + path = tmp_ipynb_path + cmd = described_class.new([], json: true, no_color: true, verbose: false, + description: 'hello', kernel: 'python3') + expect(out).to receive(:json).with(hash_including(cells: 2)) + cmd.create(path) + ensure + File.unlink(path) if path && File.exist?(path) + end + end + + describe 'private helpers' do + describe '#load_notebook' do + it 'returns parsed JSON for valid .ipynb' do + result = cli.send(:load_notebook, tmpfile.path, instance_double(Legion::CLI::Output::Formatter)) + expect(result['cells'].length).to eq(2) + end + + it 'raises SystemExit for missing file' do + out = instance_double(Legion::CLI::Output::Formatter) + allow(out).to receive(:error) + expect { cli.send(:load_notebook, '/no/such/file.ipynb', out) }.to raise_error(SystemExit) + end + + it 'raises SystemExit for non-.ipynb extension' do + f = Tempfile.new(['test', '.json']) + f.write('{}') + f.close + out = instance_double(Legion::CLI::Output::Formatter) + allow(out).to receive(:error) + expect { cli.send(:load_notebook, f.path, out) }.to raise_error(SystemExit) + ensure + f&.unlink + end + + it 'raises SystemExit for invalid JSON' do + f = Tempfile.new(['bad', '.ipynb']) + f.write('not valid json {{') + f.close + out = instance_double(Legion::CLI::Output::Formatter) + allow(out).to receive(:error) + expect { cli.send(:load_notebook, f.path, out) }.to raise_error(SystemExit) + ensure + f&.unlink + end + end + + describe '#export_as_markdown' do + it 'wraps code cells in fenced code blocks' do + cells = [{ type: 'code', source: 'x = 1', outputs: [] }] + result = cli.send(:export_as_markdown, cells, 'python') + expect(result).to include('```python') + expect(result).to include('x = 1') + expect(result).to include('```') + end + + it 'includes markdown cells as plain text' do + cells = [{ type: 'markdown', source: '# Title', outputs: [] }] + result = cli.send(:export_as_markdown, cells, 'python') + expect(result).to include('# Title') + expect(result).not_to include('```') + end + end + + describe '#export_as_script' do + it 'includes code cells as-is' do + cells = [{ type: 'code', source: 'x = 1', outputs: [] }] + result = cli.send(:export_as_script, cells, 'python') + expect(result).to include('x = 1') + end + + it 'comments out markdown cells' do + cells = [{ type: 'markdown', source: '# Title', outputs: [] }] + result = cli.send(:export_as_script, cells, 'python') + expect(result).to include('# # Title') + end + end + end +end diff --git a/spec/legion/cli/observe_command_spec.rb b/spec/legion/cli/observe_command_spec.rb new file mode 100644 index 00000000..160513e6 --- /dev/null +++ b/spec/legion/cli/observe_command_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'legion/mcp/observer' +require 'legion/mcp/embedding_index' + +require 'thor' + +unless defined?(Legion::CLI::Main) + module Legion + module CLI + class Main < Thor; end + end + end +end + +require 'legion/cli/observe_command' + +RSpec.describe Legion::CLI::ObserveCommand do + before(:each) do + Legion::MCP::Observer.reset! + Legion::MCP::EmbeddingIndex.reset! + end + + describe '#stats' do + let(:command) { described_class.new } + + before do + allow(command).to receive(:options).and_return({ 'json' => false }) + end + + it 'outputs total calls' do + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: true) + expect { command.stats }.to output(/Total Calls.*1/).to_stdout + end + + it 'outputs tool count' do + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: true) + Legion::MCP::Observer.record(tool_name: 'legion.list_tasks', duration_ms: 50, success: true) + expect { command.stats }.to output(/Tools Used.*2/).to_stdout + end + + it 'outputs failure rate' do + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: false) + expect { command.stats }.to output(/Failure Rate.*100\.0%/).to_stdout + end + + it 'outputs top tools table' do + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: true) + expect { command.stats }.to output(/Top Tools/).to_stdout + end + + it 'outputs JSON when --json flag is set' do + allow(command).to receive(:options).and_return({ 'json' => true }) + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: true) + output = StringIO.new + $stdout = output + command.stats + $stdout = STDOUT + parsed = JSON.parse(output.string) + expect(parsed['total_calls']).to eq(1) + end + + it 'handles empty stats gracefully' do + expect { command.stats }.to output(/Total Calls.*0/).to_stdout + end + end + + describe '#recent' do + let(:command) { described_class.new } + + before do + allow(command).to receive(:options).and_return({ 'json' => false, 'limit' => 10 }) + end + + it 'outputs recent tool calls' do + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: true) + expect { command.recent }.to output(/legion\.run_task/).to_stdout + end + + it 'shows empty message when no calls recorded' do + expect { command.recent }.to output(/No recent tool calls recorded/).to_stdout + end + + it 'shows status OK for successful calls' do + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: true) + expect { command.recent }.to output(/OK/).to_stdout + end + + it 'shows status FAIL for failed calls' do + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: false) + expect { command.recent }.to output(/FAIL/).to_stdout + end + + it 'outputs JSON when --json flag is set' do + allow(command).to receive(:options).and_return({ 'json' => true, 'limit' => 10 }) + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: true) + output = StringIO.new + $stdout = output + command.recent + $stdout = STDOUT + parsed = JSON.parse(output.string) + expect(parsed).to be_an(Array) + expect(parsed.first['tool_name']).to eq('legion.run_task') + end + end + + describe '#reset' do + let(:command) { described_class.new } + + it 'clears observer data when confirmed' do + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: true) + allow($stdin).to receive(:gets).and_return("yes\n") + command.reset + expect(Legion::MCP::Observer.stats[:total_calls]).to eq(0) + end + + it 'does not clear when user declines' do + Legion::MCP::Observer.record(tool_name: 'legion.run_task', duration_ms: 100, success: true) + allow($stdin).to receive(:gets).and_return("no\n") + command.reset + expect(Legion::MCP::Observer.stats[:total_calls]).to eq(1) + end + end + + describe '#embeddings' do + let(:command) { described_class.new } + + before do + allow(command).to receive(:options).and_return({ 'json' => false }) + Legion::MCP::EmbeddingIndex.reset! + end + + it 'outputs index size' do + expect { command.embeddings }.to output(/Index Size.*0/).to_stdout + end + + it 'outputs coverage' do + expect { command.embeddings }.to output(/Coverage/).to_stdout + end + + it 'shows populated status when index has entries' do + fake_embedder = ->(text) { ('a'..'z').map { |c| text.downcase.count(c).to_f } } + Legion::MCP::EmbeddingIndex.build_from_tool_data( + [{ name: 'legion.run_task', description: 'Execute', params: [] }], + embedder: fake_embedder + ) + expect { command.embeddings }.to output(/Index Size.*1/).to_stdout + end + + it 'outputs JSON when --json flag is set' do + allow(command).to receive(:options).and_return({ 'json' => true }) + output = StringIO.new + $stdout = output + command.embeddings + $stdout = STDOUT + parsed = JSON.parse(output.string) + expect(parsed).to have_key('index_size') + expect(parsed).to have_key('coverage') + end + end +end diff --git a/spec/legion/cli/openapi_command_spec.rb b/spec/legion/cli/openapi_command_spec.rb new file mode 100644 index 00000000..20f1d53e --- /dev/null +++ b/spec/legion/cli/openapi_command_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'sinatra/base' +require 'legion/cli/output' +require 'legion/cli/openapi_command' +require 'legion/api/openapi' + +RSpec.describe Legion::CLI::Openapi do + before do + Legion::Settings.load unless Legion::Settings.instance_variable_get(:@loader) + loader = Legion::Settings.loader + loader.settings[:client] ||= { name: 'test-node' } + end + + describe '#generate — stdout' do + it 'outputs JSON to stdout' do + output = capture_stdout { described_class.start(['generate']) } + expect { JSON.parse(output) }.not_to raise_error + end + + it 'output includes openapi version' do + output = capture_stdout { described_class.start(['generate']) } + parsed = JSON.parse(output) + expect(parsed['openapi']).to eq('3.1.0') + end + + it 'output includes paths key' do + output = capture_stdout { described_class.start(['generate']) } + parsed = JSON.parse(output) + expect(parsed['paths']).to be_a(Hash) + end + end + + describe '#generate — file output' do + let(:output_path) { File.join(Dir.tmpdir, "legion_openapi_test_#{Process.pid}.json") } + + after { FileUtils.rm_f(output_path) } + + it 'writes JSON to specified file' do + described_class.start(['generate', '--output', output_path]) + expect(File.exist?(output_path)).to be(true) + end + + it 'written file contains valid JSON' do + described_class.start(['generate', '--output', output_path]) + expect { JSON.parse(File.read(output_path)) }.not_to raise_error + end + + it 'written file includes openapi version' do + described_class.start(['generate', '--output', output_path]) + parsed = JSON.parse(File.read(output_path)) + expect(parsed['openapi']).to eq('3.1.0') + end + end + + describe '#routes' do + it 'outputs route lines to stdout' do + output = capture_stdout { described_class.start(['routes']) } + expect(output).not_to be_empty + end + + it 'includes GET method in output' do + output = capture_stdout { described_class.start(['routes']) } + expect(output).to match(/GET/) + end + + it 'includes /api/health path' do + output = capture_stdout { described_class.start(['routes']) } + expect(output).to include('/api/health') + end + + it 'includes /api/tasks path' do + output = capture_stdout { described_class.start(['routes']) } + expect(output).to include('/api/tasks') + end + + it 'includes POST method in output' do + output = capture_stdout { described_class.start(['routes']) } + expect(output).to match(/POST/) + end + + it 'includes route summaries' do + output = capture_stdout { described_class.start(['routes']) } + expect(output).to match(/#\s+\S/) + end + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end diff --git a/spec/legion/cli/output_spec.rb b/spec/legion/cli/output_spec.rb new file mode 100644 index 00000000..c7f51188 --- /dev/null +++ b/spec/legion/cli/output_spec.rb @@ -0,0 +1,663 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/output' +require 'json' +require 'stringio' + +RSpec.describe Legion::CLI::Output do + describe '.encode_json' do + context 'when Legion::JSON is available and responds to :dump' do + before do + stub_const('Legion::JSON', Module.new do + def self.dump(_data) + '{"stubbed":true}' + end + end) + end + + it 'delegates to Legion::JSON.dump' do + expect(described_class.encode_json({ test: true })).to eq('{"stubbed":true}') + end + end + + context 'when Legion::JSON is not available' do + before do + hide_const('Legion::JSON') if defined?(Legion::JSON) + rescue TypeError + # hide_const may not work if constant is not defined; that is fine + end + + it 'falls back to JSON.pretty_generate' do + data = { key: 'value' } + result = described_class.encode_json(data) + parsed = JSON.parse(result) + expect(parsed['key']).to eq('value') + end + end + + context 'when Legion::JSON is defined but does not respond to :dump' do + before do + stub_const('Legion::JSON', Module.new) + end + + it 'falls back to stdlib JSON.pretty_generate, raising NoMethodError because Legion::JSON shadows stdlib JSON' do + # When Legion::JSON is defined without :dump, the `else` branch calls JSON.pretty_generate + # but within the Legion namespace, `JSON` resolves to `Legion::JSON` (not stdlib JSON), + # so this raises NoMethodError — this is expected behaviour from the namespace shadowing. + data = { hello: 'world' } + expect { described_class.encode_json(data) }.to raise_error(NoMethodError) + end + end + end + + describe Legion::CLI::Output::COLORS do + it 'includes reset, bold, and dim keys' do + expect(described_class).to have_key(:reset) + expect(described_class).to have_key(:bold) + expect(described_class).to have_key(:dim) + end + + it 'includes all legacy color names' do + %i[red green yellow blue magenta cyan white gray].each do |color| + expect(described_class).to have_key(color) + end + end + + it 'includes all semantic names' do + %i[title heading body label accent muted disabled border node nominal caution critical].each do |name| + expect(described_class).to have_key(name) + end + end + end + + describe Legion::CLI::Output::STATUS_ICONS do + it 'maps every expected status key' do + %i[ok ready running enabled loaded completed warning pending disabled error failed dead unknown].each do |key| + expect(described_class).to have_key(key) + end + end + + it 'maps positive statuses to nominal' do + %i[ok ready running enabled loaded completed].each do |key| + expect(described_class[key]).to eq('nominal') + end + end + + it 'maps warning and pending to caution' do + expect(described_class[:warning]).to eq('caution') + expect(described_class[:pending]).to eq('caution') + end + + it 'maps disabled to muted' do + expect(described_class[:disabled]).to eq('muted') + end + + it 'maps error statuses to critical' do + %i[error failed dead].each do |key| + expect(described_class[key]).to eq('critical') + end + end + + it 'maps unknown to disabled' do + expect(described_class[:unknown]).to eq('disabled') + end + end +end + +RSpec.describe Legion::CLI::Output::Formatter do + def capture_stdout + output = StringIO.new + $stdout = output + yield + output.string + ensure + $stdout = STDOUT + end + + describe '#initialize' do + it 'sets json_mode from :json option' do + formatter = described_class.new(json: true, color: false) + expect(formatter.json_mode).to be(true) + end + + it 'sets json_mode to false by default' do + formatter = described_class.new(color: false) + expect(formatter.json_mode).to be(false) + end + + it 'disables color when json: true' do + # Even if color: true is passed, json mode forces color off + formatter = described_class.new(json: true, color: true) + expect(formatter.color_enabled).to be(false) + end + + it 'disables color when color: false' do + formatter = described_class.new(json: false, color: false) + expect(formatter.color_enabled).to be(false) + end + + it 'disables color when stdout is not a tty (e.g., StringIO in tests)' do + allow($stdout).to receive(:tty?).and_return(false) + formatter = described_class.new(json: false, color: true) + expect(formatter.color_enabled).to be(false) + end + end + + describe '#colorize' do + context 'with color disabled' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'returns text unchanged for any color' do + %i[red green yellow blue magenta cyan white gray title heading body label accent muted disabled].each do |color| + expect(formatter.colorize('hello', color)).to eq('hello') + end + end + + it 'converts non-string values to string' do + expect(formatter.colorize(42, :red)).to eq('42') + expect(formatter.colorize(nil, :red)).to eq('') + end + end + + context 'with color enabled' do + let(:formatter) do + f = described_class.new(json: false, color: false) + # Force color_enabled on by overriding the instance variable + f.instance_variable_set(:@color_enabled, true) + f + end + + it 'wraps text with the ANSI escape for the given color and a reset' do + result = formatter.colorize('hello', :red) + expect(result).to include('hello') + expect(result).to include(Legion::CLI::Output::COLORS[:red]) + expect(result).to include(Legion::CLI::Output::COLORS[:reset]) + end + + it 'works for every color key in COLORS (excluding bold/dim/reset)' do + color_keys = Legion::CLI::Output::COLORS.keys - %i[reset bold dim] + color_keys.each do |color| + result = formatter.colorize('x', color) + expect(result).to include(Legion::CLI::Output::COLORS[:reset]), + "expected reset for color #{color}" + end + end + end + end + + describe '#bold' do + context 'with color disabled' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'returns the text as a string' do + expect(formatter.bold('important')).to eq('important') + end + end + + context 'with color enabled' do + let(:formatter) do + f = described_class.new(json: false, color: false) + f.instance_variable_set(:@color_enabled, true) + f + end + + it 'wraps text with bold and heading escape codes and resets' do + result = formatter.bold('important') + expect(result).to include('important') + expect(result).to include(Legion::CLI::Output::COLORS[:bold]) + expect(result).to include(Legion::CLI::Output::COLORS[:heading]) + expect(result).to include(Legion::CLI::Output::COLORS[:reset]) + end + end + end + + describe '#dim' do + context 'with color disabled' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'returns the text as a string' do + expect(formatter.dim('faded')).to eq('faded') + end + end + + context 'with color enabled' do + let(:formatter) do + f = described_class.new(json: false, color: false) + f.instance_variable_set(:@color_enabled, true) + f + end + + it 'wraps text with the gray escape code and resets' do + result = formatter.dim('faded') + expect(result).to include('faded') + expect(result).to include(Legion::CLI::Output::COLORS[:gray]) + expect(result).to include(Legion::CLI::Output::COLORS[:reset]) + end + end + end + + describe '#status_color' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'returns :nominal for ok' do + expect(formatter.status_color(:ok)).to eq(:nominal) + end + + it 'returns :nominal for ready' do + expect(formatter.status_color(:ready)).to eq(:nominal) + end + + it 'returns :nominal for running' do + expect(formatter.status_color(:running)).to eq(:nominal) + end + + it 'returns :nominal for enabled' do + expect(formatter.status_color(:enabled)).to eq(:nominal) + end + + it 'returns :nominal for loaded' do + expect(formatter.status_color(:loaded)).to eq(:nominal) + end + + it 'returns :nominal for completed' do + expect(formatter.status_color(:completed)).to eq(:nominal) + end + + it 'returns :caution for warning' do + expect(formatter.status_color(:warning)).to eq(:caution) + end + + it 'returns :caution for pending' do + expect(formatter.status_color(:pending)).to eq(:caution) + end + + it 'returns :muted for disabled' do + expect(formatter.status_color(:disabled)).to eq(:muted) + end + + it 'returns :critical for error' do + expect(formatter.status_color(:error)).to eq(:critical) + end + + it 'returns :critical for failed' do + expect(formatter.status_color(:failed)).to eq(:critical) + end + + it 'returns :critical for dead' do + expect(formatter.status_color(:dead)).to eq(:critical) + end + + it 'returns :disabled for unknown' do + expect(formatter.status_color(:unknown)).to eq(:disabled) + end + + it 'returns :disabled for unrecognised statuses' do + expect(formatter.status_color(:something_else)).to eq(:disabled) + end + + it 'accepts string input and normalises it' do + expect(formatter.status_color('ok')).to eq(:nominal) + expect(formatter.status_color('FAILED')).to eq(:critical) + end + + it 'converts dots to underscores before lookup' do + # Dot-separated status strings are normalised + expect(formatter.status_color('unknown.thing')).to eq(:disabled) + end + end + + describe '#status' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'returns the text for known statuses' do + expect(formatter.status('ok')).to eq('ok') + end + + it 'returns the text for unknown statuses' do + expect(formatter.status('bogus')).to eq('bogus') + end + + context 'with color enabled' do + let(:formatter) do + f = described_class.new(json: false, color: false) + f.instance_variable_set(:@color_enabled, true) + f + end + + it 'wraps the text with the appropriate color escape' do + result = formatter.status('ok') + expect(result).to include('ok') + expect(result).to include(Legion::CLI::Output::COLORS[:reset]) + end + end + end + + describe '#banner' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'prints output to stdout' do + result = capture_stdout { formatter.banner } + expect(result).not_to be_empty + end + + it 'includes LEGION text (via logo characters) in the output' do + result = capture_stdout { formatter.banner } + # The banner renders block characters from the LOGO constant + expect(result).to include("\u2588") + end + + it 'includes the version string when provided' do + result = capture_stdout { formatter.banner(version: '1.2.3') } + expect(result).to include('1.2.3') + end + + it 'includes a description when a version is provided' do + result = capture_stdout { formatter.banner(version: '1.0.0') } + expect(result).to include('Async Job Engine') + end + + it 'does not include version text when version is nil' do + result = capture_stdout { formatter.banner } + expect(result).not_to include('Async Job Engine') + end + end + + describe '#header' do + context 'in text mode' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'prints the header text to stdout' do + result = capture_stdout { formatter.header('My Section') } + expect(result.strip).to eq('My Section') + end + + context 'with color enabled' do + let(:formatter) do + f = described_class.new(json: false, color: false) + f.instance_variable_set(:@color_enabled, true) + f + end + + it 'wraps the text in bold/heading escapes' do + result = capture_stdout { formatter.header('Colored Header') } + expect(result).to include('Colored Header') + expect(result).to include(Legion::CLI::Output::COLORS[:bold]) + expect(result).to include(Legion::CLI::Output::COLORS[:reset]) + end + end + end + + context 'in json mode' do + let(:formatter) { described_class.new(json: true, color: false) } + + it 'prints nothing' do + result = capture_stdout { formatter.header('Silent Header') } + expect(result).to be_empty + end + end + end + + describe '#detail' do + context 'in text mode' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'prints each key-value pair' do + result = capture_stdout { formatter.detail({ name: 'legion', version: '1.0.0' }) } + expect(result).to include('name') + expect(result).to include('legion') + expect(result).to include('version') + expect(result).to include('1.0.0') + end + + it 'renders true as "yes"' do + result = capture_stdout { formatter.detail({ active: true }) } + expect(result).to include('yes') + end + + it 'renders false as "no"' do + result = capture_stdout { formatter.detail({ active: false }) } + expect(result).to include('no') + end + + it 'renders nil as "(none)"' do + result = capture_stdout { formatter.detail({ value: nil }) } + expect(result).to include('(none)') + end + + it 'renders numeric values as strings' do + result = capture_stdout { formatter.detail({ count: 42 }) } + expect(result).to include('42') + end + + it 'renders string values directly' do + result = capture_stdout { formatter.detail({ label: 'hello' }) } + expect(result).to include('hello') + end + + it 'applies indentation when indent: is specified' do + result_no_indent = capture_stdout { formatter.detail({ key: 'val' }, indent: 0) } + result_indented = capture_stdout { formatter.detail({ key: 'val' }, indent: 4) } + expect(result_indented.length).to be > result_no_indent.length + end + + it 'left-justifies keys to the longest key width' do + result = capture_stdout { formatter.detail({ a: '1', longkey: '2' }) } + lines = result.lines + # Both lines must have the same key-column width (padded with spaces) + key_columns = lines.map { |l| l.match(/^\s+(\S+\s*):/)&.send(:[], 0) }.compact + expect(key_columns).not_to be_empty + end + end + + context 'in json mode' do + let(:formatter) { described_class.new(json: true, color: false) } + + it 'prints JSON-encoded hash to stdout' do + result = capture_stdout { formatter.detail({ name: 'legion', active: true }) } + parsed = JSON.parse(result) + expect(parsed['name']).to eq('legion') + expect(parsed['active']).to be(true) + end + end + end + + describe '#table' do + context 'in text mode with rows' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'renders column headers uppercased' do + result = capture_stdout { formatter.table(%w[name status], [%w[alpha ok]]) } + expect(result).to include('NAME') + expect(result).to include('STATUS') + end + + it 'renders each row value' do + result = capture_stdout { formatter.table(%w[name status], [%w[alpha ok], %w[beta running]]) } + expect(result).to include('alpha') + expect(result).to include('beta') + expect(result).to include('ok') + expect(result).to include('running') + end + + it 'renders a separator line under the header' do + result = capture_stdout { formatter.table(%w[name], [%w[x]]) } + expect(result).to match(/─+/) + end + + it 'adds a blank line before content when title is given' do + result = capture_stdout { formatter.table(%w[name], [%w[x]], title: 'My Table') } + # A title causes a puts before the header line, so there should be a blank line + expect(result).to start_with("\n").or include("\n\n") + end + + it 'does not add a blank line when title is nil' do + result = capture_stdout { formatter.table(%w[name], [%w[x]]) } + expect(result).not_to start_with("\n\n") + end + end + + context 'in text mode with empty rows' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'prints a (no results) message' do + result = capture_stdout { formatter.table(%w[name status], []) } + expect(result).to include('(no results)') + end + + it 'does not print headers when rows are empty' do + result = capture_stdout { formatter.table(%w[name status], []) } + expect(result).not_to include('NAME') + end + end + + context 'in json mode' do + let(:formatter) { described_class.new(json: true, color: false) } + + it 'prints a JSON array of objects keyed by header' do + result = capture_stdout { formatter.table(%w[name status], [%w[alpha ok]]) } + parsed = JSON.parse(result) + expect(parsed).to be_an(Array) + expect(parsed.first['name']).to eq('alpha') + expect(parsed.first['status']).to eq('ok') + end + + it 'wraps output in a titled object when title is given' do + result = capture_stdout { formatter.table(%w[name], [%w[x]], title: 'My Table') } + parsed = JSON.parse(result) + expect(parsed).to have_key('title') + expect(parsed['title']).to eq('My Table') + expect(parsed).to have_key('data') + end + + it 'returns an empty array for empty rows' do + result = capture_stdout { formatter.table(%w[name status], []) } + parsed = JSON.parse(result) + expect(parsed).to eq([]) + end + end + end + + describe '#success' do + context 'in text mode' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'prints the message to stdout' do + result = capture_stdout { formatter.success('It worked!') } + expect(result).to include('It worked!') + end + + it 'includes the arrow character' do + result = capture_stdout { formatter.success('Done') } + expect(result).to include('»') + end + end + + context 'in json mode' do + let(:formatter) { described_class.new(json: true, color: false) } + + it 'prints JSON with success: true and message' do + result = capture_stdout { formatter.success('It worked!') } + parsed = JSON.parse(result) + expect(parsed['success']).to be(true) + expect(parsed['message']).to eq('It worked!') + end + end + end + + describe '#warn' do + context 'in text mode' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'prints the message to stdout' do + result = capture_stdout { formatter.warn('Take care!') } + expect(result).to include('Take care!') + end + + it 'includes the arrow character' do + result = capture_stdout { formatter.warn('Careful') } + expect(result).to include('»') + end + end + + context 'in json mode' do + let(:formatter) { described_class.new(json: true, color: false) } + + it 'prints JSON with warning: true and message' do + result = capture_stdout { formatter.warn('Take care!') } + parsed = JSON.parse(result) + expect(parsed['warning']).to be(true) + expect(parsed['message']).to eq('Take care!') + end + end + end + + describe '#error' do + context 'in text mode' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'prints the message to stdout' do + result = capture_stdout { formatter.error('Something broke') } + expect(result).to include('Something broke') + end + + it 'includes the arrow character twice (error delegates to warn which also adds one)' do + result = capture_stdout { formatter.error('Oops') } + expect(result.count('»')).to be >= 2 + end + end + + context 'in json mode' do + let(:formatter) { described_class.new(json: true, color: false) } + + it 'prints JSON with error: true and message' do + result = capture_stdout { formatter.error('Something broke') } + parsed = JSON.parse(result) + expect(parsed['error']).to be(true) + expect(parsed['message']).to eq('Something broke') + end + end + end + + describe '#json' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'outputs valid JSON regardless of json_mode' do + result = capture_stdout { formatter.json({ key: 'value', count: 3 }) } + parsed = JSON.parse(result) + expect(parsed['key']).to eq('value') + expect(parsed['count']).to eq(3) + end + + it 'outputs valid JSON for arrays' do + result = capture_stdout { formatter.json([1, 2, 3]) } + parsed = JSON.parse(result) + expect(parsed).to eq([1, 2, 3]) + end + + it 'outputs a newline terminator' do + result = capture_stdout { formatter.json({ a: 1 }) } + expect(result).to end_with("\n") + end + end + + describe '#spacer' do + context 'in text mode' do + let(:formatter) { described_class.new(json: false, color: false) } + + it 'prints a blank line' do + result = capture_stdout { formatter.spacer } + expect(result).to eq("\n") + end + end + + context 'in json mode' do + let(:formatter) { described_class.new(json: true, color: false) } + + it 'prints nothing' do + result = capture_stdout { formatter.spacer } + expect(result).to be_empty + end + end + end +end diff --git a/spec/legion/cli/pr_spec.rb b/spec/legion/cli/pr_spec.rb new file mode 100644 index 00000000..ed807465 --- /dev/null +++ b/spec/legion/cli/pr_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' + +require 'legion/cli/pr_command' + +PrResponse = Struct.new(:content) + +RSpec.describe Legion::CLI::Pr do + let(:fake_chat) do + chat = double('chat') + allow(chat).to receive(:ask).and_return( + PrResponse.new(content: "Add user authentication\n\n## Summary\n- Add JWT auth\n- Add login endpoint") + ) + chat + end + + before do + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_llm) + allow(Legion::CLI::Connection).to receive(:shutdown) + allow(Legion::LLM).to receive(:chat).and_return(fake_chat) + end + + describe 'current_branch' do + it 'returns the current git branch' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'rev-parse', '--abbrev-ref', 'HEAD') + .and_return(["feature/auth\n", '', double(success?: true)]) + + expect(instance.current_branch).to eq('feature/auth') + end + end + + describe 'branch_diff' do + it 'returns diff against base branch' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'diff', 'main...HEAD') + .and_return(["diff --git a/auth.rb\n+login code\n", '', double(success?: true)]) + + result = instance.branch_diff('main') + expect(result).to include('diff --git') + end + end + + describe 'branch_log' do + it 'returns commit log since base' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'log', 'main..HEAD', '--oneline', '--no-decorate') + .and_return(["abc123 add auth\ndef456 add tests\n", '', double(success?: true)]) + + result = instance.branch_log('main') + expect(result).to include('add auth') + end + end + + describe 'detect_remote' do + it 'parses HTTPS remote URL' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'remote', 'get-url', 'origin') + .and_return(["https://github.com/LegionIO/LegionIO.git\n", '', double(success?: true)]) + + owner, repo = instance.detect_remote + expect(owner).to eq('LegionIO') + expect(repo).to eq('LegionIO') + end + + it 'parses SSH remote URL' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'remote', 'get-url', 'origin') + .and_return(["git@github.com:LegionIO/LegionIO.git\n", '', double(success?: true)]) + + owner, repo = instance.detect_remote + expect(owner).to eq('LegionIO') + expect(repo).to eq('LegionIO') + end + + it 'handles URLs without .git suffix' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'remote', 'get-url', 'origin') + .and_return(["https://github.com/org/repo\n", '', double(success?: true)]) + + owner, repo = instance.detect_remote + expect(owner).to eq('org') + expect(repo).to eq('repo') + end + end + + describe 'resolve_token' do + it 'uses --token option when provided' do + instance = described_class.new([], { token: 'my-token' }) + expect(instance.resolve_token).to eq('my-token') + end + + it 'falls back to GITHUB_TOKEN env var' do + instance = described_class.new + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('GITHUB_TOKEN', nil).and_return('env-token') + expect(instance.resolve_token).to eq('env-token') + end + + it 'raises when no token available' do + instance = described_class.new + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('GITHUB_TOKEN', nil).and_return(nil) + allow(ENV).to receive(:fetch).with('GH_TOKEN', nil).and_return(nil) + expect { instance.resolve_token }.to raise_error(Legion::CLI::Error, /No GitHub token/) + end + end + + describe 'build_prompt' do + it 'includes diff, stat, log, and branch in prompt' do + instance = described_class.new + prompt = instance.build_prompt('diff', 'stat', 'log', 'feature/auth') + expect(prompt).to include('diff') + expect(prompt).to include('stat') + expect(prompt).to include('log') + expect(prompt).to include('feature/auth') + expect(prompt).to include('under 70 characters') + end + end + + describe 'parse_pr_response' do + it 'splits title and body from LLM response' do + instance = described_class.new + title, body = instance.parse_pr_response("My Title\n\n## Summary\n- thing one\n- thing two") + expect(title).to eq('My Title') + expect(body).to include('## Summary') + end + + it 'handles single-line response' do + instance = described_class.new + title, body = instance.parse_pr_response('Just a title') + expect(title).to eq('Just a title') + expect(body).to eq('') + end + end + + describe 'generate_pr_content' do + it 'returns title and body from LLM' do + instance = described_class.new([], { model: nil, provider: nil }) + title, body = instance.generate_pr_content('diff', 'stat', 'log', 'feature/auth') + expect(title).to eq('Add user authentication') + expect(body).to include('## Summary') + end + end + + describe 'push_branch' do + it 'pushes to origin' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'push', '-u', 'origin', 'feature/auth') + .and_return(['', '', double(success?: true)]) + + expect { instance.push_branch('feature/auth') }.not_to raise_error + end + + it 'raises on push failure' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'push', '-u', 'origin', 'feature/auth') + .and_return(['', 'rejected', double(success?: false)]) + + expect { instance.push_branch('feature/auth') }.to raise_error( + Legion::CLI::Error, /git push failed/ + ) + end + end +end diff --git a/spec/legion/cli/prompt_command_spec.rb b/spec/legion/cli/prompt_command_spec.rb new file mode 100644 index 00000000..3dc53e63 --- /dev/null +++ b/spec/legion/cli/prompt_command_spec.rb @@ -0,0 +1,463 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/prompt_command' + +RSpec.describe Legion::CLI::Prompt do + let(:out) { instance_double(Legion::CLI::Output::Formatter) } + let(:client) { instance_double('Legion::Extensions::Prompt::Client') } + + before do + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(out).to receive(:success) + allow(out).to receive(:error) + allow(out).to receive(:warn) + allow(out).to receive(:json) + allow(out).to receive(:spacer) + allow(out).to receive(:detail) + allow(out).to receive(:header) + allow(out).to receive(:table) + + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + + stub_const('Legion::Extensions::Prompt::Client', Class.new do + def initialize(**); end + end) + allow(Legion::Extensions::Prompt::Client).to receive(:new).and_return(client) + + data_mod = Module.new { def self.db = nil } + stub_const('Legion::Data', data_mod) + end + + def build_command(opts = {}) + described_class.new([], opts.merge(json: false, no_color: true, verbose: false)) + end + + def build_json_command(opts = {}) + described_class.new([], opts.merge(json: true, no_color: true, verbose: false)) + end + + # Helper to stub the with_prompt_client block to yield our test client + def stub_client(cmd) + allow(cmd).to receive(:with_prompt_client).and_yield(client) + end + + describe 'class structure' do + it 'is a Thor subcommand' do + expect(described_class).to be < Thor + end + + it 'has list as default task' do + expect(described_class.default_command).to eq('list') + end + + it 'responds to list, show, create, tag, diff' do + expect(described_class.commands.keys).to include('list', 'show', 'create', 'tag', 'diff') + end + end + + describe '#list' do + let(:prompts) do + [ + { name: 'summarize', description: 'Summarize text', latest_version: 2, updated_at: '2026-01-01' }, + { name: 'translate', description: 'Translate text', latest_version: 1, updated_at: '2026-01-02' } + ] + end + + before { allow(client).to receive(:list_prompts).and_return(prompts) } + + it 'renders a table of prompts' do + cmd = build_command + stub_client(cmd) + expect(out).to receive(:table).with(%w[name description version updated_at], anything) + cmd.list + end + + it 'outputs JSON when --json is set' do + cmd = build_json_command + stub_client(cmd) + expect(out).to receive(:json).with(prompts) + cmd.list + end + + it 'warns when no prompts exist' do + allow(client).to receive(:list_prompts).and_return([]) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:warn).with('No prompts found') + cmd.list + end + end + + describe '#show' do + let(:prompt_result) do + { name: 'summarize', version: 2, template: 'Summarize: {{text}}', + model_params: { temperature: 0.5 }, content_hash: 'abc123', created_at: '2026-01-01' } + end + + before { allow(client).to receive(:get_prompt).and_return(prompt_result) } + + it 'renders prompt details' do + cmd = build_command + stub_client(cmd) + expect(out).to receive(:header).with('Prompt: summarize') + cmd.show('summarize') + end + + it 'outputs JSON when --json is set' do + cmd = build_json_command + stub_client(cmd) + expect(out).to receive(:json).with(prompt_result) + cmd.show('summarize') + end + + it 'shows error when prompt not found' do + allow(client).to receive(:get_prompt).and_return({ error: 'not_found' }) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:error).with(/not_found/) + expect { cmd.show('missing') }.to raise_error(SystemExit) + end + + it 'passes version option to get_prompt' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, version: 1) + stub_client(cmd) + expect(client).to receive(:get_prompt).with(name: 'summarize', version: 1).and_return(prompt_result) + cmd.show('summarize') + end + + it 'passes tag option to get_prompt' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, tag: 'stable') + stub_client(cmd) + expect(client).to receive(:get_prompt).with(name: 'summarize', tag: 'stable').and_return(prompt_result) + cmd.show('summarize') + end + end + + describe '#create' do + let(:create_result) { { created: true, name: 'new-prompt', version: 1, prompt_id: 42 } } + + before { allow(client).to receive(:create_prompt).and_return(create_result) } + + it 'calls create_prompt with name and template' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + template: 'Hello {{name}}') + stub_client(cmd) + expect(client).to receive(:create_prompt).with( + name: 'new-prompt', template: 'Hello {{name}}', description: nil, model_params: {} + ).and_return(create_result) + cmd.create('new-prompt') + end + + it 'outputs success message after creation' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + template: 'Hello {{name}}') + stub_client(cmd) + expect(out).to receive(:success).with(/new-prompt.*version 1/i) + cmd.create('new-prompt') + end + + it 'outputs JSON when --json is set' do + cmd = described_class.new([], json: true, no_color: true, verbose: false, + template: 'Hello') + stub_client(cmd) + expect(out).to receive(:json).with(create_result) + cmd.create('new-prompt') + end + + it 'passes description and model_params when provided' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + template: 'tmpl', description: 'my desc', + model_params: '{"temperature":0.7}') + stub_client(cmd) + expect(client).to receive(:create_prompt).with( + name: 'new-prompt', template: 'tmpl', description: 'my desc', + model_params: { 'temperature' => 0.7 } + ).and_return(create_result) + cmd.create('new-prompt') + end + + it 'shows error on invalid JSON in --model-params' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + template: 'tmpl', model_params: 'not-json') + stub_client(cmd) + expect(client).not_to receive(:create_prompt) + expect(out).to receive(:error).with(/Invalid JSON/) + cmd.create('new-prompt') + end + end + + describe '#tag' do + let(:tag_result) { { tagged: true, name: 'summarize', tag: 'stable', version: 2 } } + + before { allow(client).to receive(:tag_prompt).and_return(tag_result) } + + it 'calls tag_prompt with name and tag' do + cmd = build_command + stub_client(cmd) + expect(client).to receive(:tag_prompt).with(name: 'summarize', tag: 'stable').and_return(tag_result) + cmd.tag('summarize', 'stable') + end + + it 'outputs success message' do + cmd = build_command + stub_client(cmd) + expect(out).to receive(:success).with(/summarize.*v2.*stable/i) + cmd.tag('summarize', 'stable') + end + + it 'outputs JSON when --json is set' do + cmd = build_json_command + stub_client(cmd) + expect(out).to receive(:json).with(tag_result) + cmd.tag('summarize', 'stable') + end + + it 'shows error when not found' do + allow(client).to receive(:tag_prompt).and_return({ error: 'not_found' }) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:error).with(/not_found/) + expect { cmd.tag('missing', 'stable') }.to raise_error(SystemExit) + end + + it 'passes version option to tag_prompt' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, version: 3) + stub_client(cmd) + expect(client).to receive(:tag_prompt).with(name: 'summarize', tag: 'stable', version: 3).and_return(tag_result) + cmd.tag('summarize', 'stable') + end + end + + describe '#diff' do + let(:v1_result) { { name: 'summarize', version: 1, template: "line one\nline two" } } + let(:v2_result) { { name: 'summarize', version: 2, template: "line one\nline three" } } + + before do + allow(client).to receive(:get_prompt).with(name: 'summarize', version: 1).and_return(v1_result) + allow(client).to receive(:get_prompt).with(name: 'summarize', version: 2).and_return(v2_result) + end + + it 'fetches both versions and prints diff' do + cmd = build_command + stub_client(cmd) + output = StringIO.new + $stdout = output + cmd.diff('summarize', '1', '2') + $stdout = STDOUT + expect(output.string).to include('--- v1') + expect(output.string).to include('+++ v2') + end + + it 'outputs JSON when --json is set' do + cmd = build_json_command + stub_client(cmd) + expect(out).to receive(:json).with(hash_including(name: 'summarize', v1: 1, v2: 2)) + cmd.diff('summarize', '1', '2') + end + + it 'shows error when v1 not found' do + allow(client).to receive(:get_prompt).with(name: 'summarize', version: 1) + .and_return({ error: 'not_found' }) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:error).with(/Version 1/) + expect { cmd.diff('summarize', '1', '2') }.to raise_error(SystemExit) + end + + it 'shows error when v2 not found' do + allow(client).to receive(:get_prompt).with(name: 'summarize', version: 2) + .and_return({ error: 'not_found' }) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:error).with(/Version 2/) + expect { cmd.diff('summarize', '1', '2') }.to raise_error(SystemExit) + end + end + + describe '#play' do + let(:prompt_result) { { name: 'summarize', version: 2, template: 'Summarize: {{text}}' } } + let(:rendered) { 'Summarize: Hello world' } + let(:llm_response) { { content: 'This is a summary.', usage: { input_tokens: 10, output_tokens: 5 } } } + let(:llm_module) { Module.new } + + before do + stub_const('Legion::LLM', llm_module) + allow(Legion::LLM).to receive(:started?).and_return(true) + allow(Legion::LLM).to receive(:chat).and_return(llm_response) + + allow(client).to receive(:get_prompt).and_return(prompt_result) + allow(client).to receive(:render_prompt).and_return(rendered) + end + + it 'is registered as a command' do + expect(described_class.commands.keys).to include('play') + end + + context 'single version mode' do + it 'renders the prompt and calls LLM' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + variables: '{"text":"Hello world"}') + stub_client(cmd) + expect(client).to receive(:get_prompt).with(name: 'summarize').and_return(prompt_result) + expect(client).to receive(:render_prompt) + .with(name: 'summarize', variables: { 'text' => 'Hello world' }) + .and_return(rendered) + expect(Legion::LLM).to receive(:chat) + .with(hash_including(messages: [{ role: 'user', content: rendered }])) + .and_return(llm_response) + cmd.play('summarize') + end + + it 'outputs header with prompt name and version' do + cmd = described_class.new([], json: false, no_color: true, verbose: false) + stub_client(cmd) + expect(out).to receive(:header).with(/summarize.*v2/i) + cmd.play('summarize') + end + + it 'passes version option to get_prompt and render_prompt' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, version: 1) + stub_client(cmd) + versioned_result = prompt_result.merge(version: 1) + allow(client).to receive(:get_prompt).with(name: 'summarize', version: 1) + .and_return(versioned_result) + allow(client).to receive(:render_prompt) + .with(name: 'summarize', variables: {}, version: 1) + .and_return(rendered) + expect(Legion::LLM).to receive(:chat).and_return(llm_response) + cmd.play('summarize') + end + + it 'passes model and provider to LLM when provided' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + model: 'claude-3', provider: 'anthropic') + stub_client(cmd) + expect(Legion::LLM).to receive(:chat) + .with(hash_including(messages: anything, model: 'claude-3', provider: 'anthropic')) + .and_return(llm_response) + cmd.play('summarize') + end + + it 'outputs JSON when --json is set' do + cmd = described_class.new([], json: true, no_color: true, verbose: false) + stub_client(cmd) + expect(out).to receive(:json).with(hash_including( + name: 'summarize', + version: 2, + rendered: rendered, + response: 'This is a summary.' + )) + cmd.play('summarize') + end + + it 'shows error when prompt not found' do + allow(client).to receive(:get_prompt).and_return({ error: 'not_found' }) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:error).with(/not_found/) + expect { cmd.play('missing') }.to raise_error(SystemExit) + end + + it 'shows error on invalid JSON in --variables' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + variables: 'not-json') + stub_client(cmd) + expect(out).to receive(:error).with(/Invalid JSON/) + cmd.play('summarize') + end + end + + context 'compare mode' do + let(:prompt_v1) { { name: 'summarize', version: 1, template: 'v1 template' } } + let(:prompt_v2) { { name: 'summarize', version: 2, template: 'v2 template' } } + let(:rendered_v1) { 'v1 rendered' } + let(:rendered_v2) { 'v2 rendered' } + let(:response_v1) { { content: 'Response A', usage: {} } } + let(:response_v2) { { content: 'Response B', usage: {} } } + + before do + allow(client).to receive(:get_prompt).with(name: 'summarize').and_return(prompt_v2) + allow(client).to receive(:get_prompt).with(name: 'summarize', version: 1).and_return(prompt_v1) + allow(client).to receive(:get_prompt).with(name: 'summarize', version: 2).and_return(prompt_v2) + allow(client).to receive(:render_prompt) + .with(name: 'summarize', variables: {}, version: 1).and_return(rendered_v1) + allow(client).to receive(:render_prompt) + .with(name: 'summarize', variables: {}, version: 2).and_return(rendered_v2) + allow(Legion::LLM).to receive(:chat) + .with(hash_including(messages: [{ role: 'user', content: rendered_v1 }])).and_return(response_v1) + allow(Legion::LLM).to receive(:chat) + .with(hash_including(messages: [{ role: 'user', content: rendered_v2 }])).and_return(response_v2) + end + + it 'renders both versions and calls LLM twice' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + version: 2, compare: 1) + stub_client(cmd) + expect(Legion::LLM).to receive(:chat).twice.and_return(response_v1, response_v2) + cmd.play('summarize') + end + + it 'displays headers for both versions' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + version: 2, compare: 1) + stub_client(cmd) + expect(out).to receive(:header).with(/Version A.*v2/i) + expect(out).to receive(:header).with(/Version B.*v1/i) + cmd.play('summarize') + end + + it 'outputs JSON when --json is set' do + cmd = described_class.new([], json: true, no_color: true, verbose: false, + version: 2, compare: 1) + stub_client(cmd) + expect(out).to receive(:json).with(hash_including( + name: 'summarize', + version_a: 2, + version_b: 1 + )) + cmd.play('summarize') + end + + it 'shows diff section when responses differ' do + cmd = described_class.new([], json: false, no_color: true, verbose: false, + version: 2, compare: 1) + stub_client(cmd) + expect(out).to receive(:header).with(/Diff/i) + cmd.play('summarize') + end + + it 'skips diff section when responses are identical' do + identical = { content: 'Same response', usage: {} } + allow(Legion::LLM).to receive(:chat).and_return(identical) + cmd = described_class.new([], json: false, no_color: true, verbose: false, + version: 2, compare: 1) + stub_client(cmd) + expect(out).not_to receive(:header).with(/Diff/i) + cmd.play('summarize') + end + end + + context 'when LLM is not available' do + it 'shows error and raises SystemExit when Legion::LLM not defined' do + hide_const('Legion::LLM') + cmd = build_command + stub_client(cmd) + expect(out).to receive(:error).with(/legion-llm is not available/) + expect { cmd.play('summarize') }.to raise_error(SystemExit) + end + + it 'shows error and raises SystemExit when Legion::LLM not started' do + allow(Legion::LLM).to receive(:started?).and_return(false) + cmd = build_command + stub_client(cmd) + expect(out).to receive(:error).with(/legion-llm is not available/) + expect { cmd.play('summarize') }.to raise_error(SystemExit) + end + end + end +end diff --git a/spec/legion/cli/rbac_command_spec.rb b/spec/legion/cli/rbac_command_spec.rb new file mode 100644 index 00000000..534e4397 --- /dev/null +++ b/spec/legion/cli/rbac_command_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'sequel' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/cli/error' +require 'legion/cli/rbac_command' + +RSpec.describe Legion::CLI::Rbac do + let(:out) do + instance_double(Legion::CLI::Output::Formatter, + success: nil, error: nil, warn: nil, + table: nil, json: nil, header: nil) + end + + before do + allow_any_instance_of(described_class).to receive(:formatter).and_return(out) + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_settings) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + def stub_rbac_setup + rbac_mod = Module.new do + def self.setup; end + def self.role_index = {} + end + stub_const('Legion::Rbac', rbac_mod) unless defined?(Legion::Rbac) + allow(Legion::Rbac).to receive(:setup) + allow_any_instance_of(described_class).to receive(:require).and_call_original + allow_any_instance_of(described_class).to receive(:require).with('legion/rbac').and_return(true) + end + + describe 'roles' do + it 'lists roles in a table' do + stub_rbac_setup + allow(Legion::Rbac).to receive(:role_index).and_return({}) + expect(out).to receive(:table).with(%w[Role Description CrossTeam], []) + described_class.start(%w[roles]) + end + + it 'outputs JSON when requested' do + stub_rbac_setup + allow(Legion::Rbac).to receive(:role_index).and_return({}) + expect(out).to receive(:json) + described_class.start(%w[roles --json]) + end + end + + describe 'show' do + it 'displays role details' do + stub_rbac_setup + fake_role = double('role', + name: 'admin', + description: 'Full access', + cross_team?: true, + permissions: [], + deny_rules: []) + allow(Legion::Rbac).to receive(:role_index).and_return({ admin: fake_role }) + + expect(out).to receive(:header).with('Role: admin') + expect { described_class.start(%w[show admin]) }.to output(/Full access/).to_stdout + end + + it 'reports error for unknown role' do + stub_rbac_setup + allow(Legion::Rbac).to receive(:role_index).and_return({}) + expect(out).to receive(:error).with(/Role not found/) + described_class.start(%w[show nonexistent]) + end + end + + describe 'assignments' do + it 'lists role assignments' do + stub_rbac_setup + model = class_double('Legion::Data::Model::RbacRoleAssignment') + stub_const('Legion::Data::Model::RbacRoleAssignment', model) + + fake_dataset = double('dataset') + allow(model).to receive(:dataset).and_return(fake_dataset) + allow(fake_dataset).to receive(:all).and_return([]) + + expect(out).to receive(:table) + described_class.start(%w[assignments]) + end + end + + describe 'assign' do + it 'creates a role assignment' do + stub_rbac_setup + model = class_double('Legion::Data::Model::RbacRoleAssignment') + stub_const('Legion::Data::Model::RbacRoleAssignment', model) + + fake_record = double('record', id: 7) + allow(model).to receive(:create).and_return(fake_record) + + expect(out).to receive(:success).with(/Assigned operator to user-42/) + described_class.start(%w[assign user-42 operator]) + end + end + + describe 'revoke' do + it 'removes role assignments' do + stub_rbac_setup + model = class_double('Legion::Data::Model::RbacRoleAssignment') + stub_const('Legion::Data::Model::RbacRoleAssignment', model) + + fake_dataset = double('dataset') + allow(model).to receive(:where).and_return(fake_dataset) + allow(fake_dataset).to receive(:count).and_return(2) + allow(fake_dataset).to receive(:destroy) + + expect(out).to receive(:success).with(/Revoked 2/) + described_class.start(%w[revoke user-42 operator]) + end + end + + describe 'check' do + it 'evaluates authorization' do + stub_rbac_setup + + principal_class = Class.new do + def initialize(**); end + end + stub_const('Legion::Rbac::Principal', principal_class) + + engine = Module.new do + def self.evaluate(**) = { allowed: true, reason: 'admin role' } + end + stub_const('Legion::Rbac::PolicyEngine', engine) + + expect { described_class.start(%w[check user-1 tasks/42 --action read]) }.to output(/ALLOWED/).to_stdout + end + + it 'shows DENIED for unauthorized access' do + stub_rbac_setup + + principal_class = Class.new do + def initialize(**); end + end + stub_const('Legion::Rbac::Principal', principal_class) + + engine = Module.new do + def self.evaluate(**) = { allowed: false, reason: 'no matching permission' } + end + stub_const('Legion::Rbac::PolicyEngine', engine) + + expect { described_class.start(%w[check user-1 secrets/key]) }.to output(/DENIED/).to_stdout + end + end +end diff --git a/spec/legion/cli/review_spec.rb b/spec/legion/cli/review_spec.rb new file mode 100644 index 00000000..091ef7f2 --- /dev/null +++ b/spec/legion/cli/review_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' + +require 'legion/cli/review_command' + +ReviewResponse = Struct.new(:content) + +RSpec.describe Legion::CLI::Review do + let(:review_response) do + <<~REVIEW + [CRITICAL] auth.rb:15 - SQL injection vulnerability in user lookup + [WARNING] auth.rb:23 - Missing nil check on session token + [SUGGESTION] auth.rb:30 - Extract magic number into a constant + [NOTE] auth.rb:1 - Consider adding module documentation + SUMMARY: Auth module has a critical SQL injection vulnerability that must be fixed. + REVIEW + end + + let(:fake_chat) do + chat = double('chat') + allow(chat).to receive(:ask).and_return(ReviewResponse.new(content: review_response)) + chat + end + + before do + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_llm) + allow(Legion::CLI::Connection).to receive(:shutdown) + allow(Legion::LLM).to receive(:chat).and_return(fake_chat) + end + + describe 'fetch_staged_diff' do + it 'returns staged diff and stat' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'diff', '--staged') + .and_return(["diff --git a/foo\n", '', double(success?: true)]) + allow(Open3).to receive(:capture3) + .with('git', 'diff', '--staged', '--stat') + .and_return([' foo.rb | 2 +-', '', double(success?: true)]) + + diff, context = instance.fetch_staged_diff + expect(diff).to include('diff --git') + expect(context[:mode]).to eq('staged') + end + end + + describe 'fetch_working_diff' do + it 'returns working directory diff' do + instance = described_class.new + allow(Open3).to receive(:capture3) + .with('git', 'diff') + .and_return(["diff --git a/bar\n", '', double(success?: true)]) + allow(Open3).to receive(:capture3) + .with('git', 'diff', '--stat') + .and_return([' bar.rb | 1 +', '', double(success?: true)]) + + diff, context = instance.fetch_working_diff + expect(diff).to include('diff --git') + expect(context[:mode]).to eq('working') + end + end + + describe 'fetch_branch_diff' do + it 'returns branch diff with log' do + instance = described_class.new([], { base: 'main' }) + allow(Open3).to receive(:capture3) + .with('git', 'diff', 'main...HEAD') + .and_return(["diff content\n", '', double(success?: true)]) + allow(Open3).to receive(:capture3) + .with('git', 'diff', 'main...HEAD', '--stat') + .and_return(['stat content', '', double(success?: true)]) + allow(Open3).to receive(:capture3) + .with('git', 'log', 'main..HEAD', '--oneline', '--no-decorate') + .and_return(['abc add feature', '', double(success?: true)]) + + diff, context = instance.fetch_branch_diff + expect(diff).to include('diff content') + expect(context[:mode]).to eq('branch') + expect(context[:log]).to include('add feature') + end + end + + describe 'parse_review' do + it 'parses findings by severity' do + instance = described_class.new + result = instance.parse_review(review_response, { mode: 'working' }) + + expect(result[:findings].length).to eq(4) + expect(result[:findings][0][:severity]).to eq('critical') + expect(result[:findings][1][:severity]).to eq('warning') + expect(result[:findings][2][:severity]).to eq('suggestion') + expect(result[:findings][3][:severity]).to eq('note') + end + + it 'extracts summary' do + instance = described_class.new + result = instance.parse_review(review_response, { mode: 'working' }) + expect(result[:summary]).to include('SQL injection') + end + + it 'handles response with no findings' do + instance = described_class.new + result = instance.parse_review("SUMMARY: No issues found.\n", { mode: 'staged' }) + expect(result[:findings]).to be_empty + expect(result[:summary]).to eq('No issues found.') + end + + it 'parses fix blocks' do + fix_response = <<~REVIEW + [CRITICAL] foo.rb:10 - Bug found + SUMMARY: Has a bug. + FIX foo.rb:10 + ```diff + -old line + +new line + ``` + REVIEW + instance = described_class.new + result = instance.parse_review(fix_response, { mode: 'working' }) + expect(result[:fixes].length).to eq(1) + expect(result[:fixes][0][:patch]).to include('-old line') + end + end + + describe 'build_review_prompt' do + it 'includes diff and context' do + instance = described_class.new([], { fix: false }) + prompt = instance.build_review_prompt('diff content', { mode: 'staged', stat: 'stat' }) + expect(prompt).to include('diff content') + expect(prompt).to include('CRITICAL') + expect(prompt).to include('WARNING') + expect(prompt).to include('SUGGESTION') + end + + it 'includes fix instructions when --fix is set' do + instance = described_class.new([], { fix: true }) + prompt = instance.build_review_prompt('diff', { mode: 'working', stat: 'stat' }) + expect(prompt).to include('FIX file:line') + expect(prompt).to include('unified diff') + end + + it 'includes PR context for PR mode' do + instance = described_class.new([], { fix: false }) + context = { mode: 'pr', pr: 42, title: 'Add auth', body: 'Adds authentication', stat: 'files' } + prompt = instance.build_review_prompt('diff', context) + expect(prompt).to include('PR #42') + expect(prompt).to include('Add auth') + end + end + + describe 'run_review' do + it 'returns parsed review from LLM' do + instance = described_class.new([], { model: nil, provider: nil, fix: false }) + result = instance.run_review('diff text', { mode: 'working', stat: 'stat' }) + expect(result[:findings].length).to eq(4) + expect(result[:summary]).to include('SQL injection') + end + end + + describe 'build_context_section' do + it 'formats PR context' do + instance = described_class.new + section = instance.build_context_section( + mode: 'pr', pr: 5, title: 'Fix bug', body: 'Fixes #123', stat: 'foo.rb +1/-1' + ) + expect(section).to include('PR #5') + expect(section).to include('Fix bug') + end + + it 'formats branch context' do + instance = described_class.new + section = instance.build_context_section( + mode: 'branch', base: 'main', stat: 'stat', log: 'abc commit' + ) + expect(section).to include('main') + expect(section).to include('abc commit') + end + + it 'formats working/staged context' do + instance = described_class.new + section = instance.build_context_section(mode: 'staged', stat: 'stat') + expect(section).to include('Staged') + end + end +end diff --git a/spec/legion/cli/schedule_command_spec.rb b/spec/legion/cli/schedule_command_spec.rb new file mode 100644 index 00000000..a888ae1b --- /dev/null +++ b/spec/legion/cli/schedule_command_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/schedule_command' + +RSpec.describe Legion::CLI::Schedule do + let(:output) { StringIO.new } + + describe 'class' do + it 'is a Thor subcommand' do + expect(described_class).to be < Thor + end + + it 'has list as default task' do + expect(described_class.default_command).to eq('list') + end + + it 'responds to list, show, add, remove, logs' do + commands = described_class.commands.keys + expect(commands).to include('list', 'show', 'add', 'remove', 'logs') + end + end +end diff --git a/spec/legion/cli/setup_command_spec.rb b/spec/legion/cli/setup_command_spec.rb new file mode 100644 index 00000000..ecdc9064 --- /dev/null +++ b/spec/legion/cli/setup_command_spec.rb @@ -0,0 +1,570 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/python' +require 'legion/cli/setup_command' + +RSpec.describe Legion::CLI::Setup do + let(:tmpdir) { Dir.mktmpdir } + + after { FileUtils.rm_rf(tmpdir) } + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end + + describe 'LLM pack definition' do + let(:native_llm_gems) do + %w[ + legion-llm + lex-llm + lex-llm-anthropic + lex-llm-azure-foundry + lex-llm-bedrock + lex-llm-gemini + lex-llm-mlx + lex-llm-ollama + lex-llm-openai + lex-llm-vertex + lex-llm-vllm + ] + end + + it 'includes the Legion-native hosted provider extensions' do + llm_gems = described_class::PACKS.fetch(:llm).fetch(:gems) + + expect(llm_gems).to include(*native_llm_gems) + expect(llm_gems).not_to include('lex-llm-gateway', 'lex-llm-ledger') + end + + it 'uses the Legion-native provider stack in the agentic pack' do + agentic_gems = described_class::PACKS.fetch(:agentic).fetch(:gems) + + expect(agentic_gems).to include(*native_llm_gems) + expect(agentic_gems).not_to include( + 'lex-azure-ai', + 'lex-bedrock', + 'lex-claude', + 'lex-foundry', + 'lex-gemini', + 'lex-llm-gateway', + 'lex-openai', + 'lex-xai' + ) + end + end + + describe 'GAIA pack definition' do + it 'includes legion-gaia, all lex-agentic-* gems, lex-synapse, lex-mind-growth, and lex-tick' do + gaia_gems = described_class::PACKS.fetch(:gaia).fetch(:gems) + + expect(gaia_gems).to include('legion-gaia') + expect(gaia_gems).to include( + 'lex-agentic-affect', 'lex-agentic-attention', 'lex-agentic-defense', + 'lex-agentic-executive', 'lex-agentic-homeostasis', 'lex-agentic-inference', + 'lex-agentic-integration', 'lex-agentic-language', 'lex-agentic-learning', + 'lex-agentic-memory', 'lex-agentic-self', 'lex-agentic-social' + ) + expect(gaia_gems).to include('lex-synapse', 'lex-mind-growth', 'lex-tick') + end + end + + describe 'identity pack definition' do + it 'includes legion-rbac, lex-identity-system, and lex-identity-kerberos' do + identity_gems = described_class::PACKS.fetch(:identity).fetch(:gems) + + expect(identity_gems).to include('legion-rbac', 'lex-identity-system', 'lex-identity-kerberos') + end + end + + describe 'claude-code' do + let(:settings_path) { File.join(tmpdir, '.claude', 'settings.json') } + let(:skill_path) { File.join(tmpdir, '.claude', 'commands', 'legion.md') } + + before do + allow(File).to receive(:expand_path).with('~/.claude/settings.json').and_return(settings_path) + allow(File).to receive(:expand_path).with('~/.claude/commands/legion.md').and_return(skill_path) + end + + it 'creates the MCP settings file' do + capture_stdout { described_class.start(%w[claude-code --no-color]) } + expect(File.exist?(settings_path)).to be true + data = JSON.parse(File.read(settings_path)) + expect(data.dig('mcpServers', 'legion', 'command')).to eq('legionio') + expect(data.dig('mcpServers', 'legion', 'args')).to eq(%w[mcp stdio]) + end + + it 'creates the slash command skill file' do + capture_stdout { described_class.start(%w[claude-code --no-color]) } + expect(File.exist?(skill_path)).to be true + content = File.read(skill_path) + expect(content).to include('name: legion') + expect(content).to include('legion.discover_tools') + expect(content).to include('legion.do_action') + expect(content).to include('legion.run_task') + expect(content).to include('legion.list_peers') + expect(content).to include('legion.ask_peer') + end + + it 'merges with existing MCP servers without overwriting them' do + FileUtils.mkdir_p(File.dirname(settings_path)) + File.write(settings_path, JSON.pretty_generate({ + 'mcpServers' => { + 'other-server' => { 'command' => 'other', 'args' => [] } + } + })) + + capture_stdout { described_class.start(%w[claude-code --no-color]) } + data = JSON.parse(File.read(settings_path)) + expect(data.dig('mcpServers', 'other-server', 'command')).to eq('other') + expect(data.dig('mcpServers', 'legion', 'command')).to eq('legionio') + end + + it 'skips MCP entry if already present without --force' do + FileUtils.mkdir_p(File.dirname(settings_path)) + File.write(settings_path, JSON.pretty_generate({ + 'mcpServers' => { + 'legion' => { 'command' => 'legionio', 'args' => %w[mcp stdio] } + } + })) + + output = capture_stdout { described_class.start(%w[claude-code --no-color]) } + expect(output).to include('already present') + end + + it 'overwrites MCP entry when --force is passed' do + FileUtils.mkdir_p(File.dirname(settings_path)) + File.write(settings_path, JSON.pretty_generate({ + 'mcpServers' => { + 'legion' => { 'command' => 'old', 'args' => [] } + } + })) + + capture_stdout { described_class.start(%w[claude-code --force --no-color]) } + data = JSON.parse(File.read(settings_path)) + expect(data.dig('mcpServers', 'legion', 'command')).to eq('legionio') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[claude-code --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:platform]).to eq('claude-code') + expect(parsed[:installed]).to be_an(Array) + end + end + + describe 'cursor' do + let(:mcp_path) { File.join(tmpdir, '.cursor', 'mcp.json') } + + before do + allow(Dir).to receive(:pwd).and_return(tmpdir) + end + + it 'creates .cursor/mcp.json with legion MCP entry' do + capture_stdout { described_class.start(%w[cursor --no-color]) } + expect(File.exist?(mcp_path)).to be true + data = JSON.parse(File.read(mcp_path)) + expect(data.dig('mcpServers', 'legion', 'command')).to eq('legionio') + expect(data.dig('mcpServers', 'legion', 'args')).to eq(%w[mcp stdio]) + end + + it 'merges with existing cursor MCP servers' do + FileUtils.mkdir_p(File.dirname(mcp_path)) + File.write(mcp_path, JSON.pretty_generate({ + 'mcpServers' => { + 'existing' => { 'command' => 'existing', 'args' => [] } + } + })) + + capture_stdout { described_class.start(%w[cursor --no-color]) } + data = JSON.parse(File.read(mcp_path)) + expect(data.dig('mcpServers', 'existing', 'command')).to eq('existing') + expect(data.dig('mcpServers', 'legion', 'command')).to eq('legionio') + end + + it 'skips if already configured without --force' do + FileUtils.mkdir_p(File.dirname(mcp_path)) + File.write(mcp_path, JSON.pretty_generate({ + 'mcpServers' => { + 'legion' => { 'command' => 'legionio', 'args' => %w[mcp stdio] } + } + })) + + output = capture_stdout { described_class.start(%w[cursor --no-color]) } + expect(output).to include('already present') + end + + it 'overwrites when --force is passed' do + FileUtils.mkdir_p(File.dirname(mcp_path)) + File.write(mcp_path, JSON.pretty_generate({ + 'mcpServers' => { + 'legion' => { 'command' => 'old', 'args' => [] } + } + })) + + capture_stdout { described_class.start(%w[cursor --force --no-color]) } + data = JSON.parse(File.read(mcp_path)) + expect(data.dig('mcpServers', 'legion', 'command')).to eq('legionio') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[cursor --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:platform]).to eq('cursor') + expect(parsed[:installed]).to be_an(Array) + end + end + + describe 'vscode' do + let(:mcp_path) { File.join(tmpdir, '.vscode', 'mcp.json') } + + before do + allow(Dir).to receive(:pwd).and_return(tmpdir) + end + + it 'creates .vscode/mcp.json with vscode-style legion entry' do + capture_stdout { described_class.start(%w[vscode --no-color]) } + expect(File.exist?(mcp_path)).to be true + data = JSON.parse(File.read(mcp_path)) + expect(data.dig('servers', 'legion', 'type')).to eq('stdio') + expect(data.dig('servers', 'legion', 'command')).to eq('legionio') + expect(data.dig('servers', 'legion', 'args')).to eq(%w[mcp stdio]) + end + + it 'merges with existing vscode servers' do + FileUtils.mkdir_p(File.dirname(mcp_path)) + File.write(mcp_path, JSON.pretty_generate({ + 'servers' => { + 'other' => { 'type' => 'stdio', 'command' => 'other', 'args' => [] } + } + })) + + capture_stdout { described_class.start(%w[vscode --no-color]) } + data = JSON.parse(File.read(mcp_path)) + expect(data.dig('servers', 'other', 'command')).to eq('other') + expect(data.dig('servers', 'legion', 'command')).to eq('legionio') + end + + it 'skips if already configured without --force' do + FileUtils.mkdir_p(File.dirname(mcp_path)) + File.write(mcp_path, JSON.pretty_generate({ + 'servers' => { + 'legion' => { 'type' => 'stdio', 'command' => 'legionio', + 'args' => %w[mcp stdio] } + } + })) + + output = capture_stdout { described_class.start(%w[vscode --no-color]) } + expect(output).to include('already present') + end + + it 'overwrites when --force is passed' do + FileUtils.mkdir_p(File.dirname(mcp_path)) + File.write(mcp_path, JSON.pretty_generate({ + 'servers' => { + 'legion' => { 'type' => 'stdio', 'command' => 'old', 'args' => [] } + } + })) + + capture_stdout { described_class.start(%w[vscode --force --no-color]) } + data = JSON.parse(File.read(mcp_path)) + expect(data.dig('servers', 'legion', 'command')).to eq('legionio') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[vscode --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:platform]).to eq('vscode') + expect(parsed[:installed]).to be_an(Array) + end + end + + describe 'python' do + let(:venv_dir) { File.join(tmpdir, 'python') } + let(:marker) { File.join(tmpdir, '.python-venv') } + + before do + stub_const('Legion::CLI::Setup::PYTHON_VENV_DIR', venv_dir) + stub_const('Legion::CLI::Setup::PYTHON_MARKER', marker) + end + + it 'exits with error when python3 is not found' do + allow(Legion::Python).to receive(:find_system_python3).and_return(nil) + expect do + capture_stdout { described_class.start(%w[python --no-color]) } + end.to raise_error(SystemExit) + end + + def setup_venv_stubs + allow(Legion::Python).to receive(:find_system_python3).and_return('/usr/bin/python3') + + # Pre-create venv structure so the system() call to create venv is skipped + pip_path = File.join(venv_dir, 'bin', 'pip') + FileUtils.mkdir_p(File.join(venv_dir, 'bin')) + File.write(File.join(venv_dir, 'pyvenv.cfg'), 'home = /usr') + FileUtils.touch(pip_path) + File.chmod(0o755, pip_path) + + mock_status = instance_double(::Process::Status, success?: true) + allow(Open3).to receive(:capture2e).and_return(['Successfully installed', mock_status]) + + # Stub the backtick call inside python_version without stubbing the Thor instance + allow_any_instance_of(Kernel).to receive(:`).and_return('Python 3.12.0') + end + + it 'creates venv when python3 is available' do + setup_venv_stubs + output = capture_stdout { described_class.start(%w[python --no-color]) } + expect(output).to include('Python environment ready') + end + + it 'outputs JSON when --json is passed' do + setup_venv_stubs + output = capture_stdout { described_class.start(%w[python --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:venv]).to eq(venv_dir) + expect(parsed[:results]).to be_an(Array) + end + + it 'destroys and recreates venv with --rebuild' do + setup_venv_stubs + output = capture_stdout { described_class.start(%w[python --rebuild --no-color]) } + expect(output).to include('Rebuilding') + end + + it 'reports failed packages in results' do + setup_venv_stubs + fail_status = instance_double(::Process::Status, success?: false) + allow(Open3).to receive(:capture2e).and_return(['error: no matching distribution', fail_status]) + output = capture_stdout do + described_class.start(%w[python --json]) + rescue SystemExit + # expected — exit 1 on package failure + end + parsed = begin + JSON.parse(output, symbolize_names: true) + rescue StandardError + nil + end + expect(parsed[:results]).to be_an(Array) if parsed + end + end + + describe 'status' do + before do + allow(Dir).to receive(:pwd).and_return(tmpdir) + allow(File).to receive(:expand_path).with('~/.claude/settings.json') + .and_return(File.join(tmpdir, '.claude', 'settings.json')) + end + + it 'shows not configured when no files exist' do + output = capture_stdout { described_class.start(%w[status --no-color]) } + expect(output).to include('Claude Code') + expect(output).to include('Cursor') + expect(output).to include('VS Code') + expect(output).to include('not configured') + end + + it 'shows configured when claude settings has legion entry' do + settings_path = File.join(tmpdir, '.claude', 'settings.json') + FileUtils.mkdir_p(File.dirname(settings_path)) + File.write(settings_path, JSON.pretty_generate({ + 'mcpServers' => { + 'legion' => { 'command' => 'legionio', 'args' => %w[mcp stdio] } + } + })) + + output = capture_stdout { described_class.start(%w[status --no-color]) } + expect(output).to match(/Claude Code.*configured/m) + end + + it 'shows configured count in summary' do + output = capture_stdout { described_class.start(%w[status --no-color]) } + expect(output).to match(/\d+ of \d+ platform/) + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[status --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:platforms]).to be_an(Array) + expect(parsed[:platforms].size).to eq(3) + parsed[:platforms].each do |p| + expect(p).to have_key(:name) + expect(p).to have_key(:configured) + end + end + end + + describe 'proxy-mode' do + let(:codex_dir) { File.join(tmpdir, '.codex') } + let(:codex_path) { File.join(codex_dir, 'config.toml') } + let(:claude_dir) { File.join(tmpdir, '.claude') } + let(:claude_path) { File.join(claude_dir, 'settings.json') } + let(:zshrc_path) { File.join(tmpdir, '.zshrc') } + let(:zsh_file) { File.join(tmpdir, '.zsh_legionio') } + let(:packs_dir) { File.join(tmpdir, '.legionio', '.packs') } + + before do + allow(File).to receive(:expand_path).with('~/.codex').and_return(codex_dir) + allow(File).to receive(:expand_path).with('~/.codex/config.toml').and_return(codex_path) + allow(File).to receive(:expand_path).with('~/.claude').and_return(claude_dir) + allow(File).to receive(:expand_path).with('~/.claude/settings.json').and_return(claude_path) + allow(File).to receive(:expand_path).with('~/.zshrc').and_return(zshrc_path) + allow(File).to receive(:expand_path).with('~/.zsh_legionio').and_return(zsh_file) + allow(File).to receive(:expand_path).with('~/.legionio/.packs').and_return(packs_dir) + allow(File).to receive(:expand_path).with('~/.legionio/settings/packs.json').and_return(File.join(tmpdir, '.legionio', 'settings', 'packs.json')) + allow(File).to receive(:expand_path).with('~/.legionio/.packs/proxy-mode').and_return(File.join(packs_dir, 'proxy-mode')) + end + + it 'upserts [model_providers.legionio] block into ~/.codex/config.toml' do + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + expect(File.exist?(codex_path)).to be true + + content = File.read(codex_path) + expect(content).to include('[model_providers.legionio]') + expect(content).to include('base_url = "http://localhost:4567/v1"') + expect(content).to include('wire_api = "responses"') + expect(content).not_to include('profile = "legionio"') + end + + it 'creates ~/.codex/legionio.config.toml with provider config' do + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + profile_path = File.join(codex_dir, 'legionio.config.toml') + expect(File.exist?(profile_path)).to be true + + content = File.read(profile_path) + expect(content).to include('model = "legionio"') + expect(content).to include('model_provider = "legionio"') + expect(content).to include('base_url = "http://localhost:4567/v1"') + expect(content).to include('wire_api = "responses"') + expect(content).to include('api_key = "legion"') + expect(content).to include('model_catalog_json') + end + + it 'creates ~/.codex/legionio-catalog.json with legionio model entry' do + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + catalog_path = File.join(codex_dir, 'legionio-catalog.json') + expect(File.exist?(catalog_path)).to be true + + catalog = JSON.parse(File.read(catalog_path)) + model = catalog['models'].first + expect(model['slug']).to eq('legionio') + expect(model['display_name']).to eq('LegionIO') + expect(model['context_window']).to eq(262_144) + expect(model['context_size']).to eq(262_144) + end + + it 'does not write ~/.claude/settings.json' do + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + expect(File.exist?(claude_path)).to be false + end + + it 'adds provider block to existing config.toml without destroying its content' do + FileUtils.mkdir_p(File.dirname(codex_path)) + File.write(codex_path, "[model_providers.openai]\napi_key = \"sk-existing\"\n") + + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + content = File.read(codex_path) + expect(content).to include('[model_providers.legionio]') + expect(content).to include('api_key = "sk-existing"') + expect(content).not_to include('profile = "legionio"') + end + + it 'does not duplicate profile line when config.toml already has it' do + FileUtils.mkdir_p(File.dirname(codex_path)) + File.write(codex_path, "profile = \"legionio\"\n") + + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + content = File.read(codex_path) + expect(content.scan('profile = "legionio"').size).to eq(1) + end + + it 'overwrites when --force is passed' do + FileUtils.mkdir_p(File.dirname(codex_path)) + File.write(codex_path, 'existing content') + FileUtils.mkdir_p(File.dirname(claude_path)) + File.write(claude_path, '{}') + + output = capture_stdout { described_class.start(%w[proxy-mode --force --no-color]) } + expect(output).to include('Written') + end + + it 'accepts --port and --host options' do + capture_stdout { described_class.start(%w[proxy-mode --host 0.0.0.0 --port 9292 --no-color]) } + profile_path = File.join(codex_dir, 'legionio.config.toml') + expect(File.exist?(profile_path)).to be true + + content = File.read(profile_path) + expect(content).to include('base_url = "http://0.0.0.0:9292/v1"') + end + + it 'outputs JSON when --json is passed' do + output = capture_stdout { described_class.start(%w[proxy-mode --json]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:written]).to be_an(Array) + expect(parsed[:skipped]).to be_an(Array) + expect(parsed[:base_url]).to include('localhost:4567') + end + + it 'registers the proxy command' do + commands = described_class.all_commands.keys + expect(commands).to include('proxy_mode') + end + + context 'zsh shell functions' do + it 'skips zsh setup when ~/.zshrc does not exist' do + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + expect(File.exist?(zsh_file)).to be false + end + + context 'when ~/.zshrc exists' do + before { File.write(zshrc_path, "# existing zshrc\n") } + + it 'writes ~/.zsh_legionio with claude-legionio and codex-legionio functions' do + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + expect(File.exist?(zsh_file)).to be true + content = File.read(zsh_file) + expect(content).to include('claude-legionio()') + expect(content).to include('codex-legionio()') + expect(content).to include('ANTHROPIC_BASE_URL=http://localhost:4567') + expect(content).to include('CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1') + expect(content).to include('unset ANTHROPIC_DEFAULT_OPUS_MODEL') + expect(content).to include('unset ANTHROPIC_DEFAULT_SONNET_MODEL') + expect(content).to include('unset ANTHROPIC_DEFAULT_HAIKU_MODEL') + expect(content).to include('claude --model legionio') + expect(content).to include('codex --profile legionio') + end + + it 'appends source line to ~/.zshrc' do + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + zshrc = File.read(zshrc_path) + expect(zshrc).to include('[ -f ~/.zsh_legionio ] && source ~/.zsh_legionio') + end + + it 'does not duplicate source line when run twice' do + 2.times { capture_stdout { described_class.start(%w[proxy-mode --no-color]) } } + zshrc = File.read(zshrc_path) + expect(zshrc.scan('source ~/.zsh_legionio').size).to eq(1) + end + + it 'replaces ~/.zsh_legionio on re-run (always overwrite)' do + File.write(zsh_file, "# old content\n") + capture_stdout { described_class.start(%w[proxy-mode --no-color]) } + expect(File.read(zsh_file)).to include('claude-legionio()') + expect(File.read(zsh_file)).not_to include('# old content') + end + + it 'uses --host and --port in the generated functions' do + capture_stdout { described_class.start(%w[proxy-mode --host 10.0.0.1 --port 9000 --no-color]) } + expect(File.read(zsh_file)).to include('ANTHROPIC_BASE_URL=http://10.0.0.1:9000') + end + end + end + end +end diff --git a/spec/legion/cli/skill_command_spec.rb b/spec/legion/cli/skill_command_spec.rb new file mode 100644 index 00000000..ec5911e2 --- /dev/null +++ b/spec/legion/cli/skill_command_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'tmpdir' +require 'legion/cli/skill_command' + +RSpec.describe Legion::CLI::Skill do + let(:ok_skills_response) do + double(:response, + is_a?: true, + body: Legion::JSON.dump({ + data: [ + { namespace: 'superpowers', name: 'brainstorming', + trigger: 'on_demand', description: 'Brainstorm ideas' } + ], + meta: {} + })) + end + + let(:not_found_response) do + double(:response, is_a?: false, code: '404', body: '{"error":{"code":"not_found"}}') + end + + before do + allow_any_instance_of(described_class).to receive(:daemon_get).and_return(ok_skills_response) + allow(ok_skills_response).to receive(:is_a?).with(::Net::HTTPSuccess).and_return(true) + allow(not_found_response).to receive(:is_a?).with(::Net::HTTPSuccess).and_return(false) + end + + describe '#list' do + it 'shows namespace:name format' do + expect { described_class.start(%w[list]) }.to output(/superpowers:brainstorming/).to_stdout + end + + it 'shows trigger type' do + expect { described_class.start(%w[list]) }.to output(/on_demand/).to_stdout + end + + it 'shows description' do + expect { described_class.start(%w[list]) }.to output(/Brainstorm ideas/).to_stdout + end + + context 'with empty skill list' do + before do + empty_response = double(:response, body: Legion::JSON.dump({ data: [], meta: {} })) + allow(empty_response).to receive(:is_a?).with(::Net::HTTPSuccess).and_return(true) + allow_any_instance_of(described_class).to receive(:daemon_get).and_return(empty_response) + end + + it 'shows no skills message' do + expect { described_class.start(%w[list]) }.to output(/No skills registered/).to_stdout + end + end + end + + describe '#show' do + let(:show_response) do + double(:response, + body: Legion::JSON.dump({ + data: { + namespace: 'superpowers', name: 'brainstorming', + description: 'Brainstorm ideas', trigger: 'on_demand', + steps: ['ideate'] + }, + meta: {} + })) + end + + before do + allow(show_response).to receive(:is_a?).with(::Net::HTTPSuccess).and_return(true) + allow_any_instance_of(described_class).to receive(:daemon_get) + .with('/api/skills/superpowers/brainstorming').and_return(show_response) + end + + it 'shows skill namespace:name' do + expect { described_class.start(%w[show superpowers:brainstorming]) }.to output(/superpowers:brainstorming/).to_stdout + end + + it 'shows description' do + expect { described_class.start(%w[show superpowers:brainstorming]) }.to output(/Brainstorm ideas/).to_stdout + end + + context 'with nonexistent skill' do + before do + allow_any_instance_of(described_class).to receive(:daemon_get).and_return(not_found_response) + end + + it 'shows not found message' do + expect { described_class.start(%w[show unknown:nope]) } + .to output(/not found/).to_stdout.and raise_error(SystemExit) + end + end + end + + describe '#run_skill' do + let(:run_success_response) do + double(:response, + body: Legion::JSON.dump({ + data: { conversation_id: 'conv_abc', content: 'result text', skill_name: 'superpowers:brainstorming' }, + meta: {} + })) + end + + let(:run_error_response) do + double(:response, code: '404', body: '{"error":{"code":"not_found"}}') + end + + before do + allow(run_success_response).to receive(:is_a?).with(::Net::HTTPSuccess).and_return(true) + allow(run_error_response).to receive(:is_a?).with(::Net::HTTPSuccess).and_return(false) + end + + context 'on success' do + before do + allow(::Net::HTTP).to receive(:post).and_return(run_success_response) + end + + it 'outputs the skill content' do + expect { described_class.start(%w[run superpowers:brainstorming]) }.to output(/result text/).to_stdout + end + end + + context 'on failure' do + before do + allow(::Net::HTTP).to receive(:post).and_return(run_error_response) + end + + it 'outputs an error message' do + expect { described_class.start(%w[run unknown:nope]) } + .to output(/Error/).to_stdout.and raise_error(SystemExit) + end + end + end + + describe '#create' do + around do |example| + Dir.mktmpdir do |dir| + Dir.chdir(dir) { example.run } + end + end + + it 'creates skill file in .legion/skills/' do + described_class.start(%w[create new-skill]) + path = '.legion/skills/new-skill.md' + expect(File).to exist(path) + content = File.read(path) + expect(content).to include('name: new-skill') + end + + context 'when skill already exists' do + before do + dir = '.legion/skills' + FileUtils.mkdir_p(dir) + File.write(File.join(dir, 'existing.md'), '---') + end + + it 'shows already exists message' do + expect { described_class.start(%w[create existing]) }.to output(/already exists/).to_stdout + end + end + end +end diff --git a/spec/legion/cli/swarm_command_spec.rb b/spec/legion/cli/swarm_command_spec.rb new file mode 100644 index 00000000..28732af5 --- /dev/null +++ b/spec/legion/cli/swarm_command_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'tmpdir' +require 'json' +require 'legion/cli/output' +require 'legion/cli/error' +require 'legion/cli/chat/subagent' +require 'legion/cli/swarm_command' + +RSpec.describe Legion::CLI::Swarm do + let(:tmpdir) { Dir.mktmpdir('swarm-test') } + let(:workflow_dir) { File.join(tmpdir, '.legion', 'swarms') } + + let(:workflow) do + { + 'name' => 'test-flow', + 'goal' => 'Analyze and improve the auth module', + 'agents' => [ + { 'role' => 'researcher', 'description' => 'Analyze codebase', 'tools' => %w[read search], 'model' => 'claude-sonnet' }, + { 'role' => 'planner', 'description' => 'Create implementation plan', 'tools' => %w[write], 'model' => nil } + ], + 'pipeline' => %w[researcher planner] + } + end + + before do + FileUtils.mkdir_p(workflow_dir) + File.write(File.join(workflow_dir, 'test-flow.json'), JSON.pretty_generate(workflow)) + allow(Dir).to receive(:pwd).and_return(tmpdir) + end + + after { FileUtils.rm_rf(tmpdir) } + + describe '#list' do + it 'shows workflow count' do + expect { described_class.start(%w[list --no-color]) }.to output(/Swarm Workflows \(1\)/).to_stdout + end + + it 'shows workflow name and goal' do + expect { described_class.start(%w[list --no-color]) }.to output(/test-flow.*Analyze/).to_stdout + end + + context 'when no workflow directory exists' do + before { FileUtils.rm_rf(workflow_dir) } + + it 'shows warning' do + expect { described_class.start(%w[list --no-color]) }.to output(/No workflows found/).to_stdout + end + end + + context 'when directory is empty' do + before { FileUtils.rm(File.join(workflow_dir, 'test-flow.json')) } + + it 'shows warning' do + expect { described_class.start(%w[list --no-color]) }.to output(/No workflow files found/).to_stdout + end + end + end + + describe '#show' do + it 'shows workflow header' do + expect { described_class.start(%w[show test-flow --no-color]) }.to output(/Workflow: test-flow/).to_stdout + end + + it 'shows goal' do + expect { described_class.start(%w[show test-flow --no-color]) }.to output(/Analyze and improve/).to_stdout + end + + it 'shows agents with roles' do + expect { described_class.start(%w[show test-flow --no-color]) }.to output(/researcher/).to_stdout + end + + it 'shows pipeline' do + expect { described_class.start(%w[show test-flow --no-color]) }.to output(/researcher -> planner/).to_stdout + end + + it 'outputs JSON when requested' do + output = capture_stdout { described_class.start(%w[show test-flow --json --no-color]) } + parsed = JSON.parse(output, symbolize_names: true) + expect(parsed[:name]).to eq('test-flow') + expect(parsed[:agents].length).to eq(2) + end + + context 'with nonexistent workflow' do + it 'raises error' do + expect { described_class.start(%w[show nonexistent --no-color]) }.to raise_error(Legion::CLI::Error, /Workflow not found/) + end + end + end + + describe '#start' do + before do + allow(Legion::CLI::Chat::Subagent).to receive(:run_headless).and_return( + { exit_code: 0, output: 'Agent output here' } + ) + end + + it 'shows swarm header' do + expect { described_class.start(%w[start test-flow --no-color]) }.to output(/Swarm: test-flow/).to_stdout + end + + it 'shows step progress' do + expect { described_class.start(%w[start test-flow --no-color]) }.to output(%r{Step 1/2: researcher}).to_stdout + end + + it 'shows completion' do + expect { described_class.start(%w[start test-flow --no-color]) }.to output(/Swarm Complete/).to_stdout + end + + it 'calls subagent for each pipeline step' do + described_class.start(%w[start test-flow --no-color]) + expect(Legion::CLI::Chat::Subagent).to have_received(:run_headless).twice + end + + context 'when a step fails' do + before do + allow(Legion::CLI::Chat::Subagent).to receive(:run_headless).and_return( + { exit_code: 1, error: 'model unavailable' } + ) + end + + it 'shows error and stops pipeline' do + expect { described_class.start(%w[start test-flow --no-color]) }.to output(/researcher failed/).to_stdout + end + + it 'does not run subsequent steps' do + described_class.start(%w[start test-flow --no-color]) + expect(Legion::CLI::Chat::Subagent).to have_received(:run_headless).once + end + end + end + + def capture_stdout + original = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = original + end +end diff --git a/spec/legion/cli/task_command_spec.rb b/spec/legion/cli/task_command_spec.rb new file mode 100644 index 00000000..57647a6f --- /dev/null +++ b/spec/legion/cli/task_command_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'sequel' +require 'legion/cli/output' +require 'legion/cli/connection' +require 'legion/cli/error' +require 'legion/cli/task_command' + +RSpec.describe Legion::CLI::Task do + let(:out) { instance_double(Legion::CLI::Output::Formatter, success: nil, error: nil, warn: nil, spacer: nil, header: nil, detail: nil, table: nil, json: nil, status: 'ok') } + + before do + allow_any_instance_of(described_class).to receive(:formatter).and_return(out) + end + + def stub_data_connection + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + def stub_transport_connection + allow(Legion::CLI::Connection).to receive(:ensure_transport) + end + + describe 'list' do + before { stub_data_connection } + + it 'queries tasks and renders table' do + task_model = double('Legion::Data::Model::Task') + stub_const('Legion::Data::Model::Task', task_model) + + fake_dataset = double('dataset') + allow(task_model).to receive(:order).and_return(fake_dataset) + allow(fake_dataset).to receive(:limit).and_return(fake_dataset) + allow(fake_dataset).to receive(:map).and_return([]) + + expect(out).to receive(:table).with(%w[id function status created relationship], []) + described_class.start(%w[list]) + end + + it 'applies status filter when provided' do + task_model = double('Legion::Data::Model::Task') + stub_const('Legion::Data::Model::Task', task_model) + + fake_dataset = double('dataset') + allow(task_model).to receive(:order).and_return(fake_dataset) + allow(fake_dataset).to receive(:limit).and_return(fake_dataset) + allow(fake_dataset).to receive(:where).and_return(fake_dataset) + allow(fake_dataset).to receive(:map).and_return([]) + + expect(fake_dataset).to receive(:where) + described_class.start(%w[list -s completed]) + end + end + + describe 'show' do + before { stub_data_connection } + + it 'displays task details' do + task_model = double('Legion::Data::Model::Task') + stub_const('Legion::Data::Model::Task', task_model) + + fake_task = double('task', values: { + id: 42, status: 'completed', function_id: 1, relationship_id: nil, + runner_id: 2, created: Time.now, updated: Time.now, + parent_id: nil, master_id: nil, args: nil + }) + allow(task_model).to receive(:[]).with(42).and_return(fake_task) + + expect(out).to receive(:header).with('Task #42') + expect(out).to receive(:detail) + described_class.start(%w[show 42]) + end + + it 'reports error for missing task' do + task_model = double('Legion::Data::Model::Task') + stub_const('Legion::Data::Model::Task', task_model) + allow(task_model).to receive(:[]).with(999).and_return(nil) + + expect(out).to receive(:error).with('Task 999 not found') + expect { described_class.start(%w[show 999]) }.to raise_error(SystemExit) + end + + it 'outputs JSON when --json flag is set' do + task_model = double('Legion::Data::Model::Task') + stub_const('Legion::Data::Model::Task', task_model) + + fake_task = double('task', values: { id: 1, status: 'queued' }) + allow(task_model).to receive(:[]).with(1).and_return(fake_task) + + expect(out).to receive(:json).with(hash_including(id: 1)) + described_class.start(%w[show 1 --json]) + end + end + + describe 'logs' do + before { stub_data_connection } + + it 'displays log entries' do + log_model = class_double('Legion::Data::Model::TaskLog') + stub_const('Legion::Data::Model::TaskLog', log_model) + + fake_dataset = double('dataset') + allow(log_model).to receive(:where).and_return(fake_dataset) + allow(fake_dataset).to receive(:order).and_return(fake_dataset) + allow(fake_dataset).to receive(:limit).and_return(fake_dataset) + allow(fake_dataset).to receive(:map).and_return([%w[1 - 2026-01-01 started]]) + + expect(out).to receive(:table).with(%w[id node created entry], [%w[1 - 2026-01-01 started]]) + described_class.start(%w[logs 10]) + end + + it 'warns when no logs found' do + log_model = class_double('Legion::Data::Model::TaskLog') + stub_const('Legion::Data::Model::TaskLog', log_model) + + fake_dataset = double('dataset') + allow(log_model).to receive(:where).and_return(fake_dataset) + allow(fake_dataset).to receive(:order).and_return(fake_dataset) + allow(fake_dataset).to receive(:limit).and_return(fake_dataset) + allow(fake_dataset).to receive(:map).and_return([]) + + expect(out).to receive(:warn).with(/No logs found/) + described_class.start(%w[logs 10]) + end + end + + describe 'purge' do + before { stub_data_connection } + + it 'reports no tasks to purge when count is zero' do + task_model = double('Legion::Data::Model::Task') + stub_const('Legion::Data::Model::Task', task_model) + + fake_dataset = double('dataset') + allow(task_model).to receive(:where).and_return(fake_dataset) + allow(fake_dataset).to receive(:count).and_return(0) + + expect(out).to receive(:success).with('No tasks to purge') + described_class.start(%w[purge]) + end + + it 'deletes old tasks when confirmed' do + task_model = double('Legion::Data::Model::Task') + stub_const('Legion::Data::Model::Task', task_model) + + fake_dataset = double('dataset') + allow(task_model).to receive(:where).and_return(fake_dataset) + allow(fake_dataset).to receive(:count).and_return(5) + allow(fake_dataset).to receive(:delete) + + expect(out).to receive(:success).with('Purged 5 tasks') + described_class.start(%w[purge -y]) + end + end + + describe 'helper methods' do + let(:instance) { described_class.new } + + describe '#short_status' do + it 'removes task. prefix' do + expect(instance.send(:short_status, 'task.completed')).to eq('completed') + end + + it 'returns non-string values unchanged' do + expect(instance.send(:short_status, nil)).to be_nil + end + end + + describe '#format_time' do + it 'formats Time objects' do + t = Time.new(2026, 3, 15, 10, 30, 0) + expect(instance.send(:format_time, t)).to eq('2026-03-15 10:30:00') + end + + it 'returns dash for nil' do + expect(instance.send(:format_time, nil)).to eq('-') + end + end + end +end diff --git a/spec/legion/cli/team_command_spec.rb b/spec/legion/cli/team_command_spec.rb new file mode 100644 index 00000000..f8cc4df4 --- /dev/null +++ b/spec/legion/cli/team_command_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/team_command' + +RSpec.describe Legion::CLI::Team do + let(:settings_store) { { teams: {}, team: {} } } + let(:loader_double) { double('loader') } + + before do + allow(loader_double).to receive(:settings).and_return(settings_store) + allow(Legion::Settings).to receive(:dig).and_call_original + allow(Legion::Settings).to receive(:load) + allow(Legion::Settings).to receive(:instance_variable_get).with(:@loader).and_return(true) + allow(Legion::Settings).to receive(:loader).and_return(loader_double) + end + + def build_command + described_class.new([], {}) + end + + describe '#list' do + it 'shows all configured teams' do + allow(Legion::Settings).to receive(:[]).with(:teams).and_return({ ops: {}, dev: {} }) + cmd = build_command + expect { cmd.list }.to output(/ops/).to_stdout + end + + it 'shows a message when no teams are configured' do + allow(Legion::Settings).to receive(:[]).with(:teams).and_return(nil) + cmd = build_command + expect { cmd.list }.to output(/No teams configured/i).to_stdout + end + end + + describe '#show' do + it 'shows team members when team exists' do + teams = { engineering: { members: %w[alice bob] } } + allow(Legion::Settings).to receive(:[]).with(:teams).and_return(teams) + cmd = build_command + expect { cmd.show('engineering') }.to output(/alice/).to_stdout + end + + it 'shows error when team does not exist' do + allow(Legion::Settings).to receive(:[]).with(:teams).and_return({}) + cmd = build_command + expect { cmd.show('unknown') }.to output(/not found/i).to_stdout + end + + it 'shows "No members" when team has no members' do + teams = { empty_team: { members: [] } } + allow(Legion::Settings).to receive(:[]).with(:teams).and_return(teams) + cmd = build_command + expect { cmd.show('empty_team') }.to output(/No members/i).to_stdout + end + end + + describe '#current' do + it 'prints the current team name' do + allow(Legion::Settings).to receive(:dig).with(:team, :name).and_return('ops') + cmd = build_command + expect { cmd.current }.to output(/ops/).to_stdout + end + + it 'prints "default" when no team is set' do + allow(Legion::Settings).to receive(:dig).with(:team, :name).and_return(nil) + cmd = build_command + expect { cmd.current }.to output(/default/).to_stdout + end + end + + describe '#set' do + it 'updates the active team in settings' do + settings_hash = { team: {}, teams: {} } + allow(loader_double).to receive(:settings).and_return(settings_hash) + cmd = build_command + expect { cmd.set('platform') }.to output(/set to 'platform'/i).to_stdout + expect(settings_hash[:team][:name]).to eq('platform') + end + end + + describe '#create' do + it 'creates a new team in settings' do + teams_hash = {} + settings_hash = { teams: teams_hash } + allow(loader_double).to receive(:settings).and_return(settings_hash) + cmd = build_command + expect { cmd.create('new-team') }.to output(/created/i).to_stdout + expect(teams_hash[:'new-team']).to include(name: 'new-team', members: []) + end + + it 'warns when team already exists' do + settings_hash = { teams: { ops: { name: 'ops', members: [] } } } + allow(loader_double).to receive(:settings).and_return(settings_hash) + cmd = build_command + expect { cmd.create('ops') }.to output(/already exists/i).to_stdout + end + end + + describe '#add_member' do + it 'adds a user to an existing team' do + settings_hash = { teams: { ops: { name: 'ops', members: [] } } } + allow(loader_double).to receive(:settings).and_return(settings_hash) + cmd = build_command + expect { cmd.add_member('ops', 'alice') }.to output(/Added alice/i).to_stdout + expect(settings_hash[:teams][:ops][:members]).to include('alice') + end + + it 'warns when user is already a member' do + settings_hash = { teams: { ops: { name: 'ops', members: ['alice'] } } } + allow(loader_double).to receive(:settings).and_return(settings_hash) + cmd = build_command + expect { cmd.add_member('ops', 'alice') }.to output(/already a member/i).to_stdout + end + + it 'shows error when team does not exist' do + settings_hash = { teams: {} } + allow(loader_double).to receive(:settings).and_return(settings_hash) + cmd = build_command + expect { cmd.add_member('missing', 'alice') }.to output(/not found/i).to_stdout + end + end +end diff --git a/spec/legion/cli/telemetry_command_spec.rb b/spec/legion/cli/telemetry_command_spec.rb new file mode 100644 index 00000000..639daa1f --- /dev/null +++ b/spec/legion/cli/telemetry_command_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/telemetry_command' + +RSpec.describe Legion::CLI::Telemetry do + let(:runner_stub) do + Module.new do + def self.aggregate_stats(**) + { success: true, stats: { session_count: 3, total_events: 150, tool_frequency: { 'Read' => 50 } } } + end + + def self.session_stats(session_id:, **) + { success: true, stats: { session_id: session_id, tool_counts: { 'Read' => 5 }, error_count: 0 } } + end + + def self.ingest_session(file_path:, **) + { success: true, event_count: 10, session_id: 'abc-123', file_path: file_path } + end + + def self.telemetry_status(**) + { success: true, buffer_size: 100, pending_count: 5, session_count: 3, parsers: [:claude_code] } + end + end + end + + before do + stub_const('Legion::Extensions::Telemetry::Runners::Telemetry', runner_stub) + allow_any_instance_of(described_class).to receive(:telemetry_runner).and_return(runner_stub) + end + + describe '#stats' do + it 'calls aggregate_stats when no session_id given' do + expect { described_class.new.invoke(:stats) }.to output(/session_count/).to_stdout + end + + it 'calls session_stats when session_id given' do + expect { described_class.new.invoke(:stats, ['abc-123']) }.to output(/tool_counts/).to_stdout + end + end + + describe '#ingest' do + it 'calls ingest_session with file path' do + expect { described_class.new.invoke(:ingest, ['/tmp/test.jsonl']) }.to output(/Ingested.*10/).to_stdout + end + end + + describe '#status' do + it 'calls telemetry_status' do + expect { described_class.new.invoke(:status) }.to output(/Buffer Size/).to_stdout + end + end +end diff --git a/spec/legion/cli/trace_command_spec.rb b/spec/legion/cli/trace_command_spec.rb new file mode 100644 index 00000000..41b54514 --- /dev/null +++ b/spec/legion/cli/trace_command_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'thor' +require 'legion/cli/output' +require 'legion/cli/trace_command' + +RSpec.describe Legion::CLI::TraceCommand do + let(:search_result) do + { + results: [ + { created_at: Time.utc(2026, 3, 23, 12, 0, 0), extension: 'lex-llm-openai', + runner_function: 'chat', status: 'success', cost_usd: 0.0042, + tokens_in: 120, tokens_out: 350, wall_clock_ms: 1200, worker_id: 'w-1' }, + { created_at: Time.utc(2026, 3, 23, 11, 30, 0), extension: 'lex-apollo', + runner_function: 'ingest', status: 'failure', cost_usd: 0.0, + tokens_in: 0, tokens_out: 0, wall_clock_ms: 50, worker_id: nil } + ], + count: 2, + total: 5, + truncated: true, + filter: { where: { status: 'success' } } + } + end + + before do + stub_const('Legion::TraceSearch', Module.new) + allow(Legion::TraceSearch).to receive(:search).and_return(search_result) + + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + allow(Legion::CLI::Connection).to receive(:ensure_llm) + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + end + + describe '#search' do + it 'outputs Trace Search header' do + expect { described_class.start(%w[search failed tasks --no-color]) }.to output(/Trace Search/).to_stdout + end + + it 'shows query text' do + expect { described_class.start(%w[search failed tasks --no-color]) }.to output(/failed tasks/).to_stdout + end + + it 'shows result count and total' do + expect { described_class.start(%w[search failed tasks --no-color]) }.to output(/2 of 5 results/).to_stdout + end + + it 'indicates truncation' do + expect { described_class.start(%w[search failed tasks --no-color]) }.to output(/truncated/).to_stdout + end + + it 'shows extension and function' do + expect { described_class.start(%w[search all --no-color]) }.to output(/lex-llm-openai\.chat/).to_stdout + end + + it 'shows cost' do + expect { described_class.start(%w[search all --no-color]) }.to output(/\$0\.0042/).to_stdout + end + + it 'shows tokens' do + expect { described_class.start(%w[search all --no-color]) }.to output(%r{120in/350out}).to_stdout + end + + it 'shows wall clock time' do + expect { described_class.start(%w[search all --no-color]) }.to output(/1200ms/).to_stdout + end + + it 'shows worker id when present' do + expect { described_class.start(%w[search all --no-color]) }.to output(/worker: w-1/).to_stdout + end + + context 'with --json flag' do + it 'outputs JSON' do + expect { described_class.start(%w[search all --json --no-color]) }.to output(/results/).to_stdout + end + end + + context 'when search returns error' do + before do + allow(Legion::TraceSearch).to receive(:search).and_return({ results: [], error: 'data unavailable' }) + end + + it 'displays error message' do + expect { described_class.start(%w[search all --no-color]) }.to output(/data unavailable/).to_stdout + end + end + + context 'when no results found' do + before do + allow(Legion::TraceSearch).to receive(:search).and_return({ results: [], count: 0, total: 0, truncated: false }) + end + + it 'shows no results message' do + expect { described_class.start(%w[search all --no-color]) }.to output(/No results found/).to_stdout + end + end + + it 'passes limit option to TraceSearch' do + described_class.start(%w[search expensive --limit 10 --no-color]) + expect(Legion::TraceSearch).to have_received(:search).with('expensive', limit: 10) + end + end + + describe '#summarize' do + let(:summary_result) do + { + total_records: 100, + total_tokens_in: 5000, + total_tokens_out: 8000, + total_cost: 1.2345, + avg_latency_ms: 150.7, + max_latency_ms: 2500, + time_range: { from: Time.utc(2026, 3, 1), to: Time.utc(2026, 3, 23) }, + status_counts: { 'success' => 90, 'failure' => 10 }, + top_extensions: [{ name: 'http', count: 60 }, { name: 'vault', count: 40 }], + top_workers: [{ id: 'w-1', count: 70 }], + filter: {} + } + end + + before do + allow(Legion::TraceSearch).to receive(:summarize).and_return(summary_result) + end + + it 'outputs Trace Summary header' do + expect { described_class.start(%w[summarize all tasks --no-color]) }.to output(/Trace Summary/).to_stdout + end + + it 'shows total records' do + expect { described_class.start(%w[summarize all --no-color]) }.to output(/100/).to_stdout + end + + it 'shows total cost' do + expect { described_class.start(%w[summarize all --no-color]) }.to output(/\$1\.2345/).to_stdout + end + + it 'shows status breakdown' do + expect { described_class.start(%w[summarize all --no-color]) }.to output(/success: 90/).to_stdout + end + + it 'shows top extensions' do + expect { described_class.start(%w[summarize all --no-color]) }.to output(/http: 60/).to_stdout + end + + it 'shows top workers' do + expect { described_class.start(%w[summarize all --no-color]) }.to output(/w-1: 70/).to_stdout + end + + context 'with --json flag' do + it 'outputs JSON' do + expect { described_class.start(%w[summarize all --json --no-color]) }.to output(/total_records/).to_stdout + end + end + + context 'when summarize returns error' do + before do + allow(Legion::TraceSearch).to receive(:summarize).and_return({ error: 'data unavailable' }) + end + + it 'displays error message' do + expect { described_class.start(%w[summarize all --no-color]) }.to output(/data unavailable/).to_stdout + end + end + end +end diff --git a/spec/legion/cli/tree_command_spec.rb b/spec/legion/cli/tree_command_spec.rb new file mode 100644 index 00000000..fd381491 --- /dev/null +++ b/spec/legion/cli/tree_command_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' + +RSpec.describe Legion::CLI::Main do + def capture_tree_output + output = StringIO.new + instance = described_class.new([], json: false, no_color: true, verbose: false) + allow(instance).to receive(:say) do |text, _color = nil, _newline = true| + output.print(text.to_s) + end + instance.tree + output.string + end + + describe '#tree' do + subject(:output) { capture_tree_output } + + let(:prog) { File.basename($PROGRAM_NAME) } + + it 'shows the binary name as the root node' do + expect(output).to include(prog) + end + + it 'does not expose internal Thor namespace paths' do + expect(output).not_to include('c_l_i') + end + + it 'does not show the raw namespace for the root command' do + expect(output).not_to include("#{prog}:c_l_i:main") + end + + it 'shows subcommand groups with clean prefixed names' do + expect(output).to include("#{prog} lex") + expect(output).to include("#{prog} task") + expect(output).to include("#{prog} admin") + end + + it 'does not show raw namespace for subcommands' do + expect(output).not_to include("#{prog}:c_l_i:lex") + expect(output).not_to include("#{prog}:c_l_i:task") + end + + it 'includes top-level commands like version and start' do + expect(output).to include('version') + expect(output).to include('start') + end + + it 'does not include tree itself in the output' do + # tree should suppress itself to avoid noise + lines = output.split("\n").map(&:strip) + command_lines = lines.grep(/^[├└]/) + expect(command_lines.none? { |l| l.include?('tree') }).to be true + end + end +end diff --git a/spec/legion/cli/update_command_spec.rb b/spec/legion/cli/update_command_spec.rb new file mode 100644 index 00000000..e7d7d3b5 --- /dev/null +++ b/spec/legion/cli/update_command_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/update_command' +require 'legion/cli/output' + +RSpec.describe Legion::CLI::Update do + let(:formatter) { Legion::CLI::Output::Formatter.new(json: false, color: false) } + let(:instance) { described_class.new([], options) } + let(:options) { { json: false, no_color: true, dry_run: false } } + + before do + allow(instance).to receive(:formatter).and_return(formatter) + allow(instance).to receive(:fetch_outdated).and_return({}) + end + + describe '#discover_legion_gems' do + it 'always includes legionio' do + gems = instance.send(:discover_legion_gems) + expect(gems).to include('legionio') + end + + it 'includes legion-* gems' do + gems = instance.send(:discover_legion_gems) + legion_gems = gems.select { |g| g.start_with?('legion-') } + expect(legion_gems).not_to be_empty + end + + it 'returns sorted unique list' do + gems = instance.send(:discover_legion_gems) + expect(gems).to eq(gems.uniq.sort) + end + end + + describe '#snapshot_versions' do + it 'returns version hash for installed gems' do + versions = instance.send(:snapshot_versions, ['legionio']) + expect(versions['legionio']).to match(/\d+\.\d+\.\d+/) + end + + it 'returns nil for missing gems' do + versions = instance.send(:snapshot_versions, ['nonexistent-gem-xyz']) + expect(versions['nonexistent-gem-xyz']).to be_nil + end + end + + describe '#parse_outdated' do + it 'parses gem outdated output format' do + outdated_output = "lex-kerberos (0.1.6 < 0.1.7)\nlegionio (1.5.0 < 1.6.0)\nrake (13.0.0 < 13.1.0)\n" + allowed = %w[legionio lex-kerberos] + result = instance.send(:parse_outdated, outdated_output, allowed) + + expect(result).to eq({ + 'lex-kerberos' => { local: '0.1.6', remote: '0.1.7' }, + 'legionio' => { local: '1.5.0', remote: '1.6.0' } + }) + end + + it 'filters to only allowed gem names' do + outdated_output = "rake (13.0.0 < 13.1.0)\nlex-kerberos (0.1.6 < 0.1.7)\n" + result = instance.send(:parse_outdated, outdated_output, %w[lex-kerberos]) + + expect(result.keys).to eq(['lex-kerberos']) + end + + it 'returns empty hash for empty output' do + result = instance.send(:parse_outdated, '', %w[legionio]) + expect(result).to eq({}) + end + end + + describe '#gems (dry_run)' do + let(:options) { { json: false, no_color: true, dry_run: true } } + + before do + allow(instance).to receive(:discover_legion_gems).and_return(%w[legionio legion-json]) + allow(instance).to receive(:fetch_outdated).and_return( + 'legionio' => { local: '1.5.0', remote: '2.0.0' } + ) + end + + it 'does not shell out to gem install' do + output = StringIO.new + $stdout = output + expect(instance).not_to receive(:`).with(/gem install/) + instance.gems + ensure + $stdout = STDOUT + end + + it 'reports available updates' do + output = StringIO.new + $stdout = output + instance.gems + $stdout = STDOUT + expect(output.string).to include('legionio') + end + end + + describe '#gems (json + dry_run)' do + let(:options) { { json: true, no_color: true, dry_run: true } } + + before do + allow(instance).to receive(:discover_legion_gems).and_return(%w[legionio]) + allow(instance).to receive(:fetch_outdated).and_return( + 'legionio' => { local: '1.5.0', remote: '2.0.0' } + ) + end + + it 'outputs valid JSON with gems key' do + output = StringIO.new + $stdout = output + instance.gems + $stdout = STDOUT + parsed = JSON.parse(output.string) + expect(parsed).to have_key('gems') + expect(parsed['dry_run']).to be true + end + end + + describe '#display_results' do + it 'shows up-to-date message when nothing changed' do + output = StringIO.new + $stdout = output + results = [{ name: 'legionio', status: 'current', from: '1.0.0' }] + instance.send(:display_results, formatter, results, {}, {}) + $stdout = STDOUT + expect(output.string).to include('already latest') + end + + it 'shows updated message when version changed' do + output = StringIO.new + $stdout = output + results = [{ name: 'legionio', status: 'installed' }] + before_v = { 'legionio' => '1.0.0' } + after_v = { 'legionio' => '1.1.0' } + instance.send(:display_results, formatter, results, before_v, after_v) + $stdout = STDOUT + expect(output.string).to include('1.0.0') + expect(output.string).to include('1.1.0') + end + + it 'shows failure message on error' do + output = StringIO.new + $stdout = output + results = [{ name: 'legionio', status: 'failed' }] + instance.send(:display_results, formatter, results, {}, {}) + $stdout = STDOUT + expect(output.string).to include('failed') + end + + it 'shows available status for dry run results' do + output = StringIO.new + $stdout = output + results = [{ name: 'legionio', status: 'available', from: '1.0.0', to: '2.0.0' }] + instance.send(:display_results, formatter, results, {}, {}) + $stdout = STDOUT + expect(output.string).to include('1.0.0') + expect(output.string).to include('2.0.0') + end + + it 'shows current status for dry run with no update' do + output = StringIO.new + $stdout = output + results = [{ name: 'legionio', status: 'current', from: '1.0.0' }] + instance.send(:display_results, formatter, results, {}, {}) + $stdout = STDOUT + expect(output.string).to include('already latest') + end + end +end diff --git a/spec/legion/cli/worker_command_spec.rb b/spec/legion/cli/worker_command_spec.rb new file mode 100644 index 00000000..2508b15d --- /dev/null +++ b/spec/legion/cli/worker_command_spec.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli' +require 'legion/cli/worker_command' +require 'legion/digital_worker/lifecycle' + +RSpec.describe Legion::CLI::Worker do + let(:worker_id) { 'abc-1234-5678' } + let(:worker_model) { double('Legion::Data::Model::DigitalWorker') } + let(:worker) { double('worker', worker_id: worker_id, name: 'TestBot', lifecycle_state: 'active') } + let(:out) { instance_double(Legion::CLI::Output::Formatter) } + + before do + stub_const('Legion::Data::Model::DigitalWorker', worker_model) + + allow(Legion::CLI::Output::Formatter).to receive(:new).and_return(out) + allow(out).to receive(:success) + allow(out).to receive(:error) + allow(out).to receive(:warn) + allow(out).to receive(:json) + allow(out).to receive(:spacer) + allow(out).to receive(:detail) + + allow(Legion::CLI::Connection).to receive(:ensure_data) + allow(Legion::CLI::Connection).to receive(:shutdown) + allow(Legion::CLI::Connection).to receive(:config_dir=) + allow(Legion::CLI::Connection).to receive(:log_level=) + end + + def build_command(opts = {}) + described_class.new([], opts.merge(json: false, no_color: true, verbose: false)) + end + + def stub_find_worker(result) + allow(worker_model).to receive(:first).and_return(result) + sequel_stub = double('Sequel') + allow(sequel_stub).to receive(:like).and_return(double('like_expr')) + stub_const('Sequel', sequel_stub) + allow(worker_model).to receive(:where).and_return(double('ds', first: nil)) + end + + describe '#pause' do + it 'passes authority_verified: true to transition!' do + stub_find_worker(worker) + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker, + to_state: 'paused', + by: 'cli', + reason: nil, + authority_verified: true + ).and_return(worker) + + build_command.pause(worker_id) + end + + it 'shows success message on successful transition' do + stub_find_worker(worker) + allow(Legion::DigitalWorker::Lifecycle).to receive(:transition!).and_return(worker) + + expect(out).to receive(:success).with(/paused/) + build_command.pause(worker_id) + end + + it 'shows user-friendly error when AuthorityRequired is raised' do + stub_find_worker(worker) + allow(Legion::DigitalWorker::Lifecycle).to receive(:transition!) + .and_raise(Legion::DigitalWorker::Lifecycle::AuthorityRequired, 'active -> paused requires owner_or_manager') + + expect(out).to receive(:error).with(/authority|permission/i) + build_command.pause(worker_id) + end + + it 'shows user-friendly error when GovernanceRequired is raised' do + stub_find_worker(worker) + allow(Legion::DigitalWorker::Lifecycle).to receive(:transition!) + .and_raise(Legion::DigitalWorker::Lifecycle::GovernanceRequired, 'active -> terminated requires council_approval') + + expect(out).to receive(:error).with(/governance|approval/i) + build_command.pause(worker_id) + end + end + + describe '#activate' do + it 'passes authority_verified: true to transition!' do + stub_find_worker(worker) + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker, + to_state: 'active', + by: 'cli', + reason: nil, + authority_verified: true + ).and_return(worker) + + build_command.activate(worker_id) + end + end + + describe '#retire' do + it 'passes authority_verified: true to transition!' do + stub_find_worker(worker) + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker, + to_state: 'retired', + by: 'cli', + reason: nil, + authority_verified: true + ).and_return(worker) + + build_command.retire(worker_id) + end + end + + describe '#terminate' do + it 'passes governance_override: true after user confirms' do + stub_find_worker(worker) + allow($stdin).to receive(:gets).and_return("yes\n") + + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker, + to_state: 'terminated', + by: 'cli', + reason: nil, + governance_override: true + ).and_return(worker) + + build_command(yes: false).terminate(worker_id) + end + + it 'skips confirmation prompt with --yes flag and passes governance_override: true' do + stub_find_worker(worker) + + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker, + to_state: 'terminated', + by: 'cli', + reason: nil, + governance_override: true + ).and_return(worker) + + build_command(yes: true).terminate(worker_id) + end + + it 'aborts without calling transition! when user types something other than yes' do + allow($stdin).to receive(:gets).and_return("no\n") + expect(Legion::DigitalWorker::Lifecycle).not_to receive(:transition!) + build_command(yes: false).terminate(worker_id) + end + + it 'shows user-friendly error when GovernanceRequired is raised' do + stub_find_worker(worker) + allow($stdin).to receive(:gets).and_return("yes\n") + allow(Legion::DigitalWorker::Lifecycle).to receive(:transition!) + .and_raise(Legion::DigitalWorker::Lifecycle::GovernanceRequired, + 'retired -> terminated requires council_approval') + + expect(out).to receive(:error).with(/governance|approval/i) + build_command(yes: false).terminate(worker_id) + end + end + + describe '#create' do + let(:mock_worker) { double('worker', to_hash: { worker_id: 'uuid-1', name: 'test-worker' }) } + + before do + allow(worker_model).to receive(:create).and_return(mock_worker) + end + + it 'creates a worker in bootstrap state with required options' do + expect(worker_model).to receive(:create).with(hash_including( + lifecycle_state: 'bootstrap', + trust_score: 0.0, + entra_app_id: 'app-123' + )) + build_command(entra_app_id: 'app-123', owner_msid: 'user@uhg.com', + extension: 'lex-github', risk_tier: 'low', consent_tier: 'supervised').create('test-worker') + end + + it 'generates a UUID worker_id' do + expect(worker_model).to receive(:create).with(hash_including( + worker_id: match(/\A[0-9a-f-]{36}\z/) + )) + build_command(entra_app_id: 'app-123', owner_msid: 'user@uhg.com', + extension: 'lex-github', risk_tier: 'low', consent_tier: 'supervised').create('test-worker') + end + + it 'includes optional team and manager when provided' do + expect(worker_model).to receive(:create).with(hash_including( + team: 'grid-team', manager_msid: 'mgr@uhg.com', risk_tier: 'high' + )) + build_command(entra_app_id: 'app-123', owner_msid: 'user@uhg.com', extension: 'lex-github', + team: 'grid-team', manager_msid: 'mgr@uhg.com', + risk_tier: 'high', consent_tier: 'supervised').create('test-worker') + end + + it 'outputs JSON when --json is set' do + expect(out).to receive(:json).with(hash_including(worker_id: 'uuid-1')) + described_class.new([], json: true, no_color: true, verbose: false, + entra_app_id: 'app-123', owner_msid: 'user@uhg.com', + extension: 'lex-github', risk_tier: 'low', consent_tier: 'supervised').create('test-worker') + end + + it 'outputs duplicate error on UniqueConstraintViolation' do + stub_const('Sequel::UniqueConstraintViolation', Class.new(StandardError)) + allow(worker_model).to receive(:create) + .and_raise(Sequel::UniqueConstraintViolation.new('duplicate')) + expect(out).to receive(:error).with(/already exists/) + build_command(entra_app_id: 'dup-app', owner_msid: 'user@uhg.com', + extension: 'lex-github', risk_tier: 'low', consent_tier: 'supervised').create('test-worker') + end + + context 'with client_secret and Vault available' do + let(:vault_mod) do + Module.new do + def self.store_client_secret(**) = true + def self.vault_available? = true + end + end + + before { stub_const('Legion::Extensions::Identity::Helpers::VaultSecrets', vault_mod) } + + it 'stores the client secret in Vault' do + expect(vault_mod).to receive(:store_client_secret) + .with(hash_including(worker_id: match(/\A[0-9a-f-]{36}\z/), client_secret: 'secret-value')) + build_command(entra_app_id: 'app-123', owner_msid: 'user@uhg.com', + extension: 'lex-github', client_secret: 'secret-value', + risk_tier: 'low', consent_tier: 'supervised').create('test-worker') + end + end + end + + describe 'worker not found' do + it 'shows error and returns without calling transition!' do + stub_find_worker(nil) + + expect(Legion::DigitalWorker::Lifecycle).not_to receive(:transition!) + expect(out).to receive(:error).with(/not found/i) + + build_command.pause('nonexistent-id') + end + end +end diff --git a/spec/legion/cli/workflow_command_spec.rb b/spec/legion/cli/workflow_command_spec.rb new file mode 100644 index 00000000..8bd1792e --- /dev/null +++ b/spec/legion/cli/workflow_command_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cli/workflow_command' + +RSpec.describe Legion::CLI::Workflow do + it 'is a Thor subcommand' do + expect(described_class.superclass).to eq(Thor) + end + + it 'defines install command' do + expect(described_class.all_commands).to have_key('install') + end + + it 'defines list command' do + expect(described_class.all_commands).to have_key('list') + end + + it 'defines uninstall command' do + expect(described_class.all_commands).to have_key('uninstall') + end + + it 'defines status command' do + expect(described_class.all_commands).to have_key('status') + end + + it 'defaults to list' do + expect(described_class.default_command).to eq('list') + end +end diff --git a/spec/legion/cluster/leader_spec.rb b/spec/legion/cluster/leader_spec.rb new file mode 100644 index 00000000..5ecd1070 --- /dev/null +++ b/spec/legion/cluster/leader_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' +require 'legion/cluster/lock' +require 'legion/cluster/leader' + +RSpec.describe Legion::Cluster::Leader do + subject(:leader) { described_class.new } + + describe '#initialize' do + it 'starts not as leader' do + expect(leader.is_leader).to be false + end + + it 'assigns a node_id' do + expect(leader.node_id).not_to be_nil + end + + it 'accepts a custom node_id' do + custom = described_class.new(node_id: 'my-node') + expect(custom.node_id).to eq('my-node') + end + end + + describe '#leader?' do + it 'returns false initially' do + expect(leader.leader?).to be false + end + end + + describe '#node_id' do + it 'is set to a non-empty string' do + expect(leader.node_id).to be_a(String) + expect(leader.node_id).not_to be_empty + end + + it 'is unique across instances by default' do + other = described_class.new + expect(leader.node_id).not_to eq(other.node_id) + end + end + + describe '#stop' do + it 'is safe to call when not started' do + expect { leader.stop }.not_to raise_error + end + + it 'does not call resign when not a leader' do + allow(Legion::Cluster::Lock).to receive(:release) + leader.stop + expect(Legion::Cluster::Lock).not_to have_received(:release) + end + end + + describe '#start and #stop lifecycle' do + before do + allow(Legion::Cluster::Lock).to receive(:acquire).and_return(false) + allow(Legion::Cluster::Lock).to receive(:release) + end + + it 'starts a heartbeat thread' do + leader.start + expect(leader.instance_variable_get(:@heartbeat_thread)).not_to be_nil + leader.stop + end + + it 'sets running to false after stop' do + leader.start + leader.stop + expect(leader.instance_variable_get(:@running)).to be false + end + end + + describe 'election logic' do + it 'becomes leader when lock is acquired' do + allow(Legion::Cluster::Lock).to receive(:acquire).and_return(true) + allow(Legion::Cluster::Lock).to receive(:release) + leader.send(:attempt_election) + expect(leader.leader?).to be true + end + + it 'is not leader when lock is unavailable' do + allow(Legion::Cluster::Lock).to receive(:acquire).and_return(false) + leader.send(:attempt_election) + expect(leader.leader?).to be false + end + + it 'sets is_leader to false when attempt_election raises' do + allow(Legion::Cluster::Lock).to receive(:acquire).and_raise(StandardError, 'db down') + leader.send(:attempt_election) + expect(leader.leader?).to be false + end + end +end + +require 'legion/service' + +RSpec.describe 'Cluster::Leader boot integration' do + let(:service) { Legion::Service.allocate } + + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:warn) + allow(Legion::Logging).to receive(:emit_tagged) + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:logging).and_return(nil) + end + + context 'when cluster.leader_election is true' do + before do + allow(Legion::Settings).to receive(:[]).with(:cluster).and_return({ leader_election: true }) + end + + it 'starts leader election' do + leader = instance_double(Legion::Cluster::Leader) + allow(Legion::Cluster::Leader).to receive(:new).and_return(leader) + allow(leader).to receive(:start) + + service.send(:setup_cluster) + + expect(leader).to have_received(:start) + end + end + + context 'when cluster.leader_election is false' do + before do + allow(Legion::Settings).to receive(:[]).with(:cluster).and_return({ leader_election: false }) + end + + it 'does not start leader election' do + expect(Legion::Cluster::Leader).not_to receive(:new) + service.send(:setup_cluster) + end + end + + context 'when cluster settings are nil' do + before do + allow(Legion::Settings).to receive(:[]).with(:cluster).and_return(nil) + end + + it 'does not start leader election' do + expect(Legion::Cluster::Leader).not_to receive(:new) + service.send(:setup_cluster) + end + end +end diff --git a/spec/legion/cluster/lock_spec.rb b/spec/legion/cluster/lock_spec.rb new file mode 100644 index 00000000..957e5426 --- /dev/null +++ b/spec/legion/cluster/lock_spec.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/cluster/lock' + +RSpec.describe Legion::Cluster::Lock do + # Reset the token store between examples to avoid cross-test pollution + before do + described_class.tokens.clear + end + + describe '.lock_key' do + it 'produces a consistent integer from a string' do + key = described_class.lock_key('my_lock') + expect(key).to be_a(Integer) + end + + it 'is deterministic — same input produces same output' do + expect(described_class.lock_key('some_lock')).to eq(described_class.lock_key('some_lock')) + end + + it 'produces different keys for different names' do + expect(described_class.lock_key('lock_a')).not_to eq(described_class.lock_key('lock_b')) + end + + it 'stays within non-negative 32-bit range' do + key = described_class.lock_key('test') + expect(key).to be >= 0 + expect(key).to be <= 0x7FFFFFFF + end + end + + describe '.backend' do + context 'when Legion::Cache::Redis is available with a live client' do + let(:redis_client) { double('Redis') } + + before do + redis_mod = Module.new + stub_const('Legion::Cache', Module.new) + stub_const('Legion::Cache::Redis', redis_mod) + allow(Legion::Cache::Redis).to receive(:client).and_return(redis_client) + end + + it 'returns :redis' do + expect(described_class.backend).to eq(:redis) + end + end + + context 'when only Legion::Data is available' do + let(:fake_db) { double('Sequel::Database') } + + before do + hide_const('Legion::Cache') if defined?(Legion::Cache) + stub_const('Legion::Data', Module.new) + allow(Legion::Data).to receive(:connection).and_return(fake_db) + end + + it 'returns :postgres' do + expect(described_class.backend).to eq(:postgres) + end + end + + context 'when neither cache nor DB is available' do + before do + hide_const('Legion::Cache') if defined?(Legion::Cache) + hide_const('Legion::Data') if defined?(Legion::Data) + end + + it 'returns :none' do + expect(described_class.backend).to eq(:none) + end + end + end + + describe '.acquire' do + context 'when no DB connection' do + before do + hide_const('Legion::Cache') if defined?(Legion::Cache) + stub_const('Legion::Data', Module.new) + allow(Legion::Data).to receive(:connection).and_return(nil) + end + + it 'returns false' do + expect(described_class.acquire(name: 'test_lock')).to be false + end + end + + context 'when DB is available and lock is acquired' do + let(:result_row) { { acquired: true } } + let(:fake_db) { instance_double('Sequel::Database') } + + before do + hide_const('Legion::Cache') if defined?(Legion::Cache) + stub_const('Legion::Data', Module.new) + allow(Legion::Data).to receive(:connection).and_return(fake_db) + allow(fake_db).to receive(:fetch).and_return([result_row]) + end + + it 'returns true' do + expect(described_class.acquire(name: 'test_lock')).to be true + end + end + + context 'when DB raises an error' do + let(:fake_db) { instance_double('Sequel::Database') } + + before do + hide_const('Legion::Cache') if defined?(Legion::Cache) + stub_const('Legion::Data', Module.new) + allow(Legion::Data).to receive(:connection).and_return(fake_db) + allow(fake_db).to receive(:fetch).and_raise(StandardError, 'connection lost') + end + + it 'returns false' do + expect(described_class.acquire(name: 'test_lock')).to be false + end + end + + context 'when Redis backend — key does not exist' do + let(:redis_client) { double('Redis') } + + before do + stub_const('Legion::Cache', Module.new) + stub_const('Legion::Cache::Redis', Module.new) + allow(Legion::Cache::Redis).to receive(:client).and_return(redis_client) + allow(redis_client).to receive(:call).with('SET', anything, anything, 'NX', 'PX', anything).and_return('OK') + end + + it 'returns a token string on success' do + result = described_class.acquire(name: 'test_lock', ttl: 30) + expect(result).to be_a(String) + expect(result).not_to be_empty + end + end + + context 'when Redis backend — key already exists' do + let(:redis_client) { double('Redis') } + + before do + stub_const('Legion::Cache', Module.new) + stub_const('Legion::Cache::Redis', Module.new) + allow(Legion::Cache::Redis).to receive(:client).and_return(redis_client) + allow(redis_client).to receive(:call).with('SET', anything, anything, 'NX', 'PX', anything).and_return(nil) + end + + it 'returns nil when key already exists' do + expect(described_class.acquire(name: 'test_lock', ttl: 30)).to be_nil + end + end + + context 'when Redis backend — TTL is passed correctly' do + let(:redis_client) { double('Redis') } + + before do + stub_const('Legion::Cache', Module.new) + stub_const('Legion::Cache::Redis', Module.new) + allow(Legion::Cache::Redis).to receive(:client).and_return(redis_client) + end + + it 'passes ttl in milliseconds to SET PX' do + expect(redis_client).to receive(:call).with('SET', 'legion:lock:timed_lock', anything, 'NX', 'PX', 60_000).and_return('OK') + described_class.acquire(name: 'timed_lock', ttl: 60) + end + end + end + + describe '.release' do + context 'when no DB connection' do + before do + hide_const('Legion::Cache') if defined?(Legion::Cache) + stub_const('Legion::Data', Module.new) + allow(Legion::Data).to receive(:connection).and_return(nil) + end + + it 'returns false' do + expect(described_class.release(name: 'test_lock')).to be false + end + end + + context 'when DB is available and lock is released' do + let(:result_row) { { released: true } } + let(:fake_db) { instance_double('Sequel::Database') } + + before do + hide_const('Legion::Cache') if defined?(Legion::Cache) + stub_const('Legion::Data', Module.new) + allow(Legion::Data).to receive(:connection).and_return(fake_db) + allow(fake_db).to receive(:fetch).and_return([result_row]) + end + + it 'returns true' do + expect(described_class.release(name: 'test_lock')).to be true + end + end + + context 'when DB raises an error' do + let(:fake_db) { instance_double('Sequel::Database') } + + before do + hide_const('Legion::Cache') if defined?(Legion::Cache) + stub_const('Legion::Data', Module.new) + allow(Legion::Data).to receive(:connection).and_return(fake_db) + allow(fake_db).to receive(:fetch).and_raise(StandardError, 'connection lost') + end + + it 'returns false' do + expect(described_class.release(name: 'test_lock')).to be false + end + end + + context 'when Redis backend — correct token' do + let(:redis_client) { double('Redis') } + let(:token) { 'abc123correcttoken' } + + before do + stub_const('Legion::Cache', Module.new) + stub_const('Legion::Cache::Redis', Module.new) + allow(Legion::Cache::Redis).to receive(:client).and_return(redis_client) + allow(redis_client).to receive(:call).with('EVAL', anything, 1, 'legion:lock:test_lock', token).and_return(1) + end + + it 'returns true when the correct token matches' do + expect(described_class.release(name: 'test_lock', token: token)).to be true + end + end + + context 'when Redis backend — wrong token' do + let(:redis_client) { double('Redis') } + + before do + stub_const('Legion::Cache', Module.new) + stub_const('Legion::Cache::Redis', Module.new) + allow(Legion::Cache::Redis).to receive(:client).and_return(redis_client) + allow(redis_client).to receive(:call).with('EVAL', anything, 1, 'legion:lock:test_lock', 'wrongtoken').and_return(0) + end + + it 'returns false when token does not match' do + expect(described_class.release(name: 'test_lock', token: 'wrongtoken')).to be false + end + end + end + + describe '.with_lock' do + context 'when lock is acquired (PG-style true)' do + before do + allow(described_class).to receive(:acquire).and_return(true) + allow(described_class).to receive(:release) + end + + it 'yields the block' do + yielded = false + described_class.with_lock(name: 'test_lock') { yielded = true } + expect(yielded).to be true + end + + it 'releases the lock after yielding' do + described_class.with_lock(name: 'test_lock') { nil } + expect(described_class).to have_received(:release).with(name: 'test_lock', token: nil) + end + end + + context 'when lock is unavailable' do + before do + allow(described_class).to receive(:acquire).and_return(false) + allow(described_class).to receive(:release) + end + + it 'does not yield' do + yielded = false + described_class.with_lock(name: 'test_lock') { yielded = true } + expect(yielded).to be false + end + + it 'does not call release' do + described_class.with_lock(name: 'test_lock') { nil } + expect(described_class).not_to have_received(:release) + end + end + + context 'when Redis backend — lock acquired' do + let(:redis_client) { double('Redis') } + let(:token) { 'deadbeefdeadbeef' } + + before do + stub_const('Legion::Cache', Module.new) + stub_const('Legion::Cache::Redis', Module.new) + allow(Legion::Cache::Redis).to receive(:client).and_return(redis_client) + allow(redis_client).to receive(:call).with('SET', anything, anything, 'NX', 'PX', anything).and_return('OK') + allow(redis_client).to receive(:call).with('EVAL', anything, 1, anything, anything).and_return(1) + allow(SecureRandom).to receive(:hex).with(16).and_return(token) + end + + it 'yields the block' do + yielded = false + described_class.with_lock(name: 'redis_lock') { yielded = true } + expect(yielded).to be true + end + + it 'releases with the acquired token' do + described_class.with_lock(name: 'redis_lock') { nil } + expect(redis_client).to have_received(:call).with('EVAL', anything, 1, 'legion:lock:redis_lock', token) + end + end + + context 'when Redis backend — lock unavailable' do + let(:redis_client) { double('Redis') } + + before do + stub_const('Legion::Cache', Module.new) + stub_const('Legion::Cache::Redis', Module.new) + allow(Legion::Cache::Redis).to receive(:client).and_return(redis_client) + allow(redis_client).to receive(:call).with('SET', anything, anything, 'NX', 'PX', anything).and_return(nil) + end + + it 'does not yield when lock is unavailable' do + yielded = false + described_class.with_lock(name: 'redis_lock') { yielded = true } + expect(yielded).to be false + end + end + end +end diff --git a/spec/legion/compliance/phi_access_log_spec.rb b/spec/legion/compliance/phi_access_log_spec.rb new file mode 100644 index 00000000..69622897 --- /dev/null +++ b/spec/legion/compliance/phi_access_log_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/compliance' + +RSpec.describe Legion::Compliance::PhiAccessLog do + before do + Legion::Settings.merge_settings(:compliance, Legion::Compliance::DEFAULTS) + end + + describe '.log_access' do + context 'when phi_enabled is true and Legion::Audit is defined' do + before do + stub_const('Legion::Audit', Module.new) + allow(Legion::Audit).to receive(:record) + end + + it 'calls Legion::Audit.record with event_type phi_access' do + described_class.log_access( + resource: 'task:42', + action: 'read', + actor: 'worker:7', + reason: 'treatment' + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'phi_access', + action: 'read', + resource: 'task:42', + principal_id: 'worker:7' + ) + ) + end + + it 'passes reason in detail' do + described_class.log_access( + resource: 'task:1', + action: 'write', + actor: 'system', + reason: 'payment' + ) + + expect(Legion::Audit).to have_received(:record).with( + hash_including(detail: hash_including(reason: 'payment')) + ) + end + end + + context 'when phi_enabled is false' do + before do + allow(Legion::Compliance).to receive(:phi_enabled?).and_return(false) + stub_const('Legion::Audit', Module.new) + allow(Legion::Audit).to receive(:record) + end + + it 'does not call Legion::Audit.record' do + described_class.log_access(resource: 'task:1', action: 'read', actor: 'x', reason: 'y') + expect(Legion::Audit).not_to have_received(:record) + end + end + + context 'when Legion::Audit is not defined' do + before do + hide_const('Legion::Audit') + end + + it 'does not raise' do + expect do + described_class.log_access(resource: 'task:1', action: 'read', actor: 'x', reason: 'y') + end.not_to raise_error + end + end + end +end diff --git a/spec/legion/compliance/phi_erasure_spec.rb b/spec/legion/compliance/phi_erasure_spec.rb new file mode 100644 index 00000000..8d9d072f --- /dev/null +++ b/spec/legion/compliance/phi_erasure_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/compliance' + +RSpec.describe Legion::Compliance::PhiErasure do + before do + allow(Legion::Settings).to receive(:[]).with(:compliance).and_return({ phi_enabled: true }) + end + + describe '.erase' do + context 'when all optional components are present' do + before do + stub_const('Legion::Crypt::Erasure', Module.new) + stub_const('Legion::Cache', Module.new) + allow(Legion::Crypt::Erasure).to receive(:erase_tenant).and_return({ erased: true }) + allow(Legion::Crypt::Erasure).to receive(:verify_erasure).and_return({ erased: true }) + allow(Legion::Cache).to receive(:delete) + stub_const('Legion::Compliance::PhiAccessLog', Module.new) + allow(Legion::Compliance::PhiAccessLog).to receive(:log_access) + end + + it 'calls Crypt::Erasure.erase_tenant with task_id as tenant_id' do + described_class.erase(task_id: 'task:77', reason: 'patient_request') + expect(Legion::Crypt::Erasure).to have_received(:erase_tenant).with(tenant_id: 'task:77') + end + + it 'calls PhiAccessLog.log_access with erasure action' do + described_class.erase(task_id: 'task:77', reason: 'patient_request') + expect(Legion::Compliance::PhiAccessLog).to have_received(:log_access).with( + hash_including(action: 'erasure', resource: 'task:77', reason: 'patient_request') + ) + end + + it 'calls Crypt::Erasure.verify_erasure' do + described_class.erase(task_id: 'task:77', reason: 'patient_request') + expect(Legion::Crypt::Erasure).to have_received(:verify_erasure).with(tenant_id: 'task:77') + end + + it 'returns a result hash with erased: true' do + result = described_class.erase(task_id: 'task:77', reason: 'patient_request') + expect(result[:erased]).to be true + expect(result[:task_id]).to eq('task:77') + end + end + + context 'when Legion::Crypt::Erasure is not defined' do + before do + hide_const('Legion::Crypt::Erasure') if defined?(Legion::Crypt::Erasure) + hide_const('Legion::Cache') if defined?(Legion::Cache) + hide_const('Legion::Compliance::PhiAccessLog') if defined?(Legion::Compliance::PhiAccessLog) + end + + it 'does not raise and returns partial result' do + expect do + result = described_class.erase(task_id: 'task:88', reason: 'test') + expect(result[:task_id]).to eq('task:88') + end.not_to raise_error + end + end + + context 'when Legion::Cache is not defined' do + before do + stub_const('Legion::Crypt::Erasure', Module.new) + allow(Legion::Crypt::Erasure).to receive(:erase_tenant).and_return({ erased: true }) + allow(Legion::Crypt::Erasure).to receive(:verify_erasure).and_return({ erased: true }) + hide_const('Legion::Cache') if defined?(Legion::Cache) + stub_const('Legion::Compliance::PhiAccessLog', Module.new) + allow(Legion::Compliance::PhiAccessLog).to receive(:log_access) + end + + it 'skips cache purge without raising' do + expect { described_class.erase(task_id: 'task:99', reason: 'test') }.not_to raise_error + end + end + + context 'when erase_tenant fails' do + before do + stub_const('Legion::Crypt::Erasure', Module.new) + allow(Legion::Crypt::Erasure).to receive(:erase_tenant).and_return({ erased: false, error: 'vault unavailable' }) + allow(Legion::Crypt::Erasure).to receive(:verify_erasure).and_return({ erased: false }) + hide_const('Legion::Cache') if defined?(Legion::Cache) + stub_const('Legion::Compliance::PhiAccessLog', Module.new) + allow(Legion::Compliance::PhiAccessLog).to receive(:log_access) + end + + it 'returns erased: false' do + result = described_class.erase(task_id: 'task:bad', reason: 'test') + expect(result[:erased]).to be false + end + end + end +end diff --git a/spec/legion/compliance/phi_tag_spec.rb b/spec/legion/compliance/phi_tag_spec.rb new file mode 100644 index 00000000..d08f5681 --- /dev/null +++ b/spec/legion/compliance/phi_tag_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/compliance' + +RSpec.describe Legion::Compliance::PhiTag do + before do + Legion::Settings.merge_settings(:compliance, Legion::Compliance::DEFAULTS) + end + + describe '.phi?' do + it 'returns true when metadata has phi: true' do + expect(described_class.phi?(phi: true)).to be true + end + + it 'returns false when phi key is absent' do + expect(described_class.phi?({})).to be false + end + + it 'returns false when phi is false' do + expect(described_class.phi?(phi: false)).to be false + end + + it 'returns false for nil metadata' do + expect(described_class.phi?(nil)).to be false + end + end + + describe '.tag' do + it 'merges phi: true and data_classification: restricted' do + result = described_class.tag(task_id: 'abc') + expect(result[:phi]).to be true + expect(result[:data_classification]).to eq('restricted') + expect(result[:task_id]).to eq('abc') + end + + it 'preserves existing keys' do + result = described_class.tag(foo: 'bar', baz: 42) + expect(result[:foo]).to eq('bar') + expect(result[:baz]).to eq(42) + end + end + + describe '.tagged_cache_key' do + it 'prefixes key with phi:' do + expect(described_class.tagged_cache_key('task:123')).to eq('phi:task:123') + end + + it 'does not double-prefix already-tagged keys' do + expect(described_class.tagged_cache_key('phi:task:123')).to eq('phi:task:123') + end + end + + describe 'feature flag' do + it 'returns false from phi? when phi_enabled is false' do + allow(Legion::Compliance).to receive(:phi_enabled?).and_return(false) + expect(described_class.phi?(phi: true)).to be false + end + end +end diff --git a/spec/legion/compliance/profile_spec.rb b/spec/legion/compliance/profile_spec.rb new file mode 100644 index 00000000..b0c348a8 --- /dev/null +++ b/spec/legion/compliance/profile_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/compliance' + +RSpec.describe Legion::Compliance do + before do + described_class.setup + end + + describe '.setup' do + it 'registers compliance defaults' do + expect(Legion::Settings.dig(:compliance, :enabled)).to eq(true) + end + end + + describe '.enabled?' do + it 'returns true by default' do + expect(described_class.enabled?).to be true + end + end + + describe '.phi_enabled?' do + it 'returns true by default' do + expect(described_class.phi_enabled?).to be true + end + end + + describe '.pci_enabled?' do + it 'returns true by default' do + expect(described_class.pci_enabled?).to be true + end + end + + describe '.pii_enabled?' do + it 'returns true by default' do + expect(described_class.pii_enabled?).to be true + end + end + + describe '.fedramp_enabled?' do + it 'returns true by default' do + expect(described_class.fedramp_enabled?).to be true + end + end + + describe '.classification_level' do + it 'returns confidential by default' do + expect(described_class.classification_level).to eq('confidential') + end + end + + describe '.profile' do + it 'returns a hash with all compliance flags' do + profile = described_class.profile + expect(profile[:classification_level]).to eq('confidential') + expect(profile[:phi]).to be true + expect(profile[:pci]).to be true + expect(profile[:pii]).to be true + expect(profile[:fedramp]).to be true + expect(profile[:log_redaction]).to be true + expect(profile[:cache_phi_max_ttl]).to eq(3600) + end + end +end diff --git a/spec/legion/context_spec.rb b/spec/legion/context_spec.rb new file mode 100644 index 00000000..035abefd --- /dev/null +++ b/spec/legion/context_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/context' + +RSpec.describe Legion::Context do + after { described_class.end_session } + + describe '.with_session' do + it 'sets and restores session' do + ctx = Legion::Context::SessionContext.new(user_id: 'test') + inner = nil + described_class.with_session(ctx) { inner = described_class.current_session } + expect(inner.user_id).to eq('test') + expect(described_class.current_session).to be_nil + end + end + + describe '.start_session' do + it 'creates session with uuid' do + ctx = described_class.start_session(user_id: 'user-1') + expect(ctx.session_id).to match(/\A[0-9a-f-]{36}\z/) + expect(described_class.current_session).to eq(ctx) + end + end + + describe '.session_metadata' do + it 'returns empty hash without session' do + expect(described_class.session_metadata).to eq({}) + end + + it 'returns metadata with session' do + described_class.start_session(user_id: 'u1') + meta = described_class.session_metadata + expect(meta[:user_id]).to eq('u1') + expect(meta[:session_id]).not_to be_nil + end + end + + describe '.end_session' do + it 'clears current session' do + described_class.start_session + described_class.end_session + expect(described_class.current_session).to be_nil + end + end + + describe '.with_task_context' do + after { Thread.current[:legion_context] = nil } + + it 'sets thread-local context from message hash' do + message = { task_id: 42, conversation_id: 'conv-1', chain_id: 7, function: 'get', runner_class: 'Foo' } + captured = nil + described_class.with_task_context(message) { captured = Thread.current[:legion_context] } + expect(captured).to eq(message.slice(:task_id, :conversation_id, :chain_id, :function, :runner_class)) + end + + it 'compacts nil values' do + described_class.with_task_context({ task_id: nil, function: 'get' }) do + expect(Thread.current[:legion_context]).to eq({ function: 'get' }) + end + end + + it 'restores previous context in ensure' do + Thread.current[:legion_context] = { task_id: 99 } + described_class.with_task_context({ task_id: 1 }) do + expect(Thread.current[:legion_context][:task_id]).to eq(1) + end + expect(Thread.current[:legion_context][:task_id]).to eq(99) + end + + it 'restores on exception' do + described_class.with_task_context({ task_id: 1 }) { raise 'boom' } + rescue RuntimeError + nil + ensure + expect(Thread.current[:legion_context]).to be_nil + end + end + + describe '.current_task_context' do + it 'returns nil when no context set' do + expect(described_class.current_task_context).to be_nil + end + + it 'returns the current task context' do + Thread.current[:legion_context] = { task_id: 5 } + expect(described_class.current_task_context).to eq({ task_id: 5 }) + ensure + Thread.current[:legion_context] = nil + end + end +end diff --git a/spec/legion/digital_worker/airb_spec.rb b/spec/legion/digital_worker/airb_spec.rb new file mode 100644 index 00000000..f1d95445 --- /dev/null +++ b/spec/legion/digital_worker/airb_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' + +unless defined?(Legion::Data::Model::DigitalWorker) + module Legion + module Data + module Model + class DigitalWorker; end # rubocop:disable Lint/EmptyClass + end + end + end +end + +require 'legion/digital_worker/lifecycle' +require 'legion/digital_worker/registration' +require 'legion/digital_worker/airb' + +RSpec.describe Legion::DigitalWorker::Airb do + let(:worker_id) { SecureRandom.uuid } + let(:intake_id) { "airb-mock-#{worker_id[0..7]}-12345" } + + before do + allow(Legion::Logging).to receive(:info) if defined?(Legion::Logging) + allow(Legion::Logging).to receive(:warn) if defined?(Legion::Logging) + allow(Legion::Logging).to receive(:debug) if defined?(Legion::Logging) + end + + describe '.create_intake' do + context 'without a live API configured (mock mode)' do + before do + allow(Legion::Settings).to receive(:dig).with(:airb, :api_endpoint).and_return(nil) if defined?(Legion::Settings) + end + + it 'returns a mock intake_id string' do + result = described_class.create_intake(worker_id, description: 'test worker registration') + expect(result).to be_a(String) + expect(result).to include('airb-mock') + end + + it 'includes the worker_id prefix in the intake_id' do + result = described_class.create_intake(worker_id, description: 'test') + expect(result).to include(worker_id[0..7]) + end + end + end + + describe '.check_status' do + context 'without a live API (mock mode)' do + before do + allow(Legion::Settings).to receive(:dig).with(:airb, :api_endpoint).and_return(nil) if defined?(Legion::Settings) + allow(Legion::Settings).to receive(:dig).with(:airb, :credentials).and_return(nil) if defined?(Legion::Settings) + end + + it 'returns pending by default' do + result = described_class.check_status(intake_id) + expect(result).to eq('pending') + end + + it 'returns a string status' do + result = described_class.check_status('any-id') + expect(result).to be_a(String) + end + end + end + + describe '.sync_status' do + let(:pending_worker) do + double('Worker', + worker_id: worker_id, + lifecycle_state: 'pending_approval', + airb_intake_id: intake_id) + end + + context 'when worker is not found' do + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: 'missing').and_return(nil) + end + + it 'returns synced: false with reason' do + result = described_class.sync_status('missing') + expect(result[:synced]).to be(false) + expect(result[:reason]).to eq('worker not found') + end + end + + context 'when worker is not pending approval' do + let(:active_worker) { double('Worker', worker_id: worker_id, lifecycle_state: 'active') } + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: worker_id).and_return(active_worker) + end + + it 'returns synced: false' do + result = described_class.sync_status(worker_id) + expect(result[:synced]).to be(false) + expect(result[:reason]).to eq('not pending approval') + end + end + + context 'when worker is pending but has no intake_id' do + let(:no_intake_worker) do + double('Worker', + worker_id: worker_id, + lifecycle_state: 'pending_approval') + end + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: worker_id) + .and_return(no_intake_worker) + allow(no_intake_worker).to receive(:respond_to?).with(:airb_intake_id).and_return(false) + end + + it 'returns synced: false with no intake_id reason' do + result = described_class.sync_status(worker_id) + expect(result[:synced]).to be(false) + expect(result[:reason]).to eq('no intake_id found') + end + end + + context 'when AIRB status is pending' do + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: worker_id) + .and_return(pending_worker) + allow(pending_worker).to receive(:respond_to?).with(:airb_intake_id).and_return(true) + allow(described_class).to receive(:check_status).with(intake_id).and_return('pending') + end + + it 'returns synced: false' do + result = described_class.sync_status(worker_id) + expect(result[:synced]).to be(false) + end + end + end +end diff --git a/spec/legion/digital_worker/consent_sync_spec.rb b/spec/legion/digital_worker/consent_sync_spec.rb new file mode 100644 index 00000000..f9739738 --- /dev/null +++ b/spec/legion/digital_worker/consent_sync_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/digital_worker/lifecycle' + +RSpec.describe Legion::DigitalWorker::Lifecycle, 'consent sync' do + let(:worker) do + double('Worker', + lifecycle_state: 'active', + worker_id: 'w1', + consent_tier: 'autonomous', + retired_at: nil, + retired_by: nil, + retired_reason: nil, + owner_msid: 'owner@example.com', + update: true) + end + + before do + hide_const('Legion::Events') if defined?(Legion::Events) + hide_const('Legion::Audit') if defined?(Legion::Audit) + hide_const('Legion::Extensions::Governance') if defined?(Legion::Extensions::Governance) + hide_const('Legion::Extensions::Extinction') if defined?(Legion::Extensions::Extinction) + hide_const('Legion::Extensions::Consent') if defined?(Legion::Extensions::Consent) + end + + describe 'consent tier update on transition' do + it 'sets consent_tier to consult when paused' do + expect(worker).to receive(:update).with(hash_including(consent_tier: 'consult')) + described_class.transition!(worker, to_state: 'paused', by: 'owner1', authority_verified: true) + end + + it 'sets consent_tier to inform when retired' do + expect(worker).to receive(:update).with(hash_including(consent_tier: 'inform')) + described_class.transition!(worker, to_state: 'retired', by: 'owner1', authority_verified: true) + end + + it 'sets consent_tier to inform when terminated' do + expect(worker).to receive(:update).with(hash_including(consent_tier: 'inform')) + described_class.transition!(worker, to_state: 'terminated', by: 'admin', governance_override: true) + end + end + + describe 'lex-consent sync when available' do + let(:consent_runner) { Module.new } + + before do + stub_const('Legion::Extensions::Consent::Runners::Consent', consent_runner) + end + + it 'calls update_tier on lex-consent runner' do + allow(worker).to receive(:update) + expect(consent_runner).to receive(:update_tier).with(worker_id: 'w1', tier: 'consult') + described_class.transition!(worker, to_state: 'paused', by: 'owner1', authority_verified: true) + end + + it 'does not raise when consent sync fails' do + allow(worker).to receive(:update) + allow(consent_runner).to receive(:update_tier).and_raise(StandardError, 'consent unavailable') + expect do + described_class.transition!(worker, to_state: 'paused', by: 'owner1', authority_verified: true) + end.not_to raise_error + end + end + + describe 'without lex-consent loaded' do + it 'transitions normally without consent sync' do + allow(worker).to receive(:update) + expect do + described_class.transition!(worker, to_state: 'paused', by: 'owner1', authority_verified: true) + end.not_to raise_error + end + end +end diff --git a/spec/legion/digital_worker/heartbeat_spec.rb b/spec/legion/digital_worker/heartbeat_spec.rb new file mode 100644 index 00000000..873afd1f --- /dev/null +++ b/spec/legion/digital_worker/heartbeat_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' + +unless defined?(Legion::Data::Model::DigitalWorker) + module Legion + module Data + module Model + class DigitalWorker; end # rubocop:disable Lint/EmptyClass + end + end + end +end + +require 'legion/digital_worker' + +RSpec.describe Legion::DigitalWorker do + describe '.heartbeat' do + let(:worker) { double('Worker', worker_id: 'w1') } + + it 'updates last_heartbeat_at and health_status' do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: 'w1').and_return(worker) + expect(worker).to receive(:update).with(hash_including( + health_status: 'healthy', + last_heartbeat_at: an_instance_of(Time) + )) + described_class.heartbeat(worker_id: 'w1') + end + + it 'includes health_node when provided' do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: 'w1').and_return(worker) + expect(worker).to receive(:update).with(hash_including(health_node: 'node-1')) + described_class.heartbeat(worker_id: 'w1', health_node: 'node-1') + end + + it 'accepts custom health_status' do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: 'w1').and_return(worker) + expect(worker).to receive(:update).with(hash_including(health_status: 'degraded')) + described_class.heartbeat(worker_id: 'w1', health_status: 'degraded') + end + + it 'returns nil when worker not found' do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: 'missing').and_return(nil) + expect(described_class.heartbeat(worker_id: 'missing')).to be_nil + end + end + + describe '.detect_orphans' do + let(:stale_worker) do + double('Worker', worker_id: 'w-stale', lifecycle_state: 'active', + last_heartbeat_at: Time.now.utc - 864_000, owner_msid: 'user1') + end + let(:nil_heartbeat_worker) do + double('Worker', worker_id: 'w-nil', lifecycle_state: 'active', + last_heartbeat_at: nil, owner_msid: 'user2') + end + let(:healthy_worker) do + double('Worker', worker_id: 'w-ok', lifecycle_state: 'active', + last_heartbeat_at: Time.now.utc, owner_msid: 'user3') + end + let(:dataset) { double('dataset') } + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:where) + .with(lifecycle_state: 'active').and_return(dataset) + allow(dataset).to receive(:all).and_return([stale_worker, nil_heartbeat_worker, healthy_worker]) + end + + it 'returns workers with stale or nil heartbeats' do + orphans = described_class.detect_orphans(stale_days: 7) + expect(orphans).to contain_exactly(stale_worker, nil_heartbeat_worker) + end + + it 'respects custom stale_days' do + orphans = described_class.detect_orphans(stale_days: 20) + expect(orphans.map(&:worker_id)).to include('w-nil') + end + + it 'excludes healthy workers' do + orphans = described_class.detect_orphans(stale_days: 7) + expect(orphans.map(&:worker_id)).not_to include('w-ok') + end + end + + describe '.pause_orphans!' do + let(:stale_worker) do + double('Worker', worker_id: 'w-stale', lifecycle_state: 'active', + last_heartbeat_at: nil, owner_msid: 'user1', + retired_at: nil, retired_by: nil, retired_reason: nil) + end + let(:dataset) { double('dataset') } + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:where) + .with(lifecycle_state: 'active').and_return(dataset) + allow(dataset).to receive(:all).and_return([stale_worker]) + hide_const('Legion::Events') if defined?(Legion::Events) + hide_const('Legion::Audit') if defined?(Legion::Audit) + hide_const('Legion::Extensions::Governance') if defined?(Legion::Extensions::Governance) + end + + it 'transitions orphaned workers to paused' do + expect(stale_worker).to receive(:update).with(hash_including(lifecycle_state: 'paused')) + described_class.pause_orphans!(stale_days: 7) + end + end +end diff --git a/spec/legion/digital_worker/lifecycle_audit_spec.rb b/spec/legion/digital_worker/lifecycle_audit_spec.rb new file mode 100644 index 00000000..0eb1670f --- /dev/null +++ b/spec/legion/digital_worker/lifecycle_audit_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/audit' +require 'legion/digital_worker/lifecycle' + +RSpec.describe Legion::DigitalWorker::Lifecycle do + let(:worker) do + double('Worker', + worker_id: 'worker-42', + lifecycle_state: 'active', + retired_at: nil, + retired_by: nil, + retired_reason: nil, + update: true) + end + + before do + allow(Legion::Events).to receive(:emit) if defined?(Legion::Events) + end + + describe '.transition! audit integration' do + context 'when Legion::Audit is defined' do + before do + allow(Legion::Audit).to receive(:record) + end + + it 'calls Legion::Audit.record on successful transition' do + described_class.transition!(worker, to_state: 'paused', by: 'manager-1', + reason: 'maintenance', authority_verified: true) + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'lifecycle_transition', + principal_id: 'manager-1', + principal_type: 'human', + action: 'transition', + resource: 'worker-42', + status: 'success' + ) + ) + end + + it 'includes from_state, to_state, and reason in detail' do + described_class.transition!(worker, to_state: 'paused', by: 'manager-1', + reason: 'maintenance', authority_verified: true) + expect(Legion::Audit).to have_received(:record).with( + hash_including( + detail: { from_state: 'active', to_state: 'paused', reason: 'maintenance' } + ) + ) + end + + it 'still returns the worker when audit publishing raises' do + allow(Legion::Audit).to receive(:record).and_raise(StandardError, 'audit down') + result = described_class.transition!(worker, to_state: 'paused', by: 'mgr', + authority_verified: true) + expect(result).to eq(worker) + end + end + + it 'does not call Legion::Audit.record when not defined' do + hide_const('Legion::Audit') + expect do + described_class.transition!(worker, to_state: 'paused', by: 'mgr', authority_verified: true) + end.not_to raise_error + end + end +end diff --git a/spec/legion/digital_worker/lifecycle_governance_spec.rb b/spec/legion/digital_worker/lifecycle_governance_spec.rb new file mode 100644 index 00000000..98a3ddac --- /dev/null +++ b/spec/legion/digital_worker/lifecycle_governance_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/digital_worker/lifecycle' + +RSpec.describe Legion::DigitalWorker::Lifecycle do + let(:worker) do + double('Worker', + lifecycle_state: 'active', + worker_id: 'w1', + retired_at: nil, + retired_by: nil, + retired_reason: nil, + update: true) + end + + before do + hide_const('Legion::Events') if defined?(Legion::Events) + hide_const('Legion::Audit') if defined?(Legion::Audit) + end + + describe '.transition! with lex-governance loaded' do + let(:governance_runner) { Module.new } + + before do + stub_const('Legion::Extensions::Governance::Runners::Governance', governance_runner) + end + + it 'calls review_transition and proceeds when allowed' do + allow(governance_runner).to receive(:review_transition).and_return({ allowed: true, checks: [] }) + expect(worker).to receive(:update) + described_class.transition!(worker, to_state: 'paused', by: 'owner1') + end + + it 'raises GovernanceBlocked when review returns not allowed' do + allow(governance_runner).to receive(:review_transition).and_return( + { allowed: false, reasons: [:council_approval_required] } + ) + expect do + described_class.transition!(worker, to_state: 'terminated', by: 'user1') + end.to raise_error(Legion::DigitalWorker::Lifecycle::GovernanceBlocked, /council_approval_required/) + end + + it 'passes worker_id, from_state, to_state, and principal_id' do + expect(governance_runner).to receive(:review_transition).with( + hash_including(worker_id: 'w1', from_state: 'active', to_state: 'paused', principal_id: 'owner1') + ).and_return({ allowed: true, checks: [] }) + allow(worker).to receive(:update) + described_class.transition!(worker, to_state: 'paused', by: 'owner1') + end + end + + describe '.transition! without lex-governance loaded' do + before do + hide_const('Legion::Extensions::Governance') if defined?(Legion::Extensions::Governance) + end + + it 'falls back to legacy governance check' do + expect do + described_class.transition!(worker, to_state: 'terminated', by: 'user1') + end.to raise_error(Legion::DigitalWorker::Lifecycle::GovernanceRequired) + end + + it 'proceeds with governance_override and authority_verified flags' do + expect(worker).to receive(:update) + described_class.transition!(worker, to_state: 'terminated', by: 'user1', + governance_override: true, authority_verified: true) + end + end +end diff --git a/spec/legion/digital_worker/registration_spec.rb b/spec/legion/digital_worker/registration_spec.rb new file mode 100644 index 00000000..684922c0 --- /dev/null +++ b/spec/legion/digital_worker/registration_spec.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'securerandom' + +unless defined?(Legion::Data::Model::DigitalWorker) + module Legion + module Data + module Model + class DigitalWorker; end # rubocop:disable Lint/EmptyClass + end + end + end +end + +require 'legion/digital_worker/lifecycle' +require 'legion/digital_worker/registration' + +RSpec.describe Legion::DigitalWorker::Registration do + let(:worker_id) { SecureRandom.uuid } + + let(:worker_double) do + double( + 'Worker', + worker_id: worker_id, + name: 'TestBot', + lifecycle_state: 'pending_approval', + risk_tier: 'high', + created_at: Time.now.utc - 3600, + update: true, + retired_at: nil, + retired_by: nil, + retired_reason: nil, + to_hash: { worker_id: worker_id, name: 'TestBot', lifecycle_state: 'pending_approval' } + ) + end + + let(:active_worker_double) do + double( + 'Worker', + worker_id: worker_id, + name: 'TestBot', + lifecycle_state: 'active', + risk_tier: 'high', + created_at: Time.now.utc - 3600, + update: true, + retired_at: nil, + retired_by: nil, + retired_reason: nil, + to_hash: { worker_id: worker_id, name: 'TestBot', lifecycle_state: 'active' } + ) + end + + before do + allow(Legion::Events).to receive(:emit) if defined?(Legion::Events) + allow(Legion::Logging).to receive(:info) if defined?(Legion::Logging) + allow(Legion::Logging).to receive(:warn) if defined?(Legion::Logging) + allow(Legion::Logging).to receive(:debug) if defined?(Legion::Logging) + end + + describe '.approval_required?' do + it 'returns true for high tier' do + expect(described_class.approval_required?('high')).to be(true) + end + + it 'returns true for critical tier' do + expect(described_class.approval_required?('critical')).to be(true) + end + + it 'returns false for medium tier' do + expect(described_class.approval_required?('medium')).to be(false) + end + + it 'returns false for low tier' do + expect(described_class.approval_required?('low')).to be(false) + end + + it 'returns false for an empty string' do + expect(described_class.approval_required?('')).to be(false) + end + + it 'handles symbol input by converting to string' do + expect(described_class.approval_required?(:high)).to be(true) + end + end + + describe '.register' do + let(:base_attrs) do + { + name: 'TestBot', + extension_name: 'lex-testbot', + entra_app_id: 'app-123', + owner_msid: 'owner@example.com', + risk_tier: 'low' + } + end + + context 'with a low risk tier' do + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:create).and_return( + double('Worker', + worker_id: worker_id, name: 'TestBot', lifecycle_state: 'bootstrap', + risk_tier: 'low', created_at: Time.now.utc, update: true, + retired_at: nil, retired_by: nil, retired_reason: nil) + ) + end + + it 'creates the worker in bootstrap state' do + expect(Legion::Data::Model::DigitalWorker).to receive(:create).with( + hash_including(lifecycle_state: 'bootstrap') + ) + described_class.register(base_attrs) + end + + it 'does not require approval' do + expect(Legion::Data::Model::DigitalWorker).to receive(:create).with( + hash_including(lifecycle_state: 'bootstrap') + ) + described_class.register(base_attrs) + end + end + + context 'with a high risk tier' do + let(:high_attrs) { base_attrs.merge(risk_tier: 'high') } + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:create).and_return(worker_double) + allow(Legion::DigitalWorker::Airb).to receive(:create_intake).and_return('airb-mock-001') if defined?(Legion::DigitalWorker::Airb) + end + + it 'creates the worker in pending_approval state' do + expect(Legion::Data::Model::DigitalWorker).to receive(:create).with( + hash_including(lifecycle_state: 'pending_approval') + ) + described_class.register(high_attrs) + end + + it 'returns the created worker' do + result = described_class.register(high_attrs) + expect(result).to eq(worker_double) + end + end + + context 'with a critical risk tier' do + let(:critical_attrs) { base_attrs.merge(risk_tier: 'critical') } + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:create).and_return( + double('Worker', + worker_id: worker_id, name: 'CritBot', lifecycle_state: 'pending_approval', + risk_tier: 'critical', created_at: Time.now.utc, update: true, + retired_at: nil, retired_by: nil, retired_reason: nil) + ) + end + + it 'creates the worker in pending_approval state' do + expect(Legion::Data::Model::DigitalWorker).to receive(:create).with( + hash_including(lifecycle_state: 'pending_approval') + ) + described_class.register(critical_attrs) + end + end + + it 'sets consent_tier to supervised by default' do + allow(Legion::Data::Model::DigitalWorker).to receive(:create).and_return(worker_double) + expect(Legion::Data::Model::DigitalWorker).to receive(:create).with( + hash_including(consent_tier: 'supervised') + ) + described_class.register(base_attrs) + end + + it 'sets trust_score to 0.0 by default' do + allow(Legion::Data::Model::DigitalWorker).to receive(:create).and_return(worker_double) + expect(Legion::Data::Model::DigitalWorker).to receive(:create).with( + hash_including(trust_score: 0.0) + ) + described_class.register(base_attrs) + end + end + + describe '.pending_approvals' do + it 'returns workers with pending_approval state' do + dataset = [worker_double] + allow(Legion::Data::Model::DigitalWorker).to receive(:where).with(lifecycle_state: 'pending_approval').and_return(double(all: dataset)) + expect(described_class.pending_approvals).to eq(dataset) + end + + it 'returns an empty array when DigitalWorker model is not defined' do + hide_const('Legion::Data::Model::DigitalWorker') + expect(described_class.pending_approvals).to eq([]) + end + end + + describe '.approve' do + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: worker_id).and_return(worker_double) + allow(Legion::DigitalWorker::Lifecycle).to receive(:transition!).and_return(active_worker_double) + allow(Legion::Audit).to receive(:record) if defined?(Legion::Audit) + end + + it 'calls Lifecycle.transition! with to_state active' do + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker_double, + hash_including(to_state: 'active') + ).and_return(active_worker_double) + described_class.approve(worker_id, approver: 'admin@example.com') + end + + it 'passes the approver as the by argument' do + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker_double, + hash_including(by: 'admin@example.com') + ).and_return(active_worker_double) + described_class.approve(worker_id, approver: 'admin@example.com') + end + + it 'passes notes as the reason' do + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker_double, + hash_including(reason: 'LGTM') + ).and_return(active_worker_double) + described_class.approve(worker_id, approver: 'admin@example.com', notes: 'LGTM') + end + + it 'raises ArgumentError when worker is not found' do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: 'bad-id').and_return(nil) + expect { described_class.approve('bad-id', approver: 'admin') }.to raise_error(ArgumentError, /worker not found/) + end + + it 'raises ArgumentError when worker is not pending approval' do + non_pending = double('Worker', worker_id: worker_id, lifecycle_state: 'active') + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: worker_id).and_return(non_pending) + expect { described_class.approve(worker_id, approver: 'admin') }.to raise_error(ArgumentError, /not pending approval/) + end + end + + describe '.reject' do + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: worker_id).and_return(worker_double) + allow(Legion::DigitalWorker::Lifecycle).to receive(:transition!).and_return( + double('Worker', worker_id: worker_id, name: 'TestBot', lifecycle_state: 'rejected', + update: true, retired_at: nil, retired_by: nil, retired_reason: nil) + ) + allow(Legion::Audit).to receive(:record) if defined?(Legion::Audit) + end + + it 'calls Lifecycle.transition! with to_state rejected' do + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker_double, + hash_including(to_state: 'rejected') + ) + described_class.reject(worker_id, approver: 'admin@example.com', reason: 'policy violation') + end + + it 'passes the approver and reason' do + expect(Legion::DigitalWorker::Lifecycle).to receive(:transition!).with( + worker_double, + hash_including(by: 'admin@example.com', reason: 'policy violation') + ) + described_class.reject(worker_id, approver: 'admin@example.com', reason: 'policy violation') + end + + it 'raises ArgumentError when worker is not found' do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: 'no-such-id').and_return(nil) + expect { described_class.reject('no-such-id', approver: 'admin', reason: 'nope') } + .to raise_error(ArgumentError, /worker not found/) + end + end + + describe '.escalate' do + context 'when worker is pending and has exceeded timeout' do + let(:old_worker) do + double('Worker', + worker_id: worker_id, + lifecycle_state: 'pending_approval', + created_at: Time.now.utc - Legion::DigitalWorker::Registration::APPROVAL_TIMEOUT_SECONDS - 3600) + end + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: worker_id).and_return(old_worker) + end + + it 'returns escalated: true' do + result = described_class.escalate(worker_id) + expect(result[:escalated]).to be(true) + end + + it 'includes the worker_id in the result' do + result = described_class.escalate(worker_id) + expect(result[:worker_id]).to eq(worker_id) + end + end + + context 'when worker is pending but within timeout' do + let(:recent_worker) do + double('Worker', + worker_id: worker_id, + lifecycle_state: 'pending_approval', + created_at: Time.now.utc - 3600) + end + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: worker_id).and_return(recent_worker) + end + + it 'returns escalated: false' do + result = described_class.escalate(worker_id) + expect(result[:escalated]).to be(false) + end + + it 'includes remaining_seconds in the result' do + result = described_class.escalate(worker_id) + expect(result[:remaining_seconds]).to be > 0 + end + end + + context 'when worker is not found' do + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: 'missing').and_return(nil) + end + + it 'returns escalated: false with a reason' do + result = described_class.escalate('missing') + expect(result[:escalated]).to be(false) + expect(result[:reason]).to eq('worker not found') + end + end + + context 'when worker is not pending' do + let(:active_w) { double('Worker', worker_id: worker_id, lifecycle_state: 'active', created_at: Time.now.utc - 1000) } + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).with(worker_id: worker_id).and_return(active_w) + end + + it 'returns escalated: false' do + result = described_class.escalate(worker_id) + expect(result[:escalated]).to be(false) + expect(result[:reason]).to eq('not pending approval') + end + end + end +end diff --git a/spec/legion/digital_worker/registry_spec.rb b/spec/legion/digital_worker/registry_spec.rb new file mode 100644 index 00000000..494e40d9 --- /dev/null +++ b/spec/legion/digital_worker/registry_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'spec_helper' + +unless defined?(Legion::Data::Model::DigitalWorker) + module Legion + module Data + module Model + class DigitalWorker; end # rubocop:disable Lint/EmptyClass + end + end + end +end + +require 'legion/digital_worker/registry' + +RSpec.describe Legion::DigitalWorker::Registry do + before(:each) do + described_class.clear_local_workers! if described_class.respond_to?(:clear_local_workers!) + end + + describe '.local_worker_ids' do + it 'returns empty array initially' do + expect(described_class.local_worker_ids).to eq([]) + end + end + + describe '.clear_local_workers!' do + it 'empties the local workers set' do + described_class.clear_local_workers! + expect(described_class.local_worker_ids).to eq([]) + end + end + + describe 'worker tracking via validate_execution!' do + let(:worker) do + double('worker', active?: true, consent_tier: 'autonomous', lifecycle_state: 'active') + end + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).and_return(worker) + end + + it 'adds worker_id to local_worker_ids after successful validation' do + described_class.validate_execution!(worker_id: 'w-123') + expect(described_class.local_worker_ids).to include('w-123') + end + + it 'does not duplicate worker_ids on repeated validations' do + described_class.validate_execution!(worker_id: 'w-123') + described_class.validate_execution!(worker_id: 'w-123') + expect(described_class.local_worker_ids.count('w-123')).to eq(1) + end + end + + describe 'DigitalWorker.active_local_ids' do + it 'delegates to Registry.local_worker_ids' do + require 'legion/digital_worker' + expect(Legion::DigitalWorker.active_local_ids).to eq(described_class.local_worker_ids) + end + end + + describe 'CONSENT_HIERARCHY' do + it 'uses inform (not notify) to match Lifecycle::CONSENT_MAPPING' do + expect(described_class::CONSENT_HIERARCHY).to include('inform') + expect(described_class::CONSENT_HIERARCHY).not_to include('notify') + end + + it 'orders tiers from most restrictive to most autonomous' do + expect(described_class::CONSENT_HIERARCHY).to eq(%w[supervised consult inform autonomous]) + end + end + + describe '.consent_sufficient?' do + it 'returns true when current tier meets required tier' do + expect(described_class.consent_sufficient?('autonomous', 'inform')).to be true + end + + it 'returns false when current tier is below required tier' do + expect(described_class.consent_sufficient?('supervised', 'autonomous')).to be false + end + + it 'returns true when tiers are equal' do + expect(described_class.consent_sufficient?('inform', 'inform')).to be true + end + end + + describe '.validate_execution! blocked paths' do + before do + allow(Legion::Events).to receive(:emit) + end + + it 'raises WorkerNotFound and emits worker.blocked when worker is missing' do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).and_return(nil) + expect { described_class.validate_execution!(worker_id: 'missing') } + .to raise_error(described_class::WorkerNotFound) + expect(Legion::Events).to have_received(:emit) + .with('worker.blocked', hash_including(worker_id: 'missing', reason: 'unregistered')) + end + + it 'raises WorkerNotActive and emits worker.blocked when worker is not active' do + worker = double('worker', active?: false, lifecycle_state: 'paused') + allow(Legion::Data::Model::DigitalWorker).to receive(:first).and_return(worker) + expect { described_class.validate_execution!(worker_id: 'w-paused') } + .to raise_error(described_class::WorkerNotActive) + expect(Legion::Events).to have_received(:emit) + .with('worker.blocked', hash_including(worker_id: 'w-paused')) + end + + it 'raises InsufficientConsent and emits worker.blocked when consent is too low' do + worker = double('worker', active?: true, consent_tier: 'supervised', lifecycle_state: 'active') + allow(Legion::Data::Model::DigitalWorker).to receive(:first).and_return(worker) + expect { described_class.validate_execution!(worker_id: 'w-low', required_consent: 'autonomous') } + .to raise_error(described_class::InsufficientConsent) + expect(Legion::Events).to have_received(:emit) + .with('worker.blocked', hash_including(worker_id: 'w-low')) + end + end + + describe 'thread safety' do + let(:worker) do + double('worker', active?: true, consent_tier: 'autonomous', lifecycle_state: 'active') + end + + before do + allow(Legion::Data::Model::DigitalWorker).to receive(:first).and_return(worker) + end + + it 'handles concurrent validate_execution! calls without losing worker IDs' do + threads = 10.times.map do |i| + Thread.new { described_class.validate_execution!(worker_id: "w-#{i}") } + end + threads.each(&:join) + expect(described_class.local_worker_ids.sort).to eq((0..9).map { |i| "w-#{i}" }.sort) + end + end +end diff --git a/spec/legion/digital_worker/risk_tier_spec.rb b/spec/legion/digital_worker/risk_tier_spec.rb new file mode 100644 index 00000000..ff32492c --- /dev/null +++ b/spec/legion/digital_worker/risk_tier_spec.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/digital_worker/registry' +require 'legion/digital_worker/risk_tier' + +RSpec.describe Legion::DigitalWorker::RiskTier do + describe 'TIERS' do + it 'contains the four AIRB risk tiers in ascending order' do + expect(described_class::TIERS).to eq(%w[low medium high critical]) + end + + it 'is frozen' do + expect(described_class::TIERS).to be_frozen + end + end + + describe 'CONSTRAINTS' do + it 'is frozen' do + expect(described_class::CONSTRAINTS).to be_frozen + end + + it 'covers all tiers' do + described_class::TIERS.each do |tier| + expect(described_class::CONSTRAINTS).to have_key(tier) + end + end + end + + describe '.valid?' do + it 'returns true for known tiers' do + %w[low medium high critical].each do |tier| + expect(described_class.valid?(tier)).to be(true) + end + end + + it 'returns false for unknown tiers' do + expect(described_class.valid?('extreme')).to be(false) + expect(described_class.valid?('')).to be(false) + expect(described_class.valid?(nil)).to be(false) + end + end + + describe '.constraints_for' do + it 'returns the constraint hash for a valid tier' do + result = described_class.constraints_for('low') + expect(result).to be_a(Hash) + expect(result).to have_key(:min_consent) + expect(result).to have_key(:governance_gate) + expect(result).to have_key(:council_required) + end + + it 'raises ArgumentError for an unknown tier' do + expect { described_class.constraints_for('extreme') }.to raise_error(ArgumentError, /unknown risk tier: extreme/) + end + + it 'includes valid tier list in the error message' do + expect { described_class.constraints_for('bogus') }.to raise_error(ArgumentError, /low, medium, high, critical/) + end + end + + describe '.min_consent' do + it 'returns inform for low tier' do + expect(described_class.min_consent('low')).to eq('inform') + end + + it 'returns consult for medium tier' do + expect(described_class.min_consent('medium')).to eq('consult') + end + + it 'returns consult for high tier' do + expect(described_class.min_consent('high')).to eq('consult') + end + + it 'returns supervised for critical tier' do + expect(described_class.min_consent('critical')).to eq('supervised') + end + end + + describe '.governance_required?' do + it 'returns false for low tier' do + expect(described_class.governance_required?('low')).to be(false) + end + + it 'returns false for medium tier' do + expect(described_class.governance_required?('medium')).to be(false) + end + + it 'returns true for high tier' do + expect(described_class.governance_required?('high')).to be(true) + end + + it 'returns true for critical tier' do + expect(described_class.governance_required?('critical')).to be(true) + end + end + + describe '.council_required?' do + it 'returns false for low tier' do + expect(described_class.council_required?('low')).to be(false) + end + + it 'returns false for medium tier' do + expect(described_class.council_required?('medium')).to be(false) + end + + it 'returns true for high tier' do + expect(described_class.council_required?('high')).to be(true) + end + + it 'returns true for critical tier' do + expect(described_class.council_required?('critical')).to be(true) + end + end + + describe '.assign!' do + let(:worker) do + double('worker', + worker_id: 'abc-123', + risk_tier: nil, + consent_tier: 'supervised') + end + + before do + allow(worker).to receive(:update) + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:warn) + end + + it 'raises ArgumentError for an invalid tier' do + expect { described_class.assign!(worker, tier: 'extreme', by: 'admin') } + .to raise_error(ArgumentError, /invalid tier: extreme/) + end + + it 'calls update on the worker with the new tier' do + expect(worker).to receive(:update).with(hash_including(risk_tier: 'high')) + described_class.assign!(worker, tier: 'high', by: 'admin') + end + + it 'returns a hash with assigned: true' do + result = described_class.assign!(worker, tier: 'medium', by: 'admin') + expect(result[:assigned]).to be(true) + end + + it 'includes event metadata in the returned hash' do + result = described_class.assign!(worker, tier: 'low', by: 'tester', reason: 'review passed') + expect(result[:worker_id]).to eq('abc-123') + expect(result[:to_tier]).to eq('low') + expect(result[:by]).to eq('tester') + expect(result[:reason]).to eq('review passed') + end + + it 'logs a warning when tier is lowered' do + allow(worker).to receive(:risk_tier).and_return('critical') + expect(Legion::Logging).to receive(:warn).with(/lowering risk from critical to high/) + described_class.assign!(worker, tier: 'high', by: 'admin') + end + + it 'does not warn when tier is the same or raised' do + allow(worker).to receive(:risk_tier).and_return('low') + expect(Legion::Logging).not_to receive(:warn) + described_class.assign!(worker, tier: 'high', by: 'admin') + end + + it 'emits a worker.risk_tier_changed event when Legion::Events is defined' do + allow(Legion::Events).to receive(:emit) + described_class.assign!(worker, tier: 'medium', by: 'admin') + expect(Legion::Events).to have_received(:emit).with('worker.risk_tier_changed', hash_including(worker_id: 'abc-123')) + end + end + + describe '.consent_compliant?' do + # CONSENT_HIERARCHY = %w[supervised consult inform autonomous] + # Index 0=supervised, 1=consult, 2=inform, 3=autonomous + # Compliant when hierarchy.index(worker.consent_tier) >= hierarchy.index(min_consent) + let(:worker) { double('worker', worker_id: 'abc-123') } + + it 'returns true when worker has no risk tier' do + allow(worker).to receive(:risk_tier).and_return(nil) + expect(described_class.consent_compliant?(worker)).to be(true) + end + + it 'returns true when consent tier exactly meets the minimum' do + # low requires 'inform' (index 2); worker at 'inform' (index 2) — compliant + allow(worker).to receive(:risk_tier).and_return('low') + allow(worker).to receive(:consent_tier).and_return('inform') + expect(described_class.consent_compliant?(worker)).to be(true) + end + + it 'returns true when consent tier exceeds the minimum' do + # low requires 'inform' (index 2); 'autonomous' is index 3 — compliant + allow(worker).to receive(:risk_tier).and_return('low') + allow(worker).to receive(:consent_tier).and_return('autonomous') + expect(described_class.consent_compliant?(worker)).to be(true) + end + + it 'returns false when consent tier is below the minimum' do + # low requires 'inform' (index 2); 'supervised' is index 0 — non-compliant + allow(worker).to receive(:risk_tier).and_return('low') + allow(worker).to receive(:consent_tier).and_return('supervised') + expect(described_class.consent_compliant?(worker)).to be(false) + end + + it 'returns true for critical tier with any consent tier' do + # critical requires 'supervised' (index 0); every tier has index >= 0 + allow(worker).to receive(:risk_tier).and_return('critical') + allow(worker).to receive(:consent_tier).and_return('supervised') + expect(described_class.consent_compliant?(worker)).to be(true) + end + + it 'returns false for medium tier with supervised consent' do + # medium requires 'consult' (index 1); 'supervised' is index 0 — non-compliant + allow(worker).to receive(:risk_tier).and_return('medium') + allow(worker).to receive(:consent_tier).and_return('supervised') + expect(described_class.consent_compliant?(worker)).to be(false) + end + end +end diff --git a/spec/legion/digital_worker/value_metrics_spec.rb b/spec/legion/digital_worker/value_metrics_spec.rb new file mode 100644 index 00000000..f3dc7193 --- /dev/null +++ b/spec/legion/digital_worker/value_metrics_spec.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/digital_worker/value_metrics' + +RSpec.describe Legion::DigitalWorker::ValueMetrics do + describe 'METRIC_TYPES' do + it 'contains the three supported metric types' do + expect(described_class::METRIC_TYPES).to contain_exactly(:counter, :gauge, :duration) + end + + it 'is frozen' do + expect(described_class::METRIC_TYPES).to be_frozen + end + end + + describe '.record' do + before do + allow(Legion::Logging).to receive(:debug) + allow(Legion::JSON).to receive(:dump).and_return('{}') + end + + it 'raises ArgumentError for an invalid metric_type' do + expect do + described_class.record(worker_id: 'w1', metric_name: 'tasks_run', metric_type: :histogram, value: 5) + end.to raise_error(ArgumentError, /invalid metric_type: histogram/) + end + + it 'returns a record hash with the normalized fields' do + result = described_class.record( + worker_id: 'w1', + metric_name: :tasks_run, + metric_type: :counter, + value: 42 + ) + expect(result[:worker_id]).to eq('w1') + expect(result[:metric_name]).to eq('tasks_run') + expect(result[:metric_type]).to eq('counter') + expect(result[:value]).to eq(42.0) + expect(result[:recorded_at]).to be_a(Time) + end + + it 'converts value to float' do + result = described_class.record(worker_id: 'w1', metric_name: 'latency', metric_type: :duration, value: '3') + expect(result[:value]).to eq(3.0) + end + + it 'serializes metadata via Legion::JSON.dump' do + meta = { env: 'prod' } + expect(Legion::JSON).to receive(:dump).with(meta).and_return('{"env":"prod"}') + result = described_class.record( + worker_id: 'w1', + metric_name: 'cpu', + metric_type: :gauge, + value: 0.8, + metadata: meta + ) + expect(result[:metadata]).to eq('{"env":"prod"}') + end + + it 'defaults metadata to empty hash when not provided' do + expect(Legion::JSON).to receive(:dump).with({}).and_return('{}') + described_class.record(worker_id: 'w1', metric_name: 'mem', metric_type: :gauge, value: 1.0) + end + + it 'inserts into the database when Legion::Data is available and table exists' do + dataset = double('dataset') + connection = double('connection') + allow(connection).to receive(:table_exists?).with(:value_metrics).and_return(true) + allow(connection).to receive(:[]).with(:value_metrics).and_return(dataset) + allow(dataset).to receive(:insert) + + stub_const('Legion::Data', double(connection: connection)) + + described_class.record(worker_id: 'w1', metric_name: 'tasks', metric_type: :counter, value: 1) + + expect(dataset).to have_received(:insert) + end + + it 'skips DB insert when table does not exist' do + connection = double('connection') + allow(connection).to receive(:table_exists?).with(:value_metrics).and_return(false) + stub_const('Legion::Data', double(connection: connection)) + + expect(connection).not_to receive(:[]) + described_class.record(worker_id: 'w1', metric_name: 'tasks', metric_type: :counter, value: 1) + end + + it 'logs a debug message' do + expect(Legion::Logging).to receive(:debug).with(/worker=w1.*tasks_run.*counter/) + described_class.record(worker_id: 'w1', metric_name: 'tasks_run', metric_type: :counter, value: 7) + end + end + + describe '.for_worker' do + context 'when Legion::Data is not available' do + it 'returns an empty array' do + hide_const('Legion::Data') + expect(described_class.for_worker(worker_id: 'w1')).to eq([]) + end + end + + context 'when the value_metrics table does not exist' do + it 'returns an empty array' do + connection = double('connection') + allow(connection).to receive(:table_exists?).with(:value_metrics).and_return(false) + stub_const('Legion::Data', double(connection: connection)) + + expect(described_class.for_worker(worker_id: 'w1')).to eq([]) + end + end + + context 'when Legion::Data is available and table exists' do + let(:rows) { [{ worker_id: 'w1', metric_name: 'cpu', value: 0.5 }] } + let(:dataset) { double('dataset') } + let(:connection) { double('connection') } + + before do + allow(connection).to receive(:table_exists?).with(:value_metrics).and_return(true) + allow(connection).to receive(:[]).with(:value_metrics).and_return(dataset) + allow(dataset).to receive(:where).and_return(dataset) + allow(dataset).to receive(:order).and_return(dataset) + allow(dataset).to receive(:all).and_return(rows) + stub_const('Legion::Data', double(connection: connection)) + end + + it 'returns all rows for the worker' do + result = described_class.for_worker(worker_id: 'w1') + expect(result).to eq(rows) + end + + it 'filters by metric_name when provided' do + expect(dataset).to receive(:where).with(worker_id: 'w1').and_return(dataset) + expect(dataset).to receive(:where).with(metric_name: 'cpu').and_return(dataset) + described_class.for_worker(worker_id: 'w1', metric_name: :cpu) + end + + it 'filters by since when provided' do + cutoff = Time.now.utc - 3600 + expect(dataset).to receive(:where).with(worker_id: 'w1').and_return(dataset) + expect(dataset).to receive(:where).and_return(dataset) + described_class.for_worker(worker_id: 'w1', since: cutoff) + end + end + end + + describe '.summary' do + context 'when Legion::Data is not available' do + it 'returns an empty hash' do + hide_const('Legion::Data') + expect(described_class.summary(worker_id: 'w1')).to eq({}) + end + end + + context 'when the value_metrics table does not exist' do + it 'returns an empty hash' do + connection = double('connection') + allow(connection).to receive(:table_exists?).with(:value_metrics).and_return(false) + stub_const('Legion::Data', double(connection: connection)) + + expect(described_class.summary(worker_id: 'w1')).to eq({}) + end + end + + context 'when Legion::Data is available and table exists' do + let(:connection) { double('connection') } + let(:ds) { double('dataset') } + let(:subset) { double('subset') } + + before do + allow(connection).to receive(:table_exists?).with(:value_metrics).and_return(true) + allow(connection).to receive(:[]).with(:value_metrics).and_return(ds) + allow(ds).to receive(:where).with(worker_id: 'w1').and_return(ds) + allow(ds).to receive(:select).and_return(ds) + allow(ds).to receive(:distinct).and_return(ds) + allow(ds).to receive(:select_map).with(:metric_name).and_return(['tasks_run']) + allow(ds).to receive(:where).with(metric_name: 'tasks_run').and_return(subset) + allow(subset).to receive(:count).and_return(5) + allow(subset).to receive(:sum).with(:value).and_return(50.0) + allow(subset).to receive(:avg).with(:value).and_return(10.0) + allow(subset).to receive(:min).with(:value).and_return(8.0) + allow(subset).to receive(:max).with(:value).and_return(12.0) + allow(subset).to receive(:order).and_return(subset) + allow(subset).to receive(:first).and_return({ value: 12.0 }) + stub_const('Legion::Data', double(connection: connection)) + end + + it 'returns a hash keyed by metric name' do + result = described_class.summary(worker_id: 'w1') + expect(result).to have_key('tasks_run') + end + + it 'includes count, sum, avg, min, max, and latest' do + result = described_class.summary(worker_id: 'w1') + stat = result['tasks_run'] + expect(stat[:count]).to eq(5) + expect(stat[:sum]).to eq(50.0) + expect(stat[:avg]).to eq(10.0) + expect(stat[:min]).to eq(8.0) + expect(stat[:max]).to eq(12.0) + expect(stat[:latest]).to eq(12.0) + end + + it 'returns empty hash when worker has no metrics' do + allow(ds).to receive(:select_map).with(:metric_name).and_return([]) + result = described_class.summary(worker_id: 'w1') + expect(result).to eq({}) + end + end + end +end diff --git a/spec/legion/dispatch/local_spec.rb b/spec/legion/dispatch/local_spec.rb new file mode 100644 index 00000000..38bde1e2 --- /dev/null +++ b/spec/legion/dispatch/local_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/dispatch' +require 'legion/dispatch/local' + +RSpec.describe Legion::Dispatch::Local do + subject(:dispatcher) { described_class.new(pool_size: 2) } + + after { dispatcher.stop } + + describe '#initialize' do + it 'creates a dispatcher with the given pool size' do + expect(dispatcher.capacity[:pool_size]).to eq(2) + end + + it 'defaults pool_size from settings when not provided' do + allow(Legion::Settings).to receive(:dig).with(:dispatch, :local_pool_size).and_return(4) + d = described_class.new + expect(d.capacity[:pool_size]).to eq(4) + d.stop + end + + it 'falls back to 8 when settings are nil' do + allow(Legion::Settings).to receive(:dig).with(:dispatch, :local_pool_size).and_return(nil) + d = described_class.new + expect(d.capacity[:pool_size]).to eq(8) + d.stop + end + end + + describe '#submit' do + it 'executes the block on the thread pool' do + result = Concurrent::IVar.new + dispatcher.submit { result.set(:done) } + expect(result.value(5)).to eq(:done) + end + + it 'logs errors without crashing the pool' do + dispatcher.submit { raise 'test explosion' } + result = Concurrent::IVar.new + dispatcher.submit { result.set(:still_alive) } + expect(result.value(5)).to eq(:still_alive) + end + end + + describe '#stop' do + it 'shuts down the thread pool' do + dispatcher.stop + expect(dispatcher.capacity[:running]).to be false + end + + it 'is idempotent' do + dispatcher.stop + expect { dispatcher.stop }.not_to raise_error + end + end + + describe '#capacity' do + it 'returns pool_size and queue_length' do + cap = dispatcher.capacity + expect(cap).to have_key(:pool_size) + expect(cap).to have_key(:queue_length) + expect(cap).to have_key(:running) + end + end +end diff --git a/spec/legion/dispatch_spec.rb b/spec/legion/dispatch_spec.rb new file mode 100644 index 00000000..cfddd4fb --- /dev/null +++ b/spec/legion/dispatch_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/dispatch' + +RSpec.describe Legion::Dispatch do + describe '.dispatcher' do + before { described_class.reset! } + + after { described_class.shutdown } + + it 'returns a Local dispatcher by default' do + expect(described_class.dispatcher).to be_a(Legion::Dispatch::Local) + end + + it 'memoizes the dispatcher instance' do + expect(described_class.dispatcher).to be(described_class.dispatcher) + end + end + + describe '.submit' do + before { described_class.reset! } + + after { described_class.shutdown } + + it 'delegates to the dispatcher' do + result = Concurrent::IVar.new + described_class.submit { result.set(:dispatched) } + expect(result.value(5)).to eq(:dispatched) + end + end + + describe '.shutdown' do + before { described_class.reset! } + + it 'stops the dispatcher' do + described_class.dispatcher # ensure initialized + described_class.shutdown + expect(described_class.dispatcher.capacity[:running]).to be false + end + end + + describe '.reset!' do + it 'clears the memoized dispatcher' do + described_class.reset! + d1 = described_class.dispatcher + described_class.shutdown + described_class.reset! + d2 = described_class.dispatcher + expect(d1).not_to be(d2) + described_class.shutdown + end + end +end diff --git a/spec/legion/docs/site_generator_spec.rb b/spec/legion/docs/site_generator_spec.rb new file mode 100644 index 00000000..fd772d1b --- /dev/null +++ b/spec/legion/docs/site_generator_spec.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/docs/site_generator' + +RSpec.describe Legion::Docs::SiteGenerator do + subject(:generator) { described_class.new(output_dir: tmpdir) } + + let(:tmpdir) { Dir.mktmpdir } + + after { FileUtils.rm_rf(tmpdir) } + + # --------------------------------------------------------------------------- + # #generate — orchestration + # --------------------------------------------------------------------------- + + describe '#generate' do + it 'creates the output directory when it does not exist' do + subdir = File.join(tmpdir, 'nested', 'site') + gen = described_class.new(output_dir: subdir) + gen.generate + expect(Dir.exist?(subdir)).to be true + end + + it 'returns a hash with :output equal to the output_dir' do + result = generator.generate + expect(result[:output]).to eq(tmpdir) + end + + it 'returns :sections equal to the number of GUIDE_SOURCES entries' do + result = generator.generate + expect(result[:sections]).to eq(described_class::GUIDE_SOURCES.size) + end + + it 'returns :pages as a positive integer' do + result = generator.generate + expect(result[:pages]).to be > 0 + end + + it 'returns :files as an array of absolute paths' do + result = generator.generate + expect(result[:files]).to all(be_a(String)) + expect(result[:files]).to all(start_with('/')) + end + + it 'writes index.html to the output directory' do + generator.generate + expect(File.exist?(File.join(tmpdir, 'index.html'))).to be true + end + + it 'writes cli-reference.html to the output directory' do + generator.generate + expect(File.exist?(File.join(tmpdir, 'cli-reference.html'))).to be true + end + + it 'writes extensions.html to the output directory' do + generator.generate + expect(File.exist?(File.join(tmpdir, 'extensions.html'))).to be true + end + end + + # --------------------------------------------------------------------------- + # SECTIONS / GUIDE_SOURCES backwards compat + # --------------------------------------------------------------------------- + + describe 'SECTIONS constant' do + it 'is equal to GUIDE_SOURCES for backwards compatibility' do + expect(described_class::SECTIONS).to eq(described_class::GUIDE_SOURCES) + end + + it 'has 5 entries' do + expect(described_class::GUIDE_SOURCES.size).to eq(5) + end + + it 'each entry has :source, :title, and :section keys' do + described_class::GUIDE_SOURCES.each do |entry| + expect(entry).to include(:source, :title, :section) + end + end + end + + # --------------------------------------------------------------------------- + # Guide page generation + # --------------------------------------------------------------------------- + + describe 'guide pages' do + it 'generates an HTML file for each guide source' do + generator.generate + described_class::GUIDE_SOURCES.each do |entry| + slug = File.basename(entry[:source], '.md').downcase.tr('_', '-') + expect(File.exist?(File.join(tmpdir, "#{slug}.html"))).to be true + end + end + + it 'uses a placeholder when the source file does not exist' do + generator.generate + described_class::GUIDE_SOURCES.each do |entry| + slug = File.basename(entry[:source], '.md').downcase.tr('_', '-') + content = File.read(File.join(tmpdir, "#{slug}.html")) + # Either rendered content from real file, or the fallback placeholder + expect(content).not_to be_empty + end + end + end + + # --------------------------------------------------------------------------- + # Markdown rendering (private, tested through public output) + # --------------------------------------------------------------------------- + + describe 'markdown rendering' do + it 'converts headings to tags in guide output' do + # Write a temp guide source to test conversion + guide_path = File.join(tmpdir, 'src') + FileUtils.mkdir_p(guide_path) + md_file = File.join(guide_path, 'getting-started.md') + File.write(md_file, "# Hello World\n\nSome content.\n") + + gen = described_class.new(output_dir: File.join(tmpdir, 'out')) + + # Directly test render_markdown via the rendered index output + html = gen.send(:render_markdown, "# Hello World\n\nSome content.\n") + expect(html).to include('Body

', nav: 'Nav') + end + + it 'wraps content in a valid HTML5 document' do + expect(template_output).to include('') + expect(template_output).to include('') + end + + it 'includes the page title in the tag' do + expect(template_output).to include('<title>My Page') + end + + it 'injects the body content' do + expect(template_output).to include('<p>Body</p>') + end + + it 'injects the nav content' do + expect(template_output).to include('<a href="#">Nav</a>') + end + + it 'includes an h1 with the title' do + expect(template_output).to include('<h1>My Page</h1>') + end + + it 'escapes HTML special characters in title' do + out = generator.send(:html_template, title: '<script>alert(1)</script>', body: '', nav: '') + expect(out).to include('<script>') + expect(out).not_to include('<script>alert') + end + end + + # --------------------------------------------------------------------------- + # Index page + # --------------------------------------------------------------------------- + + describe 'index page' do + it 'creates index.html with links to all pages' do + generator.generate + index = File.read(File.join(tmpdir, 'index.html')) + expect(index).to include('.html') + expect(index).to include('LegionIO') + end + + it 'groups pages by section' do + generator.generate + index = File.read(File.join(tmpdir, 'index.html')) + # Guides and reference sections both appear + expect(index.downcase).to include('guides') + expect(index.downcase).to include('reference') + end + end + + # --------------------------------------------------------------------------- + # CLI reference generation (with mocked introspection) + # --------------------------------------------------------------------------- + + describe 'CLI reference generation' do + context 'when Legion::CLI::Main is not defined' do + it 'falls back to a "unavailable" message' do + hide_const('Legion::CLI::Main') if defined?(Legion::CLI::Main) + generator.generate + cli_html = File.read(File.join(tmpdir, 'cli-reference.html')) + expect(cli_html).to include('unavailable').or include('CLI Reference') + end + end + + context 'when Legion::CLI::Main is available' do + before do + fake_cmd = double('cmd', description: 'Do a thing') + fake_main = double('Main') + allow(fake_main).to receive(:all_commands).and_return({ 'start' => fake_cmd }) + stub_const('Legion::CLI::Main', fake_main) + end + + it 'includes introspected command names' do + generator.generate + cli_html = File.read(File.join(tmpdir, 'cli-reference.html')) + expect(cli_html).to include('legion start') + end + + it 'includes command descriptions' do + generator.generate + cli_html = File.read(File.join(tmpdir, 'cli-reference.html')) + expect(cli_html).to include('Do a thing') + end + end + end + + # --------------------------------------------------------------------------- + # Extension reference generation + # --------------------------------------------------------------------------- + + describe 'extension reference generation' do + context 'when no lex- gems are installed' do + before do + fake_loader = double('BundlerLoader') + allow(fake_loader).to receive(:specs).and_return([]) + allow(Bundler).to receive(:load).and_return(fake_loader) + end + + it 'still creates the extensions.html page' do + generator.generate + expect(File.exist?(File.join(tmpdir, 'extensions.html'))).to be true + end + + it 'shows a "no extensions" message' do + generator.generate + ext_html = File.read(File.join(tmpdir, 'extensions.html')) + expect(ext_html).to include('No extensions').or include('Extensions') + end + end + + context 'when lex- gems are available' do + let(:fake_spec) do + double('Gem::Specification', name: 'lex-http', version: double(to_s: '0.2.0')) + end + + before do + fake_loader = double('BundlerLoader') + allow(fake_loader).to receive(:specs).and_return([fake_spec]) + allow(Bundler).to receive(:load).and_return(fake_loader) + end + + it 'lists the discovered extension' do + generator.generate + ext_html = File.read(File.join(tmpdir, 'extensions.html')) + expect(ext_html).to include('lex-http') + expect(ext_html).to include('0.2.0') + end + end + end +end diff --git a/spec/legion/extensions/absorbers/base_spec.rb b/spec/legion/extensions/absorbers/base_spec.rb new file mode 100644 index 00000000..d73e35c0 --- /dev/null +++ b/spec/legion/extensions/absorbers/base_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/auth/token_manager' +require 'legion/extensions/absorbers/matchers/base' +require 'legion/extensions/absorbers/matchers/url' +require 'legion/extensions/absorbers/base' + +RSpec.describe Legion::Extensions::Absorbers::Base do + let(:test_absorber) do + Class.new(described_class) do + pattern :url, 'example.com/docs/*' + pattern :url, 'example.com/files/*', priority: 50 + description 'Test absorber for specs' + + def absorb(url: nil, content: nil, **) + { absorbed: true, url: url, content: content } + end + end + end + + describe '.pattern' do + it 'registers patterns on the class' do + expect(test_absorber.patterns.length).to eq(2) + end + + it 'stores type, value, and priority' do + pat = test_absorber.patterns.first + expect(pat[:type]).to eq(:url) + expect(pat[:value]).to eq('example.com/docs/*') + expect(pat[:priority]).to eq(100) + end + + it 'allows custom priority' do + pat = test_absorber.patterns.last + expect(pat[:priority]).to eq(50) + end + end + + describe '.description' do + it 'stores description text' do + expect(test_absorber.description).to eq('Test absorber for specs') + end + end + + describe '.patterns' do + it 'returns empty array when no patterns defined' do + bare = Class.new(described_class) + expect(bare.patterns).to eq([]) + end + end + + describe '#absorb' do + it 'raises NotImplementedError on base class' do + expect { described_class.new.absorb }.to raise_error(NotImplementedError) + end + + it 'accepts url keyword' do + result = test_absorber.new.absorb(url: 'https://example.com/docs/a') + expect(result[:url]).to eq('https://example.com/docs/a') + end + + it 'accepts content keyword' do + result = test_absorber.new.absorb(content: 'raw text') + expect(result[:content]).to eq('raw text') + end + end + + describe '#handle (deprecated)' do + it 'delegates to #absorb and returns its result' do + result = test_absorber.new.handle(url: 'https://example.com/docs/a') + expect(result[:url]).to eq('https://example.com/docs/a') + end + + it 'accepts content keyword' do + result = test_absorber.new.handle(content: 'raw text') + expect(result[:content]).to eq('raw text') + end + end + + describe '#absorb_to_knowledge' do + it 'responds to absorb_to_knowledge' do + expect(test_absorber.new).to respond_to(:absorb_to_knowledge) + end + end + + describe '#absorb_raw' do + it 'responds to absorb_raw' do + expect(test_absorber.new).to respond_to(:absorb_raw) + end + end + + describe '#translate' do + it 'raises when legion-data not available' do + absorber = test_absorber.new + expect { absorber.translate('file.pdf') }.to raise_error(RuntimeError, /legion-data/) unless defined?(Legion::Data::Extract) + end + end + + describe '#report_progress' do + it 'responds to report_progress' do + expect(test_absorber.new).to respond_to(:report_progress) + end + + it 'does not error without job_id' do + expect { test_absorber.new.report_progress(message: 'test') }.not_to raise_error + end + end + + describe 'attr_accessors' do + it 'has job_id accessor' do + absorber = test_absorber.new + absorber.job_id = 'abc123' + expect(absorber.job_id).to eq('abc123') + end + + it 'has runners accessor' do + absorber = test_absorber.new + absorber.runners = double('runners') + expect(absorber.runners).not_to be_nil + end + end + + describe 'error constants' do + it 'defines TokenRevocationError' do + expect(described_class::TokenRevocationError.ancestors).to include(StandardError) + end + + it 'defines TokenUnavailableError' do + expect(described_class::TokenUnavailableError.ancestors).to include(StandardError) + end + end + + describe '#with_token' do + let(:absorber) { test_absorber.new } + let(:mock_manager) { instance_double(Legion::Auth::TokenManager) } + + before do + allow(absorber).to receive(:token_manager_for).and_return(mock_manager) + end + + it 'yields the token when valid' do + allow(mock_manager).to receive(:token_valid?).and_return(true) + allow(mock_manager).to receive(:revoked?).and_return(false) + allow(mock_manager).to receive(:ensure_valid_token).and_return('valid-token') + + result = nil + absorber.with_token(provider: :microsoft) { |t| result = t } + expect(result).to eq('valid-token') + end + + it 'raises TokenUnavailableError when no valid token' do + allow(mock_manager).to receive(:token_valid?).and_return(false) + expect { absorber.with_token(provider: :microsoft) { nil } }.to raise_error(described_class::TokenUnavailableError) + end + + it 'raises TokenRevocationError when token is revoked' do + allow(mock_manager).to receive(:token_valid?).and_return(true) + allow(mock_manager).to receive(:revoked?).and_return(true) + expect { absorber.with_token(provider: :microsoft) { nil } }.to raise_error(described_class::TokenRevocationError) + end + + it 'raises TokenUnavailableError when refresh returns nil' do + allow(mock_manager).to receive(:token_valid?).and_return(true) + allow(mock_manager).to receive(:revoked?).and_return(false) + allow(mock_manager).to receive(:ensure_valid_token).and_return(nil) + expect { absorber.with_token(provider: :microsoft) { nil } }.to raise_error(described_class::TokenUnavailableError) + end + + it 'wraps TokenExpiredError as TokenUnavailableError' do + allow(mock_manager).to receive(:token_valid?).and_return(true) + allow(mock_manager).to receive(:revoked?).and_return(false) + allow(mock_manager).to receive(:ensure_valid_token).and_raise(Legion::Auth::TokenManager::TokenExpiredError, 'expired') + expect { absorber.with_token(provider: :microsoft) { nil } }.to raise_error(described_class::TokenUnavailableError, 'expired') + end + end +end diff --git a/spec/legion/extensions/absorbers/dispatch_spec.rb b/spec/legion/extensions/absorbers/dispatch_spec.rb new file mode 100644 index 00000000..618cbb00 --- /dev/null +++ b/spec/legion/extensions/absorbers/dispatch_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/absorbers/dispatch' + +RSpec.describe Legion::Extensions::Absorbers::Dispatch do + before { described_class.reset_dispatched! if described_class.respond_to?(:reset_dispatched!) } + + describe '.dispatch' do + let(:absorber_class) do + Class.new(Legion::Extensions::Absorbers::Base) do + pattern :url, 'example.com/*' + def self.name = 'TestAbsorber' + end + end + + before do + allow(Legion::Extensions::Absorbers::PatternMatcher).to receive(:resolve).and_return(absorber_class) + end + + it 'resolves input to an absorber and returns dispatch metadata' do + result = described_class.dispatch('https://example.com/item/123', context: { conversation_id: 'conv-1' }) + expect(result[:absorb_id]).to be_a(String) + expect(result[:absorber_class]).to eq('TestAbsorber') + expect(result[:status]).to eq(:dispatched) + end + + it 'returns nil when no absorber matches' do + allow(Legion::Extensions::Absorbers::PatternMatcher).to receive(:resolve).and_return(nil) + result = described_class.dispatch('https://unknown.com/foo') + expect(result).to be_nil + end + + it 'respects max_depth and rejects over-depth requests' do + result = described_class.dispatch('https://example.com/item/123', + context: { depth: 5, max_depth: 5 }) + expect(result[:status]).to eq(:depth_exceeded) + end + + it 'detects cycles via ancestor_chain' do + result = described_class.dispatch('https://example.com/item/123', + context: { ancestor_chain: ['absorb:example.com/item/123'] }) + expect(result[:status]).to eq(:cycle_detected) + end + end + + describe '.dispatch_children' do + it 'dispatches each child with incremented depth' do + children = [{ url: 'https://example.com/a' }, { url: 'https://example.com/b' }] + allow(described_class).to receive(:dispatch).and_call_original + allow(Legion::Extensions::Absorbers::PatternMatcher).to receive(:resolve).and_return(nil) + + results = described_class.dispatch_children(children, + parent_context: { depth: 0, max_depth: 5, ancestor_chain: [] }) + expect(results.size).to eq(2) + end + end +end diff --git a/spec/legion/extensions/absorbers/matchers/base_spec.rb b/spec/legion/extensions/absorbers/matchers/base_spec.rb new file mode 100644 index 00000000..ba7fe835 --- /dev/null +++ b/spec/legion/extensions/absorbers/matchers/base_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/absorbers/matchers/base' + +module Legion + module Extensions + module Absorbers + module Matchers + class TestMatcherAuto < Base + def self.type = :test_matcher_auto + def self.match?(_pattern, _input) = true + end + end + end + end +end + +RSpec.describe Legion::Extensions::Absorbers::Matchers::Base do + describe '.registry' do + it 'returns a hash' do + expect(described_class.registry).to be_a(Hash) + end + end + + describe '.for_type' do + it 'returns nil for unknown types' do + expect(described_class.for_type(:nonexistent)).to be_nil + end + end + + describe '.type' do + it 'returns nil on base class' do + expect(described_class.type).to be_nil + end + end + + describe 'auto-registration via inherited' do + it 'registers subclasses that define a type' do + expect(described_class.for_type(:test_matcher_auto)).to eq( + Legion::Extensions::Absorbers::Matchers::TestMatcherAuto + ) + end + end +end diff --git a/spec/legion/extensions/absorbers/matchers/file_spec.rb b/spec/legion/extensions/absorbers/matchers/file_spec.rb new file mode 100644 index 00000000..15e060b9 --- /dev/null +++ b/spec/legion/extensions/absorbers/matchers/file_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/absorbers/matchers/file' + +RSpec.describe Legion::Extensions::Absorbers::Matchers::File do + describe '.match?' do + it 'matches exact file extensions' do + expect(described_class.match?('**/*.pdf', '/home/user/doc.pdf')).to be true + end + + it 'matches nested paths' do + expect(described_class.match?('**/*.docx', '/a/b/c/report.docx')).to be true + end + + it 'rejects non-matching patterns' do + expect(described_class.match?('**/*.pdf', '/home/user/doc.txt')).to be false + end + + it 'matches absolute path patterns' do + expect(described_class.match?('/home/user/docs/**/*', '/home/user/docs/report.pdf')).to be true + end + end + + describe '.type' do + it 'returns :file' do + expect(described_class.type).to eq(:file) + end + end +end diff --git a/spec/legion/extensions/absorbers/matchers/url_spec.rb b/spec/legion/extensions/absorbers/matchers/url_spec.rb new file mode 100644 index 00000000..1d1066ed --- /dev/null +++ b/spec/legion/extensions/absorbers/matchers/url_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/absorbers/matchers/base' +require 'legion/extensions/absorbers/matchers/url' + +RSpec.describe Legion::Extensions::Absorbers::Matchers::Url do + describe '.type' do + it 'returns :url' do + expect(described_class.type).to eq(:url) + end + end + + describe '.match?' do + it 'matches exact host and wildcard path' do + expect(described_class.match?( + 'teams.microsoft.com/l/meetup-join/*', + 'https://teams.microsoft.com/l/meetup-join/abc123' + )).to be true + end + + it 'matches wildcard subdomain' do + expect(described_class.match?( + '*.sharepoint.com/sites/*/Documents/*', + 'https://contoso.sharepoint.com/sites/team/Documents/report.docx' + )).to be true + end + + it 'rejects non-matching hosts' do + expect(described_class.match?( + 'teams.microsoft.com/l/meetup-join/*', + 'https://zoom.us/j/123456' + )).to be false + end + + it 'rejects non-matching paths' do + expect(described_class.match?( + 'teams.microsoft.com/l/meetup-join/*', + 'https://teams.microsoft.com/l/channel/abc' + )).to be false + end + + it 'handles URLs without scheme' do + expect(described_class.match?( + 'teams.microsoft.com/l/meetup-join/*', + 'teams.microsoft.com/l/meetup-join/abc123' + )).to be true + end + + it 'returns false for non-URL input' do + expect(described_class.match?( + 'teams.microsoft.com/*', + 'this is not a url' + )).to be false + end + + it 'matches double-star glob for deep paths' do + expect(described_class.match?( + 'github.com/**/issues/*', + 'https://github.com/LegionIO/LegionIO/issues/42' + )).to be true + end + end + + describe '.registered?' do + it 'is registered in the matcher registry' do + expect(Legion::Extensions::Absorbers::Matchers::Base.for_type(:url)).to eq(described_class) + end + end +end diff --git a/spec/legion/extensions/absorbers/pattern_matcher_spec.rb b/spec/legion/extensions/absorbers/pattern_matcher_spec.rb new file mode 100644 index 00000000..c3c40dff --- /dev/null +++ b/spec/legion/extensions/absorbers/pattern_matcher_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/absorbers' + +RSpec.describe Legion::Extensions::Absorbers::PatternMatcher do + let(:teams_absorber) do + Class.new(Legion::Extensions::Absorbers::Base) do + pattern :url, 'teams.microsoft.com/l/meetup-join/*' + description 'Teams meeting absorber' + def handle(**) = { handler: :teams } + end + end + + let(:github_absorber) do + Class.new(Legion::Extensions::Absorbers::Base) do + pattern :url, 'github.com/**/issues/*' + description 'GitHub issue absorber' + def handle(**) = { handler: :github } + end + end + + before do + described_class.reset! + described_class.register(teams_absorber) + described_class.register(github_absorber) + end + + after { described_class.reset! } + + describe '.register' do + it 'adds absorber patterns to the registry' do + expect(described_class.registrations.length).to eq(2) + end + end + + describe '.resolve' do + it 'returns the matching absorber class for a Teams URL' do + result = described_class.resolve('https://teams.microsoft.com/l/meetup-join/abc123') + expect(result).to eq(teams_absorber) + end + + it 'returns the matching absorber class for a GitHub URL' do + result = described_class.resolve('https://github.com/LegionIO/LegionIO/issues/42') + expect(result).to eq(github_absorber) + end + + it 'returns nil when no pattern matches' do + expect(described_class.resolve('https://zoom.us/j/123')).to be_nil + end + end + + describe '.resolve priority' do + let(:high_priority) do + Class.new(Legion::Extensions::Absorbers::Base) do + pattern :url, 'teams.microsoft.com/l/meetup-join/*', priority: 10 + def handle(**) = { handler: :high } + end + end + + it 'returns the higher-priority (lower number) absorber' do + described_class.register(high_priority) + result = described_class.resolve('https://teams.microsoft.com/l/meetup-join/abc') + expect(result).to eq(high_priority) + end + end + + describe '.list' do + it 'returns all registered patterns with their absorber classes' do + list = described_class.list + expect(list.length).to eq(2) + expect(list.first).to include(:type, :value, :absorber_class, :description) + end + end + + describe '.reset!' do + it 'clears all registrations' do + described_class.reset! + expect(described_class.registrations).to be_empty + end + end +end diff --git a/spec/legion/extensions/absorbers/transport_spec.rb b/spec/legion/extensions/absorbers/transport_spec.rb new file mode 100644 index 00000000..2b82ba3c --- /dev/null +++ b/spec/legion/extensions/absorbers/transport_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/absorbers/transport' + +RSpec.describe Legion::Extensions::Absorbers::Transport do + describe '.build_message' do + let(:record) do + { + absorb_id: 'absorb:test-123', + input: 'https://example.com/item/1', + context: { depth: 0, max_depth: 5, ancestor_chain: [], conversation_id: 'conv-1' }, + metadata: {} + } + end + + it 'builds a message with correct exchange and routing key' do + msg = described_class.build_message( + lex_name: 'example', + absorber_name: 'content', + record: record + ) + expect(msg[:exchange]).to eq('lex.example') + expect(msg[:routing_key]).to eq('lex.example.absorbers.content.absorb') + expect(msg[:payload][:type]).to eq('absorb.request') + expect(msg[:payload][:absorb_id]).to eq('absorb:test-123') + end + + it 'sets url field for http inputs' do + msg = described_class.build_message( + lex_name: 'example', absorber_name: 'content', record: record + ) + expect(msg[:payload][:url]).to eq('https://example.com/item/1') + expect(msg[:payload][:file_path]).to be_nil + end + + it 'sets file_path field for non-http inputs' do + file_record = record.merge(input: '/home/user/doc.pdf') + msg = described_class.build_message( + lex_name: 'example', absorber_name: 'content', record: file_record + ) + expect(msg[:payload][:file_path]).to eq('/home/user/doc.pdf') + expect(msg[:payload][:url]).to be_nil + end + end + + describe '.lex_name_from_absorber_class' do + it 'extracts lex_name from a Legion::Extensions namespace' do + klass = double(name: 'Legion::Extensions::MicrosoftTeams::Absorbers::Meeting') + expect(described_class.lex_name_from_absorber_class(klass)).to eq('microsoft_teams') + end + + it 'extracts lex_name from a Lex namespace' do + klass = double(name: 'Lex::Example::Absorbers::Content') + expect(described_class.lex_name_from_absorber_class(klass)).to eq('example') + end + end + + describe '.absorber_name_from_class' do + it 'returns snake_case class name' do + klass = double(name: 'Legion::Extensions::MicrosoftTeams::Absorbers::Meeting') + expect(described_class.absorber_name_from_class(klass)).to eq('meeting') + end + end +end diff --git a/spec/legion/extensions/actors/absorber_dispatch_spec.rb b/spec/legion/extensions/actors/absorber_dispatch_spec.rb new file mode 100644 index 00000000..a0b851d3 --- /dev/null +++ b/spec/legion/extensions/actors/absorber_dispatch_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/absorbers' +require 'legion/extensions/actors/absorber_dispatch' + +RSpec.describe Legion::Extensions::Actors::AbsorberDispatch do + let(:test_absorber) do + Class.new(Legion::Extensions::Absorbers::Base) do + pattern :url, 'example.com/test/*' + description 'Test absorber' + def self.name = 'TestDispatchAbsorber' + + def absorb(url: nil, **_opts) + { success: true, url: url } + end + end + end + + before do + Legion::Extensions::Absorbers::PatternMatcher.reset! + Legion::Extensions::Absorbers::PatternMatcher.register(test_absorber) + end + + after { Legion::Extensions::Absorbers::PatternMatcher.reset! } + + describe '.dispatch' do + it 'resolves input and calls the matching absorber' do + result = described_class.dispatch( + input: 'https://example.com/test/doc1', + job_id: 'test-123' + ) + expect(result[:success]).to be true + expect(result[:absorber]).to include('TestDispatchAbsorber') + expect(result[:job_id]).to eq('test-123') + end + + it 'returns the absorber result' do + result = described_class.dispatch( + input: 'https://example.com/test/doc1', + job_id: 'test-124' + ) + expect(result[:result][:url]).to eq('https://example.com/test/doc1') + end + + it 'generates a job_id when not provided' do + result = described_class.dispatch(input: 'https://example.com/test/doc1') + expect(result[:job_id]).not_to be_nil + expect(result[:job_id].length).to eq(16) + end + + it 'returns failure when no absorber matches' do + result = described_class.dispatch( + input: 'https://unknown.com/page', + job_id: 'test-456' + ) + expect(result[:success]).to be false + expect(result[:error]).to include('no handler') + end + + it 'returns failure when absorber raises' do + error_absorber = Class.new(Legion::Extensions::Absorbers::Base) do + pattern :url, 'error.com/*' + def self.name = 'ErrorAbsorber' + def absorb(**) = raise('boom') + end + Legion::Extensions::Absorbers::PatternMatcher.register(error_absorber) + + result = described_class.dispatch( + input: 'https://error.com/test', + job_id: 'test-789' + ) + expect(result[:success]).to be false + expect(result[:error]).to include('boom') + end + + it 'passes context content to the absorber' do + content_absorber = Class.new(Legion::Extensions::Absorbers::Base) do + pattern :url, 'content.com/*' + def self.name = 'ContentAbsorber' + + def absorb(content: nil, **_opts) + { received_content: content } + end + end + Legion::Extensions::Absorbers::PatternMatcher.register(content_absorber) + + result = described_class.dispatch( + input: 'https://content.com/doc', + job_id: 'test-content', + context: { content: 'pre-fetched data' } + ) + expect(result[:success]).to be true + expect(result[:result][:received_content]).to eq('pre-fetched data') + end + end +end diff --git a/spec/legion/extensions/actors/dsl_spec.rb b/spec/legion/extensions/actors/dsl_spec.rb new file mode 100644 index 00000000..97f2629a --- /dev/null +++ b/spec/legion/extensions/actors/dsl_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/actors/dsl' + +RSpec.describe Legion::Extensions::Actors::Dsl do + let(:base_class) do + Class.new do + extend Legion::Extensions::Actors::Dsl + + define_dsl_accessor :time, default: 9 + define_dsl_accessor :run_now, default: true + define_dsl_accessor :enabled, default: true + end + end + + it 'returns default when not set' do + expect(base_class.time).to eq(9) + end + + it 'sets and returns a value' do + child = Class.new(base_class) { time 30 } + expect(child.time).to eq(30) + end + + it 'does not affect parent class' do + child = Class.new(base_class) { time 30 } + expect(base_class.time).to eq(9) + expect(child.time).to eq(30) + end + + it 'works as instance method too (reads class value)' do + child = Class.new(base_class) { time 30 } + instance = child.new + expect(instance.time).to eq(30) + end + + it 'allows boolean accessors' do + child = Class.new(base_class) { run_now false } + expect(child.run_now).to be false + end +end diff --git a/spec/legion/extensions/actors/every_fingerprint_spec.rb b/spec/legion/extensions/actors/every_fingerprint_spec.rb new file mode 100644 index 00000000..17280001 --- /dev/null +++ b/spec/legion/extensions/actors/every_fingerprint_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'spec_helper' + +unless defined?(Legion::Logging) + module Legion + module Logging + def self.debug(_msg); end + def self.info(_msg); end + def self.warn(_msg); end + def self.error(_msg); end + end + end +end + +unless defined?(Legion::Extensions::Helpers::Lex) + module Legion + module Extensions + module Helpers + module Lex + def lex_name = 'test' + def runner_class = Object + def runner_function = 'run' + def runner_name = 'test' + end + end + end + end +end + +unless defined?(Concurrent::TimerTask) + module Concurrent + class TimerTask + def initialize(**_opts, &); end + def execute; end + def shutdown; end + + def respond_to?(_method, *) = true + end + end +end + +require 'legion/extensions/actors/fingerprint' +require 'legion/extensions/actors/base' +require 'legion/extensions/actors/every' + +RSpec.describe Legion::Extensions::Actors::Every do + describe '#skip_if_unchanged?' do + it 'defaults to false' do + actor = described_class.new + expect(actor.skip_if_unchanged?).to be false + end + end + + describe 'subclass with skip_if_unchanged enabled' do + let(:actor_class) do + Class.new(Legion::Extensions::Actors::Every) do + def skip_if_unchanged? = true + def time = 30 + end + end + + it 'responds to skip_or_run' do + actor = actor_class.new + expect(actor).to respond_to(:skip_or_run) + end + + it 'skips second run when fingerprint is stable' do + actor = actor_class.new + allow(actor).to receive(:fingerprint_source).and_return('stable') + runs = 0 + actor.skip_or_run { runs += 1 } + actor.skip_or_run { runs += 1 } + expect(runs).to eq(1) + end + + it 'runs again when fingerprint changes' do + actor = actor_class.new + sources = %w[v1 v2] + idx = 0 + allow(actor).to receive(:fingerprint_source) { sources[idx] } + runs = 0 + 2.times do + actor.skip_or_run { runs += 1 } + idx += 1 + end + expect(runs).to eq(2) + end + end +end diff --git a/spec/legion/extensions/actors/fingerprint_spec.rb b/spec/legion/extensions/actors/fingerprint_spec.rb new file mode 100644 index 00000000..bf745867 --- /dev/null +++ b/spec/legion/extensions/actors/fingerprint_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'digest' + +unless defined?(Legion::Logging) + module Legion + module Logging + def self.debug(_msg); end + def self.info(_msg); end + def self.warn(_msg); end + def self.error(_msg); end + end + end +end + +require 'legion/extensions/actors/fingerprint' + +RSpec.describe Legion::Extensions::Actors::Fingerprint do + let(:host) do + obj = Object.new + obj.extend(described_class) + obj + end + + describe '#skip_if_unchanged?' do + it 'returns false by default' do + expect(host.skip_if_unchanged?).to be false + end + end + + describe '#fingerprint_source' do + it 'returns a non-nil string by default' do + expect(host.fingerprint_source).to be_a(String) + expect(host.fingerprint_source).not_to be_empty + end + end + + describe '#compute_fingerprint' do + it 'returns a 64-char hex string' do + fp = host.compute_fingerprint + expect(fp).to match(/\A[0-9a-f]{64}\z/) + end + + it 'produces the same value for the same source within the same interval bucket' do + source = 'stable-content' + allow(host).to receive(:fingerprint_source).and_return(source) + expect(host.compute_fingerprint).to eq(host.compute_fingerprint) + end + end + + describe '#unchanged?' do + it 'returns false when @last_fingerprint is nil (first run)' do + expect(host.unchanged?).to be false + end + + it 'returns true after the fingerprint is stored and source is stable' do + allow(host).to receive(:fingerprint_source).and_return('fixed-content') + host.store_fingerprint! + expect(host.unchanged?).to be true + end + + it 'returns false when the fingerprint changes' do + call_count = 0 + allow(host).to receive(:fingerprint_source) do + call_count += 1 + call_count == 1 ? 'content-a' : 'content-b' + end + host.store_fingerprint! + expect(host.unchanged?).to be false + end + end + + describe '#store_fingerprint!' do + it 'sets @last_fingerprint to current fingerprint' do + allow(host).to receive(:fingerprint_source).and_return('my-content') + host.store_fingerprint! + expect(host.instance_variable_get(:@last_fingerprint)).to eq(Digest::SHA256.hexdigest('my-content')) + end + end + + describe '#skip_or_run' do + context 'when skip_if_unchanged? is false' do + it 'always yields' do + allow(host).to receive(:skip_if_unchanged?).and_return(false) + allow(host).to receive(:fingerprint_source).and_return('content') + called = false + host.skip_or_run { called = true } + expect(called).to be true + end + end + + context 'when skip_if_unchanged? is true and content is unchanged' do + it 'does not yield after first run' do + allow(host).to receive(:skip_if_unchanged?).and_return(true) + allow(host).to receive(:fingerprint_source).and_return('stable') + call_count = 0 + host.skip_or_run { call_count += 1 } + host.skip_or_run { call_count += 1 } + expect(call_count).to eq(1) + end + end + + context 'when skip_if_unchanged? is true and content changes' do + it 'yields on each change' do + allow(host).to receive(:skip_if_unchanged?).and_return(true) + sources = %w[content-a content-b content-b content-c] + call_index = 0 + allow(host).to receive(:fingerprint_source) do + sources[call_index] + end + results = [] + 4.times do + host.skip_or_run { results << sources[call_index] } + call_index += 1 + end + expect(results.size).to eq(3) + end + end + end +end diff --git a/spec/legion/extensions/actors/poll_fingerprint_spec.rb b/spec/legion/extensions/actors/poll_fingerprint_spec.rb new file mode 100644 index 00000000..e5d2be33 --- /dev/null +++ b/spec/legion/extensions/actors/poll_fingerprint_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'spec_helper' + +unless defined?(Legion::Logging) + module Legion + module Logging + def self.debug(_msg); end + def self.info(_msg); end + def self.warn(_msg); end + def self.error(_msg); end + def self.fatal(_msg); end + end + end +end + +unless defined?(Legion::Extensions::Helpers::Lex) + module Legion + module Extensions + module Helpers + module Lex + def lex_name = 'test' + def runner_class = Object + def runner_function = 'run' + def runner_name = 'test' + end + end + end + end +end + +unless defined?(Concurrent::TimerTask) + module Concurrent + class TimerTask + def initialize(**_opts, &); end + def execute; end + def shutdown; end + + def respond_to?(_method, *) = true + end + end +end + +require 'legion/extensions/actors/fingerprint' +require 'legion/extensions/actors/base' +require 'legion/extensions/actors/poll' + +RSpec.describe Legion::Extensions::Actors::Poll do + describe '#skip_if_unchanged?' do + it 'defaults to false' do + actor = described_class.new + expect(actor.skip_if_unchanged?).to be false + end + end + + describe 'subclass with skip_if_unchanged enabled' do + let(:actor_class) do + Class.new(Legion::Extensions::Actors::Poll) do + def skip_if_unchanged? = true + def time = 30 + end + end + + it 'responds to skip_or_run' do + actor = actor_class.new + expect(actor).to respond_to(:skip_or_run) + end + + it 'skips second run when fingerprint is stable' do + actor = actor_class.new + allow(actor).to receive(:fingerprint_source).and_return('stable') + runs = 0 + actor.skip_or_run { runs += 1 } + actor.skip_or_run { runs += 1 } + expect(runs).to eq(1) + end + + it 'runs again when fingerprint changes' do + actor = actor_class.new + sources = %w[v1 v2] + idx = 0 + allow(actor).to receive(:fingerprint_source) { sources[idx] } + runs = 0 + 2.times do + actor.skip_or_run { runs += 1 } + idx += 1 + end + expect(runs).to eq(2) + end + end +end diff --git a/spec/legion/extensions/actors/retry_policy_spec.rb b/spec/legion/extensions/actors/retry_policy_spec.rb new file mode 100644 index 00000000..f1dda77f --- /dev/null +++ b/spec/legion/extensions/actors/retry_policy_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Load just the module we are testing +require 'legion/extensions/actors/retry_policy' + +RSpec.describe Legion::Extensions::Actors::RetryPolicy do + describe '.should_retry?' do + context 'with default threshold of 2' do + it 'returns true when retry count is 0' do + expect(described_class.should_retry?(retry_count: 0, threshold: 2)).to be true + end + + it 'returns true when retry count is 1' do + expect(described_class.should_retry?(retry_count: 1, threshold: 2)).to be true + end + + it 'returns false when retry count equals threshold' do + expect(described_class.should_retry?(retry_count: 2, threshold: 2)).to be false + end + + it 'returns false when retry count exceeds threshold' do + expect(described_class.should_retry?(retry_count: 5, threshold: 2)).to be false + end + end + + context 'with threshold of 0 (no retries)' do + it 'returns false immediately' do + expect(described_class.should_retry?(retry_count: 0, threshold: 0)).to be false + end + end + + context 'with nil threshold (unlimited retries)' do + it 'always returns true' do + expect(described_class.should_retry?(retry_count: 100, threshold: nil)).to be true + end + end + end + + describe '.extract_retry_count' do + it 'returns 0 when no headers present' do + expect(described_class.extract_retry_count(nil)).to eq(0) + end + + it 'returns 0 when x-retry-count header is missing' do + expect(described_class.extract_retry_count({})).to eq(0) + end + + it 'reads x-retry-count from headers' do + headers = { 'x-retry-count' => 3 } + expect(described_class.extract_retry_count(headers)).to eq(3) + end + + it 'handles symbol keys' do + headers = { 'x-retry-count': 2 } + expect(described_class.extract_retry_count(headers)).to eq(2) + end + end + + describe '.retry_threshold' do + before do + allow(Legion::Settings).to receive(:dig).with(:fleet, :poison_message_threshold).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:transport, :retry_threshold).and_return(nil) + end + + it 'returns 2 as the default' do + expect(described_class.retry_threshold).to eq(2) + end + + it 'reads from fleet settings when available' do + allow(Legion::Settings).to receive(:dig).with(:fleet, :poison_message_threshold).and_return(5) + expect(described_class.retry_threshold).to eq(5) + end + + it 'reads from transport settings as fallback' do + allow(Legion::Settings).to receive(:dig).with(:transport, :retry_threshold).and_return(3) + expect(described_class.retry_threshold).to eq(3) + end + end +end diff --git a/spec/legion/extensions/actors/singleton_spec.rb b/spec/legion/extensions/actors/singleton_spec.rb new file mode 100644 index 00000000..5ebb3367 --- /dev/null +++ b/spec/legion/extensions/actors/singleton_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/lock' +require 'legion/cluster/lock' +require 'legion/extensions/actors/singleton' + +module TestExt + module Actors + class Cleanup + def initialize(**_opts); end + def time = 10 + + include Legion::Extensions::Actors::Singleton + + private + + def skip_or_run + yield + end + end + end +end + +RSpec.describe Legion::Extensions::Actors::Singleton do + let(:actor) { TestExt::Actors::Cleanup.new } + + before do + allow(Legion::Lock).to receive(:acquire).and_return('tok-123') + allow(Legion::Lock).to receive(:extend_lock).and_return(true) + allow(Legion::Lock).to receive(:release).and_return(true) + allow(Legion::Settings).to receive(:[]).with(:cluster).and_return({ singleton_enabled: true }) + allow(Legion::Cluster::Lock).to receive(:acquire).and_return('cluster-tok-123') + allow(Legion::Cluster::Lock).to receive(:extend_lock).and_return(true) + end + + describe '#singleton_role' do + it 'derives role from class name' do + expect(actor.singleton_role).to eq('testext_actors_cleanup') + end + end + + describe '#singleton_ttl' do + it 'returns at least 30 seconds' do + expect(actor.singleton_ttl).to be >= 30 + end + + it 'returns 3x the interval when interval is large' do + allow(actor).to receive(:time).and_return(60) + expect(actor.singleton_ttl).to eq(180) + end + end + + describe 'ExecutionGuard#skip_or_run' do + context 'when singleton_enabled is false (default)' do + before do + allow(Legion::Settings).to receive(:[]).with(:cluster).and_return({ singleton_enabled: false }) + end + + it 'passes through without acquiring any lock' do + executed = false + actor.send(:skip_or_run) { executed = true } + expect(executed).to be true + expect(Legion::Cluster::Lock).not_to have_received(:acquire) + expect(Legion::Lock).not_to have_received(:acquire) + end + end + + context 'when Legion::Settings is not defined' do + it 'falls through without acquiring any lock' do + hide_const('Legion::Settings') + executed = false + actor.send(:skip_or_run) { executed = true } + expect(executed).to be true + end + end + + context 'when singleton_enabled is true and Cluster::Lock is available' do + it 'uses Cluster::Lock instead of Legion::Lock' do + actor.send(:skip_or_run) { nil } + expect(Legion::Cluster::Lock).to have_received(:acquire) + expect(Legion::Lock).not_to have_received(:acquire) + end + + it 'extends via Cluster::Lock on subsequent ticks' do + actor.send(:skip_or_run) { nil } + actor.send(:skip_or_run) { nil } + expect(Legion::Cluster::Lock).to have_received(:extend_lock).at_least(:once) + end + + it 'skips execution when Cluster::Lock cannot be acquired' do + allow(Legion::Cluster::Lock).to receive(:acquire).and_return(nil) + executed = false + actor.send(:skip_or_run) { executed = true } + expect(executed).to be false + end + + it 'executes the block when lock is held' do + executed = false + actor.send(:skip_or_run) { executed = true } + expect(executed).to be true + end + + it 're-acquires via Cluster::Lock when extend fails' do + actor.send(:skip_or_run) { nil } + allow(Legion::Cluster::Lock).to receive(:extend_lock).and_return(false) + allow(Legion::Cluster::Lock).to receive(:acquire).and_return('cluster-tok-456') + actor.send(:skip_or_run) { nil } + expect(Legion::Cluster::Lock).to have_received(:acquire).at_least(:twice) + end + end + + context 'when singleton_enabled is true and Cluster::Lock is not available' do + before do + hide_const('Legion::Cluster::Lock') + end + + it 'falls back to Legion::Lock' do + actor.send(:skip_or_run) { nil } + expect(Legion::Lock).to have_received(:acquire) + end + + it 'extends via Legion::Lock on subsequent ticks' do + actor.send(:skip_or_run) { nil } + actor.send(:skip_or_run) { nil } + expect(Legion::Lock).to have_received(:extend_lock).at_least(:once) + end + + it 'skips execution when Legion::Lock cannot be acquired' do + allow(Legion::Lock).to receive(:acquire).and_return(nil) + executed = false + actor.send(:skip_or_run) { executed = true } + expect(executed).to be false + end + + it 're-acquires when extend fails' do + actor.send(:skip_or_run) { nil } + allow(Legion::Lock).to receive(:extend_lock).and_return(false) + allow(Legion::Lock).to receive(:acquire).and_return('tok-456') + actor.send(:skip_or_run) { nil } + expect(Legion::Lock).to have_received(:acquire).at_least(:twice) + end + + it 'falls through when neither lock is defined' do + hide_const('Legion::Lock') + executed = false + actor.send(:skip_or_run) { executed = true } + expect(executed).to be true + end + end + end +end diff --git a/spec/legion/extensions/actors/subscription_activate_spec.rb b/spec/legion/extensions/actors/subscription_activate_spec.rb new file mode 100644 index 00000000..cd164d85 --- /dev/null +++ b/spec/legion/extensions/actors/subscription_activate_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Legion::Extensions::Actors::Subscription#activate' do + let(:actor) { Legion::Extensions::Actors::Subscription.allocate } + let(:channel) { double('channel') } + let(:queue_double) { double('queue', channel: channel) } + let(:consumer_double) { double('consumer') } + + before do + actor.instance_variable_set(:@queue, queue_double) + actor.instance_variable_set(:@consumer, consumer_double) + allow(actor).to receive(:lex_name).and_return('test_lex') + allow(actor).to receive(:runner_name).and_return('test_runner') + allow(actor).to receive(:log).and_return(double('log', warn: nil, info: nil, error: nil, debug: nil)) + end + + context 'when no consumer exists' do + before { actor.instance_variable_set(:@consumer, nil) } + + it 'warns and returns without subscribing' do + expect(queue_double).not_to receive(:subscribe_with) + actor.activate + end + end + + context 'when the channel is open' do + before { allow(channel).to receive(:open?).and_return(true) } + + it 'subscribes directly without re-preparing' do + expect(actor).not_to receive(:prepare) + expect(queue_double).to receive(:subscribe_with).with(consumer_double) + actor.activate + end + end + + context 'when the channel is closed' do + let(:fresh_channel) { double('fresh_channel') } + let(:fresh_queue) { double('fresh_queue', channel: fresh_channel) } + let(:fresh_consumer) { double('fresh_consumer') } + + before do + allow(channel).to receive(:open?).and_return(false) + end + + it 'calls prepare and retries subscribe on fresh channel' do + allow(fresh_channel).to receive(:open?).and_return(true) + allow(actor).to receive(:prepare) do + actor.instance_variable_set(:@queue, fresh_queue) + actor.instance_variable_set(:@consumer, fresh_consumer) + end + expect(fresh_queue).to receive(:subscribe_with).with(fresh_consumer) + actor.activate + end + + it 'logs and skips subscribe when re-prepare leaves channel closed' do + allow(actor).to receive(:prepare) do + actor.instance_variable_set(:@queue, fresh_queue) + actor.instance_variable_set(:@consumer, fresh_consumer) + end + allow(fresh_channel).to receive(:open?).and_return(false) + + expect(fresh_queue).not_to receive(:subscribe_with) + actor.activate + end + + it 'logs and skips subscribe when re-prepare leaves no consumer' do + allow(actor).to receive(:prepare) do + actor.instance_variable_set(:@queue, fresh_queue) + actor.instance_variable_set(:@consumer, nil) + end + allow(fresh_channel).to receive(:open?).and_return(true) + + expect(fresh_queue).not_to receive(:subscribe_with) + actor.activate + end + end +end diff --git a/spec/legion/extensions/actors/subscription_encryption_spec.rb b/spec/legion/extensions/actors/subscription_encryption_spec.rb new file mode 100644 index 00000000..ef2d5780 --- /dev/null +++ b/spec/legion/extensions/actors/subscription_encryption_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Actors::Subscription do + let(:actor) { described_class.allocate } + let(:delivery_info) { { routing_key: 'lex.test.runner' } } + + before do + allow(actor).to receive(:lex_name).and_return('test') + allow(actor).to receive(:runner_name).and_return('runner') + end + + describe '#process_message encrypted/cs handling' do + it 'decrypts with a string-keyed iv header' do + metadata = metadata_for(headers: { 'iv' => 'string-iv' }) + + expect(Legion::Crypt).to receive(:decrypt).with('ciphertext', 'string-iv').and_return('{"ok":true}') + + message = actor.process_message('ciphertext', metadata, delivery_info) + + expect(message).to include(ok: true, iv: 'string-iv', routing_key: 'lex.test.runner') + end + + it 'decrypts with a symbol-keyed iv header' do + metadata = metadata_for(headers: { iv: 'symbol-iv' }) + + expect(Legion::Crypt).to receive(:decrypt).with('ciphertext', 'symbol-iv').and_return('{"ok":true}') + + message = actor.process_message('ciphertext', metadata, delivery_info) + + expect(message).to include(ok: true, iv: 'symbol-iv', routing_key: 'lex.test.runner') + end + + it 'dead-letters encrypted messages that are missing an iv before decrypting' do + metadata = metadata_for(headers: {}) + + expect(Legion::Crypt).not_to receive(:decrypt) + + expect do + actor.process_message('ciphertext', metadata, delivery_info) + end.to raise_error( + Legion::Extensions::Actors::UnrecoverableMessageError, + 'encrypted/cs message missing iv header (test/runner)' + ) + end + + it 'does not decrypt identity encoded messages' do + metadata = metadata_for(content_encoding: 'identity', headers: { iv: 'ignored' }) + + expect(Legion::Crypt).not_to receive(:decrypt) + + message = actor.process_message('{"ok":true}', metadata, delivery_info) + + expect(message).to include(ok: true, iv: 'ignored', routing_key: 'lex.test.runner') + end + end + + def metadata_for(content_encoding: 'encrypted/cs', content_type: 'application/json', headers: {}) + instance_double( + Bunny::MessageProperties, + content_encoding: content_encoding, + content_type: content_type, + headers: headers + ) + end +end diff --git a/spec/legion/extensions/actors/subscription_open_inference_spec.rb b/spec/legion/extensions/actors/subscription_open_inference_spec.rb new file mode 100644 index 00000000..55043dd4 --- /dev/null +++ b/spec/legion/extensions/actors/subscription_open_inference_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Legion::Extensions::Actors::Subscription OpenInference' do + let(:actor) { Legion::Extensions::Actors::Subscription.allocate } + + before do + stub_const('Legion::Telemetry::OpenInference', Module.new do + def self.open_inference_enabled? + true + end + + def self.chain_span(**) + yield(nil) + end + end) + end + + describe '#dispatch_with_chain_span' do + it 'wraps runner dispatch in chain_span' do + expect(Legion::Telemetry::OpenInference).to receive(:chain_span) + .with(hash_including(type: 'task_chain')) + .and_yield(nil) + + allow(Legion::Runner).to receive(:run).and_return({ success: true }) + + actor.send(:dispatch_runner, { test: true }, 'TestRunner', 'func', true, true) + end + + it 'works without OpenInference' do + hide_const('Legion::Telemetry::OpenInference') + allow(Legion::Runner).to receive(:run).and_return({ success: true }) + + result = actor.send(:dispatch_runner, { test: true }, 'TestRunner', 'func', true, true) + expect(result[:success]).to be true + end + end +end diff --git a/spec/legion/extensions/actors/subscription_region_spec.rb b/spec/legion/extensions/actors/subscription_region_spec.rb new file mode 100644 index 00000000..31b6eb89 --- /dev/null +++ b/spec/legion/extensions/actors/subscription_region_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Legion::Extensions::Actors::Subscription region affinity' do + let(:actor) { Legion::Extensions::Actors::Subscription.allocate } + + describe '#check_region_affinity' do + context 'when Legion::Region is not defined' do + before { hide_const('Legion::Region') } + + it 'returns :local regardless of message contents' do + expect(actor.send(:check_region_affinity, { region: 'us-east-2', region_affinity: 'require_local' })).to eq(:local) + end + end + + context 'when Legion::Region is defined' do + before do + stub_const('Legion::Region', Module.new do + module_function + + def affinity_for(message_region, affinity) + return :local if message_region.nil? || message_region == current || affinity == 'any' + return :remote if affinity == 'prefer_local' + return :reject if affinity == 'require_local' + + :local + end + + def current + 'us-east-1' + end + end) + end + + it 'returns :local when message has no region header' do + expect(actor.send(:check_region_affinity, {})).to eq(:local) + end + + it 'returns :local when message region matches current region' do + expect(actor.send(:check_region_affinity, { region: 'us-east-1' })).to eq(:local) + end + + it 'returns :local when affinity is any regardless of region' do + expect(actor.send(:check_region_affinity, { region: 'eu-west-1', region_affinity: 'any' })).to eq(:local) + end + + it 'returns :remote when region differs and affinity is prefer_local' do + expect(actor.send(:check_region_affinity, { region: 'eu-west-1', region_affinity: 'prefer_local' })).to eq(:remote) + end + + it 'returns :reject when region differs and affinity is require_local' do + expect(actor.send(:check_region_affinity, { region: 'eu-west-1', region_affinity: 'require_local' })).to eq(:reject) + end + end + end + + describe 'subscribe block region affinity enforcement' do + let(:delivery_info) { double('delivery_info', delivery_tag: 'tag-1', :[] => nil) } + let(:metadata) do + double('metadata', + content_encoding: nil, + content_type: 'application/json', + headers: nil) + end + let(:queue_double) { double('queue') } + + before do + stub_const('Legion::Region', Module.new do + module_function + + def affinity_for(message_region, affinity) + return :local if message_region.nil? || message_region == current || affinity == 'any' + return :remote if affinity == 'prefer_local' + return :reject if affinity == 'require_local' + + :local + end + + def current + 'us-east-1' + end + end) + + allow(Legion::JSON).to receive(:load).and_return({}) + allow(Legion::Logging).to receive(:warn) + allow(Legion::Logging).to receive(:debug) + + allow(actor).to receive(:manual_ack).and_return(true) + allow(actor).to receive(:use_runner?).and_return(false) + allow(actor).to receive(:runner_class).and_return(double('runner_class')) + allow(actor).to receive(:find_function).and_return(:process) + allow(actor).to receive(:process_message).and_return({ function: :process }) + allow(actor).to receive(:instance_variable_get).with(:@queue).and_return(queue_double) + actor.instance_variable_set(:@queue, queue_double) + end + + context 'when affinity result is :reject' do + it 'returns :reject for a different region with require_local affinity' do + result = actor.send(:check_region_affinity, { region: 'eu-west-1', region_affinity: 'require_local' }) + expect(result).to eq(:reject) + end + end + + context 'when affinity result is :remote' do + it 'logs a debug message and continues processing' do + result = actor.send(:check_region_affinity, { region: 'eu-west-1', region_affinity: 'prefer_local' }) + expect(result).to eq(:remote) + end + end + + context 'when affinity result is :local' do + it 'processes normally without extra logging' do + result = actor.send(:check_region_affinity, { region: 'us-east-1' }) + expect(result).to eq(:local) + end + end + end +end diff --git a/spec/legion/extensions/actors/subscription_retry_integration_spec.rb b/spec/legion/extensions/actors/subscription_retry_integration_spec.rb new file mode 100644 index 00000000..c2d3ae8f --- /dev/null +++ b/spec/legion/extensions/actors/subscription_retry_integration_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/actors/retry_policy' + +RSpec.describe 'Subscription retry integration' do + describe 'message lifecycle with threshold=2' do + it 'allows 2 retries then dead-letters' do + threshold = 2 + headers = {} + + # First failure: retry_count=0, should retry + count = Legion::Extensions::Actors::RetryPolicy.extract_retry_count(headers) + expect(count).to eq(0) + expect(Legion::Extensions::Actors::RetryPolicy.should_retry?(retry_count: count, threshold: threshold)).to be true + + # After republish: retry_count=1, should retry + headers = { 'x-retry-count' => 1 } + count = Legion::Extensions::Actors::RetryPolicy.extract_retry_count(headers) + expect(count).to eq(1) + expect(Legion::Extensions::Actors::RetryPolicy.should_retry?(retry_count: count, threshold: threshold)).to be true + + # After second republish: retry_count=2, should dead-letter + headers = { 'x-retry-count' => 2 } + count = Legion::Extensions::Actors::RetryPolicy.extract_retry_count(headers) + expect(count).to eq(2) + expect(Legion::Extensions::Actors::RetryPolicy.should_retry?(retry_count: count, threshold: threshold)).to be false + end + end + + describe 'configurable threshold' do + it 'respects custom threshold from settings' do + allow(Legion::Settings).to receive(:dig).with(:fleet, :poison_message_threshold).and_return(5) + allow(Legion::Settings).to receive(:dig).with(:transport, :retry_threshold).and_return(nil) + + expect(Legion::Extensions::Actors::RetryPolicy.retry_threshold).to eq(5) + + headers = { 'x-retry-count' => 4 } + count = Legion::Extensions::Actors::RetryPolicy.extract_retry_count(headers) + expect(Legion::Extensions::Actors::RetryPolicy.should_retry?(retry_count: count, threshold: 5)).to be true + + headers = { 'x-retry-count' => 5 } + count = Legion::Extensions::Actors::RetryPolicy.extract_retry_count(headers) + expect(Legion::Extensions::Actors::RetryPolicy.should_retry?(retry_count: count, threshold: 5)).to be false + end + end +end diff --git a/spec/legion/extensions/actors/subscription_retry_spec.rb b/spec/legion/extensions/actors/subscription_retry_spec.rb new file mode 100644 index 00000000..9e9f739c --- /dev/null +++ b/spec/legion/extensions/actors/subscription_retry_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/actors/retry_policy' + +RSpec.describe 'Subscription retry behavior' do + let(:queue) { double('queue') } + let(:delivery_info) { double('delivery_info', delivery_tag: 'tag-1') } + + describe 'reject_or_retry logic' do + # Test the decision logic extracted into a helper method + # that the subscription actor will call + + it 'requeues when under threshold' do + headers = { 'x-retry-count' => 0 } + retry_count = Legion::Extensions::Actors::RetryPolicy.extract_retry_count(headers) + threshold = 2 + + should_retry = Legion::Extensions::Actors::RetryPolicy.should_retry?( + retry_count: retry_count, threshold: threshold + ) + + expect(should_retry).to be true + # In the actor: queue.reject(tag, requeue: true) with incremented header + end + + it 'dead-letters when at threshold' do + headers = { 'x-retry-count' => 2 } + retry_count = Legion::Extensions::Actors::RetryPolicy.extract_retry_count(headers) + threshold = 2 + + should_retry = Legion::Extensions::Actors::RetryPolicy.should_retry?( + retry_count: retry_count, threshold: threshold + ) + + expect(should_retry).to be false + # In the actor: queue.reject(tag, requeue: false) -> DLX + end + end +end diff --git a/spec/legion/extensions/builders/absorbers_spec.rb b/spec/legion/extensions/builders/absorbers_spec.rb new file mode 100644 index 00000000..1edd7488 --- /dev/null +++ b/spec/legion/extensions/builders/absorbers_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Builders::Absorbers' do + let(:builder_module) { Legion::Extensions::Builder::Absorbers } + + it 'is defined' do + expect(builder_module).to be_a(Module) + end + + describe '#build_absorbers' do + it 'responds to build_absorbers when included' do + dummy = Module.new { extend Legion::Extensions::Builder::Absorbers } + expect(dummy).to respond_to(:build_absorbers) + end + end + + describe '#absorbers' do + it 'returns empty hash by default' do + dummy = Module.new { extend Legion::Extensions::Builder::Absorbers } + expect(dummy.absorbers).to eq({}) + end + end +end diff --git a/spec/legion/extensions/builders/skills_spec.rb b/spec/legion/extensions/builders/skills_spec.rb new file mode 100644 index 00000000..7e60ebd2 --- /dev/null +++ b/spec/legion/extensions/builders/skills_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/builders/skills' + +RSpec.describe Legion::Extensions::Builder::Skills do + let(:extension_module) do + mod = Module.new + mod.extend(described_class) + allow(mod).to receive(:lex_class).and_return(mod) + allow(mod).to receive(:find_files).with('skills').and_return([]) + allow(mod).to receive(:require_files) + mod + end + + describe '#build_skills' do + context 'when legion-llm is not loaded' do + it 'returns nil without error' do + hide_const('Legion::LLM::Skills') + expect { extension_module.build_skills }.not_to raise_error + end + end + + context 'when skills directory is empty' do + it 'registers nothing' do + llm_mod = Module.new do + def self.started? = true + + def self.settings = { skills: { enabled: true } } + end + stub_const('Legion::LLM', llm_mod) + stub_const('Legion::LLM::Skills', Module.new) + allow(extension_module).to receive(:find_files).with('skills').and_return([]) + extension_module.build_skills + expect(extension_module.instance_variable_get(:@skills)).to eq({}) + end + end + end +end diff --git a/spec/legion/extensions/capability_absorber_spec.rb b/spec/legion/extensions/capability_absorber_spec.rb new file mode 100644 index 00000000..43eaebc7 --- /dev/null +++ b/spec/legion/extensions/capability_absorber_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/capability' + +RSpec.describe Legion::Extensions::Capability do + describe '.from_absorber' do + let(:absorber_class) do + Class.new(Legion::Extensions::Absorbers::Base) do + pattern :url, 'example.com/docs/*' + description 'Test absorber' + def self.name = 'TestAbsorber' + def handle(**) = { success: true } + end + end + + it 'creates a capability from an absorber class' do + cap = described_class.from_absorber( + extension: 'lex-example', + absorber: absorber_class, + patterns: absorber_class.patterns, + description: absorber_class.description + ) + expect(cap.name).to include('absorber') + expect(cap.extension).to eq('lex-example') + expect(cap.description).to eq('Test absorber') + expect(cap.tags).to include('absorber') + end + + it 'includes pattern info in tags' do + cap = described_class.from_absorber( + extension: 'lex-example', + absorber: absorber_class, + patterns: absorber_class.patterns + ) + expect(cap.tags.any? { |t| t.include?('pattern:url:') }).to be true + end + end +end diff --git a/spec/legion/extensions/capability_spec.rb b/spec/legion/extensions/capability_spec.rb new file mode 100644 index 00000000..a54afe60 --- /dev/null +++ b/spec/legion/extensions/capability_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/capability' + +RSpec.describe Legion::Extensions::Capability do + describe '.from_runner' do + it 'creates a capability from runner metadata' do + cap = described_class.from_runner( + extension: 'lex-github', + runner: 'PullRequest', + function: 'close', + description: 'Close a pull request', + parameters: { pr_id: { type: :integer, required: true } }, + tags: %w[github pr write] + ) + + expect(cap.name).to eq('lex-github:pull_request:close') + expect(cap.extension).to eq('lex-github') + expect(cap.runner).to eq('PullRequest') + expect(cap.function).to eq('close') + expect(cap.description).to eq('Close a pull request') + expect(cap.tags).to eq(%w[github pr write]) + expect(cap.frozen?).to eq(true) + end + + it 'generates canonical name from extension:runner:function' do + cap = described_class.from_runner( + extension: 'lex-http', runner: 'Request', function: 'get' + ) + expect(cap.name).to eq('lex-http:request:get') + end + end + + describe '#matches_intent?' do + it 'matches on keyword overlap' do + cap = described_class.from_runner( + extension: 'lex-github', runner: 'PullRequest', function: 'close', + description: 'Close a GitHub pull request', + tags: %w[github pr close] + ) + + expect(cap.matches_intent?('close pull request')).to eq(true) + expect(cap.matches_intent?('create jira ticket')).to eq(false) + end + end + + describe '#to_mcp_tool' do + it 'converts to MCP tool definition hash' do + cap = described_class.from_runner( + extension: 'lex-github', runner: 'PullRequest', function: 'close', + description: 'Close a pull request', + parameters: { pr_id: { type: 'integer', description: 'PR number' } } + ) + + tool = cap.to_mcp_tool + expect(tool[:name]).to eq('legion.github.pull_request.close') + expect(tool[:description]).to eq('Close a pull request') + expect(tool[:input_schema]).to have_key(:properties) + end + end +end diff --git a/spec/legion/extensions/catalog/registry_spec.rb b/spec/legion/extensions/catalog/registry_spec.rb new file mode 100644 index 00000000..44b43136 --- /dev/null +++ b/spec/legion/extensions/catalog/registry_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Catalog::Registry do + before { described_class.reset! } + + describe '.register' do + it 'registers a capability' do + cap = Legion::Extensions::Capability.from_runner( + extension: 'lex-github', runner: 'PullRequest', function: 'close', + description: 'Close a PR', tags: %w[github pr] + ) + described_class.register(cap) + expect(described_class.capabilities).to include(cap) + end + + it 'prevents duplicates by name' do + cap = Legion::Extensions::Capability.from_runner( + extension: 'lex-github', runner: 'PullRequest', function: 'close' + ) + described_class.register(cap) + described_class.register(cap) + expect(described_class.capabilities.count { |c| c.name == cap.name }).to eq(1) + end + end + + describe '.find' do + it 'finds by canonical name' do + cap = Legion::Extensions::Capability.from_runner( + extension: 'lex-github', runner: 'PullRequest', function: 'close' + ) + described_class.register(cap) + found = described_class.find(name: cap.name) + expect(found).to eq(cap) + end + + it 'returns nil for unknown' do + expect(described_class.find(name: 'nonexistent')).to be_nil + end + end + + describe '.find_by_intent' do + it 'returns capabilities matching intent text' do + cap1 = Legion::Extensions::Capability.from_runner( + extension: 'lex-github', runner: 'PullRequest', function: 'close', + description: 'Close a pull request', tags: %w[github pr close] + ) + cap2 = Legion::Extensions::Capability.from_runner( + extension: 'lex-jira', runner: 'Issue', function: 'create', + description: 'Create a Jira issue', tags: %w[jira issue create] + ) + described_class.register(cap1) + described_class.register(cap2) + + results = described_class.find_by_intent('close pull request') + expect(results.map(&:name)).to include(cap1.name) + expect(results.map(&:name)).not_to include(cap2.name) + end + end + + describe '.for_mcp' do + it 'returns all capabilities as MCP-exposable tools' do + cap = Legion::Extensions::Capability.from_runner( + extension: 'lex-github', runner: 'PullRequest', function: 'close', + description: 'Close a PR' + ) + described_class.register(cap) + mcp_tools = described_class.for_mcp + expect(mcp_tools.length).to eq(1) + expect(mcp_tools.first).to eq(cap) + end + end + + describe '.for_override' do + it 'finds capability that can override an MCP tool' do + cap = Legion::Extensions::Capability.from_runner( + extension: 'lex-github', runner: 'PullRequest', function: 'close', + tags: %w[github pr close] + ) + described_class.register(cap) + + override = described_class.for_override('close') + expect(override).to eq(cap) + end + + it 'returns nil when no match' do + expect(described_class.for_override('nonexistent')).to be_nil + end + end + + describe '.count' do + it 'returns the number of registered capabilities' do + expect(described_class.count).to eq(0) + cap = Legion::Extensions::Capability.from_runner( + extension: 'lex-http', runner: 'Request', function: 'get' + ) + described_class.register(cap) + expect(described_class.count).to eq(1) + end + end +end diff --git a/spec/legion/extensions/catalog_available_spec.rb b/spec/legion/extensions/catalog_available_spec.rb new file mode 100644 index 00000000..e1142e73 --- /dev/null +++ b/spec/legion/extensions/catalog_available_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Catalog::Available do + describe '.find' do + it 'includes the Legion-native LLM hosted provider extensions' do + expect(described_class.find('lex-llm-bedrock')).to include( + name: 'lex-llm-bedrock', + category: 'ai' + ) + expect(described_class.find('lex-llm-azure-foundry')).to include( + name: 'lex-llm-azure-foundry', + category: 'ai' + ) + expect(described_class.find('lex-llm-vertex')).to include( + name: 'lex-llm-vertex', + category: 'ai' + ) + end + + it 'does not advertise lex-llm-gateway' do + expect(described_class.find('lex-llm-gateway')).to be_nil + end + + it 'does not advertise deprecated direct provider extensions' do + %w[ + lex-azure-ai + lex-bedrock + lex-claude + lex-foundry + lex-gemini + lex-ollama + lex-openai + ].each do |deprecated| + expect(described_class.find(deprecated)).to be_nil, "expected #{deprecated} to be removed from catalog" + end + end + end +end diff --git a/spec/legion/extensions/catalog_population_spec.rb b/spec/legion/extensions/catalog_population_spec.rb new file mode 100644 index 00000000..fc6fe19f --- /dev/null +++ b/spec/legion/extensions/catalog_population_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Catalog population at boot' do + describe '.register_capabilities' do + it 'is a no-op (replaced by Tools::Discovery)' do + runners = { + pull_request: { + extension: 'legion::extensions::github', + extension_name: 'github', + runner_name: 'pull_request', + runner_class: 'Legion::Extensions::Github::Runners::PullRequest', + class_methods: { + close: { args: [%i[keyreq pr_id]] } + } + } + } + + expect { Legion::Extensions.register_capabilities('lex-github', runners) }.not_to raise_error + end + end +end diff --git a/spec/legion/extensions/catalog_spec.rb b/spec/legion/extensions/catalog_spec.rb new file mode 100644 index 00000000..b84dc9ff --- /dev/null +++ b/spec/legion/extensions/catalog_spec.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Catalog do + before do + described_class.reset! + allow(Legion::Logging).to receive(:warn) + end + + describe '.register' do + it 'registers an extension with default state :registered' do + described_class.register('lex-detect') + expect(described_class.state('lex-detect')).to eq(:registered) + end + + it 'accepts a custom initial state' do + described_class.register('lex-detect', state: :loaded) + expect(described_class.state('lex-detect')).to eq(:loaded) + end + + it 'does not overwrite an existing entry' do + described_class.register('lex-detect', state: :loaded) + described_class.register('lex-detect', state: :registered) + expect(described_class.state('lex-detect')).to eq(:loaded) + end + end + + describe '.transition' do + before { described_class.register('lex-detect') } + + it 'transitions to a valid next state' do + described_class.transition('lex-detect', :loaded) + expect(described_class.state('lex-detect')).to eq(:loaded) + end + + it 'updates started_at on transition to :running' do + described_class.transition('lex-detect', :loaded) + described_class.transition('lex-detect', :starting) + described_class.transition('lex-detect', :running) + entry = described_class.entry('lex-detect') + expect(entry[:started_at]).to be_a(Time) + end + + it 'publishes to transport when available' do + allow(described_class).to receive(:publish_transition) + described_class.transition('lex-detect', :loaded) + expect(described_class).to have_received(:publish_transition).with('lex-detect', :loaded) + end + + it 'persists to Data::Local when available' do + allow(described_class).to receive(:persist_transition) + described_class.transition('lex-detect', :loaded) + expect(described_class).to have_received(:persist_transition).with('lex-detect', :loaded) + end + + it 'publishes a raw catalog event instead of using function-backed dynamic messages' do + exchange = instance_double('Legion::Transport::Exchange', publish: true) + exchange_class = class_double('Legion::Transport::Exchange', new: exchange) + connection = class_double('Legion::Transport::Connection', session_open?: true) + stub_const('Legion::Transport::Exchange', exchange_class) + stub_const('Legion::Transport::Connection', connection) + + allow(described_class).to receive(:persist_transition) + + described_class.transition('lex-detect', :loaded) + + expect(exchange_class).to have_received(:new).with('legion.catalog') + expect(exchange).to have_received(:publish).with( + kind_of(String), + routing_key: 'legion.catalog.lex-detect.loaded', + content_type: 'application/json', + persistent: true + ) + end + end + + describe '.loaded?' do + it 'returns false for unregistered extensions' do + expect(described_class.loaded?('lex-nonexistent')).to be false + end + + it 'returns true when state is :loaded or beyond' do + described_class.register('lex-detect', state: :loaded) + expect(described_class.loaded?('lex-detect')).to be true + end + + it 'returns false when state is :registered' do + described_class.register('lex-detect') + expect(described_class.loaded?('lex-detect')).to be false + end + end + + describe '.running?' do + it 'returns true only when state is :running' do + described_class.register('lex-detect', state: :running) + expect(described_class.running?('lex-detect')).to be true + end + + it 'returns false for :loaded' do + described_class.register('lex-detect', state: :loaded) + expect(described_class.running?('lex-detect')).to be false + end + end + + describe '.all' do + it 'returns all registered extensions' do + described_class.register('lex-detect') + described_class.register('lex-node') + expect(described_class.all.keys).to contain_exactly('lex-detect', 'lex-node') + end + end + + describe '.reset!' do + it 'clears all entries' do + described_class.register('lex-detect') + described_class.reset! + expect(described_class.all).to be_empty + end + end + + describe 'graceful degradation' do + it 'does not raise when transport is unavailable' do + described_class.register('lex-detect') + expect { described_class.transition('lex-detect', :loaded) }.not_to raise_error + end + + it 'does not raise when Data::Local is unavailable' do + described_class.register('lex-detect') + expect { described_class.transition('lex-detect', :loaded) }.not_to raise_error + end + + it 'warns once and skips persistence when extension_catalog is missing' do + connection = double('Sequel::Database', tables: []) + local = Module.new do + class << self + attr_accessor :connection + end + + def self.connected? = true + def self.registered_migrations = {} + end + local.connection = connection + allow(local).to receive(:register_migrations) + stub_const('Legion::Data::Local', local) + + described_class.register('lex-detect') + described_class.transition('lex-detect', :loaded) + described_class.transition('lex-detect', :running) + described_class.flush_persisted_transitions + + expect(local).to have_received(:register_migrations).with( + name: :extension_catalog, + path: kind_of(String) + ).at_least(:once) + expect(Legion::Logging).to have_received(:warn).with(/extension_catalog table is missing/).once + end + + it 'registers the local migration lazily once Data::Local is available' do + connection = double('Sequel::Database', tables: [:extension_catalog]) + dataset = instance_double('Sequel::Dataset', first: nil) + model = double('Sequel::Model', where: dataset, insert: true) + nil + local = Module.new do + class << self + attr_accessor :connection + end + + def self.connected? = true + def self.registered_migrations = {} + end + local.connection = connection + allow(connection).to receive(:transaction) { |&blk| blk.call } + allow(local).to receive(:register_migrations) + allow(local).to receive(:model).with(:extension_catalog).and_return(model) + stub_const('Legion::Data::Local', local) + + described_class.register('lex-detect') + described_class.transition('lex-detect', :loaded) + described_class.flush_persisted_transitions + + expect(local).to have_received(:register_migrations).with( + name: :extension_catalog, + path: kind_of(String) + ) + end + + it 'skips persisted transition updates when the stored state is unchanged' do + connection = double('Sequel::Database', tables: [:extension_catalog]) + existing = double('ExtensionCatalogRow', state: 'loaded') + dataset = instance_double('Sequel::Dataset', first: existing) + model = double('Sequel::Model', where: dataset) + local = Module.new do + class << self + attr_accessor :connection + end + + def self.connected? = true + def self.registered_migrations = { extension_catalog: '/tmp/extension_catalog' } + end + local.connection = connection + allow(connection).to receive(:transaction) { |&blk| blk.call } + allow(local).to receive(:model).with(:extension_catalog).and_return(model) + allow(existing).to receive(:update) + stub_const('Legion::Data::Local', local) + + described_class.register('lex-detect') + described_class.transition('lex-detect', :loaded) + described_class.flush_persisted_transitions + + expect(existing).not_to have_received(:update) + end + end +end diff --git a/spec/legion/extensions/catalog_unregister_spec.rb b/spec/legion/extensions/catalog_unregister_spec.rb new file mode 100644 index 00000000..71d9fdd6 --- /dev/null +++ b/spec/legion/extensions/catalog_unregister_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Catalog unregister on extension unload' do + describe '.unregister_capabilities' do + it 'is a no-op (replaced by Tools::Registry.clear on reload)' do + expect { Legion::Extensions.unregister_capabilities('lex-github') }.not_to raise_error + end + end +end diff --git a/spec/legion/extensions/catalog_wiring_spec.rb b/spec/legion/extensions/catalog_wiring_spec.rb new file mode 100644 index 00000000..115e81f0 --- /dev/null +++ b/spec/legion/extensions/catalog_wiring_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Extension Catalog wiring' do + before { Legion::Extensions::Catalog.reset! } + + describe 'lifecycle state transitions' do + it 'registers extensions during discovery' do + Legion::Extensions::Catalog.register('lex-test') + expect(Legion::Extensions::Catalog.state('lex-test')).to eq(:registered) + end + + it 'transitions to :loaded after successful load' do + Legion::Extensions::Catalog.register('lex-test') + Legion::Extensions::Catalog.transition('lex-test', :loaded) + expect(Legion::Extensions::Catalog.loaded?('lex-test')).to be true + end + + it 'transitions through starting to running' do + Legion::Extensions::Catalog.register('lex-test', state: :loaded) + Legion::Extensions::Catalog.transition('lex-test', :starting) + Legion::Extensions::Catalog.transition('lex-test', :running) + expect(Legion::Extensions::Catalog.running?('lex-test')).to be true + end + + it 'transitions through stopping to stopped' do + Legion::Extensions::Catalog.register('lex-test', state: :running) + Legion::Extensions::Catalog.transition('lex-test', :stopping) + Legion::Extensions::Catalog.transition('lex-test', :stopped) + expect(Legion::Extensions::Catalog.state('lex-test')).to eq(:stopped) + end + end +end diff --git a/spec/legion/extensions/core_spec.rb b/spec/legion/extensions/core_spec.rb new file mode 100644 index 00000000..06fe5d66 --- /dev/null +++ b/spec/legion/extensions/core_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe Legion::Extensions::Core do + describe '.build_settings' do + around do |example| + original_loader = Legion::Settings.instance_variable_get(:@loader) + Legion::Settings.instance_variable_set(:@loader, Legion::Settings::Loader.new) + example.run + ensure + Legion::Settings.instance_variable_set(:@loader, original_loader) + end + + it 'merges nested extension defaults into the nested settings path' do + stub_const('Legion::Extensions::Foo', Module.new) + stub_const('Legion::Extensions::Foo::Bar', Module.new do + extend Legion::Extensions::Core + + def self.default_settings + { enabled: true, runners: { ping: { desc: 'default' } } } + end + end) + + Legion::Settings[:extensions][:foo] = { bar: { enabled: false } } + + Legion::Extensions::Foo::Bar.build_settings + + expect(Legion::Settings.dig(:extensions, :foo, :bar)).to include( + enabled: false, + runners: { ping: { desc: 'default' } } + ) + expect(Legion::Settings.dig(:extensions, :foo_bar)).to be_nil + end + + it 'keeps flat underscored extension defaults under the flat settings key' do + stub_const('Legion::Extensions::FooBar', Module.new do + extend Legion::Extensions::Core + + def self.default_settings + { enabled: true, workers: 1 } + end + end) + + Legion::Settings[:extensions][:foo_bar] = { enabled: false } + + Legion::Extensions::FooBar.build_settings + + expect(Legion::Settings.dig(:extensions, :foo_bar)).to include(enabled: false, workers: 1) + expect(Legion::Settings.dig(:extensions, :foo)).to be_nil + end + end + + describe '.sticky_tools?' do + it 'returns true by default' do + stub_const('Legion::Extensions::StickyTest', Module.new { extend Legion::Extensions::Core }) + expect(Legion::Extensions::StickyTest.sticky_tools?).to eq(true) + end + + it 'can be overridden to false on extension module' do + mod = Module.new do + extend Legion::Extensions::Core + + def self.sticky_tools? + false + end + end + expect(mod.sticky_tools?).to eq(false) + end + end + + describe '.trigger_words' do + it 'defaults to lex name segments derived from the module name' do + stub_const('Legion::Extensions::Github', Module.new { extend Legion::Extensions::Core }) + expect(Legion::Extensions::Github.trigger_words).to eq(['github']) + end + + it 'splits compound lex names into individual words' do + stub_const('Legion::Extensions::IdentityLdap', Module.new { extend Legion::Extensions::Core }) + expect(Legion::Extensions::IdentityLdap.trigger_words).to eq(%w[identity ldap]) + end + + it 'returns explicit trigger_words unchanged when overridden' do + mod = Module.new do + extend Legion::Extensions::Core + + def self.trigger_words + %w[custom words] + end + end + expect(mod.trigger_words).to eq(%w[custom words]) + end + end + + describe '.autobuild' do + it 'builds extension data when migrations exist even if data_required? is false' do + Dir.mktmpdir do |dir| + FileUtils.mkdir_p(File.join(dir, 'data', 'migrations')) + File.write(File.join(dir, 'data', 'migrations', '001_create_test_table.rb'), '# migration') + + stub_const('Legion::Extensions::MigrationProbe', Module.new { extend Legion::Extensions::Core }) + allow(Legion::Extensions::MigrationProbe).to receive(:extension_path).and_return(dir) + allow(Legion::Extensions::MigrationProbe).to receive(:build_settings) + allow(Legion::Extensions::MigrationProbe).to receive(:build_transport) + allow(Legion::Extensions::MigrationProbe).to receive(:build_data) + allow(Legion::Extensions::MigrationProbe).to receive(:build_helpers) + allow(Legion::Extensions::MigrationProbe).to receive(:build_runners) + allow(Legion::Extensions::MigrationProbe).to receive(:generate_messages_from_definitions) + allow(Legion::Extensions::MigrationProbe).to receive(:build_absorbers) + allow(Legion::Extensions::MigrationProbe).to receive(:build_actors) + allow(Legion::Extensions::MigrationProbe).to receive(:build_hooks) + allow(Legion::Extensions::MigrationProbe).to receive(:build_routes) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ connected: true }) + + Legion::Extensions::MigrationProbe.autobuild + + expect(Legion::Extensions::MigrationProbe).to have_received(:build_data) + end + end + end +end diff --git a/spec/legion/extensions/definitions_spec.rb b/spec/legion/extensions/definitions_spec.rb new file mode 100644 index 00000000..ea96b11d --- /dev/null +++ b/spec/legion/extensions/definitions_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/definitions' + +RSpec.describe Legion::Extensions::Definitions do + let(:klass) do + Class.new do + extend Legion::Extensions::Definitions + end + end + + describe '.definition' do + it 'stores a definition for a method' do + klass.definition :create, + desc: 'Create a thing', + inputs: { name: { type: :string, required: true } }, + outputs: { id: { type: :integer } } + + expect(klass.definitions[:create]).to include( + desc: 'Create a thing', + inputs: { name: { type: :string, required: true } }, + outputs: { id: { type: :integer } } + ) + end + + it 'stores multiple definitions independently' do + klass.definition :create, desc: 'Create' + klass.definition :delete, desc: 'Delete' + expect(klass.definitions.keys).to contain_exactly(:create, :delete) + end + + it 'defaults remote_invocable to true' do + klass.definition :create, desc: 'Create' + expect(klass.definitions[:create][:remote_invocable]).to be true + end + + it 'defaults mcp_exposed to true' do + klass.definition :create, desc: 'Create' + expect(klass.definitions[:create][:mcp_exposed]).to be true + end + + it 'defaults idempotent to false' do + klass.definition :create, desc: 'Create' + expect(klass.definitions[:create][:idempotent]).to be false + end + + it 'defaults risk_tier to :standard' do + klass.definition :create, desc: 'Create' + expect(klass.definitions[:create][:risk_tier]).to eq(:standard) + end + + it 'allows overriding all flags' do + klass.definition :create, desc: 'Create', + remote_invocable: false, mcp_exposed: false, + idempotent: true, risk_tier: :critical + defn = klass.definitions[:create] + expect(defn[:remote_invocable]).to be false + expect(defn[:mcp_exposed]).to be false + expect(defn[:idempotent]).to be true + expect(defn[:risk_tier]).to eq(:critical) + end + + it 'returns empty hash when no definitions' do + expect(klass.definitions).to eq({}) + end + + it 'supports definition reuse via hash merge' do + shared = { repo: { type: :string, required: true } } + klass.definition :create, desc: 'Create', + inputs: shared.merge(title: { type: :string, required: true }) + expect(klass.definitions[:create][:inputs]).to include(:repo, :title) + end + + it 'inherits definitions from parent class' do + klass.definition :create, desc: 'Create' + child = Class.new(klass) + child.definition :update, desc: 'Update' + expect(child.definitions.keys).to contain_exactly(:create, :update) + expect(klass.definitions.keys).to contain_exactly(:create) + end + end + + describe '.definition_for' do + it 'returns a single definition' do + klass.definition :create, desc: 'Create' + expect(klass.definition_for(:create)[:desc]).to eq('Create') + end + + it 'returns nil for undefined method' do + expect(klass.definition_for(:missing)).to be_nil + end + end +end diff --git a/spec/legion/extensions/find_extensions_spec.rb b/spec/legion/extensions/find_extensions_spec.rb new file mode 100644 index 00000000..6c3fd72c --- /dev/null +++ b/spec/legion/extensions/find_extensions_spec.rb @@ -0,0 +1,346 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions do + describe '.find_extensions' do + let(:mock_specs) do + [ + { name: 'lex-node', version: '0.1.0' }, + { name: 'lex-agentic-cognitive-anchor', version: '0.1.0' }, + { name: 'lex-claude', version: '0.1.0' }, + { name: 'lex-consul', version: '0.1.0' } + ] + end + + before do + described_class.instance_variable_set(:@extensions, nil) + allow(described_class).to receive(:gem_names_for_discovery).and_return(mock_specs) + allow(Legion::Settings).to receive(:[]).with(:extensions).and_return( + core: %w[lex-node], ai: %w[lex-claude], gaia: [], + categories: { + core: { type: :list, tier: 1 }, + ai: { type: :list, tier: 2 }, + gaia: { type: :list, tier: 3 }, + agentic: { type: :prefix, tier: 4 } + }, + blocked: [], agentic: { allowed: nil, blocked: [] } + ) + allow(Legion::Settings).to receive(:[]).with(:role).and_return({ profile: nil }) + end + + it 'returns an array of entry hashes' do + result = described_class.find_extensions + expect(result).to be_an(Array) + expect(result).not_to be_empty + end + + it 'each entry has required keys' do + result = described_class.find_extensions + entry = result.first + expect(entry).to include(:gem_name, :category, :tier, :segments, :const_path, :require_path) + end + + it 'returns extensions in tier order (core before ai before agentic before default)' do + result = described_class.find_extensions + names = result.map { |e| e[:gem_name] } + expect(names.index('lex-node')).to be < names.index('lex-claude') + expect(names.index('lex-claude')).to be < names.index('lex-agentic-cognitive-anchor') + expect(names.index('lex-agentic-cognitive-anchor')).to be < names.index('lex-consul') + end + + it 'only includes lex-* gems, excluding non-lex gems' do + allow(described_class).to receive(:gem_names_for_discovery).and_return( + [{ name: 'not-a-lex', version: '1.0.0' }, { name: 'lex-real', version: '0.2.0' }] + ) + result = described_class.find_extensions + gem_names = result.map { |e| e[:gem_name] } + expect(gem_names).not_to include('not-a-lex') + expect(gem_names).to include('lex-real') + end + + it 'includes lex-node entry' do + result = described_class.find_extensions + gem_names = result.map { |e| e[:gem_name] } + expect(gem_names).to include('lex-node') + end + + it 'includes lex-agentic-cognitive-anchor entry' do + result = described_class.find_extensions + gem_names = result.map { |e| e[:gem_name] } + expect(gem_names).to include('lex-agentic-cognitive-anchor') + end + + context 'when running under Bundler' do + it 'uses Bundler.load.specs for discovery' do + fake_spec = double('spec', name: 'lex-fake', version: '0.1.0') + fake_bundler_load = double('bundler_load', specs: [fake_spec]) + allow(described_class).to receive(:gem_names_for_discovery).and_call_original + allow(Bundler).to receive(:load).and_return(fake_bundler_load) + + described_class.instance_variable_set(:@extensions, nil) + described_class.find_extensions + + extensions = described_class.instance_variable_get(:@extensions) + gem_names = extensions.map { |e| e[:gem_name] } + expect(gem_names).to include('lex-fake') + end + end + + context 'when Bundler is not defined' do + it 'falls back to Gem::Specification.latest_specs' do + hide_const('Bundler') + fake_spec = double('spec', name: 'lex-fallback', version: double(to_s: '0.1.0')) + allow(Gem::Specification).to receive(:latest_specs).and_return([fake_spec]) + allow(described_class).to receive(:gem_names_for_discovery).and_call_original + + described_class.instance_variable_set(:@extensions, nil) + described_class.find_extensions + + extensions = described_class.instance_variable_get(:@extensions) + gem_names = extensions.map { |e| e[:gem_name] } + expect(gem_names).to include('lex-fallback') + end + end + end + + describe '.ensure_namespace' do + it 'creates intermediate modules for nested const path' do + described_class.ensure_namespace('Legion::Extensions::Agentic::Cognitive::TestEnsure') + expect(Legion::Extensions::Agentic).to be_a(Module) + expect(Legion::Extensions::Agentic::Cognitive).to be_a(Module) + end + + it 'does NOT create the final constant (TestEnsure itself)' do + described_class.ensure_namespace('Legion::Extensions::Agentic::Cognitive::TestEnsureLeaf') + expect(Legion::Extensions::Agentic::Cognitive.const_defined?(:TestEnsureLeaf, false)).to be false + end + + it 'is idempotent — calling twice does not raise' do + expect do + described_class.ensure_namespace('Legion::Extensions::Agentic::Cognitive::TestEnsure') + described_class.ensure_namespace('Legion::Extensions::Agentic::Cognitive::TestEnsure') + end.not_to raise_error + end + + it 'does nothing for flat extensions (no intermediate modules needed)' do + expect { described_class.ensure_namespace('Legion::Extensions::Node') }.not_to raise_error + end + + it 'does nothing for two-segment paths (Legion::Extensions::X has no intermediates)' do + expect { described_class.ensure_namespace('Legion::Extensions::SomeThing') }.not_to raise_error + end + end + + describe '.categorize_and_order' do + let(:gem_names) do + %w[ + lex-consul lex-node lex-agentic-cognitive-anchor lex-claude + lex-tick lex-tasker lex-agentic-attention-spotlight lex-slack + lex-openai lex-apollo + ] + end + + let(:ext_settings) do + { + core: %w[lex-node lex-tasker], + ai: %w[lex-claude lex-openai], + gaia: %w[lex-tick lex-apollo], + categories: { + core: { type: :list, tier: 1 }, + ai: { type: :list, tier: 2 }, + gaia: { type: :list, tier: 3 }, + agentic: { type: :prefix, tier: 4 } + }, + blocked: ['lex-slack'], + agentic: { allowed: nil, blocked: [] } + } + end + + before do + allow(Legion::Settings).to receive(:[]).with(:extensions).and_return(ext_settings) + end + + it 'returns gems in tier order' do + result = described_class.categorize_and_order(gem_names) + names = result.map { |r| r[:gem_name] } + expect(names.index('lex-node')).to be < names.index('lex-claude') + expect(names.index('lex-claude')).to be < names.index('lex-tick') + expect(names.index('lex-tick')).to be < names.index('lex-agentic-cognitive-anchor') + expect(names.index('lex-agentic-cognitive-anchor')).to be < names.index('lex-consul') + end + + it 'excludes blocked gems' do + result = described_class.categorize_and_order(gem_names) + expect(result.map { |r| r[:gem_name] }).not_to include('lex-slack') + end + + it 'skips list gems that are not in the input' do + result = described_class.categorize_and_order(['lex-node']) + names = result.map { |r| r[:gem_name] } + expect(names).to eq(['lex-node']) + end + + it 'assigns correct categories' do + result = described_class.categorize_and_order(gem_names) + by_name = result.to_h { |r| [r[:gem_name], r] } + expect(by_name['lex-node'][:category]).to eq(:core) + expect(by_name['lex-claude'][:category]).to eq(:ai) + expect(by_name['lex-tick'][:category]).to eq(:gaia) + expect(by_name['lex-agentic-cognitive-anchor'][:category]).to eq(:agentic) + expect(by_name['lex-consul'][:category]).to eq(:default) + end + + it 'derives nested const_path for agentic gems' do + result = described_class.categorize_and_order(gem_names) + anchor = result.find { |r| r[:gem_name] == 'lex-agentic-cognitive-anchor' } + expect(anchor[:const_path]).to eq('Legion::Extensions::Agentic::Cognitive::Anchor') + end + + it 'derives flat const_path for list-category gems' do + result = described_class.categorize_and_order(gem_names) + node = result.find { |r| r[:gem_name] == 'lex-node' } + expect(node[:const_path]).to eq('Legion::Extensions::Node') + end + + it 'derives flat const_path for default-tier gems' do + result = described_class.categorize_and_order(gem_names) + consul = result.find { |r| r[:gem_name] == 'lex-consul' } + expect(consul[:const_path]).to eq('Legion::Extensions::Consul') + end + + it 'each entry includes gem_name, category, tier, segments, const_path, require_path' do + result = described_class.categorize_and_order(['lex-node']) + entry = result.first + expect(entry).to include(:gem_name, :category, :tier, :segments, :const_path, :require_path) + end + + it 'derives a nested settings path for hyphenated nested extensions' do + entry = described_class.build_extension_entry('lex-foo-bar', :default, {}, nesting: false) + expect(entry[:const_path]).to eq('Legion::Extensions::Foo::Bar') + expect(entry[:settings_path]).to eq(%i[foo bar]) + end + + it 'derives a flat settings path for underscored flat extensions' do + entry = described_class.build_extension_entry('lex-foo_bar', :default, {}, nesting: false) + expect(entry[:const_path]).to eq('Legion::Extensions::FooBar') + expect(entry[:settings_path]).to eq([:foo_bar]) + end + end + + describe '.check_reserved_words' do + it 'warns when an unknown-origin gem uses a reserved category prefix' do + expect(Legion::Logging).to receive(:warn).with(/reserved prefix/) + described_class.check_reserved_words('lex-agentic-custom-thing', known_org: false) + end + + it 'does not warn for known org gems' do + expect(Legion::Logging).not_to receive(:warn) + described_class.check_reserved_words('lex-agentic-cognitive-anchor', known_org: true) + end + + it 'warns when first segment is a reserved word' do + expect(Legion::Logging).to receive(:warn).with(/reserved word/) + described_class.check_reserved_words('lex-transport-adapter', known_org: false) + end + + it 'does not raise, just warns' do + expect { described_class.check_reserved_words('lex-transport-adapter', known_org: false) }.not_to raise_error + end + end + + describe '.apply_role_filter' do + # @extensions is now an array of entry hashes, each with :gem_name + def build_entry(gem_name, category, tier) + segments = gem_name.delete_prefix('lex-').split('-') + { + gem_name: gem_name, + category: category, + tier: tier, + segments: segments, + const_path: "Legion::Extensions::#{segments.map(&:capitalize).join('::')}", + require_path: "legion/extensions/#{segments.join('/')}" + } + end + + let(:sample_entries) do + [ + build_entry('lex-node', :core, 1), + build_entry('lex-tasker', :core, 1), + build_entry('lex-health', :core, 1), + build_entry('lex-attention', :default, 5), + build_entry('lex-memory', :default, 5), + build_entry('lex-claude', :ai, 2), + build_entry('lex-llm', :ai, 2), + build_entry('lex-llm-openai', :ai, 2), + build_entry('lex-github', :default, 5), + build_entry('lex-slack', :default, 5) + ] + end + + before do + described_class.instance_variable_set(:@extensions, sample_entries.dup) + end + + def ext_gem_names + described_class.instance_variable_get(:@extensions).map { |e| e[:gem_name] } + end + + context 'when profile is nil' do + it 'loads all extensions' do + allow(Legion::Settings).to receive(:[]).with(:role).and_return({ profile: nil }) + described_class.send(:apply_role_filter) + expect(described_class.instance_variable_get(:@extensions).count).to eq(10) + end + end + + context 'when profile is :core' do + it 'only loads core extensions' do + allow(Legion::Settings).to receive(:[]).with(:role).and_return({ profile: 'core' }) + described_class.send(:apply_role_filter) + names = ext_gem_names + expect(names).to include('lex-node', 'lex-tasker', 'lex-health') + expect(names).not_to include('lex-attention', 'lex-slack') + end + end + + context 'when profile is :cognitive' do + it 'loads core + agentic extensions without legacy or native LLM providers' do + allow(Legion::Settings).to receive(:[]).with(:role).and_return({ profile: 'cognitive' }) + described_class.send(:apply_role_filter) + names = ext_gem_names + expect(names).to include('lex-node', 'lex-memory') + expect(names).not_to include('lex-claude', 'lex-llm', 'lex-llm-openai') + end + end + + context 'when profile is :custom' do + it 'only loads listed extensions' do + allow(Legion::Settings).to receive(:[]).with(:role).and_return({ + profile: 'custom', + extensions: %w[node github] + }) + described_class.send(:apply_role_filter) + expect(ext_gem_names).to match_array(%w[lex-node lex-github]) + end + end + + context 'when profile is :dev' do + it 'loads core + ai + essential agentic' do + allow(Legion::Settings).to receive(:[]).with(:role).and_return({ profile: 'dev' }) + described_class.send(:apply_role_filter) + names = ext_gem_names + expect(names).to include('lex-node', 'lex-memory', 'lex-llm', 'lex-llm-openai') + expect(names).not_to include('lex-claude', 'lex-slack', 'lex-github') + end + end + + context 'when profile is unknown' do + it 'loads all extensions' do + allow(Legion::Settings).to receive(:[]).with(:role).and_return({ profile: 'unknown_thing' }) + described_class.send(:apply_role_filter) + expect(described_class.instance_variable_get(:@extensions).count).to eq(10) + end + end + end +end diff --git a/spec/legion/extensions/gem_source_spec.rb b/spec/legion/extensions/gem_source_spec.rb new file mode 100644 index 00000000..29430ef9 --- /dev/null +++ b/spec/legion/extensions/gem_source_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/gem_source' + +RSpec.describe Legion::Extensions::GemSource do + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + end + + describe '.configured_sources' do + it 'returns default rubygems.org when no sources configured' do + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(nil) + result = described_class.configured_sources + expect(result).to eq([{ url: 'https://rubygems.org' }]) + end + + it 'returns configured sources as hashes' do + sources = [ + { url: 'https://rubygems.org' }, + { url: 'https://gems.example.com', credentials: 'token123' } + ] + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(sources) + result = described_class.configured_sources + expect(result.length).to eq(2) + expect(result[1][:url]).to eq('https://gems.example.com') + expect(result[1][:credentials]).to eq('token123') + end + + it 'normalizes string sources to hashes' do + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(['https://custom.gem.server']) + result = described_class.configured_sources + expect(result).to eq([{ url: 'https://custom.gem.server' }]) + end + end + + describe '.source_urls' do + it 'extracts URLs from configured sources' do + sources = [{ url: 'https://rubygems.org' }, { url: 'https://private.gems.io' }] + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(sources) + expect(described_class.source_urls).to eq(%w[https://rubygems.org https://private.gems.io]) + end + end + + describe '.source_args_for_cli' do + it 'returns empty string when only default source is configured' do + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(nil) + expect(described_class.source_args_for_cli).to eq('') + end + + it 'returns --source flags with --clear-sources for custom sources' do + sources = [{ url: 'https://rubygems.org' }, { url: 'https://private.gems.io' }] + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(sources) + result = described_class.source_args_for_cli + expect(result).to include('--source https://rubygems.org') + expect(result).to include('--source https://private.gems.io') + expect(result).to include('--clear-sources') + end + end + + describe '.resolve_credential' do + it 'returns literal values as-is' do + result = described_class.send(:resolve_credential, 'my-token-123') + expect(result).to eq('my-token-123') + end + + it 'resolves env: prefix to environment variable' do + allow(ENV).to receive(:fetch).with('MY_GEM_TOKEN', nil).and_return('secret-from-env') + result = described_class.send(:resolve_credential, 'env:MY_GEM_TOKEN') + expect(result).to eq('secret-from-env') + end + + it 'returns nil when env var is not set' do + allow(ENV).to receive(:fetch).with('MISSING_VAR', nil).and_return(nil) + result = described_class.send(:resolve_credential, 'env:MISSING_VAR') + expect(result).to be_nil + end + end + + describe '.install_gem command construction' do + it 'builds correct command with default sources' do + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(nil) + sources = described_class.source_args_for_cli + cmd = "/usr/bin/gem install lex-test --no-document #{sources}".strip.squeeze(' ') + expect(cmd).to include('lex-test') + expect(cmd).to include('--no-document') + expect(cmd).not_to include('--clear-sources') + end + + it 'includes source args when custom sources are configured' do + sources = [{ url: 'https://rubygems.org' }, { url: 'https://private.gems.io' }] + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(sources) + args = described_class.source_args_for_cli + cmd = "/usr/bin/gem install lex-test --no-document #{args}".strip + expect(cmd).to include('--source https://private.gems.io') + expect(cmd).to include('--clear-sources') + end + + it 'includes version when specified' do + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(nil) + args = described_class.source_args_for_cli + cmd = "/usr/bin/gem install lex-test -v 1.2.0 --no-document #{args}".strip.squeeze(' ') + expect(cmd).to include('-v 1.2.0') + end + end + + describe '.setup!' do + it 'does not raise when sources are default' do + allow(Legion::Settings).to receive(:dig).with(:extensions, :sources).and_return(nil) + expect { described_class.setup! }.not_to raise_error + end + end +end diff --git a/spec/legion/extensions/handle_registry_spec.rb b/spec/legion/extensions/handle_registry_spec.rb new file mode 100644 index 00000000..27208694 --- /dev/null +++ b/spec/legion/extensions/handle_registry_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/handle_registry' + +RSpec.describe Legion::Extensions::HandleRegistry do + subject(:registry) { described_class.new } + + let(:spec) do + instance_double(Gem::Specification, + name: 'lex-example', + version: Gem::Version.new('1.2.3'), + gem_dir: '/gems/lex-example-1.2.3') + end + + it 'registers an extension handle with runtime metadata' do + handle = registry.register('lex-example', spec: spec, state: :loaded) + + expect(handle.lex_name).to eq('lex-example') + expect(handle.gem_name).to eq('lex-example') + expect(handle.active_version).to eq(Gem::Version.new('1.2.3')) + expect(handle.gem_dir).to eq('/gems/lex-example-1.2.3') + expect(handle.state).to eq(:loaded) + expect(handle.reload_state).to eq(:idle) + expect(handle.loaded_at).to be_a(Time) + end + + it 'transitions state without replacing unrelated metadata' do + registry.register('lex-example', spec: spec) + + handle = registry.transition('lex-example', :running) + + expect(handle.state).to eq(:running) + expect(handle.active_version).to eq(Gem::Version.new('1.2.3')) + end + + it 'updates controlled fields on an existing handle' do + registry.register('lex-example', spec: spec) + + handle = registry.update('lex-example', reload_state: :pending, last_error: 'newer version installed') + + expect(handle.reload_state).to eq(:pending) + expect(handle.last_error).to eq('newer version installed') + end + + it 'returns state-filtered handle collections' do + registry.register('lex-loaded', state: :loaded) + registry.register('lex-running', state: :running) + + expect(registry.loaded.map(&:lex_name)).to contain_exactly('lex-loaded', 'lex-running') + expect(registry.running.map(&:lex_name)).to contain_exactly('lex-running') + end + + it 'does not treat stopped or failed handles as loaded' do + registry.register('lex-loaded', state: :loaded) + registry.register('lex-stopped', state: :stopped) + registry.register('lex-failed', state: :failed) + + expect(registry.loaded.map(&:lex_name)).to contain_exactly('lex-loaded') + end + + it 'derives pending reload from installed and active versions' do + handle = registry.register('lex-example', + active_version: '1.2.3', + latest_installed_version: '1.2.4') + + expect(handle.pending_reload?).to be true + end + + it 'reports non-dispatchable handles while reload or stop is in progress' do + registry.register('lex-example', state: :running, reload_state: :updating) + + expect(registry.fetch('lex-example')).not_to be_dispatchable + end + + it 'can delete and reset handles' do + registry.register('lex-example') + expect(registry.delete('lex-example').lex_name).to eq('lex-example') + expect(registry.fetch('lex-example')).to be_nil + + registry.register('lex-other') + registry.reset! + expect(registry.all).to be_empty + end +end diff --git a/spec/legion/extensions/helpers/base_spec.rb b/spec/legion/extensions/helpers/base_spec.rb new file mode 100644 index 00000000..d3af0f47 --- /dev/null +++ b/spec/legion/extensions/helpers/base_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Helpers::Base do + before(:all) do + # Nested extension: Legion::Extensions::Agentic::Cognitive::Anchor + unless defined?(Legion::Extensions::Agentic::Cognitive::Anchor::Actor::TestActor) + module Legion + module Extensions + module Agentic + module Cognitive + module Anchor + module Actor + class TestActor + include Legion::Extensions::Helpers::Base + end + end + end + end + end + end + end + end + + # Flat extension: Legion::Extensions::Http (simulating lex-http) + unless defined?(Legion::Extensions::Http::Actor::TestFlatActor) + module Legion + module Extensions + module Http + module Actor + class TestFlatActor + include Legion::Extensions::Helpers::Base + end + end + end + end + end + end + end + + describe 'nested extension (Agentic::Cognitive::Anchor)' do + subject { Legion::Extensions::Agentic::Cognitive::Anchor::Actor::TestActor.new } + + it 'returns segments array' do + expect(subject.segments).to eq(%w[agentic cognitive anchor]) + end + + it 'returns lex_slug as dot-joined segments' do + expect(subject.lex_slug).to eq('agentic.cognitive.anchor') + end + + it 'returns log_tag as bracketed segments' do + expect(subject.log_tag).to eq('[agentic][cognitive][anchor]') + end + + it 'returns amqp_prefix with lex. prefix' do + expect(subject.amqp_prefix).to eq('lex.agentic.cognitive.anchor') + end + + it 'returns settings_path as symbol array' do + expect(subject.settings_path).to eq(%i[agentic cognitive anchor]) + end + + it 'returns table_prefix as underscore-joined' do + expect(subject.table_prefix).to eq('agentic_cognitive_anchor') + end + + it 'returns lex_name as underscore-joined (backward compat)' do + expect(subject.lex_name).to eq('agentic_cognitive_anchor') + end + + it 'returns lex_class as full extension module constant' do + expect(subject.lex_class).to eq(Legion::Extensions::Agentic::Cognitive::Anchor) + end + + it 'returns lex_const as last segment of the extension module' do + expect(subject.lex_const).to eq('Anchor') + end + end + + describe 'flat extension (Http)' do + subject { Legion::Extensions::Http::Actor::TestFlatActor.new } + + it 'returns single-element segments array' do + expect(subject.segments).to eq(['http']) + end + + it 'returns simple lex_slug' do + expect(subject.lex_slug).to eq('http') + end + + it 'returns single-bracket log_tag' do + expect(subject.log_tag).to eq('[http]') + end + + it 'returns simple lex_name (backward compat)' do + expect(subject.lex_name).to eq('http') + end + + it 'returns amqp_prefix with lex. prefix' do + expect(subject.amqp_prefix).to eq('lex.http') + end + + it 'returns settings_path as symbol array' do + expect(subject.settings_path).to eq([:http]) + end + + it 'returns table_prefix' do + expect(subject.table_prefix).to eq('http') + end + + it 'returns lex_class as Legion::Extensions::Http' do + expect(subject.lex_class).to eq(Legion::Extensions::Http) + end + + it 'returns lex_const as Http' do + expect(subject.lex_const).to eq('Http') + end + end + + describe 'flat extension with camelized multi-word name (MicrosoftTeams)' do + before(:all) do + Legion::Extensions.const_set('MicrosoftTeams', Module.new) unless defined?(Legion::Extensions::MicrosoftTeams) + unless defined?(TestMicrosoftTeamsExtension) + TestMicrosoftTeamsExtension = Module.new do + extend Legion::Extensions::Helpers::Base + + def self.calling_class_array + %w[Legion Extensions MicrosoftTeams] + end + end + end + end + + subject(:ext) { TestMicrosoftTeamsExtension } + + it 'derives segments with underscore preserved (not concatenated)' do + expect(ext.segments).to eq(['microsoft_teams']) + end + + it 'derives lex_name correctly' do + expect(ext.lex_name).to eq('microsoft_teams') + end + + it 'derives amqp_prefix correctly' do + expect(ext.amqp_prefix).to eq('lex.microsoft_teams') + end + end + + describe 'lex_class boundary detection for runner/actor sub-modules' do + before(:all) do + unless defined?(Legion::Extensions::Agentic::Cognitive::Anchor::Runners::TestRunner) + module Legion + module Extensions + module Agentic + module Cognitive + module Anchor + module Runners + class TestRunner + include Legion::Extensions::Helpers::Base + end + end + end + end + end + end + end + end + end + + subject { Legion::Extensions::Agentic::Cognitive::Anchor::Runners::TestRunner.new } + + it 'still returns the extension module, not the runner sub-module' do + expect(subject.lex_class).to eq(Legion::Extensions::Agentic::Cognitive::Anchor) + end + + it 'returns the same segments as the actor' do + expect(subject.segments).to eq(%w[agentic cognitive anchor]) + end + end +end diff --git a/spec/legion/extensions/helpers/cache_spec.rb b/spec/legion/extensions/helpers/cache_spec.rb new file mode 100644 index 00000000..d05a9ee6 --- /dev/null +++ b/spec/legion/extensions/helpers/cache_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/helpers/cache' + +RSpec.describe Legion::Extensions::Helpers::Cache do + let(:test_class) do + Class.new do + include Legion::Extensions::Helpers::Cache + + def lex_filename + 'test_lex' + end + end + end + + subject { test_class.new } + + describe 'includes Legion::Cache::Helper' do + it 'responds to core cache helper methods' do + expect(subject).to respond_to(:cache_get, :cache_set, :cache_delete, :cache_fetch, + :cache_namespace) + end + + it 'responds to local cache helper methods' do + expect(subject).to respond_to(:local_cache_get, :local_cache_set, :local_cache_delete, + :local_cache_fetch) + end + end + + describe 'includes Base' do + it 'responds to base helper methods' do + expect(subject).to respond_to(:lex_name, :segments) + end + end + + describe '#cache_namespace' do + it 'derives from lex_filename' do + expect(subject.cache_namespace).to eq('test_lex') + end + end + + describe '#cache_set' do + it 'delegates to Legion::Cache with namespaced key' do + allow(Legion::Cache).to receive(:set) + subject.cache_set(':key', 'val', ttl: 120) + expect(Legion::Cache).to have_received(:set).with('test_lex:key', 'val', ttl: 120, async: false, phi: false) + end + end + + describe '#cache_get' do + it 'delegates to Legion::Cache with namespaced key' do + allow(Legion::Cache).to receive(:get).with('test_lex:key').and_return('val') + expect(subject.cache_get(':key')).to eq('val') + end + end +end diff --git a/spec/legion/extensions/helpers/data_spec.rb b/spec/legion/extensions/helpers/data_spec.rb new file mode 100644 index 00000000..85a843cb --- /dev/null +++ b/spec/legion/extensions/helpers/data_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/helpers/data' + +RSpec.describe Legion::Extensions::Helpers::Data do + let(:test_class) do + Class.new do + include Legion::Extensions::Helpers::Data + + def lex_filename + 'test_lex' + end + end + end + + subject { test_class.new } + + describe 'includes Legion::Data::Helper' do + it 'responds to data helper methods' do + expect(subject).to respond_to(:data_connected?, :data_connection, :data_adapter, + :data_pool_stats, :data_stats, :data_can_read?, + :data_can_write?) + end + + it 'responds to local data helper methods' do + expect(subject).to respond_to(:local_data_connected?, :local_data_connection, + :local_data_model, :local_data_stats) + end + end + + describe 'includes Base' do + it 'responds to base helper methods' do + expect(subject).to respond_to(:lex_name, :segments) + end + end + + describe '#data_connected?' do + it 'reads from settings' do + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ connected: true }) + expect(subject.data_connected?).to be true + end + end +end diff --git a/spec/legion/extensions/helpers/knowledge_integration_spec.rb b/spec/legion/extensions/helpers/knowledge_integration_spec.rb new file mode 100644 index 00000000..d9adae09 --- /dev/null +++ b/spec/legion/extensions/helpers/knowledge_integration_spec.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/helpers/knowledge' + +RSpec.describe Legion::Extensions::Helpers::Knowledge do + # --------------------------------------------------------------------------- + # Test class that includes the mixin, named so derive_lex_name returns 'mylex' + # --------------------------------------------------------------------------- + let(:host_class) do + Class.new do + include Legion::Extensions::Helpers::Knowledge + + def self.name + 'Legion::Extensions::Mylex::SomeRunner' + end + end + end + + subject(:instance) { host_class.new } + + # --------------------------------------------------------------------------- + # Helpers to set up / tear down the optional top-level constants + # --------------------------------------------------------------------------- + def stub_apollo(started: true, ingest_result: { success: true, mode: :async }, query_result: {}) + apollo = Module.new do + def self.started?; end + def self.ingest(**); end + def self.query(**); end + end + allow(apollo).to receive(:started?).and_return(started) + allow(apollo).to receive(:ingest).and_return(ingest_result) + allow(apollo).to receive(:query).and_return(query_result) + stub_const('Legion::Apollo', apollo) + apollo + end + + def stub_extract(result) + extractor = Module.new do + def self.extract(*); end + end + allow(extractor).to receive(:extract).and_return(result) + stub_const('Legion::Data::Extract', extractor) + extractor + end + + # --------------------------------------------------------------------------- + # Silence optional Logging calls + # --------------------------------------------------------------------------- + before do + allow(Legion::Logging).to receive(:debug) if defined?(Legion::Logging) + end + + # =========================================================================== + # derive_lex_name (private helper — tested via ingest_knowledge side-effects) + # =========================================================================== + describe '#derive_lex_name (via source_channel default)' do + it 'derives the lex name from the third namespace segment, downcased' do + apollo = stub_apollo + stub_extract(text: 'hello', metadata: { type: :txt }) + + allow(File).to receive(:exist?).and_return(false) + + instance.ingest_knowledge('hello world') + + expect(apollo).to have_received(:ingest).with(hash_including(source_channel: 'mylex')) + end + end + + # =========================================================================== + # 1. Full happy-path: File -> Extract -> Apollo.ingest + # =========================================================================== + describe '#ingest_knowledge — full path with file extraction' do + let(:extract_result) { { text: 'extracted content', metadata: { type: :txt } } } + let(:apollo) { stub_apollo(ingest_result: { success: true, mode: :async }) } + let(:extractor) { stub_extract(extract_result) } + + before do + apollo + extractor + allow(File).to receive(:exist?).with('/tmp/test.txt').and_return(true) + end + + it 'calls Data::Extract.extract with the file path and type' do + instance.ingest_knowledge('/tmp/test.txt', tags: ['test']) + expect(extractor).to have_received(:extract).with('/tmp/test.txt', type: :auto) + end + + it 'calls Apollo.ingest with extracted content' do + instance.ingest_knowledge('/tmp/test.txt', tags: ['test']) + expect(apollo).to have_received(:ingest).with(hash_including(content: 'extracted content')) + end + + it 'merges caller-supplied tags with metadata-derived tags' do + instance.ingest_knowledge('/tmp/test.txt', tags: ['test']) + expect(apollo).to have_received(:ingest).with(hash_including(tags: %w[test txt])) + end + + it 'passes source_channel derived from the class name' do + instance.ingest_knowledge('/tmp/test.txt', tags: ['test']) + expect(apollo).to have_received(:ingest).with(hash_including(source_channel: 'mylex')) + end + + it 'returns the result from Apollo.ingest' do + result = instance.ingest_knowledge('/tmp/test.txt', tags: ['test']) + expect(result).to eq({ success: true, mode: :async }) + end + + it 'accepts a custom type keyword and forwards it to Extract' do + instance.ingest_knowledge('/tmp/test.txt', type: :md, tags: []) + expect(extractor).to have_received(:extract).with('/tmp/test.txt', type: :md) + end + + it 'accepts a custom source_channel in opts and passes it through' do + instance.ingest_knowledge('/tmp/test.txt', tags: [], source_channel: 'custom_channel') + expect(apollo).to have_received(:ingest).with(hash_including(source_channel: 'custom_channel')) + end + + it 'does not forward source_channel as an extra kwarg' do + instance.ingest_knowledge('/tmp/test.txt', tags: [], source_channel: 'c') + apollo.method(:ingest).arity + # Verify the call hash does not duplicate source_channel in the splat remainder + expect(apollo).to have_received(:ingest).once + end + end + + # =========================================================================== + # 2. Metadata-to-tags: pages tag added when metadata includes :pages + # =========================================================================== + describe '#ingest_knowledge — metadata with pages' do + before do + stub_apollo + stub_extract(text: 'pdf text', metadata: { type: :pdf, pages: 12 }) + allow(File).to receive(:exist?).with('/tmp/doc.pdf').and_return(true) + end + + it 'adds a pages: tag derived from metadata' do + instance.ingest_knowledge('/tmp/doc.pdf', tags: ['doc']) + expect(Legion::Apollo).to have_received(:ingest).with( + hash_including(tags: array_including('doc', 'pdf', 'pages:12')) + ) + end + end + + # =========================================================================== + # 3. Plain-string path (not a file, not IO) — no extraction + # =========================================================================== + describe '#ingest_knowledge — plain string content (not a file path)' do + before do + stub_apollo + allow(File).to receive(:exist?).and_return(false) + end + + it 'passes the raw string directly to Apollo without calling Extract' do + stub_extract(text: 'should not be called', metadata: {}) + instance.ingest_knowledge('plain text content', tags: ['raw']) + expect(Legion::Data::Extract).not_to have_received(:extract) + expect(Legion::Apollo).to have_received(:ingest).with(hash_including(content: 'plain text content')) + end + end + + # =========================================================================== + # 4. Graceful degradation — Apollo not started + # =========================================================================== + describe '#ingest_knowledge — Apollo not started' do + it 'returns apollo_not_available when Apollo.started? is false' do + stub_apollo(started: false) + result = instance.ingest_knowledge('/tmp/test.txt', tags: ['test']) + expect(result).to eq({ success: false, error: :apollo_not_available }) + end + + it 'does not call Apollo.ingest when not started' do + apollo = stub_apollo(started: false) + instance.ingest_knowledge('/tmp/test.txt', tags: ['test']) + expect(apollo).not_to have_received(:ingest) + end + end + + # =========================================================================== + # 5. Graceful degradation — Apollo constant not defined + # =========================================================================== + describe '#ingest_knowledge — Apollo constant absent' do + it 'returns apollo_not_available when Legion::Apollo is not defined' do + hide_const('Legion::Apollo') if defined?(Legion::Apollo) + result = instance.ingest_knowledge('/tmp/test.txt', tags: ['test']) + expect(result).to eq({ success: false, error: :apollo_not_available }) + end + end + + # =========================================================================== + # 6. Graceful degradation — Data::Extract not defined + # =========================================================================== + describe '#ingest_knowledge — Data::Extract not defined' do + before do + stub_apollo + allow(File).to receive(:exist?).with('/tmp/test.txt').and_return(true) + end + + it 'falls back to treating the path as raw string content' do + hide_const('Legion::Data::Extract') if defined?(Legion::Data::Extract) + result = instance.ingest_knowledge('/tmp/test.txt', tags: ['fallback']) + expect(result).to eq({ success: true, mode: :async }) + expect(Legion::Apollo).to have_received(:ingest).with( + hash_including(content: '/tmp/test.txt', tags: ['fallback']) + ) + end + end + + # =========================================================================== + # 7. Extraction failure — Extract returns no :text key + # =========================================================================== + describe '#ingest_knowledge — extraction returns no text' do + before do + stub_apollo + stub_extract({ error: 'unsupported format' }) + allow(File).to receive(:exist?).with('/tmp/bad.bin').and_return(true) + end + + it 'returns extraction_failed' do + result = instance.ingest_knowledge('/tmp/bad.bin', tags: []) + expect(result[:success]).to be false + expect(result[:error]).to eq(:extraction_failed) + end + + it 'does not call Apollo.ingest on extraction failure' do + instance.ingest_knowledge('/tmp/bad.bin', tags: []) + expect(Legion::Apollo).not_to have_received(:ingest) + end + + it 'includes the raw Extract result as :detail' do + result = instance.ingest_knowledge('/tmp/bad.bin', tags: []) + expect(result[:detail]).to eq({ error: 'unsupported format' }) + end + end + + # =========================================================================== + # 8. IO object path — File-like object with #read + # =========================================================================== + describe '#ingest_knowledge — IO / File-like object' do + let(:io_obj) { instance_double(File, read: 'file data') } + + before do + stub_apollo + stub_extract(text: 'io extracted', metadata: { type: :txt }) + end + + it 'treats any object responding to #read as extractable' do + instance.ingest_knowledge(io_obj, tags: ['io']) + expect(Legion::Data::Extract).to have_received(:extract).with(io_obj, type: :auto) + end + + it 'passes extracted content to Apollo.ingest' do + instance.ingest_knowledge(io_obj, tags: ['io']) + expect(Legion::Apollo).to have_received(:ingest).with(hash_including(content: 'io extracted')) + end + end + + # =========================================================================== + # 9. #query_knowledge — happy path + # =========================================================================== + describe '#query_knowledge' do + let(:query_result) { { results: [{ content: 'relevant', score: 0.9 }] } } + + before { stub_apollo(query_result: query_result) } + + it 'delegates to Apollo.query with text and limit' do + instance.query_knowledge(text: 'find me something', limit: 3) + expect(Legion::Apollo).to have_received(:query).with(text: 'find me something', limit: 3) + end + + it 'uses a default limit of 5' do + instance.query_knowledge(text: 'search') + expect(Legion::Apollo).to have_received(:query).with(hash_including(limit: 5)) + end + + it 'returns the result from Apollo.query' do + result = instance.query_knowledge(text: 'find me something', limit: 3, scope: :global) + expect(result).to eq(query_result) + end + + it 'forwards extra keyword args to Apollo.query' do + instance.query_knowledge(text: 'search', namespace: 'prod') + expect(Legion::Apollo).to have_received(:query).with(hash_including(namespace: 'prod')) + end + end + + # =========================================================================== + # 10. #query_knowledge — Apollo not available + # =========================================================================== + describe '#query_knowledge — Apollo not started' do + it 'returns apollo_not_available when Apollo.started? is false' do + stub_apollo(started: false) + result = instance.query_knowledge(text: 'search') + expect(result).to eq({ success: false, error: :apollo_not_available }) + end + + it 'does not call Apollo.query when not started' do + apollo = stub_apollo(started: false) + instance.query_knowledge(text: 'search') + expect(apollo).not_to have_received(:query) + end + end + + describe '#query_knowledge — Apollo constant absent' do + it 'returns apollo_not_available when Legion::Apollo is not defined' do + hide_const('Legion::Apollo') if defined?(Legion::Apollo) + result = instance.query_knowledge(text: 'anything') + expect(result).to eq({ success: false, error: :apollo_not_available }) + end + end +end diff --git a/spec/legion/extensions/helpers/knowledge_spec.rb b/spec/legion/extensions/helpers/knowledge_spec.rb new file mode 100644 index 00000000..7367c94e --- /dev/null +++ b/spec/legion/extensions/helpers/knowledge_spec.rb @@ -0,0 +1,356 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/apollo' +require 'legion/extensions/helpers/knowledge' + +# Test harness — include the helper into a test class +class KnowledgeTestRunner + include Legion::Extensions::Helpers::Knowledge + + def self.name + 'Legion::Extensions::TestExt::Runners::TestRunner' + end +end + +RSpec.describe Legion::Extensions::Helpers::Knowledge do + let(:runner) { KnowledgeTestRunner.new } + + # Anonymous subclass that overrides layered defaults to verify the LEX override path + let(:custom_runner_class) do + Class.new do + include Legion::Extensions::Helpers::Knowledge + + def self.name + 'Legion::Extensions::CustomExt::Runners::CustomRunner' + end + + def knowledge_default_scope + :local + end + + def knowledge_default_tags + %w[custom ext-tag] + end + end + end + + let(:custom_runner) { custom_runner_class.new } + + describe '#ingest_knowledge' do + context 'when Apollo is not available' do + it 'returns apollo_not_available' do + result = runner.ingest_knowledge('test text', tags: %w[test]) + expect(result).to eq({ success: false, error: :apollo_not_available }) + end + end + + context 'when Apollo is available' do + before do + allow(Legion::Apollo).to receive(:started?).and_return(true) + allow(Legion::Apollo).to receive(:ingest).and_return({ success: true, mode: :async }) + end + + it 'sends plain text to Apollo' do + result = runner.ingest_knowledge('some knowledge', tags: %w[test]) + expect(result[:success]).to be true + expect(Legion::Apollo).to have_received(:ingest).with( + hash_including(content: 'some knowledge', tags: %w[test]) + ) + end + + it 'derives lex_name from class hierarchy' do + runner.ingest_knowledge('text') + expect(Legion::Apollo).to have_received(:ingest).with( + hash_including(source_channel: 'testext') + ) + end + + it 'allows source_channel override' do + runner.ingest_knowledge('text', source_channel: 'custom') + expect(Legion::Apollo).to have_received(:ingest).with( + hash_including(source_channel: 'custom') + ) + end + + it 'merges knowledge_default_tags into the call' do + allow(Legion::Apollo).to receive(:ingest).and_return({ success: true, mode: :async }) + allow(Legion::Apollo).to receive(:started?).and_return(true) + custom_runner.ingest_knowledge('tagged text', tags: %w[explicit]) + expect(Legion::Apollo).to have_received(:ingest).with( + hash_including(tags: include('custom', 'ext-tag', 'explicit')) + ) + end + + it 'uses empty knowledge_default_tags by default' do + runner.ingest_knowledge('plain', tags: %w[only]) + expect(Legion::Apollo).to have_received(:ingest).with( + hash_including(tags: %w[only]) + ) + end + end + + context 'when scope is :local' do + before do + stub_const('Legion::Apollo::Local', Module.new do + extend self + + define_method(:started?) { true } + define_method(:ingest) { |**_| { success: true, mode: :local } } + end) + allow(Legion::Apollo::Local).to receive(:ingest).and_return({ success: true, mode: :local }) + end + + it 'routes to Apollo::Local' do + result = runner.ingest_knowledge('private data', tags: %w[secret], scope: :local) + expect(result[:mode]).to eq(:local) + expect(Legion::Apollo::Local).to have_received(:ingest).with( + hash_including(content: 'private data') + ) + end + end + + context 'when Data::Extract is available' do + before do + allow(Legion::Apollo).to receive(:started?).and_return(true) + allow(Legion::Apollo).to receive(:ingest).and_return({ success: true }) + stub_const('Legion::Data::Extract', double( + extract: { success: true, text: 'extracted text', metadata: { pages: 5 }, type: :pdf } + )) + allow(File).to receive(:exist?).and_return(true) + end + + it 'extracts files before ingesting' do + result = runner.ingest_knowledge('/tmp/doc.pdf', tags: %w[doc]) + expect(result[:success]).to be true + expect(Legion::Apollo).to have_received(:ingest).with( + hash_including(content: 'extracted text', tags: include('pages:5')) + ) + end + end + end + + describe '#query_knowledge' do + context 'when Apollo is not available' do + it 'returns apollo_not_available' do + result = runner.query_knowledge(text: 'test') + expect(result).to eq({ success: false, error: :apollo_not_available }) + end + end + + context 'when Apollo is available' do + before do + allow(Legion::Apollo).to receive(:started?).and_return(true) + allow(Legion::Apollo).to receive(:query).and_return({ success: true, results: [] }) + end + + it 'delegates to Apollo.query' do + result = runner.query_knowledge(text: 'question', limit: 3) + expect(result[:success]).to be true + expect(Legion::Apollo).to have_received(:query).with(text: 'question', limit: 3) + end + end + + context 'when scope is :local' do + before do + stub_const('Legion::Apollo::Local', Module.new do + extend self + + define_method(:started?) { true } + define_method(:query) { |**_| { success: true, results: [{ content: 'local result' }], mode: :local } } + end) + allow(Legion::Apollo::Local).to receive(:query).and_return({ success: true, results: [], mode: :local }) + end + + it 'queries only local store' do + allow(Legion::Apollo::Local).to receive(:query).and_return({ success: true, results: [], mode: :local }) + result = runner.query_knowledge(text: 'test', scope: :local) + expect(result[:mode]).to eq(:local) + end + end + + context 'when scope is :all' do + before do + allow(Legion::Apollo).to receive(:started?).and_return(true) + allow(Legion::Apollo).to receive(:query).and_return({ success: true, results: [{ content: 'global', content_hash: 'g1' }] }) + stub_const('Legion::Apollo::Local', Module.new do + extend self + + define_method(:started?) { true } + define_method(:query) { |**_| { success: true, results: [{ content: 'local', content_hash: 'l1' }] } } + end) + allow(Legion::Apollo::Local).to receive(:query).and_return({ success: true, results: [{ content: 'local', content_hash: 'l1' }] }) + end + + it 'merges results from both stores' do + result = runner.query_knowledge(text: 'test', scope: :all) + expect(result[:results].size).to eq(2) + end + + it 'deduplicates by content_hash with local winning' do + allow(Legion::Apollo).to receive(:query).and_return({ success: true, results: [{ content: 'global version', content_hash: 'same' }] }) + allow(Legion::Apollo::Local).to receive(:query).and_return({ success: true, results: [{ content: 'local version', content_hash: 'same' }] }) + result = runner.query_knowledge(text: 'test', scope: :all) + expect(result[:results].size).to eq(1) + expect(result[:results].first[:content]).to eq('local version') + end + end + end + + # --- Status checks --- + + describe '#knowledge_connected?' do + context 'when neither store is available' do + it 'returns false' do + expect(runner.knowledge_connected?).to be false + end + end + + context 'when only global Apollo is available' do + before { allow(Legion::Apollo).to receive(:started?).and_return(true) } + + it 'returns true' do + expect(runner.knowledge_connected?).to be true + end + end + + context 'when only Apollo::Local is available' do + before do + stub_const('Legion::Apollo::Local', Module.new do + extend self + + define_method(:started?) { true } + end) + end + + it 'returns true' do + expect(runner.knowledge_connected?).to be true + end + end + + context 'when both stores are available' do + before do + allow(Legion::Apollo).to receive(:started?).and_return(true) + stub_const('Legion::Apollo::Local', Module.new do + extend self + + define_method(:started?) { true } + end) + end + + it 'returns true' do + expect(runner.knowledge_connected?).to be true + end + end + end + + describe '#knowledge_global_connected?' do + it 'returns false when Apollo is not available' do + expect(runner.knowledge_global_connected?).to be false + end + + it 'returns true when Apollo is started' do + allow(Legion::Apollo).to receive(:started?).and_return(true) + expect(runner.knowledge_global_connected?).to be true + end + + it 'returns false when Apollo.started? returns false' do + allow(Legion::Apollo).to receive(:started?).and_return(false) + expect(runner.knowledge_global_connected?).to be false + end + end + + describe '#knowledge_local_connected?' do + it 'returns false when Apollo::Local is not defined' do + expect(runner.knowledge_local_connected?).to be false + end + + it 'returns true when Apollo::Local is started' do + stub_const('Legion::Apollo::Local', Module.new do + extend self + + define_method(:started?) { true } + end) + expect(runner.knowledge_local_connected?).to be true + end + + it 'returns false when Apollo::Local.started? returns false' do + stub_const('Legion::Apollo::Local', Module.new do + extend self + + define_method(:started?) { false } + end) + expect(runner.knowledge_local_connected?).to be false + end + end + + # --- Layered defaults --- + + describe '#knowledge_default_scope' do + context 'when Legion::Settings is not defined' do + it 'returns :all' do + hide_const('Legion::Settings') + expect(runner.knowledge_default_scope).to eq(:all) + end + end + + context 'when Settings returns a scope' do + before do + allow(Legion::Settings).to receive(:dig).with(:apollo, :local, :default_query_scope).and_return('local') + end + + it 'returns the settings value as a symbol' do + expect(runner.knowledge_default_scope).to eq(:local) + end + end + + context 'when Settings returns nil' do + before do + allow(Legion::Settings).to receive(:dig).with(:apollo, :local, :default_query_scope).and_return(nil) + end + + it 'falls back to :all' do + expect(runner.knowledge_default_scope).to eq(:all) + end + end + + context 'when Settings raises' do + before do + allow(Legion::Settings).to receive(:dig).and_raise(StandardError, 'boom') + end + + it 'falls back to :all' do + expect(runner.knowledge_default_scope).to eq(:all) + end + end + + context 'when overridden in a LEX subclass' do + it 'returns the overridden scope' do + expect(custom_runner.knowledge_default_scope).to eq(:local) + end + end + end + + describe '#knowledge_default_tags' do + it 'returns an empty array by default' do + expect(runner.knowledge_default_tags).to eq([]) + end + + it 'returns the overridden tags in a LEX subclass' do + expect(custom_runner.knowledge_default_tags).to eq(%w[custom ext-tag]) + end + end + + # --- default_query_scope private delegate --- + + describe 'private #default_query_scope' do + it 'delegates to knowledge_default_scope' do + expect(runner).to receive(:knowledge_default_scope).and_call_original + runner.send(:default_query_scope) + end + + it 'returns the same value as knowledge_default_scope' do + expect(runner.send(:default_query_scope)).to eq(runner.knowledge_default_scope) + end + end +end diff --git a/spec/legion/extensions/helpers/lex_spec.rb b/spec/legion/extensions/helpers/lex_spec.rb new file mode 100644 index 00000000..898fdfbe --- /dev/null +++ b/spec/legion/extensions/helpers/lex_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Helpers::Lex do + it 'is a module' do + expect(described_class).to be_a(Module) + end +end diff --git a/spec/legion/extensions/helpers/llm_spec.rb b/spec/legion/extensions/helpers/llm_spec.rb new file mode 100644 index 00000000..c7f5d71f --- /dev/null +++ b/spec/legion/extensions/helpers/llm_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions/helpers/llm' + +RSpec.describe Legion::Extensions::Helpers::LLM do + let(:test_class) do + Class.new do + include Legion::Extensions::Helpers::LLM + end + end + + subject { test_class.new } + + describe 'includes Legion::LLM::Helper when available' do + it 'responds to llm_embed (always available)' do + expect(subject).to respond_to(:llm_embed) + end + + it 'responds to extended helper methods when Legion::LLM::Helper is defined', if: defined?(Legion::LLM::Helper) do + expect(subject).to respond_to(:llm_chat, :llm_embed_batch, :llm_session, + :llm_structured, :llm_ask, :llm_connected?, + :llm_cost_estimate, :llm_default_model) + end + end + + describe '#llm_embed' do + it 'delegates to LLM.embed' do + expect(Legion::LLM).to receive(:embed).with('test text') + subject.llm_embed('test text') + end + end + + describe '#llm_connected?', if: defined?(Legion::LLM::Helper) do + it 'returns true when LLM is started' do + allow(Legion::LLM).to receive(:started?).and_return(true) + expect(subject.llm_connected?).to be true + end + + it 'returns false when LLM is not started' do + allow(Legion::LLM).to receive(:started?).and_return(false) + expect(subject.llm_connected?).to be false + end + end +end diff --git a/spec/legion/extensions/helpers/secret_spec.rb b/spec/legion/extensions/helpers/secret_spec.rb new file mode 100644 index 00000000..7212efc9 --- /dev/null +++ b/spec/legion/extensions/helpers/secret_spec.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Helpers::Secret do + let(:test_module) do + Module.new do + extend Legion::Extensions::Helpers::Base + extend Legion::Extensions::Helpers::Secret + + def self.calling_class + Legion::Extensions::Github::Runners::Repositories + end + + def self.calling_class_array + %w[Legion Extensions Github Runners Repositories] + end + end + end + + before do + described_class.reset_identity! + stub_const('Legion::Settings', Module.new do + extend self + + def [](key) + { vault: { connected: true } } if key == :crypt + end + end) + end + + describe '.resolve_identity!' do + it 'returns nil when no auth sources available' do + expect(described_class.resolve_identity!).to be_nil + end + + it 'prefers kerberos principal over all other sources' do + stub_const('Legion::Crypt', Module.new do + extend self + + def kerberos_principal + 'kerb_user' + end + end) + expect(described_class.resolve_identity!).to eq('kerb_user') + expect(described_class.identity_source).to eq(:kerberos) + end + + it 'falls back to entra when kerberos is unavailable' do + token_cache_instance = double('token_cache', user_principal: 'entra_user') + token_cache_class = double('TokenCache', instance: token_cache_instance) + allow(token_cache_class).to receive(:respond_to?).with(:instance).and_return(true) + allow(token_cache_instance).to receive(:respond_to?).with(:user_principal).and_return(true) + + stub_const('Legion::Extensions::MicrosoftTeams::Helpers::TokenCache', token_cache_class) + + expect(described_class.resolve_identity!).to eq('entra_user') + expect(described_class.identity_source).to eq(:entra) + end + end + + describe '#secret' do + it 'returns a SecretAccessor scoped to the extension lex_name' do + accessor = test_module.secret + expect(accessor).to be_a(Legion::Extensions::Helpers::SecretAccessor) + end + + it 'returns the same accessor on repeated calls' do + expect(test_module.secret).to equal(test_module.secret) + end + end +end + +RSpec.describe Legion::Extensions::Helpers::SecretAccessor do + subject(:accessor) { described_class.new(lex_name: 'github') } + + before do + Legion::Extensions::Helpers::Secret.reset_identity! + allow(ENV).to receive(:fetch).with('USER', 'default').and_return('testuser') + end + + describe '#[]' do + it 'uses Legion::Crypt.vault_connected? when available' do + crypt = Module.new do + extend self + + def vault_connected? + true + end + + def get(path) + { token: 'abc123' } if path == 'users/testuser/github/api_key' + end + + def kerberos_principal = nil + end + stub_const('Legion::Crypt', crypt) + + expect(accessor[:api_key]).to eq({ token: 'abc123' }) + end + + it 'reads from per-user vault path' do + stub_const('Legion::Crypt', Module.new do + extend self + + def get(path) + { token: 'abc123' } if path == 'users/testuser/github/api_key' + end + + def kerberos_principal = nil + end) + + expect(accessor[:api_key]).to eq({ token: 'abc123' }) + end + + it 'reads from shared path when shared: true' do + stub_const('Legion::Crypt', Module.new do + extend self + + def get(path) + { token: 'shared_tok' } if path == 'shared/github/api_key' + end + + def kerberos_principal = nil + end) + + expect(accessor[:api_key, shared: true]).to eq({ token: 'shared_tok' }) + end + + it 'uses explicit user when provided' do + stub_const('Legion::Crypt', Module.new do + extend self + + def get(path) + { token: 'other_tok' } if path == 'users/other_person/github/api_key' + end + + def kerberos_principal = nil + end) + + expect(accessor[:api_key, user: 'other_person']).to eq({ token: 'other_tok' }) + end + + it 'returns nil when Legion::Crypt is not defined' do + hide_const('Legion::Crypt') + expect(accessor[:api_key]).to be_nil + end + end + + describe '#[]=' do + it 'writes to per-user vault path' do + crypt = Module.new do + extend self + + def write(path, **data); end + def kerberos_principal = nil + end + stub_const('Legion::Crypt', crypt) + allow(crypt).to receive(:write) + + accessor[:api_key] = { token: 'new_tok' } + expect(crypt).to have_received(:write).with('users/testuser/github/api_key', token: 'new_tok') + end + end + + describe '#write' do + it 'writes to shared path when shared: true' do + crypt = Module.new do + extend self + + def write(path, **data); end + def kerberos_principal = nil + end + stub_const('Legion::Crypt', crypt) + allow(crypt).to receive(:write) + + accessor.write(:api_key, token: 'shared_tok', shared: true) + expect(crypt).to have_received(:write).with('shared/github/api_key', token: 'shared_tok') + end + end + + describe '#exist?' do + it 'checks per-user path by default' do + crypt = Module.new do + extend self + + def exist?(path) + path == 'users/testuser/github/api_key' + end + + def kerberos_principal = nil + end + stub_const('Legion::Crypt', crypt) + + expect(accessor.exist?(:api_key)).to be true + expect(accessor.exist?(:missing_key)).to be false + end + end + + describe '#delete' do + it 'deletes from per-user path' do + crypt = Module.new do + extend self + + def delete(path); end + def kerberos_principal = nil + end + stub_const('Legion::Crypt', crypt) + allow(crypt).to receive(:delete) + + accessor.delete(:api_key) + expect(crypt).to have_received(:delete).with('users/testuser/github/api_key') + end + end + + describe 'identity resolution in path' do + it 'uses kerberos principal when available' do + crypt = Module.new do + extend self + + def get(path); end + + def kerberos_principal + 'kerb_user' + end + end + stub_const('Legion::Crypt', crypt) + allow(crypt).to receive(:get) + + Legion::Extensions::Helpers::Secret.resolve_identity! + accessor[:api_key] + expect(crypt).to have_received(:get).with('users/kerb_user/github/api_key') + end + end +end + +RSpec.describe 'Helpers::Lex includes Secret' do + it 'includes Secret module' do + expect(Legion::Extensions::Helpers::Lex.ancestors).to include(Legion::Extensions::Helpers::Secret) + end +end diff --git a/spec/legion/extensions/helpers/segments_spec.rb b/spec/legion/extensions/helpers/segments_spec.rb new file mode 100644 index 00000000..90603b39 --- /dev/null +++ b/spec/legion/extensions/helpers/segments_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Helpers::Segments do + describe '.derive_segments' do + it 'returns single-element array for flat gem' do + expect(described_class.derive_segments('lex-node')).to eq(['node']) + end + + it 'preserves underscores within a segment' do + expect(described_class.derive_segments('lex-microsoft_teams')).to eq(['microsoft_teams']) + end + + it 'splits dashes into separate segments' do + expect(described_class.derive_segments('lex-agentic-cognitive-anchor')).to eq(%w[agentic cognitive anchor]) + end + + it 'handles dashes and underscores together' do + expect(described_class.derive_segments('lex-agentic-attention_spotlight')).to eq(%w[agentic attention_spotlight]) + end + + it 'handles deep nesting' do + expect(described_class.derive_segments('lex-agentic-cognitive-dissonance-resolution')) + .to eq(%w[agentic cognitive dissonance resolution]) + end + + it 'keeps compound LLM provider suffixes aligned with provider namespaces' do + expect(described_class.derive_segments('lex-llm-azure-foundry')).to eq(%w[llm azure_foundry]) + end + end + + describe '.derive_namespace' do + it 'capitalizes a single flat segment' do + expect(described_class.derive_namespace('lex-node')).to eq(['Node']) + end + + it 'converts underscored segment to CamelCase' do + expect(described_class.derive_namespace('lex-microsoft_teams')).to eq(['MicrosoftTeams']) + end + + it 'maps dashes to separate capitalized namespace parts' do + expect(described_class.derive_namespace('lex-agentic-cognitive-anchor')).to eq(%w[Agentic Cognitive Anchor]) + end + + it 'handles underscores within a nested segment' do + expect(described_class.derive_namespace('lex-agentic-attention_spotlight')).to eq(%w[Agentic AttentionSpotlight]) + end + + it 'derives the Azure Foundry LLM provider namespace' do + expect(described_class.derive_namespace('lex-llm-azure-foundry')).to eq(%w[Llm AzureFoundry]) + end + end + + describe '.derive_const_path' do + it 'returns flat Legion::Extensions::Name for single segment' do + expect(described_class.derive_const_path('lex-node')) + .to eq('Legion::Extensions::Node') + end + + it 'returns fully nested path for multi-segment gem' do + expect(described_class.derive_const_path('lex-agentic-cognitive-anchor')) + .to eq('Legion::Extensions::Agentic::Cognitive::Anchor') + end + + it 'handles underscored segments' do + expect(described_class.derive_const_path('lex-microsoft_teams')) + .to eq('Legion::Extensions::MicrosoftTeams') + end + + it 'derives the Azure Foundry LLM provider constant path' do + expect(described_class.derive_const_path('lex-llm-azure-foundry')) + .to eq('Legion::Extensions::Llm::AzureFoundry') + end + end + + describe '.derive_require_path' do + it 'returns flat path for single segment' do + expect(described_class.derive_require_path('lex-node')) + .to eq('legion/extensions/node') + end + + it 'returns nested path for multi-segment gem' do + expect(described_class.derive_require_path('lex-agentic-cognitive-anchor')) + .to eq('legion/extensions/agentic/cognitive/anchor') + end + + it 'preserves underscores in path segments' do + expect(described_class.derive_require_path('lex-microsoft_teams')) + .to eq('legion/extensions/microsoft_teams') + end + + it 'derives the Azure Foundry LLM provider require path' do + expect(described_class.derive_require_path('lex-llm-azure-foundry')) + .to eq('legion/extensions/llm/azure_foundry') + end + end + + describe '.segments_to_log_tag' do + it 'wraps each segment in brackets' do + expect(described_class.segments_to_log_tag(%w[agentic cognitive anchor])) + .to eq('[agentic][cognitive][anchor]') + end + + it 'handles single segment' do + expect(described_class.segments_to_log_tag(['node'])).to eq('[node]') + end + end + + describe '.segments_to_amqp_prefix' do + it 'prepends lex. and joins with dots' do + expect(described_class.segments_to_amqp_prefix(%w[agentic cognitive anchor])) + .to eq('lex.agentic.cognitive.anchor') + end + + it 'handles single segment' do + expect(described_class.segments_to_amqp_prefix(['node'])).to eq('lex.node') + end + end + + describe '.segments_to_settings_path' do + it 'converts strings to symbols' do + expect(described_class.segments_to_settings_path(%w[agentic cognitive anchor])) + .to eq(%i[agentic cognitive anchor]) + end + end + + describe '.segments_to_table_prefix' do + it 'joins with underscores' do + expect(described_class.segments_to_table_prefix(%w[agentic cognitive anchor])) + .to eq('agentic_cognitive_anchor') + end + + it 'handles single segment' do + expect(described_class.segments_to_table_prefix(['node'])).to eq('node') + end + end + + describe '.categorize_gem' do + let(:categories) do + { + core: { type: :list, tier: 1 }, + ai: { type: :list, tier: 2 }, + gaia: { type: :list, tier: 3 }, + agentic: { type: :prefix, tier: 4 } + } + end + let(:lists) { { core: %w[lex-node lex-tasker], ai: %w[lex-claude], gaia: %w[lex-tick] } } + + it 'identifies a core gem by list membership' do + result = described_class.categorize_gem('lex-node', categories: categories, lists: lists) + expect(result).to eq({ category: :core, tier: 1 }) + end + + it 'identifies an agentic gem by prefix' do + result = described_class.categorize_gem('lex-agentic-cognitive-anchor', categories: categories, lists: lists) + expect(result).to eq({ category: :agentic, tier: 4 }) + end + + it 'returns tier 5 default for uncategorized gems' do + result = described_class.categorize_gem('lex-consul', categories: categories, lists: lists) + expect(result).to eq({ category: :default, tier: 5 }) + end + + it 'list membership takes priority over prefix matching' do + # lex-core-thing would match prefix 'core' but core is a :list type not :prefix + # A gem in the lists hash takes priority + result = described_class.categorize_gem('lex-node', categories: categories, lists: lists) + expect(result[:category]).to eq(:core) + end + end +end diff --git a/spec/legion/extensions/helpers/transport_spec.rb b/spec/legion/extensions/helpers/transport_spec.rb new file mode 100644 index 00000000..59836ba5 --- /dev/null +++ b/spec/legion/extensions/helpers/transport_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Helpers::Transport do + before(:all) do + unless Legion::Extensions.const_defined?('Agentic', false) + agentic = Module.new + cognitive = Module.new + anchor = Module.new + cognitive.const_set('Anchor', anchor) + agentic.const_set('Cognitive', cognitive) + Legion::Extensions.const_set('Agentic', agentic) + end + end + + let(:mock_extension) do + Module.new do + extend Legion::Extensions::Helpers::Transport + + def self.calling_class_array + %w[Legion Extensions Agentic Cognitive Anchor] + end + + def self.transport_class + @transport_class ||= begin + mod = Module.new + mod.const_set('Exchanges', Module.new) + mod + end + end + + def self.full_path + '/fake/path' + end + end + end + + describe '#amqp_prefix' do + it 'returns dot-joined segments with lex prefix' do + expect(mock_extension.amqp_prefix).to eq('lex.agentic.cognitive.anchor') + end + end + + describe '#build_default_exchange' do + it 'creates an exchange class with exchange_name returning amqp_prefix' do + exchange_class = mock_extension.build_default_exchange + # Use allocate to skip initialize (which requires a live RabbitMQ connection) + expect(exchange_class.allocate.exchange_name).to eq('lex.agentic.cognitive.anchor') + end + + it 'registers the exchange constant under lex_const name' do + mock_extension.build_default_exchange + expect(mock_extension.transport_class::Exchanges.const_defined?('Anchor')).to be true + end + end + + let(:flat_extension) do + Module.new do + extend Legion::Extensions::Helpers::Transport + + def self.calling_class_array + %w[Legion Extensions Node] + end + + def self.transport_class + @transport_class ||= begin + mod = Module.new + mod.const_set('Exchanges', Module.new) + mod + end + end + + def self.full_path + '/fake/path' + end + end + end + + context 'with a flat extension (single segment)' do + before(:all) do + Legion::Extensions.const_set('Node', Module.new) unless Legion::Extensions.const_defined?('Node', false) + end + + describe '#amqp_prefix' do + it 'returns lex.node for a flat extension' do + expect(flat_extension.amqp_prefix).to eq('lex.node') + end + end + + describe '#build_default_exchange' do + it 'creates an exchange class with exchange_name returning lex.node' do + exchange_class = flat_extension.build_default_exchange + expect(exchange_class.allocate.exchange_name).to eq('lex.node') + end + + it 'registers the exchange constant under Node' do + flat_extension.build_default_exchange + expect(flat_extension.transport_class::Exchanges.const_defined?('Node')).to be true + end + end + end +end diff --git a/spec/legion/extensions/local_dispatch_spec.rb b/spec/legion/extensions/local_dispatch_spec.rb new file mode 100644 index 00000000..9ab3278f --- /dev/null +++ b/spec/legion/extensions/local_dispatch_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/extensions' +require 'legion/dispatch' + +RSpec.describe 'Extensions local dispatch wiring' do + before do + Legion::Dispatch.reset! + allow(Legion::Settings).to receive(:dig).and_call_original + end + + after { Legion::Dispatch.shutdown } + + describe 'hook_all_actors' do + it 'logs local task count alongside other actor types' do + allow(Legion::Extensions).to receive(:instance_variable_get).with(:@pending_actors).and_return([]) + expect(Legion::Dispatch.dispatcher).to be_a(Legion::Dispatch::Local) + end + end + + describe 'local_tasks accessor' do + it 'exposes local_tasks as an array' do + expect(Legion::Extensions.local_tasks).to be_an(Array).or be_nil + end + end + + describe 'dispatch_local_actors' do + it 'submits each local actor to Legion::Dispatch' do + mock_dispatcher = instance_double(Legion::Dispatch::Local, submit: nil, stop: nil, capacity: {}) + allow(Legion::Dispatch).to receive(:dispatcher).and_return(mock_dispatcher) + + runner_mod = Module.new { def self.action(**); end } + actor_hash = { + extension_name: 'test_ext', + actor_class: Class.new, + runner_class: runner_mod, + actor_name: 'test_actor' + } + + Legion::Extensions.send(:dispatch_local_actors, [actor_hash]) + expect(actor_hash).to have_key(:runner_module) + end + end +end diff --git a/spec/legion/extensions/permissions_spec.rb b/spec/legion/extensions/permissions_spec.rb new file mode 100644 index 00000000..d1b33407 --- /dev/null +++ b/spec/legion/extensions/permissions_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions::Permissions do + before { described_class.reset! } + + describe '.sandbox_path' do + it 'returns the default sandbox for an extension' do + path = described_class.sandbox_path('lex-github') + expect(path).to eq(File.expand_path('~/.legionio/data/lex-github')) + end + end + + describe '.allowed?' do + it 'always allows sandbox paths' do + path = File.expand_path('~/.legionio/data/lex-github/cache.json') + expect(described_class.allowed?('lex-github', path, :read)).to be true + end + + it 'denies paths outside sandbox by default' do + expect(described_class.allowed?('lex-github', '/etc/passwd', :read)).to be false + end + + it 'denies access to ~/.ssh even if explicitly approved' do + described_class.approve('lex-github', File.expand_path('~/.ssh/'), :read) + expect(described_class.allowed?('lex-github', File.expand_path('~/.ssh/id_rsa'), :read)).to be false + end + + it 'denies access to ~/.gnupg' do + expect(described_class.allowed?('lex-github', File.expand_path('~/.gnupg/private-keys'), :read)).to be false + end + + it 'denies access to ~/.aws/credentials' do + expect(described_class.allowed?('lex-github', File.expand_path('~/.aws/credentials'), :read)).to be false + end + + it 'allows paths matching auto-approve globs' do + described_class.add_auto_approve('lex-github', ['/Users/test/repos/**']) + expect(described_class.allowed?('lex-github', '/Users/test/repos/myapp/README.md', :read)).to be true + end + + it 'allows explicitly approved paths' do + described_class.approve('lex-github', '/var/log/github/', :read) + expect(described_class.allowed?('lex-github', '/var/log/github/app.log', :read)).to be true + end + end + + describe '.approve and .deny' do + it 'stores approval' do + described_class.approve('lex-github', '/tmp/test/', :write) + expect(described_class.approved?('lex-github', '/tmp/test/', :write)).to be true + end + + it 'stores denial' do + described_class.deny('lex-github', '/tmp/test/', :write) + expect(described_class.approved?('lex-github', '/tmp/test/', :write)).to be false + end + end + + describe '.declared_paths' do + it 'returns empty arrays for unknown extensions' do + result = described_class.declared_paths('lex-unknown') + expect(result).to eq({ read_paths: [], write_paths: [] }) + end + end +end diff --git a/spec/legion/extensions/registry_wiring_spec.rb b/spec/legion/extensions/registry_wiring_spec.rb new file mode 100644 index 00000000..ae41f22f --- /dev/null +++ b/spec/legion/extensions/registry_wiring_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/registry' + +RSpec.describe 'Extension Registry wiring' do + before { Legion::Registry.clear! } + + describe 'Legion::Extensions.register_in_registry' do + context 'when Legion::Registry is defined' do + it 'creates a Registry::Entry for a gem' do + allow(Gem::Specification).to receive(:find_by_name).with('lex-example').and_return( + instance_double(Gem::Specification, metadata: {}) + ) + + Legion::Extensions.register_in_registry(gem_name: 'lex-example', version: '1.0.0') + + entry = Legion::Registry.lookup('lex-example') + expect(entry).not_to be_nil + expect(entry.name).to eq('lex-example') + expect(entry.version).to eq('1.0.0') + expect(entry.airb_status).to eq('approved') + expect(entry.risk_tier).to eq('low') + end + + it 'reads capabilities from gemspec metadata when available' do + spec = instance_double(Gem::Specification, metadata: { 'legion.capabilities' => 'network:outbound, data:read' }) + allow(Gem::Specification).to receive(:find_by_name).with('lex-example').and_return(spec) + + Legion::Extensions.register_in_registry(gem_name: 'lex-example') + + entry = Legion::Registry.lookup('lex-example') + expect(entry.capabilities).to eq(%w[network:outbound data:read]) + end + + it 'registers with empty capabilities when gemspec has no legion.capabilities key' do + spec = instance_double(Gem::Specification, metadata: {}) + allow(Gem::Specification).to receive(:find_by_name).with('lex-example').and_return(spec) + + Legion::Extensions.register_in_registry(gem_name: 'lex-example') + + entry = Legion::Registry.lookup('lex-example') + expect(entry.capabilities).to eq([]) + end + + it 'registers with empty capabilities when gem is not found' do + allow(Gem::Specification).to receive(:find_by_name).with('lex-missing').and_raise(Gem::MissingSpecError.new('lex-missing', '>= 0')) + + Legion::Extensions.register_in_registry(gem_name: 'lex-missing') + + entry = Legion::Registry.lookup('lex-missing') + expect(entry).not_to be_nil + expect(entry.capabilities).to eq([]) + end + + it 'does not duplicate existing entries' do + spec = instance_double(Gem::Specification, metadata: {}) + allow(Gem::Specification).to receive(:find_by_name).with('lex-example').and_return(spec) + + Legion::Extensions.register_in_registry(gem_name: 'lex-example', version: '1.0.0') + Legion::Extensions.register_in_registry(gem_name: 'lex-example', version: '2.0.0') + + entry = Legion::Registry.lookup('lex-example') + expect(entry.version).to eq('1.0.0') + end + end + + context 'when Legion::Registry is not defined' do + it 'returns early without error' do + hide_const('Legion::Registry') + + expect do + Legion::Extensions.register_in_registry(gem_name: 'lex-example') + end.not_to raise_error + end + end + end +end diff --git a/spec/legion/extensions/remote_invocable_spec.rb b/spec/legion/extensions/remote_invocable_spec.rb new file mode 100644 index 00000000..429bc26e --- /dev/null +++ b/spec/legion/extensions/remote_invocable_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions do + describe '.resolve_remote_invocable' do + before do + allow(Legion::Settings).to receive(:dig).with(:extensions, anything).and_return(nil) + end + + context 'level 5: default' do + it 'returns true when nothing is configured' do + expect(described_class.send(:resolve_remote_invocable, :test_ext)).to be true + end + end + + context 'level 4: extension module method' do + it 'returns false when extension declares remote_invocable? false' do + ext = Module.new { def self.remote_invocable? = false } + expect(described_class.send(:resolve_remote_invocable, :test_ext, extension: ext)).to be false + end + + it 'returns true when extension declares remote_invocable? true' do + ext = Module.new { def self.remote_invocable? = true } + expect(described_class.send(:resolve_remote_invocable, :test_ext, extension: ext)).to be true + end + end + + context 'level 3: runner class method' do + it 'returns false when runner class declares remote_invocable? false' do + runner = Module.new { def self.remote_invocable? = false } + ext = Module.new { def self.remote_invocable? = true } + expect(described_class.send(:resolve_remote_invocable, :test_ext, runner_class: runner, extension: ext)).to be false + end + + it 'does not use remote_invocable? inherited from a superclass singleton chain' do + parent = Class.new { def self.remote_invocable? = false } + child = Class.new(parent) + # child inherits remote_invocable? from parent but does not define it directly + ext = Module.new { def self.remote_invocable? = true } + expect(described_class.send(:resolve_remote_invocable, :test_ext, runner_class: child, extension: ext)).to be true + end + + it 'honors remote_invocable? defined via extend' do + mod = Module.new { def remote_invocable? = false } + runner = Module.new { extend mod } + # runner has remote_invocable? via extend — should be used at level 3 + ext = Module.new { def self.remote_invocable? = true } + expect(described_class.send(:resolve_remote_invocable, :test_ext, runner_class: runner, extension: ext)).to be false + end + + it 'runner class method overrides extension module method' do + runner = Module.new { def self.remote_invocable? = false } + ext = Module.new { def self.remote_invocable? = true } + result = described_class.send(:resolve_remote_invocable, :test_ext, runner_class: runner, extension: ext) + expect(result).to be false + end + end + + context 'level 2: extension settings' do + before do + allow(Legion::Settings).to receive(:dig).with(:extensions, :test_ext).and_return({ remote_invocable: false }) + end + + it 'returns false from settings' do + expect(described_class.send(:resolve_remote_invocable, :test_ext)).to be false + end + + it 'extension settings override extension module method' do + ext = Module.new { def self.remote_invocable? = true } + expect(described_class.send(:resolve_remote_invocable, :test_ext, extension: ext)).to be false + end + end + + context 'level 1: per-runner settings (runner-specific override)' do + before do + allow(Legion::Settings).to receive(:dig).with(:extensions, :test_ext).and_return({ + runners: { my_runner: { remote_invocable: false } } + }) + end + + it 'returns false for the specific runner configured' do + expect(described_class.send(:resolve_remote_invocable, :test_ext, actor_name: :my_runner)).to be false + end + + it 'falls through to lower levels for unconfigured runners' do + # No extension-level setting, no runner class, no extension module — falls to default true + expect(described_class.send(:resolve_remote_invocable, :test_ext, actor_name: :other_runner)).to be true + end + + it 'per-runner settings override extension module method' do + ext = Module.new { def self.remote_invocable? = true } + expect(described_class.send(:resolve_remote_invocable, :test_ext, actor_name: :my_runner, extension: ext)).to be false + end + end + end + + describe '@local_tasks' do + it 'is accessible via attr_reader' do + expect(described_class).to respond_to(:local_tasks) + end + + it 'is initialized as an array after hook_extensions' do + described_class.instance_variable_set(:@local_tasks, []) + expect(described_class.local_tasks).to be_an(Array) + end + end +end diff --git a/spec/legion/extensions/runtime_handles_spec.rb b/spec/legion/extensions/runtime_handles_spec.rb new file mode 100644 index 00000000..a0e3140c --- /dev/null +++ b/spec/legion/extensions/runtime_handles_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions do + before do + described_class.reset_runtime_handles! + described_class.instance_variable_set(:@loaded_extensions, %w[lex-example]) + end + + after do + described_class.reset_runtime_handles! + described_class.instance_variable_set(:@loaded_extensions, nil) + described_class.instance_variable_set(:@extensions, nil) + end + + it 'exposes extension handles without requiring callers to read ivars' do + described_class.register_extension_handle('lex-example', state: :loaded) + described_class.transition_extension_handle('lex-example', :running) + + handle = described_class.extension_handle('lex-example') + + expect(handle.state).to eq(:running) + expect(described_class.extension_handles.map(&:lex_name)).to contain_exactly('lex-example') + expect(described_class.loaded_extensions).to eq(%w[lex-example]) + end + + it 'blocks dispatch when a handle is stopping or reloading' do + described_class.register_extension_handle('lex-example', state: :running) + expect(described_class.dispatch_allowed?('lex-example')).to be true + + described_class.update_extension_handle('lex-example', reload_state: :updating) + expect(described_class.dispatch_allowed?('lex-example')).to be false + + described_class.update_extension_handle('lex-example', reload_state: :idle, state: :stopping) + expect(described_class.dispatch_allowed?('lex-example')).to be false + end + + it 'does not expose modules for handles that are not dispatchable' do + ext_mod = Module.new do + def self.name = 'Legion::Extensions::Example' + def self.runner_modules = [] + end + described_class.const_set(:Example, ext_mod) + described_class.register_extension_handle('lex-example', state: :failed) + + expect(described_class.loaded_extension_modules).to be_empty + ensure + described_class.send(:remove_const, :Example) if described_class.const_defined?(:Example, false) + end + + it 'matches multi-segment extension modules to hyphenated lex handles' do + ext_mod = Module.new do + def self.name = 'Legion::Extensions::Llm::Openai' + def self.runner_modules = [] + end + described_class.const_set(:OpenaiForSpec, ext_mod) + described_class.register_extension_handle('lex-llm-openai', state: :running) + + expect(described_class.loaded_extension_modules).to contain_exactly(ext_mod) + ensure + described_class.send(:remove_const, :OpenaiForSpec) if described_class.const_defined?(:OpenaiForSpec, false) + end + + it 'does not mark a gem loaded when require fails' do + spec = instance_double(Gem::Specification, gem_dir: Dir.tmpdir, version: Gem::Version.new('1.2.3')) + allow(Gem::Specification).to receive(:find_by_name).with('lex-broken').and_return(spec) + + expect(described_class.send(:gem_load, { gem_name: 'lex-broken', require_path: 'missing_lex_for_spec' })).to be_nil + expect(described_class.extension_handle('lex-broken')).to be_nil + end + + it 'provides a scoped reload hook that quiesces, cleans callable state, and reopens dispatch' do + described_class.register_extension_handle('lex-example', state: :running, tools: ['legion-example-runner-call']) + allow(described_class).to receive(:unregister_capabilities) + stub_const('Legion::Ingress', Module.new) + allow(Legion::Ingress).to receive(:reset_runner_cache!) + + expect(described_class.reload_extension('lex-example')).to be true + + handle = described_class.extension_handle('lex-example') + expect(handle.state).to eq(:running) + expect(handle.reload_state).to eq(:idle) + expect(handle.last_error).to be_nil + expect(described_class).to have_received(:unregister_capabilities).with('lex-example') + expect(Legion::Ingress).to have_received(:reset_runner_cache!) + end + + it 'refreshes the LLM provider registry after hot-reloading a lex-llm provider extension' do + providers = Module.new do + def self.rediscover_all_providers; end + end + stub_const('Legion::LLM::Call::Providers', providers) + allow(providers).to receive(:rediscover_all_providers) + allow(described_class).to receive(:unregister_capabilities) + allow(described_class).to receive(:load_extension).and_return(true) + described_class.instance_variable_set(:@extensions, [{ gem_name: 'lex-llm-vllm' }]) + + expect(described_class.reload_extension('lex-llm-vllm')).to be true + + expect(providers).to have_received(:rediscover_all_providers) + end +end diff --git a/spec/legion/extensions/sandbox_wiring_spec.rb b/spec/legion/extensions/sandbox_wiring_spec.rb new file mode 100644 index 00000000..4ce82c69 --- /dev/null +++ b/spec/legion/extensions/sandbox_wiring_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/sandbox' + +RSpec.describe 'Extension Sandbox wiring' do + before { Legion::Sandbox.clear! } + + describe 'Legion::Extensions.register_sandbox_policy' do + context 'when Legion::Sandbox is defined' do + it 'registers a policy from a capabilities list' do + Legion::Extensions.register_sandbox_policy( + gem_name: 'lex-example', + capabilities: %w[network:outbound data:read] + ) + + policy = Legion::Sandbox.policy_for('lex-example') + expect(policy).not_to be_nil + expect(policy.allowed?('network:outbound')).to be true + expect(policy.allowed?('data:read')).to be true + end + + it 'registers an empty policy when no capabilities are given' do + Legion::Extensions.register_sandbox_policy(gem_name: 'lex-empty') + + policy = Legion::Sandbox.policy_for('lex-empty') + expect(policy.capabilities).to eq([]) + expect(policy.allowed?('network:outbound')).to be false + end + + it 'filters out unknown capabilities' do + Legion::Extensions.register_sandbox_policy( + gem_name: 'lex-example', + capabilities: %w[network:outbound totally:fake] + ) + + policy = Legion::Sandbox.policy_for('lex-example') + expect(policy.capabilities).to eq(%w[network:outbound]) + end + end + + context 'when Legion::Sandbox is not defined' do + it 'returns early without error' do + hide_const('Legion::Sandbox') + + expect do + Legion::Extensions.register_sandbox_policy(gem_name: 'lex-example', capabilities: %w[network:outbound]) + end.not_to raise_error + end + end + end +end diff --git a/spec/legion/extensions/yaml_agents_spec.rb b/spec/legion/extensions/yaml_agents_spec.rb new file mode 100644 index 00000000..8d3249c0 --- /dev/null +++ b/spec/legion/extensions/yaml_agents_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' + +RSpec.describe 'Legion::Extensions YAML agent loading' do + before do + Legion::Extensions.instance_variable_set(:@load_yaml_agents, nil) + + # Stub the AgentLoader in case the installed legion-settings gem doesn't yet include it + unless defined?(Legion::Settings::AgentLoader) + stub_const('Legion::Settings::AgentLoader', Module.new do + def self.load_agents(dir) + return [] unless dir && Dir.exist?(dir) + + require 'yaml' + Dir.glob(File.join(dir, '*.{yaml,yml,json}')).filter_map do |path| + content = File.read(path) + defn = YAML.safe_load(content, symbolize_names: true) + next unless defn.is_a?(Hash) && defn[:name] && defn.dig(:runner, :functions)&.any? + + defn + end + end + end) + $LOADED_FEATURES << 'legion/settings/agent_loader.rb' + end + end + + describe '.load_yaml_agents' do + context 'when agents directory exists' do + let(:agents_dir) { Dir.mktmpdir } + let(:agent_yaml) do + { + 'name' => 'test-yaml-agent', + 'version' => '1.0', + 'runner' => { + 'functions' => [ + { 'name' => 'greet', 'type' => 'llm', 'prompt' => 'Hello {{name}}', 'model' => 'test' } + ] + } + } + end + + before do + require 'yaml' + File.write(File.join(agents_dir, 'test.yaml'), YAML.dump(agent_yaml)) + allow(Legion::Settings).to receive(:dig).with(:agents, :directory).and_return(agents_dir) + end + + after { FileUtils.rm_rf(agents_dir) } + + it 'loads agent definitions and generates runner modules' do + agents = Legion::Extensions.load_yaml_agents + expect(agents).to be_an(Array) + expect(agents.size).to eq(1) + expect(agents.first[:name]).to eq('test-yaml-agent') + end + + it 'generates a runner module with defined methods' do + agents = Legion::Extensions.load_yaml_agents + runner_mod = agents.first[:_runner_module] + expect(runner_mod).to be_a(Module) + + instance = Object.new.extend(runner_mod) + expect(instance).to respond_to(:greet) + end + end + + context 'when agents directory does not exist' do + before do + allow(Legion::Settings).to receive(:dig).with(:agents, :directory).and_return(nil) + end + + it 'returns empty array' do + expect(Legion::Extensions.load_yaml_agents).to eq([]) + end + end + end +end diff --git a/spec/legion/extensions_manifest_spec.rb b/spec/legion/extensions_manifest_spec.rb new file mode 100644 index 00000000..72f054d1 --- /dev/null +++ b/spec/legion/extensions_manifest_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/cli/lex_cli_manifest' + +RSpec.describe 'Legion::Extensions CLI manifest wiring' do + let(:cache_dir) { Dir.mktmpdir } + + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + end + + after { FileUtils.remove_entry(cache_dir) } + + describe '.build_manifest_commands' do + let(:runner_module) { Module.new } + let(:extension) do + rm = runner_module + mod = Module.new + mod.define_singleton_method(:runners) do + { + search: { + runner_name: :search, + runner_module: rm, + class_methods: { + execute: { args: [%i[keyreq query], %i[key limit]] }, + _internal_hook: { args: [] } + } + }, + empty_runner: { + runner_name: :empty_runner, + runner_module: Module.new, + class_methods: {} + } + } + end + mod + end + + it 'builds commands from runners, skipping underscore-prefixed methods' do + result = Legion::Extensions.send(:build_manifest_commands, extension) + expect(result).to have_key('search') + expect(result['search'][:methods]).to have_key('execute') + expect(result['search'][:methods]).not_to have_key('_internal_hook') + end + + it 'skips runners with no public methods' do + result = Legion::Extensions.send(:build_manifest_commands, extension) + expect(result).not_to have_key('empty_runner') + end + + it 'includes argument metadata' do + result = Legion::Extensions.send(:build_manifest_commands, extension) + args = result['search'][:methods]['execute'][:args] + expect(args).to include('query:keyreq') + expect(args).to include('limit:key') + end + + it 'returns empty hash when extension has no runners method' do + bare = Module.new + expect(Legion::Extensions.send(:build_manifest_commands, bare)).to eq({}) + end + end + + describe '.write_lex_cli_manifest' do + let(:extension) do + mod = Module.new + mod.const_set(:VERSION, '1.2.3') + mod.define_singleton_method(:runners) { {} } + mod + end + let(:entry) { { gem_name: 'lex-test-manifest' } } + + it 'writes manifest when stale' do + manifest = Legion::CLI::LexCliManifest.new(cache_dir: cache_dir) + allow(Legion::CLI::LexCliManifest).to receive(:new).and_return(manifest) + + Legion::Extensions.send(:write_lex_cli_manifest, entry, extension) + + data = manifest.read_manifest('lex-test-manifest') + expect(data).not_to be_nil + expect(data['version']).to eq('1.2.3') + expect(data['alias']).to eq('test-manifest') + end + + it 'skips write when manifest is fresh' do + manifest = Legion::CLI::LexCliManifest.new(cache_dir: cache_dir) + manifest.write_manifest(gem_name: 'lex-test-manifest', gem_version: '1.2.3', + alias_name: 'test-manifest', commands: {}) + allow(Legion::CLI::LexCliManifest).to receive(:new).and_return(manifest) + allow(manifest).to receive(:write_manifest).and_call_original + + Legion::Extensions.send(:write_lex_cli_manifest, entry, extension) + + expect(manifest).not_to have_received(:write_manifest) + end + + it 'does not raise on error' do + allow(Legion::CLI::LexCliManifest).to receive(:new).and_raise(Errno::EACCES, 'permission denied') + expect { Legion::Extensions.send(:write_lex_cli_manifest, entry, extension) }.not_to raise_error + end + end +end diff --git a/spec/legion/extensions_pause_spec.rb b/spec/legion/extensions_pause_spec.rb new file mode 100644 index 00000000..9e269e9d --- /dev/null +++ b/spec/legion/extensions_pause_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions do + describe '.pause_actors' do + before do + allow(Legion::Logging).to receive(:warn) + allow(Legion::Logging).to receive(:error) + end + + it 'shuts down all timer tasks on running instances' do + timer1 = instance_double(Concurrent::TimerTask, shutdown: true) + timer2 = instance_double(Concurrent::TimerTask, shutdown: true) + + inst1 = double('actor1') + inst2 = double('actor2') + allow(inst1).to receive(:instance_variable_get).with(:@timer).and_return(timer1) + allow(inst2).to receive(:instance_variable_get).with(:@timer).and_return(timer2) + + described_class.instance_variable_set(:@running_instances, Concurrent::Array.new([inst1, inst2])) + + described_class.pause_actors + + expect(timer1).to have_received(:shutdown) + expect(timer2).to have_received(:shutdown) + end + + it 'skips instances without a timer' do + inst = double('actor_no_timer') + allow(inst).to receive(:instance_variable_get).with(:@timer).and_return(nil) + + described_class.instance_variable_set(:@running_instances, Concurrent::Array.new([inst])) + + expect { described_class.pause_actors }.not_to raise_error + end + + it 'does not raise when running_instances is nil' do + described_class.instance_variable_set(:@running_instances, nil) + + expect { described_class.pause_actors }.not_to raise_error + end + + it 'rescues errors from individual actors' do + inst = double('bad_actor') + allow(inst).to receive(:instance_variable_get).with(:@timer).and_raise(StandardError, 'oops') + + described_class.instance_variable_set(:@running_instances, Concurrent::Array.new([inst])) + + expect { described_class.pause_actors }.not_to raise_error + end + + it 'logs that actors were paused' do + described_class.instance_variable_set(:@running_instances, Concurrent::Array.new) + + described_class.pause_actors + + expect(Legion::Logging).to have_received(:warn).with('All actors paused') + end + end +end diff --git a/spec/legion/extensions_phased_loading_spec.rb b/spec/legion/extensions_phased_loading_spec.rb new file mode 100644 index 00000000..fea8d527 --- /dev/null +++ b/spec/legion/extensions_phased_loading_spec.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Extensions do + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:warn) + allow(Legion::Logging).to receive(:debug) + end + + describe '.group_by_phase' do + before do + described_class.instance_variable_set(:@extensions, extensions) + end + + after do + described_class.instance_variable_set(:@extensions, nil) + end + + context 'with identity and default extensions' do + let(:extensions) do + [ + { gem_name: 'lex-identity-kerberos', category: :identity, tier: 0 }, + { gem_name: 'lex-identity-ldap', category: :identity, tier: 0 }, + { gem_name: 'lex-identity-system', category: :identity, tier: 0 }, + { gem_name: 'lex-http', category: :core, tier: 1 }, + { gem_name: 'lex-redis', category: :core, tier: 1 }, + { gem_name: 'lex-agentic-memory', category: :agentic, tier: 4 } + ] + end + + it 'groups identity extensions into phase 0' do + phases = described_class.send(:group_by_phase) + identity_phase = phases.find { |num, _| num == 0 } + expect(identity_phase).not_to be_nil + names = identity_phase.last.map { |e| e[:gem_name] } + expect(names).to contain_exactly('lex-identity-kerberos', 'lex-identity-ldap', 'lex-identity-system') + end + + it 'groups non-identity extensions into phase 1' do + phases = described_class.send(:group_by_phase) + main_phase = phases.find { |num, _| num == 1 } + expect(main_phase).not_to be_nil + names = main_phase.last.map { |e| e[:gem_name] } + expect(names).to contain_exactly('lex-http', 'lex-redis', 'lex-agentic-memory') + end + + it 'returns phases sorted by phase number (0 before 1)' do + phases = described_class.send(:group_by_phase) + expect(phases.map(&:first)).to eq([0, 1]) + end + end + + context 'with no identity extensions' do + let(:extensions) do + [ + { gem_name: 'lex-http', category: :core, tier: 1 }, + { gem_name: 'lex-redis', category: :core, tier: 1 } + ] + end + + it 'has no phase 0' do + phases = described_class.send(:group_by_phase) + identity_phase = phases.find { |num, _| num == 0 } + expect(identity_phase).to be_nil + end + + it 'puts everything in phase 1' do + phases = described_class.send(:group_by_phase) + expect(phases.size).to eq(1) + expect(phases.first.first).to eq(1) + end + end + + context 'with default category extensions' do + let(:extensions) do + [ + { gem_name: 'lex-custom-thing', category: :default, tier: 5 } + ] + end + + it 'assigns default category to phase 1' do + phases = described_class.send(:group_by_phase) + expect(phases.first.first).to eq(1) + end + end + end + + describe '.hook_extensions' do + let(:lex_llm) { { gem_name: 'lex-llm', category: :default, tier: 5 } } + let(:lex_llm_openai) { { gem_name: 'lex-llm-openai', category: :default, tier: 5 } } + let(:lex_llm_ollama) { { gem_name: 'lex-llm-ollama', category: :default, tier: 5 } } + let(:lex_http) { { gem_name: 'lex-http', category: :core, tier: 1 } } + let(:lex_identity) { { gem_name: 'lex-identity-system', category: :identity, tier: 0 } } + + before do + allow(described_class).to receive(:find_extensions) + allow(described_class).to receive(:transition_loaded_extensions) + allow(described_class).to receive(:load_yaml_agents) + allow(described_class).to receive(:reset_runtime_handles!) + allow(Legion::Extensions::Catalog).to receive(:flush_persisted_transitions) + end + + it 'loads lex-llm before lex-llm provider extensions and normal phases' do + phases = [ + [0, [lex_identity]], + [1, [lex_llm_openai, lex_http, lex_llm, lex_llm_ollama]] + ] + loaded_phases = [] + + allow(described_class).to receive(:group_by_phase).and_return(phases) + allow(described_class).to receive(:load_phase_extensions) do |phase_name, entries| + loaded_phases << [phase_name, entries.map { |entry| entry[:gem_name] }] + end + allow(described_class).to receive(:hook_phase_actors) + + described_class.hook_extensions + + expect(loaded_phases).to eq( + [ + [0, ['lex-identity-system']], + [:llm_base, ['lex-llm']], + [:llm_extensions, %w[lex-llm-ollama lex-llm-openai]], + [1, ['lex-http']] + ] + ) + end + + it 'keeps normal phase loading unchanged when no lex-llm gems are discovered' do + phases = [ + [0, [lex_identity]], + [1, [lex_http]] + ] + loaded_phases = [] + + allow(described_class).to receive(:group_by_phase).and_return(phases) + allow(described_class).to receive(:load_phase_extensions) do |phase_name, entries| + loaded_phases << [phase_name, entries.map { |entry| entry[:gem_name] }] + end + allow(described_class).to receive(:hook_phase_actors) + + described_class.hook_extensions + + expect(loaded_phases).to eq( + [ + [0, ['lex-identity-system']], + [1, ['lex-http']] + ] + ) + end + + it 'loads lex-llm before providers discovered through Bundler' do + phases = [ + [1, [lex_llm_openai, lex_http, lex_llm, lex_llm_ollama]] + ] + loaded_names = [] + + allow(described_class).to receive(:group_by_phase).and_return(phases) + allow(described_class).to receive(:load_phase_extensions) do |_phase_name, entries| + loaded_names.concat(entries.map { |entry| entry[:gem_name] }) + end + allow(described_class).to receive(:hook_phase_actors) + + described_class.hook_extensions + + expect(loaded_names.index('lex-llm')).to be < loaded_names.index('lex-llm-openai') + expect(loaded_names.index('lex-llm')).to be < loaded_names.index('lex-llm-ollama') + end + + it 'wires local lex-llm provider gems after the base gem in the Gemfile' do + gemfile = File.read(File.expand_path('../../Gemfile', __dir__)) + base_index = gemfile.index("gem 'lex-llm'") + provider_list_index = gemfile.index('%w[anthropic azure-foundry bedrock gemini mlx ollama openai vertex vllm]') + provider_token = ['#', '{provider}'].join + provider_gem_index = gemfile.index(%(gem "lex-llm-#{provider_token}")) + + expect(base_index).not_to be_nil + expect(provider_list_index).not_to be_nil + expect(provider_gem_index).not_to be_nil + expect(base_index).to be < provider_list_index + expect(base_index).to be < provider_gem_index + end + + it 'wires legion-llm for local development when present' do + gemfile = File.read(File.expand_path('../../Gemfile', __dir__)) + + expect(gemfile).to include("gem 'legion-llm', path: '../legion-llm'") + expect(gemfile).to include("File.exist?(File.expand_path('../legion-llm', __dir__))") + end + + it 'wires legion-tty for local development when present' do + gemfile = File.read(File.expand_path('../../Gemfile', __dir__)) + + expect(gemfile).to include("gem 'legion-tty', path: '../legion-tty'") + expect(gemfile).to include("File.exist?(File.expand_path('../legion-tty', __dir__))") + end + + it 'wires hosted lex-llm provider gems for local development' do + gemfile = File.read(File.expand_path('../../Gemfile', __dir__)) + + expect(gemfile).to include('azure-foundry') + expect(gemfile).to include('bedrock') + expect(gemfile).to include('vertex') + end + + it 'wires lex-llm-ledger for local development when present' do + gemfile = File.read(File.expand_path('../../Gemfile', __dir__)) + + expect(gemfile).to include("gem 'lex-llm-ledger', path: '../extensions-ai/lex-llm-ledger'") + expect(gemfile).to include("File.exist?(File.expand_path('../extensions-ai/lex-llm-ledger', __dir__))") + end + end + + describe '.require_identity_extensions' do + let(:lex_identity) do + { + gem_name: 'lex-identity-system', + category: :identity, + tier: 0, + segments: %w[identity system], + const_path: 'Legion::Extensions::Identity::System', + require_path: 'legion/extensions/identity/system', + settings_path: %i[identity system] + } + end + let(:lex_http) do + { + gem_name: 'lex-http', + category: :core, + tier: 1, + segments: ['http'], + const_path: 'Legion::Extensions::Http', + require_path: 'legion/extensions/http', + settings_path: [:http] + } + end + + before do + allow(described_class).to receive(:find_extensions).and_return([lex_identity, lex_http]) + allow(described_class).to receive(:extension_settings_for_entry).and_return({}) + allow(described_class).to receive(:latest_installed_version) + allow(described_class).to receive(:register_extension_handle) + allow(described_class).to receive(:ensure_namespace) + allow(described_class).to receive(:gem_load) + allow(Legion::Extensions::Catalog).to receive(:register) + end + + it 'requires identity extension files without loading non-identity extensions' do + described_class.require_identity_extensions + + expect(described_class).to have_received(:gem_load).with(lex_identity) + expect(described_class).not_to have_received(:gem_load).with(lex_http) + end + + it 'does not require disabled identity extensions' do + allow(described_class).to receive(:extension_settings_for_entry).with(lex_identity).and_return(enabled: false) + allow(described_class).to receive(:extension_settings_for_entry).with(lex_http).and_return({}) + + described_class.require_identity_extensions + + expect(described_class).not_to have_received(:gem_load) + end + end + + describe '.default_category_registry' do + subject(:registry) { described_class.send(:default_category_registry) } + + it 'includes identity category at phase 0' do + expect(registry[:identity][:phase]).to eq(0) + end + + it 'includes identity category with prefix type' do + expect(registry[:identity][:type]).to eq(:prefix) + end + + it 'includes identity category at tier 0' do + expect(registry[:identity][:tier]).to eq(0) + end + + it 'assigns all other categories to phase 1' do + non_identity = registry.except(:identity) + non_identity.each_value do |v| + expect(v[:phase]).to eq(1), "Expected phase 1 for #{v}" + end + end + end +end diff --git a/spec/legion/fleet/conditioner_rules_spec.rb b/spec/legion/fleet/conditioner_rules_spec.rb new file mode 100644 index 00000000..fbf853a7 --- /dev/null +++ b/spec/legion/fleet/conditioner_rules_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/fleet/conditioner_rules' + +RSpec.describe Legion::Fleet::ConditionerRules do + describe '.rules' do + subject(:rules) { described_class.rules } + + it 'returns an array' do + expect(rules).to be_a(Array) + end + + it 'has rules for fleet routing' do + expect(rules).not_to be_empty + end + + it 'each rule has required keys' do + rules.each do |rule| + expect(rule).to have_key(:name) + expect(rule).to have_key(:conditions) + end + end + + it 'includes planning skip rule' do + names = rules.map { |r| r[:name] } + expect(names).to include('fleet-skip-planning-trivial') + end + + it 'includes escalation rule' do + names = rules.map { |r| r[:name] } + expect(names).to include('fleet-escalate-max-iterations') + end + end + + describe '.seed!' do + it 'is defined as a class method' do + expect(described_class).to respond_to(:seed!) + end + + it 'returns a result hash' do + result = described_class.seed! + expect(result).to be_a(Hash) + expect(result).to have_key(:success) + end + end +end diff --git a/spec/legion/fleet/integration_spec.rb b/spec/legion/fleet/integration_spec.rb new file mode 100644 index 00000000..0c66997b --- /dev/null +++ b/spec/legion/fleet/integration_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/workflow/manifest' +require 'legion/fleet/settings_defaults' +require 'legion/fleet/conditioner_rules' +require 'legion/cli/output' +require 'legion/cli/fleet_setup' +require 'legion/cli/fleet_command' + +RSpec.describe 'Fleet CLI Integration' do + describe 'manifest + settings + rules coherence' do + let(:manifest_path) { Legion::CLI::FleetSetup::MANIFEST_PATH } + let(:manifest) { Legion::Workflow::Manifest.new(path: manifest_path) } + let(:settings) { Legion::Fleet::SettingsDefaults.defaults } + let(:rules) { Legion::Fleet::ConditionerRules.rules } + + it 'manifest is valid' do + expect(manifest).to be_valid + end + + it 'manifest defines exactly 10 relationships' do + expect(manifest.relationships.size).to eq(10) + end + + it 'manifest max_iterations threshold matches settings default' do + # Relationship 7 (index 8) uses attempt < 4, which means max 5 total runs + # Settings default max_iterations is 5 + rel7 = manifest.relationships[8] + threshold = rel7[:conditions][:all].find { |c| c[:fact] == 'results.pipeline.attempt' }[:value] + max_iter = settings.dig(:fleet, :implementation, :max_iterations) + # threshold should be max_iter - 1 (because attempt starts at 0) + expect(threshold).to eq(max_iter - 1) + end + + it 'all manifest extensions have corresponding gems in fleet_gems' do + required_extensions = manifest.relationships.flat_map do |rel| + [rel[:trigger][:extension], rel[:action][:extension]] + end.uniq + + gem_names = Legion::CLI::FleetSetup::FLEET_GEMS.map { |g| g.sub('lex-', '') } + required_extensions.each do |ext| + expect(gem_names).to include(ext), + "Extension '#{ext}' in manifest but 'lex-#{ext}' not in FLEET_GEMS" + end + end + + it 'conditioner rules reference valid operators' do + valid_binary = %w[equal not_equal greater_than less_than greater_or_equal + less_or_equal between contains starts_with ends_with + matches in_set not_in_set size_equal] + valid_unary = %w[empty not_empty nil not_nil is_true is_false + is_array is_string is_integer] + valid_ops = valid_binary + valid_unary + + rules.each do |rule| + next unless rule[:conditions] + + conditions = rule[:conditions][:all] || rule[:conditions][:any] || [] + conditions.each do |cond| + expect(valid_ops).to include(cond[:operator]), + "Rule '#{rule[:name]}' uses invalid operator '#{cond[:operator]}'" + end + end + end + + it 'manifest conditions use valid operators' do + valid_ops = %w[equal not_equal greater_than less_than greater_or_equal + less_or_equal between contains starts_with ends_with + matches in_set not_in_set size_equal] + + manifest.relationships.each do |rel| + next unless rel[:conditions] + + conditions = rel[:conditions][:all] || rel[:conditions][:any] || [] + conditions.each do |cond| + expect(valid_ops).to include(cond[:operator]), + "Relationship '#{rel[:name]}' uses invalid operator '#{cond[:operator]}'" + end + end + end + + it 'manifest conditions prefix facts with results.' do + manifest.relationships.each do |rel| + next unless rel[:conditions] + + conditions = rel[:conditions][:all] || rel[:conditions][:any] || [] + conditions.each do |cond| + expect(cond[:fact]).to start_with('results.'), + "Relationship '#{rel[:name]}' fact '#{cond[:fact]}' missing 'results.' prefix" + end + end + end + + it 'entry relationships allow new chains' do + # Relationships 1 and 2 (assessor -> planner/developer) must allow new chains + expect(manifest.relationships[0][:allow_new_chains]).to be true + expect(manifest.relationships[1][:allow_new_chains]).to be true + end + + it 'non-entry relationships default to no new chains' do + # Relationships 3-8 plus 4b,4c (indices 2-9) should not allow new chains + (2..9).each do |idx| + rel_name = manifest.relationships[idx][:name] + expect(manifest.relationships[idx][:allow_new_chains]).to be(false), + "Relationship at index #{idx} (#{rel_name}) should not allow new chains" + end + end + + it 'boolean condition values are actual booleans not strings' do + manifest.relationships.each do |rel| + next unless rel[:conditions] + + conditions = rel[:conditions][:all] || rel[:conditions][:any] || [] + conditions.each do |cond| + next unless [true, false, 'true', 'false'].include?(cond[:value]) + + expect(cond[:value]).to satisfy("be a boolean (not string) in '#{rel[:name]}'") { |v| + v.is_a?(TrueClass) || v.is_a?(FalseClass) + } + end + end + end + end + + describe 'FleetCommand class' do + it 'has all expected commands' do + expected = %w[status pending approve add config] + expected.each do |cmd| + expect(Legion::CLI::FleetCommand.commands).to have_key(cmd), + "Missing fleet command: #{cmd}" + end + end + end + + describe 'FleetSetup class' do + it 'fleet_gems includes all required gems' do + gems = Legion::CLI::FleetSetup.fleet_gems + expect(gems.size).to be >= 10 + end + + it 'manifest_path points to existing file' do + expect(File.exist?(Legion::CLI::FleetSetup.manifest_path)).to be true + end + end +end diff --git a/spec/legion/fleet/manifest_spec.rb b/spec/legion/fleet/manifest_spec.rb new file mode 100644 index 00000000..3a07ebb7 --- /dev/null +++ b/spec/legion/fleet/manifest_spec.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/workflow/manifest' + +RSpec.describe 'Fleet Manifest' do + let(:manifest_path) { File.expand_path('../../../lib/legion/fleet/manifest.yml', __dir__) } + let(:manifest) { Legion::Workflow::Manifest.new(path: manifest_path) } + + it 'loads without error' do + expect { manifest }.not_to raise_error + end + + it 'has the correct name' do + expect(manifest.name).to eq('fleet-pipeline') + end + + it 'has a version' do + expect(manifest.version).to match(/\A\d+\.\d+\.\d+\z/) + end + + it 'has a description' do + expect(manifest.description).not_to be_nil + end + + it 'defines exactly 10 relationships' do + expect(manifest.relationships.size).to eq(10) + end + + it 'is valid' do + expect(manifest).to be_valid + end + + it 'requires fleet extension gems' do + expect(manifest.requires).to include('lex-assessor', 'lex-planner', 'lex-developer', 'lex-validator') + end + + describe 'relationship 1: assessor -> planner (planning enabled)' do + subject(:rel) { manifest.relationships[0] } + + it 'triggers from assessor.assess' do + expect(rel[:trigger]).to eq({ extension: 'assessor', runner: 'assessor', function: 'assess' }) + end + + it 'routes to planner.plan' do + expect(rel[:action]).to eq({ extension: 'planner', runner: 'planner', function: 'plan' }) + end + + it 'conditions on planning.enabled == true' do + expect(rel[:conditions][:all]).to include( + { fact: 'results.config.planning.enabled', operator: 'equal', value: true } + ) + end + + it 'allows new chains (entry relationship)' do + expect(rel[:allow_new_chains]).to be true + end + end + + describe 'relationship 2: assessor -> developer (planning disabled)' do + subject(:rel) { manifest.relationships[1] } + + it 'triggers from assessor.assess' do + expect(rel[:trigger]).to eq({ extension: 'assessor', runner: 'assessor', function: 'assess' }) + end + + it 'routes to developer.implement' do + expect(rel[:action]).to eq({ extension: 'developer', runner: 'developer', function: 'implement' }) + end + + it 'conditions on planning.enabled == false' do + expect(rel[:conditions][:all]).to include( + { fact: 'results.config.planning.enabled', operator: 'equal', value: false } + ) + end + + it 'allows new chains (entry relationship)' do + expect(rel[:allow_new_chains]).to be true + end + end + + describe 'relationship 3: planner -> developer' do + subject(:rel) { manifest.relationships[2] } + + it 'triggers from planner.plan' do + expect(rel[:trigger]).to eq({ extension: 'planner', runner: 'planner', function: 'plan' }) + end + + it 'routes to developer.implement' do + expect(rel[:action]).to eq({ extension: 'developer', runner: 'developer', function: 'implement' }) + end + + it 'does not allow new chains (inherits from entry)' do + expect(rel[:allow_new_chains]).to be false + end + end + + describe 'relationship 4: developer -> validator (validation enabled)' do + subject(:rel) { manifest.relationships[3] } + + it 'triggers from developer.implement' do + expect(rel[:trigger]).to eq({ extension: 'developer', runner: 'developer', function: 'implement' }) + end + + it 'routes to validator.validate' do + expect(rel[:action]).to eq({ extension: 'validator', runner: 'validator', function: 'validate' }) + end + + it 'conditions on validation.enabled == true' do + expect(rel[:conditions][:all]).to include( + { fact: 'results.config.validation.enabled', operator: 'equal', value: true } + ) + end + end + + describe 'relationship 4b: developer feedback -> validator (validation enabled)' do + subject(:rel) { manifest.relationships[4] } + + it 'triggers from developer.incorporate_feedback' do + expect(rel[:trigger]).to eq({ extension: 'developer', runner: 'developer', function: 'incorporate_feedback' }) + end + + it 'routes to validator.validate' do + expect(rel[:action]).to eq({ extension: 'validator', runner: 'validator', function: 'validate' }) + end + + it 'conditions on validation.enabled == true' do + expect(rel[:conditions][:all]).to include( + { fact: 'results.config.validation.enabled', operator: 'equal', value: true } + ) + end + + it 'does not allow new chains' do + expect(rel[:allow_new_chains]).to be false + end + end + + describe 'relationship 4c: developer feedback -> escalate (escalate flag)' do + subject(:rel) { manifest.relationships[5] } + + it 'triggers from developer.incorporate_feedback' do + expect(rel[:trigger]).to eq({ extension: 'developer', runner: 'developer', function: 'incorporate_feedback' }) + end + + it 'routes to assessor.escalate' do + expect(rel[:action]).to eq({ extension: 'assessor', runner: 'assessor', function: 'escalate' }) + end + + it 'conditions on results.escalate == true' do + expect(rel[:conditions][:all]).to include( + { fact: 'results.escalate', operator: 'equal', value: true } + ) + end + + it 'does not allow new chains' do + expect(rel[:allow_new_chains]).to be false + end + end + + describe 'relationship 5: developer -> ship (validation disabled)' do + subject(:rel) { manifest.relationships[6] } + + it 'triggers from developer.implement' do + expect(rel[:trigger]).to eq({ extension: 'developer', runner: 'developer', function: 'implement' }) + end + + it 'routes to ship.finalize' do + expect(rel[:action]).to eq({ extension: 'developer', runner: 'ship', function: 'finalize' }) + end + + it 'conditions on validation.enabled == false' do + expect(rel[:conditions][:all]).to include( + { fact: 'results.config.validation.enabled', operator: 'equal', value: false } + ) + end + end + + describe 'relationship 6: validator -> ship (approved)' do + subject(:rel) { manifest.relationships[7] } + + it 'triggers from validator.validate' do + expect(rel[:trigger]).to eq({ extension: 'validator', runner: 'validator', function: 'validate' }) + end + + it 'routes to ship.finalize' do + expect(rel[:action]).to eq({ extension: 'developer', runner: 'ship', function: 'finalize' }) + end + + it 'conditions on verdict == approved' do + expect(rel[:conditions][:all]).to include( + { fact: 'results.pipeline.review_result.verdict', operator: 'equal', value: 'approved' } + ) + end + end + + describe 'relationship 7: validator -> developer feedback (rejected, under limit)' do + subject(:rel) { manifest.relationships[8] } + + it 'routes to developer.incorporate_feedback' do + expect(rel[:action]).to eq({ extension: 'developer', runner: 'developer', function: 'incorporate_feedback' }) + end + + it 'conditions on verdict == rejected AND attempt < 4' do + conditions = rel[:conditions][:all] + expect(conditions).to include( + { fact: 'results.pipeline.review_result.verdict', operator: 'equal', value: 'rejected' } + ) + expect(conditions).to include( + { fact: 'results.pipeline.attempt', operator: 'less_than', value: 4 } + ) + end + + it 'does not allow new chains (feedback stays in existing chain)' do + expect(rel[:allow_new_chains]).to be false + end + end + + describe 'relationship 8: validator -> escalate (rejected, at limit)' do + subject(:rel) { manifest.relationships[9] } + + it 'routes to assessor.escalate' do + expect(rel[:action]).to eq({ extension: 'assessor', runner: 'assessor', function: 'escalate' }) + end + + it 'conditions on verdict == rejected AND attempt >= 4' do + conditions = rel[:conditions][:all] + expect(conditions).to include( + { fact: 'results.pipeline.review_result.verdict', operator: 'equal', value: 'rejected' } + ) + expect(conditions).to include( + { fact: 'results.pipeline.attempt', operator: 'greater_or_equal', value: 4 } + ) + end + end + + describe 'settings defaults' do + it 'includes fleet settings' do + expect(manifest.settings).to include(:fleet) + end + + it 'enables escalation in LLM routing' do + expect(manifest.settings.dig(:fleet, :llm, :routing, :escalation, :enabled)).to be true + end + end +end diff --git a/spec/legion/fleet/settings_defaults_spec.rb b/spec/legion/fleet/settings_defaults_spec.rb new file mode 100644 index 00000000..f3f08e9f --- /dev/null +++ b/spec/legion/fleet/settings_defaults_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/fleet/settings_defaults' + +RSpec.describe Legion::Fleet::SettingsDefaults do + describe '.defaults' do + subject(:defaults) { described_class.defaults } + + it 'returns a hash' do + expect(defaults).to be_a(Hash) + end + + it 'includes fleet key' do + expect(defaults).to have_key(:fleet) + end + + it 'enables fleet by default' do + expect(defaults[:fleet][:enabled]).to be true + end + + it 'starts with empty sources list' do + expect(defaults[:fleet][:sources]).to eq([]) + end + + it 'enables LLM escalation' do + expect(defaults.dig(:fleet, :llm, :routing, :escalation, :enabled)).to be true + end + + it 'sets default implementation max_iterations to 5' do + expect(defaults.dig(:fleet, :implementation, :max_iterations)).to eq(5) + end + + it 'sets default implementation validators to 3' do + expect(defaults.dig(:fleet, :implementation, :validators)).to eq(3) + end + + it 'uses worktree isolation by default' do + expect(defaults.dig(:fleet, :workspace, :isolation)).to eq(:worktree) + end + + it 'sets consent domain to fleet.shipping' do + expect(defaults.dig(:fleet, :escalation, :consent_domain)).to eq('fleet.shipping') + end + end + + describe '.write_settings_file' do + let(:tmpdir) { Dir.mktmpdir } + let(:settings_path) { File.join(tmpdir, 'fleet.json') } + + after { FileUtils.rm_rf(tmpdir) } + + it 'writes a valid JSON file' do + described_class.write_settings_file(settings_path) + expect(File.exist?(settings_path)).to be true + data = JSON.parse(File.read(settings_path), symbolize_names: true) + expect(data).to have_key(:fleet) + end + + it 'does not overwrite existing file without force' do + File.write(settings_path, '{"existing": true}') + described_class.write_settings_file(settings_path, force: false) + data = JSON.parse(File.read(settings_path)) + expect(data).to have_key('existing') + end + + it 'overwrites existing file with force' do + File.write(settings_path, '{"existing": true}') + described_class.write_settings_file(settings_path, force: true) + data = JSON.parse(File.read(settings_path), symbolize_names: true) + expect(data).to have_key(:fleet) + end + end +end diff --git a/spec/legion/fleet/settings_spec.rb b/spec/legion/fleet/settings_spec.rb new file mode 100644 index 00000000..42c4b5c2 --- /dev/null +++ b/spec/legion/fleet/settings_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/fleet/settings' + +RSpec.describe Legion::Fleet::Settings do + describe 'FLEET_DEFAULTS' do + subject { described_class::FLEET_DEFAULTS } + + it 'is frozen' do + expect(subject).to be_frozen + end + + it 'disables fleet by default' do + expect(subject[:enabled]).to be false + end + + it 'sets poison_message_threshold to 2' do + expect(subject[:poison_message_threshold]).to eq(2) + end + + it 'sets transport retry_base_delay_seconds' do + expect(subject[:transport][:retry_base_delay_seconds]).to eq(1) + end + + it 'sets transport retry_max_delay_seconds' do + expect(subject[:transport][:retry_max_delay_seconds]).to eq(30) + end + + it 'sets git clone depth' do + expect(subject[:git][:depth]).to eq(5) + end + + it 'sets workspace base_dir' do + expect(subject[:workspace][:base_dir]).to eq('~/.legionio/fleet/repos') + end + + it 'sets workspace worktree_base' do + expect(subject[:workspace][:worktree_base]).to eq('~/.legionio/fleet/worktrees') + end + + it 'sets workspace isolation to worktree' do + expect(subject[:workspace][:isolation]).to eq(:worktree) + end + + it 'sets workspace cleanup_on_complete to true' do + expect(subject[:workspace][:cleanup_on_complete]).to be true + end + + it 'sets workspace cleanup_clones to false' do + expect(subject[:workspace][:cleanup_clones]).to be false + end + + it 'sets materialization strategy to clone' do + expect(subject[:materialization][:strategy]).to eq(:clone) + end + + it 'sets cache dedup TTL' do + expect(subject[:cache][:dedup_ttl_seconds]).to eq(86_400) + end + + it 'sets cache payload TTL' do + expect(subject[:cache][:payload_ttl_seconds]).to eq(86_400) + end + + it 'sets cache context TTL' do + expect(subject[:cache][:context_ttl_seconds]).to eq(86_400) + end + + it 'sets cache worktree TTL' do + expect(subject[:cache][:worktree_ttl_seconds]).to eq(86_400) + end + + it 'enables planning by default' do + expect(subject[:planning][:enabled]).to be true + end + + it 'sets planning solvers to 1' do + expect(subject[:planning][:solvers]).to eq(1) + end + + it 'sets planning validators to 2' do + expect(subject[:planning][:validators]).to eq(2) + end + + it 'sets planning max_iterations to 5' do + expect(subject[:planning][:max_iterations]).to eq(5) + end + + it 'sets implementation solvers to 1' do + expect(subject[:implementation][:solvers]).to eq(1) + end + + it 'sets implementation validators to 3' do + expect(subject[:implementation][:validators]).to eq(3) + end + + it 'sets implementation max_iterations to 5' do + expect(subject[:implementation][:max_iterations]).to eq(5) + end + + it 'enables validation by default' do + expect(subject[:validation][:enabled]).to be true + end + + it 'enables adversarial_review' do + expect(subject[:validation][:adversarial_review]).to be true + end + + it 'sets validation quality_gate_threshold' do + expect(subject[:validation][:quality_gate_threshold]).to eq(0.8) + end + + it 'enables feedback drain' do + expect(subject[:feedback][:drain_enabled]).to be true + end + + it 'sets feedback max_drain_rounds to 3' do + expect(subject[:feedback][:max_drain_rounds]).to eq(3) + end + + it 'sets context max_context_files to 50' do + expect(subject[:context][:max_context_files]).to eq(50) + end + + it 'sets llm thinking_budget_base_tokens' do + expect(subject[:llm][:thinking_budget_base_tokens]).to eq(16_000) + end + + it 'sets llm thinking_budget_max_tokens' do + expect(subject[:llm][:thinking_budget_max_tokens]).to eq(64_000) + end + + it 'sets llm validator_timeout_seconds to 120' do + expect(subject[:llm][:validator_timeout_seconds]).to eq(120) + end + + it 'sets github pr_files_per_page to 30' do + expect(subject[:github][:pr_files_per_page]).to eq(30) + end + + it 'sets escalation on_max_iterations to human' do + expect(subject[:escalation][:on_max_iterations]).to eq(:human) + end + + it 'sets escalation consent_domain' do + expect(subject[:escalation][:consent_domain]).to eq('fleet.shipping') + end + end + + describe 'LLM_ROUTING_OVERRIDES' do + subject { described_class::LLM_ROUTING_OVERRIDES } + + it 'enables escalation' do + expect(subject[:escalation][:enabled]).to be true + end + + it 'enables pipeline_enabled' do + expect(subject[:escalation][:pipeline_enabled]).to be true + end + + it 'sets max_attempts to 3' do + expect(subject[:escalation][:max_attempts]).to eq(3) + end + + it 'sets quality_threshold to 50' do + expect(subject[:escalation][:quality_threshold]).to eq(50) + end + + it 'is frozen' do + expect(subject).to be_frozen + end + end + + describe '.apply!' do + context 'when Legion::Settings is defined' do + let(:loader) { double('loader') } + + before do + allow(Legion::Settings).to receive(:loader).and_return(loader) + allow(loader).to receive(:load_module_settings) + end + + it 'loads fleet defaults into settings' do + expect(loader).to receive(:load_module_settings).with( + { fleet: Legion::Fleet::Settings::FLEET_DEFAULTS } + ) + allow(loader).to receive(:load_module_settings).with(anything) + Legion::Fleet::Settings.apply! + end + + it 'loads LLM routing overrides into settings' do + allow(loader).to receive(:load_module_settings).with(hash_including(fleet: anything)) + expect(loader).to receive(:load_module_settings).with( + { llm: { routing: Legion::Fleet::Settings::LLM_ROUTING_OVERRIDES } } + ) + Legion::Fleet::Settings.apply! + end + + it 'calls load_module_settings twice' do + expect(loader).to receive(:load_module_settings).twice + Legion::Fleet::Settings.apply! + end + end + + context 'when Legion::Settings is not defined' do + it 'returns without error' do + hide_const('Legion::Settings') + expect { Legion::Fleet::Settings.apply! }.not_to raise_error + end + end + end +end diff --git a/spec/legion/graph/builder_spec.rb b/spec/legion/graph/builder_spec.rb new file mode 100644 index 00000000..d1fb2892 --- /dev/null +++ b/spec/legion/graph/builder_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/graph/builder' + +RSpec.describe Legion::Graph::Builder do + describe '.build' do + it 'returns empty graph when db unavailable' do + allow(described_class).to receive(:db_available?).and_return(false) + result = described_class.build + expect(result[:nodes]).to be_empty + expect(result[:edges]).to be_empty + end + end +end diff --git a/spec/legion/graph/exporter_spec.rb b/spec/legion/graph/exporter_spec.rb new file mode 100644 index 00000000..e63bb99f --- /dev/null +++ b/spec/legion/graph/exporter_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/graph/exporter' + +RSpec.describe Legion::Graph::Exporter do + let(:graph) do + { + nodes: { + 'a' => { label: 'TaskA', type: 'trigger' }, + 'b' => { label: 'TaskB', type: 'action' } + }, + edges: [{ from: 'a', to: 'b', label: 'process' }] + } + end + + let(:empty_label_graph) do + { + nodes: { + 'x' => { label: 'X', type: 'trigger' }, + 'y' => { label: 'Y', type: 'action' } + }, + edges: [{ from: 'x', to: 'y', label: '' }] + } + end + + describe '.to_mermaid' do + it 'produces valid mermaid syntax' do + output = described_class.to_mermaid(graph) + expect(output).to include('graph TD') + expect(output).to include('-->|process|') + end + + it 'handles edges without labels' do + output = described_class.to_mermaid(empty_label_graph) + expect(output).to include('-->') + expect(output).not_to include('-->|') + end + end + + describe '.to_dot' do + it 'produces valid DOT syntax' do + output = described_class.to_dot(graph) + expect(output).to include('digraph legion_tasks') + expect(output).to include('"a" -> "b"') + expect(output).to include('shape=box') + expect(output).to include('shape=ellipse') + end + + it 'includes edge labels in DOT output' do + output = described_class.to_dot(graph) + expect(output).to include('label="process"') + end + end +end diff --git a/spec/legion/graph_spec.rb b/spec/legion/graph_spec.rb new file mode 100644 index 00000000..67aab371 --- /dev/null +++ b/spec/legion/graph_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/graph/builder' +require 'legion/graph/exporter' + +RSpec.describe Legion::Graph do + describe Legion::Graph::Builder do + describe '.build' do + context 'when database is not available' do + it 'returns empty graph' do + result = described_class.build + expect(result[:nodes]).to eq({}) + expect(result[:edges]).to eq([]) + end + end + end + end + + describe Legion::Graph::Exporter do + let(:graph) do + { + nodes: { + 'task_a' => { label: 'Task A', type: 'trigger' }, + 'task_b' => { label: 'Task B', type: 'action' }, + 'task_c' => { label: 'Task C', type: 'action' } + }, + edges: [ + { from: 'task_a', to: 'task_b', label: 'on_success', chain_id: 'c1' }, + { from: 'task_b', to: 'task_c', label: '', chain_id: 'c1' } + ] + } + end + + describe '.to_mermaid' do + it 'starts with graph TD' do + result = described_class.to_mermaid(graph) + expect(result).to start_with('graph TD') + end + + it 'includes node definitions' do + result = described_class.to_mermaid(graph) + expect(result).to include('Task A') + expect(result).to include('Task B') + expect(result).to include('Task C') + end + + it 'includes labeled edges' do + result = described_class.to_mermaid(graph) + expect(result).to include('on_success') + end + + it 'uses simple arrow for unlabeled edges' do + result = described_class.to_mermaid(graph) + expect(result).to match(/N\d+ --> N\d+/) + end + + it 'handles empty graph' do + result = described_class.to_mermaid({ nodes: {}, edges: [] }) + expect(result).to eq('graph TD') + end + end + + describe '.to_dot' do + it 'starts with digraph declaration' do + result = described_class.to_dot(graph) + expect(result).to start_with('digraph legion_tasks {') + end + + it 'ends with closing brace' do + result = described_class.to_dot(graph) + expect(result.strip).to end_with('}') + end + + it 'uses box shape for trigger nodes' do + result = described_class.to_dot(graph) + expect(result).to include('shape=box') + end + + it 'uses ellipse shape for action nodes' do + result = described_class.to_dot(graph) + expect(result).to include('shape=ellipse') + end + + it 'includes edge labels' do + result = described_class.to_dot(graph) + expect(result).to include('label="on_success"') + end + + it 'includes rankdir' do + result = described_class.to_dot(graph) + expect(result).to include('rankdir=LR') + end + + it 'handles empty graph' do + result = described_class.to_dot({ nodes: {}, edges: [] }) + expect(result).to include('digraph legion_tasks') + expect(result).to include('}') + end + end + end +end diff --git a/spec/legion/guardrails_spec.rb b/spec/legion/guardrails_spec.rb new file mode 100644 index 00000000..0b6a074e --- /dev/null +++ b/spec/legion/guardrails_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/guardrails' + +RSpec.describe Legion::Guardrails::EmbeddingSimilarity do + describe '.cosine_distance' do + it 'returns 0 for identical vectors' do + v = [1.0, 0.0, 0.0] + expect(described_class.cosine_distance(v, v)).to be_within(0.001).of(0.0) + end + + it 'returns 1 for orthogonal vectors' do + a = [1.0, 0.0] + b = [0.0, 1.0] + expect(described_class.cosine_distance(a, b)).to be_within(0.001).of(1.0) + end + + it 'handles empty vectors' do + expect(described_class.cosine_distance([], [])).to eq(1.0) + end + + it 'handles nil vectors' do + expect(described_class.cosine_distance(nil, nil)).to eq(1.0) + end + end + + describe '.check' do + it 'returns safe when no LLM' do + result = described_class.check('test', safe_embeddings: [], threshold: 0.3) + expect(result[:safe]).to be true + end + end +end + +RSpec.describe Legion::Guardrails do + describe 'SYSTEM_CALLER' do + subject(:caller_hash) { described_class::SYSTEM_CALLER } + + it 'nests identity under requested_by' do + expect(caller_hash[:requested_by][:identity]).to eq('system:guardrails') + end + + it 'uses :system type to trigger system pipeline profile' do + expect(caller_hash[:requested_by][:type]).to eq(:system) + end + + it 'uses :internal credential' do + expect(caller_hash[:requested_by][:credential]).to eq(:internal) + end + + it 'is frozen' do + expect(caller_hash).to be_frozen + end + end +end + +RSpec.describe Legion::Guardrails::RAGRelevancy do + describe '.check' do + it 'returns relevant when no LLM' do + result = described_class.check(question: 'q', context: 'c', answer: 'a') + expect(result[:relevant]).to be true + end + + context 'when Legion::LLM is available' do + let(:llm_result) { { content: '4' } } + + before do + stub_const('Legion::LLM', Module.new) + allow(Legion::LLM).to receive(:chat).and_return(llm_result) + end + + it 'passes the system caller identity to avoid pipeline recursion' do + described_class.check(question: 'q', context: 'c', answer: 'a') + expect(Legion::LLM).to have_received(:chat).with( + hash_including(caller: Legion::Guardrails::SYSTEM_CALLER) + ) + end + + it 'returns relevant when score meets threshold' do + result = described_class.check(question: 'q', context: 'c', answer: 'a', threshold: 3) + expect(result[:relevant]).to be true + expect(result[:score]).to eq(4) + end + + it 'returns not relevant when score is below threshold' do + allow(Legion::LLM).to receive(:chat).and_return({ content: '1' }) + result = described_class.check(question: 'q', context: 'c', answer: 'a', threshold: 3) + expect(result[:relevant]).to be false + expect(result[:score]).to eq(1) + end + + it 'returns relevant: true on LLM error' do + allow(Legion::LLM).to receive(:chat).and_raise(StandardError, 'boom') + result = described_class.check(question: 'q', context: 'c', answer: 'a') + expect(result[:relevant]).to be true + expect(result[:reason]).to eq('check failed') + end + end + end +end diff --git a/spec/legion/identity/broker_spec.rb b/spec/legion/identity/broker_spec.rb new file mode 100644 index 00000000..0ef22fff --- /dev/null +++ b/spec/legion/identity/broker_spec.rb @@ -0,0 +1,863 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/identity/lease' +require 'legion/identity/lease_renewer' +require 'legion/identity/process' +require 'legion/identity/broker' + +RSpec.describe Legion::Identity::Broker do + def make_lease(valid: true, token: 'tok.abc123', expires_at: Time.now + 3600, renewable: true) + double( + 'Lease', + valid?: valid, + token: token, + expires_at: expires_at, + renewable: renewable, + to_h: { token: token, valid: valid } + ) + end + + def make_static_lease(token: 'static.key') + double( + 'StaticLease', + valid?: true, + token: token, + expires_at: nil, + renewable: false, + to_h: { token: token, valid: true } + ) + end + + def make_renewer(lease: make_lease) + double('LeaseRenewer', current_lease: lease, stop!: nil) + end + + before(:each) { described_class.reset! } + after(:each) { described_class.reset! } + + # --------------------------------------------------------------------------- + # token_for + # --------------------------------------------------------------------------- + describe '.token_for' do + context 'when provider is registered with a valid lease' do + before do + renewer = make_renewer(lease: make_lease(token: 'vault.token')) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + end + + it 'returns the lease token' do + expect(described_class.token_for(:vault)).to eq('vault.token') + end + end + + context 'when provider is not registered' do + it 'returns nil' do + expect(described_class.token_for(:unknown)).to be_nil + end + end + + context 'when the lease is invalid/expired' do + before do + renewer = make_renewer(lease: make_lease(valid: false, token: 'stale')) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + end + + it 'returns nil' do + expect(described_class.token_for(:vault)).to be_nil + end + end + + context 'when the renewer has a nil lease' do + before do + renewer = make_renewer(lease: nil) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + end + + it 'returns nil' do + expect(described_class.token_for(:vault)).to be_nil + end + end + end + + describe '.credential_for' do + it 'returns the raw token for a registered provider' do + described_class.register_provider(:anthropic, provider: double('p'), lease: make_static_lease(token: 'sk-ant')) + + expect(described_class.credential_for(:anthropic)).to eq('sk-ant') + end + + it 'returns nil when the provider has no valid credential' do + expect(described_class.credential_for(:anthropic)).to be_nil + end + end + + # --------------------------------------------------------------------------- + # credentials_for + # --------------------------------------------------------------------------- + describe '.credentials_for' do + context 'when provider is registered with a valid lease' do + let(:lease) { make_lease(token: 'cred.token') } + + before do + renewer = make_renewer(lease: lease) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:kerberos, provider: double('p'), lease: make_lease) + end + + it 'returns a hash with token' do + result = described_class.credentials_for(:kerberos) + expect(result[:token]).to eq('cred.token') + end + + it 'returns a hash with provider' do + result = described_class.credentials_for(:kerberos) + expect(result[:provider]).to eq(:kerberos) + end + + it 'returns a hash with service when provided' do + result = described_class.credentials_for(:kerberos, service: 'HTTP/host.example.com') + expect(result[:service]).to eq('HTTP/host.example.com') + end + + it 'returns nil for service when not provided' do + result = described_class.credentials_for(:kerberos) + expect(result[:service]).to be_nil + end + + it 'returns the lease object' do + result = described_class.credentials_for(:kerberos) + expect(result[:lease]).to equal(lease) + end + end + + context 'when provider is not registered' do + it 'returns nil' do + expect(described_class.credentials_for(:ghost)).to be_nil + end + end + + context 'when the lease is invalid' do + before do + renewer = make_renewer(lease: make_lease(valid: false)) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + end + + it 'returns nil' do + expect(described_class.credentials_for(:vault)).to be_nil + end + end + end + + # --------------------------------------------------------------------------- + # register_provider + # --------------------------------------------------------------------------- + describe '.register_provider' do + it 'creates a LeaseRenewer for the provider' do + renewer = make_renewer + expect(Legion::Identity::LeaseRenewer).to receive(:new).with( + provider_name: :vault, + provider: anything, + lease: anything + ).and_return(renewer) + + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + expect(described_class.providers).to include(:vault) + end + + it 'stops the existing renewer before replacing it' do + old_renewer = make_renewer + new_renewer = make_renewer + + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(old_renewer, new_renewer) + + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + + expect(old_renewer).to receive(:stop!) + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + end + + it 'accepts string provider names and converts to symbol' do + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + + described_class.register_provider('ldap', provider: double('p'), lease: make_lease) + expect(described_class.providers).to include(:ldap) + end + + context 'with a static credential (expires_at: nil, renewable: false)' do + it 'does NOT create a LeaseRenewer' do + expect(Legion::Identity::LeaseRenewer).not_to receive(:new) + described_class.register_provider(:openai, provider: double('p'), lease: make_static_lease) + end + + it 'includes the provider in providers list' do + described_class.register_provider(:openai, provider: double('p'), lease: make_static_lease) + expect(described_class.providers).to include(:openai) + end + + it 'stores the lease so token_for returns the token' do + described_class.register_provider(:openai, provider: double('p'), lease: make_static_lease(token: 'sk-abc')) + expect(described_class.token_for(:openai)).to eq('sk-abc') + end + + it 'stores the lease so lease_for returns the lease object' do + lease = make_static_lease + described_class.register_provider(:openai, provider: double('p'), lease: lease) + expect(described_class.lease_for(:openai)).to equal(lease) + end + + it 'returns nil from renewer_for' do + described_class.register_provider(:openai, provider: double('p'), lease: make_static_lease) + expect(described_class.renewer_for(:openai)).to be_nil + end + + it 'stops any existing renewer before switching to static' do + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:openai, provider: double('p'), lease: make_lease) + + expect(renewer).to receive(:stop!) + described_class.register_provider(:openai, provider: double('p'), lease: make_static_lease) + end + + it 'replaces a static lease when re-registered' do + described_class.register_provider(:openai, provider: double('p'), lease: make_static_lease(token: 'old')) + described_class.register_provider(:openai, provider: double('p'), lease: make_static_lease(token: 'new')) + expect(described_class.token_for(:openai)).to eq('new') + end + end + + context 'switching from static to dynamic' do + it 'removes the static lease and creates a LeaseRenewer' do + described_class.register_provider(:vault, provider: double('p'), lease: make_static_lease) + + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + + expect(described_class.renewer_for(:vault)).to equal(renewer) + expect(described_class.lease_for(:vault)).to eq(renewer.current_lease) + end + end + end + + # --------------------------------------------------------------------------- + # lease_for + # --------------------------------------------------------------------------- + describe '.lease_for' do + context 'when provider has a dynamic renewer' do + before do + lease = make_lease(token: 'dyn.tok') + renewer = make_renewer(lease: lease) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + end + + it 'returns the current lease from the renewer' do + result = described_class.lease_for(:vault) + expect(result.token).to eq('dyn.tok') + end + end + + context 'when provider has a static lease' do + it 'returns the stored static lease' do + lease = make_static_lease(token: 'api.key') + described_class.register_provider(:openai, provider: double('p'), lease: lease) + expect(described_class.lease_for(:openai)).to equal(lease) + end + end + + context 'when provider is not registered' do + it 'returns nil' do + expect(described_class.lease_for(:unknown)).to be_nil + end + end + end + + # --------------------------------------------------------------------------- + # renewer_for + # --------------------------------------------------------------------------- + describe '.renewer_for' do + context 'when provider has a dynamic renewer' do + it 'returns the LeaseRenewer instance' do + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:kerberos, provider: double('p'), lease: make_lease) + + expect(described_class.renewer_for(:kerberos)).to equal(renewer) + end + end + + context 'when provider is static' do + it 'returns nil' do + described_class.register_provider(:openai, provider: double('p'), lease: make_static_lease) + expect(described_class.renewer_for(:openai)).to be_nil + end + end + + context 'when provider is not registered' do + it 'returns nil' do + expect(described_class.renewer_for(:ghost)).to be_nil + end + end + end + + # --------------------------------------------------------------------------- + # refresh_credential + # --------------------------------------------------------------------------- + describe '.refresh_credential' do + context 'when provider is static and supports provide_token' do + let(:new_lease) { make_static_lease(token: 'refreshed.key') } + let(:provider) { double('StaticProvider', provide_token: new_lease) } + + before do + described_class.register_provider(:openai, provider: provider, lease: make_static_lease(token: 'old.key')) + end + + it 'returns true' do + expect(described_class.refresh_credential(:openai)).to be(true) + end + + it 'updates the stored lease' do + described_class.refresh_credential(:openai) + expect(described_class.token_for(:openai)).to eq('refreshed.key') + end + end + + context 'when provider is dynamic (not static)' do + it 'returns false' do + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double('p'), lease: make_lease) + + expect(described_class.refresh_credential(:vault)).to be(false) + end + end + + context 'when provider is not registered' do + it 'returns false' do + expect(described_class.refresh_credential(:unknown)).to be(false) + end + end + + context 'when provider returns nil from provide_token' do + it 'returns false and does not change the existing lease' do + provider = double('BadProvider', provide_token: nil) + described_class.register_provider(:openai, provider: provider, lease: make_static_lease(token: 'orig.key')) + + result = described_class.refresh_credential(:openai) + expect(result).to be(false) + expect(described_class.token_for(:openai)).to eq('orig.key') + end + end + end + + # --------------------------------------------------------------------------- + # authenticated? + # --------------------------------------------------------------------------- + describe '.authenticated?' do + it 'delegates to Identity::Process.resolved? when true' do + allow(Legion::Identity::Process).to receive(:resolved?).and_return(true) + expect(described_class.authenticated?).to be(true) + end + + it 'delegates to Identity::Process.resolved? when false' do + allow(Legion::Identity::Process).to receive(:resolved?).and_return(false) + expect(described_class.authenticated?).to be(false) + end + end + + # --------------------------------------------------------------------------- + # groups + # --------------------------------------------------------------------------- + describe '.groups' do + before do + allow(Legion::Identity::Process).to receive(:identity_hash).and_return({ groups: [] }) + allow(Legion::Identity::Process).to receive(:id).and_return('principal-1') + end + + context 'when cache is warm and within TTL' do + it 'returns cached groups without re-fetching' do + allow(Legion::Identity::Process).to receive(:identity_hash) + .and_return({ groups: %w[admin ops] }) + + first_call = described_class.groups + expect(Legion::Identity::Process).not_to receive(:identity_hash) + second_call = described_class.groups + + expect(first_call).to eq(%w[admin ops]) + expect(second_call).to eq(%w[admin ops]) + end + end + + context 'when cache is empty' do + it 'fetches groups from Identity::Process when non-empty' do + allow(Legion::Identity::Process).to receive(:identity_hash) + .and_return({ groups: %w[dev qa] }) + + expect(described_class.groups).to eq(%w[dev qa]) + end + + it 'returns empty array when Process groups are empty and DB unavailable' do + allow(Legion::Identity::Process).to receive(:identity_hash) + .and_return({ groups: [] }) + hide_const('Legion::Data') if defined?(Legion::Data) + + expect(described_class.groups).to eq([]) + end + end + + context 'after TTL expires' do + it 'fetches fresh groups' do + allow(Legion::Identity::Process).to receive(:identity_hash) + .and_return({ groups: ['initial'] }, { groups: ['refreshed'] }) + + described_class.groups + + described_class.send(:instance_variable_get, :@groups_cache) + .set({ groups: ['initial'], fetched_at: Time.now - (described_class::GROUPS_CACHE_TTL + 1) }) + + result = described_class.groups + expect(result).to eq(['refreshed']) + end + end + + context 'single-flight: concurrent calls when fetch is in progress' do + it 'does not trigger multiple concurrent fetches when stale cache exists' do + # Prime the cache with a stale entry + allow(Legion::Identity::Process).to receive(:identity_hash) + .and_return({ groups: ['stale'] }) + described_class.groups + + # Now make the cache stale by backdating fetched_at + described_class.instance_variable_get(:@groups_cache) + .set({ groups: ['stale'], fetched_at: Time.now - 120 }) + + fetch_count = Concurrent::AtomicFixnum.new(0) + allow(Legion::Identity::Process).to receive(:identity_hash) do + fetch_count.increment + sleep 0.05 + { groups: ['concurrent'] } + end + + threads = Array.new(5) { Thread.new { described_class.groups } } + results = threads.map(&:value) + + expect(fetch_count.value).to be <= 2 + results.each { |r| expect(r).to include('stale').or include('concurrent') } + end + end + end + + # --------------------------------------------------------------------------- + # invalidate_groups_cache! + # --------------------------------------------------------------------------- + describe '.invalidate_groups_cache!' do + it 'clears the groups cache so the next call re-fetches' do + allow(Legion::Identity::Process).to receive(:identity_hash) + .and_return({ groups: %w[cached] }, { groups: %w[fresh] }) + + described_class.groups + described_class.invalidate_groups_cache! + + expect(described_class.groups).to eq(%w[fresh]) + end + end + + # --------------------------------------------------------------------------- + # emails + # --------------------------------------------------------------------------- + describe '.emails' do + it 'returns emails from Process identity_hash metadata' do + allow(Legion::Identity::Process).to receive(:identity_hash) + .and_return({ metadata: { emails: %w[a@example.com b@example.com] } }) + + expect(described_class.emails).to eq(%w[a@example.com b@example.com]) + end + + it 'returns empty array when metadata has no emails' do + allow(Legion::Identity::Process).to receive(:identity_hash).and_return({}) + expect(described_class.emails).to eq([]) + end + end + + # --------------------------------------------------------------------------- + # providers + # --------------------------------------------------------------------------- + describe '.providers' do + it 'returns empty array initially' do + expect(described_class.providers).to eq([]) + end + + it 'returns registered provider names as symbols' do + r1 = make_renewer + r2 = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(r1, r2) + + described_class.register_provider(:vault, provider: double, lease: make_lease) + described_class.register_provider(:kerberos, provider: double, lease: make_lease) + + expect(described_class.providers).to contain_exactly(:vault, :kerberos) + end + end + + # --------------------------------------------------------------------------- + # leases + # --------------------------------------------------------------------------- + describe '.leases' do + it 'returns a nested hash of provider -> qualifier -> lease.to_h' do + lease = make_lease(token: 'mytok') + renewer = make_renewer(lease: lease) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + + described_class.register_provider(:vault, provider: double, lease: make_lease) + + result = described_class.leases + expect(result[:vault]).to be_a(Hash) + expect(result[:vault][:default]).to eq({ token: 'mytok', valid: true }) + end + + it 'returns nil for qualifiers with no current lease' do + renewer = make_renewer(lease: nil) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double, lease: make_lease) + + expect(described_class.leases[:vault][:default]).to be_nil + end + end + + # --------------------------------------------------------------------------- + # shutdown + # --------------------------------------------------------------------------- + describe '.shutdown' do + it 'calls stop! on all registered renewers' do + r1 = make_renewer + r2 = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(r1, r2) + + described_class.register_provider(:vault, provider: double, lease: make_lease) + described_class.register_provider(:kerberos, provider: double, lease: make_lease) + + expect(r1).to receive(:stop!) + expect(r2).to receive(:stop!) + + described_class.shutdown + end + + it 'clears the providers list after shutdown' do + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double, lease: make_lease) + + described_class.shutdown + expect(described_class.providers).to be_empty + end + end + + # --------------------------------------------------------------------------- + # reset! + # --------------------------------------------------------------------------- + describe '.reset!' do + it 'stops all renewers' do + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double, lease: make_lease) + + expect(renewer).to receive(:stop!) + described_class.reset! + end + + it 'clears all providers' do + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:vault, provider: double, lease: make_lease) + + described_class.reset! + expect(described_class.providers).to be_empty + end + + it 'resets the groups cache so next groups call re-fetches' do + allow(Legion::Identity::Process).to receive(:identity_hash) + .and_return({ groups: %w[before] }, { groups: %w[after] }) + + described_class.groups + described_class.reset! + + expect(described_class.groups).to eq(%w[after]) + end + + it 'resets the in-progress flag to false' do + described_class.reset! + flag = described_class.instance_variable_get(:@groups_fetch_in_progress) + expect(flag.true?).to be(false) + end + end + + # --------------------------------------------------------------------------- + # backward-compatible registration (no qualifier) + # --------------------------------------------------------------------------- + describe 'backward-compatible registration (no qualifier)' do + it 'registers and retrieves a token without specifying qualifier' do + renewer = make_renewer(lease: make_lease(token: 'compat.tok')) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:test, provider: double('p'), lease: make_lease) + + expect(described_class.token_for(:test)).to eq('compat.tok') + end + + it 'includes the provider in the providers list' do + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:test, provider: double('p'), lease: make_lease) + + expect(described_class.providers).to include(:test) + end + + it 'returns a lease from lease_for without qualifier' do + lease = make_lease(token: 'compat.lease') + renewer = make_renewer(lease: lease) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:test, provider: double('p'), lease: make_lease) + + expect(described_class.lease_for(:test).token).to eq('compat.lease') + end + + it 'returns credentials from credentials_for without qualifier' do + renewer = make_renewer(lease: make_lease(token: 'compat.cred')) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:test, provider: double('p'), lease: make_lease) + + result = described_class.credentials_for(:test) + expect(result[:token]).to eq('compat.cred') + expect(result[:provider]).to eq(:test) + end + + it 'returns the renewer from renewer_for without qualifier' do + renewer = make_renewer + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:test, provider: double('p'), lease: make_lease) + + expect(described_class.renewer_for(:test)).to equal(renewer) + end + + it 'works with static credentials without qualifier' do + described_class.register_provider(:api, provider: double('p'), lease: make_static_lease(token: 'sk-compat')) + expect(described_class.token_for(:api)).to eq('sk-compat') + end + end + + # --------------------------------------------------------------------------- + # multi-instance registration (with qualifier) + # --------------------------------------------------------------------------- + describe 'multi-instance registration (with qualifier)' do + let(:delegated_lease) { make_static_lease(token: 'entra.delegated.tok') } + let(:app_lease) { make_static_lease(token: 'entra.app.tok') } + + before do + described_class.register_provider(:entra, + provider: double('EntraProvider'), + lease: delegated_lease, + qualifier: :delegated, + default: true) + described_class.register_provider(:entra, + provider: double('EntraProvider'), + lease: app_lease, + qualifier: :app) + end + + it 'returns the default qualifier token when no qualifier specified' do + expect(described_class.token_for(:entra)).to eq('entra.delegated.tok') + end + + it 'returns the app qualifier token when qualifier: :app specified' do + expect(described_class.token_for(:entra, qualifier: :app)).to eq('entra.app.tok') + end + + it 'returns the delegated qualifier token when qualifier: :delegated specified' do + expect(described_class.token_for(:entra, qualifier: :delegated)).to eq('entra.delegated.tok') + end + + it 'lists both qualifiers via credentials_available' do + expect(described_class.credentials_available(:entra)).to contain_exactly(:delegated, :app) + end + + it 'includes :entra in providers exactly once' do + expect(described_class.providers).to eq([:entra]) + end + + it 'returns nil for a non-existent qualifier' do + expect(described_class.token_for(:entra, qualifier: :nonexistent)).to be_nil + end + + it 'returns credentials_for with explicit qualifier' do + result = described_class.credentials_for(:entra, qualifier: :app) + expect(result[:token]).to eq('entra.app.tok') + expect(result[:provider]).to eq(:entra) + end + + it 'returns credentials_for using the default qualifier' do + result = described_class.credentials_for(:entra) + expect(result[:token]).to eq('entra.delegated.tok') + end + + it 'returns an empty list for credentials_available on unregistered provider' do + expect(described_class.credentials_available(:unknown)).to eq([]) + end + + it 'stops existing renewer for same tuple when re-registering' do + renewer = make_renewer(lease: make_lease(token: 'dyn.tok')) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(renewer) + described_class.register_provider(:multi, provider: double('p'), lease: make_lease, qualifier: :slot_a) + + expect(renewer).to receive(:stop!) + new_renewer = make_renewer(lease: make_lease(token: 'dyn.tok.new')) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(new_renewer) + described_class.register_provider(:multi, provider: double('p'), lease: make_lease, qualifier: :slot_a) + end + end + + # --------------------------------------------------------------------------- + # context-based routing (for_context) + # --------------------------------------------------------------------------- + describe 'context-based routing (for_context)' do + let(:legion_lease) { make_static_lease(token: 'gh.legion.tok') } + let(:personal_lease) { make_static_lease(token: 'gh.personal.tok') } + + let(:routing_provider) do + provider = double('GitHubProvider') + allow(provider).to receive(:resolve_qualifier) do |ctx| + case ctx[:org] + when 'LegionIO' then :legion + when 'Personal' then :personal + end + end + provider + end + + before do + described_class.register_provider(:github, + provider: routing_provider, + lease: legion_lease, + qualifier: :legion, + default: true) + described_class.register_provider(:github, + provider: routing_provider, + lease: personal_lease, + qualifier: :personal) + end + + it 'routes to the correct qualifier based on for_context' do + token = described_class.token_for(:github, for_context: { org: 'LegionIO' }) + expect(token).to eq('gh.legion.tok') + end + + it 'routes to a different qualifier based on for_context' do + token = described_class.token_for(:github, for_context: { org: 'Personal' }) + expect(token).to eq('gh.personal.tok') + end + + it 'falls back to default when resolve_qualifier returns nil' do + token = described_class.token_for(:github, for_context: { org: 'Unknown' }) + expect(token).to eq('gh.legion.tok') + end + + it 'falls back to default when provider does not respond to resolve_qualifier' do + plain_provider = double('PlainProvider') + described_class.register_provider(:plain, + provider: plain_provider, + lease: make_static_lease(token: 'plain.default'), + qualifier: :default) + + token = described_class.token_for(:plain, for_context: { org: 'Anything' }) + expect(token).to eq('plain.default') + end + + it 'prefers explicit qualifier over for_context' do + token = described_class.token_for(:github, qualifier: :personal, for_context: { org: 'LegionIO' }) + expect(token).to eq('gh.personal.tok') + end + end + + # --------------------------------------------------------------------------- + # credentials_for with qualifier + # --------------------------------------------------------------------------- + describe 'credentials_for with qualifier' do + before do + described_class.register_provider(:gh, + provider: double('GHProvider'), + lease: make_static_lease(token: 'gh.default.tok'), + qualifier: :default) + described_class.register_provider(:gh, + provider: double('GHProvider'), + lease: make_static_lease(token: 'gh.esity.tok'), + qualifier: :esity) + end + + it 'returns default credentials when no qualifier given' do + result = described_class.credentials_for(:gh) + expect(result[:token]).to eq('gh.default.tok') + expect(result[:provider]).to eq(:gh) + end + + it 'returns specific credentials when qualifier given' do + result = described_class.credentials_for(:gh, qualifier: :esity) + expect(result[:token]).to eq('gh.esity.tok') + expect(result[:provider]).to eq(:gh) + end + + it 'returns nil when qualifier does not exist' do + expect(described_class.credentials_for(:gh, qualifier: :nonexistent)).to be_nil + end + + it 'passes service through to the result' do + result = described_class.credentials_for(:gh, qualifier: :esity, service: 'api.github.com') + expect(result[:service]).to eq('api.github.com') + end + end + + # --------------------------------------------------------------------------- + # audit emission in token_for + # --------------------------------------------------------------------------- + describe 'audit emission in token_for' do + it 'pushes an audit event to the queue on successful token_for' do + described_class.register_provider(:aud, provider: double('p'), lease: make_static_lease(token: 'aud.tok')) + described_class.token_for(:aud, purpose: 'api_call', context: { request_id: '123' }) + + queue = described_class.instance_variable_get(:@audit_queue) + expect(queue.size).to be >= 1 + event = queue.first + expect(event[:provider]).to eq(:aud) + expect(event[:qualifier]).to eq(:default) + expect(event[:purpose]).to eq('api_call') + expect(event[:context]).to eq({ request_id: '123' }) + expect(event[:granted]).to be(true) + expect(event[:timestamp]).to be_a(Time) + end + + it 'pushes an audit event with granted: false when token is nil' do + described_class.token_for(:nonexistent, purpose: 'test') + + queue = described_class.instance_variable_get(:@audit_queue) + event = queue.last + expect(event[:granted]).to be(false) + end + + it 'drops events when audit queue is full' do + described_class.register_provider(:flood, provider: double('p'), lease: make_static_lease(token: 'f.tok')) + + # Fill the queue to capacity + queue = described_class.instance_variable_get(:@audit_queue) + Legion::Identity::Broker::AUDIT_QUEUE_MAX.times { queue.push({ filler: true }) } + + # This call should drop rather than push + described_class.token_for(:flood) + drops = described_class.instance_variable_get(:@audit_drops) + expect(drops.value).to be >= 1 + end + end +end diff --git a/spec/legion/identity/grant_spec.rb b/spec/legion/identity/grant_spec.rb new file mode 100644 index 00000000..a1eda3dd --- /dev/null +++ b/spec/legion/identity/grant_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/identity/grant' + +RSpec.describe Legion::Identity::Grant do + describe 'granted access' do + subject(:grant) do + described_class.new( + grant_id: 'g-123', token: 'secret_token', provider: :entra, + qualifier: :default, purpose: 'graph_api', result: :granted, + expires_at: Time.now + 3600 + ) + end + + it { is_expected.to be_granted } + it { is_expected.not_to be_denied } + + it 'exposes token' do + expect(grant.token).to eq('secret_token') + end + + it 'exposes provider' do + expect(grant.provider).to eq(:entra) + end + + it 'exposes grant_id' do + expect(grant.grant_id).to eq('g-123') + end + + it 'exposes qualifier' do + expect(grant.qualifier).to eq(:default) + end + + it 'exposes purpose' do + expect(grant.purpose).to eq('graph_api') + end + + it 'is frozen' do + expect(grant).to be_frozen + end + end + + describe 'denied access' do + subject(:grant) do + described_class.new( + grant_id: 'g-456', token: nil, provider: :entra, + qualifier: :app, purpose: 'admin_op', result: :denied, + reason: 'rbac:insufficient_role' + ) + end + + it { is_expected.to be_denied } + it { is_expected.not_to be_granted } + + it 'exposes reason' do + expect(grant.reason).to eq('rbac:insufficient_role') + end + + it 'has nil token' do + expect(grant.token).to be_nil + end + + it 'has nil expires_at' do + expect(grant.expires_at).to be_nil + end + end + + describe 'defaults' do + subject(:grant) do + described_class.new(grant_id: 'g-789', token: 'tok', provider: :test, result: :granted) + end + + it 'defaults qualifier to :default' do + expect(grant.qualifier).to eq(:default) + end + + it 'defaults purpose to nil' do + expect(grant.purpose).to be_nil + end + + it 'defaults reason to nil' do + expect(grant.reason).to be_nil + end + end +end diff --git a/spec/legion/identity/integration_spec.rb b/spec/legion/identity/integration_spec.rb new file mode 100644 index 00000000..624458e9 --- /dev/null +++ b/spec/legion/identity/integration_spec.rb @@ -0,0 +1,334 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'concurrent' +require 'legion/mode' +require 'legion/identity/process' +require 'legion/identity/broker' +require 'legion/identity/lease' +require 'legion/identity/lease_renewer' +require 'legion/identity/request' + +RSpec.describe 'Identity Integration' do + before do + Legion::Identity::Process.reset! + Legion::Identity::Broker.reset! + end + + after do + Legion::Identity::Broker.shutdown + Legion::Identity::Process.reset! + end + + describe 'boot -> identity resolves -> broker registers' do + it 'resolves identity and registers provider lease' do + provider_identity = { + id: SecureRandom.uuid, + canonical_name: 'test-agent', + kind: :service, + persistent: true + } + + initial_lease = Legion::Identity::Lease.new( + provider: :kerberos, + credential: 'spnego-token-abc', + lease_id: 'vault-lease-123', + expires_at: Time.now + 3600, + renewable: true, + issued_at: Time.now + ) + + mock_provider = double('IdentityProvider') + allow(mock_provider).to receive(:provide_token).and_return(initial_lease) + + stub_renewer = instance_double( + Legion::Identity::LeaseRenewer, + current_lease: initial_lease, + stop!: nil + ) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(stub_renewer) + + # Step 1: Bind identity + Legion::Identity::Process.bind!(mock_provider, provider_identity) + + expect(Legion::Identity::Process.resolved?).to be true + expect(Legion::Identity::Process.canonical_name).to eq('test-agent') + expect(Legion::Identity::Process.kind).to eq(:service) + expect(Legion::Identity::Process.persistent?).to be true + expect(Legion::Identity::Process.id).to eq(provider_identity[:id]) + + # Step 2: Register provider with Broker + Legion::Identity::Broker.register_provider(:kerberos, provider: mock_provider, lease: initial_lease) + + expect(Legion::Identity::Broker.providers).to include(:kerberos) + expect(Legion::Identity::Broker.token_for(:kerberos)).to eq('spnego-token-abc') + expect(Legion::Identity::Broker.authenticated?).to be true + + # Step 3: Verify credentials_for returns full hash + creds = Legion::Identity::Broker.credentials_for(:kerberos, service: :vault) + expect(creds[:token]).to eq('spnego-token-abc') + expect(creds[:provider]).to eq(:kerberos) + expect(creds[:service]).to eq(:vault) + expect(creds[:lease]).to be_a(Legion::Identity::Lease) + end + end + + describe 'fallback identity when no providers' do + it 'uses ENV USER as fallback' do + allow(ENV).to receive(:fetch).with('USER', 'anonymous').and_return('testuser') + + Legion::Identity::Process.bind_fallback! + + expect(Legion::Identity::Process.resolved?).to be false + expect(Legion::Identity::Process.persistent?).to be false + expect(Legion::Identity::Process.canonical_name).to eq('testuser') + expect(Legion::Identity::Process.kind).to eq(:human) + end + end + + describe 'provider raises during resolution' do + it 'does not crash and falls back gracefully' do + failing_provider = double('FailingProvider') + allow(failing_provider).to receive(:resolve).and_raise(StandardError, 'connection refused') + + # Process should not be resolved without an explicit bind + expect(Legion::Identity::Process.resolved?).to be false + + # Fallback should work + Legion::Identity::Process.bind_fallback! + expect(Legion::Identity::Process.canonical_name).not_to be_nil + end + end + + describe 'request identity from auth context' do + it 'builds request and maps to caller hash' do + request = Legion::Identity::Request.from_auth_context( + sub: 'user-uuid-123', + name: 'John.Doe', + kind: :human, + groups: %w[admin operators], + source: :kerberos + ) + + expect(request.principal_id).to eq('user-uuid-123') + expect(request.canonical_name).to eq('john-doe') + expect(request.kind).to eq(:human) + expect(request.groups).to eq(%w[admin operators]) + expect(request.source).to eq(:kerberos) + + caller_hash = request.to_caller_hash + expect(caller_hash[:requested_by][:id]).to eq('user-uuid-123') + expect(caller_hash[:requested_by][:identity]).to eq('john-doe') + expect(caller_hash[:requested_by][:type]).to eq(:human) + expect(caller_hash[:requested_by][:credential]).to eq(:kerberos) + + rbac = request.to_rbac_principal + expect(rbac[:identity]).to eq('john-doe') + expect(rbac[:type]).to eq(:human) + end + end + + describe 'queue_prefix depends on mode' do + let(:fixed_uuid) { 'test-instance-id' } + let(:fixed_host) { 'test-host' } + + before do + allow(Legion).to receive(:instance_id).and_return(fixed_uuid) + allow(Socket).to receive(:gethostname).and_return(fixed_host) + Legion::Identity::Process.bind!(nil, { + id: 'uuid-1', + canonical_name: 'myagent', + kind: :service, + persistent: true + }) + end + + it 'uses agent prefix for agent mode' do + allow(Legion::Mode).to receive(:current).and_return(:agent) + expect(Legion::Identity::Process.queue_prefix).to eq("agent.myagent.#{fixed_host}") + end + + it 'uses worker prefix for worker mode' do + allow(Legion::Mode).to receive(:current).and_return(:worker) + expect(Legion::Identity::Process.queue_prefix).to eq("worker.myagent.#{fixed_uuid}") + end + + it 'uses infra prefix for infra mode' do + allow(Legion::Mode).to receive(:current).and_return(:infra) + expect(Legion::Identity::Process.queue_prefix).to eq("infra.myagent.#{fixed_host}") + end + + it 'uses lite prefix for lite mode' do + allow(Legion::Mode).to receive(:current).and_return(:lite) + expect(Legion::Identity::Process.queue_prefix).to eq("lite.myagent.#{fixed_uuid}") + end + end + + describe 'lease lifecycle' do + it 'detects fresh lease as valid and not stale' do + fresh = Legion::Identity::Lease.new( + provider: :test, + credential: 'token-1', + expires_at: Time.now + 100, + issued_at: Time.now + ) + expect(fresh.valid?).to be true + expect(fresh.stale?).to be false + expect(fresh.expired?).to be false + end + + it 'detects a stale lease (past 50% TTL)' do + stale = Legion::Identity::Lease.new( + provider: :test, + credential: 'token-2', + expires_at: Time.now + 10, + issued_at: Time.now - 90 + ) + expect(stale.valid?).to be true + expect(stale.stale?).to be true + end + + it 'detects an expired lease' do + expired = Legion::Identity::Lease.new( + provider: :test, + credential: 'token-3', + expires_at: Time.now - 1, + issued_at: Time.now - 100 + ) + expect(expired.valid?).to be false + expect(expired.expired?).to be true + expect(expired.ttl_seconds).to eq(0) + end + end + + describe 'Postgres unavailable (in-memory only)' do + it 'identity system works without database' do + Legion::Identity::Process.bind_fallback! + expect(Legion::Identity::Process.canonical_name).not_to be_nil + end + + it 'groups returns empty array without DB' do + hide_const('Legion::Data') if defined?(Legion::Data) + allow(Legion::Identity::Process).to receive(:identity_hash).and_return({ groups: [] }) + + groups = Legion::Identity::Broker.groups + expect(groups).to eq([]) + end + + it 'request objects work without database' do + request = Legion::Identity::Request.new( + principal_id: 'test-id', + canonical_name: 'test-user', + kind: :human + ) + expect(request.to_caller_hash).to be_a(Hash) + end + end + + describe 'reload path' do + it 'refresh_credentials does not raise when no provider is bound' do + Legion::Identity::Process.bind_fallback! + expect { Legion::Identity::Process.refresh_credentials }.not_to raise_error + end + + it 'refresh_credentials does not raise when provider does not respond to refresh' do + provider = double('NoRefreshProvider') + Legion::Identity::Process.bind!(provider, { + id: 'x', + canonical_name: 'svc', + kind: :service, + persistent: true + }) + expect { Legion::Identity::Process.refresh_credentials }.not_to raise_error + end + end + + describe 'static credential registration (Phase 8 credential-only providers)' do + let(:static_lease) do + Legion::Identity::Lease.new( + provider: :openai, + credential: 'sk-test-abc123', + expires_at: nil, + renewable: false + ) + end + + let(:provider) { double('CredentialProvider', provide_token: static_lease) } + + it 'token_for returns the credential string for a static provider' do + Legion::Identity::Broker.register_provider(:openai, provider: provider, lease: static_lease) + expect(Legion::Identity::Broker.token_for(:openai)).to eq('sk-test-abc123') + end + + it 'lease_for returns the Lease object for a static provider' do + Legion::Identity::Broker.register_provider(:openai, provider: provider, lease: static_lease) + result = Legion::Identity::Broker.lease_for(:openai) + expect(result).to be_a(Legion::Identity::Lease) + expect(result.token).to eq('sk-test-abc123') + end + + it 'renewer_for returns nil for static providers (no background thread)' do + Legion::Identity::Broker.register_provider(:openai, provider: provider, lease: static_lease) + expect(Legion::Identity::Broker.renewer_for(:openai)).to be_nil + end + + it 'includes the static provider in the providers list' do + Legion::Identity::Broker.register_provider(:openai, provider: provider, lease: static_lease) + expect(Legion::Identity::Broker.providers).to include(:openai) + end + + it 'refresh_credential calls provide_token and updates the stored lease' do + new_lease = Legion::Identity::Lease.new( + provider: :openai, + credential: 'sk-refreshed', + expires_at: nil, + renewable: false + ) + allow(provider).to receive(:provide_token).and_return(new_lease) + Legion::Identity::Broker.register_provider(:openai, provider: provider, lease: static_lease) + + result = Legion::Identity::Broker.refresh_credential(:openai) + expect(result).to be(true) + expect(Legion::Identity::Broker.token_for(:openai)).to eq('sk-refreshed') + end + + it 'static leases appear in leases hash' do + Legion::Identity::Broker.register_provider(:openai, provider: provider, lease: static_lease) + leases = Legion::Identity::Broker.leases + expect(leases[:openai]).to be_a(Hash) + expect(leases[:openai][:default]).to be_a(Hash) + expect(leases[:openai][:default][:valid]).to be(true) + end + + it 'shutdown clears static leases' do + Legion::Identity::Broker.register_provider(:openai, provider: provider, lease: static_lease) + Legion::Identity::Broker.shutdown + expect(Legion::Identity::Broker.providers).to be_empty + end + end + + describe 'Broker registration via register_provider_with_broker (Phase 8 8.0e)' do + it 'registers a provider that responds to provide_token' do + initial_lease = Legion::Identity::Lease.new( + provider: :entra, + credential: 'entra-bearer-token', + expires_at: Time.now + 3600, + renewable: true, + issued_at: Time.now + ) + provider = double('EntraProvider', provider_name: :entra, provide_token: initial_lease) + + stub_renewer = instance_double( + Legion::Identity::LeaseRenewer, + current_lease: initial_lease, + stop!: nil + ) + allow(Legion::Identity::LeaseRenewer).to receive(:new).and_return(stub_renewer) + + Legion::Identity::Broker.register_provider(:entra, provider: provider, lease: initial_lease) + + expect(Legion::Identity::Broker.token_for(:entra)).to eq('entra-bearer-token') + expect(Legion::Identity::Broker.renewer_for(:entra)).to equal(stub_renewer) + end + end +end diff --git a/spec/legion/identity/lease_renewer_spec.rb b/spec/legion/identity/lease_renewer_spec.rb new file mode 100644 index 00000000..6832becc --- /dev/null +++ b/spec/legion/identity/lease_renewer_spec.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/identity/lease' +require 'legion/identity/lease_renewer' + +RSpec.describe Legion::Identity::LeaseRenewer do + let(:provider_name) { :vault } + let(:now) { Time.now } + + def make_lease(ttl_seconds: 10, offset: 0) + issued = now - offset + expires_at = issued + ttl_seconds + Legion::Identity::Lease.new( + provider: :vault, + credential: 'tok.abc123', + issued_at: issued, + expires_at: expires_at + ) + end + + let(:initial_lease) { make_lease(ttl_seconds: 10) } + + let(:provider) do + instance_double('Provider').tap do |p| + allow(p).to receive(:provide_token).and_return(make_lease(ttl_seconds: 10)) + end + end + + subject(:renewer) do + described_class.new(provider_name: provider_name, provider: provider, lease: initial_lease) + end + + after do + renewer.stop! if renewer.alive? + end + + describe '#initialize' do + it 'starts the background thread immediately' do + expect(renewer.alive?).to be(true) + end + + it 'names the thread after the provider' do + renewer # trigger subject creation so the thread exists + thread_name = Thread.list.find { |t| t.name == "lease-renewer-#{provider_name}" }&.name + expect(thread_name).to eq("lease-renewer-#{provider_name}") + end + end + + describe '#provider_name' do + it 'returns the provider name' do + expect(renewer.provider_name).to eq(provider_name) + end + end + + describe '#provider' do + it 'returns the provider object' do + expect(renewer.provider).to equal(provider) + end + + it 'is readable (not private)' do + expect { renewer.provider }.not_to raise_error + end + end + + describe '#current_lease' do + it 'returns the initial lease without blocking' do + expect(renewer.current_lease).to equal(initial_lease) + end + + it 'never blocks (returns immediately)' do + t0 = Time.now + renewer.current_lease + expect(Time.now - t0).to be < 0.05 + end + end + + describe '#alive?' do + it 'returns true while the thread is running' do + expect(renewer.alive?).to be(true) + end + + it 'returns false after stop!' do + renewer.stop! + expect(renewer.alive?).to be(false) + end + end + + describe '#stop!' do + it 'cooperatively shuts down the thread' do + expect(renewer.alive?).to be(true) + renewer.stop! + expect(renewer.alive?).to be(false) + end + + it 'returns within bounded time (< 6 seconds)' do + t0 = Time.now + renewer.stop! + expect(Time.now - t0).to be < 6 + end + + it 'is safe to call multiple times' do + renewer.stop! + expect { renewer.stop! }.not_to raise_error + end + end + + describe 'lease renewal' do + it 'renews the lease when the current lease becomes stale' do + # Build a lease that is already past the 50% mark so the first sleep is tiny + stale_lease = make_lease(ttl_seconds: 3, offset: 2) # 67% elapsed + new_lease = make_lease(ttl_seconds: 10) + + allow(provider).to receive(:provide_token).and_return(new_lease) + + r = described_class.new(provider_name: :vault, provider: provider, lease: stale_lease) + + # Give the background thread up to 3 seconds to perform the renewal + deadline = Time.now + 3 + sleep 0.1 until r.current_lease.equal?(new_lease) || Time.now > deadline + + expect(r.current_lease).to equal(new_lease) + ensure + r&.stop! + end + + it 'does not replace the lease when provider returns nil' do + stale_lease = make_lease(ttl_seconds: 2, offset: 1) # 50% elapsed + allow(provider).to receive(:provide_token).and_return(nil) + + r = described_class.new(provider_name: :vault, provider: provider, lease: stale_lease) + sleep 0.5 + expect(r.current_lease).to equal(stale_lease) + ensure + r&.stop! + end + + it 'does not replace the lease when provider returns an invalid lease' do + stale_lease = make_lease(ttl_seconds: 2, offset: 1) + invalid_lease = Legion::Identity::Lease.new(provider: :vault, credential: nil) + allow(provider).to receive(:provide_token).and_return(invalid_lease) + + r = described_class.new(provider_name: :vault, provider: provider, lease: stale_lease) + sleep 0.5 + expect(r.current_lease).to equal(stale_lease) + ensure + r&.stop! + end + end + + describe 'error handling' do + it 'does not crash the thread when provider raises StandardError' do + stale_lease = make_lease(ttl_seconds: 2, offset: 1) + call_count = 0 + good_lease = make_lease(ttl_seconds: 10) + + allow(provider).to receive(:provide_token) do + call_count += 1 + raise StandardError, 'temporary error' if call_count == 1 + + good_lease + end + + r = described_class.new(provider_name: :vault, provider: provider, lease: stale_lease) + + deadline = Time.now + 10 + sleep 0.1 until r.current_lease.equal?(good_lease) || Time.now > deadline + + expect(r.alive?).to be(true) + expect(r.current_lease).to equal(good_lease) + ensure + r&.stop! + end + + it 'logs renewal failures to $stderr when Legion::Logging is unavailable' do + # Use a nearly-expired lease so compute_sleep returns MIN_SLEEP (1s) and renewal triggers quickly + stale_lease = make_lease(ttl_seconds: 2, offset: 1.9) + allow(provider).to receive(:provide_token).and_raise(StandardError, 'boom') + + hide_const('Legion::Logging') if defined?(Legion::Logging) + + expect($stderr).to receive(:puts).with(/LeaseRenewer.*vault.*boom/).at_least(:once) + + r = described_class.new(provider_name: :vault, provider: provider, lease: stale_lease) + sleep 1.5 + ensure + r&.stop! + end + end + + describe '#compute_sleep (private)' do + subject(:renewer_bare) do + described_class.new(provider_name: :vault, provider: provider, lease: initial_lease) + end + + after { renewer_bare.stop! } + + it 'returns 50% of remaining TTL for a lease with expiry info' do + # 10-second TTL, just issued — remaining is ~10s, half is ~5s + lease = make_lease(ttl_seconds: 10) + result = renewer_bare.send(:compute_sleep, lease) + expect(result).to be_between(4.5, 5.5) + end + + it 'returns DEFAULT_SLEEP when lease is nil' do + result = renewer_bare.send(:compute_sleep, nil) + expect(result).to eq(described_class::DEFAULT_SLEEP) + end + + it 'returns DEFAULT_SLEEP when expires_at is nil' do + lease = Legion::Identity::Lease.new(provider: :vault, credential: 'tok', expires_at: nil) + result = renewer_bare.send(:compute_sleep, lease) + expect(result).to eq(described_class::DEFAULT_SLEEP) + end + + it 'returns DEFAULT_SLEEP when issued_at is nil' do + lease = Legion::Identity::Lease.new( + provider: :vault, + credential: 'tok', + expires_at: Time.now + 100, + issued_at: nil + ) + allow(lease).to receive(:issued_at).and_return(nil) + result = renewer_bare.send(:compute_sleep, lease) + expect(result).to eq(described_class::DEFAULT_SLEEP) + end + + it 'returns MIN_SLEEP when remaining TTL is very small' do + # Nearly expired: expires_at is 0.5s from now + lease = Legion::Identity::Lease.new( + provider: :vault, + credential: 'tok', + issued_at: Time.now - 99.5, + expires_at: Time.now + 0.5 + ) + result = renewer_bare.send(:compute_sleep, lease) + expect(result).to eq(described_class::MIN_SLEEP) + end + end +end diff --git a/spec/legion/identity/lease_spec.rb b/spec/legion/identity/lease_spec.rb new file mode 100644 index 00000000..a2e492dd --- /dev/null +++ b/spec/legion/identity/lease_spec.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/identity/lease' + +RSpec.describe Legion::Identity::Lease do + let(:provider) { :vault } + let(:credential) { 's.abc123token' } + let(:lease_id) { 'auth/token/create/abc123' } + let(:now) { Time.now } + let(:future) { now + 3600 } + let(:past) { now - 3600 } + + describe '#initialize' do + it 'sets all attributes from keyword arguments' do + issued = now - 100 + meta = { role: 'admin' } + lease = described_class.new( + provider: provider, + credential: credential, + lease_id: lease_id, + expires_at: future, + renewable: true, + issued_at: issued, + metadata: meta + ) + + expect(lease.provider).to eq(provider) + expect(lease.credential).to eq(credential) + expect(lease.lease_id).to eq(lease_id) + expect(lease.expires_at).to eq(future) + expect(lease.renewable).to be(true) + expect(lease.issued_at).to eq(issued) + expect(lease.metadata).to eq(meta) + end + + it 'defaults lease_id to nil' do + lease = described_class.new(provider: provider, credential: credential) + expect(lease.lease_id).to be_nil + end + + it 'defaults expires_at to nil' do + lease = described_class.new(provider: provider, credential: credential) + expect(lease.expires_at).to be_nil + end + + it 'defaults renewable to false' do + lease = described_class.new(provider: provider, credential: credential) + expect(lease.renewable).to be(false) + end + + it 'defaults issued_at to approximately now when not provided' do + before = Time.now + lease = described_class.new(provider: provider, credential: credential) + after = Time.now + expect(lease.issued_at).to be_between(before, after) + end + + it 'defaults metadata to a frozen empty hash' do + lease = described_class.new(provider: provider, credential: credential) + expect(lease.metadata).to eq({}) + expect(lease.metadata).to be_frozen + end + end + + describe '#token' do + it 'returns the credential' do + lease = described_class.new(provider: provider, credential: credential) + expect(lease.token).to eq(credential) + end + end + + describe '#expired?' do + it 'returns false when expires_at is nil' do + lease = described_class.new(provider: provider, credential: credential, expires_at: nil) + expect(lease.expired?).to be(false) + end + + it 'returns false when expires_at is in the future' do + lease = described_class.new(provider: provider, credential: credential, expires_at: future) + expect(lease.expired?).to be(false) + end + + it 'returns true when expires_at is in the past' do + lease = described_class.new(provider: provider, credential: credential, expires_at: past) + expect(lease.expired?).to be(true) + end + end + + describe '#stale?' do + it 'returns false when expires_at is nil' do + lease = described_class.new(provider: provider, credential: credential, expires_at: nil) + expect(lease.stale?).to be(false) + end + + it 'returns false when issued_at is nil' do + lease = described_class.new( + provider: provider, + credential: credential, + expires_at: future, + issued_at: nil + ) + # issued_at defaults to Time.now inside initialize, so we must bypass it + allow(lease).to receive(:issued_at).and_return(nil) + expect(lease.stale?).to be(false) + end + + it 'returns false before 50% of the TTL has elapsed' do + # 25% through a 100-second lease + issued = now - 25 + exp = now + 75 + lease = described_class.new(provider: provider, credential: credential, + issued_at: issued, expires_at: exp) + expect(lease.stale?).to be(false) + end + + it 'returns true after 50% of the TTL has elapsed' do + # 75% through a 100-second lease + issued = now - 75 + exp = now + 25 + lease = described_class.new(provider: provider, credential: credential, + issued_at: issued, expires_at: exp) + expect(lease.stale?).to be(true) + end + + it 'returns true at exactly the 50% mark' do + # 50% through a 200-second lease + issued = now - 100 + exp = now + 100 + lease = described_class.new(provider: provider, credential: credential, + issued_at: issued, expires_at: exp) + expect(lease.stale?).to be(true) + end + end + + describe '#ttl_seconds' do + it 'returns nil when expires_at is nil' do + lease = described_class.new(provider: provider, credential: credential, expires_at: nil) + expect(lease.ttl_seconds).to be_nil + end + + it 'returns 0 when the lease has expired' do + lease = described_class.new(provider: provider, credential: credential, expires_at: past) + expect(lease.ttl_seconds).to eq(0) + end + + it 'returns a positive integer when the lease is still valid' do + lease = described_class.new(provider: provider, credential: credential, expires_at: future) + expect(lease.ttl_seconds).to be_a(Integer) + expect(lease.ttl_seconds).to be > 0 + end + + it 'approximates the remaining seconds' do + exp = now + 120 + lease = described_class.new(provider: provider, credential: credential, expires_at: exp) + expect(lease.ttl_seconds).to be_between(118, 120) + end + end + + describe '#valid?' do + it 'returns true when credential is present and lease is not expired' do + lease = described_class.new(provider: provider, credential: credential, expires_at: future) + expect(lease.valid?).to be(true) + end + + it 'returns true when credential is present and expires_at is nil' do + lease = described_class.new(provider: provider, credential: credential, expires_at: nil) + expect(lease.valid?).to be(true) + end + + it 'returns false when credential is nil' do + lease = described_class.new(provider: provider, credential: nil, expires_at: future) + expect(lease.valid?).to be(false) + end + + it 'returns false when the lease has expired' do + lease = described_class.new(provider: provider, credential: credential, expires_at: past) + expect(lease.valid?).to be(false) + end + end + + describe '#to_h' do + let(:issued) { now - 60 } + let(:lease) do + described_class.new( + provider: provider, + credential: credential, + lease_id: lease_id, + expires_at: future, + renewable: true, + issued_at: issued, + metadata: { env: 'production' } + ) + end + + it 'returns a Hash' do + expect(lease.to_h).to be_a(Hash) + end + + it 'includes the provider' do + expect(lease.to_h[:provider]).to eq(provider) + end + + it 'includes the lease_id' do + expect(lease.to_h[:lease_id]).to eq(lease_id) + end + + it 'serializes expires_at as an ISO 8601 string' do + expect(lease.to_h[:expires_at]).to eq(future.iso8601) + end + + it 'includes renewable' do + expect(lease.to_h[:renewable]).to be(true) + end + + it 'serializes issued_at as an ISO 8601 string' do + expect(lease.to_h[:issued_at]).to eq(issued.iso8601) + end + + it 'includes the computed ttl' do + expect(lease.to_h[:ttl]).to be_a(Integer) + expect(lease.to_h[:ttl]).to be > 0 + end + + it 'includes the valid flag' do + expect(lease.to_h[:valid]).to be(true) + end + + it 'includes the metadata' do + expect(lease.to_h[:metadata]).to eq({ env: 'production' }) + end + + it 'returns nil for expires_at when it is not set' do + lease_no_exp = described_class.new(provider: provider, credential: credential) + expect(lease_no_exp.to_h[:expires_at]).to be_nil + end + end + + describe 'edge cases' do + it 'handles issued_at in the past with expires_at in the future correctly' do + issued = now - 10 + exp = now + 3590 + lease = described_class.new(provider: provider, credential: credential, + issued_at: issued, expires_at: exp) + expect(lease.expired?).to be(false) + expect(lease.valid?).to be(true) + expect(lease.ttl_seconds).to be_between(3588, 3590) + expect(lease.stale?).to be(false) + end + + it 'freezes metadata provided at initialization' do + meta = { role: 'reader' } + lease = described_class.new(provider: provider, credential: credential, metadata: meta) + expect(lease.metadata).to be_frozen + end + + it 'does not expose credential through token aliasing side effects' do + lease = described_class.new(provider: provider, credential: credential) + expect(lease.token).to equal(lease.credential) + end + end +end diff --git a/spec/legion/identity/middleware_spec.rb b/spec/legion/identity/middleware_spec.rb new file mode 100644 index 00000000..da73995a --- /dev/null +++ b/spec/legion/identity/middleware_spec.rb @@ -0,0 +1,587 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/identity/request' +require 'legion/identity/middleware' + +RSpec.describe Legion::Identity::Middleware do + let(:inner_app) { ->(_env) { [200, {}, ['ok']] } } + let(:middleware) { described_class.new(inner_app) } + + def env_for(path, extra = {}) + { 'PATH_INFO' => path }.merge(extra) + end + + # ─── skip paths ───────────────────────────────────────────────────────────── + + describe 'skip paths' do + described_class::SKIP_PATHS.each do |path| + it "returns the app response directly for #{path}" do + allow(inner_app).to receive(:call).and_call_original + middleware.call(env_for(path)) + expect(inner_app).to have_received(:call) do |received_env| + expect(received_env.key?('legion.principal')).to be(false) + end + end + end + + it 'skips paths that start with a skip prefix' do + env = env_for('/api/health/detail') + allow(inner_app).to receive(:call).and_call_original + middleware.call(env) + expect(inner_app).to have_received(:call) do |received_env| + expect(received_env.key?('legion.principal')).to be(false) + end + end + end + + # ─── bridge legion.auth to legion.principal ────────────────────────────────── + + describe 'when legion.auth is present' do + let(:jwt_claims) do + { sub: 'user-001', name: 'Alice Smith', groups: ['readers'], scope: 'human' } + end + + let(:env) { env_for('/api/tasks', 'legion.auth' => jwt_claims, 'legion.auth_method' => 'jwt') } + + it 'sets legion.principal on the downstream env' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expect(captured['legion.principal']).to be_a(Legion::Identity::Request) + end + + it 'sets principal_id from sub' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expect(captured['legion.principal'].principal_id).to eq('user-001') + end + + it 'sets kind to :human for human scope' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expect(captured['legion.principal'].kind).to eq(:human) + end + + it 'sets source from the auth method' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expect(captured['legion.principal'].source).to eq(:jwt) + end + end + + # ─── worker scope → :service kind ──────────────────────────────────────────── + + describe 'when auth claims indicate a worker' do + let(:worker_claims) { { sub: nil, worker_id: 'w-99', name: 'Bot', scope: 'worker' } } + let(:env) { env_for('/api/tasks', 'legion.auth' => worker_claims, 'legion.auth_method' => 'api_key') } + + it 'sets kind to :service' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expect(captured['legion.principal'].kind).to eq(:service) + end + + it 'falls back to worker_id when sub is nil' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expect(captured['legion.principal'].principal_id).to eq('w-99') + end + end + + # ─── kerberos auth → :human kind ───────────────────────────────────────────── + + describe 'when auth method is kerberos' do + let(:krb_claims) { { sub: 'jdoe@EXAMPLE.COM', name: 'John Doe', groups: [] } } + let(:env) { env_for('/api/tasks', 'legion.auth' => krb_claims, 'legion.auth_method' => 'kerberos') } + + it 'sets kind to :human' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expect(captured['legion.principal'].kind).to eq(:human) + end + + it 'sets source to :kerberos' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expect(captured['legion.principal'].source).to eq(:kerberos) + end + end + + # ─── no auth, auth not required → system principal ─────────────────────────── + + describe 'when no auth is present and require_auth is false (default)' do + let(:env) { env_for('/api/tasks') } + + def stub_process_identity(canonical_name: 'matt@example.com', kind: :human, source: :system) + process = Module.new do + class << self + attr_accessor :canonical_name_value, :kind_value, :source_value + + def canonical_name = @canonical_name_value + def kind = @kind_value + def source = @source_value + def resolved? = false + end + end + process.canonical_name_value = canonical_name + process.kind_value = kind + process.source_value = source + + stub_const('Legion::Identity::Process', process) + end + + it 'sets a system principal' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expect(captured['legion.principal']).to be_a(Legion::Identity::Request) + end + + it 'sets principal_id to system:<canonical>' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + principal = captured['legion.principal'] + expected_canonical = if defined?(Legion::Identity::Process) && + Legion::Identity::Process.respond_to?(:canonical_name) && + Legion::Identity::Process.canonical_name.to_s != '' + Legion::Identity::Process.canonical_name + else + 'system' + end + expect(principal.principal_id).to eq("system:#{expected_canonical}") + end + + it 'uses the local process identity even when the process resolver is not formally resolved' do + stub_process_identity + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + + app.call(env) + + principal = captured['legion.principal'] + expect(principal.principal_id).to eq('system:matt@example.com') + expect(principal.canonical_name).to eq('matt@example.com') + expect(principal.kind).to eq(:human) + expect(principal.source).to eq(:system) + end + + it 'sets kind from the local process identity when available' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + app.call(env) + expected_kind = if defined?(Legion::Identity::Process) && + Legion::Identity::Process.respond_to?(:kind) && + Legion::Identity::Process.kind + Legion::Identity::Process.kind + else + :service + end + expect(captured['legion.principal'].kind).to eq(expected_kind) + end + + it 'memoizes the system principal across calls' do + principals = [] + app = described_class.new(lambda { |e| + principals << e['legion.principal'] + [200, {}, []] + }) + 2.times { app.call(env_for('/api/tasks')) } + expect(principals[0]).to equal(principals[1]) + end + end + + # ─── no auth, auth required → nil principal ────────────────────────────────── + + describe 'when no auth is present and require_auth is true' do + let(:env) { env_for('/api/tasks') } + + it 'sets legion.principal to nil' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }, require_auth: true) + app.call(env) + expect(captured['legion.principal']).to be_nil + end + + it 'still calls the downstream app' do + called = false + app = described_class.new(lambda { |_e| + called = true + [200, {}, []] + }, require_auth: true) + app.call(env) + expect(called).to be(true) + end + end + + # ─── groups vs roles separation (§3.4 prerequisite fix) ───────────────────── + + describe 'groups vs roles separation in build_request' do + let(:claims_with_both) do + { + sub: 'user-001', + name: 'Alice', + groups: ['group-oid-abc'], + roles: ['app-admin'], + scope: 'human' + } + end + + it 'passes groups from claims[:groups] to Request, not claims[:roles]' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => claims_with_both, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.principal'].groups).to eq(['group-oid-abc']) + end + + it 'does not conflate claims[:roles] into groups' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => claims_with_both, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.principal'].groups).not_to include('app-admin') + end + end + + # ─── worker token: worker_id takes precedence over sub ─────────────────────── + + describe 'worker token principal_id resolution' do + let(:worker_token_claims) do + { sub: 'owner@example.com', worker_id: 'w-007', name: 'Bot', scope: 'worker' } + end + + it 'uses worker_id as principal_id when both sub and worker_id are present' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => worker_token_claims, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.principal'].principal_id).to eq('w-007') + end + + it 'does not use the owner sub as principal_id when worker_id is present' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => worker_token_claims, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.principal'].principal_id).not_to eq('owner@example.com') + end + + context 'when the worker token has no name claim (production JWT format)' do + let(:nameless_worker_claims) do + { sub: 'owner@example.com', worker_id: 'w-007', scope: 'worker' } + end + + it 'derives canonical_name from worker_id, not the owner sub' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => nameless_worker_claims, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.principal'].canonical_name).not_to include('owner') + expect(captured['legion.principal'].canonical_name).not_to include('example.com') + end + + it 'sets canonical_name based on worker_id when name is absent' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => nameless_worker_claims, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.principal'].canonical_name).to eq('w-007') + end + end + end + + # ─── RBAC principal bridge (§5.3) ──────────────────────────────────────────── + + describe 'RBAC principal bridge' do + let(:jwt_claims) do + { sub: 'user-001', name: 'Alice', groups: ['readers'], scope: 'human' } + end + + context 'when Legion::Rbac::Principal is NOT available' do + before do + hide_const('Legion::Rbac::Principal') if defined?(Legion::Rbac::Principal) + hide_const('Legion::Rbac') if defined?(Legion::Rbac) + end + + it 'does not set legion.rbac_principal' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => jwt_claims, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured.key?('legion.rbac_principal')).to be(false) + end + end + + context 'when Legion::Rbac::Principal is available with enabled?' do + let(:rbac_principal_double) { double('rbac_principal') } + let(:principal_class) do + klass = Class.new + allow(klass).to receive(:new).and_return(rbac_principal_double) + klass + end + let(:rbac_module) do + Module.new do + def self.enabled? + true + end + end + end + + before do + stub_const('Legion::Rbac', rbac_module) + stub_const('Legion::Rbac::Principal', principal_class) + end + + it 'sets legion.rbac_principal on the env' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => jwt_claims, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.rbac_principal']).to eq(rbac_principal_double) + end + + it 'passes the principal_id to Legion::Rbac::Principal' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => jwt_claims, 'legion.auth_method' => 'jwt') + app.call(env) + expect(principal_class).to have_received(:new).with(hash_including(id: 'user-001')) + end + + it 'maps :service kind to :worker type in the RBAC principal' do + service_claims = { sub: 'svc-1', name: 'Bot', scope: 'worker', worker_id: 'svc-1' } + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => service_claims, 'legion.auth_method' => 'jwt') + app.call(env) + expect(principal_class).to have_received(:new).with(hash_including(type: :worker)) + end + + it 'passes resolved roles (from claims[:roles]) to the RBAC principal' do + claims_with_roles = jwt_claims.merge(roles: ['admin']) + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => claims_with_roles, 'legion.auth_method' => 'jwt') + app.call(env) + expect(principal_class).to have_received(:new).with(hash_including(roles: ['admin'])) + end + end + + context 'when request is nil (require_auth=true, no auth)' do + it 'does not set legion.rbac_principal' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }, require_auth: true) + app.call(env_for('/api/tasks')) + expect(captured.key?('legion.rbac_principal')).to be(false) + end + end + end + + # ─── GroupRoleMapper enrichment (§5.2) ─────────────────────────────────────── + + describe 'GroupRoleMapper enrichment in build_request' do + let(:claims_with_groups) do + { + sub: 'user-001', + name: 'Alice', + groups: %w[group-a group-b], + roles: ['existing-role'], + scope: 'human' + } + end + + context 'when GroupRoleMapper is available and RBAC is enabled' do + let(:rbac_module) do + Module.new do + def self.enabled? + true + end + end + end + + let(:mapper_module) do + Module.new do + def self.resolve_roles(groups:, **) + groups.include?('group-a') ? ['mapped-admin'] : [] + end + end + end + + before do + stub_const('Legion::Rbac', rbac_module) + stub_const('Legion::Rbac::GroupRoleMapper', mapper_module) + end + + it 'merges group-derived roles with existing roles' do + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => claims_with_groups, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.principal'].roles).to include('existing-role', 'mapped-admin') + end + + it 'deduplicates roles' do + dup_claims = claims_with_groups.merge(roles: ['mapped-admin']) + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => dup_claims, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.principal'].roles.count('mapped-admin')).to eq(1) + end + end + + context 'when RBAC is disabled (no enabled? method)' do + it 'passes claims[:roles] through as resolved_roles without enrichment' do + stub_const('Legion::Rbac', Module.new) + captured = nil + app = described_class.new(lambda { |e| + captured = e + [200, {}, []] + }) + env = env_for('/api/tasks', 'legion.auth' => claims_with_groups, 'legion.auth_method' => 'jwt') + app.call(env) + expect(captured['legion.principal'].roles).to eq(['existing-role']) + end + end + end + + # ─── .require_auth? class method ───────────────────────────────────────────── + + describe '.require_auth?' do + context 'when mode is :lite' do + it 'returns false for a non-loopback bind' do + expect(described_class.require_auth?(bind: '0.0.0.0', mode: :lite)).to be(false) + end + + it 'returns false for a loopback bind' do + expect(described_class.require_auth?(bind: '127.0.0.1', mode: :lite)).to be(false) + end + end + + context 'when mode is :agent' do + described_class::LOOPBACK_BINDS.each do |loopback| + it "returns false for loopback bind #{loopback}" do + expect(described_class.require_auth?(bind: loopback, mode: :agent)).to be(false) + end + end + + it 'returns true for a non-loopback bind' do + expect(described_class.require_auth?(bind: '10.0.0.5', mode: :agent)).to be(true) + end + + it 'returns true for 0.0.0.0 (public bind)' do + expect(described_class.require_auth?(bind: '0.0.0.0', mode: :agent)).to be(true) + end + end + + context 'when mode is :worker' do + it 'returns false for localhost' do + expect(described_class.require_auth?(bind: 'localhost', mode: :worker)).to be(false) + end + + it 'returns true for a routable IP' do + expect(described_class.require_auth?(bind: '192.168.1.10', mode: :worker)).to be(true) + end + end + + context 'when mode is :infra' do + it 'returns false for ::1' do + expect(described_class.require_auth?(bind: '::1', mode: :infra)).to be(false) + end + + it 'returns true for a routable IP' do + expect(described_class.require_auth?(bind: '172.16.0.1', mode: :infra)).to be(true) + end + end + end +end diff --git a/spec/legion/identity/process_spec.rb b/spec/legion/identity/process_spec.rb new file mode 100644 index 00000000..d2ef402d --- /dev/null +++ b/spec/legion/identity/process_spec.rb @@ -0,0 +1,527 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/identity/process' + +RSpec.describe Legion::Identity::Process do + let(:fixed_uuid) { 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } + let(:fixed_hostname) { 'test-host-01' } + + before do + described_class.reset! + allow(Legion).to receive(:instance_id).and_return(fixed_uuid) + allow(Socket).to receive(:gethostname).and_return(fixed_hostname) + end + + describe 'default state' do + it 'is not resolved' do + expect(described_class.resolved?).to be(false) + end + + it 'returns anonymous as canonical_name' do + expect(described_class.canonical_name).to eq('anonymous') + end + + it 'returns instance_id as id fallback' do + expect(described_class.id).to eq(fixed_uuid) + end + + it 'returns nil for kind' do + expect(described_class.kind).to be_nil + end + + it 'is not persistent' do + expect(described_class.persistent?).to be(false) + end + end + + describe '.bind!' do + let(:provider) { double('provider') } + let(:identity) do + { + id: 'cccccccc-1111-2222-3333-444444444444', + canonical_name: 'my-service', + kind: :service, + persistent: true + } + end + + before { described_class.bind!(provider, identity) } + + it 'marks as resolved' do + expect(described_class.resolved?).to be(true) + end + + it 'stores the id' do + expect(described_class.id).to eq(identity[:id]) + end + + it 'stores the canonical_name' do + expect(described_class.canonical_name).to eq('my-service') + end + + it 'stores the kind' do + expect(described_class.kind).to eq(:service) + end + + it 'stores persistent true' do + expect(described_class.persistent?).to be(true) + end + + it 'stores groups when provided' do + described_class.bind!(provider, identity.merge(groups: %w[ops support])) + expect(described_class.identity_hash[:groups]).to eq(%w[ops support]) + end + + it 'stores metadata when provided' do + described_class.bind!(provider, identity.merge(metadata: { emails: ['a@example.com'] })) + expect(described_class.identity_hash[:metadata]).to eq(emails: ['a@example.com']) + end + end + + describe '.bind_fallback!' do + context 'when ENV USER is set' do + before do + allow(ENV).to receive(:fetch).with('USER', 'anonymous').and_return('jdoe') + described_class.bind_fallback! + end + + it 'uses ENV USER as canonical_name' do + expect(described_class.canonical_name).to eq('jdoe') + end + + it 'sets kind to :human' do + expect(described_class.kind).to eq(:human) + end + + it 'is not persistent (ephemeral)' do + expect(described_class.persistent?).to be(false) + end + + it 'is not resolved' do + expect(described_class.resolved?).to be(false) + end + end + + context 'when ENV USER is not set' do + before do + allow(ENV).to receive(:fetch).with('USER', 'anonymous').and_return('anonymous') + described_class.bind_fallback! + end + + it 'falls back to anonymous' do + expect(described_class.canonical_name).to eq('anonymous') + end + end + end + + describe '.mode' do + it 'delegates to Legion::Mode.current' do + allow(Legion::Mode).to receive(:current).and_return(:worker) + expect(described_class.mode).to eq(:worker) + end + end + + describe '.queue_prefix' do + before do + described_class.bind!(double('provider'), { + id: fixed_uuid, + canonical_name: 'my-node', + kind: :service, + persistent: true + }) + end + + context 'when mode is :agent' do + before { allow(Legion::Mode).to receive(:current).and_return(:agent) } + + it 'uses agent.canonical_name.hostname pattern' do + expect(described_class.queue_prefix).to eq("agent.my-node.#{fixed_hostname}") + end + end + + context 'when mode is :worker' do + before { allow(Legion::Mode).to receive(:current).and_return(:worker) } + + it 'uses worker.canonical_name.instance_id pattern' do + expect(described_class.queue_prefix).to eq("worker.my-node.#{fixed_uuid}") + end + end + + context 'when mode is :infra' do + before { allow(Legion::Mode).to receive(:current).and_return(:infra) } + + it 'uses infra.canonical_name.hostname pattern' do + expect(described_class.queue_prefix).to eq("infra.my-node.#{fixed_hostname}") + end + end + + context 'when mode is :lite' do + before { allow(Legion::Mode).to receive(:current).and_return(:lite) } + + it 'uses lite.canonical_name.instance_id pattern' do + expect(described_class.queue_prefix).to eq("lite.my-node.#{fixed_uuid}") + end + end + + context 'with unresolved identity (canonical_name falls back to anonymous)' do + before do + described_class.reset! + allow(Legion).to receive(:instance_id).and_return(fixed_uuid) + allow(Socket).to receive(:gethostname).and_return(fixed_hostname) + allow(Legion::Mode).to receive(:current).and_return(:agent) + end + + it 'uses anonymous as the canonical_name segment' do + expect(described_class.queue_prefix).to eq("agent.anonymous.#{fixed_hostname}") + end + end + + context 'when hostname contains special characters' do + before do + allow(Socket).to receive(:gethostname).and_return('Host_Name.local') + allow(Legion::Mode).to receive(:current).and_return(:agent) + end + + it 'strips non-alphanumeric/dash characters from hostname' do + expect(described_class.queue_prefix).to eq('agent.my-node.host-name-local') + end + end + end + + describe '.identity_hash' do + before do + allow(Legion::Mode).to receive(:current).and_return(:agent) + described_class.bind!(double('provider'), { + id: fixed_uuid, + canonical_name: 'hash-test', + kind: :machine, + persistent: true + }) + end + + subject(:hash) { described_class.identity_hash } + + it 'includes id' do + expect(hash[:id]).to eq(fixed_uuid) + end + + it 'includes canonical_name' do + expect(hash[:canonical_name]).to eq('hash-test') + end + + it 'includes kind' do + expect(hash[:kind]).to eq(:machine) + end + + it 'includes source (nil when no provider_name)' do + expect(hash[:source]).to be_nil + end + + it 'includes mode' do + expect(hash[:mode]).to eq(:agent) + end + + it 'includes queue_prefix' do + expect(hash[:queue_prefix]).to eq("agent.hash-test.#{fixed_hostname}") + end + + it 'includes resolved' do + expect(hash[:resolved]).to be(true) + end + + it 'includes persistent' do + expect(hash[:persistent]).to be(true) + end + + it 'includes groups (defaults to empty)' do + expect(hash[:groups]).to eq([]) + end + + it 'includes metadata (defaults to empty)' do + expect(hash[:metadata]).to eq({}) + end + + it 'returns a Hash with exactly 16 keys' do + expect(hash.keys).to match_array(%i[id canonical_name kind source mode queue_prefix resolved persistent groups metadata trust aliases providers profile + db_principal_id db_identity_id]) + end + + it 'has nil db_principal_id when not bound with db fields' do + expect(hash[:db_principal_id]).to be_nil + end + + it 'has nil db_identity_id when not bound with db fields' do + expect(hash[:db_identity_id]).to be_nil + end + + context 'when the provider exposes provider_name' do + before do + described_class.reset! + described_class.bind!(double('provider', provider_name: :custom_provider), { + id: fixed_uuid, + canonical_name: 'hash-test', + kind: :machine, + persistent: true + }) + end + + it 'includes source from provider.provider_name' do + expect(hash[:source]).to eq(:custom_provider) + end + end + + context 'when bound with db integer PKs' do + before do + described_class.reset! + described_class.bind!(double('provider', provider_name: 'test'), { + id: fixed_uuid, + canonical_name: 'hash-test', + kind: :machine, + persistent: true, + db_principal_id: 42, + db_identity_id: 99 + }) + end + + it 'includes db_principal_id and db_identity_id' do + h = described_class.identity_hash + expect(h[:db_principal_id]).to eq(42) + expect(h[:db_identity_id]).to eq(99) + end + end + + context 'when using bind_fallback!' do + before do + described_class.reset! + described_class.bind_fallback! + end + + it 'includes source as :system' do + expect(hash[:source]).to eq(:system) + end + end + end + + describe '.reset!' do + before do + described_class.bind!(double('provider'), { + id: fixed_uuid, + canonical_name: 'before-reset', + kind: :service, + persistent: true + }) + end + + it 'clears resolved state' do + described_class.reset! + expect(described_class.resolved?).to be(false) + end + + it 'resets canonical_name to anonymous' do + described_class.reset! + expect(described_class.canonical_name).to eq('anonymous') + end + + it 'clears id to instance_id fallback' do + described_class.reset! + expect(described_class.id).to eq(fixed_uuid) + end + + it 'clears kind to nil' do + described_class.reset! + expect(described_class.kind).to be_nil + end + + it 'resets persistent to false' do + described_class.reset! + expect(described_class.persistent?).to be(false) + end + end + + describe '.refresh_credentials' do + context 'when provider responds to refresh' do + let(:provider) { double('provider', refresh: :refreshed) } + + before do + described_class.bind!(provider, { + id: fixed_uuid, + canonical_name: 'refresh-test', + kind: :service, + persistent: true + }) + end + + it 'calls provider.refresh' do + expect(provider).to receive(:refresh) + described_class.refresh_credentials + end + end + + context 'when provider does not respond to refresh' do + let(:provider) { double('provider') } + + before do + described_class.bind!(provider, { + id: fixed_uuid, + canonical_name: 'no-refresh', + kind: :service, + persistent: true + }) + end + + it 'does not raise' do + expect { described_class.refresh_credentials }.not_to raise_error + end + end + + context 'when no provider has been bound' do + it 'does not raise' do + expect { described_class.refresh_credentials }.not_to raise_error + end + end + end + + describe '#db_principal_id' do + it 'returns nil before bind' do + expect(described_class.db_principal_id).to be_nil + end + + it 'returns integer after bind with db_principal_id' do + described_class.bind!(double('provider', provider_name: 'test'), + { canonical_name: 'alice', kind: :human, db_principal_id: 42, db_identity_id: 99 }) + expect(described_class.db_principal_id).to eq(42) + end + end + + describe '#db_identity_id' do + it 'returns nil before bind' do + expect(described_class.db_identity_id).to be_nil + end + + it 'returns integer after bind with db_identity_id' do + described_class.bind!(double('provider', provider_name: 'test'), + { canonical_name: 'alice', kind: :human, db_principal_id: 42, db_identity_id: 99 }) + expect(described_class.db_identity_id).to eq(99) + end + end + + describe 'thread safety' do + it 'does not corrupt state under concurrent bind! calls' do + identities = (1..20).map do |i| + { + id: "id-#{i}", + canonical_name: "node-#{i}", + kind: :service, + persistent: true + } + end + + threads = identities.map do |ident| + Thread.new { described_class.bind!(double('provider'), ident) } + end + threads.each(&:join) + + # identity_hash reads a single atomic snapshot — id and canonical_name must be consistent + allow(Legion::Mode).to receive(:current).and_return(:agent) + snapshot = described_class.identity_hash + + expect(snapshot[:id]).to match(/\Aid-\d+\z/) + expect(snapshot[:canonical_name]).to match(/\Anode-\d+\z/) + # The numeric suffix of id and canonical_name must match (same atomic write) + id_num = snapshot[:id].split('-').last.to_i + name_num = snapshot[:canonical_name].split('-').last.to_i + expect(id_num).to eq(name_num) + end + + it 'resolved? remains true after concurrent reads during bind!' do + provider = double('provider') + described_class.bind!(provider, { + id: fixed_uuid, + canonical_name: 'concurrent-read', + kind: :service, + persistent: true + }) + + results = Array.new(10) { Thread.new { described_class.resolved? } }.map(&:value) + expect(results).to all(be(true)) + end + end + + describe 'composite state' do + let(:composite) do + { + id: 'test-id', + canonical_name: 'miverso2', + kind: :human, + source: :kerberos, + persistent: true, + trust: :verified, + groups: ['admins'], + aliases: { kerberos: ['miverso2@MS.DS.UHC.COM'], entra: ['eb282cc7'] }, + providers: { kerberos: { status: :resolved, trust: :verified } }, + profile: { email: 'matt@optum.com', title: 'Engineer' }, + metadata: {} + } + end + + before do + described_class.reset! + described_class.bind!(nil, composite) + end + + it 'stores trust level' do + expect(described_class.trust).to eq(:verified) + end + + it 'stores aliases as arrays per provider' do + expect(described_class.aliases[:kerberos]).to eq(['miverso2@MS.DS.UHC.COM']) + end + + it 'stores providers map' do + expect(described_class.providers[:kerberos][:status]).to eq(:resolved) + end + + it 'stores profile' do + expect(described_class.profile[:email]).to eq('matt@optum.com') + end + + it 'includes trust in identity_hash' do + expect(described_class.identity_hash[:trust]).to eq(:verified) + end + + it 'includes aliases in identity_hash' do + expect(described_class.identity_hash[:aliases]).to include(:kerberos) + end + + it 'includes providers in identity_hash' do + expect(described_class.identity_hash[:providers]).to have_key(:kerberos) + end + + it 'includes profile in identity_hash' do + expect(described_class.identity_hash[:profile][:email]).to eq('matt@optum.com') + end + + it 'defaults trust to nil when unset' do + described_class.reset! + expect(described_class.trust).to be_nil + end + + it 'defaults aliases to empty hash when unset' do + described_class.reset! + expect(described_class.aliases).to eq({}) + end + + it 'freezes aliases' do + expect(described_class.aliases).to be_frozen + end + + it 'freezes providers' do + expect(described_class.providers).to be_frozen + end + + it 'freezes profile' do + expect(described_class.profile).to be_frozen + end + end +end diff --git a/spec/legion/identity/request_spec.rb b/spec/legion/identity/request_spec.rb new file mode 100644 index 00000000..89d86da0 --- /dev/null +++ b/spec/legion/identity/request_spec.rb @@ -0,0 +1,352 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/identity/request' + +RSpec.describe Legion::Identity::Request do + let(:principal_id) { 'user-abc-123' } + let(:canonical_name) { 'jane-doe' } + let(:kind) { :human } + let(:groups) { %w[admins readers] } + let(:source) { :kerberos } + let(:metadata) { { department: 'engineering' } } + + let(:request) do + described_class.new( + principal_id: principal_id, + canonical_name: canonical_name, + kind: kind, + groups: groups, + source: source, + metadata: metadata + ) + end + + describe '#initialize' do + it 'sets principal_id' do + expect(request.principal_id).to eq(principal_id) + end + + it 'sets canonical_name' do + expect(request.canonical_name).to eq(canonical_name) + end + + it 'sets kind' do + expect(request.kind).to eq(kind) + end + + it 'sets groups' do + expect(request.groups).to eq(groups) + end + + it 'sets source' do + expect(request.source).to eq(source) + end + + it 'sets metadata' do + expect(request.metadata).to eq(metadata) + end + + it 'defaults groups to an empty array' do + req = described_class.new(principal_id: principal_id, canonical_name: canonical_name, kind: kind) + expect(req.groups).to eq([]) + end + + it 'defaults roles to an empty array' do + req = described_class.new(principal_id: principal_id, canonical_name: canonical_name, kind: kind) + expect(req.roles).to eq([]) + end + + it 'sets roles when provided' do + req = described_class.new(principal_id: principal_id, canonical_name: canonical_name, kind: kind, roles: %w[admin operator]) + expect(req.roles).to eq(%w[admin operator]) + end + + it 'defaults source to nil' do + req = described_class.new(principal_id: principal_id, canonical_name: canonical_name, kind: kind) + expect(req.source).to be_nil + end + + it 'freezes groups' do + expect(request.groups).to be_frozen + end + + it 'freezes roles' do + req = described_class.new(principal_id: principal_id, canonical_name: canonical_name, kind: kind, roles: ['admin']) + expect(req.roles).to be_frozen + end + + it 'freezes the object after creation' do + expect(request).to be_frozen + end + end + + describe '#id alias' do + it 'returns the same value as principal_id' do + expect(request.id).to eq(request.principal_id) + end + + it 'returns the principal_id string' do + expect(request.id).to eq(principal_id) + end + end + + describe '.from_env' do + it 'returns the identity object stored at env[legion.principal]' do + env = { 'legion.principal' => request } + expect(described_class.from_env(env)).to equal(request) + end + + it 'returns nil when the key is absent' do + expect(described_class.from_env({})).to be_nil + end + end + + describe '.from_auth_context' do + let(:claims) do + { + sub: 'svc-worker-42', + name: 'Worker Bot', + kind: :service, + groups: ['workers'], + source: :entra + } + end + + it 'builds a Request from the claims hash' do + req = described_class.from_auth_context(claims) + expect(req).to be_a(described_class) + end + + it 'maps sub to principal_id' do + expect(described_class.from_auth_context(claims).principal_id).to eq('svc-worker-42') + end + + it 'maps name to canonical_name' do + expect(described_class.from_auth_context(claims).canonical_name).to eq('workerbot') + end + + it 'maps kind' do + expect(described_class.from_auth_context(claims).kind).to eq(:service) + end + + it 'maps groups' do + expect(described_class.from_auth_context(claims).groups).to eq(['workers']) + end + + it 'maps source' do + expect(described_class.from_auth_context(claims).source).to eq(:entra) + end + + it 'normalizes canonical_name to lowercase' do + req = described_class.from_auth_context(claims.merge(name: 'UPPER CASE')) + expect(req.canonical_name).to eq('uppercase') + end + + it 'strips leading and trailing whitespace from canonical_name' do + req = described_class.from_auth_context(claims.merge(name: ' spaced ')) + expect(req.canonical_name).to eq('spaced') + end + + it 'replaces dots with hyphens in canonical_name' do + req = described_class.from_auth_context(claims.merge(name: 'jane.doe')) + expect(req.canonical_name).to eq('jane-doe') + end + + it 'falls back to preferred_username when name is absent' do + req = described_class.from_auth_context( + sub: 'u1', + preferred_username: 'jdoe@example.com', + kind: :human, + groups: [], + source: :entra + ) + expect(req.canonical_name).to eq('jdoe') + end + + it 'defaults kind to :human when not provided' do + req = described_class.from_auth_context(sub: 'u1', name: 'alice', groups: [], source: nil) + expect(req.kind).to eq(:human) + end + + it 'defaults groups to [] when not provided' do + req = described_class.from_auth_context(sub: 'u1', name: 'alice', source: nil) + expect(req.groups).to eq([]) + end + + it 'maps resolved_roles to roles' do + req = described_class.from_auth_context(claims.merge(resolved_roles: %w[admin operator])) + expect(req.roles).to eq(%w[admin operator]) + end + + it 'defaults roles to [] when resolved_roles is absent' do + req = described_class.from_auth_context(claims) + expect(req.roles).to eq([]) + end + end + + describe '.from_auth_context canonical normalization' do + it 'strips domain from email-style names' do + req = described_class.from_auth_context(sub: 'uid', name: 'matt.iverson@optum.com') + expect(req.canonical_name).to eq('matt-iverson') + end + + it 'removes characters outside the allowed set' do + req = described_class.from_auth_context(sub: 'uid', name: 'user name!') + expect(req.canonical_name).to match(/\A[a-z0-9][a-z0-9_-]*\z/) + end + + it 'handles uppercase' do + req = described_class.from_auth_context(sub: 'uid', name: 'Matt.Iverson@OPTUM.COM') + expect(req.canonical_name).to eq('matt-iverson') + end + end + + describe '#groups' do + it 'is frozen' do + expect(request.groups).to be_frozen + end + end + + describe '#identity_hash' do + subject(:hash) { request.identity_hash } + + it 'includes principal_id' do + expect(hash[:principal_id]).to eq(principal_id) + end + + it 'includes canonical_name' do + expect(hash[:canonical_name]).to eq(canonical_name) + end + + it 'includes kind' do + expect(hash[:kind]).to eq(kind) + end + + it 'includes groups' do + expect(hash[:groups]).to eq(groups) + end + + it 'includes roles' do + req = described_class.new(principal_id: principal_id, canonical_name: canonical_name, kind: kind, roles: ['admin']) + expect(req.identity_hash[:roles]).to eq(['admin']) + end + + it 'includes roles as empty array by default' do + expect(hash[:roles]).to eq([]) + end + + it 'includes source' do + expect(hash[:source]).to eq(source) + end + end + + describe '#to_rbac_principal' do + it 'maps :service kind to :worker type' do + req = described_class.new(principal_id: 'svc1', canonical_name: 'my-service', kind: :service) + expect(req.to_rbac_principal[:type]).to eq(:worker) + end + + it 'keeps :human kind as :human type' do + expect(request.to_rbac_principal[:type]).to eq(:human) + end + + it 'keeps :machine kind as :machine type' do + req = described_class.new(principal_id: 'mc1', canonical_name: 'my-machine', kind: :machine) + expect(req.to_rbac_principal[:type]).to eq(:machine) + end + + it 'sets identity to canonical_name' do + expect(request.to_rbac_principal[:identity]).to eq(canonical_name) + end + end + + describe '#to_caller_hash' do + subject(:hash) { request.to_caller_hash } + + it 'nests everything under requested_by' do + expect(hash).to have_key(:requested_by) + end + + it 'sets id to principal_id' do + expect(hash[:requested_by][:id]).to eq(principal_id) + end + + it 'sets identity to canonical_name' do + expect(hash[:requested_by][:identity]).to eq(canonical_name) + end + + it 'sets type to kind' do + expect(hash[:requested_by][:type]).to eq(kind) + end + + it 'sets credential to source' do + expect(hash[:requested_by][:credential]).to eq(source) + end + end + + describe 'SOURCE_NORMALIZATION' do + subject(:map) { described_class::SOURCE_NORMALIZATION } + + it 'maps :api_key to :api' do + expect(map[:api_key]).to eq(:api) + end + + it 'maps :jwt to :jwt' do + expect(map[:jwt]).to eq(:jwt) + end + + it 'maps :kerberos to :kerberos' do + expect(map[:kerberos]).to eq(:kerberos) + end + + it 'maps :local to :system' do + expect(map[:local]).to eq(:system) + end + + it 'maps :system to :system' do + expect(map[:system]).to eq(:system) + end + + it 'is frozen' do + expect(map).to be_frozen + end + end + + describe '.from_auth_context with source normalization' do + it 'normalizes :api_key source to :api' do + req = described_class.from_auth_context(sub: 'u1', name: 'alice', source: :api_key) + expect(req.source).to eq(:api) + end + + it 'normalizes :local source to :system' do + req = described_class.from_auth_context(sub: 'u1', name: 'system', source: :local) + expect(req.source).to eq(:system) + end + + it 'normalizes :kerberos source to :kerberos (passthrough)' do + req = described_class.from_auth_context(sub: 'u1', name: 'alice', source: :kerberos) + expect(req.source).to eq(:kerberos) + end + + it 'normalizes :jwt source to :jwt (passthrough)' do + req = described_class.from_auth_context(sub: 'u1', name: 'service', source: :jwt) + expect(req.source).to eq(:jwt) + end + + it 'preserves unknown source values as-is' do + req = described_class.from_auth_context(sub: 'u1', name: 'alice', source: :entra) + expect(req.source).to eq(:entra) + end + + it 'handles nil source gracefully' do + req = described_class.from_auth_context(sub: 'u1', name: 'alice', source: nil) + expect(req.source).to be_nil + end + + it 'normalizes string source values by converting to symbol first' do + req = described_class.from_auth_context(sub: 'u1', name: 'alice', source: 'local') + expect(req.source).to eq(:system) + end + end +end diff --git a/spec/legion/identity/resolver_spec.rb b/spec/legion/identity/resolver_spec.rb new file mode 100644 index 00000000..0d1b39b6 --- /dev/null +++ b/spec/legion/identity/resolver_spec.rb @@ -0,0 +1,490 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/identity' +require 'legion/identity/resolver' +require 'legion/identity/process' +require 'legion/identity/trust' + +RSpec.describe Legion::Identity::Resolver do + before { described_class.reset_all! } + after { described_class.reset_all! } + + let(:kerberos_provider) do + Module.new do + extend self + + def provider_name = :kerberos + def provider_type = :auth + def priority = 100 + def trust_weight = 30 + def trust_level = :verified + def capabilities = [:authenticate] + + def resolve + { canonical_name: 'miverso2', kind: :human, source: :kerberos, + provider_identity: 'miverso2@MS.DS.UHC.COM' } + end + + def normalize(val) = val.to_s.split('@').first.downcase.gsub(/[^a-z0-9_-]/, '') + end + end + + let(:low_priority_auth) do + Module.new do + extend self + + def provider_name = :entra + def provider_type = :auth + def priority = 50 + def trust_weight = 50 + def trust_level = :authenticated + def capabilities = [:authenticate] + + def resolve + { canonical_name: 'miverso2-entra', kind: :human, source: :entra, + provider_identity: 'eb282cc7-uuid' } + end + + def normalize(val) = val.to_s.downcase + end + end + + let(:system_provider) do + Module.new do + extend self + + def provider_name = :system + def provider_type = :fallback + def priority = 0 + def trust_weight = 200 + def trust_level = :unverified + def capabilities = [:profile] + + def resolve + { canonical_name: 'testuser', kind: :human, source: :system, + provider_identity: 'testuser' } + end + + def normalize(val) = val.to_s.downcase.gsub(/[^a-z0-9_-]/, '') + end + end + + let(:profile_provider) do + Module.new do + extend self + + def provider_name = :ldap + def provider_type = :profile + def priority = 0 + def trust_weight = 10 + def trust_level = :verified + def capabilities = %i[profile groups] + + def resolve(canonical_name:) # rubocop:disable Lint/UnusedMethodArgument + { groups: ['devs'], profile: { department: 'Engineering' } } + end + + def normalize(val) = val.to_s.downcase + end + end + + let(:timeout_provider) do + Module.new do + extend self + + def provider_name = :slow_provider + def provider_type = :auth + def priority = 200 + def trust_weight = 10 + def trust_level = :verified + def capabilities = [:authenticate] + + def resolve + sleep 10 + { canonical_name: 'slow-user', kind: :human, source: :slow_provider, + provider_identity: 'slow@example.com' } + end + + def normalize(val) = val.to_s.downcase + end + end + + let(:failing_provider) do + Module.new do + extend self + + def provider_name = :broken + def provider_type = :auth + def priority = 150 + def trust_weight = 20 + def trust_level = :verified + def capabilities = [:authenticate] + + def resolve + raise StandardError, 'connection refused' + end + + def normalize(val) = val.to_s.downcase + end + end + + let(:nil_provider) do + Module.new do + extend self + + def provider_name = :empty + def provider_type = :auth + def priority = 80 + def trust_weight = 40 + def trust_level = :authenticated + def capabilities = [:authenticate] + def resolve = nil + def normalize(val) = val.to_s.downcase + end + end + + describe '.register' do + it 'adds a provider' do + described_class.register(kerberos_provider) + expect(described_class.providers.size).to eq(1) + end + + it 'ignores duplicates by provider_name' do + described_class.register(kerberos_provider) + described_class.register(kerberos_provider) + expect(described_class.providers.size).to eq(1) + end + + it 'accepts providers with different names' do + described_class.register(kerberos_provider) + described_class.register(system_provider) + expect(described_class.providers.size).to eq(2) + end + end + + describe '.resolve!' do + before do + Legion::Identity::Process.reset! + allow(File).to receive(:file?).and_call_original + allow(File).to receive(:file?).with(File.expand_path('~/.legionio/settings/identity.json')).and_return(false) + end + + after do + Legion::Identity::Process.reset! + end + + it 'sets resolved? to true after successful resolution' do + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.resolved?).to be(true) + end + + it 'picks the highest-priority auth provider for canonical_name' do + described_class.register(low_priority_auth) + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.composite[:canonical_name]).to eq('miverso2') + end + + it 'sets trust from the winning provider trust_level method' do + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.composite[:trust]).to eq(:verified) + end + + it 'records aliases as arrays per provider' do + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.composite[:aliases][:kerberos]).to eq(['miverso2@MS.DS.UHC.COM']) + end + + it 'tracks all provider results in the composite' do + described_class.register(kerberos_provider) + described_class.register(low_priority_auth) + described_class.resolve! + providers_map = described_class.composite[:providers] + expect(providers_map).to have_key(:kerberos) + expect(providers_map).to have_key(:entra) + expect(providers_map[:kerberos][:status]).to eq(:resolved) + expect(providers_map[:entra][:status]).to eq(:resolved) + end + + it 'binds Identity::Process' do + described_class.register(kerberos_provider) + described_class.resolve! + expect(Legion::Identity::Process.resolved?).to be(true) + expect(Legion::Identity::Process.canonical_name).to eq('miverso2') + end + + it 'returns the composite hash' do + described_class.register(kerberos_provider) + result = described_class.resolve! + expect(result).to be_a(Hash) + expect(result[:canonical_name]).to eq('miverso2') + end + + it 'returns nil when no providers are registered' do + result = described_class.resolve! + expect(result).to be_nil + expect(described_class.resolved?).to be(false) + end + + it 'sets persistent to true' do + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.composite[:persistent]).to be(true) + end + + it 'sets source to the winning provider name' do + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.composite[:source]).to eq(:kerberos) + end + + it 'sets kind from winning result' do + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.composite[:kind]).to eq(:human) + end + + context 'with profile providers' do + it 'merges groups from profile providers' do + described_class.register(kerberos_provider) + described_class.register(profile_provider) + described_class.resolve! + expect(described_class.composite[:groups]).to include('devs') + end + + it 'merges profile data from profile providers' do + described_class.register(kerberos_provider) + described_class.register(profile_provider) + described_class.resolve! + expect(described_class.composite[:profile][:department]).to eq('Engineering') + end + + it 'includes profile provider in the providers map' do + described_class.register(kerberos_provider) + described_class.register(profile_provider) + described_class.resolve! + expect(described_class.composite[:providers]).to have_key(:ldap) + expect(described_class.composite[:providers][:ldap][:status]).to eq(:resolved) + end + end + + context 'with no auth providers but a fallback' do + it 'falls back to the fallback provider' do + described_class.register(system_provider) + described_class.resolve! + expect(described_class.resolved?).to be(true) + expect(described_class.composite[:canonical_name]).to eq('testuser') + end + + it 'uses fallback provider trust_level' do + described_class.register(system_provider) + described_class.resolve! + expect(described_class.composite[:trust]).to eq(:unverified) + end + end + + context 'with cached identity' do + before do + allow(described_class).to receive(:persist_identity_json) + allow(File).to receive(:read).and_call_original + end + + it 'uses identity.json before unverified fallback providers' do + allow(File).to receive(:file?).with(File.expand_path('~/.legionio/settings/identity.json')).and_return(true) + allow(File).to receive(:read).with(File.expand_path('~/.legionio/settings/identity.json')) + .and_return('{"canonical_name":"cached-user","kind":"human"}') + + described_class.register(system_provider) + described_class.resolve! + + expect(described_class.composite[:canonical_name]).to eq('cached-user') + expect(described_class.composite[:trust]).to eq(:cached) + expect(described_class.composite[:providers][:identity_cache][:status]).to eq(:resolved) + expect(Legion::Identity::Process.canonical_name).to eq('cached-user') + end + end + + context 'with a timeout provider' do + it 'records :timeout status and falls through' do + described_class.register(timeout_provider) + described_class.register(kerberos_provider) + result = described_class.resolve!(timeout: 1) + expect(result[:canonical_name]).to eq('miverso2') + expect(result[:providers][:slow_provider][:status]).to eq(:timeout) + end + end + + context 'with a failing provider' do + it 'records :failed status' do + described_class.register(failing_provider) + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.composite[:providers][:broken][:status]).to eq(:failed) + end + + it 'still resolves via working providers' do + described_class.register(failing_provider) + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.composite[:canonical_name]).to eq('miverso2') + end + end + + context 'with a nil-returning provider' do + it 'records :no_identity status' do + described_class.register(nil_provider) + described_class.register(kerberos_provider) + described_class.resolve! + expect(described_class.composite[:providers][:empty][:status]).to eq(:no_identity) + end + end + end + + describe '.reset!' do + it 'preserves providers' do + described_class.register(kerberos_provider) + described_class.reset! + expect(described_class.providers.size).to eq(1) + end + + it 'clears composite' do + described_class.register(kerberos_provider) + Legion::Identity::Process.reset! + described_class.resolve! + described_class.reset! + expect(described_class.composite).to be_nil + expect(described_class.resolved?).to be(false) + Legion::Identity::Process.reset! + end + + it 'regenerates session_id' do + old_session = described_class.session_id + described_class.reset! + expect(described_class.session_id).not_to eq(old_session) + end + end + + describe '.reset_all!' do + it 'clears everything including providers' do + described_class.register(kerberos_provider) + described_class.reset_all! + expect(described_class.providers).to be_empty + expect(described_class.composite).to be_nil + expect(described_class.resolved?).to be(false) + end + end + + describe 'pending registrations' do + it 'drains pending registrations on resolve!' do + Legion::Identity.pending_registrations << kerberos_provider + Legion::Identity::Process.reset! + described_class.resolve! + expect(described_class.resolved?).to be(true) + expect(described_class.composite[:canonical_name]).to eq('miverso2') + expect(Legion::Identity.pending_registrations).to be_empty + Legion::Identity::Process.reset! + end + end + + describe '.upgrade!' do + before do + Legion::Identity::Process.reset! + described_class.register(system_provider) + described_class.resolve! + end + + after do + Legion::Identity::Process.reset! + end + + it 'upgrades trust level' do + result = { canonical_name: 'testuser', kind: :human, source: :kerberos, + provider_identity: 'testuser@MS.DS.UHC.COM' } + described_class.upgrade!(kerberos_provider, result) + expect(described_class.composite[:trust]).to eq(:verified) + end + + it 'adds new provider to providers map' do + result = { canonical_name: 'testuser', kind: :human, source: :kerberos, + provider_identity: 'testuser@MS.DS.UHC.COM' } + described_class.upgrade!(kerberos_provider, result) + expect(described_class.composite[:providers]).to have_key(:kerberos) + expect(described_class.composite[:providers][:kerberos][:status]).to eq(:resolved) + end + + it 'adds alias from new provider' do + result = { canonical_name: 'testuser', kind: :human, source: :kerberos, + provider_identity: 'testuser@MS.DS.UHC.COM' } + described_class.upgrade!(kerberos_provider, result) + expect(described_class.composite[:aliases][:kerberos]).to include('testuser@MS.DS.UHC.COM') + end + + it 'can change canonical_name' do + result = { canonical_name: 'miverso2', kind: :human, source: :kerberos, + provider_identity: 'miverso2@MS.DS.UHC.COM' } + described_class.upgrade!(kerberos_provider, result) + expect(described_class.composite[:canonical_name]).to eq('miverso2') + end + + it 're-binds Identity::Process after upgrade' do + result = { canonical_name: 'testuser', kind: :human, source: :kerberos, + provider_identity: 'testuser@MS.DS.UHC.COM' } + described_class.upgrade!(kerberos_provider, result) + expect(Legion::Identity::Process.trust).to eq(:verified) + end + end + + describe '.session_id' do + it 'returns a UUID string' do + expect(described_class.session_id).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/) + end + end + + describe 'tiebreaking' do + it 'uses trust_weight for tiebreak when priorities are equal' do + provider_a = Module.new do + extend self + + def provider_name = :provider_a + def provider_type = :auth + def priority = 100 + def trust_weight = 50 + def trust_level = :authenticated + def capabilities = [:authenticate] + + def resolve + { canonical_name: 'user-a', kind: :human, source: :provider_a, + provider_identity: 'user-a@example.com' } + end + end + + provider_b = Module.new do + extend self + + def provider_name = :provider_b + def provider_type = :auth + def priority = 100 + def trust_weight = 10 + def trust_level = :verified + def capabilities = [:authenticate] + + def resolve + { canonical_name: 'user-b', kind: :human, source: :provider_b, + provider_identity: 'user-b@example.com' } + end + end + + Legion::Identity::Process.reset! + described_class.register(provider_a) + described_class.register(provider_b) + described_class.resolve! + # Same priority (100), lower trust_weight wins tiebreak + expect(described_class.composite[:canonical_name]).to eq('user-b') + Legion::Identity::Process.reset! + end + end +end diff --git a/spec/legion/identity/trust_spec.rb b/spec/legion/identity/trust_spec.rb new file mode 100644 index 00000000..d66bab61 --- /dev/null +++ b/spec/legion/identity/trust_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Identity::Trust do + describe '.levels' do + it 'returns all trust levels in descending order of trust' do + expect(described_class.levels).to eq(%i[verified authenticated configured cached unverified]) + end + end + + describe '.rank' do + it 'returns 0 for :verified (highest trust)' do + expect(described_class.rank(:verified)).to eq(0) + end + + it 'returns 4 for :unverified (lowest trust)' do + expect(described_class.rank(:unverified)).to eq(4) + end + + it 'returns nil for unknown levels' do + expect(described_class.rank(:bogus)).to be_nil + end + end + + describe '.above?' do + it 'returns true when first level is more trusted' do + expect(described_class.above?(:verified, :cached)).to be true + end + + it 'returns false when first level is less trusted' do + expect(described_class.above?(:unverified, :verified)).to be false + end + + it 'returns false when levels are equal' do + expect(described_class.above?(:verified, :verified)).to be false + end + end + + describe '.at_least?' do + it 'returns true when levels are equal' do + expect(described_class.at_least?(:verified, :verified)).to be true + end + + it 'returns true when first is more trusted' do + expect(described_class.at_least?(:verified, :cached)).to be true + end + + it 'returns false when first is less trusted' do + expect(described_class.at_least?(:unverified, :verified)).to be false + end + end +end diff --git a/spec/legion/ingress_local_spec.rb b/spec/legion/ingress_local_spec.rb new file mode 100644 index 00000000..d97e47d5 --- /dev/null +++ b/spec/legion/ingress_local_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/ingress' +require 'legion/extensions' + +RSpec.describe 'Ingress local dispatch' do + let(:runner_module) do + Module.new do + def self.action(**args) + { result: 'local', **args } + end + end + end + + describe '.local_runner?' do + it 'returns true when runner is in local_tasks' do + allow(Legion::Extensions).to receive(:local_tasks).and_return([ + { runner_module: runner_module, actor_name: 'test' } + ]) + expect(Legion::Ingress.local_runner?(runner_module)).to be true + end + + it 'returns false when runner is not in local_tasks' do + allow(Legion::Extensions).to receive(:local_tasks).and_return([]) + expect(Legion::Ingress.local_runner?(runner_module)).to be false + end + + it 'returns false when Extensions is not set up' do + allow(Legion::Extensions).to receive(:local_tasks).and_return(nil) + expect(Legion::Ingress.local_runner?(runner_module)).to be false + end + end + + describe '.run with local runner' do + before do + allow(Legion::Extensions).to receive(:local_tasks).and_return([ + { runner_module: runner_module, actor_name: 'test' } + ]) + end + + it 'invokes the runner directly without AMQP' do + # Use a named constant so Ingress validation and const_get work + stub_const('TestLocalRunner', runner_module) + allow(Legion::Extensions).to receive(:local_tasks).and_return([ + { runner_module: TestLocalRunner, actor_name: 'test' } + ]) + + result = Legion::Ingress.run( + payload: { key: 'value' }, + runner_class: 'TestLocalRunner', + function: 'action', + source: 'test' + ) + expect(result[:result]).to eq('local') + end + end +end diff --git a/spec/legion/ingress_open_inference_spec.rb b/spec/legion/ingress_open_inference_spec.rb new file mode 100644 index 00000000..ad1b22e0 --- /dev/null +++ b/spec/legion/ingress_open_inference_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/ingress' + +unless defined?(Legion::DigitalWorker::Registry) + module Legion + module DigitalWorker + module Registry + class WorkerNotFound < StandardError + end + + class WorkerNotActive < StandardError + end + + class InsufficientConsent < StandardError + end + end + end + end +end + +unless defined?(Legion::Rbac::Principal) + module Legion + module Rbac + class Principal + def self.local_admin = :admin + end + + def self.authorize_execution!(**) = nil + end + end +end + +RSpec.describe 'Legion::Ingress OpenInference instrumentation' do + before do + stub_const('Legion::Telemetry::OpenInference', Module.new do + def self.open_inference_enabled? + true + end + + def self.tool_span(**) + yield(nil) + end + end) + + stub_const('Legion::Runner', Class.new do + def self.run(**) = { success: true } + end) + + stub_const('Legion::Events', Class.new do + def self.emit(*) = nil + end) + + allow(Legion::Rbac).to receive(:authorize_execution!) + end + + describe '.run' do + it 'wraps runner invocation in tool_span' do + expect(Legion::Telemetry::OpenInference).to receive(:tool_span) + .with(hash_including(name: 'TestRunner.func')) + .and_yield(nil) + + Legion::Ingress.run( + payload: {}, + runner_class: 'TestRunner', + function: 'func', + source: 'test' + ) + end + + it 'works without OpenInference loaded' do + hide_const('Legion::Telemetry::OpenInference') + result = Legion::Ingress.run( + payload: {}, + runner_class: 'TestRunner', + function: 'func', + source: 'test' + ) + expect(result[:success]).to be true + end + end +end diff --git a/spec/legion/ingress_spec.rb b/spec/legion/ingress_spec.rb new file mode 100644 index 00000000..a6d5051a --- /dev/null +++ b/spec/legion/ingress_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Stub dependencies +unless defined?(Legion::DigitalWorker::Registry) + module Legion + module DigitalWorker + module Registry + class WorkerNotFound < StandardError; end + class WorkerNotActive < StandardError; end + class InsufficientConsent < StandardError; end + end + end + end +end + +RSpec.describe Legion::Ingress do + let(:runner_class) { 'Legion::Test::Runners::Example' } + let(:function) { :do_work } + + before do + allow(Legion::Events).to receive(:emit) + allow(Legion::Runner).to receive(:run).and_return({ success: true, status: 'task.completed' }) + if defined?(Legion::Rbac) + allow(Legion::Rbac).to receive(:authorize_execution!) + stub_const('Legion::Rbac::Principal', double(local_admin: double('Principal'))) + end + end + + describe '.run' do + context 'without worker_id' do + it 'does not call Registry.validate_execution!' do + expect(Legion::DigitalWorker::Registry).not_to receive(:validate_execution!) + described_class.run(payload: {}, runner_class: runner_class, function: function) + end + + it 'proceeds to Runner.run' do + expect(Legion::Runner).to receive(:run) + described_class.run(payload: {}, runner_class: runner_class, function: function) + end + end + + context 'with worker_id and Registry defined' do + it 'calls Registry.validate_execution! with the worker_id' do + expect(Legion::DigitalWorker::Registry).to receive(:validate_execution!) + .with(worker_id: 'dw-123', required_consent: nil) + described_class.run(payload: { worker_id: 'dw-123' }, runner_class: runner_class, function: function) + end + + it 'checks registration before execution' do + call_order = [] + allow(Legion::DigitalWorker::Registry).to receive(:validate_execution!) { call_order << :registry } + allow(Legion::Runner).to receive(:run) do + call_order << :runner + { success: true } + end + described_class.run(payload: { worker_id: 'dw-123' }, runner_class: runner_class, function: function) + expect(call_order).to eq(%i[registry runner]) + end + end + + context 'when worker is not registered' do + before do + allow(Legion::DigitalWorker::Registry).to receive(:validate_execution!) + .and_raise(Legion::DigitalWorker::Registry::WorkerNotFound, 'no registered worker with id dw-999') + end + + it 'returns a structured error' do + result = described_class.run(payload: { worker_id: 'dw-999' }, runner_class: runner_class, function: function) + expect(result[:success]).to be false + expect(result[:status]).to eq('task.blocked') + expect(result[:error][:code]).to eq('worker_not_found') + end + + it 'does not call Runner.run' do + expect(Legion::Runner).not_to receive(:run) + described_class.run(payload: { worker_id: 'dw-999' }, runner_class: runner_class, function: function) + end + end + + context 'when worker is not active' do + before do + allow(Legion::DigitalWorker::Registry).to receive(:validate_execution!) + .and_raise(Legion::DigitalWorker::Registry::WorkerNotActive, 'worker dw-456 is paused') + end + + it 'returns a structured error with worker_not_active code' do + result = described_class.run(payload: { worker_id: 'dw-456' }, runner_class: runner_class, function: function) + expect(result[:success]).to be false + expect(result[:error][:code]).to eq('worker_not_active') + end + end + + context 'when consent is insufficient' do + before do + allow(Legion::DigitalWorker::Registry).to receive(:validate_execution!) + .and_raise(Legion::DigitalWorker::Registry::InsufficientConsent, 'consent too low') + end + + it 'returns a structured error with insufficient_consent code' do + result = described_class.run( + payload: { worker_id: 'dw-789', required_consent: 'autonomous' }, + runner_class: runner_class, function: function + ) + expect(result[:success]).to be false + expect(result[:error][:code]).to eq('insufficient_consent') + end + end + + context 'with required_consent in payload' do + it 'passes required_consent to validate_execution!' do + expect(Legion::DigitalWorker::Registry).to receive(:validate_execution!) + .with(worker_id: 'dw-123', required_consent: 'autonomous') + described_class.run( + payload: { worker_id: 'dw-123', required_consent: 'autonomous' }, + runner_class: runner_class, function: function + ) + end + end + + context 'input validation' do + it 'rejects invalid runner_class format' do + result = described_class.run( + payload: {}, runner_class: '../../../etc/passwd', function: 'read', source: 'test' + ) + expect(result[:success]).to be false + expect(result[:error][:code]).to eq('invalid_runner_class') + end + + it 'rejects invalid function format' do + result = described_class.run( + payload: {}, runner_class: 'Legion::Test::Runner', + function: 'system("rm -rf /")', source: 'test' + ) + expect(result[:success]).to be false + expect(result[:error][:code]).to eq('invalid_function') + end + + it 'accepts valid runner_class and function formats' do + result = described_class.run( + payload: {}, runner_class: 'Legion::Test::Runner', function: 'do_thing', source: 'test' + ) + expect(result[:error]).to be_nil + end + end + + context 'when an extension handle is quiescing' do + before do + Legion::Extensions.reset_runtime_handles! + Legion::Extensions.register_extension_handle('lex-example', state: :running, reload_state: :updating) + end + + after { Legion::Extensions.reset_runtime_handles! } + + it 'blocks runner dispatch for that extension before Runner.run' do + result = described_class.run( + payload: {}, + runner_class: 'Legion::Extensions::Example::Runners::Worker', + function: 'do_work', + source: 'test' + ) + + expect(result[:success]).to be false + expect(result[:status]).to eq('task.blocked') + expect(result[:error][:code]).to eq('extension_quiescing') + expect(Legion::Runner).not_to have_received(:run) + end + end + end +end diff --git a/spec/legion/isolation_spec.rb b/spec/legion/isolation_spec.rb new file mode 100644 index 00000000..d3a252b1 --- /dev/null +++ b/spec/legion/isolation_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/isolation' + +RSpec.describe Legion::Isolation::Context do + let(:ctx) { described_class.new(agent_id: 'bot-1', tenant_id: 'askid-123', allowed_tools: ['read_file']) } + + describe '#tool_allowed?' do + it 'allows listed tools' do + expect(ctx.tool_allowed?('read_file')).to be true + end + + it 'denies unlisted tools' do + expect(ctx.tool_allowed?('delete_all')).to be false + end + + it 'allows all when empty' do + open_ctx = described_class.new(agent_id: 'bot-2') + expect(open_ctx.tool_allowed?('anything')).to be true + end + end + + describe '#data_filter' do + it 'includes agent_id and tenant_id' do + expect(ctx.data_filter).to eq({ agent_id: 'bot-1', tenant_id: 'askid-123' }) + end + + it 'excludes tenant_id when nil' do + ctx_no_tenant = described_class.new(agent_id: 'bot-2') + expect(ctx_no_tenant.data_filter).to eq({ agent_id: 'bot-2' }) + end + end + + describe '#risk_tier' do + it 'defaults to standard' do + expect(ctx.risk_tier).to eq(:standard) + end + + it 'accepts custom tier' do + high = described_class.new(agent_id: 'bot', risk_tier: :high) + expect(high.risk_tier).to eq(:high) + end + end +end + +RSpec.describe Legion::Isolation do + after { Thread.current[:legion_isolation_context] = nil } + + describe '.with_context' do + it 'sets and restores context' do + ctx = Legion::Isolation::Context.new(agent_id: 'test') + inner_ctx = nil + described_class.with_context(ctx) { inner_ctx = described_class.current } + expect(inner_ctx).to eq(ctx) + expect(described_class.current).to be_nil + end + + it 'restores previous context on exception' do + ctx = Legion::Isolation::Context.new(agent_id: 'test') + begin + described_class.with_context(ctx) { raise 'boom' } + rescue RuntimeError + nil + end + expect(described_class.current).to be_nil + end + end + + describe '.enforce_tool_access!' do + it 'raises for unauthorized tool' do + ctx = Legion::Isolation::Context.new(agent_id: 'bot', allowed_tools: ['safe_tool']) + described_class.with_context(ctx) do + expect { described_class.enforce_tool_access!('dangerous') }.to raise_error(SecurityError) + end + end + + it 'passes without context' do + expect(described_class.enforce_tool_access!('anything')).to be true + end + + it 'passes for allowed tool' do + ctx = Legion::Isolation::Context.new(agent_id: 'bot', allowed_tools: ['read_file']) + described_class.with_context(ctx) do + expect(described_class.enforce_tool_access!('read_file')).to be true + end + end + end +end diff --git a/spec/legion/leader_spec.rb b/spec/legion/leader_spec.rb new file mode 100644 index 00000000..7d83347d --- /dev/null +++ b/spec/legion/leader_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/leader' + +RSpec.describe Legion::Leader do + before do + described_class.reset! + allow(Legion::Lock).to receive(:acquire).and_return('test-token') + allow(Legion::Lock).to receive(:release).and_return(true) + allow(Legion::Lock).to receive(:extend_lock).and_return(true) + allow(Legion::Lock).to receive(:locked?).and_return(true) + end + + after { described_class.reset! } + + describe '.elect' do + it 'returns token on success' do + expect(described_class.elect(:scheduler)).to eq('test-token') + end + + it 'returns nil when lock not acquired' do + allow(Legion::Lock).to receive(:acquire).and_return(nil) + expect(described_class.elect(:scheduler)).to be_nil + end + + it 'converts ttl seconds to milliseconds' do + described_class.elect(:scheduler, ttl: 15) + expect(Legion::Lock).to have_received(:acquire).with('leader:scheduler', ttl: 15_000) + end + + it 'stores the leadership entry' do + described_class.elect(:scheduler) + expect(described_class.leader?(:scheduler)).to be true + end + end + + describe '.leader?' do + it 'returns false when role not elected' do + expect(described_class.leader?(:unknown)).to be false + end + + it 'returns true when role is elected and lock exists' do + described_class.elect(:scheduler) + expect(described_class.leader?(:scheduler)).to be true + end + + it 'returns false when lock has expired' do + described_class.elect(:scheduler) + allow(Legion::Lock).to receive(:locked?).and_return(false) + expect(described_class.leader?(:scheduler)).to be false + end + end + + describe '.resign' do + it 'releases the lock' do + described_class.elect(:scheduler) + described_class.resign(:scheduler) + expect(Legion::Lock).to have_received(:release).with('leader:scheduler', 'test-token') + end + + it 'returns false when not a leader' do + expect(described_class.resign(:unknown)).to be false + end + + it 'clears the leadership entry' do + described_class.elect(:scheduler) + described_class.resign(:scheduler) + expect(described_class.leader?(:scheduler)).to be false + end + end + + describe '.with_leadership' do + it 'yields when leadership is acquired' do + expect { |b| described_class.with_leadership(:scheduler, ttl: 30, &b) }.to yield_control + end + + it 'raises NotAcquired when election fails' do + allow(Legion::Lock).to receive(:acquire).and_return(nil) + expect { described_class.with_leadership(:scheduler) { nil } }.to raise_error(Legion::Lock::NotAcquired) + end + + it 'resigns after the block completes' do + described_class.with_leadership(:scheduler) { nil } + expect(Legion::Lock).to have_received(:release) + end + + it 'resigns even when the block raises' do + begin + described_class.with_leadership(:scheduler) { raise 'boom' } + rescue RuntimeError + nil + end + expect(Legion::Lock).to have_received(:release) + end + end + + describe '.reset!' do + it 'resigns all leaders' do + described_class.elect(:scheduler) + described_class.elect(:archiver) + described_class.reset! + expect(described_class.leader?(:scheduler)).to be false + expect(described_class.leader?(:archiver)).to be false + end + end +end diff --git a/spec/legion/llm/settings_override_spec.rb b/spec/legion/llm/settings_override_spec.rb new file mode 100644 index 00000000..0ada75e7 --- /dev/null +++ b/spec/legion/llm/settings_override_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/llm' + +RSpec.describe 'LegionIO LLM namespace settings override' do + it 'enables use_namespaces via loader.settings override' do + Legion::Settings.merge_settings('llm', Legion::LLM::Settings.default) + Legion::Settings.loader.settings[:llm][:use_namespaces] = true + + expect(Legion::Settings[:llm][:use_namespaces]).to eq(true) + end + + it 'preserves other api defaults after override' do + Legion::Settings.merge_settings('llm', Legion::LLM::Settings.default) + Legion::Settings.loader.settings[:llm][:use_namespaces] = true + + expect(Legion::Settings[:llm][:api][:auth][:enabled]).to eq(false) + expect(Legion::Settings[:llm][:api][:auth][:api_keys]).to eq([]) + end +end diff --git a/spec/legion/lock_spec.rb b/spec/legion/lock_spec.rb new file mode 100644 index 00000000..0fb96926 --- /dev/null +++ b/spec/legion/lock_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/lock' + +RSpec.describe Legion::Lock do + let(:mock_redis) { instance_double('Redis') } + let(:mock_pool) { instance_double('ConnectionPool') } + + before do + pool = mock_pool + redis = mock_redis + allow(pool).to receive(:with).and_yield(redis) + cache_mod = Module.new + cache_mod.define_singleton_method(:client) { pool } + stub_const('Legion::Cache', cache_mod) + end + + describe '.acquire' do + it 'returns a UUID token when SET NX succeeds' do + allow(mock_redis).to receive(:set).and_return(true) + token = described_class.acquire('test-lock') + expect(token).to match(/\A[0-9a-f-]{36}\z/) + end + + it 'returns nil when SET NX fails' do + allow(mock_redis).to receive(:set).and_return(false) + expect(described_class.acquire('test-lock')).to be_nil + end + + it 'passes NX and PX options to Redis SET' do + allow(mock_redis).to receive(:set).and_return(true) + described_class.acquire('test-lock', ttl: 5000) + expect(mock_redis).to have_received(:set).with('legion:lock:test-lock', anything, nx: true, px: 5000) + end + + it 'returns nil when Redis is unavailable' do + pool = mock_pool + allow(pool).to receive(:with).and_raise(StandardError, 'connection refused') + expect(described_class.acquire('test-lock')).to be_nil + end + end + + describe '.release' do + it 'returns true when token matches' do + allow(mock_redis).to receive(:eval).and_return(1) + expect(described_class.release('test-lock', 'my-token')).to be true + end + + it 'returns false when token does not match' do + allow(mock_redis).to receive(:eval).and_return(0) + expect(described_class.release('test-lock', 'wrong-token')).to be false + end + + it 'uses Lua script with correct key and argv' do + allow(mock_redis).to receive(:eval).and_return(1) + described_class.release('test-lock', 'my-token') + expect(mock_redis).to have_received(:eval).with( + described_class::RELEASE_SCRIPT, + keys: ['legion:lock:test-lock'], + argv: ['my-token'] + ) + end + + it 'returns false when Redis is unavailable' do + pool = mock_pool + allow(pool).to receive(:with).and_raise(StandardError, 'connection refused') + expect(described_class.release('test-lock', 'tok')).to be false + end + end + + describe '.with_lock' do + it 'yields when lock is acquired' do + allow(mock_redis).to receive(:set).and_return(true) + allow(mock_redis).to receive(:eval).and_return(1) + expect { |b| described_class.with_lock('test-lock', &b) }.to yield_control + end + + it 'raises NotAcquired when lock cannot be obtained' do + allow(mock_redis).to receive(:set).and_return(false) + expect { described_class.with_lock('test-lock') { nil } }.to raise_error(Legion::Lock::NotAcquired) + end + + it 'releases the lock even when the block raises' do + allow(mock_redis).to receive(:set).and_return(true) + allow(mock_redis).to receive(:eval).and_return(1) + begin + described_class.with_lock('test-lock') { raise 'boom' } + rescue RuntimeError + nil + end + expect(mock_redis).to have_received(:eval).with(described_class::RELEASE_SCRIPT, anything) + end + end + + describe '.extend_lock' do + it 'returns true when token matches and TTL is reset' do + allow(mock_redis).to receive(:eval).and_return(1) + expect(described_class.extend_lock('test-lock', 'my-token', ttl: 10_000)).to be true + end + + it 'returns false when token does not match' do + allow(mock_redis).to receive(:eval).and_return(0) + expect(described_class.extend_lock('test-lock', 'wrong', ttl: 10_000)).to be false + end + end + + describe '.locked?' do + it 'returns true when key exists' do + allow(mock_redis).to receive(:exists?).and_return(true) + expect(described_class.locked?('test-lock')).to be true + end + + it 'returns false when key does not exist' do + allow(mock_redis).to receive(:exists?).and_return(false) + expect(described_class.locked?('test-lock')).to be false + end + end +end diff --git a/spec/legion/memory/consolidator_spec.rb b/spec/legion/memory/consolidator_spec.rb new file mode 100644 index 00000000..fc2bf800 --- /dev/null +++ b/spec/legion/memory/consolidator_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'legion/memory/consolidator' + +RSpec.describe Legion::Memory::Consolidator do + let(:tmpdir) { Dir.mktmpdir } + let(:lock_file) { File.join(tmpdir, 'memory_consolidation.lock') } + let(:sessions_dir) { File.join(tmpdir, 'sessions') } + + before do + stub_const('Legion::Memory::Consolidator::LOCK_FILE', lock_file) + stub_const('Legion::Memory::Consolidator::SESSIONS_DIR', sessions_dir) + FileUtils.mkdir_p(sessions_dir) + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + allow(Legion::Logging).to receive(:error) + end + + after { FileUtils.rm_rf(tmpdir) } + + def write_session(name, messages: [], cwd: '/tmp') + data = { name: name, cwd: cwd, messages: messages } + raw = defined?(Legion::JSON) ? Legion::JSON.dump(data) : data.to_json + File.write(File.join(sessions_dir, "#{name}.json"), raw) + end + + describe '.enabled?' do + it 'returns false by default' do + allow(Legion::Settings).to receive(:dig).with(:memory, :consolidation).and_return(nil) + expect(described_class.enabled?).to be false + end + + it 'returns true when enabled in settings' do + allow(Legion::Settings).to receive(:dig).with(:memory, :consolidation).and_return({ enabled: true }) + expect(described_class.enabled?).to be true + end + end + + describe '.gate_status' do + before do + allow(Legion::Settings).to receive(:dig).with(:memory, :consolidation) + .and_return({ enabled: true, min_hours: 24, min_sessions: 5 }) + end + + it 'returns hash with three gates' do + status = described_class.gate_status + expect(status).to have_key(:time_gate) + expect(status).to have_key(:session_gate) + expect(status).to have_key(:lock_gate) + end + + context 'time gate' do + it 'passes when no lock file exists' do + expect(described_class.gate_status[:time_gate]).to be true + end + + it 'fails when lock file is recent' do + FileUtils.mkdir_p(File.dirname(lock_file)) + FileUtils.touch(lock_file) + expect(described_class.gate_status[:time_gate]).to be false + end + end + + context 'session gate' do + it 'fails when fewer than min_sessions exist' do + 2.times { |i| write_session("s#{i}", messages: [{ role: 'user', content: "msg#{i}" }]) } + expect(described_class.gate_status[:session_gate]).to be false + end + + it 'passes when enough new sessions exist' do + 6.times { |i| write_session("s#{i}", messages: [{ role: 'user', content: "msg#{i}" }]) } + expect(described_class.gate_status[:session_gate]).to be true + end + end + + context 'lock gate' do + it 'passes when no active lock' do + expect(described_class.gate_status[:lock_gate]).to be true + end + + it 'fails when active lock exists' do + FileUtils.mkdir_p(File.dirname(lock_file)) + FileUtils.touch(lock_file) + File.write("#{lock_file}.active", '12345') + expect(described_class.gate_status[:lock_gate]).to be false + end + end + end + + describe '.run' do + before do + allow(Legion::Settings).to receive(:dig).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:memory, :consolidation) + .and_return({ enabled: true, min_hours: 0, min_sessions: 1 }) + end + + it 'returns disabled when not enabled' do + allow(Legion::Settings).to receive(:dig).with(:memory, :consolidation).and_return({ enabled: false }) + result = described_class.run + expect(result[:success]).to be false + expect(result[:reason]).to eq(:disabled) + end + + it 'returns gates_failed when gates do not pass' do + allow(Legion::Settings).to receive(:dig).with(:memory, :consolidation) + .and_return({ enabled: true, min_hours: 24, min_sessions: 100 }) + result = described_class.run + expect(result[:success]).to be false + expect(result[:reason]).to eq(:gates_failed) + end + + it 'succeeds with force even when gates fail' do + write_session('forced', messages: [{ role: 'user', content: 'hello' }]) + result = described_class.run(force: true) + expect(result[:success]).to be true + end + + it 'returns llm_unavailable reason when no LLM' do + hide_const('Legion::LLM') if defined?(Legion::LLM) + write_session('test', messages: [{ role: 'user', content: 'hello' }]) + result = described_class.run(force: true) + expect(result[:success]).to be true + expect(result[:reason]).to eq(:llm_unavailable) + expect(result[:insights]).to eq([]) + end + + it 'releases lock even on failure' do + write_session('test', messages: [{ role: 'user', content: 'hello' }]) + described_class.run(force: true) + expect(File.exist?("#{lock_file}.active")).to be false + end + + it 'touches lock file on success' do + write_session('test', messages: [{ role: 'user', content: 'hello' }]) + described_class.run(force: true) + expect(File.exist?(lock_file)).to be true + end + end + + describe '.parse_insights' do + it 'parses valid JSON array' do + json = '[{"text": "user prefers concise output", "category": "preference"}]' + result = described_class.send(:parse_insights, json) + expect(result.length).to eq(1) + expect(result.first[:text]).to eq('user prefers concise output') + end + + it 'returns empty array for invalid JSON' do + expect(described_class.send(:parse_insights, 'not json')).to eq([]) + end + + it 'extracts JSON from markdown-wrapped response' do + text = "Here are the insights:\n```json\n[{\"text\": \"insight\", \"category\": \"learning\"}]\n```" + result = described_class.send(:parse_insights, text) + expect(result.length).to eq(1) + end + end +end diff --git a/spec/legion/metrics_spec.rb b/spec/legion/metrics_spec.rb new file mode 100644 index 00000000..62ea77b6 --- /dev/null +++ b/spec/legion/metrics_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/metrics' + +RSpec.describe Legion::Metrics do + before(:each) { described_class.reset! } + after(:each) { described_class.reset! } + + describe '.available?' do + it 'returns false when prometheus-client is absent' do + hide_const('Prometheus::Client') if defined?(Prometheus::Client) + expect(described_class.available?).to be false + end + + it 'returns true when prometheus-client is loaded' do + stub_const('Prometheus::Client', Module.new) + expect(described_class.available?).to be true + end + end + + describe '.setup' do + it 'is a no-op when prometheus-client is absent' do + hide_const('Prometheus::Client') if defined?(Prometheus::Client) + expect { described_class.setup }.not_to raise_error + end + end + + context 'with prometheus-client stubbed' do + let(:fake_counter) { instance_double('Counter', increment: nil) } + let(:fake_gauge) { instance_double('Gauge', set: nil) } + let(:fake_registry) do + reg = instance_double('Registry') + allow(reg).to receive(:counter).and_return(fake_counter) + allow(reg).to receive(:gauge).and_return(fake_gauge) + reg + end + + before do + stub_const('Prometheus::Client', Module.new) + stub_const('Prometheus::Client::Registry', Class.new) + allow(Prometheus::Client::Registry).to receive(:new).and_return(fake_registry) + described_class.setup + end + + it 'creates a registry' do + expect(described_class.registry).to eq(fake_registry) + end + + it 'increments tasks_total on ingress.received' do + expect(fake_counter).to receive(:increment).with(labels: { status: 'queued' }) + Legion::Events.emit('ingress.received') + end + + it 'increments tasks_total on runner.success' do + expect(fake_counter).to receive(:increment).with(labels: { status: 'success' }) + Legion::Events.emit('runner.success') + end + + it 'increments consent_violations on governance event' do + expect(fake_counter).to receive(:increment).with(no_args) + Legion::Events.emit('governance.consent_violation') + end + end +end diff --git a/spec/legion/mode_spec.rb b/spec/legion/mode_spec.rb new file mode 100644 index 00000000..202e4415 --- /dev/null +++ b/spec/legion/mode_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/mode' + +RSpec.describe Legion::Mode do + before do + ENV.delete('LEGION_MODE') + end + + after do + ENV.delete('LEGION_MODE') + end + + describe '.current' do + context 'when no ENV, settings, or legacy role is set' do + before do + allow(described_class).to receive(:settings_dig).and_return(nil) + end + + it 'returns :agent by default' do + expect(described_class.current).to eq(:agent) + end + end + + context 'when ENV[LEGION_MODE] is set' do + it 'returns :agent when set to "agent"' do + ENV['LEGION_MODE'] = 'agent' + expect(described_class.current).to eq(:agent) + end + + it 'returns :worker when set to "worker"' do + ENV['LEGION_MODE'] = 'worker' + expect(described_class.current).to eq(:worker) + end + + it 'returns :lite when set to "lite"' do + ENV['LEGION_MODE'] = 'lite' + expect(described_class.current).to eq(:lite) + end + + it 'returns :infra when set to "infra"' do + ENV['LEGION_MODE'] = 'infra' + expect(described_class.current).to eq(:infra) + end + + it 'takes precedence over settings' do + ENV['LEGION_MODE'] = 'lite' + allow(Legion::Settings).to receive(:[]).with(:mode).and_return('worker') + expect(described_class.current).to eq(:lite) + end + + it 'normalizes uppercase input' do + ENV['LEGION_MODE'] = 'WORKER' + expect(described_class.current).to eq(:worker) + end + end + + context 'when Settings[:mode] is set' do + before { ENV.delete('LEGION_MODE') } + + it 'returns the mode from Settings[:mode]' do + allow(Legion::Settings).to receive(:[]).with(:mode).and_return('worker') + allow(Legion::Settings).to receive(:[]).with('mode').and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:process).and_return(nil) + allow(Legion::Settings).to receive(:[]).with('process').and_return(nil) + expect(described_class.current).to eq(:worker) + end + end + + context 'when Settings[:process][:mode] is set' do + before { ENV.delete('LEGION_MODE') } + + it 'returns the mode from Settings[:process][:mode]' do + allow(Legion::Settings).to receive(:[]).with(:mode).and_return(nil) + allow(Legion::Settings).to receive(:[]).with('mode').and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:process).and_return({ mode: 'infra' }) + allow(Legion::Settings).to receive(:[]).with('process').and_return(nil) + expect(described_class.current).to eq(:infra) + end + end + + context 'when Settings[:process][:role] (legacy) is set' do + before { ENV.delete('LEGION_MODE') } + + it 'maps :full to :agent via LEGACY_MAP' do + allow(Legion::Settings).to receive(:[]).with(:mode).and_return(nil) + allow(Legion::Settings).to receive(:[]).with('mode').and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:process).and_return({ role: 'full' }) + allow(Legion::Settings).to receive(:[]).with('process').and_return(nil) + expect(described_class.current).to eq(:agent) + end + + it 'maps :api to :worker via LEGACY_MAP' do + allow(Legion::Settings).to receive(:[]).with(:mode).and_return(nil) + allow(Legion::Settings).to receive(:[]).with('mode').and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:process).and_return({ role: 'api' }) + allow(Legion::Settings).to receive(:[]).with('process').and_return(nil) + expect(described_class.current).to eq(:worker) + end + + it 'maps :router to :worker via LEGACY_MAP' do + allow(Legion::Settings).to receive(:[]).with(:mode).and_return(nil) + allow(Legion::Settings).to receive(:[]).with('mode').and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:process).and_return({ role: 'router' }) + allow(Legion::Settings).to receive(:[]).with('process').and_return(nil) + expect(described_class.current).to eq(:worker) + end + + it 'maps :worker to :worker via LEGACY_MAP' do + allow(Legion::Settings).to receive(:[]).with(:mode).and_return(nil) + allow(Legion::Settings).to receive(:[]).with('mode').and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:process).and_return({ role: 'worker' }) + allow(Legion::Settings).to receive(:[]).with('process').and_return(nil) + expect(described_class.current).to eq(:worker) + end + + it 'maps :lite to :lite via LEGACY_MAP' do + allow(Legion::Settings).to receive(:[]).with(:mode).and_return(nil) + allow(Legion::Settings).to receive(:[]).with('mode').and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:process).and_return({ role: 'lite' }) + allow(Legion::Settings).to receive(:[]).with('process').and_return(nil) + expect(described_class.current).to eq(:lite) + end + end + + context 'with unknown mode value' do + it 'falls back to :agent for unrecognized mode' do + ENV['LEGION_MODE'] = 'bogus_mode' + expect(described_class.current).to eq(:agent) + end + end + end + + describe 'convenience predicates' do + describe '.agent?' do + it 'returns true when current mode is :agent' do + allow(described_class).to receive(:current).and_return(:agent) + expect(described_class.agent?).to be true + end + + it 'returns false when current mode is not :agent' do + allow(described_class).to receive(:current).and_return(:worker) + expect(described_class.agent?).to be false + end + end + + describe '.worker?' do + it 'returns true when current mode is :worker' do + allow(described_class).to receive(:current).and_return(:worker) + expect(described_class.worker?).to be true + end + + it 'returns false when current mode is not :worker' do + allow(described_class).to receive(:current).and_return(:agent) + expect(described_class.worker?).to be false + end + end + + describe '.infra?' do + it 'returns true when current mode is :infra' do + allow(described_class).to receive(:current).and_return(:infra) + expect(described_class.infra?).to be true + end + + it 'returns false when current mode is not :infra' do + allow(described_class).to receive(:current).and_return(:agent) + expect(described_class.infra?).to be false + end + end + + describe '.lite?' do + it 'returns true when current mode is :lite' do + allow(described_class).to receive(:current).and_return(:lite) + expect(described_class.lite?).to be true + end + + it 'returns false when current mode is not :lite' do + allow(described_class).to receive(:current).and_return(:agent) + expect(described_class.lite?).to be false + end + end + end + + describe 'LEGACY_MAP' do + it 'maps :full to :agent' do + expect(described_class::LEGACY_MAP[:full]).to eq(:agent) + end + + it 'maps :api to :worker' do + expect(described_class::LEGACY_MAP[:api]).to eq(:worker) + end + + it 'maps :router to :worker' do + expect(described_class::LEGACY_MAP[:router]).to eq(:worker) + end + + it 'maps :worker to :worker' do + expect(described_class::LEGACY_MAP[:worker]).to eq(:worker) + end + + it 'maps :lite to :lite' do + expect(described_class::LEGACY_MAP[:lite]).to eq(:lite) + end + end +end diff --git a/spec/legion/notebook/generator_spec.rb b/spec/legion/notebook/generator_spec.rb new file mode 100644 index 00000000..dda9d0b0 --- /dev/null +++ b/spec/legion/notebook/generator_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'legion/notebook/generator' + +RSpec.describe Legion::Notebook::Generator do + let(:llm_mod) { Module.new } + + let(:valid_notebook) do + { + 'nbformat' => 4, + 'nbformat_minor' => 5, + 'metadata' => { 'kernelspec' => { 'name' => 'python3' } }, + 'cells' => [ + { 'cell_type' => 'markdown', 'source' => ['# Generated'], 'metadata' => {}, 'outputs' => [] } + ] + } + end + + before do + stub_const('Legion::LLM', llm_mod) + allow(Legion::LLM).to receive(:chat).and_return({ + content: JSON.generate(valid_notebook), + usage: {} + }) + end + + describe '.generate' do + it 'returns a notebook hash' do + result = described_class.generate(description: 'A test notebook') + expect(result).to be_a(Hash) + expect(result['nbformat']).to eq(4) + end + + it 'calls LLM with the description' do + expect(Legion::LLM).to receive(:chat).with( + hash_including(messages: [hash_including(role: 'user')]) + ).and_return({ content: JSON.generate(valid_notebook), usage: {} }) + described_class.generate(description: 'plot some data') + end + + it 'passes model option to LLM when provided' do + expect(Legion::LLM).to receive(:chat) + .with(hash_including(model: 'claude-opus-4-5')) + .and_return({ content: JSON.generate(valid_notebook), usage: {} }) + described_class.generate(description: 'test', model: 'claude-opus-4-5') + end + + it 'passes provider option as symbol to LLM when provided' do + expect(Legion::LLM).to receive(:chat) + .with(hash_including(provider: :anthropic)) + .and_return({ content: JSON.generate(valid_notebook), usage: {} }) + described_class.generate(description: 'test', provider: 'anthropic') + end + + it 'strips markdown fences from LLM response' do + fenced = "```json\n#{JSON.generate(valid_notebook)}\n```" + allow(Legion::LLM).to receive(:chat).and_return({ content: fenced, usage: {} }) + result = described_class.generate(description: 'test') + expect(result['nbformat']).to eq(4) + end + + it 'strips bare code fences from LLM response' do + fenced = "```\n#{JSON.generate(valid_notebook)}\n```" + allow(Legion::LLM).to receive(:chat).and_return({ content: fenced, usage: {} }) + result = described_class.generate(description: 'test') + expect(result['nbformat']).to eq(4) + end + + it 'raises ArgumentError when legion-llm is not available' do + hide_const('Legion::LLM') + expect do + described_class.generate(description: 'test') + end.to raise_error(ArgumentError, /legion-llm is required/) + end + + it 'raises ArgumentError when LLM returns invalid JSON' do + allow(Legion::LLM).to receive(:chat).and_return({ content: 'not valid json', usage: {} }) + expect do + described_class.generate(description: 'test') + end.to raise_error(ArgumentError, /invalid JSON/) + end + + it 'raises ArgumentError when notebook is missing nbformat' do + bad = valid_notebook.except('nbformat') + allow(Legion::LLM).to receive(:chat).and_return({ content: JSON.generate(bad), usage: {} }) + expect do + described_class.generate(description: 'test') + end.to raise_error(ArgumentError, /nbformat/) + end + + it 'raises ArgumentError when cells is not an array' do + bad = valid_notebook.merge('cells' => 'not_an_array') + allow(Legion::LLM).to receive(:chat).and_return({ content: JSON.generate(bad), usage: {} }) + expect do + described_class.generate(description: 'test') + end.to raise_error(ArgumentError, /array/) + end + end + + describe '.write' do + it 'writes JSON to file' do + require 'tempfile' + f = Tempfile.new(['nb', '.ipynb']) + f.close + described_class.write(f.path, valid_notebook) + data = JSON.parse(File.read(f.path)) + expect(data['nbformat']).to eq(4) + ensure + f&.unlink + end + + it 'writes pretty-formatted JSON' do + require 'tempfile' + f = Tempfile.new(['nb', '.ipynb']) + f.close + described_class.write(f.path, valid_notebook) + content = File.read(f.path) + expect(content).to include("\n") + ensure + f&.unlink + end + end + + describe '.build_prompt' do + it 'includes the description' do + result = described_class.build_prompt('plot data', 'python3') + expect(result).to include('plot data') + end + + it 'includes the kernel' do + result = described_class.build_prompt('test', 'julia') + expect(result).to include('julia') + end + + it 'mentions .ipynb format' do + result = described_class.build_prompt('test', 'python3') + expect(result).to include('.ipynb') + end + end + + describe '.validate_notebook!' do + it 'raises when nbformat is missing' do + expect { described_class.validate_notebook!({ 'cells' => [] }) } + .to raise_error(ArgumentError, /nbformat/) + end + + it 'raises when cells is missing' do + expect { described_class.validate_notebook!({ 'nbformat' => 4 }) } + .to raise_error(ArgumentError, /cells/) + end + + it 'raises when cells is not an array' do + expect { described_class.validate_notebook!({ 'nbformat' => 4, 'cells' => {} }) } + .to raise_error(ArgumentError, /array/) + end + + it 'does not raise for valid data' do + expect { described_class.validate_notebook!({ 'nbformat' => 4, 'cells' => [] }) }.not_to raise_error + end + end + + describe 'NOTEBOOK_TEMPLATE' do + it 'has the standard .ipynb keys' do + template = described_class::NOTEBOOK_TEMPLATE + expect(template).to have_key('nbformat') + expect(template).to have_key('cells') + expect(template).to have_key('metadata') + end + + it 'defaults to python3 kernel' do + template = described_class::NOTEBOOK_TEMPLATE + expect(template.dig('metadata', 'kernelspec', 'name')).to eq('python3') + end + end +end diff --git a/spec/legion/notebook/parser_spec.rb b/spec/legion/notebook/parser_spec.rb new file mode 100644 index 00000000..f3a7fea6 --- /dev/null +++ b/spec/legion/notebook/parser_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'json' +require 'tempfile' +require 'legion/notebook/parser' + +RSpec.describe Legion::Notebook::Parser do + let(:notebook_data) do + { + 'nbformat' => 4, + 'nbformat_minor' => 5, + 'metadata' => { + 'kernelspec' => { 'display_name' => 'Python 3', 'language' => 'python', 'name' => 'python3' }, + 'language_info' => { 'name' => 'python' } + }, + 'cells' => [ + { + 'cell_type' => 'markdown', + 'metadata' => {}, + 'source' => ['# My Notebook\n', 'Some description'] + }, + { + 'cell_type' => 'code', + 'metadata' => {}, + 'source' => ['x = 1\n', 'print(x)'], + 'outputs' => [ + { 'output_type' => 'stream', 'name' => 'stdout', 'text' => ['1\n'] } + ], + 'execution_count' => 1 + }, + { + 'cell_type' => 'code', + 'metadata' => {}, + 'source' => ['import sys'], + 'outputs' => [], + 'execution_count' => nil + } + ] + } + end + + let(:tmpfile) do + f = Tempfile.new(['notebook', '.ipynb']) + f.write(JSON.generate(notebook_data)) + f.close + f + end + + after { tmpfile.unlink } + + describe '.parse' do + subject(:result) { described_class.parse(tmpfile.path) } + + it 'returns a hash with metadata, kernel, language, and cells' do + expect(result).to have_key(:metadata) + expect(result).to have_key(:kernel) + expect(result).to have_key(:language) + expect(result).to have_key(:cells) + end + + it 'extracts the kernel display name' do + expect(result[:kernel]).to eq('Python 3') + end + + it 'extracts the language' do + expect(result[:language]).to eq('python') + end + + it 'parses all cells' do + expect(result[:cells].length).to eq(3) + end + + it 'preserves metadata' do + expect(result[:metadata]).to be_a(Hash) + end + + it 'defaults language to python when missing' do + data = notebook_data.dup + data['metadata'] = { 'kernelspec' => {} } + f = Tempfile.new(['no_lang', '.ipynb']) + f.write(JSON.generate(data)) + f.close + result = described_class.parse(f.path) + expect(result[:language]).to eq('python') + ensure + f&.unlink + end + end + + describe '.parse_cell' do + it 'parses a markdown cell' do + raw = { 'cell_type' => 'markdown', 'source' => ['# Title'] } + result = described_class.parse_cell(raw) + expect(result[:type]).to eq('markdown') + expect(result[:source]).to eq('# Title') + expect(result[:outputs]).to eq([]) + end + + it 'joins source array into a single string' do + raw = { 'cell_type' => 'code', 'source' => ['line1\n', 'line2'], 'outputs' => [] } + result = described_class.parse_cell(raw) + expect(result[:source]).to eq('line1\nline2') + end + + it 'parses outputs for code cells' do + raw = { + 'cell_type' => 'code', + 'source' => ['print(1)'], + 'outputs' => [{ 'output_type' => 'stream', 'text' => ['1\n'] }] + } + result = described_class.parse_cell(raw) + expect(result[:outputs].length).to eq(1) + expect(result[:outputs][0][:output_type]).to eq('stream') + end + + it 'handles missing outputs gracefully' do + raw = { 'cell_type' => 'markdown', 'source' => ['text'] } + result = described_class.parse_cell(raw) + expect(result[:outputs]).to eq([]) + end + end + + describe '.parse_output' do + it 'parses stream output' do + output = { 'output_type' => 'stream', 'text' => ['hello\n', 'world'] } + result = described_class.parse_output(output) + expect(result[:output_type]).to eq('stream') + expect(result[:text]).to eq('hello\nworld') + end + + it 'parses execute_result output' do + output = { + 'output_type' => 'execute_result', + 'data' => { 'text/plain' => ['42'] }, + 'execution_count' => 1, + 'metadata' => {} + } + result = described_class.parse_output(output) + expect(result[:output_type]).to eq('execute_result') + expect(result[:text]).to eq('42') + end + + it 'parses display_data output' do + output = { + 'output_type' => 'display_data', + 'data' => { 'text/plain' => ['<Figure>'] }, + 'metadata' => {} + } + result = described_class.parse_output(output) + expect(result[:output_type]).to eq('display_data') + expect(result[:text]).to eq('<Figure>') + end + + it 'parses error output' do + output = { + 'output_type' => 'error', + 'ename' => 'NameError', + 'evalue' => 'name is not defined', + 'traceback' => [] + } + result = described_class.parse_output(output) + expect(result[:output_type]).to eq('error') + expect(result[:text]).to include('NameError') + expect(result[:text]).to include('name is not defined') + end + + it 'handles unknown output type gracefully' do + output = { 'output_type' => 'unknown', 'text' => ['some text'] } + result = described_class.parse_output(output) + expect(result[:output_type]).to eq('unknown') + expect(result[:text]).to eq('some text') + end + + it 'handles missing text gracefully' do + output = { 'output_type' => 'stream' } + result = described_class.parse_output(output) + expect(result[:text]).to eq('') + end + end +end diff --git a/spec/legion/notebook/renderer_spec.rb b/spec/legion/notebook/renderer_spec.rb new file mode 100644 index 00000000..2b7076d8 --- /dev/null +++ b/spec/legion/notebook/renderer_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/notebook/renderer' + +RSpec.describe Legion::Notebook::Renderer do + let(:parsed_notebook) do + { + kernel: 'Python 3', + language: 'python', + cells: [ + { type: 'markdown', source: '# Hello', outputs: [] }, + { type: 'code', source: 'x = 1', outputs: [{ output_type: 'stream', text: '1' }] }, + { type: 'code', source: '', outputs: [] } + ] + } + end + + describe '.render_notebook' do + it 'returns a string' do + result = described_class.render_notebook(parsed_notebook, color: false) + expect(result).to be_a(String) + end + + it 'includes kernel name' do + result = described_class.render_notebook(parsed_notebook, color: false) + expect(result).to include('Python 3') + end + + it 'includes cell headers' do + result = described_class.render_notebook(parsed_notebook, color: false) + expect(result).to include('Cell 1') + expect(result).to include('Cell 2') + end + + it 'includes cell source content' do + result = described_class.render_notebook(parsed_notebook, color: false) + expect(result).to include('# Hello') + expect(result).to include('x = 1') + end + + it 'includes output text' do + result = described_class.render_notebook(parsed_notebook, color: false) + expect(result).to include('=> 1') + end + + it 'omits kernel line when kernel is nil' do + nb = parsed_notebook.merge(kernel: nil) + result = described_class.render_notebook(nb, color: false) + expect(result).not_to include('Kernel:') + end + + it 'does not crash with ANSI codes when color is true' do + result = described_class.render_notebook(parsed_notebook, color: true) + expect(result).to be_a(String) + expect(result.length).to be > 0 + end + end + + describe '.render_cell_header' do + it 'returns plain label without color' do + result = described_class.render_cell_header(1, 'code', false) + expect(result).to eq('[code] Cell 1') + end + + it 'includes ANSI escape codes with color' do + result = described_class.render_cell_header(1, 'code', true) + expect(result).to include("\e[") + end + end + + describe '.render_cell_source' do + it 'returns empty string for empty source' do + cell = { type: 'code', source: '', outputs: [] } + result = described_class.render_cell_source(cell, 'python', false) + expect(result).to eq('') + end + + it 'returns source for markdown cell without fences' do + cell = { type: 'markdown', source: '# Title', outputs: [] } + result = described_class.render_cell_source(cell, 'python', false) + expect(result).to include('# Title') + end + + it 'returns highlighted code for code cells' do + cell = { type: 'code', source: 'print(1)', outputs: [] } + result = described_class.render_cell_source(cell, 'python', false) + expect(result).to include('print(1)') + end + end + + describe '.render_cell_outputs' do + it 'returns empty array for no outputs' do + result = described_class.render_cell_outputs([], false) + expect(result).to eq([]) + end + + it 'renders non-empty output text' do + outputs = [{ output_type: 'stream', text: 'hello world' }] + result = described_class.render_cell_outputs(outputs, false) + expect(result).not_to be_empty + expect(result.first).to include('hello world') + end + + it 'skips outputs with blank text' do + outputs = [{ output_type: 'stream', text: ' ' }] + result = described_class.render_cell_outputs(outputs, false) + expect(result).to eq([]) + end + end + + describe '.highlight' do + it 'returns the code string when color is false' do + result = described_class.highlight('x = 1', 'python', false) + expect(result).to eq('x = 1') + end + + it 'returns highlighted string when color is true' do + result = described_class.highlight('x = 1', 'python', true) + expect(result).to be_a(String) + expect(result.length).to be > 0 + end + + it 'handles unknown language without raising' do + result = described_class.highlight('some code', 'unknownlang123', true) + expect(result).to be_a(String) + end + end +end diff --git a/spec/legion/phi/access_log_spec.rb b/spec/legion/phi/access_log_spec.rb new file mode 100644 index 00000000..471b8b2c --- /dev/null +++ b/spec/legion/phi/access_log_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/phi/access_log' + +RSpec.describe Legion::Phi::AccessLog do + let(:valid_params) do + { + actor: 'worker-007', + resource: 'patient/p-12345', + action: 'read', + phi_fields: %i[ssn dob], + reason: 'treatment' + } + end + + # --------------------------------------------------------------------------- + # .log_access + # --------------------------------------------------------------------------- + describe '.log_access' do + context 'when neither Legion::Audit nor Legion::Logging is defined' do + before do + hide_const('Legion::Audit') + hide_const('Legion::Logging') + end + + it 'returns true without raising' do + expect { described_class.log_access(**valid_params) }.not_to raise_error + expect(described_class.log_access(**valid_params)).to be true + end + end + + context 'when Legion::Audit is defined' do + before do + stub_const('Legion::Audit', Module.new) + allow(Legion::Audit).to receive(:record) + end + + it 'calls Legion::Audit.record with phi event_type' do + described_class.log_access(**valid_params) + expect(Legion::Audit).to have_received(:record).with( + hash_including(event_type: 'phi_access', principal_id: 'worker-007') + ) + end + + it 'includes resource in the audit call' do + described_class.log_access(**valid_params) + expect(Legion::Audit).to have_received(:record).with( + hash_including(resource: 'patient/p-12345') + ) + end + + it 'returns true' do + expect(described_class.log_access(**valid_params)).to be true + end + end + + context 'when Legion::Logging is defined but Legion::Audit is not' do + before do + hide_const('Legion::Audit') + stub_const('Legion::Logging', Module.new) + allow(Legion::Logging).to receive(:info) + end + + it 'logs via Legion::Logging' do + described_class.log_access(**valid_params) + expect(Legion::Logging).to have_received(:info).with(match(/PHI ACCESS/)) + end + end + + context 'when Legion::Audit.record raises' do + before do + stub_const('Legion::Audit', Module.new) + allow(Legion::Audit).to receive(:record).and_raise(StandardError, 'transport down') + # Define Legion::Logging with a real warn singleton method so emit_warning works + logging_mod = Module.new + logging_mod.define_singleton_method(:warn) { nil } + stub_const('Legion::Logging', logging_mod) + allow(Legion::Logging).to receive(:warn) + end + + it 'does not raise' do + expect { described_class.log_access(**valid_params) }.not_to raise_error + end + + it 'emits a warning via Legion::Logging' do + described_class.log_access(**valid_params) + expect(Legion::Logging).to have_received(:warn).with(match(/PHI audit record failed/)) + end + end + end + + # --------------------------------------------------------------------------- + # .log_access! + # --------------------------------------------------------------------------- + describe '.log_access!' do + context 'when Legion::Audit is defined and works' do + before do + stub_const('Legion::Audit', Module.new) + allow(Legion::Audit).to receive(:record) + end + + it 'returns true' do + expect(described_class.log_access!(**valid_params)).to be true + end + + it 'calls Legion::Audit.record' do + described_class.log_access!(**valid_params) + expect(Legion::Audit).to have_received(:record) + end + end + end + + # --------------------------------------------------------------------------- + # .recent_access + # --------------------------------------------------------------------------- + describe '.recent_access' do + context 'when neither Legion::Audit nor Legion::Data is defined' do + before do + hide_const('Legion::Audit') + hide_const('Legion::Data') + end + + it 'returns an empty array' do + expect(described_class.recent_access(resource: 'patient/p-1')).to eq([]) + end + end + + context 'when Legion::Audit and Legion::Data::Model::AuditLog are defined' do + let(:fake_record) { { event_type: 'phi_access', resource: 'patient/p-1', principal_id: 'w-1' } } + + before do + stub_const('Legion::Audit', Module.new) + stub_const('Legion::Data', Module.new) + stub_const('Legion::Data::Model', Module.new) + stub_const('Legion::Data::Model::AuditLog', Class.new) + allow(Legion::Audit).to receive(:recent).and_return([fake_record]) + end + + it 'delegates to Legion::Audit.recent with event_type filter' do + result = described_class.recent_access(resource: 'patient/p-1', limit: 10) + expect(Legion::Audit).to have_received(:recent).with( + hash_including(limit: 10, resource: 'patient/p-1', event_type: 'phi_access') + ) + expect(result).to eq([fake_record]) + end + end + end +end diff --git a/spec/legion/phi/erasure_spec.rb b/spec/legion/phi/erasure_spec.rb new file mode 100644 index 00000000..72262749 --- /dev/null +++ b/spec/legion/phi/erasure_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/phi/erasure' + +RSpec.describe Legion::Phi::Erasure do + before { described_class.reset_erasure_log! } + + # --------------------------------------------------------------------------- + # .erase_record + # --------------------------------------------------------------------------- + describe '.erase_record' do + let(:record) { { ssn: '123-45-6789', name: 'Alice', age: 30 } } + + it 'replaces PHI fields with erasure markers' do + result = described_class.erase_record(record: record, phi_fields: %i[ssn name]) + expect(result[:ssn]).to include('[ERASED]') + expect(result[:name]).to include('[ERASED]') + end + + it 'preserves non-PHI fields' do + result = described_class.erase_record(record: record, phi_fields: %i[ssn]) + expect(result[:age]).to eq(30) + end + + it 'does not modify the original record' do + original_ssn = record[:ssn] + described_class.erase_record(record: record, phi_fields: %i[ssn]) + expect(record[:ssn]).to eq(original_ssn) + end + + it 'returns the record unchanged when phi_fields is empty' do + result = described_class.erase_record(record: record, phi_fields: []) + expect(result[:ssn]).to eq('123-45-6789') + end + + it 'returns the record unchanged when phi_fields is nil' do + result = described_class.erase_record(record: record, phi_fields: nil) + expect(result[:ssn]).to eq('123-45-6789') + end + + it 'returns the record unchanged when record is not a Hash' do + expect(described_class.erase_record(record: 'not-a-hash', phi_fields: %i[ssn])).to eq('not-a-hash') + end + + it 'includes key_id metadata in erasure marker' do + result = described_class.erase_record(record: record, phi_fields: %i[ssn], key_id: 'key-abc') + expect(result[:ssn]).to include('key_id=key-abc') + end + + it 'skips fields not present in the record' do + result = described_class.erase_record(record: record, phi_fields: %i[ssn nonexistent_field]) + expect(result).not_to have_key(:nonexistent_field) + expect(result[:ssn]).to include('[ERASED]') + end + + it 'handles nil field values gracefully' do + record_with_nil = { ssn: nil, age: 30 } + result = described_class.erase_record(record: record_with_nil, phi_fields: %i[ssn]) + expect(result[:ssn]).to eq('[ERASED]') + end + end + + # --------------------------------------------------------------------------- + # .erase_for_subject + # --------------------------------------------------------------------------- + describe '.erase_for_subject' do + it 'returns an erasure audit entry' do + result = described_class.erase_for_subject(subject_id: 'patient-99') + expect(result[:subject_id]).to eq('patient-99') + expect(result[:status]).to eq('completed') + expect(result[:method]).to eq('cryptographic_erasure') + end + + it 'includes a key_id in the audit entry' do + result = described_class.erase_for_subject(subject_id: 'patient-99') + expect(result[:key_id]).not_to be_nil + expect(result[:key_id]).not_to be_empty + end + + it 'includes an erased_at timestamp' do + result = described_class.erase_for_subject(subject_id: 'patient-99') + expect(result[:erased_at]).to match(/^\d{4}-\d{2}-\d{2}T/) + end + + it 'appends to the erasure log' do + described_class.erase_for_subject(subject_id: 'patient-100') + expect(described_class.erasure_log.size).to eq(1) + expect(described_class.erasure_log.first[:subject_id]).to eq('patient-100') + end + + context 'when Legion::Audit is defined' do + before do + stub_const('Legion::Audit', Module.new) + allow(Legion::Audit).to receive(:record) + end + + it 'calls Legion::Audit.record with phi_erasure event_type' do + described_class.erase_for_subject(subject_id: 'patient-101') + expect(Legion::Audit).to have_received(:record).with( + hash_including(event_type: 'phi_erasure', principal_id: 'patient-101') + ) + end + end + end + + # --------------------------------------------------------------------------- + # .erasure_log + # --------------------------------------------------------------------------- + describe '.erasure_log' do + it 'returns an empty array initially' do + expect(described_class.erasure_log).to eq([]) + end + + it 'returns a frozen copy (not the live array)' do + described_class.erase_for_subject(subject_id: 's-1') + log = described_class.erasure_log + expect(log).to be_frozen + end + + it 'accumulates entries from multiple erases' do + described_class.erase_for_subject(subject_id: 's-1') + described_class.erase_for_subject(subject_id: 's-2') + expect(described_class.erasure_log.size).to eq(2) + end + end + + # --------------------------------------------------------------------------- + # .reset_erasure_log! + # --------------------------------------------------------------------------- + describe '.reset_erasure_log!' do + it 'clears the log' do + described_class.erase_for_subject(subject_id: 's-x') + described_class.reset_erasure_log! + expect(described_class.erasure_log).to eq([]) + end + end +end diff --git a/spec/legion/phi_spec.rb b/spec/legion/phi_spec.rb new file mode 100644 index 00000000..e26cf1bf --- /dev/null +++ b/spec/legion/phi_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/phi' + +RSpec.describe Legion::Phi do + # --------------------------------------------------------------------------- + # PHI_TAG constant + # --------------------------------------------------------------------------- + describe 'PHI_TAG' do + it 'is :phi' do + expect(described_class::PHI_TAG).to eq(:phi) + end + end + + # --------------------------------------------------------------------------- + # .tag + # --------------------------------------------------------------------------- + describe '.tag' do + let(:data) { { ssn: '123-45-6789', name: 'Alice' } } + + it 'adds __phi_fields to the hash' do + result = described_class.tag(data, fields: [:ssn]) + expect(result[:__phi_fields]).to eq([:ssn]) + end + + it 'returns a new hash without modifying the original' do + result = described_class.tag(data, fields: [:ssn]) + expect(data).not_to have_key(:__phi_fields) + expect(result).to have_key(:__phi_fields) + end + + it 'accepts string field names and converts to symbols' do + result = described_class.tag(data, fields: ['ssn']) + expect(result[:__phi_fields]).to eq([:ssn]) + end + + it 'merges with existing __phi_fields' do + already = described_class.tag(data, fields: [:ssn]) + double_tagged = described_class.tag(already, fields: [:name]) + expect(double_tagged[:__phi_fields]).to contain_exactly(:ssn, :name) + end + + it 'deduplicates phi fields' do + result = described_class.tag(data, fields: %i[ssn ssn name]) + expect(result[:__phi_fields]).to eq(%i[ssn name]) + end + + it 'raises ArgumentError when data is not a Hash' do + expect { described_class.tag('not a hash', fields: [:ssn]) }.to raise_error(ArgumentError, /Hash/) + end + + it 'raises ArgumentError when fields is not an Array' do + expect { described_class.tag(data, fields: :ssn) }.to raise_error(ArgumentError, /Array/) + end + end + + # --------------------------------------------------------------------------- + # .tagged? + # --------------------------------------------------------------------------- + describe '.tagged?' do + it 'returns true when __phi_fields key is present' do + tagged = described_class.tag({ ssn: '123' }, fields: [:ssn]) + expect(described_class.tagged?(tagged)).to be true + end + + it 'returns false when __phi_fields key is absent' do + expect(described_class.tagged?({ ssn: '123' })).to be false + end + + it 'returns false when data is not a Hash' do + expect(described_class.tagged?('string')).to be false + end + + it 'returns false for an empty hash' do + expect(described_class.tagged?({})).to be false + end + + it 'returns false for nil' do + expect(described_class.tagged?(nil)).to be false + end + end + + # --------------------------------------------------------------------------- + # .phi_fields + # --------------------------------------------------------------------------- + describe '.phi_fields' do + it 'returns the list of tagged field names' do + tagged = described_class.tag({ ssn: '1', mrn: '2' }, fields: %i[ssn mrn]) + expect(described_class.phi_fields(tagged)).to contain_exactly(:ssn, :mrn) + end + + it 'returns an empty array when not tagged' do + expect(described_class.phi_fields({ ssn: '1' })).to eq([]) + end + + it 'returns an empty array for a non-Hash' do + expect(described_class.phi_fields(42)).to eq([]) + end + end + + # --------------------------------------------------------------------------- + # .redact + # --------------------------------------------------------------------------- + describe '.redact' do + let(:tagged_data) { described_class.tag({ ssn: '123-45-6789', name: 'Alice', age: 30 }, fields: %i[ssn name]) } + + it 'replaces tagged PHI fields with [REDACTED]' do + result = described_class.redact(tagged_data) + expect(result[:ssn]).to eq('[REDACTED]') + expect(result[:name]).to eq('[REDACTED]') + end + + it 'preserves non-PHI fields' do + result = described_class.redact(tagged_data) + expect(result[:age]).to eq(30) + end + + it 'returns a copy without modifying the original' do + original_ssn = tagged_data[:ssn] + described_class.redact(tagged_data) + expect(tagged_data[:ssn]).to eq(original_ssn) + end + + it 'returns the data unchanged if not a Hash' do + expect(described_class.redact('not a hash')).to eq('not a hash') + end + + it 'redacts auto-detected PHI fields even without explicit tagging' do + data = { ssn: '123-45-6789', safe_field: 'safe' } + result = described_class.redact(data) + expect(result[:ssn]).to eq('[REDACTED]') + expect(result[:safe_field]).to eq('safe') + end + end + + # --------------------------------------------------------------------------- + # .auto_detect_fields + # --------------------------------------------------------------------------- + describe '.auto_detect_fields' do + it 'detects ssn field' do + data = { ssn: '123-45-6789' } + expect(described_class.auto_detect_fields(data)).to include(:ssn) + end + + it 'detects mrn field' do + data = { mrn: 'M123456' } + expect(described_class.auto_detect_fields(data)).to include(:mrn) + end + + it 'detects dob field' do + data = { dob: '1990-01-01' } + expect(described_class.auto_detect_fields(data)).to include(:dob) + end + + it 'does not flag unrelated fields' do + data = { task_id: 1, status: 'pending', metadata: {} } + expect(described_class.auto_detect_fields(data)).to be_empty + end + + it 'returns an empty array for a non-Hash' do + expect(described_class.auto_detect_fields('string')).to eq([]) + end + + it 'handles string keys' do + data = { 'ssn' => '123' } + expect(described_class.auto_detect_fields(data)).to include('ssn') + end + end + + # --------------------------------------------------------------------------- + # .erase + # --------------------------------------------------------------------------- + describe '.erase' do + let(:tagged_data) { described_class.tag({ ssn: '123-45-6789', name: 'Alice', age: 30 }, fields: %i[ssn name]) } + + it 'replaces PHI fields with erasure markers' do + result = described_class.erase(tagged_data, key_id: 'test-key-001') + expect(result[:ssn]).to include('[ERASED]') + expect(result[:name]).to include('[ERASED]') + end + + it 'preserves non-PHI fields' do + result = described_class.erase(tagged_data, key_id: 'test-key-001') + expect(result[:age]).to eq(30) + end + + it 'returns data unchanged when not a Hash' do + expect(described_class.erase('not a hash', key_id: 'k1')).to eq('not a hash') + end + end +end diff --git a/spec/legion/privacy_audit_spec.rb b/spec/legion/privacy_audit_spec.rb new file mode 100644 index 00000000..3024cc6d --- /dev/null +++ b/spec/legion/privacy_audit_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Enterprise privacy mode audit logging' do + describe 'Legion::Service.log_privacy_mode_status' do + context 'when privacy mode is enabled' do + before do + allow(Legion::Settings).to receive(:enterprise_privacy?).and_return(true) + allow(Legion::Settings).to receive(:[]).with(:logging).and_return(nil) + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:emit_tagged) + end + + it 'logs an info entry when privacy mode is enabled' do + Legion::Service.log_privacy_mode_status + expect(Legion::Logging).to have_received(:emit_tagged).with(:info, /enterprise_data_privacy.*enabled/, any_args) + end + end + + context 'when privacy mode is disabled' do + before do + allow(Legion::Settings).to receive(:enterprise_privacy?).and_return(false) + allow(Legion::Settings).to receive(:[]).with(:logging).and_return(nil) + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:emit_tagged) + end + + it 'logs an info entry indicating privacy is disabled' do + Legion::Service.log_privacy_mode_status + expect(Legion::Logging).to have_received(:emit_tagged).with(:info, /enterprise_data_privacy.*disabled/, any_args) + end + end + + context 'when Legion::Logging is unavailable' do + it 'does not raise' do + allow(Legion).to receive(:const_defined?).with('Settings').and_return(true) + allow(Legion::Settings).to receive(:respond_to?).with(:enterprise_privacy?).and_return(true) + allow(Legion::Settings).to receive(:enterprise_privacy?).and_return(true) + allow(Legion).to receive(:const_defined?).with('Logging').and_return(false) + expect { Legion::Service.log_privacy_mode_status }.not_to raise_error + end + end + end +end diff --git a/spec/legion/process_role_spec.rb b/spec/legion/process_role_spec.rb new file mode 100644 index 00000000..453482f1 --- /dev/null +++ b/spec/legion/process_role_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/process_role' + +RSpec.describe Legion::ProcessRole do + describe '.resolve' do + it 'returns all-true hash for :full' do + result = described_class.resolve(:full) + expect(result[:transport]).to be true + expect(result[:cache]).to be true + expect(result[:data]).to be true + expect(result[:extensions]).to be true + expect(result[:api]).to be true + expect(result[:llm]).to be true + expect(result[:gaia]).to be true + expect(result[:crypt]).to be true + expect(result[:supervision]).to be true + end + + it 'disables extensions, llm, gaia, and supervision for :api' do + result = described_class.resolve(:api) + expect(result[:extensions]).to be false + expect(result[:llm]).to be false + expect(result[:gaia]).to be false + expect(result[:supervision]).to be false + expect(result[:api]).to be true + expect(result[:transport]).to be true + expect(result[:crypt]).to be true + end + + it 'disables api for :worker' do + result = described_class.resolve(:worker) + expect(result[:api]).to be false + expect(result[:extensions]).to be true + expect(result[:transport]).to be true + expect(result[:llm]).to be true + expect(result[:gaia]).to be true + expect(result[:supervision]).to be true + end + + it 'disables data, api, llm, gaia, and supervision for :router' do + result = described_class.resolve(:router) + expect(result[:data]).to be false + expect(result[:api]).to be false + expect(result[:llm]).to be false + expect(result[:gaia]).to be false + expect(result[:supervision]).to be false + expect(result[:transport]).to be true + expect(result[:cache]).to be true + expect(result[:crypt]).to be true + end + + it 'disables crypt for :lite' do + result = described_class.resolve(:lite) + expect(result[:transport]).to be true + expect(result[:cache]).to be true + expect(result[:data]).to be true + expect(result[:extensions]).to be true + expect(result[:api]).to be true + expect(result[:llm]).to be true + expect(result[:gaia]).to be true + expect(result[:crypt]).to be false + expect(result[:supervision]).to be true + end + + it 'accepts string input' do + result = described_class.resolve('worker') + expect(result[:api]).to be false + expect(result[:extensions]).to be true + end + + it 'falls back to :full for unrecognized roles' do + allow(Legion::Logging).to receive(:warn) if defined?(Legion::Logging) + allow($stderr).to receive(:puts) + result = described_class.resolve(:unknown) + expect(result).to eq(described_class.resolve(:full)) + end + end + + describe '.current' do + it 'returns :full when settings are not available' do + allow(Legion::Settings).to receive(:[]).with(:process).and_raise(StandardError) + expect(described_class.current).to eq(:full) + end + + it 'returns :full when Legion::Settings is not defined' do + hide_const('Legion::Settings') + expect(described_class.current).to eq(:full) + end + + it 'returns :full when process settings have no role key' do + allow(Legion::Settings).to receive(:[]).with(:process).and_return({}) + expect(described_class.current).to eq(:full) + end + + it 'returns the configured role as a symbol' do + allow(Legion::Settings).to receive(:[]).with(:process).and_return({ role: 'worker' }) + expect(described_class.current).to eq(:worker) + end + end + + describe '.role?' do + it 'returns true when current role matches' do + allow(described_class).to receive(:current).and_return(:full) + expect(described_class.role?(:full)).to be true + end + + it 'returns false when current role does not match' do + allow(described_class).to receive(:current).and_return(:worker) + expect(described_class.role?(:full)).to be false + end + + it 'accepts string input' do + allow(described_class).to receive(:current).and_return(:full) + expect(described_class.role?('full')).to be true + end + end +end diff --git a/spec/legion/process_sighup_spec.rb b/spec/legion/process_sighup_spec.rb new file mode 100644 index 00000000..e9294945 --- /dev/null +++ b/spec/legion/process_sighup_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Legion::Process SIGHUP trap' do + before do + allow(Legion).to receive(:reload) + end + + it 'calls Legion.reload when SIGHUP is received' do + # Set up the trap by calling the method that installs it + # Legion::Process includes trap_signals in its initialization + # We can test by directly installing the trap and firing the signal + trap('SIGHUP') do + Thread.new { Legion.reload } + end + + Process.kill('HUP', Process.pid) + sleep 0.2 # give the thread time to execute + + expect(Legion).to have_received(:reload) + end +end diff --git a/spec/legion/process_spec.rb b/spec/legion/process_spec.rb new file mode 100644 index 00000000..afd1251c --- /dev/null +++ b/spec/legion/process_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/process' +require 'concurrent/atomic/atomic_boolean' + +RSpec.describe Legion::Process do + let(:options) { {} } + let(:process) { described_class.new(options) } + + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + end + + after do + described_class.quit_flag = nil + end + + describe '#quit' do + it 'returns false when AtomicBoolean is false' do + process.instance_variable_set(:@quit, Concurrent::AtomicBoolean.new(false)) + expect(process.quit).to be false + end + + it 'returns true when AtomicBoolean is true' do + process.instance_variable_set(:@quit, Concurrent::AtomicBoolean.new(true)) + expect(process.quit).to be true + end + + it 'falls back to false when not AtomicBoolean' do + process.instance_variable_set(:@quit, nil) + expect(process.quit).to be false + end + end + + describe '.quit_flag' do + it 'is a class-level accessor' do + flag = Concurrent::AtomicBoolean.new(false) + described_class.quit_flag = flag + expect(described_class.quit_flag).to eq flag + end + + it 'can be signaled from external code' do + flag = Concurrent::AtomicBoolean.new(false) + described_class.quit_flag = flag + described_class.quit_flag.make_true + expect(flag.true?).to be true + end + end + + describe '#trap_signals' do + it 'installs traps for SIGINT, SIGTERM, and SIGHUP' do + process.instance_variable_set(:@quit, Concurrent::AtomicBoolean.new(false)) + expect { process.trap_signals }.not_to raise_error + end + end + + describe '#retrap_after_puma' do + it 'spawns a persistent thread that re-registers signal traps' do + process.instance_variable_set(:@quit, Concurrent::AtomicBoolean.new(false)) + process.retrap_after_puma + thread = process.instance_variable_get(:@retrap_thread) + expect(thread).to be_a(Thread) + expect(thread).to be_alive + thread.kill + end + end + + describe 'AtomicBoolean thread safety' do + it 'handles concurrent make_true from multiple threads' do + flag = Concurrent::AtomicBoolean.new(false) + threads = 5.times.map { Thread.new { flag.make_true } } + threads.each(&:join) + expect(flag.true?).to be true + end + end +end diff --git a/spec/legion/provider_spec.rb b/spec/legion/provider_spec.rb new file mode 100644 index 00000000..05423801 --- /dev/null +++ b/spec/legion/provider_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/provider' + +RSpec.describe Legion::Provider do + before do + Legion::Provider::Registry.reset! + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + end + + after { Legion::Provider::Registry.reset! } + + describe 'DSL' do + it 'declares provides, depends_on, and adapters' do + klass = Class.new(described_class) do + provides :test_component + depends_on :settings + adapters lite: 'legion/crypt/mock_vault', full: 'legion/crypt' + end + + expect(klass.provides).to eq(:test_component) + expect(klass.depends_on).to eq([:settings]) + expect(klass.adapters[:lite]).to eq('legion/crypt/mock_vault') + end + + it 'defaults depends_on to empty array' do + klass = Class.new(described_class) { provides :standalone } + expect(klass.depends_on).to eq([]) + end + end + + describe 'auto-registration' do + it 'registers subclasses in the Registry' do + Class.new(described_class) { provides :auto_registered } + expect(Legion::Provider::Registry.providers).to have_key(:auto_registered) + end + end +end + +RSpec.describe Legion::Provider::Registry do + before do + described_class.reset! + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + end + + after { described_class.reset! } + + describe '.boot_order' do + it 'returns topologically sorted provider keys' do + Class.new(Legion::Provider) { provides :settings } + Class.new(Legion::Provider) do + provides :crypt + depends_on :settings + end + Class.new(Legion::Provider) do + provides :transport + depends_on :settings, :crypt + end + + order = described_class.boot_order + expect(order.index(:settings)).to be < order.index(:crypt) + expect(order.index(:crypt)).to be < order.index(:transport) + end + + it 'raises CyclicDependencyError on cycles' do + Class.new(Legion::Provider) do + provides :alpha + depends_on :beta + end + Class.new(Legion::Provider) do + provides :beta + depends_on :alpha + end + + expect { described_class.boot_order }.to raise_error(Legion::Provider::CyclicDependencyError) + end + + it 'raises MissingDependencyError for unregistered dependencies' do + Class.new(Legion::Provider) do + provides :orphan + depends_on :nonexistent + end + + expect { described_class.boot_order }.to raise_error( + Legion::Provider::MissingDependencyError, /nonexistent/ + ) + end + end + + describe '.boot!' do + it 'calls boot on each provider in order' do + booted = [] + + Class.new(Legion::Provider) do + provides :first + define_method(:boot) { booted << :first } + end + Class.new(Legion::Provider) do + provides :second + depends_on :first + define_method(:boot) { booted << :second } + end + + instances = described_class.boot!(mode: :full, timeout: 5) + expect(booted).to eq(%i[first second]) + expect(instances.length).to eq(2) + end + end + + describe '.shutdown!' do + it 'shuts down instances in reverse boot order' do + shut = [] + + Class.new(Legion::Provider) do + provides :a_prov + define_method(:boot) { nil } + define_method(:shutdown) { shut << :a_prov } + end + Class.new(Legion::Provider) do + provides :b_prov + depends_on :a_prov + define_method(:boot) { nil } + define_method(:shutdown) { shut << :b_prov } + end + + instances = described_class.boot!(mode: :full, timeout: 5) + described_class.shutdown!(instances) + expect(shut).to eq(%i[b_prov a_prov]) + end + + it 'does not raise if a shutdown fails' do + Class.new(Legion::Provider) do + provides :fragile + define_method(:boot) { nil } + define_method(:shutdown) { raise 'boom' } + end + + instances = described_class.boot!(mode: :full, timeout: 5) + expect { described_class.shutdown!(instances) }.not_to raise_error + end + end + + describe '.reset!' do + it 'clears all registered providers' do + Class.new(Legion::Provider) { provides :temp } + described_class.reset! + expect(described_class.providers).to be_empty + end + end +end diff --git a/spec/legion/python_spec.rb b/spec/legion/python_spec.rb new file mode 100644 index 00000000..a798b4db --- /dev/null +++ b/spec/legion/python_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/python' + +RSpec.describe Legion::Python do + describe 'constants' do + it 'defines VENV_DIR as ~/.legionio/python' do + expect(described_class::VENV_DIR).to end_with('.legionio/python') + end + + it 'defines MARKER as ~/.legionio/.python-venv' do + expect(described_class::MARKER).to end_with('.legionio/.python-venv') + end + + it 'defines PACKAGES as a frozen array of pip packages' do + expect(described_class::PACKAGES).to be_frozen + expect(described_class::PACKAGES).to include('python-pptx', 'pandas', 'pillow') + end + + it 'defines SYSTEM_CANDIDATES as known python3 paths' do + expect(described_class::SYSTEM_CANDIDATES).to include('/opt/homebrew/bin/python3') + expect(described_class::SYSTEM_CANDIDATES).to include('/usr/local/bin/python3') + end + end + + describe '.venv_python' do + it 'returns the venv python3 path' do + expect(described_class.venv_python).to eq("#{described_class::VENV_DIR}/bin/python3") + end + end + + describe '.venv_pip' do + it 'returns the venv pip path' do + expect(described_class.venv_pip).to eq("#{described_class::VENV_DIR}/bin/pip") + end + end + + describe '.venv_exists?' do + it 'returns true when pyvenv.cfg exists' do + allow(File).to receive(:exist?).with("#{described_class::VENV_DIR}/pyvenv.cfg").and_return(true) + expect(described_class.venv_exists?).to be true + end + + it 'returns false when pyvenv.cfg is missing' do + allow(File).to receive(:exist?).with("#{described_class::VENV_DIR}/pyvenv.cfg").and_return(false) + expect(described_class.venv_exists?).to be false + end + end + + describe '.venv_python_exists?' do + it 'returns true when venv python3 is executable' do + allow(File).to receive(:executable?).with(described_class.venv_python).and_return(true) + expect(described_class.venv_python_exists?).to be true + end + + it 'returns false when venv python3 is not executable' do + allow(File).to receive(:executable?).with(described_class.venv_python).and_return(false) + expect(described_class.venv_python_exists?).to be false + end + end + + describe '.interpreter' do + context 'when venv python exists' do + it 'returns the venv python path' do + allow(File).to receive(:executable?).with(described_class.venv_python).and_return(true) + expect(described_class.interpreter).to eq(described_class.venv_python) + end + end + + context 'when venv python does not exist' do + before do + allow(File).to receive(:executable?).with(described_class.venv_python).and_return(false) + end + + it 'falls back to system python3' do + allow(described_class).to receive(:find_system_python3).and_return('/usr/bin/python3') + expect(described_class.interpreter).to eq('/usr/bin/python3') + end + + it 'returns bare python3 when no system python found' do + allow(described_class).to receive(:find_system_python3).and_return(nil) + expect(described_class.interpreter).to eq('python3') + end + end + end + + describe '.pip' do + context 'when venv pip exists' do + it 'returns the venv pip path' do + allow(File).to receive(:executable?).with(described_class.venv_pip).and_return(true) + expect(described_class.pip).to eq(described_class.venv_pip) + end + end + + context 'when venv pip does not exist' do + it 'returns bare pip3' do + allow(File).to receive(:executable?).with(described_class.venv_pip).and_return(false) + expect(described_class.pip).to eq('pip3') + end + end + end + + describe '.find_system_python3' do + it 'returns the first executable candidate' do + allow(described_class).to receive(:`).with('command -v python3 2>/dev/null').and_return('') + allow(File).to receive(:executable?).and_return(false) + allow(File).to receive(:executable?).with('/opt/homebrew/bin/python3').and_return(true) + expect(described_class.find_system_python3).to eq('/opt/homebrew/bin/python3') + end + + it 'prefers PATH python over hardcoded candidates' do + allow(described_class).to receive(:`).with('command -v python3 2>/dev/null').and_return("/custom/bin/python3\n") + allow(File).to receive(:executable?).and_return(false) + allow(File).to receive(:executable?).with('/custom/bin/python3').and_return(true) + expect(described_class.find_system_python3).to eq('/custom/bin/python3') + end + + it 'returns nil when no python3 is found' do + allow(described_class).to receive(:`).with('command -v python3 2>/dev/null').and_return('') + allow(File).to receive(:executable?).and_return(false) + expect(described_class.find_system_python3).to be_nil + end + end +end diff --git a/spec/legion/region/failover_spec.rb b/spec/legion/region/failover_spec.rb new file mode 100644 index 00000000..41d2ebdf --- /dev/null +++ b/spec/legion/region/failover_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/region/failover' + +RSpec.describe Legion::Region::Failover do + before do + Legion::Settings.loader.settings[:region] ||= {} + @saved_region = Legion::Settings.loader.settings[:region].dup + Legion::Settings.loader.settings[:region] = { + current: 'us-east-2', + primary: 'us-east-2', + failover: 'us-west-2', + peers: %w[us-east-2 us-west-2], + default_affinity: 'prefer_local', + data_residency: {} + } + end + + after do + Legion::Settings.loader.settings[:region] = @saved_region + end + + describe '.validate_target!' do + it 'accepts a known peer region' do + expect { described_class.validate_target!('us-west-2') }.not_to raise_error + end + + it 'accepts the failover region' do + Legion::Settings.loader.settings[:region][:peers] = [] + expect { described_class.validate_target!('us-west-2') }.not_to raise_error + end + + it 'raises UnknownRegionError for unknown region' do + expect { described_class.validate_target!('eu-west-1') } + .to raise_error(Legion::Region::Failover::UnknownRegionError, /eu-west-1/) + end + end + + describe '.replication_lag' do + context 'when Legion::Data is available' do + let(:fake_db) { instance_double('Sequel::Database') } + + before do + stub_const('Legion::Data', Module.new) + allow(Legion::Data).to receive(:connection).and_return(fake_db) + end + + it 'returns the lag in seconds' do + allow(fake_db).to receive(:fetch).and_return([{ lag: 2.5 }]) + expect(described_class.replication_lag).to eq(2.5) + end + + it 'returns nil when lag is nil' do + allow(fake_db).to receive(:fetch).and_return([{ lag: nil }]) + expect(described_class.replication_lag).to be_nil + end + + it 'returns nil on error' do + allow(fake_db).to receive(:fetch).and_raise(StandardError, 'connection lost') + expect(described_class.replication_lag).to be_nil + end + end + + context 'when Legion::Data is not available' do + before do + hide_const('Legion::Data') if defined?(Legion::Data) + end + + it 'returns nil' do + expect(described_class.replication_lag).to be_nil + end + end + end + + describe '.promote!' do + let(:fake_db) { instance_double('Sequel::Database') } + + before do + stub_const('Legion::Data', Module.new) + allow(Legion::Data).to receive(:connection).and_return(fake_db) + allow(fake_db).to receive(:fetch).and_return([{ lag: 1.0 }]) + allow(Legion::Events).to receive(:emit) if defined?(Legion::Events) + end + + it 'promotes the target region' do + result = described_class.promote!(region: 'us-west-2') + expect(result[:promoted]).to eq('us-west-2') + expect(result[:previous]).to eq('us-east-2') + end + + it 'updates settings primary to the new region' do + described_class.promote!(region: 'us-west-2') + expect(Legion::Settings.dig(:region, :primary)).to eq('us-west-2') + end + + it 'returns the replication lag' do + result = described_class.promote!(region: 'us-west-2') + expect(result[:lag_seconds]).to eq(1.0) + end + + it 'emits region.failover event' do + expect(Legion::Events).to receive(:emit).with('region.failover', from: 'us-east-2', to: 'us-west-2') if defined?(Legion::Events) + described_class.promote!(region: 'us-west-2') + end + + it 'raises LagTooHighError when lag exceeds threshold' do + allow(fake_db).to receive(:fetch).and_return([{ lag: 45.0 }]) + expect { described_class.promote!(region: 'us-west-2') } + .to raise_error(Legion::Region::Failover::LagTooHighError, /45.0s/) + end + + it 'raises UnknownRegionError for unknown region' do + expect { described_class.promote!(region: 'eu-west-1') } + .to raise_error(Legion::Region::Failover::UnknownRegionError) + end + + it 'succeeds when lag is nil (no DB)' do + allow(fake_db).to receive(:fetch).and_return([{ lag: nil }]) + result = described_class.promote!(region: 'us-west-2') + expect(result[:promoted]).to eq('us-west-2') + expect(result[:lag_seconds]).to be_nil + end + end +end diff --git a/spec/legion/region_spec.rb b/spec/legion/region_spec.rb new file mode 100644 index 00000000..a5b70a0d --- /dev/null +++ b/spec/legion/region_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/region' + +RSpec.describe Legion::Region do + before do + described_class.reset! + allow(Legion::Settings).to receive(:dig).and_call_original + end + + after do + described_class.reset! + end + + describe '.current' do + context 'when settings has a current region' do + it 'returns the region from settings' do + allow(Legion::Settings).to receive(:dig).with(:region, :current).and_return('us-east-1') + expect(described_class.current).to eq('us-east-1') + end + end + + context 'when settings returns nil' do + it 'falls back to detect_from_metadata' do + allow(Legion::Settings).to receive(:dig).with(:region, :current).and_return(nil) + allow(described_class).to receive(:detect_from_metadata).and_return('us-west-2') + expect(described_class.current).to eq('us-west-2') + end + + it 'caches a missing metadata result' do + allow(Legion::Settings).to receive(:dig).with(:region, :current).and_return(nil) + allow(described_class).to receive(:detect_from_metadata).and_return(nil) + + 2.times { expect(described_class.current).to be_nil } + expect(described_class).to have_received(:detect_from_metadata).once + end + end + + context 'when settings raises an error' do + it 'returns nil' do + allow(Legion::Settings).to receive(:dig).with(:region, :current).and_raise(StandardError, 'settings unavailable') + expect(described_class.current).to be_nil + end + end + end + + describe '.local?' do + before do + allow(Legion::Settings).to receive(:dig).with(:region, :current).and_return('us-east-1') + allow(described_class).to receive(:detect_from_metadata).and_return(nil) + end + + it 'returns true when target_region is nil' do + expect(described_class.local?(nil)).to be true + end + + it 'returns true when target_region equals current region' do + expect(described_class.local?('us-east-1')).to be true + end + + it 'returns false when target_region differs from current region' do + expect(described_class.local?('eu-west-1')).to be false + end + end + + describe '.affinity_for' do + before do + allow(Legion::Settings).to receive(:dig).with(:region, :current).and_return('us-east-1') + allow(described_class).to receive(:detect_from_metadata).and_return(nil) + end + + it 'returns :local when message is from the same region' do + expect(described_class.affinity_for('us-east-1', 'require_local')).to eq(:local) + end + + it 'returns :local when affinity is "any" regardless of region' do + expect(described_class.affinity_for('eu-west-1', 'any')).to eq(:local) + end + + it 'returns :local when message_region is nil' do + expect(described_class.affinity_for(nil, 'require_local')).to eq(:local) + end + + it 'returns :remote when affinity is "prefer_local" and region differs' do + expect(described_class.affinity_for('eu-west-1', 'prefer_local')).to eq(:remote) + end + + it 'returns :reject when affinity is "require_local" and region differs' do + expect(described_class.affinity_for('eu-west-1', 'require_local')).to eq(:reject) + end + end + + describe '.detect_from_metadata' do + context 'AWS IMDSv2 succeeds' do + it 'returns the AWS region' do + token_response = instance_double(Net::HTTPSuccess, body: 'fake-token', is_a?: true) + region_response = instance_double(Net::HTTPSuccess, body: 'us-east-2', is_a?: true) + + allow(token_response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + allow(region_response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + + call_count = 0 + allow(Net::HTTP).to receive(:start) do |_host, _port, **_opts, &block| + call_count += 1 + http = instance_double(Net::HTTP) + if call_count == 1 + allow(http).to receive(:request).and_return(token_response) + else + allow(http).to receive(:request).and_return(region_response) + end + block.call(http) + end + + expect(described_class.send(:detect_from_metadata)).to eq('us-east-2') + end + end + + context 'AWS IMDS fails, Azure IMDS succeeds' do + it 'returns the Azure region' do + azure_response = instance_double(Net::HTTPSuccess, body: 'eastus', is_a?: true) + allow(azure_response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true) + + call_count = 0 + allow(Net::HTTP).to receive(:start) do |_host, _port, **_opts, &block| + call_count += 1 + raise Errno::EHOSTUNREACH, 'no route' if call_count == 1 + + http = instance_double(Net::HTTP) + allow(http).to receive(:request).and_return(azure_response) + block.call(http) + end + + expect(described_class.send(:detect_from_metadata)).to eq('eastus') + end + end + + context 'both AWS and Azure IMDS fail' do + it 'returns nil' do + allow(Net::HTTP).to receive(:start).and_raise(Errno::EHOSTUNREACH, 'no route') + expect(described_class.send(:detect_from_metadata)).to be_nil + end + end + + context 'when Azure metadata times out' do + it 'suppresses expected timeout logging' do + allow(Net::HTTP).to receive(:start).and_raise(Net::ReadTimeout) + allow(Legion::Logging).to receive(:debug) + + expect(described_class.send(:detect_from_metadata)).to be_nil + expect(Legion::Logging).not_to have_received(:debug).with(/detect_azure_region failed/) + end + end + end + + describe '.primary' do + it 'returns the primary region from settings' do + allow(Legion::Settings).to receive(:dig).with(:region, :primary).and_return('us-east-1') + expect(described_class.primary).to eq('us-east-1') + end + + it 'returns nil when not configured' do + allow(Legion::Settings).to receive(:dig).with(:region, :primary).and_return(nil) + expect(described_class.primary).to be_nil + end + end + + describe '.failover' do + it 'returns the failover region from settings' do + allow(Legion::Settings).to receive(:dig).with(:region, :failover).and_return('us-west-2') + expect(described_class.failover).to eq('us-west-2') + end + + it 'returns nil when not configured' do + allow(Legion::Settings).to receive(:dig).with(:region, :failover).and_return(nil) + expect(described_class.failover).to be_nil + end + end + + describe '.peers' do + it 'returns the peers array from settings' do + allow(Legion::Settings).to receive(:dig).with(:region, :peers).and_return(%w[eu-west-1 ap-southeast-1]) + expect(described_class.peers).to eq(%w[eu-west-1 ap-southeast-1]) + end + + it 'returns an empty array when not configured' do + allow(Legion::Settings).to receive(:dig).with(:region, :peers).and_return(nil) + expect(described_class.peers).to eq([]) + end + end +end diff --git a/spec/legion/registry/governance_spec.rb b/spec/legion/registry/governance_spec.rb new file mode 100644 index 00000000..c1ced1f0 --- /dev/null +++ b/spec/legion/registry/governance_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/registry' + +RSpec.describe Legion::Registry::Governance do + before { described_class.reset! } + + describe '.config' do + it 'returns defaults when Settings is not available' do + expect(described_class.config).to eq(Legion::Registry::Governance::DEFAULTS) + end + + it 'includes require_airb_approval defaulting to false' do + expect(described_class.config[:require_airb_approval]).to be false + end + + it 'includes auto_approve_risk_tiers with low' do + expect(described_class.config[:auto_approve_risk_tiers]).to include('low') + end + + it 'includes review_required_risk_tiers with medium, high, critical' do + expect(described_class.config[:review_required_risk_tiers]).to include('medium', 'high', 'critical') + end + + it 'includes naming_convention' do + expect(described_class.config[:naming_convention]).to eq('lex-[a-z][a-z0-9_]*(?:-[a-z][a-z0-9_]*)*') + end + + it 'includes deprecation_notice_days defaulting to 30' do + expect(described_class.config[:deprecation_notice_days]).to eq(30) + end + end + + describe '.check_name' do + it 'accepts valid lex names' do + expect(described_class.check_name('lex-http')).to be true + end + + it 'accepts names with digits and underscores after the first character' do + expect(described_class.check_name('lex-my_ext2')).to be true + end + + it 'accepts nested lex extension names' do + expect(described_class.check_name('lex-llm-openai')).to be true + expect(described_class.check_name('lex-llm-azure-foundry')).to be true + end + + it 'rejects names not matching convention' do + expect(described_class.check_name('bad-name')).to be false + end + + it 'rejects uppercase names' do + expect(described_class.check_name('lex-HTTP')).to be false + end + + it 'rejects names with no suffix' do + expect(described_class.check_name('lex-')).to be false + end + + it 'rejects empty string' do + expect(described_class.check_name('')).to be false + end + end + + describe '.auto_approve?' do + it 'returns true for low tier' do + expect(described_class.auto_approve?('low')).to be true + end + + it 'returns false for high tier' do + expect(described_class.auto_approve?('high')).to be false + end + + it 'returns false for medium tier' do + expect(described_class.auto_approve?('medium')).to be false + end + + it 'returns false for critical tier' do + expect(described_class.auto_approve?('critical')).to be false + end + end + + describe '.review_required?' do + it 'returns true for medium tier' do + expect(described_class.review_required?('medium')).to be true + end + + it 'returns true for high tier' do + expect(described_class.review_required?('high')).to be true + end + + it 'returns true for critical tier' do + expect(described_class.review_required?('critical')).to be true + end + + it 'returns false for low tier' do + expect(described_class.review_required?('low')).to be false + end + end + + describe '.reset!' do + it 'clears memoized config' do + described_class.config + described_class.reset! + expect(described_class.instance_variable_get(:@config)).to be_nil + end + end + + describe 'Registry.register naming enforcement' do + before { Legion::Registry.clear! } + + it 'raises ArgumentError for a name that violates naming convention' do + entry = Legion::Registry::Entry.new(name: 'invalid_name', risk_tier: 'low') + expect { Legion::Registry.register(entry) }.to raise_error(ArgumentError, /violates naming convention/) + end + + it 'accepts a valid lex name' do + entry = Legion::Registry::Entry.new(name: 'lex-valid', risk_tier: 'low') + expect { Legion::Registry.register(entry) }.not_to raise_error + end + + it 'auto-approves low risk tier entries on register' do + entry = Legion::Registry::Entry.new(name: 'lex-autoapp', risk_tier: 'low') + Legion::Registry.register(entry) + stored = Legion::Registry.lookup('lex-autoapp') + expect(stored.airb_status).to eq('approved') + expect(stored.status).to eq(:approved) + end + + it 'does not auto-approve high risk tier entries on register' do + entry = Legion::Registry::Entry.new(name: 'lex-hightest', risk_tier: 'high') + Legion::Registry.register(entry) + stored = Legion::Registry.lookup('lex-hightest') + expect(stored.airb_status).to eq('pending') + end + end +end diff --git a/spec/legion/registry/marketplace_spec.rb b/spec/legion/registry/marketplace_spec.rb new file mode 100644 index 00000000..b42f0d9f --- /dev/null +++ b/spec/legion/registry/marketplace_spec.rb @@ -0,0 +1,317 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/registry' + +RSpec.describe Legion::Registry do + let(:entry_attrs) do + { + name: 'lex-test', + version: '1.0.0', + author: 'test-author', + description: 'A test extension', + risk_tier: 'low', + airb_status: 'pending' + } + end + + let(:entry) { Legion::Registry::Entry.new(**entry_attrs) } + + before(:each) do + Legion::Registry.clear! + Legion::Registry.register(entry) + end + + # ────────────────────────────────────────────────────────── + # Entry status fields + # ────────────────────────────────────────────────────────── + + describe 'Entry' do + describe '#status' do + it 'defaults to :active' do + expect(entry.status).to eq(:active) + end + + it 'accepts explicit status' do + e = Legion::Registry::Entry.new(**entry_attrs, status: :pending_review) + expect(e.status).to eq(:pending_review) + end + end + + describe '#deprecated?' do + it 'returns false for active entry' do + expect(entry.deprecated?).to be false + end + + it 'returns true for deprecated status' do + e = Legion::Registry::Entry.new(**entry_attrs, status: :deprecated) + expect(e.deprecated?).to be true + end + + it 'returns true for sunset status' do + e = Legion::Registry::Entry.new(**entry_attrs, status: :sunset) + expect(e.deprecated?).to be true + end + end + + describe '#pending_review?' do + it 'returns false for active entry' do + expect(entry.pending_review?).to be false + end + + it 'returns true when status is pending_review' do + e = Legion::Registry::Entry.new(**entry_attrs, status: :pending_review) + expect(e.pending_review?).to be true + end + end + + describe '#to_h' do + it 'includes status field' do + expect(entry.to_h).to have_key(:status) + end + + it 'includes successor field' do + expect(entry.to_h).to have_key(:successor) + end + + it 'includes sunset_date field' do + expect(entry.to_h).to have_key(:sunset_date) + end + + it 'includes submitted_at field' do + expect(entry.to_h).to have_key(:submitted_at) + end + end + end + + # ────────────────────────────────────────────────────────── + # submit_for_review + # ────────────────────────────────────────────────────────── + + describe '.submit_for_review' do + it 'sets status to pending_review' do + Legion::Registry.submit_for_review('lex-test') + expect(Legion::Registry.lookup('lex-test').status).to eq(:pending_review) + end + + it 'sets submitted_at timestamp' do + Legion::Registry.submit_for_review('lex-test') + expect(Legion::Registry.lookup('lex-test').submitted_at).to be_a(Time) + end + + it 'returns true on success' do + expect(Legion::Registry.submit_for_review('lex-test')).to be true + end + + it 'raises ArgumentError for unknown extension' do + expect { Legion::Registry.submit_for_review('lex-missing') }.to raise_error(ArgumentError, /not found/) + end + end + + # ────────────────────────────────────────────────────────── + # approve + # ────────────────────────────────────────────────────────── + + describe '.approve' do + before { Legion::Registry.submit_for_review('lex-test') } + + it 'sets status to approved' do + Legion::Registry.approve('lex-test') + expect(Legion::Registry.lookup('lex-test').status).to eq(:approved) + end + + it 'sets airb_status to approved' do + Legion::Registry.approve('lex-test') + expect(Legion::Registry.lookup('lex-test').airb_status).to eq('approved') + end + + it 'stores review notes' do + Legion::Registry.approve('lex-test', notes: 'LGTM') + expect(Legion::Registry.lookup('lex-test').review_notes).to eq('LGTM') + end + + it 'sets approved_at timestamp' do + Legion::Registry.approve('lex-test') + expect(Legion::Registry.lookup('lex-test').approved_at).to be_a(Time) + end + + it 'returns true on success' do + expect(Legion::Registry.approve('lex-test')).to be true + end + + it 'raises ArgumentError for unknown extension' do + expect { Legion::Registry.approve('lex-missing') }.to raise_error(ArgumentError, /not found/) + end + + it 'makes approved? return true' do + Legion::Registry.approve('lex-test') + expect(Legion::Registry.lookup('lex-test').approved?).to be true + end + end + + # ────────────────────────────────────────────────────────── + # reject + # ────────────────────────────────────────────────────────── + + describe '.reject' do + before { Legion::Registry.submit_for_review('lex-test') } + + it 'sets status to rejected' do + Legion::Registry.reject('lex-test') + expect(Legion::Registry.lookup('lex-test').status).to eq(:rejected) + end + + it 'stores rejection reason' do + Legion::Registry.reject('lex-test', reason: 'Security issues') + expect(Legion::Registry.lookup('lex-test').reject_reason).to eq('Security issues') + end + + it 'sets rejected_at timestamp' do + Legion::Registry.reject('lex-test') + expect(Legion::Registry.lookup('lex-test').rejected_at).to be_a(Time) + end + + it 'returns true on success' do + expect(Legion::Registry.reject('lex-test')).to be true + end + + it 'raises ArgumentError for unknown extension' do + expect { Legion::Registry.reject('lex-missing') }.to raise_error(ArgumentError, /not found/) + end + end + + # ────────────────────────────────────────────────────────── + # deprecate + # ────────────────────────────────────────────────────────── + + describe '.deprecate' do + it 'sets status to deprecated' do + Legion::Registry.deprecate('lex-test') + expect(Legion::Registry.lookup('lex-test').status).to eq(:deprecated) + end + + it 'stores successor' do + Legion::Registry.deprecate('lex-test', successor: 'lex-test-v2') + expect(Legion::Registry.lookup('lex-test').successor).to eq('lex-test-v2') + end + + it 'stores sunset_date' do + sunset = Date.new(2027, 1, 1) + Legion::Registry.deprecate('lex-test', sunset_date: sunset) + expect(Legion::Registry.lookup('lex-test').sunset_date).to eq(sunset) + end + + it 'sets deprecated_at timestamp' do + Legion::Registry.deprecate('lex-test') + expect(Legion::Registry.lookup('lex-test').deprecated_at).to be_a(Time) + end + + it 'makes deprecated? return true' do + Legion::Registry.deprecate('lex-test') + expect(Legion::Registry.lookup('lex-test').deprecated?).to be true + end + + it 'returns true on success' do + expect(Legion::Registry.deprecate('lex-test')).to be true + end + + it 'raises ArgumentError for unknown extension' do + expect { Legion::Registry.deprecate('lex-missing') }.to raise_error(ArgumentError, /not found/) + end + end + + # ────────────────────────────────────────────────────────── + # pending_reviews + # ────────────────────────────────────────────────────────── + + describe '.pending_reviews' do + it 'returns empty array when none are pending' do + expect(Legion::Registry.pending_reviews).to be_empty + end + + it 'returns entries with pending_review status' do + Legion::Registry.submit_for_review('lex-test') + expect(Legion::Registry.pending_reviews.size).to eq(1) + end + + it 'excludes active entries' do + expect(Legion::Registry.pending_reviews).not_to include(entry) + end + + it 'returns only pending_review entries when mixed statuses' do + second = Legion::Registry::Entry.new(**entry_attrs, name: 'lex-other', status: :active) + Legion::Registry.register(second) + Legion::Registry.submit_for_review('lex-test') + pending = Legion::Registry.pending_reviews + expect(pending.map(&:name)).to eq(['lex-test']) + end + end + + # ────────────────────────────────────────────────────────── + # usage_stats + # ────────────────────────────────────────────────────────── + + describe '.usage_stats' do + it 'returns nil for unknown extension' do + expect(Legion::Registry.usage_stats('lex-missing')).to be_nil + end + + it 'returns a hash for a registered extension' do + expect(Legion::Registry.usage_stats('lex-test')).to be_a(Hash) + end + + it 'includes name field' do + expect(Legion::Registry.usage_stats('lex-test')[:name]).to eq('lex-test') + end + + it 'includes install_count field' do + expect(Legion::Registry.usage_stats('lex-test')).to have_key(:install_count) + end + + it 'includes active_instances field' do + expect(Legion::Registry.usage_stats('lex-test')).to have_key(:active_instances) + end + + it 'includes downloads_7d field' do + expect(Legion::Registry.usage_stats('lex-test')).to have_key(:downloads_7d) + end + + it 'includes downloads_30d field' do + expect(Legion::Registry.usage_stats('lex-test')).to have_key(:downloads_30d) + end + end + + # ────────────────────────────────────────────────────────── + # full lifecycle flow + # ────────────────────────────────────────────────────────── + + describe 'full review lifecycle' do + it 'transitions: active -> pending_review -> approved' do + expect(entry.status).to eq(:active) + Legion::Registry.submit_for_review('lex-test') + expect(Legion::Registry.lookup('lex-test').status).to eq(:pending_review) + Legion::Registry.approve('lex-test', notes: 'All checks pass') + approved = Legion::Registry.lookup('lex-test') + expect(approved.status).to eq(:approved) + expect(approved.airb_status).to eq('approved') + end + + it 'transitions: active -> pending_review -> rejected' do + Legion::Registry.submit_for_review('lex-test') + Legion::Registry.reject('lex-test', reason: 'CVE found') + rejected = Legion::Registry.lookup('lex-test') + expect(rejected.status).to eq(:rejected) + expect(rejected.reject_reason).to eq('CVE found') + end + + it 'transitions: approved -> deprecated with successor' do + Legion::Registry.submit_for_review('lex-test') + Legion::Registry.approve('lex-test') + Legion::Registry.deprecate('lex-test', successor: 'lex-test-v2', sunset_date: Date.new(2027, 6, 1)) + deprecated = Legion::Registry.lookup('lex-test') + expect(deprecated.status).to eq(:deprecated) + expect(deprecated.successor).to eq('lex-test-v2') + expect(deprecated.sunset_date).to eq(Date.new(2027, 6, 1)) + end + end +end diff --git a/spec/legion/registry/persistence_spec.rb b/spec/legion/registry/persistence_spec.rb new file mode 100644 index 00000000..1767d6a3 --- /dev/null +++ b/spec/legion/registry/persistence_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/registry' +require 'legion/registry/persistence' + +RSpec.describe Legion::Registry::Persistence do + before { Legion::Registry.clear! } + + describe '.data_available?' do + it 'returns a boolean' do + expect(described_class.data_available?).to be(true).or be(false) + end + end + + describe '.load_from_db' do + context 'when data is not available' do + before { allow(described_class).to receive(:data_available?).and_return(false) } + + it 'returns 0' do + expect(described_class.load_from_db).to eq(0) + end + end + + context 'when data is available' do + let(:mock_dataset) do + [ + { + name: 'lex-http', + version: '0.2.0', + author: 'test', + description: 'HTTP client extension', + status: 'active', + airb_status: 'approved', + risk_tier: 'medium' + }, + { + name: 'lex-redis', + version: '0.1.0', + author: 'test', + description: 'Redis extension', + status: 'active', + airb_status: 'pending', + risk_tier: 'low' + } + ] + end + + before do + allow(described_class).to receive(:data_available?).and_return(true) + allow(described_class).to receive(:registry_dataset).and_return(mock_dataset) + # Prevent persist from firing during load_from_db (register hook) + allow(Legion::Registry::Persistence).to receive(:persist).and_return(true) + end + + it 'populates the registry from DB rows' do + count = described_class.load_from_db + expect(count).to eq(2) + end + + it 'registers each entry in Legion::Registry' do + described_class.load_from_db + expect(Legion::Registry.lookup('lex-http')).not_to be_nil + expect(Legion::Registry.lookup('lex-redis')).not_to be_nil + end + + it 'maps status as symbol' do + described_class.load_from_db + entry = Legion::Registry.lookup('lex-http') + expect(entry.status).to eq(:active) + end + end + end + + describe '.persist' do + let(:entry) do + Legion::Registry::Entry.new( + name: 'lex-test', + version: '1.0.0', + description: 'Test extension', + status: :active, + airb_status: 'approved', + risk_tier: 'low' + ) + end + + context 'when data is not available' do + before { allow(described_class).to receive(:data_available?).and_return(false) } + + it 'returns false' do + expect(described_class.persist(entry)).to be false + end + end + + context 'when data is available and row does not exist' do + let(:mock_dataset) { double('dataset') } + + before do + allow(described_class).to receive(:data_available?).and_return(true) + allow(described_class).to receive(:registry_dataset).and_return(mock_dataset) + allow(mock_dataset).to receive(:where).with(name: 'lex-test').and_return(mock_dataset) + allow(mock_dataset).to receive(:first).and_return(nil) + allow(mock_dataset).to receive(:insert).and_return(1) + end + + it 'inserts and returns true' do + expect(mock_dataset).to receive(:insert).with( + hash_including(name: 'lex-test', status: 'active', created_at: anything, updated_at: anything) + ) + expect(described_class.persist(entry)).to be true + end + end + + context 'when data is available and row exists' do + let(:mock_dataset) { double('dataset') } + let(:existing_row) { { name: 'lex-test', status: 'active' } } + + before do + allow(described_class).to receive(:data_available?).and_return(true) + allow(described_class).to receive(:registry_dataset).and_return(mock_dataset) + allow(mock_dataset).to receive(:where).with(name: 'lex-test').and_return(mock_dataset) + allow(mock_dataset).to receive(:first).and_return(existing_row) + allow(mock_dataset).to receive(:update).and_return(1) + end + + it 'updates and returns true' do + expect(mock_dataset).to receive(:update).with( + hash_including(name: 'lex-test', status: 'active', updated_at: anything) + ) + expect(described_class.persist(entry)).to be true + end + end + + context 'when a DB error occurs' do + let(:mock_dataset) { double('dataset') } + + before do + allow(described_class).to receive(:data_available?).and_return(true) + allow(described_class).to receive(:registry_dataset).and_return(mock_dataset) + allow(mock_dataset).to receive(:where).and_raise(StandardError, 'db error') + end + + it 'returns false' do + expect(described_class.persist(entry)).to be false + end + end + end + + describe 'module_name derivation via .persistence_attrs (via persist)' do + let(:mock_dataset) { double('dataset') } + + before do + allow(described_class).to receive(:data_available?).and_return(true) + allow(described_class).to receive(:registry_dataset).and_return(mock_dataset) + allow(mock_dataset).to receive(:where).with(name: 'lex-azure-ai').and_return(mock_dataset) + allow(mock_dataset).to receive(:first).and_return(nil) + end + + it 'derives module_name by capitalizing each segment' do + entry = Legion::Registry::Entry.new(name: 'lex-azure-ai', description: 'test') + expect(mock_dataset).to receive(:insert).with( + hash_including(module_name: 'Lex::Azure::Ai') + ) + described_class.persist(entry) + end + end +end diff --git a/spec/legion/registry/security_scanner_spec.rb b/spec/legion/registry/security_scanner_spec.rb new file mode 100644 index 00000000..7f19d126 --- /dev/null +++ b/spec/legion/registry/security_scanner_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' +require 'fileutils' +require 'legion/registry/security_scanner' + +RSpec.describe Legion::Registry::SecurityScanner do + let(:scanner) { described_class.new } + + describe '#scan' do + it 'returns result hash' do + result = scanner.scan(name: 'lex-test') + expect(result).to have_key(:passed) + expect(result).to have_key(:checks) + expect(result).to have_key(:scanned_at) + end + + it 'passes valid naming' do + result = scanner.scan(name: 'lex-test') + naming = result[:checks].find { |c| c[:check] == :naming_convention } + expect(naming[:status]).to eq(:pass) + end + + it 'passes nested lex extension names' do + result = scanner.scan(name: 'lex-llm-azure-foundry') + naming = result[:checks].find { |c| c[:check] == :naming_convention } + expect(naming[:status]).to eq(:pass) + end + + it 'fails invalid naming' do + result = scanner.scan(name: 'bad_name') + naming = result[:checks].find { |c| c[:check] == :naming_convention } + expect(naming[:status]).to eq(:fail) + end + + it 'skips checksum without gem path' do + result = scanner.scan(name: 'lex-test') + checksum = result[:checks].find { |c| c[:check] == :checksum } + expect(checksum[:status]).to eq(:skip) + end + + it 'overall passes when no failures' do + result = scanner.scan(name: 'lex-test') + expect(result[:passed]).to be true + end + + it 'overall fails when naming fails' do + result = scanner.scan(name: 'BAD') + expect(result[:passed]).to be false + end + + it 'skips static_analysis when no source_path provided' do + result = scanner.scan(name: 'lex-test') + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:skip) + expect(sa[:details]).to eq('no source path') + end + + it 'overall still passes when static_analysis is :warn' do + Dir.mktmpdir do |dir| + File.write(File.join(dir, 'bad.rb'), "IO.popen('dangerous_cmd')\n") + result = scanner.scan(name: 'lex-test', source_path: dir) + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:warn) + expect(result[:passed]).to be true + end + end + end + + describe '#static_analysis' do + let(:tmpdir) { Dir.mktmpdir } + + after { FileUtils.remove_entry(tmpdir) } + + def write_rb(name, content) + File.write(File.join(tmpdir, name), content) + end + + it 'passes for clean Ruby source' do + write_rb('clean.rb', "def hello\n 'world'\nend\n") + result = scanner.scan(source_path: tmpdir) + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:pass) + expect(sa[:details]).to eq('no dangerous patterns found') + end + + it 'warns for system call usage' do + write_rb('sys.rb', "Kernel.system('rm -rf /')\n") + result = scanner.scan(source_path: tmpdir) + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:warn) + expect(sa[:details]).to include('system') + end + + it 'warns for IO.popen usage' do + write_rb('io.rb', "IO.popen('cmd') { |f| f.read }\n") + result = scanner.scan(source_path: tmpdir) + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:warn) + expect(sa[:details]).to include('IO.popen') + end + + it 'warns for Open3 usage' do + write_rb('open3.rb', "require 'open3'\nOpen3.capture3('cmd')\n") + result = scanner.scan(source_path: tmpdir) + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:warn) + expect(sa[:details]).to include('Open3') + end + + it 'warns for eval usage' do + write_rb('evil.rb', "eval(user_input)\n") + result = scanner.scan(source_path: tmpdir) + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:warn) + expect(sa[:details]).to include('eval') + end + + it 'warns for backtick subshell' do + write_rb('shell.rb', "output = `ls -la`\n") + result = scanner.scan(source_path: tmpdir) + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:warn) + expect(sa[:details]).to include('backtick subshell') + end + + it 'scans only .rb files and ignores other extensions' do + write_rb('notes.md', "Use backtick for shell commands\n") + write_rb('clean.rb', "puts 'hello'\n") + result = scanner.scan(source_path: tmpdir) + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:pass) + end + + it 'includes relative path and line number in findings' do + write_rb('runner.rb', "# line 1\nIO.popen('cmd')\n") + result = scanner.scan(source_path: tmpdir) + sa = result[:checks].find { |c| c[:check] == :static_analysis } + expect(sa[:status]).to eq(:warn) + expect(sa[:details]).to include('runner.rb:2') + end + end +end diff --git a/spec/legion/registry_spec.rb b/spec/legion/registry_spec.rb new file mode 100644 index 00000000..7fc6bab0 --- /dev/null +++ b/spec/legion/registry_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/registry' + +RSpec.describe Legion::Registry do + before { described_class.clear! } + + let(:entry) do + Legion::Registry::Entry.new( + name: 'lex-test', version: '0.1.0', author: 'test', + risk_tier: 'low', airb_status: 'approved', description: 'test extension' + ) + end + + describe '.register / .lookup' do + it 'stores and retrieves entries' do + described_class.register(entry) + expect(described_class.lookup('lex-test').name).to eq(entry.name) + end + end + + describe '.unregister' do + it 'removes entries' do + described_class.register(entry) + described_class.unregister('lex-test') + expect(described_class.lookup('lex-test')).to be_nil + end + end + + describe '.all' do + it 'returns all entries' do + described_class.register(entry) + expect(described_class.all.map(&:name)).to eq([entry.name]) + end + end + + describe '.search' do + it 'finds by name' do + described_class.register(entry) + expect(described_class.search('test').size).to eq(1) + end + + it 'finds by description' do + described_class.register(entry) + expect(described_class.search('extension').size).to eq(1) + end + + it 'returns empty for no match' do + described_class.register(entry) + expect(described_class.search('nonexistent')).to be_empty + end + end + + describe '.approved' do + it 'filters by approved status' do + described_class.register(entry) + pending_entry = Legion::Registry::Entry.new(name: 'lex-pending', airb_status: 'pending', risk_tier: 'high') + described_class.register(pending_entry) + expect(described_class.approved.map(&:name)).to eq(['lex-test']) + end + end + + describe '.by_risk_tier' do + it 'filters by tier' do + described_class.register(entry) + expect(described_class.by_risk_tier('low').size).to eq(1) + expect(described_class.by_risk_tier('high').size).to eq(0) + end + end +end + +RSpec.describe Legion::Registry::Entry do + let(:entry) { described_class.new(name: 'lex-test', airb_status: 'approved') } + + it 'reports approved status' do + expect(entry.approved?).to be true + end + + it 'defaults risk_tier to low' do + expect(entry.risk_tier).to eq('low') + end + + it 'defaults airb_status to pending' do + plain = described_class.new(name: 'lex-plain') + expect(plain.airb_status).to eq('pending') + end + + it 'serializes to hash' do + expect(entry.to_h[:name]).to eq('lex-test') + end +end diff --git a/spec/legion/runner_audit_spec.rb b/spec/legion/runner_audit_spec.rb new file mode 100644 index 00000000..5a86fff0 --- /dev/null +++ b/spec/legion/runner_audit_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/audit' +require 'legion/runner' + +# Minimal runner class for testing +module TestRunners + module AuditTest + def self.succeed(**_args) + { result: 'ok' } + end + + def self.fail_hard(**_args) + raise StandardError, 'boom' + end + + def self.with_log_context(_method_name) + yield + end + + def self.handle_runner_exception(exception, **_opts); end + end +end + +RSpec.describe 'Runner.run audit integration' do + before do + stub_const('Legion::Exception::HandledTask', Class.new(StandardError)) unless defined?(Legion::Exception::HandledTask) + allow(Legion::Events).to receive(:emit) + allow(Legion::Runner::Status).to receive(:generate_task_id).and_return({ task_id: 1 }) + allow(Legion::Runner::Status).to receive(:update) + allow(Legion::Transport::Messages::CheckSubtask).to receive_message_chain(:new, :publish) + end + + context 'when Legion::Audit is defined' do + before do + allow(Legion::Audit).to receive(:record) + end + + it 'calls Legion::Audit.record on successful execution' do + Legion::Runner.run(runner_class: TestRunners::AuditTest, function: :succeed, check_subtask: false) + expect(Legion::Audit).to have_received(:record).with( + hash_including( + event_type: 'runner_execution', + action: 'execute', + resource: 'TestRunners::AuditTest/succeed', + status: 'success' + ) + ) + end + + it 'includes duration_ms in the audit record' do + Legion::Runner.run(runner_class: TestRunners::AuditTest, function: :succeed, check_subtask: false) + expect(Legion::Audit).to have_received(:record).with( + hash_including(duration_ms: a_kind_of(Integer)) + ) + end + + it 'records failure status on exception' do + Legion::Runner.run(runner_class: TestRunners::AuditTest, function: :fail_hard, + check_subtask: false, catch_exceptions: true) + expect(Legion::Audit).to have_received(:record).with( + hash_including(status: 'failure') + ) + end + + it 'includes error message on exception' do + Legion::Runner.run(runner_class: TestRunners::AuditTest, function: :fail_hard, + check_subtask: false, catch_exceptions: true) + expect(Legion::Audit).to have_received(:record).with( + hash_including(detail: hash_including(error: 'boom')) + ) + end + + it 'uses principal_id from opts when provided' do + Legion::Runner.run(runner_class: TestRunners::AuditTest, function: :succeed, + check_subtask: false, principal_id: 'worker-42') + expect(Legion::Audit).to have_received(:record).with( + hash_including(principal_id: 'worker-42') + ) + end + + it 'still works when audit publishing raises' do + allow(Legion::Audit).to receive(:record).and_raise(StandardError, 'audit down') + result = Legion::Runner.run(runner_class: TestRunners::AuditTest, function: :succeed, check_subtask: false) + expect(result[:success]).to be true + end + end +end diff --git a/spec/legion/runner_check_subtask_spec.rb b/spec/legion/runner_check_subtask_spec.rb new file mode 100644 index 00000000..b14ece92 --- /dev/null +++ b/spec/legion/runner_check_subtask_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/runner' + +module TestRunners + module CheckSubtaskTest + def self.do_work(**_args) + { result: 'done' } + end + end +end + +RSpec.describe 'Runner.run CheckSubtask forwarding' do + before do + stub_const('Legion::Exception::HandledTask', Class.new(StandardError)) unless defined?(Legion::Exception::HandledTask) + allow(Legion::Events).to receive(:emit) + allow(Legion::Runner::Status).to receive(:generate_task_id).and_return({ task_id: 42 }) + allow(Legion::Runner::Status).to receive(:update) + end + + # When args: is provided explicitly, args != opts (no aliasing), so task_id/master_id + # must be forwarded explicitly to CheckSubtask.new — they won't appear via **opts. + describe 'explicit args: path — task_id and master_id must be forwarded' do + let(:check_subtask_dbl) { double('check_subtask', publish: nil) } + + before do + allow(Legion::Transport::Messages::CheckSubtask).to receive(:new).and_return(check_subtask_dbl) + end + + it 'forwards task_id explicitly when args: is provided' do + Legion::Runner.run( + runner_class: TestRunners::CheckSubtaskTest, + function: :do_work, + task_id: 99, + args: { some_param: 'value' }, + check_subtask: true + ) + expect(Legion::Transport::Messages::CheckSubtask).to have_received(:new).with( + hash_including(task_id: 99) + ) + end + + it 'forwards master_id explicitly when args: is provided' do + Legion::Runner.run( + runner_class: TestRunners::CheckSubtaskTest, + function: :do_work, + task_id: 99, + master_id: 7, + args: { some_param: 'value' }, + check_subtask: true + ) + expect(Legion::Transport::Messages::CheckSubtask).to have_received(:new).with( + hash_including(master_id: 7) + ) + end + + it 'forwards both task_id and master_id when args: is provided' do + Legion::Runner.run( + runner_class: TestRunners::CheckSubtaskTest, + function: :do_work, + task_id: 55, + master_id: 3, + args: { payload: 'data' }, + check_subtask: true + ) + expect(Legion::Transport::Messages::CheckSubtask).to have_received(:new).with( + hash_including(task_id: 55, master_id: 3) + ) + end + end +end diff --git a/spec/legion/sandbox_spec.rb b/spec/legion/sandbox_spec.rb new file mode 100644 index 00000000..7fd2f3e8 --- /dev/null +++ b/spec/legion/sandbox_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/sandbox' + +RSpec.describe Legion::Sandbox do + before do + described_class.clear! + allow(Legion::Settings).to receive(:dig).and_return(nil) + end + + describe '.enforce!' do + it 'raises for unauthorized capability' do + described_class.register_policy('lex-test', capabilities: ['data:read']) + expect { described_class.enforce!('lex-test', 'network:outbound') }.to raise_error(SecurityError) + end + + it 'passes for authorized capability' do + described_class.register_policy('lex-test', capabilities: ['data:read']) + expect(described_class.enforce!('lex-test', 'data:read')).to be true + end + + it 'raises for unregistered extensions with no capabilities' do + expect { described_class.enforce!('lex-unknown', 'data:read') }.to raise_error(SecurityError) + end + + it 'passes when enforcement is disabled' do + allow(Legion::Settings).to receive(:dig).with(:sandbox, :enabled).and_return(false) + expect(described_class.enforce!('lex-unknown', 'anything')).to be true + end + end + + describe '.policy_for' do + it 'returns empty policy for unknown extension' do + policy = described_class.policy_for('lex-unknown') + expect(policy.capabilities).to be_empty + end + end + + describe '.allowed?' do + it 'returns false when agent domain does not match allowed domains' do + described_class.register_policy( + 'lex-claims-tool', + capabilities: ['data:read'], + allowed_domains: ['claims_optimization'] + ) + expect( + described_class.allowed?(gem_name: 'lex-claims-tool', agent_domain: 'clinical_care') + ).to be false + end + + it 'returns true when domains match' do + described_class.register_policy( + 'lex-claims-tool', + capabilities: ['data:read'], + allowed_domains: ['claims_optimization'] + ) + expect( + described_class.allowed?(gem_name: 'lex-claims-tool', agent_domain: 'claims_optimization') + ).to be true + end + + it 'returns true when no domain restrictions are set' do + described_class.register_policy( + 'lex-general-tool', + capabilities: ['data:read'] + ) + expect( + described_class.allowed?(gem_name: 'lex-general-tool', agent_domain: 'anything') + ).to be true + end + + it 'checks both capability and domain' do + described_class.register_policy( + 'lex-restricted', + capabilities: ['data:read'], + allowed_domains: ['claims'] + ) + expect( + described_class.allowed?(gem_name: 'lex-restricted', capability: 'data:read', agent_domain: 'claims') + ).to be true + expect( + described_class.allowed?(gem_name: 'lex-restricted', capability: 'network:outbound', agent_domain: 'claims') + ).to be false + end + end +end + +RSpec.describe Legion::Sandbox::Policy do + let(:policy) { described_class.new(extension_name: 'test', capabilities: %w[data:read llm:invoke]) } + + it 'checks capability allowance' do + expect(policy.allowed?('data:read')).to be true + expect(policy.allowed?('filesystem:write')).to be false + end + + it 'filters invalid capabilities' do + bad_policy = described_class.new(extension_name: 'test', capabilities: ['invalid:cap']) + expect(bad_policy.capabilities).to be_empty + end + + describe '#domain_allowed?' do + it 'allows when no domain restrictions set' do + policy = described_class.new(extension_name: 'test', capabilities: ['data:read']) + expect(policy.domain_allowed?('anything')).to be true + end + + it 'allows matching domain' do + policy = described_class.new(extension_name: 'test', capabilities: ['data:read'], allowed_domains: ['clinical']) + expect(policy.domain_allowed?('clinical')).to be true + end + + it 'rejects non-matching domain' do + policy = described_class.new(extension_name: 'test', capabilities: ['data:read'], allowed_domains: ['clinical']) + expect(policy.domain_allowed?('claims')).to be false + end + end +end diff --git a/spec/legion/service_api_settings_spec.rb b/spec/legion/service_api_settings_spec.rb new file mode 100644 index 00000000..0a8a1b6c --- /dev/null +++ b/spec/legion/service_api_settings_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'sinatra/base' +require 'legion/api/default_settings' + +RSpec.describe 'Service API settings integration' do + it 'reads port from Settings[:api] without fallback' do + previous_port = Legion::Settings[:api][:port] + Legion::Settings[:api][:port] = 9999 + expect(Legion::Settings[:api][:port]).to eq(9999) + ensure + Legion::Settings[:api][:port] = previous_port + end + + it 'reads puma threads from Settings[:api][:puma]' do + expect(Legion::Settings[:api][:puma][:min_threads]).to eq(10) + expect(Legion::Settings[:api][:puma][:max_threads]).to eq(16) + end +end diff --git a/spec/legion/service_credential_scoping_spec.rb b/spec/legion/service_credential_scoping_spec.rb new file mode 100644 index 00000000..c91fd6d8 --- /dev/null +++ b/spec/legion/service_credential_scoping_spec.rb @@ -0,0 +1,801 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/service' + +# Specs for Phase 5 Credential Scoping — service.rb integration +# Covers §8 of docs/plans/2026-04-07-credential-scoping-design.md +RSpec.describe Legion::Service do + subject(:service) { described_class.allocate } + + # --------------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------------- + + # Build a minimal Crypt stub with the Phase 5 methods + def build_crypt_stub(vault_connected: true, dynamic_rmq_creds: true) + Module.new do + define_singleton_method(:vault_connected?) { vault_connected } + define_singleton_method(:dynamic_rmq_creds?) { dynamic_rmq_creds } + define_singleton_method(:fetch_bootstrap_rmq_creds) { nil } + define_singleton_method(:swap_to_identity_creds) { |**_kwargs| nil } + define_singleton_method(:revoke_bootstrap_lease) { nil } + end + end + + # Build a minimal Mode stub + def build_mode_stub(current: :agent, lite: false) + Module.new do + define_singleton_method(:current) { current } + define_singleton_method(:lite?) { lite } + end + end + + # --------------------------------------------------------------------------- + # §8.1 Boot — fetch_bootstrap_rmq_creds called after Crypt.start + # --------------------------------------------------------------------------- + + describe '#fetch_phase5_bootstrap_creds (private helper used by boot and reload)' do + context 'when Crypt responds to fetch_bootstrap_rmq_creds and vault is connected with dynamic creds on' do + it 'calls fetch_bootstrap_rmq_creds' do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: true) + stub_const('Legion::Crypt', crypt) + + expect(Legion::Crypt).to receive(:fetch_bootstrap_rmq_creds) + service.send(:fetch_phase5_bootstrap_creds) + end + end + + context 'when Crypt does not respond to fetch_bootstrap_rmq_creds' do + it 'does not raise' do + crypt_no_bootstrap = Module.new + stub_const('Legion::Crypt', crypt_no_bootstrap) + + expect { service.send(:fetch_phase5_bootstrap_creds) }.not_to raise_error + end + end + + context 'when vault is not connected' do + it 'does not call fetch_bootstrap_rmq_creds' do + crypt = build_crypt_stub(vault_connected: false, dynamic_rmq_creds: true) + stub_const('Legion::Crypt', crypt) + + expect(Legion::Crypt).not_to receive(:fetch_bootstrap_rmq_creds) + service.send(:fetch_phase5_bootstrap_creds) + end + end + + context 'when dynamic_rmq_creds is false' do + it 'does not call fetch_bootstrap_rmq_creds' do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: false) + stub_const('Legion::Crypt', crypt) + + expect(Legion::Crypt).not_to receive(:fetch_bootstrap_rmq_creds) + service.send(:fetch_phase5_bootstrap_creds) + end + end + end + + # --------------------------------------------------------------------------- + # §8.1 Boot — initialize calls fetch_phase5_bootstrap_creds after Crypt.start + # --------------------------------------------------------------------------- + + # Verify that #initialize actually invokes fetch_phase5_bootstrap_creds after Crypt.start + # (so the call site cannot be silently deleted without breaking this spec). + describe 'Legion::Service#initialize — fetch_phase5_bootstrap_creds call site' do + let(:service_instance) { described_class.allocate } + + it 'calls fetch_phase5_bootstrap_creds when crypt is enabled and not in lite mode' do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: true) + crypt_with_start = Module.new do + define_singleton_method(:vault_connected?) { crypt.vault_connected? } + define_singleton_method(:dynamic_rmq_creds?) { crypt.dynamic_rmq_creds? } + define_singleton_method(:fetch_bootstrap_rmq_creds) { crypt.fetch_bootstrap_rmq_creds } + define_singleton_method(:swap_to_identity_creds) { |**kw| crypt.swap_to_identity_creds(**kw) } + define_singleton_method(:revoke_bootstrap_lease) { crypt.revoke_bootstrap_lease } + def self.start = nil + def self.cs = nil + end + stub_const('Legion::Crypt', crypt_with_start) + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + + # Verify fetch_phase5_bootstrap_creds is wired into the initialize call-site by + # checking that the private method is invoked when crypt=true and not lite mode. + expect(service_instance).to receive(:fetch_phase5_bootstrap_creds) + + # Stub everything else so initialize() can run through the crypt branch + allow(service_instance).to receive(:setup_logging) + allow(service_instance).to receive(:log).and_return(double(debug: nil, info: nil, warn: nil, error: nil)) + allow(service_instance).to receive(:setup_settings) + allow(service_instance).to receive(:apply_cli_overrides) + allow(service_instance).to receive(:setup_compliance) + allow(service_instance).to receive(:setup_local_mode) + allow(service_instance).to receive(:reconfigure_logging) + allow(service_instance).to receive(:setup_mtls_rotation) + allow(service_instance).to receive(:require) + allow(service_instance).to receive(:require_relative) + allow(service_instance).to receive(:setup_transport) + allow(service_instance).to receive(:setup_dispatch) + allow(service_instance).to receive(:setup_rbac) + allow(service_instance).to receive(:setup_cluster) + allow(service_instance).to receive(:setup_llm) + allow(service_instance).to receive(:setup_apollo) + allow(service_instance).to receive(:setup_gaia) + allow(service_instance).to receive(:setup_telemetry) + allow(service_instance).to receive(:setup_audit_archiver) + allow(service_instance).to receive(:setup_safety_metrics) + allow(service_instance).to receive(:setup_supervision) + allow(service_instance).to receive(:setup_extensions) + allow(service_instance).to receive(:setup_generated_functions) + allow(service_instance).to receive(:load_extensions) + allow(service_instance).to receive(:setup_api) + allow(service_instance).to receive(:setup_identity) + allow(service_instance).to receive(:setup_apm) + allow(service_instance).to receive(:setup_network_watchdog) + allow(service_instance).to receive(:register_core_tools) + allow(service_instance).to receive(:setup_alerts) + allow(service_instance).to receive(:setup_metrics) + allow(service_instance).to receive(:setup_task_outcome_observer) + allow(service_instance).to receive(:bootstrap_log_level).and_return(:info) + + process_role = Module.new do + def self.resolve(_) + { transport: false, cache: false, data: false, supervision: false, extensions: false, crypt: true, api: false, llm: false, + gaia: false } + end + + def self.current = :agent + end + stub_const('Legion::ProcessRole', process_role) + + settings_mod = Module.new do + def self.respond_to?(mth, *) = mth == :resolve_secrets! ? true : super + def self.resolve_secrets! = nil + def self.dig(*) = nil + end + settings_mod.define_singleton_method(:[]) { |_k| {} } + settings_mod.define_singleton_method(:[]=) { |_k, _v| nil } + stub_const('Legion::Settings', settings_mod) + + readiness_mod = Module.new do + def self.mark_ready(*) = nil + def self.mark_skipped(*) = nil + end + stub_const('Legion::Readiness', readiness_mod) + + service_instance.send(:initialize, crypt: true) + end + + it 'does not call fetch_phase5_bootstrap_creds when in lite mode' do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: true) + crypt_with_start = Module.new do + define_singleton_method(:vault_connected?) { crypt.vault_connected? } + define_singleton_method(:dynamic_rmq_creds?) { crypt.dynamic_rmq_creds? } + define_singleton_method(:fetch_bootstrap_rmq_creds) { crypt.fetch_bootstrap_rmq_creds } + define_singleton_method(:swap_to_identity_creds) { |**kw| crypt.swap_to_identity_creds(**kw) } + define_singleton_method(:revoke_bootstrap_lease) { crypt.revoke_bootstrap_lease } + def self.start = nil + def self.cs = nil + end + stub_const('Legion::Crypt', crypt_with_start) + stub_const('Legion::Mode', build_mode_stub(current: :lite, lite: true)) + + expect(service_instance).not_to receive(:fetch_phase5_bootstrap_creds) + + allow(service_instance).to receive(:setup_logging) + allow(service_instance).to receive(:log).and_return(double(debug: nil, info: nil, warn: nil, error: nil)) + allow(service_instance).to receive(:setup_settings) + allow(service_instance).to receive(:apply_cli_overrides) + allow(service_instance).to receive(:setup_compliance) + allow(service_instance).to receive(:setup_local_mode) + allow(service_instance).to receive(:reconfigure_logging) + allow(service_instance).to receive(:setup_mtls_rotation) + allow(service_instance).to receive(:require) + allow(service_instance).to receive(:require_relative) + allow(service_instance).to receive(:setup_transport) + allow(service_instance).to receive(:setup_dispatch) + allow(service_instance).to receive(:setup_rbac) + allow(service_instance).to receive(:setup_cluster) + allow(service_instance).to receive(:setup_llm) + allow(service_instance).to receive(:setup_apollo) + allow(service_instance).to receive(:setup_gaia) + allow(service_instance).to receive(:setup_telemetry) + allow(service_instance).to receive(:setup_audit_archiver) + allow(service_instance).to receive(:setup_safety_metrics) + allow(service_instance).to receive(:setup_supervision) + allow(service_instance).to receive(:setup_extensions) + allow(service_instance).to receive(:setup_generated_functions) + allow(service_instance).to receive(:load_extensions) + allow(service_instance).to receive(:setup_api) + allow(service_instance).to receive(:setup_identity) + allow(service_instance).to receive(:setup_apm) + allow(service_instance).to receive(:setup_network_watchdog) + allow(service_instance).to receive(:register_core_tools) + allow(service_instance).to receive(:setup_alerts) + allow(service_instance).to receive(:setup_metrics) + allow(service_instance).to receive(:setup_task_outcome_observer) + allow(service_instance).to receive(:bootstrap_log_level).and_return(:info) + + process_role = Module.new do + def self.resolve(_) + { transport: false, cache: false, data: false, supervision: false, extensions: false, crypt: true, api: false, llm: false, + gaia: false } + end + + def self.current = :lite + end + stub_const('Legion::ProcessRole', process_role) + + settings_mod = Module.new do + def self.respond_to?(mth, *) = mth == :resolve_secrets! ? true : super + def self.resolve_secrets! = nil + def self.dig(*) = nil + end + settings_mod.define_singleton_method(:[]) { |_k| {} } + settings_mod.define_singleton_method(:[]=) { |_k, _v| nil } + stub_const('Legion::Settings', settings_mod) + + readiness_mod = Module.new do + def self.mark_ready(*) = nil + def self.mark_skipped(*) = nil + end + stub_const('Legion::Readiness', readiness_mod) + + service_instance.send(:initialize, crypt: true) + end + end + + # --------------------------------------------------------------------------- + # §8.1 Boot — setup_identity credential swap + # --------------------------------------------------------------------------- + + describe '#setup_identity_before_llm' do + before do + allow(service).to receive(:require_relative) + allow(service).to receive(:setup_identity) + allow(service).to receive(:handle_exception) + + data = Module.new do + def self.respond_to?(method, *) = method == :connected? ? true : super + def self.connected? = false + end + extensions = Module.new do + def self.respond_to?(method, *) = method == :require_identity_extensions ? true : super + def self.require_identity_extensions = nil + end + + stub_const('Legion::Data', data) + stub_const('Legion::Extensions', extensions) + allow(Legion::Extensions).to receive(:require_identity_extensions) + end + + it 'requires identity extensions and resolves identity before LLM setup can run' do + expect(service).to receive(:require_relative).with('identity').ordered + expect(Legion::Extensions).to receive(:require_identity_extensions).ordered + expect(service).to receive(:setup_identity).ordered + + service.send(:setup_identity_before_llm, extensions: true, transport: true) + end + + it 'does not require identity extensions when extension loading is disabled' do + expect(Legion::Extensions).not_to receive(:require_identity_extensions) + + service.send(:setup_identity_before_llm, extensions: false, transport: true) + + expect(service).to have_received(:setup_identity) + end + end + + describe '#setup_identity — credential swap' do + before do + # Stub identity/process requires + allow(service).to receive(:require_relative) + allow(service).to receive(:resolve_identity_providers).and_return(true) + allow(service).to receive(:handle_exception) + + identity_process = Module.new do + def self.resolved? = true + def self.canonical_name = 'test-node' + def self.queue_prefix = 'agent.test-node' + def self.bind_fallback! = nil + end + stub_const('Legion::Identity::Process', identity_process) + + identity_resolver = Module.new do + def self.resolve! = nil + def self.resolved? = true + end + stub_const('Legion::Identity::Resolver', identity_resolver) + + settings = Module.new do + def self.respond_to?(method, *) = method == :resolve_secrets! ? true : super + def self.resolve_secrets! = nil + def self.dig(*) = nil + end + stub_const('Legion::Settings', settings) + + readiness = Module.new do + def self.mark_ready(*) = nil + end + stub_const('Legion::Readiness', readiness) + + extensions = Module.new do + def self.respond_to?(method, *) = method == :flush_pending_registrations! ? true : super + def self.flush_pending_registrations! = nil + end + stub_const('Legion::Extensions', extensions) + end + + context 'when Vault is connected and dynamic_rmq_creds is enabled' do + before do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: true) + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + end + + it 'calls swap_to_identity_creds with the current mode' do + expect(Legion::Crypt).to receive(:swap_to_identity_creds).with(mode: :agent) + service.setup_identity + end + end + + context 'when vault is not connected' do + before do + crypt = build_crypt_stub(vault_connected: false, dynamic_rmq_creds: true) + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + end + + it 'does not call swap_to_identity_creds' do + expect(Legion::Crypt).not_to receive(:swap_to_identity_creds) + service.setup_identity + end + end + + context 'when dynamic_rmq_creds is false' do + before do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: false) + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + end + + it 'does not call swap_to_identity_creds' do + expect(Legion::Crypt).not_to receive(:swap_to_identity_creds) + service.setup_identity + end + end + + context 'when mode is :lite' do + before do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: true) + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :lite, lite: true)) + end + + it 'does not call swap_to_identity_creds' do + expect(Legion::Crypt).not_to receive(:swap_to_identity_creds) + service.setup_identity + end + end + + context 'when Legion::Crypt is not defined' do + before do + hide_const('Legion::Crypt') + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + end + + it 'does not raise' do + expect { service.setup_identity }.not_to raise_error + end + end + + context 'when swap_to_identity_creds raises a StandardError' do + before do + crypt = Module.new do + def self.vault_connected? = true + def self.dynamic_rmq_creds? = true + def self.swap_to_identity_creds(**) = raise(StandardError, 'reconnect failed') + end + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + end + + it 'rescues and does not propagate' do + expect { service.setup_identity }.not_to raise_error + end + + it 'calls handle_exception with :warn level' do + expect(service).to receive(:handle_exception).at_least(:once) + service.setup_identity + end + end + + context 'when swap_to_identity_creds raises — fallback identity is bound if not resolved' do + before do + allow(service).to receive(:resolve_identity_providers).and_return(false) + + crypt = Module.new do + def self.vault_connected? = true + def self.dynamic_rmq_creds? = true + def self.swap_to_identity_creds(**) = raise(StandardError, 'swap boom') + end + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + + unresolved_process = Module.new do + def self.resolved? = false + def self.canonical_name = 'fallback-node' + def self.queue_prefix = '' + def self.bind_fallback! = nil + end + stub_const('Legion::Identity::Process', unresolved_process) + end + + it 'calls bind_fallback! on the process identity' do + expect(Legion::Identity::Process).to receive(:bind_fallback!).at_least(:once) + service.setup_identity + end + end + + context 'when mode is :worker and dynamic_rmq_creds is enabled' do + before do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: true) + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :worker, lite: false)) + end + + it 'calls swap_to_identity_creds with :worker mode' do + expect(Legion::Crypt).to receive(:swap_to_identity_creds).with(mode: :worker) + service.setup_identity + end + end + + context 'when mode is :infra and dynamic_rmq_creds is enabled' do + before do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: true) + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :infra, lite: false)) + end + + it 'calls swap_to_identity_creds with :infra mode' do + expect(Legion::Crypt).to receive(:swap_to_identity_creds).with(mode: :infra) + service.setup_identity + end + end + + context 'setup_identity does not call flush_pending_registrations!' do + before do + crypt = Module.new do + def self.vault_connected? = true + def self.dynamic_rmq_creds? = true + def self.swap_to_identity_creds(**) = raise(StandardError, 'swap failed') + end + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + end + + it 'does not call flush_pending_registrations! (delegated to reload!)' do + expect(Legion::Extensions).not_to receive(:flush_pending_registrations!) + service.setup_identity + end + end + + context 'when Crypt does not respond to vault_connected?' do + before do + crypt = Module.new # no methods + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + end + + it 'does not raise and does not call swap' do + expect { service.setup_identity }.not_to raise_error + end + end + end + + # --------------------------------------------------------------------------- + # §8.3 Shutdown — revoke_bootstrap_lease + # --------------------------------------------------------------------------- + + describe '#shutdown — revoke_bootstrap_lease' do + # Stub every shutdown dependency to isolate the bootstrap revocation call + + before do + allow(service).to receive(:shutdown_network_watchdog) + allow(service).to receive(:shutdown_audit_archiver) + allow(service).to receive(:shutdown_api) + allow(service).to receive(:shutdown_apm) + # Let shutdown_component yield its block so Legion::Crypt.shutdown is actually called + allow(service).to receive(:shutdown_component) { |_name, &blk| blk&.call } + allow(service).to receive(:teardown_logging_transport) + allow(service).to receive(:shutdown_mtls_rotation) + allow(service).to receive(:handle_exception) + + settings = { + client: { shutting_down: false }, + data: { connected: false }, + llm: { connected: false }, + rbac: { connected: false }, + extensions: { shutdown_timeout: 5 } + } + settings_mod = Module.new do + define_singleton_method(:dig) { |*keys| settings.dig(*keys) } + define_singleton_method(:[]) { |key| settings[key] } + define_singleton_method(:[]=) { |key, value| settings[key] = value } + end + stub_const('Legion::Settings', settings_mod) + + metrics_mod = Module.new { def self.reset! = nil } + stub_const('Legion::Metrics', metrics_mod) + + events_mod = Module.new { def self.emit(*) = nil } + stub_const('Legion::Events', events_mod) + + extensions_mod = Module.new do + def self.respond_to?(method, *) = method == :shutdown ? true : super + def self.shutdown = nil + end + stub_const('Legion::Extensions', extensions_mod) + + transport_conn = Module.new { def self.shutdown = nil } + transport_mod = Module.new { const_set(:Connection, transport_conn) } + stub_const('Legion::Transport', transport_mod) + + cache_mod = Module.new { def self.shutdown = nil } + stub_const('Legion::Cache', cache_mod) + end + + context 'when Crypt responds to revoke_bootstrap_lease' do + before do + crypt = Module.new do + def self.revoke_bootstrap_lease = nil + def self.shutdown = nil + end + stub_const('Legion::Crypt', crypt) + end + + it 'calls revoke_bootstrap_lease before shutting down Crypt' do + expect(Legion::Crypt).to receive(:revoke_bootstrap_lease).ordered + expect(Legion::Crypt).to receive(:shutdown).ordered + service.shutdown + end + end + + context 'when Crypt does not respond to revoke_bootstrap_lease' do + before do + crypt = Module.new { def self.shutdown = nil } + stub_const('Legion::Crypt', crypt) + end + + it 'does not raise' do + expect { service.shutdown }.not_to raise_error + end + end + + context 'when Legion::Crypt is not defined' do + before { hide_const('Legion::Crypt') } + + it 'does not raise' do + expect { service.shutdown }.not_to raise_error + end + end + end + + # --------------------------------------------------------------------------- + # §8.2 Reload — fetch_bootstrap_rmq_creds and resolve_secrets! after Crypt.start + # --------------------------------------------------------------------------- + + describe '#reload — bootstrap fetch and resolve_secrets! after Crypt.start' do + before do + # Stop the guard from early-exiting + service.instance_variable_set(:@reloading, false) + + allow(service).to receive(:shutdown_network_watchdog) + allow(service).to receive(:shutdown_api) + allow(service).to receive(:shutdown_apm) + allow(service).to receive(:shutdown_component) + allow(service).to receive(:teardown_logging_transport) + allow(service).to receive(:setup_transport) + allow(service).to receive(:setup_logging_transport) + allow(service).to receive(:setup_data) + allow(service).to receive(:setup_supervision) + allow(service).to receive(:setup_identity) + allow(service).to receive(:setup_apm) + + # Stub Legion::Identity::Process to prevent double-leak from prior specs + identity_process_stub = Module.new { def self.refresh_credentials = nil } + stub_const('Legion::Identity::Process', identity_process_stub) + + # Stub Legion::Cache used in reload + cache_stub = Module.new { def self.setup = nil } + stub_const('Legion::Cache', cache_stub) + + # Stub Legion::MCP used in reload + mcp_stub = Module.new do + def self.reset! = nil + def self.respond_to?(mth, *) = mth == :server ? true : super + def self.server = nil + end + stub_const('Legion::MCP', mcp_stub) + allow(service).to receive(:setup_api) + allow(service).to receive(:setup_network_watchdog) + allow(service).to receive(:setup_rbac) + allow(service).to receive(:setup_llm) + allow(service).to receive(:setup_apollo) + allow(service).to receive(:setup_gaia) + allow(service).to receive(:load_extensions) + allow(service).to receive(:register_core_tools) + allow(service).to receive(:handle_exception) + + mode_mod = Module.new { def self.lite? = false } + stub_const('Legion::Mode', mode_mod) + + loader_mod = Module.new { def self.default_directories = [] } + settings_mod = Module.new do + def self.load(*) = nil + def self.respond_to?(mth, *) = mth == :resolve_secrets! ? true : super + def self.resolve_secrets! = nil + def self.dig(*) = nil + end + settings_mod.const_set(:Loader, loader_mod) + readiness_mod = Module.new do + def self.mark_ready(*) = nil + def self.mark_not_ready(*) = nil + def self.mark_skipped(*) = nil + def self.wait_until_not_ready(*) = nil + end + events_mod = Module.new do + def self.emit(*) = nil + end + extensions_mod = Module.new do + def self.respond_to?(mth, *) = %i[flush_pending_registrations! shutdown loaded_extension_modules].include?(mth) || super + def self.flush_pending_registrations! = nil + def self.shutdown = nil + def self.loaded_extension_modules = [] + end + tools_mod = Module.new { def self.clear = nil } + embedding_mod = Module.new do + def self.respond_to?(mth, *) = mth == :clear_memory ? true : super + def self.clear_memory = nil + end + + stub_const('Legion::Settings', settings_mod) + stub_const('Legion::Readiness', readiness_mod) + stub_const('Legion::Events', events_mod) + stub_const('Legion::Extensions', extensions_mod) + stub_const('Legion::Tools::Registry', tools_mod) + stub_const('Legion::Tools::EmbeddingCache', embedding_mod) + + settings_hash = { + client: { shutting_down: false, ready: false }, + data: { connected: false }, + llm: { connected: false }, + rbac: { connected: false }, + extensions: { shutdown_timeout: 5 } + } + allow(Legion::Settings).to receive(:[]) { |k| settings_hash[k] } + allow(Legion::Settings).to receive(:[]=) { |k, v| settings_hash[k] = v } + allow(Legion::Settings).to receive(:dig) { |*k| settings_hash.dig(*k) } + end + + context 'when Crypt responds to fetch_bootstrap_rmq_creds and vault is ready' do + before do + crypt = Module.new do + def self.start = nil + def self.cs = nil + def self.shutdown = nil + def self.vault_connected? = true + def self.dynamic_rmq_creds? = true + def self.fetch_bootstrap_rmq_creds = nil + end + stub_const('Legion::Crypt', crypt) + end + + it 'calls fetch_bootstrap_rmq_creds after Crypt.start' do + expect(Legion::Crypt).to receive(:start).ordered + expect(Legion::Crypt).to receive(:fetch_bootstrap_rmq_creds).ordered + service.reload + end + end + + context 'when Crypt does not respond to fetch_bootstrap_rmq_creds' do + before do + crypt = Module.new do + def self.start = nil + def self.cs = nil + def self.shutdown = nil + end + stub_const('Legion::Crypt', crypt) + end + + it 'does not raise' do + expect { service.reload }.not_to raise_error + end + end + + context 'calls resolve_secrets! after Crypt.start during reload' do + before do + crypt = Module.new do + def self.start = nil + def self.cs = nil + def self.shutdown = nil + end + stub_const('Legion::Crypt', crypt) + end + + it 'calls resolve_secrets! during reload' do + expect(Legion::Settings).to receive(:resolve_secrets!) + service.reload + end + end + + context 'calls setup_identity during reload' do + before do + crypt = Module.new do + def self.start = nil + def self.cs = nil + def self.shutdown = nil + end + stub_const('Legion::Crypt', crypt) + end + + it 'calls setup_identity to resolve identity and swap credentials' do + expect(service).to receive(:setup_identity) + service.reload + end + end + end + + # --------------------------------------------------------------------------- + # Guard: swap skipped in full-flag-off scenario + # --------------------------------------------------------------------------- + + describe '#setup_identity — feature flag off (dynamic_rmq_creds: false)' do + before do + allow(service).to receive(:require_relative) + allow(service).to receive(:resolve_identity_providers).and_return(true) + allow(service).to receive(:handle_exception) + + identity_process = Module.new do + def self.resolved? = true + def self.canonical_name = 'test-node' + def self.queue_prefix = 'agent.test-node' + def self.bind_fallback! = nil + end + stub_const('Legion::Identity::Process', identity_process) + + settings = Module.new do + def self.respond_to?(mth, *) = mth == :resolve_secrets! ? true : super + def self.resolve_secrets! = nil + def self.dig(*) = nil + end + stub_const('Legion::Settings', settings) + + readiness = Module.new { def self.mark_ready(*) = nil } + stub_const('Legion::Readiness', readiness) + + extensions = Module.new do + def self.respond_to?(mth, *) = mth == :flush_pending_registrations! ? true : super + def self.flush_pending_registrations! = nil + end + stub_const('Legion::Extensions', extensions) + end + + context 'when dynamic_rmq_creds is false' do + before do + crypt = build_crypt_stub(vault_connected: true, dynamic_rmq_creds: false) + stub_const('Legion::Crypt', crypt) + stub_const('Legion::Mode', build_mode_stub(current: :agent, lite: false)) + end + + it 'preserves static credential behavior — swap_to_identity_creds not called' do + expect(Legion::Crypt).not_to receive(:swap_to_identity_creds) + service.setup_identity + end + + it 'completes without error' do + expect { service.setup_identity }.not_to raise_error + end + end + end +end diff --git a/spec/legion/service_generated_functions_spec.rb b/spec/legion/service_generated_functions_spec.rb new file mode 100644 index 00000000..d1c97ce7 --- /dev/null +++ b/spec/legion/service_generated_functions_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/service' + +RSpec.describe Legion::Service do + describe '#setup_generated_functions' do + let(:service) { described_class.allocate } + + it 'calls GeneratedRegistry.load_on_boot when codegen is available' do + stub_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry', Module.new do + def self.load_on_boot + 0 + end + end) + expect(Legion::Extensions::Codegen::Helpers::GeneratedRegistry).to receive(:load_on_boot).and_return(0) + service.send(:setup_generated_functions) + end + + it 'does nothing when codegen is not loaded' do + expect { service.send(:setup_generated_functions) }.not_to raise_error + end + end +end diff --git a/spec/legion/service_lite_spec.rb b/spec/legion/service_lite_spec.rb new file mode 100644 index 00000000..112234c9 --- /dev/null +++ b/spec/legion/service_lite_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/service' + +RSpec.describe Legion::Service do + describe '#lite_mode?' do + it 'returns true when LEGION_MODE is lite' do + service = described_class.allocate + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('LEGION_MODE').and_return('lite') + expect(service.lite_mode?).to be true + end + + it 'returns true when settings mode is lite' do + service = described_class.allocate + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with('LEGION_MODE').and_return(nil) + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:mode).and_return('lite') + expect(service.lite_mode?).to be true + end + end + + describe '#setup_local_mode' do + it 'marks dev mode through the public settings writer in lite mode' do + service = described_class.allocate + allow(Legion::Mode).to receive(:lite?).and_return(true) + allow(service).to receive(:require).with('legion/transport/local') + allow(service).to receive(:require).with('legion/crypt/mock_vault') + + expect(Legion::Settings).to receive(:set_prop).with(:dev, true) + + service.__send__(:setup_local_mode) + end + end +end diff --git a/spec/legion/service_logging_transport_spec.rb b/spec/legion/service_logging_transport_spec.rb new file mode 100644 index 00000000..2c8d7073 --- /dev/null +++ b/spec/legion/service_logging_transport_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Service#setup_logging_transport' do + let(:service) { Legion::Service.allocate } + + before do + Legion::Logging.log_writer = nil + Legion::Logging.exception_writer = nil + end + + after do + Legion::Logging.log_writer = nil + Legion::Logging.exception_writer = nil + end + + context 'when transport is not connected' do + it 'returns early without wiring writers' do + allow(Legion::Transport::Connection).to receive(:session_open?).and_return(false) + allow(Legion::Transport::Connection).to receive(:create_dedicated_session) + service.send(:setup_logging_transport) + expect(Legion::Transport::Connection).not_to have_received(:create_dedicated_session) + expect(service.instance_variable_get(:@log_session)).to be_nil + end + end + + context 'when transport.enabled is false' do + it 'returns early without wiring writers' do + allow(Legion::Transport::Connection).to receive(:session_open?).and_return(true) + allow(Legion::Settings).to receive(:dig).with(:logging, :transport).and_return({ enabled: false }) + allow(Legion::Transport::Connection).to receive(:create_dedicated_session) + service.send(:setup_logging_transport) + expect(Legion::Transport::Connection).not_to have_received(:create_dedicated_session) + expect(service.instance_variable_get(:@log_session)).to be_nil + end + end + + context 'when transport.enabled is true and both flags are false' do + it 'returns early without creating a session' do + allow(Legion::Transport::Connection).to receive(:session_open?).and_return(true) + allow(Legion::Settings).to receive(:dig).with(:logging, :transport) + .and_return({ enabled: true, forward_logs: false, forward_exceptions: false }) + service.send(:setup_logging_transport) + expect(service.instance_variable_get(:@log_session)).to be_nil + end + end + + context 'when transport.enabled is true with defaults' do + let(:mock_channel) { double('channel', open?: true, prefetch: nil) } + let(:mock_exchange) { double('exchange') } + let(:mock_session) { double('session', create_channel: mock_channel) } + + before do + allow(Legion::Transport::Connection).to receive(:session_open?).and_return(true) + allow(Legion::Settings).to receive(:dig).with(:logging, :transport).and_return({ enabled: true }) + allow(Legion::Transport::Connection).to receive(:create_dedicated_session) + .with(name: 'legion-logging').and_return(mock_session) + allow(mock_channel).to receive(:topic).with('legion.logging', durable: true).and_return(mock_exchange) + end + + it 'wires log_writer to a callable lambda' do + service.send(:setup_logging_transport) + expect(Legion::Logging.log_writer).to respond_to(:call) + expect(Legion::Transport::Connection).to have_received(:create_dedicated_session).with(name: 'legion-logging') + end + + it 'wires exception_writer to a callable lambda' do + service.send(:setup_logging_transport) + expect(Legion::Logging.exception_writer).to respond_to(:call) + end + + it 'stores the dedicated session in @log_session' do + service.send(:setup_logging_transport) + expect(service.instance_variable_get(:@log_session)).to eq(mock_session) + end + + it 'calls prefetch(1) on the log channel' do + expect(mock_channel).to receive(:prefetch).with(1) + service.send(:setup_logging_transport) + end + + it 'publishes via exchange when log_writer is called' do + allow(mock_exchange).to receive(:publish) + service.send(:setup_logging_transport) + Legion::Logging.log_writer.call( + { message: 'test' }, + routing_key: 'legion.logging.log.warn.core.unknown', + headers: { 'x-legion-identity-id' => 'ident-123' }, + properties: { content_type: 'application/json', type: 'log_event' } + ) + expect(mock_exchange).to have_received(:publish).with( + kind_of(String), + routing_key: 'legion.logging.log.warn.core.unknown', + headers: { 'x-legion-identity-id' => 'ident-123' }, + content_type: 'application/json', + type: 'log_event' + ) + end + + it 'publishes via exchange when exception_writer is called' do + allow(mock_exchange).to receive(:publish) + service.send(:setup_logging_transport) + Legion::Logging.exception_writer.call( + { message: 'boom' }, + routing_key: 'legion.logging.exception.error.core.unknown', + headers: { fingerprint: 'abc' }, + properties: { content_type: 'application/json' } + ) + expect(mock_exchange).to have_received(:publish).once + end + + it 'skips log_writer publish when channel is closed' do + allow(mock_channel).to receive(:open?).and_return(false) + allow(mock_exchange).to receive(:publish) + service.send(:setup_logging_transport) + Legion::Logging.log_writer.call({ message: 'test' }, routing_key: 'x') + expect(mock_exchange).not_to have_received(:publish) + end + + it 'does not raise when log_writer publish fails' do + allow(mock_exchange).to receive(:publish).and_raise(StandardError.new('disconnected')) + service.send(:setup_logging_transport) + expect do + Legion::Logging.log_writer.call({ message: 'test' }, routing_key: 'x') + end.not_to raise_error + end + end +end + +RSpec.describe 'Service#teardown_logging_transport' do + let(:service) { Legion::Service.allocate } + + after do + Legion::Logging.log_writer = nil + Legion::Logging.exception_writer = nil + end + + it 'resets log_writer to no-op' do + Legion::Logging.log_writer = ->(_e, _routing_key:) { 'test' } + service.send(:teardown_logging_transport) + expect { Legion::Logging.log_writer.call({}, routing_key: 'x') }.not_to raise_error + end + + it 'resets exception_writer to no-op' do + Legion::Logging.exception_writer = ->(_e, _routing_key:, _headers:, _properties:) { 'test' } + service.send(:teardown_logging_transport) + expect do + Legion::Logging.exception_writer.call({}, routing_key: 'x', headers: {}, properties: {}) + end.not_to raise_error + end + + it 'closes and clears @log_session when open' do + mock_session = double('session', respond_to?: true, open?: true, close: nil) + service.instance_variable_set(:@log_session, mock_session) + service.send(:teardown_logging_transport) + expect(mock_session).to have_received(:close) + expect(service.instance_variable_get(:@log_session)).to be_nil + end + + it 'skips close when session is already closed' do + mock_session = double('session', respond_to?: true, open?: false) + allow(mock_session).to receive(:close) + service.instance_variable_set(:@log_session, mock_session) + service.send(:teardown_logging_transport) + expect(mock_session).not_to have_received(:close) + end + + it 'does not raise when @log_session is nil' do + expect { service.send(:teardown_logging_transport) }.not_to raise_error + end +end diff --git a/spec/legion/service_mtls_spec.rb b/spec/legion/service_mtls_spec.rb new file mode 100644 index 00000000..9990b971 --- /dev/null +++ b/spec/legion/service_mtls_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/service' + +RSpec.describe Legion::Service do + describe '#setup_mtls_rotation' do + let(:service) { described_class.allocate } + + context 'when security.mtls.enabled is false' do + before do + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { mtls: { enabled: false } } + ) + end + + it 'does not start CertRotation' do + cert_rotation_class = double('CertRotationClass') + stub_const('Legion::Crypt::CertRotation', cert_rotation_class) + expect(cert_rotation_class).not_to receive(:new) + service.send(:setup_mtls_rotation) + end + end + + context 'when security.mtls.enabled is true' do + let(:rotation_instance) { double('CertRotation', start: nil, stop: nil) } + let(:cert_rotation_class) { double('CertRotationClass') } + + before do + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:security).and_return( + { mtls: { enabled: true } } + ) + stub_const('Legion::Crypt::CertRotation', cert_rotation_class) + stub_const('Legion::Crypt::Mtls', Module.new) + allow(Legion::Crypt::Mtls).to receive(:enabled?).and_return(true) + allow(cert_rotation_class).to receive(:new).and_return(rotation_instance) + end + + it 'creates and starts CertRotation' do + expect(cert_rotation_class).to receive(:new).and_return(rotation_instance) + expect(rotation_instance).to receive(:start) + service.send(:setup_mtls_rotation) + end + + it 'stores the rotation instance' do + service.send(:setup_mtls_rotation) + expect(service.instance_variable_get(:@cert_rotation)).to eq rotation_instance + end + end + + context 'when security settings are missing entirely' do + before do + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:security).and_return(nil) + end + + it 'does not raise' do + expect { service.send(:setup_mtls_rotation) }.not_to raise_error + end + + it 'does not start CertRotation' do + cert_rotation_class = double('CertRotationClass') + stub_const('Legion::Crypt::CertRotation', cert_rotation_class) + expect(cert_rotation_class).not_to receive(:new) + service.send(:setup_mtls_rotation) + end + end + end + + describe '#shutdown_mtls_rotation' do + let(:service) { described_class.allocate } + + context 'when @cert_rotation is set' do + let(:rotation_instance) { double('CertRotation', stop: nil) } + + before do + service.instance_variable_set(:@cert_rotation, rotation_instance) + end + + it 'calls stop on the rotation instance' do + expect(rotation_instance).to receive(:stop) + service.send(:shutdown_mtls_rotation) + end + + it 'nils out @cert_rotation' do + service.send(:shutdown_mtls_rotation) + expect(service.instance_variable_get(:@cert_rotation)).to be_nil + end + end + + context 'when @cert_rotation is nil' do + it 'does not raise' do + expect { service.send(:shutdown_mtls_rotation) }.not_to raise_error + end + end + end +end diff --git a/spec/legion/service_safety_metrics_spec.rb b/spec/legion/service_safety_metrics_spec.rb new file mode 100644 index 00000000..4022d282 --- /dev/null +++ b/spec/legion/service_safety_metrics_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/service' +require 'legion/telemetry/safety_metrics' + +RSpec.describe Legion::Service do + describe '#setup_safety_metrics' do + let(:service) { described_class.allocate } + + it 'calls SafetyMetrics.start' do + expect(Legion::Telemetry::SafetyMetrics).to receive(:start) + service.send(:setup_safety_metrics) + end + + it 'rescues LoadError gracefully' do + allow(service).to receive(:require_relative).and_raise(LoadError) + expect { service.send(:setup_safety_metrics) }.not_to raise_error + end + end +end diff --git a/spec/legion/service_setup_apollo_spec.rb b/spec/legion/service_setup_apollo_spec.rb new file mode 100644 index 00000000..acdc44b0 --- /dev/null +++ b/spec/legion/service_setup_apollo_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/service' +require 'legion/apollo' + +RSpec.describe Legion::Service do + describe '#setup_apollo' do + let(:service) { described_class.allocate } + + context 'when legion-apollo is installed' do + before do + allow(Legion::Apollo).to receive(:start) + end + + it 'calls Legion::Apollo.start' do + expect(Legion::Apollo).to receive(:start) + service.send(:setup_apollo) + end + + it 'does not raise errors' do + expect { service.send(:setup_apollo) }.not_to raise_error + end + end + + context 'when legion-apollo raises LoadError' do + it 'rescues gracefully' do + allow(service).to receive(:require).and_raise(LoadError, 'cannot load such file') + expect { service.send(:setup_apollo) }.not_to raise_error + end + end + + context 'when legion-apollo raises StandardError' do + it 'rescues gracefully' do + allow(Legion::Apollo).to receive(:start).and_raise(StandardError, 'something went wrong') + expect { service.send(:setup_apollo) }.not_to raise_error + end + end + + context 'when Apollo::Local is available' do + before do + stub_const('Legion::Apollo::Local', Module.new do + extend self + + define_method(:start) { nil } + end) + allow(Legion::Apollo).to receive(:start) + allow(Legion::Apollo::Local).to receive(:start) + end + + it 'starts Apollo::Local' do + service.send(:setup_apollo) + expect(Legion::Apollo::Local).to have_received(:start).once + end + end + end + + describe 'Readiness COMPONENTS' do + it 'includes :apollo between :llm and :gaia' do + components = Legion::Readiness::COMPONENTS + llm_idx = components.index(:llm) + apollo_idx = components.index(:apollo) + gaia_idx = components.index(:gaia) + + expect(apollo_idx).not_to be_nil + expect(apollo_idx).to be > llm_idx + expect(apollo_idx).to be < gaia_idx + end + end +end diff --git a/spec/legion/service_setup_settings_spec.rb b/spec/legion/service_setup_settings_spec.rb new file mode 100644 index 00000000..2186c284 --- /dev/null +++ b/spec/legion/service_setup_settings_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/service' + +RSpec.describe Legion::Service do + describe '#setup_settings' do + let(:service) { described_class.allocate } + + before do + stub_const('Legion::Settings', Class.new do + def self.load(**); end + + def self.loaded? + false + end + end) + stub_const('Legion::Settings::Loader', Class.new do + def self.default_directories + ['/home/test/.legionio/settings', '/etc/legionio/settings'] + end + end) + stub_const('Legion::Readiness', Class.new do + def self.mark_ready(*); end + end) + allow(Legion::Logging).to receive(:info) + allow(service.class).to receive(:log_privacy_mode_status) + end + + it 'loads settings from existing canonical directories' do + allow(Dir).to receive(:exist?).and_return(false) + allow(Dir).to receive(:exist?).with('/etc/legionio/settings').and_return(true) + + expect(Legion::Settings).to receive(:load).with(config_dirs: ['/etc/legionio/settings']) + service.send(:setup_settings) + end + + it 'filters out non-existent directories' do + allow(Dir).to receive(:exist?).and_return(false) + + expect(Legion::Settings).to receive(:load).with(config_dirs: []) + service.send(:setup_settings) + end + + it 'marks settings as ready' do + allow(Dir).to receive(:exist?).and_return(false) + allow(Legion::Settings).to receive(:load) + + expect(Legion::Readiness).to receive(:mark_ready).with(:settings) + service.send(:setup_settings) + end + + it 'skips reload when settings are already loaded' do + allow(Dir).to receive(:exist?).and_return(false) + allow(Legion::Settings).to receive(:loaded?).and_return(true) + + expect(Legion::Settings).not_to receive(:load) + service.send(:setup_settings) + end + end + + describe 'logging level resolution' do + let(:service) { described_class.allocate } + + before do + allow(Legion::Logging).to receive(:setup) + end + + it 'uses configured logging level when no CLI override is provided' do + allow(Legion::Settings).to receive(:[]).with(:logging).and_return({ level: 'info' }) + + expect(service.send(:bootstrap_log_level, nil)).to eq('info') + end + + it 'uses CLI log level when one is provided' do + allow(Legion::Settings).to receive(:[]).with(:logging).and_return({ level: 'info' }) + + expect(service.send(:bootstrap_log_level, 'debug')).to eq('debug') + end + + it 'reconfigures to the settings level when CLI override is nil' do + allow(Legion::Settings).to receive(:[]).with(:logging).and_return( + { + level: 'info', + format: 'text', + log_file: nil, + log_stdout: true, + trace: true, + async: true, + include_pid: false + } + ) + + expect(Legion::Logging).to receive(:setup).with( + level: 'info', + format: :text, + log_file: nil, + log_stdout: true, + trace: true, + async: true, + include_pid: false, + color: true + ) + + service.send(:reconfigure_logging, nil) + end + end +end diff --git a/spec/legion/service_shutdown_spec.rb b/spec/legion/service_shutdown_spec.rb new file mode 100644 index 00000000..515dcda1 --- /dev/null +++ b/spec/legion/service_shutdown_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'timeout' + +RSpec.describe Legion::Service do + let(:service) { described_class.allocate } + + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:warn) + allow(Legion::Logging).to receive(:error) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:emit_tagged) do |level, msg, **| + Legion::Logging.public_send(level, msg) if Legion::Logging.respond_to?(level) + end + allow(Legion::Events).to receive(:emit) + allow(Legion::Settings).to receive(:[]).and_call_original + allow(Legion::Settings).to receive(:[]).with(:client).and_return({ ready: true, shutting_down: false }) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ connected: false }) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ connected: false }) + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ connected: false }) + allow(Legion::Settings).to receive(:[]).with(:llm).and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:rbac).and_return(nil) + allow(Legion::Settings).to receive(:[]).with(:network).and_return(nil) + allow(Legion::Settings).to receive(:dig).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:extensions, :shutdown_timeout).and_return(nil) + end + + describe '#shutdown_component' do + it 'executes the block normally when it completes in time' do + executed = false + service.shutdown_component('Test') { executed = true } + expect(executed).to be true + end + + it 'does not raise when the block times out' do + expect do + service.shutdown_component('Test', timeout: 0.1) { sleep 5 } + end.not_to raise_error + end + + it 'logs a warning when the block times out' do + service.shutdown_component('Test', timeout: 0.1) { sleep 5 } + expect(Legion::Logging).to have_received(:warn).with(/Test shutdown timed out/) + end + + it 'completes within the timeout even if the block hangs' do + start = Time.now + service.shutdown_component('Test', timeout: 0.5) { sleep 60 } + elapsed = Time.now - start + expect(elapsed).to be < 2.0 + end + + it 'rescues StandardError from the block' do + expect do + service.shutdown_component('Test') { raise 'boom' } + end.not_to raise_error + end + + it 'logs a warning on StandardError' do + allow(service).to receive(:handle_exception) + service.shutdown_component('Test') { raise 'boom' } + expect(service).to have_received(:handle_exception).with( + instance_of(RuntimeError), + level: :warn, + operation: 'service.shutdown_component', + component: 'Test', + timeout: 5 + ) + end + end + + describe '#shutdown' do + before do + allow(service).to receive(:shutdown_network_watchdog) + allow(service).to receive(:shutdown_audit_archiver) + allow(service).to receive(:shutdown_api) + allow(service).to receive(:shutdown_mtls_rotation) + allow(Legion::Readiness).to receive(:mark_not_ready) + + # Stub identity broker shutdown to avoid leaked-double errors + broker_mod = Module.new + stub_const('Legion::Identity::Broker', broker_mod) + allow(broker_mod).to receive(:shutdown) + + # Stub extensions shutdown + allow(Legion::Extensions).to receive(:shutdown) + + # Stub cache shutdown + cache_mod = Module.new + stub_const('Legion::Cache', cache_mod) + allow(cache_mod).to receive(:shutdown) + + # Stub transport shutdown + transport_conn = Module.new + stub_const('Legion::Transport::Connection', transport_conn) + allow(transport_conn).to receive(:shutdown) + + # Stub crypt shutdown + crypt_mod = Module.new + stub_const('Legion::Crypt', crypt_mod) + allow(crypt_mod).to receive(:shutdown) + end + + it 'shuts down the network watchdog first' do + service.shutdown + expect(service).to have_received(:shutdown_network_watchdog) + end + + it 'wraps each component shutdown in a timeout' do + allow(Legion::Extensions).to receive(:shutdown).and_raise(Timeout::Error) + + start = Time.now + service.shutdown + elapsed = Time.now - start + + expect(elapsed).to be < 2.0 + end + + it 'continues shutting down other components when one times out' do + allow(Legion::Extensions).to receive(:shutdown).and_raise(Timeout::Error) + + service.shutdown + + expect(Legion::Cache).to have_received(:shutdown) + expect(Legion::Transport::Connection).to have_received(:shutdown) + expect(Legion::Crypt).to have_received(:shutdown) + end + end + + describe '#setup_network_watchdog' do + it 'does nothing when watchdog is not enabled' do + allow(Legion::Settings).to receive(:dig).with(:network, :watchdog, :enabled).and_return(nil) + + service.setup_network_watchdog + expect(service.instance_variable_get(:@network_watchdog)).to be_nil + end + + it 'creates a timer task when enabled' do + allow(Legion::Settings).to receive(:dig).with(:network, :watchdog, :enabled).and_return(true) + allow(Legion::Settings).to receive(:dig).with(:network, :watchdog, :failure_threshold).and_return(3) + allow(Legion::Settings).to receive(:dig).with(:network, :watchdog, :check_interval).and_return(60) + allow(service).to receive(:network_healthy?).and_return(true) + + service.setup_network_watchdog + watchdog = service.instance_variable_get(:@network_watchdog) + expect(watchdog).to be_a(Concurrent::TimerTask) + + # Clean up + watchdog.shutdown + end + end + + describe '#shutdown_network_watchdog' do + it 'shuts down the watchdog timer if running' do + timer = instance_double(Concurrent::TimerTask) + allow(timer).to receive(:shutdown) + service.instance_variable_set(:@network_watchdog, timer) + + service.shutdown_network_watchdog + + expect(timer).to have_received(:shutdown) + expect(service.instance_variable_get(:@network_watchdog)).to be_nil + end + + it 'does nothing when no watchdog is running' do + expect { service.shutdown_network_watchdog }.not_to raise_error + end + end + + describe '#network_healthy?' do + before do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ connected: false }) + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ connected: false }) + allow(Legion::Settings).to receive(:[]).with(:cache).and_return({ connected: false }) + end + + it 'returns true in lite mode' do + transport_conn = Module.new + stub_const('Legion::Transport::Connection', transport_conn) + allow(transport_conn).to receive(:lite_mode?).and_return(true) + + expect(service.network_healthy?).to be true + end + + it 'returns true when no backends are configured for checking' do + expect(service.network_healthy?).to be true + end + + it 'returns true when transport is connected and session is open' do + allow(Legion::Settings).to receive(:[]).with(:transport).and_return({ connected: true }) + transport_conn = Module.new + stub_const('Legion::Transport::Connection', transport_conn) + allow(transport_conn).to receive(:lite_mode?).and_return(false) + allow(transport_conn).to receive(:session_open?).and_return(true) + + expect(service.network_healthy?).to be true + end + + it 'returns false on exception' do + allow(Legion::Settings).to receive(:[]).with(:transport).and_raise(StandardError, 'gone') + + expect(service.network_healthy?).to be false + end + end +end diff --git a/spec/legion/service_spec.rb b/spec/legion/service_spec.rb new file mode 100644 index 00000000..2efe7595 --- /dev/null +++ b/spec/legion/service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/service' + +RSpec.describe Legion::Service do + describe '#setup_generated_functions' do + subject(:service) { described_class.allocate } + + context 'when GeneratedRegistry is defined' do + before do + registry = Module.new do + def self.load_on_boot + 3 + end + end + stub_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry', registry) + end + + it 'calls load_on_boot' do + expect(Legion::Extensions::Codegen::Helpers::GeneratedRegistry).to receive(:load_on_boot).and_return(3) + service.setup_generated_functions + end + + it 'returns without error when load_on_boot returns zero' do + allow(Legion::Extensions::Codegen::Helpers::GeneratedRegistry).to receive(:load_on_boot).and_return(0) + expect { service.setup_generated_functions }.not_to raise_error + end + end + + context 'when GeneratedRegistry is not defined' do + before { hide_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry') } + + it 'returns without error' do + expect { service.setup_generated_functions }.not_to raise_error + end + end + + context 'when load_on_boot raises an error' do + before do + registry = Module.new do + def self.load_on_boot + raise StandardError, 'database unavailable' + end + end + stub_const('Legion::Extensions::Codegen::Helpers::GeneratedRegistry', registry) + end + + it 'rescues the error and does not propagate' do + expect { service.setup_generated_functions }.not_to raise_error + end + end + end +end diff --git a/spec/legion/task_outcome_observer_spec.rb b/spec/legion/task_outcome_observer_spec.rb new file mode 100644 index 00000000..00e07ae7 --- /dev/null +++ b/spec/legion/task_outcome_observer_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/task_outcome_observer' + +RSpec.describe Legion::TaskOutcomeObserver do + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + # Clear event handlers between tests + Legion::Events.instance_variable_set(:@listeners, Hash.new { |h, k| h[k] = [] }) + described_class.instance_variable_set(:@meta_learning_client, nil) + described_class.instance_variable_set(:@learning_domain_map, nil) + end + + describe '.setup' do + it 'registers event handlers for task.completed and task.failed' do + described_class.setup + listeners = Legion::Events.instance_variable_get(:@listeners) + expect(listeners['task.completed']).not_to be_empty + expect(listeners['task.failed']).not_to be_empty + end + end + + describe '.enabled?' do + it 'returns true by default' do + allow(Legion::Settings).to receive(:dig).with(:task_outcome_observer).and_return(nil) + expect(described_class.enabled?).to be true + end + + it 'returns false when disabled in settings' do + allow(Legion::Settings).to receive(:[]).with(:task_outcome_observer).and_return({ enabled: false }) + expect(described_class.enabled?).to be false + end + end + + describe 'event handling' do + before { described_class.setup } + + it 'handles task.completed events' do + payload = { task_id: 'abc', runner_class: 'Legion::Extensions::Node::Runners::Node', function: 'heartbeat' } + expect { Legion::Events.emit('task.completed', **payload) }.not_to raise_error + end + + it 'handles task.failed events' do + payload = { task_id: 'def', runner_class: 'Legion::Extensions::Github::Runners::Issues', function: 'create' } + expect { Legion::Events.emit('task.failed', **payload) }.not_to raise_error + end + + it 'ignores internal runner completions without task ids' do + client = instance_double('meta_client', create_learning_domain: { id: 'dom-123' }, record_learning_episode: true) + client_class = Class.new + allow(client_class).to receive(:new).and_return(client) + stub_const('Legion::Extensions::Agentic::Learning::MetaLearning::Client', client_class) + stub_const('Legion::Apollo', Module.new { def self.ingest(**) = nil }) + expect(Legion::Apollo).not_to receive(:ingest) + + payload = { runner_class: 'Legion::Extensions::Mesh::Runners::Mesh', function: 'publish_gossip' } + Legion::Events.emit('task.completed', **payload) + + expect(client).not_to have_received(:create_learning_domain) + expect(client).not_to have_received(:record_learning_episode) + end + end + + describe '.derive_domain' do + it 'extracts snake_case domain from class name' do + expect(described_class.send(:derive_domain, 'Legion::Extensions::Node::Runners::Node')).to eq('node') + end + + it 'handles camelCase runner names' do + expect(described_class.send(:derive_domain, 'Legion::Extensions::Github::Runners::PullRequests')).to eq('pull_requests') + end + end + + describe '.record_learning' do + it 'does not raise when MetaLearning is not defined' do + expect { described_class.send(:record_learning, domain: 'test', success: true) }.not_to raise_error + end + + it 'uses the meta learning client when available' do + client = instance_double('meta_client', create_learning_domain: { id: 'dom-123' }, record_learning_episode: true) + client_class = Class.new + allow(client_class).to receive(:new).and_return(client) + stub_const('Legion::Extensions::Agentic::Learning::MetaLearning::Client', client_class) + + described_class.send(:record_learning, domain: 'test', success: true) + + expect(client).to have_received(:create_learning_domain).with(name: 'test') + expect(client).to have_received(:record_learning_episode).with(domain_id: 'dom-123', success: true) + end + end + + describe '.publish_lesson' do + it 'does not raise when Apollo is not defined' do + hide_const('Legion::Apollo') if defined?(Legion::Apollo) + expect do + described_class.send(:publish_lesson, runner: 'Test', function: 'run', success: true) + end.not_to raise_error + end + + it 'calls Apollo.ingest when available' do + stub_const('Legion::Apollo', Module.new do + def self.respond_to?(name, *) + name == :ingest ? true : super + end + + def self.ingest(**) = nil + end) + + expect(Legion::Apollo).to receive(:ingest).with(hash_including( + knowledge_domain: 'operational', + source_agent: 'system:task_observer' + )) + described_class.send(:publish_lesson, runner: 'Test::Runners::Foo', function: 'bar', success: true) + end + end + + describe '.setup_llm_reflection_hook' do + it 'does not raise when LLM is not defined' do + hide_const('Legion::LLM') if defined?(Legion::LLM) + expect { described_class.send(:setup_llm_reflection_hook) }.not_to raise_error + end + end +end diff --git a/spec/legion/team/cost_attribution_spec.rb b/spec/legion/team/cost_attribution_spec.rb new file mode 100644 index 00000000..dfccd0d9 --- /dev/null +++ b/spec/legion/team/cost_attribution_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/team' + +RSpec.describe Legion::Team::CostAttribution do + before do + allow(Legion::Settings).to receive(:dig).and_call_original + allow(Legion::Team).to receive(:current).and_return('engineering') + end + + describe '.tag' do + it 'merges team and user into metadata' do + allow(Legion::Settings).to receive(:dig).with(:team, :user).and_return('alice') + result = described_class.tag(request_id: 'abc') + expect(result[:team]).to eq('engineering') + expect(result[:user]).to eq('alice') + expect(result[:request_id]).to eq('abc') + end + + it 'falls back to ENV USER when settings has no user' do + allow(Legion::Settings).to receive(:dig).with(:team, :user).and_return(nil) + allow(ENV).to receive(:fetch).with('USER', nil).and_return('sysuser') + result = described_class.tag + expect(result[:user]).to eq('sysuser') + end + + it 'works with empty metadata' do + allow(Legion::Settings).to receive(:dig).with(:team, :user).and_return('bob') + result = described_class.tag + expect(result).to have_key(:team) + expect(result).to have_key(:user) + end + + it 'does not mutate the original metadata hash' do + allow(Legion::Settings).to receive(:dig).with(:team, :user).and_return('carol') + original = { key: 'value' } + described_class.tag(original) + expect(original).to eq({ key: 'value' }) + end + end +end diff --git a/spec/legion/team_spec.rb b/spec/legion/team_spec.rb new file mode 100644 index 00000000..20446d5f --- /dev/null +++ b/spec/legion/team_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/team' + +RSpec.describe Legion::Team do + before do + allow(Legion::Settings).to receive(:dig).and_call_original + end + + describe '.current' do + it 'returns the team name from settings' do + allow(Legion::Settings).to receive(:dig).with(:team, :name).and_return('engineering') + expect(described_class.current).to eq('engineering') + end + + it 'returns "default" when settings has no team name' do + allow(Legion::Settings).to receive(:dig).with(:team, :name).and_return(nil) + expect(described_class.current).to eq('default') + end + end + + describe '.members' do + it 'returns the members array from settings' do + allow(Legion::Settings).to receive(:dig).with(:team, :members).and_return(%w[alice bob]) + expect(described_class.members).to eq(%w[alice bob]) + end + + it 'returns an empty array when settings has no members' do + allow(Legion::Settings).to receive(:dig).with(:team, :members).and_return(nil) + expect(described_class.members).to eq([]) + end + end + + describe '.find' do + it 'returns team data by symbol key' do + teams = { engineering: { name: 'engineering', members: ['alice'] } } + allow(Legion::Settings).to receive(:[]).with(:teams).and_return(teams) + expect(described_class.find('engineering')).to eq({ name: 'engineering', members: ['alice'] }) + end + + it 'returns nil when team does not exist' do + allow(Legion::Settings).to receive(:[]).with(:teams).and_return({}) + expect(described_class.find('unknown')).to be_nil + end + + it 'returns nil when no teams are configured' do + allow(Legion::Settings).to receive(:[]).with(:teams).and_return(nil) + expect(described_class.find('anything')).to be_nil + end + end + + describe '.list' do + it 'returns team names as strings' do + teams = { engineering: {}, ops: {} } + allow(Legion::Settings).to receive(:[]).with(:teams).and_return(teams) + expect(described_class.list).to contain_exactly('engineering', 'ops') + end + + it 'returns an empty array when no teams are configured' do + allow(Legion::Settings).to receive(:[]).with(:teams).and_return(nil) + expect(described_class.list).to eq([]) + end + + it 'returns an empty array when teams hash is empty' do + allow(Legion::Settings).to receive(:[]).with(:teams).and_return({}) + expect(described_class.list).to eq([]) + end + end +end diff --git a/spec/legion/telemetry/open_inference_spec.rb b/spec/legion/telemetry/open_inference_spec.rb new file mode 100644 index 00000000..7816260e --- /dev/null +++ b/spec/legion/telemetry/open_inference_spec.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/telemetry' +require 'legion/telemetry/open_inference' + +RSpec.describe Legion::Telemetry::OpenInference do + before do + allow(Legion::Telemetry).to receive(:enabled?).and_return(false) + end + + describe '.llm_span' do + it 'yields when telemetry is disabled' do + result = described_class.llm_span(model: 'claude-sonnet-4-20250514') { 42 } + expect(result).to eq(42) + end + + it 'passes correct attributes when telemetry is enabled' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.llm_span(model: 'gpt-4o', provider: 'openai') { :ok } + expect(attrs['openinference.span.kind']).to eq('LLM') + expect(attrs['llm.model_name']).to eq('gpt-4o') + expect(attrs['llm.provider']).to eq('openai') + end + + it 'includes GenAI semantic convention attributes' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.llm_span(model: 'claude-sonnet-4-20250514', provider: 'anthropic') { :ok } + expect(attrs['gen_ai.request.model']).to eq('claude-sonnet-4-20250514') + expect(attrs['gen_ai.system']).to eq('anthropic') + end + end + + describe '.embedding_span' do + it 'sets EMBEDDING span kind' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.embedding_span(model: 'text-embedding-3-small') { :ok } + expect(attrs['openinference.span.kind']).to eq('EMBEDDING') + end + + it 'includes GenAI attributes for embeddings' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.embedding_span(model: 'text-embedding-3-small') { :ok } + expect(attrs['gen_ai.request.model']).to eq('text-embedding-3-small') + expect(attrs['gen_ai.system']).to eq('embedding') + end + end + + describe '.annotate_llm_result' do + let(:span) { double('span', set_attribute: nil) } + + before { allow(span).to receive(:respond_to?).with(:set_attribute).and_return(true) } + + it 'sets GenAI usage attributes' do + result = { input_tokens: 100, output_tokens: 50, stop_reason: 'end_turn', model: 'claude-sonnet-4-20250514' } + described_class.annotate_llm_result(span, result) + + expect(span).to have_received(:set_attribute).with('gen_ai.usage.input_tokens', 100) + expect(span).to have_received(:set_attribute).with('gen_ai.usage.output_tokens', 50) + expect(span).to have_received(:set_attribute).with('gen_ai.response.finish_reason', 'end_turn') + expect(span).to have_received(:set_attribute).with('gen_ai.response.model', 'claude-sonnet-4-20250514') + end + + it 'preserves OpenInference attributes alongside GenAI' do + result = { input_tokens: 100, output_tokens: 50 } + described_class.annotate_llm_result(span, result) + + expect(span).to have_received(:set_attribute).with('llm.token_count.prompt', 100) + expect(span).to have_received(:set_attribute).with('llm.token_count.completion', 50) + expect(span).to have_received(:set_attribute).with('gen_ai.usage.input_tokens', 100) + expect(span).to have_received(:set_attribute).with('gen_ai.usage.output_tokens', 50) + end + end + + describe '.genai_attrs' do + it 'returns model attribute' do + result = described_class.genai_attrs(model: 'gpt-4o') + expect(result['gen_ai.request.model']).to eq('gpt-4o') + end + + it 'includes system when provider given' do + result = described_class.genai_attrs(model: 'gpt-4o', provider: 'openai') + expect(result['gen_ai.system']).to eq('openai') + end + + it 'omits system when provider is nil' do + result = described_class.genai_attrs(model: 'gpt-4o') + expect(result).not_to have_key('gen_ai.system') + end + end + + describe '.tool_span' do + it 'sets TOOL span kind with tool name' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.tool_span(name: 'lex-github.issues.create', parameters: { repo: 'test' }) { :ok } + expect(attrs['openinference.span.kind']).to eq('TOOL') + expect(attrs['tool.name']).to eq('lex-github.issues.create') + end + end + + describe '.chain_span' do + it 'sets CHAIN span kind' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.chain_span(type: 'task_chain') { :ok } + expect(attrs['openinference.span.kind']).to eq('CHAIN') + end + end + + describe '.evaluator_span' do + it 'sets EVALUATOR span kind' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.evaluator_span(template: 'hallucination') { { score: 0.9, passed: true } } + expect(attrs['openinference.span.kind']).to eq('EVALUATOR') + expect(attrs['eval.template']).to eq('hallucination') + end + end + + describe '.agent_span' do + it 'sets AGENT span kind' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.agent_span(name: 'worker-1', mode: :full_active) { :ok } + expect(attrs['openinference.span.kind']).to eq('AGENT') + expect(attrs['agent.name']).to eq('worker-1') + end + end + + describe '.retriever_span' do + it 'yields when telemetry is disabled' do + result = described_class.retriever_span(name: 'apollo-local') { 42 } + expect(result).to eq(42) + end + + it 'sets RETRIEVER span kind with name and optional attributes' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.retriever_span(name: 'apollo-local', query: 'what is legion?', top_k: 5) { :ok } + expect(attrs['openinference.span.kind']).to eq('RETRIEVER') + expect(attrs['retriever.name']).to eq('apollo-local') + expect(attrs['retriever.top_k']).to eq(5) + end + end + + describe '.reranker_span' do + it 'yields when telemetry is disabled' do + result = described_class.reranker_span(model: 'cross-encoder') { 42 } + expect(result).to eq(42) + end + + it 'sets RERANKER span kind with model and optional attributes' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.reranker_span(model: 'cross-encoder', query: 'test query', top_k: 3) { :ok } + expect(attrs['openinference.span.kind']).to eq('RERANKER') + expect(attrs['reranker.model_name']).to eq('cross-encoder') + expect(attrs['reranker.top_k']).to eq(3) + end + end + + describe '.guardrail_span' do + it 'yields when telemetry is disabled' do + result = described_class.guardrail_span(name: 'pii-filter') { 42 } + expect(result).to eq(42) + end + + it 'sets GUARDRAIL span kind with name' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + attrs = nil + allow(Legion::Telemetry).to receive(:with_span) do |_name, **kwargs, &block| + attrs = kwargs[:attributes] + block.call(nil) + end + + described_class.guardrail_span(name: 'pii-filter', input: 'some text') { { passed: true, score: 0.95 } } + expect(attrs['openinference.span.kind']).to eq('GUARDRAIL') + expect(attrs['guardrail.name']).to eq('pii-filter') + end + + it 'records score of 0 via nil check' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(true) + recorded_score = :not_set + fake_span = double('span') + allow(fake_span).to receive(:respond_to?).with(:set_attribute).and_return(true) + allow(fake_span).to receive(:set_attribute) do |key, val| + recorded_score = val if key == 'guardrail.score' + end + allow(Legion::Telemetry).to receive(:with_span) do |_name, **_kwargs, &block| + block.call(fake_span) + end + + described_class.guardrail_span(name: 'pii-filter') { { passed: false, score: 0 } } + expect(recorded_score).to eq(0) + end + end + + describe '.truncate_value' do + it 'truncates strings longer than limit' do + long = 'a' * 5000 + result = described_class.truncate_value(long, max: 4096) + expect(result.length).to eq(4096) + end + + it 'passes short strings through' do + expect(described_class.truncate_value('hello', max: 4096)).to eq('hello') + end + end + + describe '.open_inference_enabled?' do + it 'returns false when telemetry is disabled' do + allow(Legion::Telemetry).to receive(:enabled?).and_return(false) + expect(described_class.open_inference_enabled?).to be false + end + end +end diff --git a/spec/legion/telemetry/safety_metrics_spec.rb b/spec/legion/telemetry/safety_metrics_spec.rb new file mode 100644 index 00000000..69e02a68 --- /dev/null +++ b/spec/legion/telemetry/safety_metrics_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/telemetry/safety_metrics' + +RSpec.describe Legion::Telemetry::SafetyMetrics do + describe Legion::Telemetry::SlidingWindow do + let(:window) { described_class.new(60) } + + it 'counts entries within window' do + 3.times { window.push(agent: 'a') } + expect(window.count_for(agent: 'a')).to eq(3) + end + + it 'filters by agent' do + 2.times { window.push(agent: 'a') } + window.push(agent: 'b') + expect(window.count_for(agent: 'a')).to eq(2) + expect(window.count_for(agent: 'b')).to eq(1) + end + + it 'expires old entries' do + window.push(agent: 'a') + window.instance_variable_get(:@entries) << { agent: 'a', at: Time.now - 120 } + expect(window.count_for(agent: 'a')).to eq(1) + end + + it 'returns ratio' do + 5.times { window.push(type: :success) } + 2.times { window.push(type: :failure) } + total = window.count + failures = window.count_for(type: :failure) + expect(failures.to_f / total).to be_within(0.01).of(0.285) + end + end + + describe '.record_action' do + before do + described_class.instance_variable_set(:@windows, nil) + described_class.init_windows + end + + it 'increments action counter' do + described_class.record_action(agent_id: 'worker-1') + expect(described_class.actions_per_minute('worker-1')).to eq(1) + end + end + + describe '.tool_failure_ratio' do + before do + described_class.instance_variable_set(:@windows, nil) + described_class.init_windows + end + + it 'computes failure ratio' do + 8.times { described_class.record_success(agent_id: 'w1') } + 2.times { described_class.record_failure(agent_id: 'w1') } + expect(described_class.tool_failure_ratio('w1')).to be_within(0.01).of(0.2) + end + + it 'returns 0.0 when no events' do + expect(described_class.tool_failure_ratio('w1')).to eq(0.0) + end + end + + describe '.confidence_drift' do + before do + described_class.instance_variable_set(:@windows, nil) + described_class.init_windows + end + + it 'computes average delta' do + described_class.record_confidence(agent_id: 'w1', delta: -0.05) + described_class.record_confidence(agent_id: 'w1', delta: -0.03) + expect(described_class.confidence_drift('w1')).to be_within(0.001).of(-0.04) + end + end +end diff --git a/spec/legion/telemetry_spec.rb b/spec/legion/telemetry_spec.rb new file mode 100644 index 00000000..a72273a8 --- /dev/null +++ b/spec/legion/telemetry_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/telemetry' + +RSpec.describe Legion::Telemetry do + describe '.configure_exporter' do + before do + allow(Legion::Settings).to receive(:[]).and_call_original + end + + it 'returns nil for :none backend' do + allow(Legion::Settings).to receive(:[]).with(:telemetry).and_return({ tracing: { exporter: :none } }) + expect(described_class.configure_exporter).to be_nil + end + + it 'returns nil when telemetry settings are empty' do + allow(Legion::Settings).to receive(:[]).with(:telemetry).and_return({}) + expect(described_class.configure_exporter).to be_nil + end + + it 'handles missing otlp gem gracefully' do + allow(Legion::Settings).to receive(:[]).with(:telemetry).and_return({ tracing: { exporter: :otlp } }) + allow(described_class).to receive(:require).with('opentelemetry-exporter-otlp').and_raise(LoadError) + expect(described_class.configure_exporter).to be false + end + end + + describe '.tracing_settings' do + before do + allow(Legion::Settings).to receive(:[]).and_call_original + end + + it 'returns tracing hash from settings' do + allow(Legion::Settings).to receive(:[]).with(:telemetry).and_return({ tracing: { exporter: :otlp } }) + expect(described_class.tracing_settings).to eq({ exporter: :otlp }) + end + + it 'returns empty hash when telemetry not configured' do + allow(Legion::Settings).to receive(:[]).with(:telemetry).and_return(nil) + expect(described_class.tracing_settings).to eq({}) + end + end +end diff --git a/spec/legion/tenant_context_spec.rb b/spec/legion/tenant_context_spec.rb new file mode 100644 index 00000000..681478ab --- /dev/null +++ b/spec/legion/tenant_context_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/tenant_context' + +RSpec.describe Legion::TenantContext do + after { described_class.clear } + + describe '.set and .current' do + it 'stores and retrieves tenant_id' do + described_class.set('askid-123') + expect(described_class.current).to eq('askid-123') + end + + it 'returns nil when not set' do + expect(described_class.current).to be_nil + end + end + + describe '.with' do + it 'sets context for the block and restores after' do + described_class.set('original') + described_class.with('temporary') do + expect(described_class.current).to eq('temporary') + end + expect(described_class.current).to eq('original') + end + + it 'restores on exception' do + described_class.set('original') + begin + described_class.with('temp') { raise 'oops' } + rescue RuntimeError + nil + end + expect(described_class.current).to eq('original') + end + end + + describe '.clear' do + it 'removes tenant context' do + described_class.set('askid-123') + described_class.clear + expect(described_class.current).to be_nil + end + end +end diff --git a/spec/legion/tenants_spec.rb b/spec/legion/tenants_spec.rb new file mode 100644 index 00000000..2672a526 --- /dev/null +++ b/spec/legion/tenants_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/tenants' + +RSpec.describe Legion::Tenants do + let(:tenants_ds) { double('tenants_dataset') } + let(:workers_ds) { double('workers_dataset') } + let(:conn) { double('connection') } + + before do + allow(Legion::Data).to receive(:connection).and_return(conn) + allow(conn).to receive(:[]).with(:tenants).and_return(tenants_ds) + allow(conn).to receive(:[]).with(:digital_workers).and_return(workers_ds) + end + + describe '.create' do + it 'creates a tenant record' do + allow(tenants_ds).to receive(:where).and_return(double(first: nil)) + allow(tenants_ds).to receive(:insert) + result = described_class.create(tenant_id: 'askid-001', name: 'Test Tenant') + expect(result[:created]).to be true + end + + it 'rejects duplicate tenant' do + allow(tenants_ds).to receive(:where).and_return(double(first: { tenant_id: 'askid-001' })) + result = described_class.create(tenant_id: 'askid-001') + expect(result[:error]).to eq('tenant_exists') + end + end + + describe '.find' do + it 'returns tenant by id' do + allow(tenants_ds).to receive(:where).with(tenant_id: 'askid-001').and_return(double(first: { tenant_id: 'askid-001' })) + expect(described_class.find('askid-001')).not_to be_nil + end + end + + describe '.check_quota' do + it 'allows when under limit' do + allow(tenants_ds).to receive(:where).and_return(double(first: { max_workers: 5 })) + allow(workers_ds).to receive(:where).and_return(double(count: 2)) + result = described_class.check_quota(tenant_id: 'askid-001', resource: :workers) + expect(result[:allowed]).to be true + end + + it 'blocks when at limit' do + allow(tenants_ds).to receive(:where).and_return(double(first: { max_workers: 5 })) + allow(workers_ds).to receive(:where).and_return(double(count: 5)) + result = described_class.check_quota(tenant_id: 'askid-001', resource: :workers) + expect(result[:allowed]).to be false + end + end +end diff --git a/spec/legion/tools/base_spec.rb b/spec/legion/tools/base_spec.rb new file mode 100644 index 00000000..8ba72cd7 --- /dev/null +++ b/spec/legion/tools/base_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Tools::Base do + let(:tool_class) do + Class.new(described_class) do + tool_name 'test.example' + description 'A test tool' + input_schema( + properties: { + name: { type: 'string', description: 'Name' } + }, + required: ['name'] + ) + + class << self + def call(name:) + text_response({ greeting: "hello #{name}" }) + end + end + end + end + + let(:deferred_tool) do + Class.new(described_class) do + tool_name 'test.deferred' + description 'A deferred tool' + deferred true + end + end + + describe 'DSL' do + it 'stores tool_name' do + expect(tool_class.tool_name).to eq('test.example') + end + + it 'stores description' do + expect(tool_class.description).to eq('A test tool') + end + + it 'stores input_schema' do + expect(tool_class.input_schema).to include(:properties) + end + + it 'defaults deferred to false' do + expect(tool_class.deferred?).to be false + end + + it 'allows deferred override' do + expect(deferred_tool.deferred?).to be true + end + end + + describe '.text_response' do + it 'wraps data in content array' do + result = tool_class.text_response({ key: 'val' }) + expect(result[:content]).to be_an(Array) + expect(result[:content].first[:type]).to eq('text') + end + + it 'passes strings through directly' do + result = tool_class.text_response('raw text') + expect(result[:content].first[:text]).to eq('raw text') + end + end + + describe '.error_response' do + it 'wraps error with error flag' do + result = tool_class.error_response('broke') + expect(result[:error]).to be true + end + end + + describe '.trigger_words' do + let(:tool_class) { Class.new(described_class) } + + it 'defaults to an empty array' do + expect(tool_class.trigger_words).to eq([]) + end + + it 'stores and returns trigger words' do + tool_class.trigger_words(%w[git github gh]) + expect(tool_class.trigger_words).to eq(%w[git github gh]) + end + end + + describe '.sticky' do + let(:tool_class) { Class.new(described_class) } + + it 'defaults to true when never set' do + expect(tool_class.sticky).to eq(true) + end + + it 'returns false when set to false' do + tool_class.sticky(false) + expect(tool_class.sticky).to eq(false) + end + + it 'returns true when set to true' do + tool_class.sticky(true) + expect(tool_class.sticky).to eq(true) + end + + it 'is a no-op read when called with nil' do + tool_class.sticky(false) + tool_class.sticky(nil) # should NOT reset to true + expect(tool_class.sticky).to eq(false) + end + end + + describe '.call' do + it 'raises NotImplementedError on base class' do + expect { described_class.call }.to raise_error(NotImplementedError) + end + + it 'executes subclass implementation' do + result = tool_class.call(name: 'world') + expect(result[:content].first[:text]).to include('hello world') + end + end +end diff --git a/spec/legion/tools/discovery_spec.rb b/spec/legion/tools/discovery_spec.rb new file mode 100644 index 00000000..afbd1f5f --- /dev/null +++ b/spec/legion/tools/discovery_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Tools::Discovery do + before { Legion::Tools::Registry.clear } + + let(:mock_runner) do + Module.new do + def self.name + 'Legion::Extensions::TestExt::Runners::MyRunner' + end + + def self.settings + { + functions: { + do_thing: { desc: 'Does a thing', options: { properties: { id: { type: 'string' } } } } + } + } + end + + def self.do_thing(**_params) + { result: 'ok' } + end + end + end + + let(:mock_extension) do + mod = Module.new do + def self.name + 'Legion::Extensions::TestExt' + end + + def self.mcp_tools? + true + end + + def self.mcp_tools_deferred? + false + end + end + + runner = mock_runner + mod.define_singleton_method(:runner_modules) { [runner] } + mod + end + + describe '.discover_and_register' do + before do + allow(Legion::Extensions).to receive(:loaded_extension_modules).and_return([mock_extension]) + end + + it 'registers discovered tools into Registry' do + described_class.discover_and_register + expect(Legion::Tools::Registry.all_tools.size).to eq(1) + end + + it 'builds correct tool_name' do + described_class.discover_and_register + tool = Legion::Tools::Registry.all_tools.first + expect(tool.tool_name).to include('do_thing') + end + + it 'sets deferred based on extension DSL' do + described_class.discover_and_register + tool = Legion::Tools::Registry.all_tools.first + expect(tool.deferred?).to be false + end + + it 'builds callable tool that delegates to runner' do + described_class.discover_and_register + tool = Legion::Tools::Registry.all_tools.first + result = tool.call(id: '123') + expect(result[:content].first[:text]).to include('ok') + end + end + + describe '.discover_and_register with mcp_tools? false' do + let(:disabled_extension) do + mod = Module.new do + def self.name + 'Legion::Extensions::Disabled' + end + + def self.mcp_tools? + false + end + + def self.mcp_tools_deferred? + true + end + end + runner = mock_runner + mod.define_singleton_method(:runner_modules) { [runner] } + mod + end + + before do + allow(Legion::Extensions).to receive(:loaded_extension_modules).and_return([disabled_extension]) + end + + it 'skips extensions with mcp_tools? false' do + described_class.discover_and_register + expect(Legion::Tools::Registry.all_tools).to be_empty + end + end + + describe 'trigger_words propagation' do + before { Legion::Tools::Registry.clear } + + let(:runner_mod) do + mod = Module.new do + def self.name = 'Legion::Extensions::Testlex::Runners::Stuff' + def self.mcp_tools? = true + def self.mcp_tools_deferred? = true + def self.trigger_words = %w[stuff things] + def self.settings = { functions: { do_stuff: { desc: 'does stuff', options: {} } } } + def self.do_stuff(**) = { result: true } + end + mod.extend(Legion::Extensions::Definitions) + mod + end + + let(:ext_mod) do + runner = runner_mod + Module.new do + def self.name = 'Legion::Extensions::Testlex' + def self.lex_name = 'testlex' + def self.mcp_tools? = true + def self.mcp_tools_deferred? = true + def self.trigger_words = %w[test] + define_singleton_method(:runner_modules) { [runner] } + end + end + + it 'propagates merged trigger words to registered tool classes' do + allow(Legion::Extensions).to receive(:loaded_extension_modules).and_return([ext_mod]) + Legion::Tools::Discovery.discover_and_register + + tool = Legion::Tools::Registry.all_tools.first + expect(tool.trigger_words).to include('stuff', 'things', 'test') + end + end + + describe 'sticky attribute on discovered tool classes' do + let(:ext) do + mod = Module.new + mod.extend(Legion::Extensions::Core) if Legion::Extensions.const_defined?(:Core, false) + mod + end + + it 'sets sticky true when extension returns true from sticky_tools?' do + allow(ext).to receive(:sticky_tools?).and_return(true) + attrs = Legion::Tools::Discovery.send(:tool_attributes, ext, double(name: 'Ext::Runners::Test'), + :do_thing, { desc: 'test', options: {} }, nil, false) + expect(attrs[:sticky]).to eq(true) + end + + it 'sets sticky false when extension returns false' do + allow(ext).to receive(:sticky_tools?).and_return(false) + attrs = Legion::Tools::Discovery.send(:tool_attributes, ext, double(name: 'Ext::Runners::Test'), + :do_thing, { desc: 'test', options: {} }, nil, false) + expect(attrs[:sticky]).to eq(false) + end + + it 'treats nil return from sticky_tools? as false (conservative opt-out)' do + allow(ext).to receive(:sticky_tools?).and_return(nil) + attrs = Legion::Tools::Discovery.send(:tool_attributes, ext, double(name: 'Ext::Runners::Test'), + :do_thing, { desc: 'test', options: {} }, nil, false) + expect(attrs[:sticky]).to eq(false) + end + + it 'calls sticky() on the created tool class' do + allow(ext).to receive(:sticky_tools?).and_return(false) + tool_class = Legion::Tools::Discovery.send(:build_tool_class, + ext: ext, + runner_mod: double(name: 'Ext::Runners::Test', respond_to?: false), + func_name: :do_thing, + meta: { desc: 'test', options: {} }, + defn: nil, + deferred: false) + expect(tool_class.sticky).to eq(false) + end + end + + describe 'runner-level override' do + let(:override_runner) do + Module.new do + def self.name + 'Legion::Extensions::Override::Runners::Special' + end + + def self.mcp_tools? + false + end + + def self.settings + { + functions: { + hidden: { desc: 'Hidden', options: {} } + } + } + end + end + end + + let(:override_extension) do + mod = Module.new do + def self.name + 'Legion::Extensions::Override' + end + + def self.mcp_tools? + true + end + + def self.mcp_tools_deferred? + true + end + end + runner = override_runner + mod.define_singleton_method(:runner_modules) { [runner] } + mod + end + + before do + allow(Legion::Extensions).to receive(:loaded_extension_modules).and_return([override_extension]) + end + + it 'respects runner-level mcp_tools? override' do + described_class.discover_and_register + expect(Legion::Tools::Registry.all_tools).to be_empty + end + end +end diff --git a/spec/legion/tools/embedding_cache_spec.rb b/spec/legion/tools/embedding_cache_spec.rb new file mode 100644 index 00000000..0a2168aa --- /dev/null +++ b/spec/legion/tools/embedding_cache_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Tools::EmbeddingCache do + before { described_class.clear } + + let(:vector) { [0.1, 0.2, 0.3, 0.4] } + + describe '.lookup' do + it 'returns nil for unknown hash' do + expect(described_class.lookup(content_hash: 'abc', model: 'test')).to be_nil + end + + it 'returns cached vector after store (L0 hit)' do + described_class.store(content_hash: 'abc', model: 'test', tool_name: 'x', vector: vector) + expect(described_class.lookup(content_hash: 'abc', model: 'test')).to eq(vector) + end + + it 'returns nil when model differs' do + described_class.store(content_hash: 'abc', model: 'test', tool_name: 'x', vector: vector) + expect(described_class.lookup(content_hash: 'abc', model: 'other')).to be_nil + end + end + + describe '.bulk_lookup' do + it 'returns hash of hits' do + described_class.store(content_hash: 'a', model: 'm', tool_name: 'x', vector: [1.0]) + described_class.store(content_hash: 'b', model: 'm', tool_name: 'y', vector: [2.0]) + result = described_class.bulk_lookup(content_hashes: %w[a b c], model: 'm') + expect(result.keys).to contain_exactly('a', 'b') + end + end + + describe '.content_hash' do + it 'is deterministic' do + expect(described_class.content_hash('hello')).to eq(described_class.content_hash('hello')) + end + end + + describe '.clear' do + it 'empties L0' do + described_class.store(content_hash: 'a', model: 'm', tool_name: 'x', vector: [1.0]) + described_class.clear + expect(described_class.lookup(content_hash: 'a', model: 'm')).to be_nil + end + end + + describe '.stats' do + it 'returns memory count' do + described_class.store(content_hash: 'a', model: 'm', tool_name: 'x', vector: [1.0]) + expect(described_class.stats[:memory]).to eq(1) + end + end +end diff --git a/spec/legion/tools/registry_spec.rb b/spec/legion/tools/registry_spec.rb new file mode 100644 index 00000000..9b8413a0 --- /dev/null +++ b/spec/legion/tools/registry_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Tools::Registry do + let(:always_tool) do + Class.new(Legion::Tools::Base) do + tool_name 'test.always' + description 'Always loaded' + end + end + + let(:deferred_tool) do + Class.new(Legion::Tools::Base) do + tool_name 'test.deferred' + description 'Deferred' + deferred true + end + end + + before { described_class.clear } + + describe '.register' do + it 'adds to always bucket by default' do + described_class.register(always_tool) + expect(described_class.tools).to include(always_tool) + end + + it 'adds deferred tool to deferred bucket' do + described_class.register(deferred_tool) + expect(described_class.deferred_tools).to include(deferred_tool) + end + + it 'deduplicates by tool_name' do + described_class.register(always_tool) + described_class.register(always_tool) + expect(described_class.tools.size).to eq(1) + end + + it 'logs warning on duplicate' do + described_class.register(always_tool) + expect(Legion::Logging).to receive(:warn).with(/duplicate registration rejected/) + described_class.register(always_tool) + end + + it 'handles duck-typed tools without deferred?' do + duck = Class.new do + def self.tool_name + 'test.duck' + end + end + described_class.register(duck) + expect(described_class.tools).to include(duck) + end + end + + describe '.find' do + it 'finds across both buckets' do + described_class.register(always_tool) + described_class.register(deferred_tool) + expect(described_class.find('test.always')).to eq(always_tool) + expect(described_class.find('test.deferred')).to eq(deferred_tool) + end + end + + describe '.for_extension' do + it 'filters by extension name' do + tool = Class.new(Legion::Tools::Base) do + tool_name 'test.ext_tool' + extension 'node' + end + described_class.register(tool) + expect(described_class.for_extension('node')).to include(tool) + expect(described_class.for_extension('other')).to be_empty + end + + it 'unregisters all tools owned by an extension' do + node_tool = Class.new(Legion::Tools::Base) do + tool_name 'test.node_tool' + extension 'node' + end + other_tool = Class.new(Legion::Tools::Base) do + tool_name 'test.other_tool' + extension 'other' + end + described_class.register(node_tool) + described_class.register(other_tool) + + removed = described_class.unregister_extension('node') + + expect(removed).to eq(1) + expect(described_class.for_extension('node')).to be_empty + expect(described_class.for_extension('other')).to include(other_tool) + end + end + + describe '.tagged' do + it 'filters by tag' do + tool = Class.new(Legion::Tools::Base) do + tool_name 'test.tagged' + tags %w[core operational] + end + described_class.register(tool) + expect(described_class.tagged('core')).to include(tool) + expect(described_class.tagged('missing')).to be_empty + end + end + + describe '.clear' do + it 'empties both buckets' do + described_class.register(always_tool) + described_class.register(deferred_tool) + described_class.clear + expect(described_class.all_tools).to be_empty + end + end +end diff --git a/spec/legion/tools/trigger_index_spec.rb b/spec/legion/tools/trigger_index_spec.rb new file mode 100644 index 00000000..83b1e324 --- /dev/null +++ b/spec/legion/tools/trigger_index_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Legion::Tools::TriggerIndex do + before { described_class.clear } + + let(:tool_a) do + Class.new(Legion::Tools::Base) do + tool_name 'legion-github-pr-create' + trigger_words %w[git github pr] + end + end + + let(:tool_b) do + Class.new(Legion::Tools::Base) do + tool_name 'legion-vault-secrets-read' + trigger_words %w[vault secret] + end + end + + describe '.build_from_registry' do + before do + Legion::Tools::Registry.clear + Legion::Tools::Registry.register(tool_a) + Legion::Tools::Registry.register(tool_b) + described_class.build_from_registry + end + + it 'indexes trigger words to tool classes' do + matched, _per_word = described_class.match(Set['git']) + expect(matched).to include(tool_a) + expect(matched).not_to include(tool_b) + end + + it 'returns tools for multiple matched words' do + matched, _per_word = described_class.match(Set['git', 'vault']) + expect(matched).to include(tool_a, tool_b) + end + + it 'returns empty set for no matches' do + matched, _per_word = described_class.match(Set['unknown']) + expect(matched).to be_empty + end + + it 'returns per_word breakdown for scoring' do + _matched, per_word = described_class.match(Set['git', 'vault']) + expect(per_word).to have_key('git') + expect(per_word).to have_key('vault') + expect(per_word['git']).to include(tool_a) + end + + it 'handles overlapping trigger words across tools' do + tool_c = Class.new(Legion::Tools::Base) do + tool_name 'legion-github-repos-list' + trigger_words %w[git repo] + end + Legion::Tools::Registry.register(tool_c) + described_class.build_from_registry + + matched, _per_word = described_class.match(Set['git']) + expect(matched).to include(tool_a, tool_c) + expect(matched).not_to include(tool_b) + end + end + + describe '.match' do + it 'returns empty set when index is empty' do + matched, per_word = described_class.match(Set['anything']) + expect(matched).to be_empty + expect(per_word).to be_empty + end + end + + describe '.empty?' do + it 'is true when no trigger words are indexed' do + expect(described_class).to be_empty + end + + it 'is false after building from registry with trigger words' do + Legion::Tools::Registry.clear + Legion::Tools::Registry.register(tool_a) + described_class.build_from_registry + expect(described_class).not_to be_empty + end + end + + describe '.size' do + it 'returns the number of unique trigger words indexed' do + Legion::Tools::Registry.clear + Legion::Tools::Registry.register(tool_a) + Legion::Tools::Registry.register(tool_b) + described_class.build_from_registry + expect(described_class.size).to eq(5) # git, github, pr, vault, secret + end + end +end diff --git a/spec/legion/trace_search_spec.rb b/spec/legion/trace_search_spec.rb new file mode 100644 index 00000000..7466ca5f --- /dev/null +++ b/spec/legion/trace_search_spec.rb @@ -0,0 +1,384 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'sequel' +require 'legion/trace_search' + +RSpec.describe Legion::TraceSearch do + describe '.generate_filter' do + it 'returns nil when LLM unavailable' do + expect(described_class.generate_filter('test')).to be_nil + end + end + + describe '.execute_filter' do + it 'returns error when data unavailable' do + result = described_class.execute_filter({ where: { status: 'failure' } }, 10) + expect(result[:error]).to include('data unavailable') + end + end + + describe '.search' do + it 'returns empty results with error when LLM unavailable' do + result = described_class.search('test query') + expect(result[:error]).to eq('no filter generated') + expect(result[:results]).to eq([]) + end + + context 'when LLM generates a filter' do + before do + allow(described_class).to receive(:generate_filter).and_return({ where: { status: 'failure' } }) + end + + it 'returns data unavailable error when data is not connected' do + result = described_class.search('failed tasks') + expect(result[:error]).to include('data unavailable') + end + end + end + + describe 'ALLOWED_COLUMNS' do + it 'includes expected columns' do + expect(described_class::ALLOWED_COLUMNS).to include('worker_id', 'status', 'cost_usd') + end + + it 'does not include dangerous columns' do + expect(described_class::ALLOWED_COLUMNS).not_to include('password', 'token', 'secret') + end + end + + describe '.schema_context' do + it 'includes current date and time' do + ctx = described_class.schema_context + expect(ctx).to include(Time.now.strftime('%Y-%m-%d')) + expect(ctx).to include('Current date/time:') + end + + it 'includes relative time guidance' do + ctx = described_class.schema_context + expect(ctx).to include('today') + expect(ctx).to include('last hour') + expect(ctx).to include('this week') + end + end + + describe 'FILTER_SCHEMA' do + it 'defines expected properties' do + props = described_class::FILTER_SCHEMA[:properties] + expect(props).to have_key(:where) + expect(props).to have_key(:order) + expect(props).to have_key(:limit) + expect(props).to have_key(:date_from) + expect(props).to have_key(:date_to) + end + end + + describe '.safe_parse_time' do + it 'returns Time objects unchanged' do + now = Time.now.utc + expect(described_class.safe_parse_time(now)).to eq(now) + end + + it 'parses ISO 8601 date strings' do + result = described_class.safe_parse_time('2026-03-23') + expect(result).to be_a(Time) + expect(result.year).to eq(2026) + expect(result.month).to eq(3) + expect(result.day).to eq(23) + end + + it 'parses datetime strings' do + result = described_class.safe_parse_time('2026-03-23T14:30:00Z') + expect(result).to be_a(Time) + expect(result.hour).to eq(14) + end + + it 'returns nil for unparseable strings' do + expect(described_class.safe_parse_time('not-a-date')).to be_nil + end + + it 'returns nil for empty string' do + expect(described_class.safe_parse_time('')).to be_nil + end + end + + describe '.apply_ordering' do + let(:mock_dataset) { double('Dataset') } + + it 'returns dataset unchanged when order is not a string' do + expect(described_class.apply_ordering(mock_dataset, { order: nil })).to eq(mock_dataset) + end + + it 'returns dataset unchanged for disallowed columns' do + expect(described_class.apply_ordering(mock_dataset, { order: 'password' })).to eq(mock_dataset) + end + + it 'applies ascending order for allowed column' do + allow(mock_dataset).to receive(:order).and_return(mock_dataset) + result = described_class.apply_ordering(mock_dataset, { order: 'cost_usd' }) + expect(mock_dataset).to have_received(:order).with(:cost_usd) + expect(result).to eq(mock_dataset) + end + + it 'applies descending order when prefixed with dash' do + allow(mock_dataset).to receive(:order).and_return(mock_dataset) + result = described_class.apply_ordering(mock_dataset, { order: '-cost_usd' }) + expect(mock_dataset).to have_received(:order) do |arg| + expect(arg).to be_a(Sequel::SQL::OrderedExpression) + end + expect(result).to eq(mock_dataset) + end + end + + describe '.apply_date_filters' do + let(:mock_dataset) { double('Dataset') } + + it 'returns dataset unchanged when no dates provided' do + expect(described_class.apply_date_filters(mock_dataset, {})).to eq(mock_dataset) + end + + it 'applies date_from filter' do + filtered = double('FilteredDataset') + allow(mock_dataset).to receive(:where).and_return(filtered) + result = described_class.apply_date_filters(mock_dataset, { date_from: '2026-03-01' }) + expect(result).to eq(filtered) + end + + it 'applies date_to filter' do + filtered = double('FilteredDataset') + allow(mock_dataset).to receive(:where).and_return(filtered) + result = described_class.apply_date_filters(mock_dataset, { date_to: '2026-03-31' }) + expect(result).to eq(filtered) + end + + it 'skips invalid date strings' do + result = described_class.apply_date_filters(mock_dataset, { date_from: 'invalid' }) + expect(result).to eq(mock_dataset) + end + end + + describe '.summarize' do + it 'returns error when LLM unavailable' do + result = described_class.summarize('test query') + expect(result[:error]).to eq('no filter generated') + end + + context 'when LLM generates a filter' do + before do + allow(described_class).to receive(:generate_filter).and_return({ where: { status: 'failure' } }) + end + + it 'returns data unavailable error when data is not connected' do + result = described_class.summarize('failed tasks') + expect(result[:error]).to include('data unavailable') + end + end + end + + describe '.compute_summary' do + it 'returns error when data unavailable' do + result = described_class.compute_summary({ where: { status: 'failure' } }) + expect(result[:error]).to include('data unavailable') + end + + context 'with mock database' do + let(:mock_ds) { double('Dataset') } + let(:mock_connection) { double('Connection') } + + before do + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + end + stub_const('Legion::Data', data_mod) + allow(Legion::Data).to receive(:connection).and_return(mock_connection) + allow(mock_connection).to receive(:[]).with(:metering_records).and_return(mock_ds) + allow(mock_ds).to receive(:where).and_return(mock_ds) + allow(mock_ds).to receive(:select).and_return(mock_ds) + allow(mock_ds).to receive(:group_and_count).and_return(mock_ds) + allow(mock_ds).to receive(:order).and_return(mock_ds) + allow(mock_ds).to receive(:limit).and_return(mock_ds) + end + + it 'returns summary with expected keys' do + allow(mock_ds).to receive(:first).and_return({ + total_records: 100, + total_tokens_in: 5000, + total_tokens_out: 8000, + total_cost: 1.2345, + avg_latency_ms: 150.67, + max_latency_ms: 2500, + earliest: Time.new(2026, 3, 1), + latest: Time.new(2026, 3, 23) + }) + allow(mock_ds).to receive(:all).and_return([]) + + result = described_class.compute_summary({ where: { status: 'success' } }) + expect(result[:total_records]).to eq(100) + expect(result[:total_tokens_in]).to eq(5000) + expect(result[:total_cost]).to eq(1.2345) + expect(result[:avg_latency_ms]).to eq(150.7) + expect(result[:time_range][:from]).to be_a(Time) + expect(result[:status_counts]).to eq({}) + expect(result[:top_extensions]).to eq([]) + expect(result[:top_workers]).to eq([]) + end + + it 'handles nil aggregate values' do + allow(mock_ds).to receive(:first).and_return({}) + allow(mock_ds).to receive(:all).and_return([]) + + result = described_class.compute_summary({}) + expect(result[:total_records]).to eq(0) + expect(result[:total_cost]).to eq(0.0) + expect(result[:avg_latency_ms]).to eq(0.0) + end + + it 'includes status breakdown' do + allow(mock_ds).to receive(:first).and_return({ total_records: 10 }) + allow(mock_ds).to receive(:all).and_return( + [{ status: 'success', count: 8 }, { status: 'failure', count: 2 }], + [], # top_extensions + [] # top_workers + ) + + result = described_class.compute_summary({}) + expect(result[:status_counts]).to eq({ 'success' => 8, 'failure' => 2 }) + end + + it 'includes top extensions and workers' do + allow(mock_ds).to receive(:first).and_return({ total_records: 50 }) + allow(mock_ds).to receive(:all).and_return( + [{ status: 'success', count: 50 }], + [{ extension: 'http', count: 30 }, { extension: 'vault', count: 20 }], + [{ worker_id: 'w-1', count: 40 }, { worker_id: 'w-2', count: 10 }] + ) + + result = described_class.compute_summary({}) + expect(result[:top_extensions]).to eq([{ name: 'http', count: 30 }, { name: 'vault', count: 20 }]) + expect(result[:top_workers]).to eq([{ id: 'w-1', count: 40 }, { id: 'w-2', count: 10 }]) + end + end + end + + describe '.detect_anomalies' do + it 'returns error when data unavailable' do + result = described_class.detect_anomalies + expect(result[:error]).to include('data unavailable') + end + + context 'with mock database' do + let(:mock_ds) { double('Dataset') } + let(:mock_connection) { double('Connection') } + + before do + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + end + stub_const('Legion::Data', data_mod) + allow(Legion::Data).to receive(:connection).and_return(mock_connection) + allow(mock_connection).to receive(:[]).with(:metering_records).and_return(mock_ds) + allow(mock_ds).to receive(:where).and_return(mock_ds) + allow(mock_ds).to receive(:select).and_return(mock_ds) + allow(mock_ds).to receive(:count).and_return(0) + end + + it 'returns anomaly report with expected keys' do + allow(mock_ds).to receive(:first).and_return({ + count: 10, avg_cost: 0.01, + avg_latency: 100.0, + input_tokens: 500, output_tokens: 300 + }) + + result = described_class.detect_anomalies + expect(result).to have_key(:anomalies) + expect(result).to have_key(:recent_count) + expect(result).to have_key(:baseline_count) + expect(result[:recent_period]).to eq('last 1 hour') + end + + it 'detects cost spike anomaly' do + # Recent period: high avg cost + recent_stats = { count: 10, avg_cost: 0.50, avg_latency: 100.0, input_tokens: 500, output_tokens: 300 } + # Baseline period: low avg cost + baseline_stats = { count: 100, avg_cost: 0.05, avg_latency: 100.0, input_tokens: 5000, output_tokens: 3000 } + + allow(mock_ds).to receive(:first).and_return(recent_stats, baseline_stats) + + result = described_class.detect_anomalies(threshold: 2.0) + cost_anomaly = result[:anomalies].find { |a| a[:metric] == 'Average cost' } + expect(cost_anomaly).not_to be_nil + expect(cost_anomaly[:ratio]).to eq(10.0) + expect(cost_anomaly[:severity]).to eq('critical') + end + + it 'returns no anomalies when metrics are normal' do + stats = { count: 10, avg_cost: 0.05, avg_latency: 100.0, input_tokens: 500, output_tokens: 300 } + allow(mock_ds).to receive(:first).and_return(stats, stats) + + result = described_class.detect_anomalies + expect(result[:anomalies]).to be_empty + end + + it 'handles zero baseline gracefully' do + recent = { count: 5, avg_cost: 0.10, avg_latency: 200.0, input_tokens: 100, output_tokens: 50 } + baseline = { count: 0, avg_cost: 0.0, avg_latency: 0.0, input_tokens: 0, output_tokens: 0 } + allow(mock_ds).to receive(:first).and_return(recent, baseline) + + result = described_class.detect_anomalies + expect(result[:anomalies]).to be_empty + end + end + end + + describe '.trend' do + it 'returns error when data unavailable' do + result = described_class.trend + expect(result[:error]).to include('data unavailable') + end + + context 'with mock database' do + let(:mock_ds) { double('Dataset') } + let(:mock_connection) { double('Connection') } + + before do + data_mod = Module.new do + def self.respond_to?(method, *) = method == :connection || super + end + stub_const('Legion::Data', data_mod) + allow(Legion::Data).to receive(:connection).and_return(mock_connection) + allow(mock_connection).to receive(:[]).with(:metering_records).and_return(mock_ds) + allow(mock_ds).to receive(:where).and_return(mock_ds) + allow(mock_ds).to receive(:select).and_return(mock_ds) + allow(mock_ds).to receive(:count).and_return(0) + allow(mock_ds).to receive(:first).and_return({ + count: 5, avg_cost: 0.01, + avg_latency: 50.0, + input_tokens: 100, output_tokens: 80 + }) + end + + it 'returns trend data with expected keys' do + result = described_class.trend(hours: 6, buckets: 3) + expect(result[:buckets]).to be_an(Array) + expect(result[:buckets].size).to eq(3) + expect(result[:hours]).to eq(6) + expect(result[:bucket_count]).to eq(3) + expect(result[:bucket_minutes]).to eq(120) + end + + it 'includes time and stats in each bucket' do + result = described_class.trend(hours: 2, buckets: 2) + bucket = result[:buckets].first + expect(bucket[:time]).to be_a(String) + expect(bucket[:count]).to eq(5) + expect(bucket[:avg_cost]).to eq(0.01) + end + + it 'defaults to 24 hours with 12 buckets' do + result = described_class.trend + expect(result[:buckets].size).to eq(12) + expect(result[:hours]).to eq(24) + end + end + end +end diff --git a/spec/legion/trigger/envelope_spec.rb b/spec/legion/trigger/envelope_spec.rb new file mode 100644 index 00000000..b0937d15 --- /dev/null +++ b/spec/legion/trigger/envelope_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/trigger/envelope' + +RSpec.describe Legion::Trigger::Envelope do + let(:envelope) do + described_class.new( + source: 'github', event_type: 'pull_request', action: 'opened', + delivery_id: 'abc-123', verified: true, payload: { number: 42 } + ) + end + + describe '#routing_key' do + it 'builds from source and event_type' do + expect(envelope.routing_key).to eq('trigger.github.pull_request') + end + end + + describe '#correlation_id' do + it 'auto-generates when not provided' do + expect(envelope.correlation_id).to start_with('leg-') + end + + it 'uses provided value' do + env = described_class.new(source: 'github', event_type: 'push', payload: {}, + correlation_id: 'custom-id') + expect(env.correlation_id).to eq('custom-id') + end + end + + describe '#to_h' do + it 'includes all fields' do + h = envelope.to_h + expect(h[:source]).to eq('github') + expect(h[:event_type]).to eq('pull_request') + expect(h[:action]).to eq('opened') + expect(h[:delivery_id]).to eq('abc-123') + expect(h[:verified]).to be true + expect(h[:payload]).to eq({ number: 42 }) + expect(h[:received_at]).to be_a(String) + end + end +end diff --git a/spec/legion/trigger/sources_spec.rb b/spec/legion/trigger/sources_spec.rb new file mode 100644 index 00000000..81e227b5 --- /dev/null +++ b/spec/legion/trigger/sources_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/trigger' + +RSpec.describe Legion::Trigger::Sources::Github do + let(:adapter) { described_class.new } + + describe '#normalize' do + it 'extracts github-specific fields' do + headers = { + 'HTTP_X_GITHUB_EVENT' => 'pull_request', + 'HTTP_X_GITHUB_DELIVERY' => 'delivery-uuid' + } + body = { 'action' => 'opened', 'number' => 42 } + + result = adapter.normalize(headers: headers, body: body) + expect(result[:source]).to eq('github') + expect(result[:event_type]).to eq('pull_request') + expect(result[:action]).to eq('opened') + expect(result[:delivery_id]).to eq('delivery-uuid') + end + end + + describe '#verify_signature' do + let(:secret) { 'test-secret' } + let(:body_raw) { '{"action":"opened"}' } + + it 'returns true for valid HMAC' do + digest = OpenSSL::HMAC.hexdigest('SHA256', secret, body_raw) + headers = { 'HTTP_X_HUB_SIGNATURE_256' => "sha256=#{digest}" } + expect(adapter.verify_signature(headers: headers, body_raw: body_raw, secret: secret)).to be true + end + + it 'returns false for invalid HMAC' do + headers = { 'HTTP_X_HUB_SIGNATURE_256' => 'sha256=bad' } + expect(adapter.verify_signature(headers: headers, body_raw: body_raw, secret: secret)).to be false + end + + it 'returns false when header is missing' do + expect(adapter.verify_signature(headers: {}, body_raw: body_raw, secret: secret)).to be false + end + end +end + +RSpec.describe Legion::Trigger::Sources::Slack do + let(:adapter) { described_class.new } + + describe '#normalize' do + it 'extracts slack-specific fields' do + body = { 'type' => 'event_callback', 'event_id' => 'ev123', 'event' => { 'type' => 'message' } } + result = adapter.normalize(headers: {}, body: body) + expect(result[:source]).to eq('slack') + expect(result[:event_type]).to eq('event_callback') + expect(result[:action]).to eq('message') + end + end +end + +RSpec.describe Legion::Trigger::Sources::Linear do + let(:adapter) { described_class.new } + + describe '#normalize' do + it 'extracts linear-specific fields' do + headers = { 'HTTP_LINEAR_EVENT' => 'Issue', 'HTTP_LINEAR_DELIVERY' => 'del-456' } + body = { 'action' => 'create', 'type' => 'Issue' } + result = adapter.normalize(headers: headers, body: body) + expect(result[:source]).to eq('linear') + expect(result[:event_type]).to eq('Issue') + expect(result[:action]).to eq('create') + end + end +end diff --git a/spec/legion/trigger_spec.rb b/spec/legion/trigger_spec.rb new file mode 100644 index 00000000..f93cc148 --- /dev/null +++ b/spec/legion/trigger_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/trigger' + +RSpec.describe Legion::Trigger do + before do + allow(Legion::Logging).to receive(:info) + allow(Legion::Logging).to receive(:debug) + allow(Legion::Logging).to receive(:warn) + allow(Legion::Logging).to receive(:error) + end + + describe '.source_for' do + it 'returns a Github adapter' do + expect(described_class.source_for('github')).to be_a(Legion::Trigger::Sources::Github) + end + + it 'returns a Slack adapter' do + expect(described_class.source_for('slack')).to be_a(Legion::Trigger::Sources::Slack) + end + + it 'returns a Linear adapter' do + expect(described_class.source_for('linear')).to be_a(Legion::Trigger::Sources::Linear) + end + + it 'raises for unknown source' do + expect { described_class.source_for('unknown') }.to raise_error(ArgumentError, /unknown trigger source/) + end + end + + describe '.registered_sources' do + it 'includes github, slack, linear' do + expect(described_class.registered_sources).to contain_exactly('github', 'slack', 'linear') + end + end + + describe '.process' do + let(:headers) do + { 'HTTP_X_GITHUB_EVENT' => 'push', 'HTTP_X_GITHUB_DELIVERY' => 'del-1' } + end + let(:body) { { 'ref' => 'refs/heads/main' } } + let(:body_raw) { '{"ref":"refs/heads/main"}' } + + before do + allow(Legion::Settings).to receive(:dig).and_return(nil) + allow(Legion::Settings).to receive(:dig).with(:trigger, :sources, anything, :require_verified).and_return(false) + # Ensure no cache interference from other tests + hide_const('Legion::Cache') if defined?(Legion::Cache) + end + + it 'returns success when AMQP is not available (bridge skipped)' do + result = described_class.process( + source_name: 'github', headers: headers, body_raw: body_raw, body: body + ) + expect(result[:success]).to be true + expect(result[:routing_key]).to eq('trigger.github.push') + expect(result[:correlation_id]).to start_with('leg-') + end + + it 'returns error for unknown source' do + result = described_class.process( + source_name: 'bogus', headers: {}, body_raw: '', body: {} + ) + expect(result[:success]).to be false + expect(result[:reason]).to eq(:unknown_source) + end + + it 'detects duplicates via cache' do + stub_const('Legion::Cache', Module.new do + @seen = {} + + def self.respond_to?(name, *) + %i[get set].include?(name) || super + end + + def self.get(key) + @seen[key] + end + + def self.set(key, val, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument + @seen[key] = val + end + end) + + # First call succeeds + result1 = described_class.process( + source_name: 'github', headers: headers, body_raw: body_raw, body: body + ) + expect(result1[:success]).to be true + + # Second call with same delivery_id is duplicate + result2 = described_class.process( + source_name: 'github', headers: headers, body_raw: body_raw, body: body + ) + expect(result2[:success]).to be false + expect(result2[:reason]).to eq(:duplicate) + end + end +end diff --git a/spec/legion/webhooks_spec.rb b/spec/legion/webhooks_spec.rb new file mode 100644 index 00000000..dbf6cfa0 --- /dev/null +++ b/spec/legion/webhooks_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/webhooks' + +RSpec.describe Legion::Webhooks do + let(:connection) { instance_double('Sequel::Database') } + let(:webhooks_dataset) { instance_double('Sequel::Dataset') } + let(:active_webhooks_dataset) { instance_double('Sequel::Dataset') } + let(:deliveries_dataset) { instance_double('Sequel::Dataset') } + let(:dead_letters_dataset) { instance_double('Sequel::Dataset') } + let(:delete_dataset) { instance_double('Sequel::Dataset') } + + before do + described_class.send(:invalidate_dispatch_cache!) + + stub_const('Legion::Data', Module.new do + class << self + attr_accessor :connection + end + end) + Legion::Data.connection = connection + + allow(connection).to receive(:[]).with(:webhooks).and_return(webhooks_dataset) + allow(connection).to receive(:[]).with(:webhook_deliveries).and_return(deliveries_dataset) + allow(connection).to receive(:[]).with(:webhook_dead_letters).and_return(dead_letters_dataset) + + allow(deliveries_dataset).to receive(:insert) + allow(dead_letters_dataset).to receive(:insert) + allow(webhooks_dataset).to receive(:where).with(status: 'active').and_return(active_webhooks_dataset) + allow(active_webhooks_dataset).to receive(:all).and_return([]) + end + + after do + described_class.send(:invalidate_dispatch_cache!) + end + + describe '.compute_signature' do + it 'returns HMAC-SHA256 hex digest' do + sig = described_class.compute_signature('secret', '{"event":"test"}') + expect(sig).to match(/\A[a-f0-9]{64}\z/) + end + + it 'is deterministic' do + s1 = described_class.compute_signature('key', 'body') + s2 = described_class.compute_signature('key', 'body') + expect(s1).to eq(s2) + end + end + + describe '.register' do + it 'invalidates the dispatch cache after insert' do + allow(webhooks_dataset).to receive(:insert).and_return(42) + described_class.instance_variable_set(:@active_webhooks_cache, [:cached]) + described_class.instance_variable_set(:@pattern_cache, { stale: true }) + + result = described_class.register(url: 'https://example.com/hook', secret: 'abc', event_types: ['test.*']) + + expect(result).to eq({ registered: true, id: 42 }) + expect(described_class.instance_variable_get(:@active_webhooks_cache)).to be_nil + expect(described_class.instance_variable_get(:@pattern_cache)).to eq({}) + end + end + + describe '.unregister' do + it 'invalidates the dispatch cache after delete' do + allow(webhooks_dataset).to receive(:where).with(id: 7).and_return(delete_dataset) + allow(delete_dataset).to receive(:delete) + described_class.instance_variable_set(:@active_webhooks_cache, [:cached]) + described_class.instance_variable_set(:@pattern_cache, { stale: true }) + + result = described_class.unregister(id: 7) + + expect(result).to eq({ unregistered: true }) + expect(described_class.instance_variable_get(:@active_webhooks_cache)).to be_nil + expect(described_class.instance_variable_get(:@pattern_cache)).to eq({}) + end + end + + describe '.dispatch' do + let(:webhook) do + { + id: 1, + url: 'https://example.com/hook', + secret: 'abc', + event_types: '["alert.*"]', + max_retries: 0, + updated_at: Time.utc(2026, 4, 2, 19, 0, 0) + } + end + + before do + allow(active_webhooks_dataset).to receive(:all).and_return([webhook]) + allow(described_class).to receive(:perform_delivery_request).and_return(instance_double('Net::HTTPResponse', code: '200')) + end + + it 'returns nil when data unavailable' do + Legion::Data.connection = nil + expect(described_class.dispatch('test.event', {})).to be_nil + end + + it 'caches the active webhook rows and parsed event patterns between dispatches' do + allow(Legion::JSON).to receive(:load).and_call_original + + 2.times { described_class.dispatch('alert.triggered', foo: 'bar') } + + expect(active_webhooks_dataset).to have_received(:all).once + expect(Legion::JSON).to have_received(:load).with('["alert.*"]').once + expect(deliveries_dataset).to have_received(:insert).twice + end + + it 'ignores events that do not match the configured patterns' do + described_class.dispatch('audit.created', foo: 'bar') + + expect(described_class).not_to have_received(:perform_delivery_request) + expect(deliveries_dataset).not_to have_received(:insert) + end + end + + describe '.deliver' do + let(:webhook) do + { + id: 9, + url: 'https://example.com/hook', + secret: 'abc', + event_types: '["test.event"]', + max_retries: max_retries, + updated_at: Time.utc(2026, 4, 2, 19, 0, 0) + } + end + let(:max_retries) { 2 } + + it 'retries non-success HTTP responses up to the configured retry limit' do + responses = [ + instance_double('Net::HTTPResponse', code: '500'), + instance_double('Net::HTTPResponse', code: '502'), + instance_double('Net::HTTPResponse', code: '200') + ] + allow(described_class).to receive(:perform_delivery_request).and_return(*responses) + + result = described_class.deliver(webhook, 'test.event', { payload: true }) + + expect(result).to eq({ delivered: true, status: 200 }) + expect(deliveries_dataset).to have_received(:insert).with( + hash_including(webhook_id: 9, event_name: 'test.event', response_status: 500, success: false, attempt: 1, error: 'http_status=500') + ).once + expect(deliveries_dataset).to have_received(:insert).with( + hash_including(webhook_id: 9, event_name: 'test.event', response_status: 502, success: false, attempt: 2, error: 'http_status=502') + ).once + expect(deliveries_dataset).to have_received(:insert).with( + hash_including(webhook_id: 9, event_name: 'test.event', response_status: 200, success: true, attempt: 3, error: nil) + ).once + expect(dead_letters_dataset).not_to have_received(:insert) + end + + it 'dead letters after the configured retry limit is exhausted on exceptions' do + allow(described_class).to receive(:perform_delivery_request).and_raise(StandardError, 'boom') + limited_webhook = webhook.merge(max_retries: 1) + + result = described_class.deliver(limited_webhook, 'test.event', { payload: true }) + + expect(result).to include(delivered: false, dead_lettered: true, error: 'boom') + expect(deliveries_dataset).to have_received(:insert).with( + hash_including(webhook_id: 9, event_name: 'test.event', response_status: nil, success: false, attempt: 1, error: 'boom') + ).once + expect(deliveries_dataset).to have_received(:insert).with( + hash_including(webhook_id: 9, event_name: 'test.event', response_status: nil, success: false, attempt: 2, error: 'boom') + ).once + expect(dead_letters_dataset).to have_received(:insert).with( + hash_including(webhook_id: 9, event_name: 'test.event', attempts: 2, last_error: 'boom') + ).once + end + + it 'does not retry when max_retries is zero' do + allow(described_class).to receive(:perform_delivery_request).and_return(instance_double('Net::HTTPResponse', code: '503')) + no_retry_webhook = webhook.merge(max_retries: 0) + + result = described_class.deliver(no_retry_webhook, 'test.event', { payload: true }) + + expect(result).to include(delivered: false, dead_lettered: true, status: 503) + expect(deliveries_dataset).to have_received(:insert).with( + hash_including(webhook_id: 9, event_name: 'test.event', response_status: 503, success: false, attempt: 1, error: 'http_status=503') + ).once + expect(dead_letters_dataset).to have_received(:insert).with( + hash_including(webhook_id: 9, event_name: 'test.event', attempts: 1, last_error: 'http_status=503') + ).once + end + end +end diff --git a/spec/legion/workflow/loader_spec.rb b/spec/legion/workflow/loader_spec.rb new file mode 100644 index 00000000..a53462d6 --- /dev/null +++ b/spec/legion/workflow/loader_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/workflow/manifest' +require 'legion/workflow/loader' + +module Legion + module Data + module Model + Extension = Class.new unless const_defined?(:Extension, false) + Runner = Class.new unless const_defined?(:Runner, false) + Function = Class.new unless const_defined?(:Function, false) + Relationship = Class.new unless const_defined?(:Relationship, false) + Chain = Class.new unless const_defined?(:Chain, false) + end + end +end + +RSpec.describe Legion::Workflow::Loader do + subject(:loader) { described_class.new } + + before do + allow(Gem::Specification).to receive(:find_all_by_name).and_return([double]) + end + + describe '#install' do + let(:manifest) do + instance_double( + Legion::Workflow::Manifest, + valid?: true, + name: 'test-workflow', + requires: ['lex-codegen'], + relationships: [ + { + name: 'step-one', + trigger: { extension: 'codegen', runner: 'from_gap', function: 'generate' }, + action: { extension: 'eval', runner: 'code_review', function: 'review_generated' }, + conditions: { all: [{ fact: 'success', operator: 'equal', value: true }] }, + transformation: nil, + delay: 0, + allow_new_chains: false + } + ] + ) + end + + context 'when manifest is invalid' do + let(:manifest) { instance_double(Legion::Workflow::Manifest, valid?: false, errors: ['name is required']) } + + it 'returns errors' do + result = loader.install(manifest) + expect(result[:success]).to be false + expect(result[:errors]).to include('name is required') + end + end + + context 'when gems are missing' do + before { allow(Gem::Specification).to receive(:find_all_by_name).with('lex-codegen').and_return([]) } + + it 'returns missing_gems error' do + result = loader.install(manifest) + expect(result[:success]).to be false + expect(result[:error]).to eq(:missing_gems) + end + end + + context 'when trigger function not found' do + before do + allow(Legion::Data::Model::Extension).to receive(:where).and_return(double(first: nil)) + allow(Legion::Data::Model::Chain).to receive(:where).and_return(double(first: nil)) + allow(Legion::Data::Model::Chain).to receive(:insert).and_return(1) + end + + it 'returns trigger_not_found error' do + result = loader.install(manifest) + expect(result[:success]).to be false + expect(result[:error]).to eq(:trigger_not_found) + end + end + + context 'when all functions resolve' do + let(:ext_codegen) { double(values: { id: 1 }) } + let(:ext_eval) { double(values: { id: 2 }) } + let(:runner_from_gap) { double(values: { id: 10 }) } + let(:runner_code_review) { double(values: { id: 20 }) } + let(:func_generate) { double(values: { id: 100 }) } + let(:func_review) { double(values: { id: 200 }) } + + before do + allow(Legion::Data::Model::Chain).to receive(:where).and_return(double(first: nil)) + allow(Legion::Data::Model::Chain).to receive(:insert).and_return(5) + + allow(Legion::Data::Model::Extension).to receive(:where).with(name: 'codegen').and_return(double(first: ext_codegen)) + allow(Legion::Data::Model::Extension).to receive(:where).with(name: 'eval').and_return(double(first: ext_eval)) + + allow(Legion::Data::Model::Runner).to receive(:where).with(extension_id: 1, name: 'from_gap').and_return(double(first: runner_from_gap)) + allow(Legion::Data::Model::Runner).to receive(:where).with(extension_id: 2, name: 'code_review').and_return(double(first: runner_code_review)) + + allow(Legion::Data::Model::Function).to receive(:where).with(runner_id: 10, name: 'generate').and_return(double(first: func_generate)) + allow(Legion::Data::Model::Function).to receive(:where).with(runner_id: 20, name: 'review_generated').and_return(double(first: func_review)) + + allow(Legion::Data::Model::Relationship).to receive(:insert).and_return(42) + end + + it 'creates chain and relationships' do + result = loader.install(manifest) + expect(result[:success]).to be true + expect(result[:chain_id]).to eq(5) + expect(result[:relationship_ids]).to eq([42]) + end + + it 'sets allow_new_chains on first relationship' do + expect(Legion::Data::Model::Relationship).to receive(:insert).with( + hash_including(allow_new_chains: true, chain_id: 5) + ).and_return(42) + loader.install(manifest) + end + end + end + + describe '#uninstall' do + context 'when workflow not found' do + before { allow(Legion::Data::Model::Chain).to receive(:where).with(name: 'missing').and_return(double(first: nil)) } + + it 'returns not_found' do + result = loader.uninstall('missing') + expect(result[:success]).to be false + expect(result[:error]).to eq(:not_found) + end + end + + context 'when workflow exists' do + let(:chain) { double(values: { id: 5 }, delete: true) } + + before do + allow(Legion::Data::Model::Chain).to receive(:where).with(name: 'test').and_return(double(first: chain)) + allow(Legion::Data::Model::Relationship).to receive(:where).with(chain_id: 5).and_return(double(delete: 3)) + end + + it 'deletes relationships and chain' do + result = loader.uninstall('test') + expect(result[:success]).to be true + expect(result[:deleted_relationships]).to eq(3) + end + end + end + + describe '#list' do + before do + allow(Legion::Data::Model::Chain).to receive(:all).and_return([ + double(values: { id: 1, name: 'wf-one' }), + double(values: { id: 2, name: 'wf-two' }) + ]) + allow(Legion::Data::Model::Relationship).to receive(:where).and_return(double(count: 3)) + end + + it 'returns workflow summaries' do + result = loader.list + expect(result.size).to eq(2) + expect(result.first[:name]).to eq('wf-one') + expect(result.first[:relationships]).to eq(3) + end + end +end diff --git a/spec/legion/workflow/manifest_spec.rb b/spec/legion/workflow/manifest_spec.rb new file mode 100644 index 00000000..3efb7083 --- /dev/null +++ b/spec/legion/workflow/manifest_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/workflow/manifest' + +RSpec.describe Legion::Workflow::Manifest do + let(:valid_yaml) do + { + name: 'test-workflow', + version: '0.1.0', + description: 'A test workflow', + requires: ['lex-codegen'], + relationships: [ + { + name: 'step-one', + trigger: { extension: 'codegen', runner: 'from_gap', function: 'generate' }, + action: { extension: 'eval', runner: 'code_review', function: 'review_generated' }, + conditions: { all: [{ fact: 'success', operator: 'equal', value: true }] } + } + ] + } + end + + let(:tmpfile) do + require 'tempfile' + require 'json' + f = Tempfile.new(['workflow', '.yml']) + f.write(YAML.dump(JSON.parse(JSON.generate(valid_yaml)))) + f.rewind + f + end + + after { tmpfile.close! } + + describe '.new' do + it 'parses a valid manifest' do + manifest = described_class.new(path: tmpfile.path) + expect(manifest.name).to eq('test-workflow') + expect(manifest.version).to eq('0.1.0') + expect(manifest.requires).to eq(['lex-codegen']) + expect(manifest.relationships.size).to eq(1) + end + end + + describe '#valid?' do + it 'returns true for valid manifest' do + manifest = described_class.new(path: tmpfile.path) + expect(manifest).to be_valid + end + + context 'with missing name' do + let(:valid_yaml) do + { relationships: [{ trigger: { extension: 'a', runner: 'b', function: 'c' }, action: { extension: 'd', runner: 'e', function: 'f' } }] } + end + + it 'returns false' do + manifest = described_class.new(path: tmpfile.path) + expect(manifest).not_to be_valid + expect(manifest.errors).to include('name is required') + end + end + + context 'with empty relationships' do + let(:valid_yaml) { { name: 'empty', relationships: [] } } + + it 'returns false' do + manifest = described_class.new(path: tmpfile.path) + expect(manifest).not_to be_valid + expect(manifest.errors).to include('at least one relationship is required') + end + end + end + + describe '#relationships' do + it 'parses trigger and action refs' do + manifest = described_class.new(path: tmpfile.path) + rel = manifest.relationships.first + expect(rel[:trigger][:extension]).to eq('codegen') + expect(rel[:action][:function]).to eq('review_generated') + expect(rel[:conditions]).to be_a(Hash) + end + end +end diff --git a/spec/legion_spec.rb b/spec/legion_spec.rb index f00571b9..8ecd10bf 100755 --- a/spec/legion_spec.rb +++ b/spec/legion_spec.rb @@ -6,4 +6,20 @@ it 'has a version number' do expect(Legion::VERSION).not_to be nil end + + it 'version is a string' do + expect(Legion::VERSION).to be_a(String) + end + + it 'responds to start' do + expect(described_class).to respond_to(:start) + end + + it 'responds to shutdown' do + expect(described_class).to respond_to(:shutdown) + end + + it 'responds to reload' do + expect(described_class).to respond_to(:reload) + end end diff --git a/spec/live/.rspec b/spec/live/.rspec new file mode 100644 index 00000000..a15e31dc --- /dev/null +++ b/spec/live/.rspec @@ -0,0 +1,4 @@ +--color +--format documentation +--require ./spec/live/spec_helper +--default-path spec/live diff --git a/spec/live/README.md b/spec/live/README.md new file mode 100644 index 00000000..0fd08c72 --- /dev/null +++ b/spec/live/README.md @@ -0,0 +1,28 @@ +# Live Daemon Integration Tests + +Black-box HTTP tests against a running Legion daemon. No Legion code is loaded — just Faraday + RSpec using the parent LegionIO bundle. + +## Running + +Start the daemon first: +```bash +legionio start +``` + +Then run the suite from the LegionIO root: +```bash +bundle exec rspec --options spec/live/.rspec +``` + +To target a different host: +```bash +LEGION_API_URL=http://192.168.1.5:4567 bundle exec rspec --options spec/live/.rspec +``` + +## Adding specs + +Each spec file tests a logical API surface. Use the `get`/`post` helpers from `spec_helper.rb` — they handle JSON encoding/decoding and base URL resolution. + +## CI + +These specs are NOT included in the normal `bundle exec rspec` run and are excluded from GitHub Actions. They require a live daemon with real infrastructure (RabbitMQ, database, LLM providers, etc). diff --git a/spec/live/api/apollo/status_spec.rb b/spec/live/api/apollo/status_spec.rb new file mode 100644 index 00000000..b2389d60 --- /dev/null +++ b/spec/live/api/apollo/status_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/apollo/status' do + subject(:response) { get('/apollo/status') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'includes available as a boolean' do + expect(response.body[:data][:available]).to be(true).or be(false) + end + + it 'includes data_connected as a boolean' do + expect(response.body[:data][:data_connected]).to be(true).or be(false) + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/events/recent_spec.rb b/spec/live/api/events/recent_spec.rb new file mode 100644 index 00000000..be9ed128 --- /dev/null +++ b/spec/live/api/events/recent_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/events/recent' do + subject(:response) { get('/events/recent') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'returns data as an array' do + expect(response.body[:data]).to be_an(Array) + end + + it 'each event has required fields' do + skip 'no events recorded yet' if response.body[:data].empty? + + entry = response.body[:data].first + expect(entry[:event]).to be_a(String) + expect(entry[:timestamp]).to be_a(String) + expect(entry[:status]).to be_a(String) + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/extension_catalog/available_spec.rb b/spec/live/api/extension_catalog/available_spec.rb new file mode 100644 index 00000000..99c459f6 --- /dev/null +++ b/spec/live/api/extension_catalog/available_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/extension_catalog/available' do + subject(:response) { get('/extension_catalog/available') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'returns data as an array' do + expect(response.body[:data]).to be_an(Array) + end + + it 'contains available extensions' do + expect(response.body[:data]).not_to be_empty + end + + it 'each entry has name, category, and description' do + entry = response.body[:data].first + expect(entry[:name]).to be_a(String) + expect(entry[:category]).to be_a(String) + expect(entry[:description]).to be_a(String) + end + + it 'includes known categories' do + categories = response.body[:data].map { |e| e[:category] }.uniq + expect(categories).to include('core') + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/extension_catalog/catalog_spec.rb b/spec/live/api/extension_catalog/catalog_spec.rb new file mode 100644 index 00000000..e6ab5a68 --- /dev/null +++ b/spec/live/api/extension_catalog/catalog_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/extension_catalog' do + subject(:response) { get('/extension_catalog') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'returns data as an array' do + expect(response.body[:data]).to be_an(Array) + end + + it 'contains at least one loaded extension' do + expect(response.body[:data]).not_to be_empty + end + + it 'each extension has required fields' do + entry = response.body[:data].first + expect(entry[:name]).to be_a(String) + expect(entry[:state]).to be_a(String) + expect(entry[:active_version]).to be_a(String) + end + + it 'each extension includes reload metadata' do + entry = response.body[:data].first + expect(entry).to have_key(:reload_state) + expect(entry).to have_key(:pending_reload) + expect(entry).to have_key(:hot_reloadable) + end + + it 'each extension includes tools and routes arrays' do + entry = response.body[:data].first + expect(entry[:tools]).to be_an(Array) + expect(entry[:routes]).to be_an(Array) + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/extensions/list_spec.rb b/spec/live/api/extensions/list_spec.rb new file mode 100644 index 00000000..f62a9e2d --- /dev/null +++ b/spec/live/api/extensions/list_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/extensions' do + subject(:response) { get('/extensions') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'returns loaded extensions as an array' do + expect(response.body[:data]).to be_an(Array) + end + + it 'has at least one extension loaded' do + expect(response.body[:data]).not_to be_empty + end +end diff --git a/spec/live/api/gaia/buffer_spec.rb b/spec/live/api/gaia/buffer_spec.rb new file mode 100644 index 00000000..e30bb5fa --- /dev/null +++ b/spec/live/api/gaia/buffer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/gaia/buffer' do + subject(:response) { get('/gaia/buffer') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'includes depth as an integer' do + expect(response.body[:data][:depth]).to be_a(Integer) + end + + it 'includes empty as a boolean' do + expect(response.body[:data][:empty]).to be(true).or be(false) + end + + it 'includes max_size as an integer' do + expect(response.body[:data][:max_size]).to be_a(Integer) + expect(response.body[:data][:max_size]).to be > 0 + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/gaia/channels_spec.rb b/spec/live/api/gaia/channels_spec.rb new file mode 100644 index 00000000..aa7e1cdd --- /dev/null +++ b/spec/live/api/gaia/channels_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/gaia/channels' do + subject(:response) { get('/gaia/channels') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'returns data with channels array and count' do + data = response.body[:data] + expect(data[:channels]).to be_an(Array) + expect(data[:count]).to be_a(Integer) + end + + it 'count matches channels array length' do + data = response.body[:data] + expect(data[:count]).to eq(data[:channels].length) + end + + it 'each channel has id, started, capabilities, and type' do + skip 'no channels configured' if response.body[:data][:channels].empty? + + channel = response.body[:data][:channels].first + expect(channel[:id]).to be_a(String) + expect(channel[:started]).to be(true).or be(false) + expect(channel[:capabilities]).to be_an(Array) + expect(channel[:type]).to be_a(String) + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/gaia/sessions_spec.rb b/spec/live/api/gaia/sessions_spec.rb new file mode 100644 index 00000000..2501bc02 --- /dev/null +++ b/spec/live/api/gaia/sessions_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/gaia/sessions' do + subject(:response) { get('/gaia/sessions') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'includes count as an integer' do + expect(response.body[:data][:count]).to be_a(Integer) + end + + it 'includes active as a boolean' do + expect(response.body[:data][:active]).to be(true).or be(false) + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/gaia/status_spec.rb b/spec/live/api/gaia/status_spec.rb new file mode 100644 index 00000000..78c156e2 --- /dev/null +++ b/spec/live/api/gaia/status_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/gaia/status' do + subject(:response) { get('/gaia/status') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'reports started status' do + expect(response.body[:data][:started]).to be(true).or be(false) + end + + it 'includes the mode' do + expect(response.body[:data][:mode]).to be_a(String) + end + + it 'includes buffer_depth as a number' do + expect(response.body[:data][:buffer_depth]).to be_a(Integer) + end + + it 'includes active_channels as an array' do + expect(response.body[:data][:active_channels]).to be_an(Array) + end + + it 'includes tick_count and tick_mode' do + expect(response.body[:data][:tick_count]).to be_a(Integer) + expect(response.body[:data][:tick_mode]).to be_a(String) + end + + it 'includes sensory_buffer details' do + buffer = response.body[:data][:sensory_buffer] + expect(buffer).to be_a(Hash) + expect(buffer[:depth]).to be_a(Integer) + expect(buffer[:max_capacity]).to be_a(Integer) + end + + it 'includes phase_list as an array' do + expect(response.body[:data][:phase_list]).to be_an(Array) + expect(response.body[:data][:phase_list]).not_to be_empty + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/health/health_spec.rb b/spec/live/api/health/health_spec.rb new file mode 100644 index 00000000..14e18603 --- /dev/null +++ b/spec/live/api/health/health_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/health' do + subject(:response) { get('/health') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'includes data.status of ok' do + expect(response.body[:data][:status]).to eq('ok') + end + + it 'includes a version string' do + expect(response.body[:data][:version]).to be_a(String) + expect(response.body[:data][:version]).to match(/\A\d+\.\d+\.\d+\z/) + end + + it 'includes uptime_seconds as a number' do + expect(response.body[:data][:uptime_seconds]).to be_a(Numeric) + expect(response.body[:data][:uptime_seconds]).to be >= 0 + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/health/ready_spec.rb b/spec/live/api/health/ready_spec.rb new file mode 100644 index 00000000..6968a197 --- /dev/null +++ b/spec/live/api/health/ready_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/ready' do + subject(:response) { get('/ready') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'reports ready as true' do + expect(response.body[:data][:ready]).to be true + end + + it 'includes a components hash' do + components = response.body[:data][:components] + expect(components).to be_a(Hash) + expect(components).not_to be_empty + end + + it 'has all core components marked as true' do + components = response.body[:data][:components] + %i[settings transport extensions api].each do |component| + expect(components[component]).to be(true), "expected #{component} to be true" + end + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/llm/providers_spec.rb b/spec/live/api/llm/providers_spec.rb new file mode 100644 index 00000000..d45800de --- /dev/null +++ b/spec/live/api/llm/providers_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/llm/providers' do + subject(:response) { get('/llm/providers') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'has a data key' do + expect(response.body).to include(:data) + end + + it 'has data.providers as an array' do + expect(response.body[:data][:providers]).to be_an(Array) + end + + it 'has data.summary.total >= 5' do + expect(response.body[:data][:summary][:total]).to be >= 5 + end + + it 'has data.summary.native >= 5' do + expect(response.body[:data][:summary][:native]).to be >= 5 + end + + it 'has data.summary.routing_enabled = true' do + expect(response.body[:data][:summary][:routing_enabled]).to be true + end +end diff --git a/spec/live/api/openapi/openapi_json_spec.rb b/spec/live/api/openapi/openapi_json_spec.rb new file mode 100644 index 00000000..6de60f3d --- /dev/null +++ b/spec/live/api/openapi/openapi_json_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/openapi.json' do + subject(:response) { get('/openapi.json') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'declares OpenAPI 3.1.0' do + expect(response.body[:openapi]).to eq('3.1.0') + end + + it 'has info with title and version' do + expect(response.body[:info][:title]).to eq('LegionIO REST API') + expect(response.body[:info][:version]).not_to be_nil + end + + it 'has paths' do + expect(response.body[:paths]).to be_a(Hash) + expect(response.body[:paths].size).to be > 0 + end + + it 'has components' do + expect(response.body[:components]).to be_a(Hash) + end + + it 'has tags' do + expect(response.body[:tags]).to be_an(Array) + expect(response.body[:tags]).not_to be_empty + end + + it 'has security schemes defined' do + expect(response.body[:security]).to be_an(Array) + expect(response.body[:security]).not_to be_empty + end +end diff --git a/spec/live/api/stats/stats_spec.rb b/spec/live/api/stats/stats_spec.rb new file mode 100644 index 00000000..2fbedb62 --- /dev/null +++ b/spec/live/api/stats/stats_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/stats' do + subject(:response) { get('/stats') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'returns data as a hash' do + expect(response.body[:data]).to be_a(Hash) + end + + it 'includes extensions stats' do + extensions = response.body[:data][:extensions] + expect(extensions).to be_a(Hash) + expect(extensions[:loaded]).to be_a(Integer) + expect(extensions[:running]).to be_a(Integer) + expect(extensions[:actors]).to be_a(Integer) + end + + it 'includes transport stats' do + transport = response.body[:data][:transport] + expect(transport).to be_a(Hash) + expect(transport[:connected]).to be(true).or be(false) + expect(transport[:connector]).to be_a(String) + end + + it 'includes cache stats' do + cache = response.body[:data][:cache] + expect(cache).to be_a(Hash) + expect(cache[:connected]).to be(true).or be(false) + end + + it 'includes llm stats' do + llm = response.body[:data][:llm] + expect(llm).to be_a(Hash) + expect(llm[:started]).to be(true).or be(false) + end + + it 'includes api stats' do + api = response.body[:data][:api] + expect(api).to be_a(Hash) + expect(api[:port]).to be_a(Integer) + expect(api[:routes]).to be_a(Integer) + end + + it 'includes gaia stats' do + gaia = response.body[:data][:gaia] + expect(gaia).to be_a(Hash) + expect(gaia[:started]).to be(true).or be(false) + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/api/transport/transport_spec.rb b/spec/live/api/transport/transport_spec.rb new file mode 100644 index 00000000..ed571415 --- /dev/null +++ b/spec/live/api/transport/transport_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +RSpec.describe 'GET /api/transport' do + subject(:response) { get('/transport') } + + it 'returns 200' do + expect(response.status).to eq(200) + end + + it 'reports connection status' do + data = response.body[:data] + expect(data[:connected]).to be(true).or be(false) + end + + it 'includes session and channel open status' do + data = response.body[:data] + expect(data).to have_key(:session_open) + expect(data).to have_key(:channel_open) + end + + it 'reports the connector type' do + data = response.body[:data] + expect(data[:connector]).to be_a(String) + end + + it 'includes meta with timestamp and node' do + expect(response.body[:meta][:timestamp]).to be_a(String) + expect(response.body[:meta][:node]).to be_a(String) + end +end diff --git a/spec/live/spec_helper.rb b/spec/live/spec_helper.rb new file mode 100644 index 00000000..ad48dbfe --- /dev/null +++ b/spec/live/spec_helper.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'bundler/setup' +require 'faraday' +require 'faraday/net_http' +require 'json' + +module LiveHelpers + def api(path = '') + base = ENV.fetch('LEGION_API_URL', 'http://localhost:4567') + "#{base}/api#{path}" + end + + def client + @client ||= Faraday.new do |f| + f.request :json + f.response :json, parser_options: { symbolize_names: true } + f.adapter Faraday.default_adapter + end + end + + def get(path) + client.get(api(path)) + end + + def post(path, body = {}) + client.post(api(path), body) + end +end + +RSpec.configure do |config| + config.include LiveHelpers + + config.before(:suite) do + url = ENV.fetch('LEGION_API_URL', 'http://localhost:4567') + begin + resp = Faraday.get("#{url}/api/ready") + unless resp.status == 200 + warn "Legion daemon at #{url} returned #{resp.status} on /api/ready" + abort 'Daemon not ready. Start it with: legionio start' + end + rescue Faraday::ConnectionFailed + abort "Cannot connect to Legion daemon at #{url}. Start it with: legionio start" + end + end + + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.order = :defined +end diff --git a/spec/readiness_spec.rb b/spec/readiness_spec.rb new file mode 100644 index 00000000..cc83bbd7 --- /dev/null +++ b/spec/readiness_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'legion/readiness' + +RSpec.describe Legion::Readiness do + before { described_class.reset } + after { described_class.reset } + + describe 'COMPONENTS' do + it 'includes expected component symbols' do + expect(described_class::COMPONENTS).to include(:settings, :crypt, :transport, :cache, :data, :extensions, :api) + end + + it 'includes llm and rbac in COMPONENTS' do + expect(described_class::COMPONENTS).to include(:llm, :rbac) + end + + it 'is frozen' do + expect(described_class::COMPONENTS).to be_frozen + end + end + + describe 'REQUIRED_COMPONENTS' do + it 'includes core infrastructure components' do + expect(described_class::REQUIRED_COMPONENTS).to include(:settings, :crypt, :transport, :cache, :data, :extensions, :api) + end + + it 'does not include optional components' do + expect(described_class::REQUIRED_COMPONENTS).not_to include(:rbac, :llm, :apollo, :gaia, :identity) + end + end + + describe 'OPTIONAL_COMPONENTS' do + it 'includes optional components' do + expect(described_class::OPTIONAL_COMPONENTS).to include(:rbac, :llm, :apollo, :gaia, :identity) + end + end + + describe 'DRAIN_TIMEOUT' do + it 'is 5' do + expect(described_class::DRAIN_TIMEOUT).to eq(5) + end + end + + describe '.mark_ready' do + it 'marks a component as ready' do + described_class.mark_ready(:settings) + expect(described_class.ready?(:settings)).to eq(true) + end + end + + describe '.mark_not_ready' do + it 'marks a component as not ready' do + described_class.mark_ready(:settings) + described_class.mark_not_ready(:settings) + expect(described_class.ready?(:settings)).to eq(false) + end + end + + describe '.mark_skipped' do + it 'marks a component as skipped' do + described_class.mark_skipped(:rbac) + expect(described_class.status[:rbac]).to eq(:skipped) + end + + it 'counts as ready for individual component check' do + described_class.mark_skipped(:rbac) + expect(described_class.ready?(:rbac)).to eq(true) + end + + it 'counts as ready for global readiness check' do + described_class::COMPONENTS.each do |c| + if described_class::OPTIONAL_COMPONENTS.include?(c) + described_class.mark_skipped(c) + else + described_class.mark_ready(c) + end + end + expect(described_class.ready?).to eq(true) + end + end + + describe '.ready?' do + it 'returns false for unmarked components' do + expect(described_class.ready?(:settings)).to eq(false) + end + + it 'returns true when a specific component is marked ready' do + described_class.mark_ready(:cache) + expect(described_class.ready?(:cache)).to eq(true) + end + + it 'returns false when called without args and not all components are ready' do + described_class.mark_ready(:settings) + expect(described_class.ready?).to eq(false) + end + + it 'returns true when all components are ready' do + described_class::COMPONENTS.each { |c| described_class.mark_ready(c) } + expect(described_class.ready?).to eq(true) + end + + it 'reports ready when optional llm is skipped' do + described_class.reset + described_class::COMPONENTS.each do |c| + if c == :llm + described_class.mark_skipped(c) + else + described_class.mark_ready(c) + end + end + expect(described_class.ready?).to be true + end + + it 'reports not ready when required component is missing' do + described_class.reset + described_class::COMPONENTS.each { |c| described_class.mark_ready(c) unless c == :settings } + expect(described_class.ready?).to be false + end + end + + describe '.reset' do + it 'clears all component status' do + described_class.mark_ready(:settings) + described_class.mark_ready(:cache) + described_class.reset + expect(described_class.ready?(:settings)).to eq(false) + expect(described_class.ready?(:cache)).to eq(false) + end + end + + describe '.to_h' do + it 'returns a hash with all components' do + result = described_class.to_h + expect(result).to be_a(Hash) + described_class::COMPONENTS.each do |c| + expect(result).to have_key(c) + end + end + + it 'returns boolean values' do + described_class.mark_ready(:settings) + result = described_class.to_h + expect(result[:settings]).to eq(true) + expect(result[:cache]).to eq(false) + end + + it 'reports skipped components as true' do + described_class.mark_skipped(:rbac) + result = described_class.to_h + expect(result[:rbac]).to eq(true) + end + end + + describe '.status' do + it 'returns a hash' do + expect(described_class.status).to be_a(Hash) + end + end + + describe '.wait_until_not_ready' do + it 'returns immediately when components are already not ready' do + start = Time.now + described_class.wait_until_not_ready(:settings, timeout: 1) + expect(Time.now - start).to be < 1 + end + end +end diff --git a/spec/runner/log_spec.rb b/spec/runner/log_spec.rb index 4accf03a..04dcf572 100644 --- a/spec/runner/log_spec.rb +++ b/spec/runner/log_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/runner/log' diff --git a/spec/runner/status_spec.rb b/spec/runner/status_spec.rb index 015829fe..5dab9b2e 100644 --- a/spec/runner/status_spec.rb +++ b/spec/runner/status_spec.rb @@ -1,6 +1,18 @@ +# frozen_string_literal: true + require 'spec_helper' require 'legion/runner/log' +module Legion + module Data + module Model + Runner = Class.new unless const_defined?(:Runner, false) + Function = Class.new unless const_defined?(:Function, false) + Task = Class.new unless const_defined?(:Task, false) + end + end +end + RSpec.describe Legion::Runner::Status do describe 'it should have things' do it { is_expected.to be_a Module } @@ -9,4 +21,37 @@ it { is_expected.to respond_to :update_db } it { is_expected.to respond_to :generate_task_id } end + + describe '.generate_task_id' do + context 'when data is not connected' do + before do + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ connected: false }) + end + + it 'returns nil' do + expect(described_class.generate_task_id(runner_class: 'SomeRunner', function: 'run')).to be_nil + end + end + + context 'when data is connected' do + let(:runner_relation) { double('runner_relation', first: nil) } + + before do + allow(Legion::Settings).to receive(:[]).with(:data).and_return({ connected: true }) + allow(Legion::Data::Model::Runner).to receive(:where).and_return(runner_relation) + end + + it 'queries runner namespace without downcasing (preserves mixed case)' do + expect(Legion::Data::Model::Runner) + .to receive(:where).with(namespace: 'Legion::Extensions::MyRunner') + .and_return(runner_relation) + described_class.generate_task_id(runner_class: 'Legion::Extensions::MyRunner', function: 'run') + end + + it 'returns nil when runner is not found' do + result = described_class.generate_task_id(runner_class: 'Legion::Extensions::MyRunner', function: 'run') + expect(result).to be_nil + end + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index eb67c903..e7dff033 100755 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,10 @@ require 'rspec' require 'simplecov' SimpleCov.start +# SimpleCov's at_exit interprets any $! (including RSpec's SystemExit(0) and +# thread IOErrors from Open3) as a "previous error" and forces exit(1). +# Override to let RSpec control the exit code. +SimpleCov.define_singleton_method(:previous_error?) { |_| false } require 'bundler/setup' require 'legion' require 'legion/service' @@ -13,3 +17,16 @@ config.disable_monkey_patching! config.expect_with(:rspec) { |c| c.syntax = :expect } end + +require 'thor' +RSpec::Mocks::AnyInstance::Recorder.prepend(Module.new do + private + + %i[observe! mark_invoked! restore_original_method! remove_dummy_method!].each do |meth| + define_method(meth) do |method_name| + return super(method_name) unless @klass < Thor + + @klass.no_commands_context.enter { super(method_name) } + end + end +end) diff --git a/workflows/autofix-pipeline.yml b/workflows/autofix-pipeline.yml new file mode 100644 index 00000000..0744c9a3 --- /dev/null +++ b/workflows/autofix-pipeline.yml @@ -0,0 +1,58 @@ +name: autofix-pipeline +version: 0.1.0 +description: > + Log event triage → GitHub issue → LLM fix → PR creation. + Triggered by batched exception log events. + +requires: + - lex-autofix + - lex-github + +relationships: + - name: triage-to-diagnose + trigger: + extension: autofix + runner: triage + function: batch_triage + action: + extension: autofix + runner: diagnose + function: check_github + conditions: + all: + - fact: success + operator: equal + value: true + + - name: diagnose-to-fix + trigger: + extension: autofix + runner: diagnose + function: check_github + action: + extension: autofix + runner: fix + function: attempt_fix + conditions: + all: + - fact: success + operator: equal + value: true + - fact: action + operator: not_equal + value: skipped + + - name: fix-to-ship + trigger: + extension: autofix + runner: fix + function: attempt_fix + action: + extension: autofix + runner: ship + function: ship + conditions: + all: + - fact: success + operator: equal + value: true diff --git a/workflows/autonomous-github-lifecycle.yml b/workflows/autonomous-github-lifecycle.yml new file mode 100644 index 00000000..2b249584 --- /dev/null +++ b/workflows/autonomous-github-lifecycle.yml @@ -0,0 +1,67 @@ +name: autonomous-github-lifecycle +version: 0.1.0 +description: > + Autonomous extension generation pipeline. Gap detection generates code, + eval reviews it, and on approval lex-swarm-github creates a branch, PR, + and optionally merges. On rejection, retries generation. + +requires: + - lex-codegen + - lex-eval + - lex-github + - lex-swarm-github + +relationships: + - name: generation-to-review + trigger: + extension: codegen + runner: from_gap + function: generate + action: + extension: eval + runner: code_review + function: review_generated + conditions: + all: + - fact: success + operator: equal + value: true + + - name: review-approve-to-lifecycle + trigger: + extension: eval + runner: code_review + function: review_generated + action: + extension: swarm_github + runner: extension_lifecycle + function: run_lifecycle + conditions: + all: + - fact: verdict + operator: equal + value: approve + + - name: review-reject-to-retry + trigger: + extension: eval + runner: code_review + function: review_generated + action: + extension: codegen + runner: from_gap + function: generate + conditions: + all: + - fact: verdict + operator: equal + value: reject + +settings: + codegen: + self_generate: + enabled: true + github: + enabled: true + target_repo: LegionIO/lex-generated + auto_merge: false diff --git a/workflows/factory-develop-codegen.yml b/workflows/factory-develop-codegen.yml new file mode 100644 index 00000000..87f5b1f7 --- /dev/null +++ b/workflows/factory-develop-codegen.yml @@ -0,0 +1,25 @@ +name: factory-develop-codegen +version: 0.1.0 +description: > + Factory develop stage delegates code generation to lex-codegen. + Each spec requirement becomes a codegen task. + +requires: + - lex-factory + - lex-codegen + +relationships: + - name: develop-to-codegen + trigger: + extension: factory + runner: factory + function: run_pipeline + action: + extension: codegen + runner: from_gap + function: generate + conditions: + all: + - fact: success + operator: equal + value: true diff --git a/workflows/mind-growth-build.yml b/workflows/mind-growth-build.yml new file mode 100644 index 00000000..2735b877 --- /dev/null +++ b/workflows/mind-growth-build.yml @@ -0,0 +1,88 @@ +name: mind-growth-build +version: 0.1.0 +description: > + Extension build pipeline: scaffold → implement → test → validate → register. + Runs locally on the build node. Task relationships provide observability + and conditional retry on test failure. + +requires: + - lex-mind-growth + - lex-codegen + - lex-eval + - lex-exec + +relationships: + - name: scaffold-to-implement + trigger: + extension: mind_growth + runner: builder + function: scaffold_stage + action: + extension: mind_growth + runner: builder + function: implement_stage + conditions: + all: + - fact: success + operator: equal + value: true + + - name: implement-to-test + trigger: + extension: mind_growth + runner: builder + function: implement_stage + action: + extension: mind_growth + runner: builder + function: test_stage + conditions: + all: + - fact: success + operator: equal + value: true + + - name: test-pass-to-validate + trigger: + extension: mind_growth + runner: builder + function: test_stage + action: + extension: mind_growth + runner: builder + function: validate_stage + conditions: + all: + - fact: success + operator: equal + value: true + + - name: test-fail-to-implement-retry + trigger: + extension: mind_growth + runner: builder + function: test_stage + action: + extension: mind_growth + runner: builder + function: implement_stage + conditions: + all: + - fact: success + operator: equal + value: false + + - name: validate-to-register + trigger: + extension: mind_growth + runner: builder + function: validate_stage + action: + extension: mind_growth + runner: builder + function: register_stage + conditions: + all: + - fact: success + operator: equal + value: true diff --git a/workflows/mind-growth-swarm-parallel-build.yml b/workflows/mind-growth-swarm-parallel-build.yml new file mode 100644 index 00000000..565e3c00 --- /dev/null +++ b/workflows/mind-growth-swarm-parallel-build.yml @@ -0,0 +1,42 @@ +name: mind-growth-swarm-parallel-build +version: 0.1.0 +description: > + Swarm-orchestrated parallel build: create swarm → build proposals → complete. + +requires: + - lex-mind-growth + - lex-swarm + +relationships: + - name: create-to-build + trigger: + extension: mind_growth + runner: swarm_builder + function: create_build_swarm + action: + extension: mind_growth + runner: swarm_builder + function: execute_parallel_build + conditions: + all: + - fact: success + operator: equal + value: true + - fact: charter_type + operator: equal + value: parallel_build + + - name: build-to-complete + trigger: + extension: mind_growth + runner: swarm_builder + function: execute_parallel_build + action: + extension: swarm + runner: swarm + function: complete_swarm + conditions: + all: + - fact: success + operator: equal + value: true