Fix completed runs not appearing in list + add purge-failed endpoint

- Update save_run_cache to also update actor_id, recipe, inputs on conflict
- Add logging for actor_id when saving runs to run_cache
- Add admin endpoint DELETE /runs/admin/purge-failed to delete all failed runs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-02 23:24:39 +00:00
parent 581da68b3b
commit d20eef76ad
24 changed files with 1671 additions and 453 deletions

View File

@@ -156,7 +156,6 @@ async def create_run(
async def create_stream_run(
request: StreamRequest,
ctx: UserContext = Depends(require_auth),
redis = Depends(get_redis_client),
):
"""Start a streaming video render.
@@ -166,13 +165,57 @@ async def create_stream_run(
Assets can be referenced by CID or friendly name in the recipe.
"""
import uuid
import tempfile
from pathlib import Path
import database
from tasks.streaming import run_stream
# Generate run ID
run_id = str(uuid.uuid4())
created_at = datetime.now(timezone.utc).isoformat()
# Store recipe in cache so it appears on /recipes page
recipe_id = None
try:
cache_manager = get_cache_manager()
with tempfile.NamedTemporaryFile(delete=False, suffix=".sexp", mode="w") as tmp:
tmp.write(request.recipe_sexp)
tmp_path = Path(tmp.name)
cached, ipfs_cid = cache_manager.put(tmp_path, node_type="recipe", move=True)
recipe_id = cached.cid
# Extract recipe name from S-expression (look for (stream "name" ...) pattern)
import re
name_match = re.search(r'\(stream\s+"([^"]+)"', request.recipe_sexp)
recipe_name = name_match.group(1) if name_match else f"stream-{run_id[:8]}"
# Track ownership in item_types
await database.save_item_metadata(
cid=recipe_id,
actor_id=ctx.actor_id,
item_type="recipe",
description=f"Streaming recipe: {recipe_name}",
filename=f"{recipe_name}.sexp",
)
# Assign friendly name
from ..services.naming_service import get_naming_service
naming = get_naming_service()
await naming.assign_name(
cid=recipe_id,
actor_id=ctx.actor_id,
item_type="recipe",
display_name=recipe_name,
)
logger.info(f"Stored streaming recipe {recipe_id[:16]}... as '{recipe_name}'")
except Exception as e:
logger.warning(f"Failed to store recipe in cache: {e}")
# Continue anyway - run will still work, just won't appear in /recipes
# Submit Celery task
task = run_stream.delay(
run_id=run_id,
recipe_sexp=request.recipe_sexp,
output_name=request.output_name,
duration=request.duration,
@@ -182,21 +225,15 @@ async def create_stream_run(
audio_sexp=request.audio_sexp,
)
# Store run metadata in Redis
run_data = {
"run_id": run_id,
"status": "pending",
"recipe": "streaming",
"actor_id": ctx.actor_id,
"created_at": created_at,
"celery_task_id": task.id,
"output_name": request.output_name,
}
await redis.set(
f"{RUNS_KEY_PREFIX}{run_id}",
json.dumps(run_data),
ex=86400 * 7 # 7 days
# Store in database for durability
pending = await database.create_pending_run(
run_id=run_id,
celery_task_id=task.id,
recipe=recipe_id or "streaming", # Use recipe CID if available
inputs=[], # Streaming recipes don't have traditional inputs
actor_id=ctx.actor_id,
dag_json=request.recipe_sexp, # Store recipe content for viewing
output_name=request.output_name,
)
logger.info(f"Started stream run {run_id} with task {task.id}")
@@ -204,8 +241,8 @@ async def create_stream_run(
return RunStatus(
run_id=run_id,
status="pending",
recipe="streaming",
created_at=created_at,
recipe=recipe_id or "streaming",
created_at=pending.get("created_at"),
celery_task_id=task.id,
)
@@ -305,6 +342,32 @@ async def get_run(
except Exception as e:
logger.warning(f"Failed to load recipe for plan: {e}")
# Handle streaming runs - detect by recipe_sexp content or legacy "streaming" marker
recipe_sexp_content = run.get("recipe_sexp")
is_streaming = run.get("recipe") == "streaming" # Legacy marker
if not is_streaming and recipe_sexp_content:
# Check if content starts with (stream after skipping comments
for line in recipe_sexp_content.split('\n'):
stripped = line.strip()
if not stripped or stripped.startswith(';'):
continue
is_streaming = stripped.startswith('(stream')
break
if is_streaming and recipe_sexp_content and not plan:
plan_sexp = recipe_sexp_content
plan = {
"steps": [{
"id": "stream",
"type": "STREAM",
"name": "Streaming Recipe",
"inputs": [],
"config": {},
"status": "completed" if run.get("status") == "completed" else "pending",
}]
}
run["total_steps"] = 1
run["executed"] = 1 if run.get("status") == "completed" else 0
# Helper to convert simple type to MIME type prefix for template
def type_to_mime(simple_type: str) -> str:
if simple_type == "video":
@@ -564,10 +627,14 @@ async def run_detail(
"analysis": analysis,
}
# Extract plan_sexp for streaming runs
plan_sexp = plan.get("sexp") if plan else None
templates = get_templates(request)
return render(templates, "runs/detail.html", request,
run=run,
plan=plan,
plan_sexp=plan_sexp,
artifacts=artifacts,
analysis=analysis,
dag_elements=dag_elements,
@@ -824,3 +891,26 @@ async def publish_run(
return HTMLResponse(f'<span class="text-green-400">Shared: {ipfs_cid[:16]}...</span>')
return {"ipfs_cid": ipfs_cid, "output_cid": output_cid, "published": True}
@router.delete("/admin/purge-failed")
async def purge_failed_runs(
ctx: UserContext = Depends(require_auth),
):
"""Delete all failed runs from pending_runs table."""
import database
# Get all failed runs
failed_runs = await database.list_pending_runs(status="failed")
deleted = []
for run in failed_runs:
run_id = run.get("run_id")
try:
await database.delete_pending_run(run_id)
deleted.append(run_id)
except Exception as e:
logger.warning(f"Failed to delete run {run_id}: {e}")
logger.info(f"Purged {len(deleted)} failed runs")
return {"purged": len(deleted), "run_ids": deleted}