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

@@ -30,12 +30,9 @@ from pathlib import Path
from dataclasses import dataclass
from typing import Dict, List, Any, Optional, Tuple
# Try pip-installed artdag first, fall back to local path
try:
from artdag.sexp.parser import parse, parse_all, Symbol, Keyword
except ImportError:
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "artdag"))
from artdag.sexp.parser import parse, parse_all, Symbol, Keyword
# Use local sexp_effects parser (supports namespaced symbols like math:sin)
sys.path.insert(0, str(Path(__file__).parent.parent))
from sexp_effects.parser import parse, parse_all, Symbol, Keyword
@dataclass
@@ -54,9 +51,10 @@ class StreamInterpreter:
and calls primitives.
"""
def __init__(self, sexp_path: str):
def __init__(self, sexp_path: str, actor_id: Optional[str] = None):
self.sexp_path = Path(sexp_path)
self.sexp_dir = self.sexp_path.parent
self.actor_id = actor_id # For friendly name resolution
text = self.sexp_path.read_text()
self.ast = parse(text)
@@ -84,6 +82,26 @@ class StreamInterpreter:
self.sources_config: Optional[Path] = None
self.audio_config: Optional[Path] = None
# Error tracking
self.errors: List[str] = []
def _resolve_name(self, name: str) -> Optional[Path]:
"""Resolve a friendly name to a file path using the naming service."""
try:
# Import here to avoid circular imports
from tasks.streaming import resolve_asset
path = resolve_asset(name, self.actor_id)
if path:
return path
except Exception as e:
print(f"Warning: failed to resolve name '{name}': {e}", file=sys.stderr)
return None
def _record_error(self, msg: str):
"""Record an error that occurred during evaluation."""
self.errors.append(msg)
print(f"ERROR: {msg}", file=sys.stderr)
import random
self.rng = random.Random(self.config.get('seed', 42))
@@ -241,27 +259,50 @@ class StreamInterpreter:
self.macros[name] = {'params': params, 'body': body}
elif cmd == 'effect':
# Handle (effect name :path "...") in included files - recursive
# Handle (effect name :path "...") or (effect name :name "...") in included files
i = 2
while i < len(form):
if isinstance(form[i], Keyword) and form[i].name == 'path':
path = str(form[i + 1]).strip('"')
# Resolve relative to the file being loaded
full = (effect_path.parent / path).resolve()
self._load_effect(full)
i += 2
if isinstance(form[i], Keyword):
kw = form[i].name
if kw == 'path':
path = str(form[i + 1]).strip('"')
full = (effect_path.parent / path).resolve()
self._load_effect(full)
i += 2
elif kw == 'name':
fname = str(form[i + 1]).strip('"')
resolved = self._resolve_name(fname)
if resolved:
self._load_effect(resolved)
else:
raise RuntimeError(f"Could not resolve effect name '{fname}' - make sure it's uploaded and you're logged in")
i += 2
else:
i += 1
else:
i += 1
elif cmd == 'include':
# Handle (include :path "...") in included files - recursive
# Handle (include :path "...") or (include :name "...") in included files
i = 1
while i < len(form):
if isinstance(form[i], Keyword) and form[i].name == 'path':
path = str(form[i + 1]).strip('"')
full = (effect_path.parent / path).resolve()
self._load_effect(full)
i += 2
if isinstance(form[i], Keyword):
kw = form[i].name
if kw == 'path':
path = str(form[i + 1]).strip('"')
full = (effect_path.parent / path).resolve()
self._load_effect(full)
i += 2
elif kw == 'name':
fname = str(form[i + 1]).strip('"')
resolved = self._resolve_name(fname)
if resolved:
self._load_effect(resolved)
else:
raise RuntimeError(f"Could not resolve include name '{fname}' - make sure it's uploaded and you're logged in")
i += 2
else:
i += 1
else:
i += 1
@@ -313,22 +354,49 @@ class StreamInterpreter:
name = form[1].name if isinstance(form[1], Symbol) else str(form[1])
i = 2
while i < len(form):
if isinstance(form[i], Keyword) and form[i].name == 'path':
path = str(form[i + 1]).strip('"')
full = (self.sexp_dir / path).resolve()
self._load_effect(full)
i += 2
if isinstance(form[i], Keyword):
kw = form[i].name
if kw == 'path':
path = str(form[i + 1]).strip('"')
full = (self.sexp_dir / path).resolve()
self._load_effect(full)
i += 2
elif kw == 'name':
# Resolve friendly name to path
fname = str(form[i + 1]).strip('"')
resolved = self._resolve_name(fname)
if resolved:
self._load_effect(resolved)
else:
raise RuntimeError(f"Could not resolve effect name '{fname}' - make sure it's uploaded and you're logged in")
i += 2
else:
i += 1
else:
i += 1
elif cmd == 'include':
i = 1
while i < len(form):
if isinstance(form[i], Keyword) and form[i].name == 'path':
path = str(form[i + 1]).strip('"')
full = (self.sexp_dir / path).resolve()
self._load_effect(full)
i += 2
if isinstance(form[i], Keyword):
kw = form[i].name
if kw == 'path':
path = str(form[i + 1]).strip('"')
full = (self.sexp_dir / path).resolve()
self._load_effect(full)
i += 2
elif kw == 'name':
# Resolve friendly name to path
fname = str(form[i + 1]).strip('"')
resolved = self._resolve_name(fname)
if resolved:
self._load_effect(resolved)
else:
raise RuntimeError(f"Could not resolve include name '{fname}' - make sure it's uploaded and you're logged in")
raise RuntimeError(f"Could not resolve include name '{fname}' - make sure it's uploaded and you're logged in")
i += 2
else:
i += 1
else:
i += 1
@@ -337,7 +405,13 @@ class StreamInterpreter:
# Skip if already set by config file
if self.audio_playback is None:
path = str(form[1]).strip('"')
self.audio_playback = str((self.sexp_dir / path).resolve())
# Try to resolve as friendly name first
resolved = self._resolve_name(path)
if resolved:
self.audio_playback = str(resolved)
else:
# Fall back to relative path
self.audio_playback = str((self.sexp_dir / path).resolve())
print(f"Audio playback: {self.audio_playback}", file=sys.stderr)
elif cmd == 'def':
@@ -419,6 +493,10 @@ class StreamInterpreter:
if isinstance(expr, Keyword):
return expr.name
# Handle dicts from new parser - evaluate values
if isinstance(expr, dict):
return {k: self._eval(v, env) for k, v in expr.items()}
if not isinstance(expr, list) or not expr:
return expr
@@ -685,8 +763,8 @@ class StreamInterpreter:
return prim_func(*evaluated_args, **kwargs)
return prim_func(*evaluated_args)
except Exception as e:
print(f"Primitive {op} error: {e}", file=sys.stderr)
return None
self._record_error(f"Primitive {op} error: {e}")
raise RuntimeError(f"Primitive {op} failed: {e}")
# === Macros (function-like: args evaluated before binding) ===
@@ -720,8 +798,8 @@ class StreamInterpreter:
return prim_func(*evaluated_args, **kwargs)
return prim_func(*evaluated_args)
except Exception as e:
print(f"Primitive {op} error: {e}", file=sys.stderr)
return None
self._record_error(f"Primitive {op} error: {e}")
raise RuntimeError(f"Primitive {op} failed: {e}")
# Unknown - return as-is
return expr