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:
@@ -2,17 +2,17 @@
|
||||
Effects routes for L1 server.
|
||||
|
||||
Handles effect upload, listing, and metadata.
|
||||
Effects are stored in IPFS like all other content-addressed data.
|
||||
Effects are S-expression files stored in IPFS like all other content-addressed data.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
|
||||
from artdag_common import render
|
||||
@@ -40,12 +40,11 @@ def get_effects_dir() -> Path:
|
||||
|
||||
def parse_effect_metadata(source: str) -> dict:
|
||||
"""
|
||||
Parse effect metadata from source code.
|
||||
Parse effect metadata from S-expression source code.
|
||||
|
||||
Extracts PEP 723 dependencies and @-tag metadata from docstring.
|
||||
Extracts metadata from comment headers (;; @key value format)
|
||||
or from (defeffect name ...) form.
|
||||
"""
|
||||
import re
|
||||
|
||||
metadata = {
|
||||
"name": "",
|
||||
"version": "1.0.0",
|
||||
@@ -53,97 +52,54 @@ def parse_effect_metadata(source: str) -> dict:
|
||||
"temporal": False,
|
||||
"description": "",
|
||||
"params": [],
|
||||
"dependencies": [],
|
||||
"requires_python": ">=3.10",
|
||||
}
|
||||
|
||||
# Parse PEP 723 dependencies
|
||||
pep723_match = re.search(r"# /// script\n(.*?)# ///", source, re.DOTALL)
|
||||
if pep723_match:
|
||||
block = pep723_match.group(1)
|
||||
deps_match = re.search(r'# dependencies = \[(.*?)\]', block, re.DOTALL)
|
||||
if deps_match:
|
||||
metadata["dependencies"] = re.findall(r'"([^"]+)"', deps_match.group(1))
|
||||
python_match = re.search(r'# requires-python = "([^"]+)"', block)
|
||||
if python_match:
|
||||
metadata["requires_python"] = python_match.group(1)
|
||||
# Parse comment-based metadata (;; @key value)
|
||||
for line in source.split("\n"):
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith(";"):
|
||||
# Stop parsing metadata at first non-comment line
|
||||
if stripped and not stripped.startswith("("):
|
||||
continue
|
||||
if stripped.startswith("("):
|
||||
break
|
||||
|
||||
# Parse docstring @-tags
|
||||
docstring_match = re.search(r'"""(.*?)"""', source, re.DOTALL)
|
||||
if not docstring_match:
|
||||
docstring_match = re.search(r"'''(.*?)'''", source, re.DOTALL)
|
||||
# Remove comment prefix
|
||||
comment = stripped.lstrip(";").strip()
|
||||
|
||||
if docstring_match:
|
||||
docstring = docstring_match.group(1)
|
||||
lines = docstring.split("\n")
|
||||
if comment.startswith("@effect "):
|
||||
metadata["name"] = comment[8:].strip()
|
||||
elif comment.startswith("@name "):
|
||||
metadata["name"] = comment[6:].strip()
|
||||
elif comment.startswith("@version "):
|
||||
metadata["version"] = comment[9:].strip()
|
||||
elif comment.startswith("@author "):
|
||||
metadata["author"] = comment[8:].strip()
|
||||
elif comment.startswith("@temporal"):
|
||||
val = comment[9:].strip().lower() if len(comment) > 9 else "true"
|
||||
metadata["temporal"] = val in ("true", "yes", "1", "")
|
||||
elif comment.startswith("@description "):
|
||||
metadata["description"] = comment[13:].strip()
|
||||
elif comment.startswith("@param "):
|
||||
# Format: @param name type [description]
|
||||
parts = comment[7:].split(None, 2)
|
||||
if len(parts) >= 2:
|
||||
param = {"name": parts[0], "type": parts[1]}
|
||||
if len(parts) > 2:
|
||||
param["description"] = parts[2]
|
||||
metadata["params"].append(param)
|
||||
|
||||
current_param = None
|
||||
desc_lines = []
|
||||
in_description = False
|
||||
# Also try to extract name from (defeffect "name" ...) or (effect "name" ...)
|
||||
if not metadata["name"]:
|
||||
name_match = re.search(r'\((defeffect|effect)\s+"([^"]+)"', source)
|
||||
if name_match:
|
||||
metadata["name"] = name_match.group(2)
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith("@effect "):
|
||||
metadata["name"] = stripped[8:].strip()
|
||||
in_description = False
|
||||
|
||||
elif stripped.startswith("@version "):
|
||||
metadata["version"] = stripped[9:].strip()
|
||||
|
||||
elif stripped.startswith("@author "):
|
||||
metadata["author"] = stripped[8:].strip()
|
||||
|
||||
elif stripped.startswith("@temporal "):
|
||||
val = stripped[10:].strip().lower()
|
||||
metadata["temporal"] = val in ("true", "yes", "1")
|
||||
|
||||
elif stripped.startswith("@description"):
|
||||
in_description = True
|
||||
desc_lines = []
|
||||
|
||||
elif stripped.startswith("@param "):
|
||||
in_description = False
|
||||
if current_param:
|
||||
metadata["params"].append(current_param)
|
||||
parts = stripped[7:].split()
|
||||
if len(parts) >= 2:
|
||||
current_param = {
|
||||
"name": parts[0],
|
||||
"type": parts[1],
|
||||
"description": "",
|
||||
}
|
||||
else:
|
||||
current_param = None
|
||||
|
||||
elif stripped.startswith("@range ") and current_param:
|
||||
range_parts = stripped[7:].split()
|
||||
if len(range_parts) >= 2:
|
||||
try:
|
||||
current_param["range"] = [float(range_parts[0]), float(range_parts[1])]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
elif stripped.startswith("@default ") and current_param:
|
||||
current_param["default"] = stripped[9:].strip()
|
||||
|
||||
elif stripped.startswith("@example"):
|
||||
in_description = False
|
||||
if current_param:
|
||||
metadata["params"].append(current_param)
|
||||
current_param = None
|
||||
|
||||
elif in_description and stripped:
|
||||
desc_lines.append(stripped)
|
||||
|
||||
elif current_param and stripped and not stripped.startswith("@"):
|
||||
current_param["description"] = stripped
|
||||
|
||||
if in_description:
|
||||
metadata["description"] = " ".join(desc_lines)
|
||||
|
||||
if current_param:
|
||||
metadata["params"].append(current_param)
|
||||
# Try to extract name from first (define ...) form
|
||||
if not metadata["name"]:
|
||||
define_match = re.search(r'\(define\s+(\w+)', source)
|
||||
if define_match:
|
||||
metadata["name"] = define_match.group(1)
|
||||
|
||||
return metadata
|
||||
|
||||
@@ -151,13 +107,18 @@ def parse_effect_metadata(source: str) -> dict:
|
||||
@router.post("/upload")
|
||||
async def upload_effect(
|
||||
file: UploadFile = File(...),
|
||||
display_name: Optional[str] = Form(None),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Upload an effect to IPFS.
|
||||
Upload an S-expression effect to IPFS.
|
||||
|
||||
Parses PEP 723 metadata and @-tag docstring.
|
||||
Parses metadata from comment headers.
|
||||
Returns IPFS CID for use in recipes.
|
||||
|
||||
Args:
|
||||
file: The .sexp effect file
|
||||
display_name: Optional custom friendly name for the effect
|
||||
"""
|
||||
content = await file.read()
|
||||
|
||||
@@ -166,7 +127,7 @@ async def upload_effect(
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(400, "Effect must be valid UTF-8 text")
|
||||
|
||||
# Parse metadata
|
||||
# Parse metadata from sexp source
|
||||
try:
|
||||
meta = parse_effect_metadata(source)
|
||||
except Exception as e:
|
||||
@@ -185,7 +146,7 @@ async def upload_effect(
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
effect_dir.mkdir(parents=True, exist_ok=True)
|
||||
(effect_dir / "effect.py").write_text(source, encoding="utf-8")
|
||||
(effect_dir / "effect.sexp").write_text(source, encoding="utf-8")
|
||||
|
||||
# Store metadata (locally and in IPFS)
|
||||
full_meta = {
|
||||
@@ -209,14 +170,14 @@ async def upload_effect(
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
# Assign friendly name
|
||||
# Assign friendly name (use custom display_name if provided, else from metadata)
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly_entry = await naming.assign_name(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="effect",
|
||||
display_name=meta.get("name"),
|
||||
display_name=display_name or meta.get("name"),
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
@@ -230,7 +191,6 @@ async def upload_effect(
|
||||
"version": meta.get("version"),
|
||||
"temporal": meta.get("temporal", False),
|
||||
"params": meta.get("params", []),
|
||||
"dependencies": meta.get("dependencies", []),
|
||||
"uploaded": True,
|
||||
}
|
||||
|
||||
@@ -258,7 +218,7 @@ async def get_effect(
|
||||
# Cache locally
|
||||
effect_dir.mkdir(parents=True, exist_ok=True)
|
||||
source = source_bytes.decode("utf-8")
|
||||
(effect_dir / "effect.py").write_text(source)
|
||||
(effect_dir / "effect.sexp").write_text(source)
|
||||
|
||||
# Parse metadata from source
|
||||
parsed_meta = parse_effect_metadata(source)
|
||||
@@ -297,12 +257,16 @@ async def get_effect_source(
|
||||
):
|
||||
"""Get effect source code."""
|
||||
effects_dir = get_effects_dir()
|
||||
source_path = effects_dir / cid / "effect.py"
|
||||
source_path = effects_dir / cid / "effect.sexp"
|
||||
|
||||
# Try local cache first
|
||||
# Try local cache first (check both .sexp and legacy .py)
|
||||
if source_path.exists():
|
||||
return PlainTextResponse(source_path.read_text())
|
||||
|
||||
legacy_path = effects_dir / cid / "effect.py"
|
||||
if legacy_path.exists():
|
||||
return PlainTextResponse(legacy_path.read_text())
|
||||
|
||||
# Fetch from IPFS
|
||||
source_bytes = ipfs_client.get_bytes(cid)
|
||||
if not source_bytes:
|
||||
|
||||
Reference in New Issue
Block a user