""" Recipe Service - business logic for recipe management. Recipes are S-expressions stored in the content-addressed cache (and IPFS). The recipe ID is the content hash of the file. """ import tempfile from pathlib import Path from typing import Optional, List, Dict, Any, Tuple from artdag.sexp import compile_string, parse, serialize, CompileError, ParseError class RecipeService: """ Service for managing recipes. Recipes are S-expressions stored in the content-addressed cache. """ def __init__(self, redis, cache): # Redis kept for compatibility but not used for recipe storage self.redis = redis self.cache = cache async def get_recipe(self, recipe_id: str) -> Optional[Dict[str, Any]]: """Get a recipe by ID (content hash).""" # Get from cache (content-addressed storage) path = self.cache.get_by_cid(recipe_id) if not path or not path.exists(): return None with open(path) as f: content = f.read() # Parse S-expression try: compiled = compile_string(content) recipe_data = compiled.to_dict() recipe_data["sexp"] = content except (ParseError, CompileError) as e: return {"error": str(e), "recipe_id": recipe_id} # Add the recipe_id to the data for convenience recipe_data["recipe_id"] = recipe_id # Get IPFS CID if available ipfs_cid = self.cache.get_ipfs_cid(recipe_id) if ipfs_cid: recipe_data["ipfs_cid"] = ipfs_cid # Compute step_count from nodes nodes = recipe_data.get("dag", {}).get("nodes", []) recipe_data["step_count"] = len(nodes) if isinstance(nodes, (list, dict)) else 0 return recipe_data async def list_recipes(self, actor_id: str = None, offset: int = 0, limit: int = 20) -> list: """ List available recipes for a user. L1 data is isolated per-user - only shows recipes owned by actor_id. """ import logging logger = logging.getLogger(__name__) recipes = [] if hasattr(self.cache, 'list_by_type'): items = self.cache.list_by_type('recipe') logger.info(f"Found {len(items)} recipes in cache") for cid in items: recipe = await self.get_recipe(cid) if recipe and not recipe.get("error"): owner = recipe.get("owner") # Filter by actor - L1 is per-user if actor_id is None or owner == actor_id: recipes.append(recipe) else: logger.warning("Cache does not have list_by_type method") # Sort by name recipes.sort(key=lambda r: r.get("name", "")) return recipes[offset:offset + limit] async def upload_recipe( self, content: str, uploader: str, name: str = None, description: str = None, ) -> Tuple[Optional[str], Optional[str]]: """ Upload a recipe from S-expression content. The recipe is stored in the cache and pinned to IPFS. Returns (recipe_id, error_message). """ # Validate S-expression try: compiled = compile_string(content) except ParseError as e: return None, f"Parse error: {e}" except CompileError as e: return None, f"Compile error: {e}" # Write to temp file for caching try: with tempfile.NamedTemporaryFile(delete=False, suffix=".sexp", mode="w") as tmp: tmp.write(content) tmp_path = Path(tmp.name) # Store in cache (content-addressed, auto-pins to IPFS) cached, ipfs_cid = self.cache.put(tmp_path, node_type="recipe", move=True) recipe_id = cached.cid return recipe_id, None except Exception as e: return None, f"Failed to cache recipe: {e}" async def delete_recipe(self, recipe_id: str, actor_id: str = None) -> Tuple[bool, Optional[str]]: """ Delete a recipe. Note: This only removes from local cache. IPFS copies persist. Returns (success, error_message). """ recipe = await self.get_recipe(recipe_id) if not recipe: return False, "Recipe not found" # Check ownership if actor_id provided if actor_id: recipe_owner = recipe.get("owner") if recipe_owner and recipe_owner != actor_id: return False, "Cannot delete: you don't own this recipe" # Delete from cache try: if hasattr(self.cache, 'delete_by_cid'): success, msg = self.cache.delete_by_cid(recipe_id) if not success: return False, msg else: path = self.cache.get_by_cid(recipe_id) if path and path.exists(): path.unlink() return True, None except Exception as e: return False, f"Failed to delete: {e}" def parse_recipe(self, content: str) -> Dict[str, Any]: """Parse recipe S-expression content.""" compiled = compile_string(content) return compiled.to_dict() def build_dag(self, recipe: Dict[str, Any]) -> Dict[str, Any]: """ Build DAG visualization data from recipe. Returns nodes and edges for Cytoscape.js. """ vis_nodes = [] edges = [] dag = recipe.get("dag", {}) dag_nodes = dag.get("nodes", []) output_node = dag.get("output") # Handle list format (compiled S-expression) if isinstance(dag_nodes, list): for node_def in dag_nodes: node_id = node_def.get("id") node_type = node_def.get("type", "EFFECT") vis_nodes.append({ "data": { "id": node_id, "label": node_id, "nodeType": node_type, "isOutput": node_id == output_node, } }) for input_ref in node_def.get("inputs", []): if isinstance(input_ref, dict): source = input_ref.get("node") or input_ref.get("input") else: source = input_ref if source: edges.append({ "data": { "source": source, "target": node_id, } }) # Handle dict format elif isinstance(dag_nodes, dict): for node_id, node_def in dag_nodes.items(): node_type = node_def.get("type", "EFFECT") vis_nodes.append({ "data": { "id": node_id, "label": node_id, "nodeType": node_type, "isOutput": node_id == output_node, } }) for input_ref in node_def.get("inputs", []): if isinstance(input_ref, dict): source = input_ref.get("node") or input_ref.get("input") else: source = input_ref if source: edges.append({ "data": { "source": source, "target": node_id, } }) return {"nodes": vis_nodes, "edges": edges}