diff --git a/.claude/context/PROJECT.md b/.claude/context/PROJECT.md index 5f6b93012..a3d2f79d3 100644 --- a/.claude/context/PROJECT.md +++ b/.claude/context/PROJECT.md @@ -10,7 +10,7 @@ Replace the sections below with information about your project. --- -## Project: amplihack +## Project: amplihack-oxidizer ## Overview diff --git a/.gitignore b/.gitignore index ed46056ee..76405eed5 100644 --- a/.gitignore +++ b/.gitignore @@ -210,3 +210,6 @@ eval_results.json *_results/ eval_progressive_example/ generated-agents/ + +# Rust recipe runner (separate repo) +amplihack-recipe-runner/ diff --git a/amplifier-bundle/recipes/oxidizer-workflow.yaml b/amplifier-bundle/recipes/oxidizer-workflow.yaml new file mode 100644 index 000000000..6e3755a0f --- /dev/null +++ b/amplifier-bundle/recipes/oxidizer-workflow.yaml @@ -0,0 +1,1131 @@ +name: "oxidizer-workflow" +description: | + Automated Python-to-Rust migration workflow. Treats the Python codebase as + the living specification and iteratively converges toward FULL Rust parity + through a recursive, quality-seeking, goal-evaluating loop. + + ZERO TOLERANCE: Convergence requires 100% parity. No partial results accepted. + Every iteration passes through quality-audit-cycle AND silent-degradation-audit. + Test coverage must be complete BEFORE any code porting begins. + + Phases: Analysis → Test Completeness Gate → Scaffolding → Test Extraction → + [Core-Out Implementation → Comparison → Quality Audit → Silent Degradation + Audit → Convergence Check] × N iterations until 100% parity. +version: "2.0.0" +author: "Amplihack Team" +tags: ["oxidizer", "migration", "python-to-rust", "goal-seeking", "zero-tolerance"] + +recursion: + max_depth: 8 + max_total_steps: 120 + +context: + # Required inputs + task_description: "" + repo_path: "." + python_package_path: "" # e.g. "src/amplihack/recipes" + rust_target_path: "" # e.g. "rust/recipe-runner-rs" + rust_repo_name: "" # e.g. "amplihack-recipe-runner" + rust_repo_org: "" # e.g. "rysweet" (GitHub org/user) + + # Phase 1 outputs + analysis_json: "" + migration_priority: "" + + # Phase 1B: Test completeness gate + test_completeness_result: "" + test_coverage_percentage: "0" + test_coverage_sufficient: "false" + + # Phase 2 outputs + scaffold_result: "" + + # Phase 3 outputs + test_extraction_result: "" + scorecard: "" + + # Phase 4-6 iteration state + current_module: "" + next_module_json: "" + implementation_result: "" + comparison_result: "" + parity_percentage: "0" + iteration_number: "1" + max_iterations: "30" + quality_audit_result: "" + silent_degradation_result: "" + convergence_status: "NOT_CONVERGED" + convergence_json: "" + + # Strict parity enforcement + parity_target: "100" + allow_partial_convergence: "false" + +steps: + # ======================================================================== + # PHASE 1: ANALYSIS + # Map Python modules, dependencies, CLI commands, test coverage + # ======================================================================== + + - id: "phase-1-analyze" + type: "agent" + agent: "amplihack:core:analyzer" + prompt: | + # Oxidizer Phase 1: Comprehensive Analysis + + Analyze the Python codebase for migration to Rust. This analysis must be + EXHAUSTIVE — every public function, every code path, every edge case. + + **Python Package Path:** {{python_package_path}} + **Repository:** {{repo_path}} + + Produce a comprehensive analysis covering: + + 1. **Dependency Graph**: Map ALL Python modules, their imports, and + inter-module dependencies. Identify leaf modules (no internal deps) + vs hub modules (many dependents). Include private helpers. + + 2. **Feature Catalog**: List ALL public AND private APIs, CLI commands, + subcommands, flags, and their behaviors. Include: + - Every function signature with parameter types and return types + - Every class with all methods + - Every constant and enum + - Error types and exception hierarchies + + 3. **Test Coverage**: Measure existing test coverage per-module. + Identify ALL untested code paths, branches, and edge cases. + This is critical — gaps here become gaps in the Rust port. + + 4. **External Integrations**: Catalog ALL external service integrations + (APIs, databases, cloud SDKs, subprocess calls, file I/O, env vars). + + 5. **Migration Priority Order**: Leaf modules first, then inward. + + 6. **Rust Ecosystem Mapping**: For EVERY Python dependency, identify + the Rust equivalent. Flag any with no good equivalent — these need + custom implementations and MUST be explicitly tracked. + + 7. **Complexity Metrics**: LOC, cyclomatic complexity, number of + functions per module. This drives effort estimation. + + **Output as JSON**: + ```json + { + "dependency_graph": {"module_name": ["dep1", "dep2"]}, + "feature_catalog": [{"module": "...", "functions": [...], "classes": [...], "constants": [...]}], + "test_coverage": {"overall": "X%", "per_module": {...}, "untested_paths": [...]}, + "external_integrations": [...], + "migration_priority": ["module1", "module2"], + "rust_mapping": {"python_dep": "rust_crate"}, + "complexity": {"total_loc": 0, "per_module": {...}}, + "total_features": 0 + } + ``` + output: "analysis_json" + parse_json: true + + - id: "extract-analysis" + type: "bash" + command: | + echo '{{analysis_json}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + print(json.dumps(data.get('migration_priority', [])))" 2>/dev/null || echo '[]' + output: "migration_priority" + + # ======================================================================== + # PHASE 1B: TEST COMPLETENESS GATE (MANDATORY) + # Ensure Python test coverage is complete BEFORE porting begins. + # If coverage is insufficient, ADD tests to the Python codebase first. + # ======================================================================== + + - id: "phase-1b-test-completeness-check" + type: "agent" + agent: "amplihack:core:reviewer" + prompt: | + # Oxidizer Phase 1B: Test Completeness Gate + + **CRITICAL**: No porting begins until the Python codebase has comprehensive + test coverage. The Python tests ARE the specification for the Rust port. + Insufficient tests = insufficient specification = guaranteed parity gaps. + + **Python Package Path:** {{python_package_path}} + **Repository:** {{repo_path}} + **Analysis:** {{analysis_json}} + + Evaluate test completeness: + + 1. Run the existing Python test suite. Report pass/fail counts. + 2. Measure line coverage and branch coverage. + 3. For every public function in the feature catalog, verify there is at + least one test exercising it. + 4. For every error path / exception handler, verify there is a test. + 5. For every CLI command/subcommand, verify there is an integration test. + 6. Identify ALL untested code paths. + + If coverage is below 90%, produce a detailed list of EXACTLY which tests + must be written. Group by module and priority. + + **Output as JSON**: + ```json + { + "test_coverage_percentage": N, + "tests_run": N, + "tests_passed": N, + "tests_failed": N, + "coverage_sufficient": true/false, + "untested_functions": ["mod.func1", "mod.func2"], + "untested_error_paths": ["mod.func1:line42"], + "untested_cli_commands": ["cmd1", "cmd2"], + "tests_to_write": [ + {"module": "...", "function": "...", "test_description": "...", "priority": "high"} + ] + } + ``` + output: "test_completeness_result" + parse_json: true + + - id: "extract-test-coverage" + type: "bash" + command: | + echo '{{test_completeness_result}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + pct = data.get('test_coverage_percentage', 0) + print(pct)" 2>/dev/null || echo '0' + output: "test_coverage_percentage" + + - id: "check-test-sufficiency" + type: "bash" + command: | + echo '{{test_completeness_result}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + sufficient = data.get('coverage_sufficient', False) + print('true' if sufficient else 'false')" 2>/dev/null || echo 'false' + output: "test_coverage_sufficient" + + # If tests are insufficient, write the missing tests FIRST + - id: "phase-1b-write-missing-tests" + type: "recipe" + recipe: "default-workflow" + condition: "test_coverage_sufficient == 'false'" + sub_context: + task_description: | + MANDATORY: Write missing Python tests before the Oxidizer migration can proceed. + + The following test gaps were identified in the Python codebase at + {{python_package_path}}. ALL of these must be filled: + + {{test_completeness_result}} + + Requirements: + 1. Write tests for EVERY function listed in untested_functions + 2. Write tests for EVERY error path listed in untested_error_paths + 3. Write integration tests for EVERY CLI command in untested_cli_commands + 4. Use pytest fixtures and parametrize for thorough coverage + 5. Include edge cases: empty inputs, None values, invalid types, boundary values + 6. Run the full test suite and verify ALL tests pass + 7. Achieve at least 90% line coverage + + Do NOT proceed to any other task until test coverage is sufficient. + repo_path: "{{repo_path}}" + output: "test_writing_result" + + # Re-verify coverage after writing tests + - id: "phase-1b-reverify-coverage" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "test_coverage_sufficient == 'false'" + prompt: | + # Re-verify Test Coverage After Writing Missing Tests + + The missing tests have been written. Re-run the full Python test suite + at {{python_package_path}} and verify coverage is now sufficient (>=90%). + + If still insufficient, list remaining gaps. + + **Output as JSON**: + ```json + { + "test_coverage_percentage": N, + "coverage_sufficient": true/false, + "remaining_gaps": [] + } + ``` + output: "test_reverify_result" + parse_json: true + + # ======================================================================== + # PHASE 2: RUST SCAFFOLDING + # Create Cargo workspace mirroring Python structure + # ======================================================================== + + - id: "phase-2-scaffold" + type: "recipe" + recipe: "default-workflow" + sub_context: + task_description: | + Create a Rust Cargo workspace for the project '{{rust_repo_name}}'. + + Based on the analysis of the Python package at {{python_package_path}}: + {{analysis_json}} + + Tasks: + 1. If repo '{{rust_repo_name}}' doesn't exist yet, create it: + gh repo create {{rust_repo_org}}/{{rust_repo_name}} --public --clone + 2. Create a Cargo workspace with modules mirroring the Python structure + 3. Set up GitHub Actions CI that builds and tests the Rust code + 4. Add dependencies to Cargo.toml based on the Rust ecosystem mapping + 5. Create a README.md documenting the migration status + 6. Create a SCORECARD.json with all features from the feature catalog, + all initially set to passes_rust: false + + The Cargo workspace should be at: {{rust_target_path}} + repo_path: "{{repo_path}}" + output: "scaffold_result" + + # ======================================================================== + # PHASE 3: TEST EXTRACTION + # Convert Python tests to Rust BEFORE any implementation + # ======================================================================== + + - id: "phase-3-extract-tests" + type: "recipe" + recipe: "default-workflow" + sub_context: + task_description: | + Extract and convert ALL tests from the Python codebase to Rust for the + '{{rust_repo_name}}' migration. Tests must exist BEFORE implementation. + + Python package: {{python_package_path}} + Rust workspace: {{rust_target_path}} + Analysis: {{analysis_json}} + + MANDATORY — Every single test must be ported: + 1. Convert ALL Python unit tests to Rust #[test] equivalents + 2. Convert ALL integration tests to Rust integration tests + 3. Create shared agentic test scenarios (YAML) that run against both + Python and Rust versions with identical inputs/outputs + 4. Create the Feature Scorecard (scorecard.json) with EVERY feature: + ```json + { + "features": [ + { + "name": "feature_name", + "module": "module_name", + "has_test": true, + "passes_python": true, + "passes_rust": false, + "exec_time_python_ms": null, + "exec_time_rust_ms": null, + "notes": "" + } + ], + "total_features": N, + "features_with_tests": N, + "parity_percentage": 0, + "last_updated": "ISO-8601" + } + ``` + 5. Verify ALL extracted tests pass against the Python version (baseline) + 6. The Rust tests should initially FAIL (compile but fail) — that's expected + + The scorecard MUST list every feature from the analysis. No feature left behind. + repo_path: "{{repo_path}}" + output: "test_extraction_result" + + # Quality audit the test extraction itself + - id: "phase-3-test-quality-audit" + type: "recipe" + recipe: "quality-audit-cycle" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "2" + max_cycles: "4" + categories: "test_gaps,reliability,structural" + + # ======================================================================== + # PHASE 4: CORE-OUT IMPLEMENTATION (Iteration 1) + # Port modules from inside-out, using default-workflow for each module + # ======================================================================== + + - id: "phase-4-select-next-module" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status != 'CONVERGED'" + prompt: | + # Oxidizer Phase 4: Select Next Module to Port (Iteration {{iteration_number}}) + + **Migration Priority Order:** {{migration_priority}} + **Current Scorecard:** {{scorecard}} + **Iteration:** {{iteration_number}} of {{max_iterations}} + **Previous Results:** {{implementation_result}} + **Previous Comparison:** {{comparison_result}} + + Select the next module to port. Rules: + 1. NEVER select a module whose dependencies haven't been ported yet + 2. Select the module with the LARGEST parity gap + 3. If this is a re-visit (module was partially ported), focus on the gaps + + **Output as JSON**: + ```json + { + "module_name": "the_module_to_port", + "reason": "why this module is next", + "dependencies_ported": true, + "estimated_complexity": "low|medium|high", + "specific_gaps_to_close": ["gap1", "gap2"] + } + ``` + output: "next_module_json" + parse_json: true + + - id: "extract-module-name" + type: "bash" + condition: "convergence_status != 'CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + print(data.get('module_name', ''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "phase-4-implement-module" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status != 'CONVERGED'" + sub_context: + task_description: | + Port the Python module '{{current_module}}' to Rust as part of the + {{rust_repo_name}} migration. Iteration {{iteration_number}}. + + **Python Source:** {{python_package_path}}/{{current_module}} + **Rust Target:** {{rust_target_path}} + **Analysis:** {{analysis_json}} + **Specific Gaps to Close:** {{next_module_json}} + + STRICT REQUIREMENTS: + 1. Read the Python source THOROUGHLY — every function, every branch + 2. Write IDIOMATIC Rust — don't transliterate Python line-by-line + 3. Implement ALL public AND private functions + 4. Write #[test] for EVERY function — not just happy paths but error cases too + 5. ALL previously extracted tests for this module MUST pass + 6. Run `cargo test` and verify zero failures + 7. Run `cargo clippy` and fix all warnings + 8. Do NOT delete or modify the Python source + 9. If the previous iteration had gaps, close ALL of them + + Python-to-Rust patterns: + - dataclasses → #[derive(Debug, Clone, Serialize, Deserialize)] + - dict → HashMap or typed structs + - Optional → Option + - exceptions → Result with thiserror + - list comprehension → .iter().map().collect() + - context managers → Drop trait or RAII + repo_path: "{{repo_path}}" + output: "implementation_result" + + # ======================================================================== + # PHASE 5: COMPARISON + QUALITY GATES + # Automated parity checking + quality audit + silent degradation audit + # ======================================================================== + + - id: "phase-5-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status != 'CONVERGED'" + prompt: | + # Oxidizer Phase 5: Strict Parity Comparison (Iteration {{iteration_number}}) + + Compare Python and Rust implementations of '{{current_module}}'. + ZERO TOLERANCE — every feature must match. + + **Python Package:** {{python_package_path}} + **Rust Workspace:** {{rust_target_path}} + **Module:** {{current_module}} + + Run these comparisons: + 1. **Feature Completeness**: For EVERY function in the Python module, + verify the Rust equivalent exists and has the same signature/behavior. + List ALL missing functions — even internal helpers. + 2. **Test Results**: Run `cargo test` on the Rust code. Run `pytest` on + the Python code. Report exact pass/fail for each test. + 3. **Output Comparison**: For key functions, run identical inputs through + both versions and diff the outputs character-by-character. + 4. **Error Behavior**: Verify that the same invalid inputs produce + equivalent errors in both versions. + 5. **Performance**: Measure execution time for both versions. + 6. **Edge Cases**: Test boundary conditions, empty inputs, None/null. + + Update the scorecard. Set parity_percentage to the ACTUAL percentage + of features that fully pass in both versions. + + **Output as JSON**: + ```json + { + "module": "{{current_module}}", + "feature_completeness": {"total": N, "ported": M, "missing": [...]}, + "tests": {"python_pass": N, "python_fail": 0, "rust_pass": M, "rust_fail": K, "rust_failing_tests": [...]}, + "output_matches": true/false, + "error_behavior_matches": true/false, + "performance": {"python_ms": N, "rust_ms": M}, + "parity_percentage": N, + "remaining_gaps": ["specific gap 1", "specific gap 2"] + } + ``` + output: "comparison_result" + parse_json: true + + - id: "extract-parity" + type: "bash" + condition: "convergence_status != 'CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + print(data.get('parity_percentage', 0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + # Quality audit — rigorous, minimum 3 cycles + - id: "phase-5-quality-gate" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status != 'CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "6" + severity_threshold: "medium" + categories: "security,reliability,dead_code,silent_fallbacks,error_swallowing,structural,hardcoded_limits,test_gaps" + + # Silent degradation audit — catch things that "work" but produce wrong results + - id: "phase-5-silent-degradation-audit" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status != 'CONVERGED'" + prompt: | + # Oxidizer: Silent Degradation Audit (Iteration {{iteration_number}}) + + Audit the Rust implementation at {{rust_target_path}} for SILENT DEGRADATION. + These are bugs where the code runs without errors but produces subtly wrong + results — the most dangerous class of migration bugs. + + **Module Under Review:** {{current_module}} + **Python Reference:** {{python_package_path}}/{{current_module}} + + Check these 6 categories: + + 1. **Dependency Failures**: What happens when external dependencies + (subprocess calls, file I/O, network) fail? Does the Rust version + handle failures identically to Python? Look for: + - unwrap() on Results that Python would catch with try/except + - Missing timeout handling that Python has + - Different default values on failure + + 2. **Config/Input Errors**: What happens with malformed input? Does + the Rust version reject the same inputs Python rejects? + - Missing validation that Python performs + - Different parsing behavior for edge cases + - Silent type coercion differences + + 3. **Background/Async Work**: If any async or background operations + exist, do failures propagate identically? + + 4. **Test Effectiveness**: Do the Rust tests actually verify behavior + or just check that code runs without panicking? Look for: + - assert!(result.is_ok()) without checking the actual value + - Tests that always pass regardless of implementation + - Missing negative test cases + + 5. **Operator Visibility**: Are errors and warnings visible? Look for: + - Python logging calls that have no Rust log::* equivalent + - Error messages that differ between versions + - Silent error swallowing (catch-all error handlers) + + 6. **Functional Stubs**: Code that compiles and runs but doesn't + actually implement the intended behavior. Look for: + - Functions that return Ok(()) or default values without real logic + - todo!() or unimplemented!() behind conditional paths + - Incomplete match arms that silently ignore variants + + **Output as JSON**: + ```json + { + "findings_count": N, + "critical_findings": [...], + "high_findings": [...], + "medium_findings": [...], + "all_clear": true/false + } + ``` + output: "silent_degradation_result" + parse_json: true + + # If silent degradation found critical/high issues, fix them + - id: "phase-5-fix-degradation" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status != 'CONVERGED'" + sub_context: + task_description: | + FIX all silent degradation findings in the Rust code at {{rust_target_path}}. + + Findings from the silent degradation audit: + {{silent_degradation_result}} + + For EACH finding: + 1. Understand the Python behavior that the Rust code should match + 2. Fix the Rust code to match Python behavior exactly + 3. Write a regression test proving the fix + 4. Run `cargo test` and verify zero failures + + Do NOT skip any finding. Every silent degradation is a parity gap. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + # ======================================================================== + # PHASE 6: CONVERGENCE CHECK (STRICT — 100% or loop) + # ======================================================================== + + - id: "phase-6-convergence-check" + type: "agent" + agent: "amplihack:core:reviewer" + prompt: | + # Oxidizer Phase 6: Strict Convergence Evaluation (Iteration {{iteration_number}}) + + **ZERO TOLERANCE POLICY**: Convergence means 100% parity. Nothing less. + + **Current Parity:** {{parity_percentage}}% + **Target Parity:** {{parity_target}}% + **Iteration:** {{iteration_number}} of {{max_iterations}} + **Scorecard:** {{scorecard}} + **Latest Comparison:** {{comparison_result}} + **Silent Degradation Findings:** {{silent_degradation_result}} + **Allow Partial:** {{allow_partial_convergence}} + + Evaluation rules: + 1. Parity is EXACTLY 100% AND zero silent degradation findings → CONVERGED + 2. Any remaining gaps, failing tests, or degradation findings → NOT_CONVERGED + 3. max_iterations reached BUT parity < 100% → NOT_CONVERGED (keep going, + raise max_iterations if needed — we do NOT accept partial results) + + For NOT_CONVERGED, identify the SPECIFIC gaps remaining and what must be + done in the next iteration to close them. + + **Output as JSON**: + ```json + { + "convergence_status": "CONVERGED" | "NOT_CONVERGED", + "parity_percentage": N, + "remaining_modules": [...], + "remaining_gaps": ["specific gap description"], + "silent_degradation_clear": true/false, + "quality_audit_clear": true/false, + "recommendation": "specific action for next iteration", + "iteration_number": N + } + ``` + output: "convergence_json" + parse_json: true + + - id: "extract-convergence" + type: "bash" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json + data = json.load(sys.stdin) + print(data.get('convergence_status', 'NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + - id: "increment-iteration" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo $(({{iteration_number}} + 1)) + output: "iteration_number" + + # ======================================================================== + # RECURSIVE LOOP: Iterations 2-5 (repeat the full cycle) + # Each iteration: select → implement → compare → quality audit → + # silent degradation → fix → convergence check + # ======================================================================== + + - id: "loop-2-select" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Oxidizer Iteration {{iteration_number}}: Module Selection + + **Priority:** {{migration_priority}} + **Scorecard:** {{scorecard}} + **Previous Gaps:** {{convergence_json}} + + Select the module with the largest remaining parity gap. + If revisiting a module, list the SPECIFIC gaps to close. + + **Output JSON**: {"module_name": "...", "reason": "...", "specific_gaps_to_close": [...]} + output: "next_module_json" + parse_json: true + + - id: "loop-2-extract-module" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('module_name',''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "loop-2-implement" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Oxidizer iteration {{iteration_number}}: Port/fix '{{current_module}}'. + Python: {{python_package_path}}/{{current_module}} + Rust: {{rust_target_path}} + Gaps to close: {{convergence_json}} + All tests must pass. Run cargo test && cargo clippy. + repo_path: "{{repo_path}}" + output: "implementation_result" + + - id: "loop-2-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Parity Comparison — Iteration {{iteration_number}}, module '{{current_module}}' + Python: {{python_package_path}}, Rust: {{rust_target_path}} + Check feature completeness, test results, output comparison, error behavior. + **Output JSON**: {"module":"...","parity_percentage":N,"remaining_gaps":[...], + "tests":{"rust_pass":N,"rust_fail":K},"feature_completeness":{"total":N,"ported":M,"missing":[...]}} + output: "comparison_result" + parse_json: true + + - id: "loop-2-extract-parity" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('parity_percentage',0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + - id: "loop-2-quality-gate" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "5" + categories: "security,reliability,dead_code,silent_fallbacks,error_swallowing,test_gaps" + + - id: "loop-2-silent-degradation" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Silent Degradation Audit — Iteration {{iteration_number}} + Rust: {{rust_target_path}}, Module: {{current_module}}, Python ref: {{python_package_path}} + Check: dependency failures, config errors, test effectiveness, operator visibility, functional stubs. + **Output JSON**: {"findings_count":N,"critical_findings":[...],"high_findings":[...],"all_clear":true/false} + output: "silent_degradation_result" + parse_json: true + + - id: "loop-2-fix-degradation" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Fix silent degradation findings in {{rust_target_path}}: + {{silent_degradation_result}} + Fix each finding, add regression tests, run cargo test. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + - id: "loop-2-convergence" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Convergence Check — Iteration {{iteration_number}} + Parity: {{parity_percentage}}%, Target: {{parity_target}}% + Comparison: {{comparison_result}}, Degradation: {{silent_degradation_result}} + RULE: 100% parity + zero degradation findings = CONVERGED. Otherwise NOT_CONVERGED. + **Output JSON**: {"convergence_status":"CONVERGED"|"NOT_CONVERGED","parity_percentage":N, + "remaining_gaps":[...],"silent_degradation_clear":true/false} + output: "convergence_json" + parse_json: true + + - id: "loop-2-extract-convergence" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('convergence_status','NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + - id: "loop-2-increment" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: "echo $(({{iteration_number}} + 1))" + output: "iteration_number" + + # --- Iteration 3 --- + - id: "loop-3-select" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Oxidizer Iteration {{iteration_number}}: Module Selection + Priority: {{migration_priority}}, Scorecard: {{scorecard}}, Gaps: {{convergence_json}} + Select next module. Output JSON: {"module_name":"...","reason":"...","specific_gaps_to_close":[...]} + output: "next_module_json" + parse_json: true + + - id: "loop-3-extract-module" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('module_name',''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "loop-3-implement" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Oxidizer iteration {{iteration_number}}: Port/fix '{{current_module}}'. + Python: {{python_package_path}}/{{current_module}}, Rust: {{rust_target_path}} + Gaps: {{convergence_json}}. All tests must pass. cargo test && cargo clippy. + repo_path: "{{repo_path}}" + output: "implementation_result" + + - id: "loop-3-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Parity Comparison — Iteration {{iteration_number}}, module '{{current_module}}' + Python: {{python_package_path}}, Rust: {{rust_target_path}} + Output JSON: {"module":"...","parity_percentage":N,"remaining_gaps":[...], + "tests":{"rust_pass":N,"rust_fail":K},"feature_completeness":{"total":N,"ported":M,"missing":[...]}} + output: "comparison_result" + parse_json: true + + - id: "loop-3-parity" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('parity_percentage',0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + - id: "loop-3-quality" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "5" + categories: "security,reliability,silent_fallbacks,error_swallowing,test_gaps" + + - id: "loop-3-degradation" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Silent Degradation Audit — Iteration {{iteration_number}} + Rust: {{rust_target_path}}, Module: {{current_module}}, Python: {{python_package_path}} + Output JSON: {"findings_count":N,"critical_findings":[...],"all_clear":true/false} + output: "silent_degradation_result" + parse_json: true + + - id: "loop-3-fix" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Fix degradation in {{rust_target_path}}: {{silent_degradation_result}} + Add regression tests. cargo test. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + - id: "loop-3-convergence" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Convergence — Iteration {{iteration_number}} + Parity: {{parity_percentage}}%, Target: 100%, Degradation: {{silent_degradation_result}} + RULE: 100% + zero degradation = CONVERGED. Otherwise NOT_CONVERGED. + Output JSON: {"convergence_status":"...","parity_percentage":N,"remaining_gaps":[...]} + output: "convergence_json" + parse_json: true + + - id: "loop-3-extract" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('convergence_status','NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + - id: "loop-3-increment" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: "echo $(({{iteration_number}} + 1))" + output: "iteration_number" + + # --- Iteration 4 --- + - id: "loop-4-select" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Oxidizer Iteration {{iteration_number}}: Module Selection + Priority: {{migration_priority}}, Gaps: {{convergence_json}} + Output JSON: {"module_name":"...","reason":"...","specific_gaps_to_close":[...]} + output: "next_module_json" + parse_json: true + + - id: "loop-4-extract-module" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('module_name',''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "loop-4-implement" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Oxidizer iteration {{iteration_number}}: Port/fix '{{current_module}}'. + Python: {{python_package_path}}/{{current_module}}, Rust: {{rust_target_path}} + Gaps: {{convergence_json}}. cargo test && cargo clippy. + repo_path: "{{repo_path}}" + output: "implementation_result" + + - id: "loop-4-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Parity — Iteration {{iteration_number}}, '{{current_module}}' + Python: {{python_package_path}}, Rust: {{rust_target_path}} + Output JSON: {"module":"...","parity_percentage":N,"remaining_gaps":[...], + "tests":{"rust_pass":N,"rust_fail":K}} + output: "comparison_result" + parse_json: true + + - id: "loop-4-parity" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('parity_percentage',0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + - id: "loop-4-quality" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "5" + categories: "security,reliability,silent_fallbacks,error_swallowing,test_gaps" + + - id: "loop-4-degradation" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Silent Degradation — Iteration {{iteration_number}} + Rust: {{rust_target_path}}, Module: {{current_module}}, Python: {{python_package_path}} + Output JSON: {"findings_count":N,"critical_findings":[...],"all_clear":true/false} + output: "silent_degradation_result" + parse_json: true + + - id: "loop-4-fix" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Fix degradation in {{rust_target_path}}: {{silent_degradation_result}} + Regression tests. cargo test. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + - id: "loop-4-convergence" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Convergence — Iteration {{iteration_number}} + Parity: {{parity_percentage}}%, Degradation: {{silent_degradation_result}} + 100% + zero degradation = CONVERGED. Otherwise NOT_CONVERGED. + Output JSON: {"convergence_status":"...","parity_percentage":N,"remaining_gaps":[...]} + output: "convergence_json" + parse_json: true + + - id: "loop-4-extract" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('convergence_status','NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + - id: "loop-4-increment" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: "echo $(({{iteration_number}} + 1))" + output: "iteration_number" + + # --- Iteration 5 --- + - id: "loop-5-select" + type: "agent" + agent: "amplihack:core:architect" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Oxidizer Iteration {{iteration_number}}: Module Selection + Priority: {{migration_priority}}, Gaps: {{convergence_json}} + Output JSON: {"module_name":"...","reason":"...","specific_gaps_to_close":[...]} + output: "next_module_json" + parse_json: true + + - id: "loop-5-extract-module" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{next_module_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('module_name',''))" 2>/dev/null || echo '' + output: "current_module" + + - id: "loop-5-implement" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Oxidizer iteration {{iteration_number}}: FINAL PUSH on '{{current_module}}'. + Python: {{python_package_path}}/{{current_module}}, Rust: {{rust_target_path}} + Gaps: {{convergence_json}}. Close EVERY remaining gap. cargo test && cargo clippy. + repo_path: "{{repo_path}}" + output: "implementation_result" + + - id: "loop-5-compare" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Final Parity — Iteration {{iteration_number}}, '{{current_module}}' + Python: {{python_package_path}}, Rust: {{rust_target_path}} + Output JSON: {"module":"...","parity_percentage":N,"remaining_gaps":[...], + "tests":{"rust_pass":N,"rust_fail":K}} + output: "comparison_result" + parse_json: true + + - id: "loop-5-parity" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{comparison_result}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('parity_percentage',0))" 2>/dev/null || echo '0' + output: "parity_percentage" + + - id: "loop-5-quality" + type: "recipe" + recipe: "quality-audit-cycle" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + target_path: "{{rust_target_path}}" + min_cycles: "3" + max_cycles: "6" + categories: "security,reliability,silent_fallbacks,error_swallowing,test_gaps" + + - id: "loop-5-degradation" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Silent Degradation — Iteration {{iteration_number}} + Rust: {{rust_target_path}}, Module: {{current_module}}, Python: {{python_package_path}} + Output JSON: {"findings_count":N,"critical_findings":[...],"all_clear":true/false} + output: "silent_degradation_result" + parse_json: true + + - id: "loop-5-fix" + type: "recipe" + recipe: "default-workflow" + condition: "convergence_status == 'NOT_CONVERGED'" + sub_context: + task_description: | + Fix degradation in {{rust_target_path}}: {{silent_degradation_result}} + Regression tests. cargo test. + repo_path: "{{repo_path}}" + output: "degradation_fix_result" + + - id: "loop-5-convergence" + type: "agent" + agent: "amplihack:core:reviewer" + condition: "convergence_status == 'NOT_CONVERGED'" + prompt: | + # Final Convergence — Iteration {{iteration_number}} + Parity: {{parity_percentage}}%, Degradation: {{silent_degradation_result}} + 100% + zero degradation = CONVERGED. Otherwise NOT_CONVERGED. + If NOT_CONVERGED after 5 iterations, detail EXACTLY what remains. + Output JSON: {"convergence_status":"...","parity_percentage":N,"remaining_gaps":[...]} + output: "convergence_json" + parse_json: true + + - id: "loop-5-extract" + type: "bash" + condition: "convergence_status == 'NOT_CONVERGED'" + command: | + echo '{{convergence_json}}' | python3 -c " + import sys, json; print(json.load(sys.stdin).get('convergence_status','NOT_CONVERGED'))" 2>/dev/null || echo 'NOT_CONVERGED' + output: "convergence_status" + + # ======================================================================== + # FINAL SUMMARY + # ======================================================================== + + - id: "final-summary" + type: "agent" + agent: "amplihack:core:reviewer" + prompt: | + # Oxidizer Migration Report + + **Final Parity:** {{parity_percentage}}% + **Target Parity:** {{parity_target}}% + **Total Iterations:** {{iteration_number}} + **Convergence Status:** {{convergence_status}} + **Final Scorecard:** {{scorecard}} + **Silent Degradation Clear:** Check {{silent_degradation_result}} + + Produce a comprehensive final report: + 1. Summary of ALL modules ported + 2. Final scorecard with every feature and its status + 3. Performance comparison (Python vs Rust) per module + 4. Quality audit history across iterations + 5. Silent degradation audit results + 6. If parity < 100%: detailed list of EVERY remaining gap with + specific instructions for closing each one + 7. If parity == 100%: recommendation for deprecating Python version, + including a migration checklist for downstream consumers + + Format as a markdown report for a GitHub issue comment. + output: "final_report" diff --git a/pyproject.toml b/pyproject.toml index 841c8ebe8..2f85e6867 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ backend-path = ["."] [project] name = "amplihack" -version = "0.5.116" +version = "0.5.119" description = "Amplifier bundle for agentic coding with comprehensive skills, recipes, and workflows" requires-python = ">=3.11" dependencies = [ diff --git a/src/amplihack/recipes/__init__.py b/src/amplihack/recipes/__init__.py index 692977379..ee40183b0 100644 --- a/src/amplihack/recipes/__init__.py +++ b/src/amplihack/recipes/__init__.py @@ -38,6 +38,13 @@ from amplihack.recipes.parser import RecipeParser from amplihack.recipes.runner import RecipeRunner +from amplihack.recipes.rust_runner import ( + RustRunnerNotFoundError, + find_rust_binary, + is_rust_runner_available, + run_recipe_via_rust, +) + __all__ = [ "AgentNotFoundError", "AgentResolver", @@ -47,6 +54,7 @@ "RecipeRunner", "Recipe", "RecipeResult", + "RustRunnerNotFoundError", "Step", "StepExecutionError", "StepResult", @@ -55,10 +63,13 @@ "check_upstream_changes", "discover_recipes", "find_recipe", + "find_rust_binary", + "is_rust_runner_available", "list_recipes", "parse_recipe", "run_recipe", "run_recipe_by_name", + "run_recipe_via_rust", "sync_upstream", "update_manifest", "verify_global_installation", @@ -90,15 +101,54 @@ def run_recipe_by_name( ) -> RecipeResult: """Find a recipe by name, parse it, and execute it. + Engine selection (no fallbacks — chosen engine must succeed or fail): + + - ``RECIPE_RUNNER_ENGINE=rust`` → Rust binary only (fails if not installed) + - ``RECIPE_RUNNER_ENGINE=python`` → Python runner only + - Not set → auto-detect once: Rust if binary exists, Python otherwise. + Logs which engine was selected. + Args: name: Recipe name (e.g. ``"default-workflow"``). - adapter: SDK adapter for step execution. + adapter: SDK adapter for step execution (used by Python engine). user_context: Context variable overrides. dry_run: If True, log steps without executing. Raises: FileNotFoundError: If no recipe with that name is found. + RustRunnerNotFoundError: If engine is 'rust' but binary is missing. """ + import os + + engine = os.environ.get("RECIPE_RUNNER_ENGINE", "").lower() + + if engine == "rust": + # Explicit Rust — no fallback + return run_recipe_via_rust(name=name, user_context=user_context, dry_run=dry_run) + + if engine == "python": + # Explicit Python — no Rust attempted + return _run_recipe_python(name, adapter, user_context, dry_run) + + # Auto-detect: check once, commit to the result, log clearly + import logging + _log = logging.getLogger(__name__) + + if is_rust_runner_available(): + _log.info("RECIPE_RUNNER_ENGINE not set — auto-selected 'rust' (binary found in PATH)") + return run_recipe_via_rust(name=name, user_context=user_context, dry_run=dry_run) + + _log.info("RECIPE_RUNNER_ENGINE not set — auto-selected 'python' (rust binary not found)") + return _run_recipe_python(name, adapter, user_context, dry_run) + + +def _run_recipe_python( + name: str, + adapter: Any, + user_context: dict[str, Any] | None, + dry_run: bool, +) -> RecipeResult: + """Execute a recipe using the Python runner.""" path = find_recipe(name) if path is None: raise FileNotFoundError(f"Recipe '{name}' not found in any search directory") diff --git a/src/amplihack/recipes/rust_runner.py b/src/amplihack/recipes/rust_runner.py new file mode 100644 index 000000000..114f2d173 --- /dev/null +++ b/src/amplihack/recipes/rust_runner.py @@ -0,0 +1,149 @@ +"""Rust recipe runner integration. + +Delegates recipe execution to the ``recipe-runner-rs`` binary. +No fallbacks — if the Rust engine is selected and the binary is missing, +execution fails immediately with a clear error. +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +from amplihack.recipes.models import RecipeResult, StepResult, StepStatus + +logger = logging.getLogger(__name__) + +# Known locations to search for the Rust binary +_BINARY_SEARCH_PATHS = [ + "recipe-runner-rs", # PATH + str(Path.home() / ".cargo" / "bin" / "recipe-runner-rs"), + str(Path.home() / ".local" / "bin" / "recipe-runner-rs"), +] + + +def find_rust_binary() -> str | None: + """Find the recipe-runner-rs binary. + + Checks the RECIPE_RUNNER_RS_PATH env var first, then known locations. + Returns the path to the binary, or None if not found. + """ + env_path = os.environ.get("RECIPE_RUNNER_RS_PATH") + if env_path and shutil.which(env_path): + return env_path + + for candidate in _BINARY_SEARCH_PATHS: + resolved = shutil.which(candidate) + if resolved: + return resolved + + return None + + +def is_rust_runner_available() -> bool: + """Check if the Rust recipe runner binary is available.""" + return find_rust_binary() is not None + + +class RustRunnerNotFoundError(RuntimeError): + """Raised when the Rust recipe runner binary is required but not found.""" + + +def run_recipe_via_rust( + name: str, + user_context: dict[str, Any] | None = None, + dry_run: bool = False, + recipe_dirs: list[str] | None = None, + working_dir: str = ".", + auto_stage: bool = True, +) -> RecipeResult: + """Execute a recipe using the Rust binary. + + Raises: + RustRunnerNotFoundError: If the binary is not installed. + RuntimeError: If the binary produces unparseable output. + """ + binary = find_rust_binary() + if binary is None: + raise RustRunnerNotFoundError( + "recipe-runner-rs binary not found. " + "Install it: cargo install --git https://github.com/rysweet/amplihack-recipe-runner " + "or set RECIPE_RUNNER_RS_PATH to the binary location." + ) + + cmd = [binary, name, "--output-format", "json", "-C", working_dir] + + if dry_run: + cmd.append("--dry-run") + + if not auto_stage: + cmd.append("--no-auto-stage") + + if recipe_dirs: + for d in recipe_dirs: + cmd.extend(["-R", d]) + + if user_context: + for key, value in user_context.items(): + if isinstance(value, (dict, list)): + cmd.extend(["--set", f"{key}={json.dumps(value)}"]) + elif isinstance(value, bool): + cmd.extend(["--set", f"{key}={'true' if value else 'false'}"]) + else: + cmd.extend(["--set", f"{key}={value}"]) + + logger.info("Executing recipe '%s' via Rust binary: %s", name, " ".join(cmd)) + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=working_dir, + timeout=3600, # 1 hour hard limit — recipes can be long-running + ) + + # Parse JSON output + try: + data = json.loads(result.stdout) + except (json.JSONDecodeError, TypeError): + if result.returncode != 0: + raise RuntimeError( + f"Rust recipe runner failed (exit {result.returncode}): " + f"{result.stderr[:1000] if result.stderr else 'no stderr'}" + ) + raise RuntimeError( + f"Rust recipe runner returned unparseable output (exit {result.returncode}): " + f"{result.stdout[:500] if result.stdout else 'empty stdout'}" + ) + + # Convert JSON output to RecipeResult + step_results = [] + for sr in data.get("step_results", []): + status_str = sr.get("status", "failed").lower() + status_map = { + "completed": StepStatus.COMPLETED, + "skipped": StepStatus.SKIPPED, + "failed": StepStatus.FAILED, + "pending": StepStatus.PENDING, + "running": StepStatus.RUNNING, + } + step_results.append( + StepResult( + step_id=sr.get("step_id", "unknown"), + status=status_map.get(status_str, StepStatus.FAILED), + output=sr.get("output", ""), + error=sr.get("error", ""), + ) + ) + + return RecipeResult( + recipe_name=data.get("recipe_name", name), + success=data.get("success", False), + step_results=step_results, + context=data.get("context", {}), + ) diff --git a/tests/recipes/test_rust_runner.py b/tests/recipes/test_rust_runner.py new file mode 100644 index 000000000..3eef8f42c --- /dev/null +++ b/tests/recipes/test_rust_runner.py @@ -0,0 +1,270 @@ +"""Tests for the Rust recipe runner integration (rust_runner.py). + +Covers: +- Binary discovery (find_rust_binary, is_rust_runner_available) +- Recipe execution via Rust binary (run_recipe_via_rust) +- JSON output parsing and error handling +- Engine selection in run_recipe_by_name +""" + +from __future__ import annotations + +import json +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from amplihack.recipes.rust_runner import ( + RustRunnerNotFoundError, + find_rust_binary, + is_rust_runner_available, + run_recipe_via_rust, +) +from amplihack.recipes.models import StepStatus + + +# ============================================================================ +# find_rust_binary +# ============================================================================ + + +class TestFindRustBinary: + """Tests for find_rust_binary().""" + + @patch.dict("os.environ", {"RECIPE_RUNNER_RS_PATH": "/usr/local/bin/recipe-runner-rs"}) + @patch("shutil.which", return_value="/usr/local/bin/recipe-runner-rs") + def test_env_var_takes_priority(self, mock_which): + result = find_rust_binary() + assert result == "/usr/local/bin/recipe-runner-rs" + + @patch.dict("os.environ", {"RECIPE_RUNNER_RS_PATH": "/nonexistent/binary"}) + @patch("shutil.which", return_value=None) + def test_env_var_invalid_returns_none(self, mock_which): + result = find_rust_binary() + assert result is None + + @patch.dict("os.environ", {}, clear=True) + @patch("shutil.which", side_effect=lambda p: "/usr/bin/recipe-runner-rs" if p == "recipe-runner-rs" else None) + def test_path_lookup(self, mock_which): + result = find_rust_binary() + assert result == "/usr/bin/recipe-runner-rs" + + @patch.dict("os.environ", {}, clear=True) + @patch("shutil.which", return_value=None) + def test_not_found(self, mock_which): + result = find_rust_binary() + assert result is None + + +class TestIsRustRunnerAvailable: + """Tests for is_rust_runner_available().""" + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + def test_available(self, mock_find): + assert is_rust_runner_available() is True + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value=None) + def test_not_available(self, mock_find): + assert is_rust_runner_available() is False + + +# ============================================================================ +# run_recipe_via_rust +# ============================================================================ + + +class TestRunRecipeViaRust: + """Tests for run_recipe_via_rust().""" + + def _make_rust_output(self, *, success=True, steps=None): + """Helper to create valid Rust binary JSON output.""" + if steps is None: + steps = [ + {"step_id": "s1", "status": "Completed", "output": "hello", "error": ""}, + ] + return json.dumps({ + "recipe_name": "test-recipe", + "success": success, + "step_results": steps, + "context": {"result": "done"}, + }) + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value=None) + def test_raises_when_binary_missing(self, mock_find): + with pytest.raises(RustRunnerNotFoundError, match="recipe-runner-rs binary not found"): + run_recipe_via_rust("test-recipe") + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_successful_execution(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + result = run_recipe_via_rust("test-recipe") + assert result.success is True + assert result.recipe_name == "test-recipe" + assert len(result.step_results) == 1 + assert result.step_results[0].step_id == "s1" + assert result.step_results[0].status == StepStatus.COMPLETED + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_passes_dry_run_flag(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe", dry_run=True) + cmd = mock_run.call_args[0][0] + assert "--dry-run" in cmd + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_passes_no_auto_stage_flag(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe", auto_stage=False) + cmd = mock_run.call_args[0][0] + assert "--no-auto-stage" in cmd + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_passes_recipe_dirs(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe", recipe_dirs=["/a", "/b"]) + cmd = mock_run.call_args[0][0] + assert "-R" in cmd + idx = cmd.index("-R") + assert cmd[idx + 1] == "/a" + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_passes_context_values(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe", user_context={ + "name": "world", + "verbose": True, + "data": {"key": "val"}, + }) + cmd = mock_run.call_args[0][0] + set_args = [cmd[i + 1] for i, v in enumerate(cmd) if v == "--set"] + assert "name=world" in set_args + assert "verbose=true" in set_args + assert any('"key"' in a for a in set_args) + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_has_timeout(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(), + stderr="", + ) + run_recipe_via_rust("test-recipe") + assert mock_run.call_args[1].get("timeout") == 3600 + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_nonzero_exit_with_bad_json_raises(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=1, + stdout="not json", + stderr="error: recipe failed", + ) + with pytest.raises(RuntimeError, match="Rust recipe runner failed"): + run_recipe_via_rust("test-recipe") + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_zero_exit_with_bad_json_raises(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout="not json at all", + stderr="", + ) + with pytest.raises(RuntimeError, match="unparseable output"): + run_recipe_via_rust("test-recipe") + + @patch("amplihack.recipes.rust_runner.find_rust_binary", return_value="/usr/bin/recipe-runner-rs") + @patch("subprocess.run") + def test_status_mapping(self, mock_run, mock_find): + mock_run.return_value = subprocess.CompletedProcess( + args=[], returncode=0, + stdout=self._make_rust_output(steps=[ + {"step_id": "a", "status": "Completed", "output": "", "error": ""}, + {"step_id": "b", "status": "Skipped", "output": "", "error": ""}, + {"step_id": "c", "status": "Failed", "output": "", "error": "boom"}, + {"step_id": "d", "status": "unknown_status", "output": "", "error": ""}, + ]), + stderr="", + ) + result = run_recipe_via_rust("test-recipe") + assert result.step_results[0].status == StepStatus.COMPLETED + assert result.step_results[1].status == StepStatus.SKIPPED + assert result.step_results[2].status == StepStatus.FAILED + assert result.step_results[3].status == StepStatus.FAILED # unknown → FAILED + + +# ============================================================================ +# Engine selection (run_recipe_by_name) +# ============================================================================ + + +class TestEngineSelection: + """Tests for run_recipe_by_name engine selection.""" + + @patch.dict("os.environ", {"RECIPE_RUNNER_ENGINE": "rust"}) + @patch("amplihack.recipes.run_recipe_via_rust") + def test_explicit_rust_engine(self, mock_rust): + from amplihack.recipes import run_recipe_by_name + mock_rust.return_value = MagicMock() + run_recipe_by_name("test", adapter=MagicMock()) + mock_rust.assert_called_once() + + @patch.dict("os.environ", {"RECIPE_RUNNER_ENGINE": "python"}) + @patch("amplihack.recipes._run_recipe_python") + def test_explicit_python_engine(self, mock_python): + from amplihack.recipes import run_recipe_by_name + mock_python.return_value = MagicMock() + run_recipe_by_name("test", adapter=MagicMock()) + mock_python.assert_called_once() + + @patch.dict("os.environ", {}, clear=True) + @patch("amplihack.recipes.is_rust_runner_available", return_value=True) + @patch("amplihack.recipes.run_recipe_via_rust") + def test_auto_detect_prefers_rust(self, mock_rust, mock_avail): + from amplihack.recipes import run_recipe_by_name + mock_rust.return_value = MagicMock() + run_recipe_by_name("test", adapter=MagicMock()) + mock_rust.assert_called_once() + + @patch.dict("os.environ", {}, clear=True) + @patch("amplihack.recipes.is_rust_runner_available", return_value=False) + @patch("amplihack.recipes._run_recipe_python") + def test_auto_detect_uses_python_when_no_rust(self, mock_python, mock_avail): + from amplihack.recipes import run_recipe_by_name + mock_python.return_value = MagicMock() + run_recipe_by_name("test", adapter=MagicMock()) + mock_python.assert_called_once() + + @patch.dict("os.environ", {"RECIPE_RUNNER_ENGINE": "rust"}) + @patch("amplihack.recipes.run_recipe_via_rust", side_effect=RustRunnerNotFoundError("not found")) + def test_explicit_rust_fails_hard(self, mock_rust): + from amplihack.recipes import run_recipe_by_name + with pytest.raises(RustRunnerNotFoundError): + run_recipe_by_name("test", adapter=MagicMock())