diff --git a/app/routers/runs.py b/app/routers/runs.py
index 3ff6cbf..865023f 100644
--- a/app/routers/runs.py
+++ b/app/routers/runs.py
@@ -944,6 +944,97 @@ async def publish_run(
return {"ipfs_cid": ipfs_cid, "output_cid": output_cid, "published": True}
+@router.post("/rerun/{recipe_id}", response_class=HTMLResponse)
+async def rerun_recipe(
+ recipe_id: str,
+ request: Request,
+):
+ """HTMX handler: run a recipe again.
+
+ Fetches the recipe by CID and starts a new streaming run.
+ """
+ import uuid
+ import database
+ from tasks.streaming import run_stream
+
+ ctx = await get_current_user(request)
+ if not ctx:
+ return HTMLResponse(
+ '
Login required
',
+ status_code=401
+ )
+
+ # Fetch the recipe
+ try:
+ from ..services.recipe_service import RecipeService
+ recipe_service = RecipeService(get_redis_client(), get_cache_manager())
+ recipe = await recipe_service.get_recipe(recipe_id)
+ if not recipe:
+ return HTMLResponse(f'Recipe not found: {recipe_id[:16]}...
')
+
+ # Get the S-expression content
+ recipe_sexp = recipe.get("sexp")
+ if not recipe_sexp:
+ return HTMLResponse('Recipe has no S-expression content
')
+
+ # Extract recipe name for output
+ import re
+ name_match = re.search(r'\(stream\s+"([^"]+)"', recipe_sexp)
+ recipe_name = name_match.group(1) if name_match else f"stream"
+
+ # Generate new run ID
+ run_id = str(uuid.uuid4())
+
+ # Extract duration from recipe if present (look for :duration pattern)
+ duration = None
+ duration_match = re.search(r':duration\s+(\d+(?:\.\d+)?)', recipe_sexp)
+ if duration_match:
+ duration = float(duration_match.group(1))
+
+ # Extract fps from recipe if present
+ fps = None
+ fps_match = re.search(r':fps\s+(\d+(?:\.\d+)?)', recipe_sexp)
+ if fps_match:
+ fps = float(fps_match.group(1))
+
+ # Submit Celery task to GPU queue
+ task = run_stream.apply_async(
+ kwargs=dict(
+ run_id=run_id,
+ recipe_sexp=recipe_sexp,
+ output_name=f"{recipe_name}.mp4",
+ duration=duration,
+ fps=fps,
+ actor_id=ctx.actor_id,
+ sources_sexp=None,
+ audio_sexp=None,
+ ),
+ queue='gpu',
+ )
+
+ # Store in database
+ await database.create_pending_run(
+ run_id=run_id,
+ celery_task_id=task.id,
+ recipe=recipe_id,
+ inputs=[],
+ actor_id=ctx.actor_id,
+ dag_json=recipe_sexp,
+ output_name=f"{recipe_name}.mp4",
+ )
+
+ logger.info(f"Started rerun {run_id} for recipe {recipe_id[:16]}...")
+
+ return HTMLResponse(
+ f'Started new run
'
+ f''
+ )
+
+ except Exception as e:
+ logger.error(f"Failed to rerun recipe {recipe_id}: {e}")
+ return HTMLResponse(f'Error: {str(e)}
')
+
+
@router.delete("/admin/purge-failed")
async def purge_failed_runs(
request: Request,
diff --git a/app/templates/runs/detail.html b/app/templates/runs/detail.html
index 22b9bd8..4c5e003 100644
--- a/app/templates/runs/detail.html
+++ b/app/templates/runs/detail.html
@@ -29,12 +29,26 @@
{{ run.error }}
{% endif %}
+ {% if run.recipe %}
+
+ {% endif %}
-
+
+