""" Recipe management routes for L1 server. Handles recipe upload, listing, viewing, and execution. """ import logging from typing import List, Optional from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File from fastapi.responses import HTMLResponse from pydantic import BaseModel from artdag_common import render from artdag_common.middleware import wants_html, wants_json from ..dependencies import require_auth, get_templates, get_redis_client from ..services.auth_service import UserContext, AuthService from ..services.recipe_service import RecipeService router = APIRouter() logger = logging.getLogger(__name__) class RecipeUploadRequest(BaseModel): yaml_content: str name: Optional[str] = None description: Optional[str] = None def get_recipe_service(): """Get recipe service instance.""" return RecipeService(get_redis_client()) @router.post("/upload") async def upload_recipe( req: RecipeUploadRequest, ctx: UserContext = Depends(require_auth), recipe_service: RecipeService = Depends(get_recipe_service), ): """Upload a new recipe from YAML.""" recipe_id, error = await recipe_service.upload_recipe( yaml_content=req.yaml_content, uploader=ctx.actor_id, name=req.name, description=req.description, ) if error: raise HTTPException(400, error) return {"recipe_id": recipe_id, "message": "Recipe uploaded successfully"} @router.get("") async def list_recipes( request: Request, offset: int = 0, limit: int = 20, recipe_service: RecipeService = Depends(get_recipe_service), ): """List available recipes.""" auth_service = AuthService(get_redis_client()) ctx = auth_service.get_user_from_cookie(request) if not ctx: if wants_json(request): raise HTTPException(401, "Authentication required") from fastapi.responses import RedirectResponse return RedirectResponse(url="/auth", status_code=302) recipes = await recipe_service.list_recipes(ctx.actor_id, offset=offset, limit=limit) if wants_json(request): return {"recipes": recipes, "offset": offset, "limit": limit} templates = get_templates(request) return render(templates, "recipes/list.html", request, recipes=recipes, user=ctx, active_tab="recipes", ) @router.get("/{recipe_id}") async def get_recipe( recipe_id: str, request: Request, recipe_service: RecipeService = Depends(get_recipe_service), ): """Get recipe details.""" auth_service = AuthService(get_redis_client()) ctx = auth_service.get_user_from_cookie(request) if not ctx: if wants_json(request): raise HTTPException(401, "Authentication required") from fastapi.responses import RedirectResponse return RedirectResponse(url="/auth", status_code=302) recipe = await recipe_service.get_recipe(recipe_id) if not recipe: raise HTTPException(404, "Recipe not found") if wants_json(request): return recipe # Build DAG elements for visualization dag_elements = [] node_colors = { "input": "#3b82f6", "effect": "#8b5cf6", "analyze": "#ec4899", "transform": "#10b981", "output": "#f59e0b", } for i, step in enumerate(recipe.get("steps", [])): step_id = step.get("id", f"step-{i}") dag_elements.append({ "data": { "id": step_id, "label": step.get("name", f"Step {i+1}"), "color": node_colors.get(step.get("type", "effect"), "#6b7280"), } }) for inp in step.get("inputs", []): dag_elements.append({ "data": {"source": inp, "target": step_id} }) templates = get_templates(request) return render(templates, "recipes/detail.html", request, recipe=recipe, dag_elements=dag_elements, user=ctx, active_tab="recipes", ) @router.delete("/{recipe_id}") async def delete_recipe( recipe_id: str, ctx: UserContext = Depends(require_auth), recipe_service: RecipeService = Depends(get_recipe_service), ): """Delete a recipe.""" success, error = await recipe_service.delete_recipe(recipe_id, ctx.actor_id) if error: raise HTTPException(400 if "Cannot" in error else 404, error) return {"deleted": True, "recipe_id": recipe_id} @router.post("/{recipe_id}/run") async def run_recipe( recipe_id: str, inputs: List[str], ctx: UserContext = Depends(require_auth), recipe_service: RecipeService = Depends(get_recipe_service), ): """Run a recipe with given inputs.""" from ..services.run_service import RunService from ..dependencies import get_cache_manager import database recipe = await recipe_service.get_recipe(recipe_id) if not recipe: raise HTTPException(404, "Recipe not found") # Create run using run service run_service = RunService(database, get_redis_client(), get_cache_manager()) run, error = await run_service.create_run( recipe=recipe.get("name", recipe_id), inputs=inputs, use_dag=True, actor_id=ctx.actor_id, l2_server=ctx.l2_server, ) if error: raise HTTPException(400, error) return { "run_id": run.run_id, "status": run.status, "message": "Recipe execution started", } @router.get("/{recipe_id}/dag") async def recipe_dag( recipe_id: str, request: Request, recipe_service: RecipeService = Depends(get_recipe_service), ): """Get recipe DAG visualization data.""" recipe = await recipe_service.get_recipe(recipe_id) if not recipe: raise HTTPException(404, "Recipe not found") dag_elements = [] node_colors = { "input": "#3b82f6", "effect": "#8b5cf6", "analyze": "#ec4899", "transform": "#10b981", "output": "#f59e0b", } for i, step in enumerate(recipe.get("steps", [])): step_id = step.get("id", f"step-{i}") dag_elements.append({ "data": { "id": step_id, "label": step.get("name", f"Step {i+1}"), "color": node_colors.get(step.get("type", "effect"), "#6b7280"), } }) for inp in step.get("inputs", []): dag_elements.append({ "data": {"source": inp, "target": step_id} }) return {"elements": dag_elements} @router.delete("/{recipe_id}/ui", response_class=HTMLResponse) async def ui_discard_recipe( recipe_id: str, request: Request, recipe_service: RecipeService = Depends(get_recipe_service), ): """HTMX handler: discard a recipe.""" auth_service = AuthService(get_redis_client()) ctx = auth_service.get_user_from_cookie(request) if not ctx: return HTMLResponse('