Complete L1 router and template migration

- Full implementation of runs, recipes, cache routers with templates
- Auth and storage routers fully migrated
- Jinja2 templates for all L1 pages
- Service layer for auth and storage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
giles
2026-01-11 07:46:15 +00:00
parent 383dbf6e03
commit 022f88bf0c
20 changed files with 2771 additions and 135 deletions

View File

@@ -4,44 +4,246 @@ Recipe management routes for L1 server.
Handles recipe upload, listing, viewing, and execution.
"""
from fastapi import APIRouter, Request, Depends, HTTPException
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.middleware import UserContext, wants_html
from artdag_common import render
from artdag_common.middleware import wants_html, wants_json
from ..dependencies import require_auth, get_templates
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__)
# TODO: Migrate routes from server.py lines 1990-2767
# - POST /recipes/upload - Upload recipe YAML
# - GET /recipes - List recipes
# - GET /recipes/{recipe_id} - Get recipe details
# - DELETE /recipes/{recipe_id} - Delete recipe
# - POST /recipes/{recipe_id}/run - Run recipe
# - GET /recipe/{recipe_id} - Recipe detail page
# - GET /recipe/{recipe_id}/dag - Recipe DAG visualization
# - POST /ui/recipes/{recipe_id}/run - Run from UI
# - GET /ui/recipes-list - Recipes list UI
class RecipeUploadRequest(BaseModel):
yaml_content: str
name: Optional[str] = None
description: Optional[str] = None
@router.get("/recipes")
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,
user: UserContext = Depends(require_auth),
offset: int = 0,
limit: int = 20,
recipe_service: RecipeService = Depends(get_recipe_service),
):
"""List available recipes."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")
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/{recipe_id}")
async def recipe_detail(
@router.get("/{recipe_id}")
async def get_recipe(
recipe_id: str,
request: Request,
user: UserContext = Depends(require_auth),
recipe_service: RecipeService = Depends(get_recipe_service),
):
"""Recipe detail page with DAG visualization."""
# TODO: Implement
raise HTTPException(501, "Not yet migrated")
"""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('<div class="text-red-400">Login required</div>', status_code=401)
success, error = await recipe_service.delete_recipe(recipe_id, ctx.actor_id)
if error:
return HTMLResponse(f'<div class="text-red-400">{error}</div>')
return HTMLResponse(
'<div class="text-green-400">Recipe deleted</div>'
'<script>setTimeout(() => window.location.href = "/recipes", 1500);</script>'
)