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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user