Files
timmy-config/scripts/recipe_engine.py

118 lines
4.0 KiB
Python
Raw Normal View History

2026-04-11 00:52:28 +00:00
#!/usr/bin/env python3
"""
recipe_engine.py Deterministic Workflow Orchestrator for the Timmy Foundation.
Executes "Recipes" sequences of deterministic steps (shell commands, scripts)
to reduce reliance on LLM reasoning for common, repetitive tasks.
Usage:
python3 scripts/recipe_engine.py recipes/deploy_beacon.json --vars REPO_PATH=/opt/the-beacon
"""
import os
import sys
import json
import subprocess
import argparse
import time
from datetime import datetime
from typing import List, Dict, Any
class RecipeEngine:
def __init__(self, vars: Dict[str, str] = None):
self.vars = vars or {}
self.vars["TIMESTAMP"] = datetime.now().strftime("%Y%m%d-%H%M%S")
def log(self, message: str):
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{ts}] {message}")
def substitute_vars(self, text: str) -> str:
for k, v in self.vars.items():
text = text.replace(f"{{{{{k}}}}}", v)
return text
def execute_step(self, step: Dict[str, Any]) -> bool:
name = step.get("name", "Unnamed Step")
command = step.get("command")
if not command:
self.log(f" [SKIP] {name}: No command provided.")
return True
command = self.substitute_vars(command)
self.log(f" [EXEC] {name}: {command}")
try:
start_time = time.time()
# Use shell=True to allow pipes and redirections in recipes
process = subprocess.run(command, shell=True, capture_output=True, text=True)
duration = time.time() - start_time
if process.returncode == 0:
self.log(f" [OK] {name} completed in {duration:.2f}s")
if process.stdout.strip():
# Print first few lines of output
lines = process.stdout.strip().split("\n")
for line in lines[:3]:
print(f" > {line}")
if len(lines) > 3:
print(f" > ... ({len(lines)-3} more lines)")
return True
else:
self.log(f" [FAIL] {name} failed with exit code {process.returncode}")
print(f" [ERR] {process.stderr.strip()}")
return False
except Exception as e:
self.log(f" [ERROR] {name} encountered an exception: {e}")
return False
def run_recipe(self, recipe_path: str) -> bool:
try:
with open(recipe_path, "r") as f:
recipe = json.load(f)
except Exception as e:
self.log(f"Failed to load recipe {recipe_path}: {e}")
return False
name = recipe.get("name", "Unknown Recipe")
description = recipe.get("description", "")
steps = recipe.get("steps", [])
self.log(f"--- Starting Recipe: {name} ---")
if description:
self.log(f"Description: {description}")
self.log(f"Steps to execute: {len(steps)}")
for i, step in enumerate(steps, 1):
self.log(f"Step {i}/{len(steps)}: {step.get('name')}")
success = self.execute_step(step)
if not success and not step.get("continue_on_failure", False):
self.log(f"Recipe {name} aborted due to failure in step {i}.")
return False
self.log(f"--- Recipe {name} completed successfully ---")
return True
def main():
parser = argparse.ArgumentParser(description="Deterministic Recipe Engine")
parser.add_argument("recipe", help="Path to the JSON recipe file")
parser.add_argument("--vars", nargs="*", help="Variables in KEY=VALUE format")
args = parser.parse_args()
variables = {}
if args.vars:
for v in args.vars:
if "=" in v:
k, val = v.split("=", 1)
variables[k] = val
engine = RecipeEngine(vars=variables)
success = engine.run_recipe(args.recipe)
if not success:
sys.exit(1)
if __name__ == "__main__":
main()