Fix run detail: add username, total_steps, recipe_name
- Extract username from actor_id format (@user@server) - Set total_steps and executed from recipe nodes - Use recipe name for display instead of hash Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class RecipeUploadRequest(BaseModel):
|
class RecipeUploadRequest(BaseModel):
|
||||||
yaml_content: str
|
sexp_content: str
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
@@ -44,47 +44,50 @@ async def upload_recipe(
|
|||||||
ctx: UserContext = Depends(require_auth),
|
ctx: UserContext = Depends(require_auth),
|
||||||
recipe_service: RecipeService = Depends(get_recipe_service),
|
recipe_service: RecipeService = Depends(get_recipe_service),
|
||||||
):
|
):
|
||||||
"""Upload a new recipe from YAML file."""
|
"""Upload a new recipe from S-expression file."""
|
||||||
import yaml
|
from artdag.sexp import compile_string, ParseError, CompileError
|
||||||
|
|
||||||
# Read the YAML content from the uploaded file
|
# Read the S-expression content from the uploaded file
|
||||||
yaml_content = (await file.read()).decode("utf-8")
|
sexp_content = (await file.read()).decode("utf-8")
|
||||||
|
|
||||||
# Parse YAML to extract recipe info for response
|
# Parse and compile S-expression to extract recipe info
|
||||||
try:
|
try:
|
||||||
recipe_data = yaml.safe_load(yaml_content)
|
compiled = compile_string(sexp_content)
|
||||||
except yaml.YAMLError as e:
|
except ParseError as e:
|
||||||
raise HTTPException(400, f"Invalid YAML: {e}")
|
raise HTTPException(400, f"Parse error: {e}")
|
||||||
|
except CompileError as e:
|
||||||
|
raise HTTPException(400, f"Compile error: {e}")
|
||||||
|
|
||||||
# Use filename (without extension) as recipe name if not in YAML
|
# Use filename (without extension) as recipe name if not specified
|
||||||
recipe_name = recipe_data.get("name")
|
recipe_name = compiled.name
|
||||||
if not recipe_name and file.filename:
|
if not recipe_name and file.filename:
|
||||||
recipe_name = file.filename.rsplit(".", 1)[0]
|
recipe_name = file.filename.rsplit(".", 1)[0]
|
||||||
|
|
||||||
recipe_id, error = await recipe_service.upload_recipe(
|
recipe_id, error = await recipe_service.upload_recipe(
|
||||||
yaml_content=yaml_content,
|
sexp_content=sexp_content,
|
||||||
uploader=ctx.actor_id,
|
uploader=ctx.actor_id,
|
||||||
name=recipe_name,
|
name=recipe_name,
|
||||||
description=recipe_data.get("description"),
|
description=compiled.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
raise HTTPException(400, error)
|
raise HTTPException(400, error)
|
||||||
|
|
||||||
# Extract input info for response
|
# Extract input info from compiled nodes
|
||||||
inputs = recipe_data.get("inputs", {})
|
|
||||||
variable_inputs = []
|
variable_inputs = []
|
||||||
fixed_inputs = []
|
fixed_inputs = []
|
||||||
for input_name, input_def in inputs.items():
|
for node in compiled.nodes:
|
||||||
if isinstance(input_def, dict) and input_def.get("fixed"):
|
if node.get("type") == "SOURCE":
|
||||||
fixed_inputs.append(input_name)
|
config = node.get("config", {})
|
||||||
else:
|
if config.get("input"):
|
||||||
variable_inputs.append(input_name)
|
variable_inputs.append(config.get("name", node.get("id")))
|
||||||
|
elif config.get("asset"):
|
||||||
|
fixed_inputs.append(config.get("asset"))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"recipe_id": recipe_id,
|
"recipe_id": recipe_id,
|
||||||
"name": recipe_name or "unnamed",
|
"name": recipe_name or "unnamed",
|
||||||
"version": recipe_data.get("version", "1.0"),
|
"version": compiled.version,
|
||||||
"variable_inputs": variable_inputs,
|
"variable_inputs": variable_inputs,
|
||||||
"fixed_inputs": fixed_inputs,
|
"fixed_inputs": fixed_inputs,
|
||||||
"message": "Recipe uploaded successfully",
|
"message": "Recipe uploaded successfully",
|
||||||
@@ -235,9 +238,9 @@ async def get_recipe(
|
|||||||
# Add steps to recipe for template
|
# Add steps to recipe for template
|
||||||
recipe["steps"] = steps
|
recipe["steps"] = steps
|
||||||
|
|
||||||
# Add YAML source
|
# Use S-expression source if available
|
||||||
import yaml
|
if "sexp" not in recipe:
|
||||||
recipe["yaml"] = yaml.dump(recipe, default_flow_style=False)
|
recipe["sexp"] = "; No S-expression source available"
|
||||||
|
|
||||||
templates = get_templates(request)
|
templates = get_templates(request)
|
||||||
return render(templates, "recipes/detail.html", request,
|
return render(templates, "recipes/detail.html", request,
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ async def get_run(
|
|||||||
|
|
||||||
# Only render HTML if browser explicitly requests it
|
# Only render HTML if browser explicitly requests it
|
||||||
if wants_html(request):
|
if wants_html(request):
|
||||||
|
# Extract username from actor_id (format: @user@server)
|
||||||
|
actor_id = run.get("actor_id", "")
|
||||||
|
if actor_id and actor_id.startswith("@"):
|
||||||
|
parts = actor_id[1:].split("@")
|
||||||
|
run["username"] = parts[0] if parts else "Unknown"
|
||||||
|
else:
|
||||||
|
run["username"] = actor_id or "Unknown"
|
||||||
|
|
||||||
# Try to load the recipe to show the plan
|
# Try to load the recipe to show the plan
|
||||||
plan = None
|
plan = None
|
||||||
recipe_id = run.get("recipe")
|
recipe_id = run.get("recipe")
|
||||||
@@ -111,11 +119,9 @@ async def get_run(
|
|||||||
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
|
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
|
||||||
recipe = await recipe_service.get_recipe(recipe_id)
|
recipe = await recipe_service.get_recipe(recipe_id)
|
||||||
if recipe:
|
if recipe:
|
||||||
# Build plan from recipe nodes
|
# Use the new build_dag method if available
|
||||||
nodes = recipe.get("nodes", [])
|
dag = recipe.get("dag", {})
|
||||||
if not nodes:
|
nodes = dag.get("nodes", [])
|
||||||
dag = recipe.get("dag", {})
|
|
||||||
nodes = dag.get("nodes", [])
|
|
||||||
|
|
||||||
steps = []
|
steps = []
|
||||||
if isinstance(nodes, list):
|
if isinstance(nodes, list):
|
||||||
@@ -137,6 +143,12 @@ async def get_run(
|
|||||||
|
|
||||||
if steps:
|
if steps:
|
||||||
plan = {"steps": steps}
|
plan = {"steps": steps}
|
||||||
|
run["total_steps"] = len(steps)
|
||||||
|
run["executed"] = len(steps) if run.get("status") == "completed" else 0
|
||||||
|
|
||||||
|
# Use recipe name instead of hash for display
|
||||||
|
if recipe.get("name"):
|
||||||
|
run["recipe_name"] = recipe["name"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to load recipe for plan: {e}")
|
logger.warning(f"Failed to load recipe for plan: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Recipe Service - business logic for recipe management.
|
Recipe Service - business logic for recipe management.
|
||||||
|
|
||||||
Recipes are content-addressed YAML files stored in the cache (and IPFS).
|
Recipes are content-addressed S-expression files stored in the cache (and IPFS).
|
||||||
The recipe ID is the content hash of the YAML file.
|
The recipe ID is the content hash of the S-expression file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
|
||||||
import yaml
|
from artdag.sexp import compile_string, parse, serialize, CompileError, ParseError
|
||||||
|
|
||||||
|
|
||||||
class RecipeService:
|
class RecipeService:
|
||||||
@@ -32,32 +32,28 @@ class RecipeService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
with open(path) as f:
|
with open(path) as f:
|
||||||
recipe_data = yaml.safe_load(f)
|
sexp_content = f.read()
|
||||||
|
|
||||||
|
# Compile S-expression recipe
|
||||||
|
try:
|
||||||
|
compiled = compile_string(sexp_content)
|
||||||
|
recipe_data = compiled.to_dict()
|
||||||
|
except (ParseError, CompileError) as e:
|
||||||
|
# Return basic error info
|
||||||
|
return {"error": str(e), "recipe_id": recipe_id}
|
||||||
|
|
||||||
# Add the recipe_id to the data for convenience
|
# Add the recipe_id to the data for convenience
|
||||||
if isinstance(recipe_data, dict):
|
recipe_data["recipe_id"] = recipe_id
|
||||||
recipe_data["recipe_id"] = recipe_id
|
recipe_data["sexp"] = sexp_content # Keep original S-expression
|
||||||
# Get IPFS CID if available
|
|
||||||
ipfs_cid = self.cache.get_ipfs_cid(recipe_id)
|
|
||||||
if ipfs_cid:
|
|
||||||
recipe_data["ipfs_cid"] = ipfs_cid
|
|
||||||
|
|
||||||
# Compute step_count from nodes
|
# Get IPFS CID if available
|
||||||
nodes = recipe_data.get("nodes", [])
|
ipfs_cid = self.cache.get_ipfs_cid(recipe_id)
|
||||||
if not nodes:
|
if ipfs_cid:
|
||||||
dag = recipe_data.get("dag", {})
|
recipe_data["ipfs_cid"] = ipfs_cid
|
||||||
nodes = dag.get("nodes", []) if isinstance(dag, dict) else []
|
|
||||||
if not nodes:
|
|
||||||
nodes = recipe_data.get("pipeline", [])
|
|
||||||
if not nodes:
|
|
||||||
nodes = recipe_data.get("steps", [])
|
|
||||||
|
|
||||||
if isinstance(nodes, list):
|
# Compute step_count from nodes
|
||||||
recipe_data["step_count"] = len(nodes)
|
nodes = recipe_data.get("dag", {}).get("nodes", [])
|
||||||
elif isinstance(nodes, dict):
|
recipe_data["step_count"] = len(nodes) if isinstance(nodes, list) else 0
|
||||||
recipe_data["step_count"] = len(nodes)
|
|
||||||
else:
|
|
||||||
recipe_data["step_count"] = 0
|
|
||||||
|
|
||||||
return recipe_data
|
return recipe_data
|
||||||
|
|
||||||
@@ -97,40 +93,33 @@ class RecipeService:
|
|||||||
|
|
||||||
async def upload_recipe(
|
async def upload_recipe(
|
||||||
self,
|
self,
|
||||||
yaml_content: str,
|
sexp_content: str,
|
||||||
uploader: str,
|
uploader: str,
|
||||||
name: str = None,
|
name: str = None,
|
||||||
description: str = None,
|
description: str = None,
|
||||||
) -> Tuple[Optional[str], Optional[str]]:
|
) -> Tuple[Optional[str], Optional[str]]:
|
||||||
"""
|
"""
|
||||||
Upload a recipe from YAML content.
|
Upload a recipe from S-expression content.
|
||||||
|
|
||||||
The recipe is stored in the cache and optionally pinned to IPFS.
|
The recipe is stored in the cache and optionally pinned to IPFS.
|
||||||
Returns (recipe_id, error_message).
|
Returns (recipe_id, error_message).
|
||||||
"""
|
"""
|
||||||
# Validate YAML
|
# Validate and compile S-expression
|
||||||
try:
|
try:
|
||||||
recipe_data = yaml.safe_load(yaml_content)
|
compiled = compile_string(sexp_content)
|
||||||
except yaml.YAMLError as e:
|
except ParseError as e:
|
||||||
return None, f"Invalid YAML: {e}"
|
return None, f"Parse error: {e}"
|
||||||
|
except CompileError as e:
|
||||||
|
return None, f"Compile error: {e}"
|
||||||
|
|
||||||
if not isinstance(recipe_data, dict):
|
# For now, store the original S-expression content
|
||||||
return None, "Recipe must be a YAML dictionary"
|
# The uploader info is not embedded in the S-expression (kept in metadata)
|
||||||
|
# In a full implementation, we might add a :uploader keyword
|
||||||
# Add uploader info to the YAML before storing
|
|
||||||
recipe_data["uploader"] = uploader
|
|
||||||
if name:
|
|
||||||
recipe_data["name"] = name
|
|
||||||
if description:
|
|
||||||
recipe_data["description"] = description
|
|
||||||
|
|
||||||
# Serialize back to YAML (with added metadata)
|
|
||||||
final_yaml = yaml.dump(recipe_data, default_flow_style=False)
|
|
||||||
|
|
||||||
# Write to temp file for caching
|
# Write to temp file for caching
|
||||||
try:
|
try:
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".yaml", mode="w") as tmp:
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".sexp", mode="w") as tmp:
|
||||||
tmp.write(final_yaml)
|
tmp.write(sexp_content)
|
||||||
tmp_path = Path(tmp.name)
|
tmp_path = Path(tmp.name)
|
||||||
|
|
||||||
# Store in cache (content-addressed, auto-pins to IPFS)
|
# Store in cache (content-addressed, auto-pins to IPFS)
|
||||||
@@ -176,9 +165,10 @@ class RecipeService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"Failed to delete: {e}"
|
return False, f"Failed to delete: {e}"
|
||||||
|
|
||||||
def parse_yaml(self, yaml_content: str) -> Dict[str, Any]:
|
def parse_recipe(self, sexp_content: str) -> Dict[str, Any]:
|
||||||
"""Parse recipe YAML content."""
|
"""Parse and compile recipe S-expression content."""
|
||||||
return yaml.safe_load(yaml_content)
|
compiled = compile_string(sexp_content)
|
||||||
|
return compiled.to_dict()
|
||||||
|
|
||||||
def build_dag(self, recipe: Dict[str, Any]) -> Dict[str, Any]:
|
def build_dag(self, recipe: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -186,37 +176,69 @@ class RecipeService:
|
|||||||
|
|
||||||
Returns nodes and edges for Cytoscape.js.
|
Returns nodes and edges for Cytoscape.js.
|
||||||
"""
|
"""
|
||||||
nodes = []
|
vis_nodes = []
|
||||||
edges = []
|
edges = []
|
||||||
|
|
||||||
dag = recipe.get("dag", {})
|
dag = recipe.get("dag", {})
|
||||||
dag_nodes = dag.get("nodes", {})
|
dag_nodes = dag.get("nodes", [])
|
||||||
output_node = dag.get("output")
|
output_node = dag.get("output")
|
||||||
|
|
||||||
for node_id, node_def in dag_nodes.items():
|
# Handle list format from compiled S-expression recipes
|
||||||
node_type = node_def.get("type", "EFFECT")
|
if isinstance(dag_nodes, list):
|
||||||
nodes.append({
|
for node_def in dag_nodes:
|
||||||
"data": {
|
node_id = node_def.get("id")
|
||||||
"id": node_id,
|
node_type = node_def.get("type", "EFFECT")
|
||||||
"label": node_id,
|
|
||||||
"nodeType": node_type,
|
|
||||||
"isOutput": node_id == output_node,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
# Build edges from inputs
|
vis_nodes.append({
|
||||||
for input_ref in node_def.get("inputs", []):
|
"data": {
|
||||||
if isinstance(input_ref, dict):
|
"id": node_id,
|
||||||
source = input_ref.get("node") or input_ref.get("input")
|
"label": node_id,
|
||||||
else:
|
"nodeType": node_type,
|
||||||
source = input_ref
|
"isOutput": node_id == output_node,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if source:
|
# Build edges from inputs
|
||||||
edges.append({
|
for input_ref in node_def.get("inputs", []):
|
||||||
"data": {
|
if isinstance(input_ref, dict):
|
||||||
"source": source,
|
source = input_ref.get("node") or input_ref.get("input")
|
||||||
"target": node_id,
|
else:
|
||||||
}
|
source = input_ref
|
||||||
})
|
|
||||||
|
|
||||||
return {"nodes": nodes, "edges": edges}
|
if source:
|
||||||
|
edges.append({
|
||||||
|
"data": {
|
||||||
|
"source": source,
|
||||||
|
"target": node_id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Handle dict format (legacy)
|
||||||
|
elif isinstance(dag_nodes, dict):
|
||||||
|
for node_id, node_def in dag_nodes.items():
|
||||||
|
node_type = node_def.get("type", "EFFECT")
|
||||||
|
|
||||||
|
vis_nodes.append({
|
||||||
|
"data": {
|
||||||
|
"id": node_id,
|
||||||
|
"label": node_id,
|
||||||
|
"nodeType": node_type,
|
||||||
|
"isOutput": node_id == output_node,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for input_ref in node_def.get("inputs", []):
|
||||||
|
if isinstance(input_ref, dict):
|
||||||
|
source = input_ref.get("node") or input_ref.get("input")
|
||||||
|
else:
|
||||||
|
source = input_ref
|
||||||
|
|
||||||
|
if source:
|
||||||
|
edges.append({
|
||||||
|
"data": {
|
||||||
|
"source": source,
|
||||||
|
"target": node_id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"nodes": vis_nodes, "edges": edges}
|
||||||
|
|||||||
Reference in New Issue
Block a user