Add PostgreSQL + IPFS backend, rename configs to recipes

- Add PostgreSQL database for cache metadata storage with schema for
  cache_items, item_types, pin_reasons, and l2_shares tables
- Add IPFS integration as durable backing store (local cache as hot storage)
- Add postgres and ipfs services to docker-compose.yml
- Update cache_manager to upload to IPFS and track CIDs
- Rename all config references to recipe throughout server.py
- Update API endpoints: /configs/* -> /recipes/*
- Update models: ConfigStatus -> RecipeStatus, ConfigRunRequest -> RecipeRunRequest
- Update UI tabs and pages to show Recipes instead of Configs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-08 14:58:29 +00:00
parent 4639a98231
commit ba244b9ebc
6 changed files with 938 additions and 212 deletions

417
server.py
View File

@@ -28,7 +28,9 @@ import yaml
from celery_app import app as celery_app
from tasks import render_effect, execute_dag, build_effect_dag
from contextlib import asynccontextmanager
from cache_manager import L1CacheManager, get_cache_manager
import database
# L2 server for auth verification
L2_SERVER = os.environ.get("L2_SERVER", "http://localhost:8200")
@@ -51,7 +53,7 @@ redis_client = redis.Redis(
db=int(parsed.path.lstrip('/') or 0)
)
RUNS_KEY_PREFIX = "artdag:run:"
CONFIGS_KEY_PREFIX = "artdag:config:"
RECIPES_KEY_PREFIX = "artdag:recipe:"
def save_run(run: "RunStatus"):
@@ -91,10 +93,21 @@ def find_runs_using_content(content_hash: str) -> list[tuple["RunStatus", str]]:
return results
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Initialize and cleanup resources."""
# Startup: initialize database
await database.init_db()
yield
# Shutdown: close database
await database.close_db()
app = FastAPI(
title="Art DAG L1 Server",
description="Distributed rendering server for Art DAG",
version="0.1.0"
version="0.1.0",
lifespan=lifespan
)
@@ -125,7 +138,7 @@ class RunStatus(BaseModel):
infrastructure: Optional[dict] = None # Hardware/software used for rendering
# ============ Config Models ============
# ============ Recipe Models ============
class VariableInput(BaseModel):
"""A variable input that must be filled at run time."""
@@ -142,9 +155,9 @@ class FixedInput(BaseModel):
content_hash: str
class ConfigStatus(BaseModel):
"""Status/metadata of a config."""
config_id: str # Content hash of the YAML file
class RecipeStatus(BaseModel):
"""Status/metadata of a recipe."""
recipe_id: str # Content hash of the YAML file
name: str
version: str
description: Optional[str] = None
@@ -156,41 +169,41 @@ class ConfigStatus(BaseModel):
uploader: Optional[str] = None
class ConfigRunRequest(BaseModel):
"""Request to run a config with variable inputs."""
class RecipeRunRequest(BaseModel):
"""Request to run a recipe with variable inputs."""
inputs: dict[str, str] # node_id -> content_hash
def save_config(config: ConfigStatus):
"""Save config to Redis."""
redis_client.set(f"{CONFIGS_KEY_PREFIX}{config.config_id}", config.model_dump_json())
def save_recipe(recipe: RecipeStatus):
"""Save recipe to Redis."""
redis_client.set(f"{RECIPES_KEY_PREFIX}{recipe.recipe_id}", recipe.model_dump_json())
def load_config(config_id: str) -> Optional[ConfigStatus]:
"""Load config from Redis."""
data = redis_client.get(f"{CONFIGS_KEY_PREFIX}{config_id}")
def load_recipe(recipe_id: str) -> Optional[RecipeStatus]:
"""Load recipe from Redis."""
data = redis_client.get(f"{RECIPES_KEY_PREFIX}{recipe_id}")
if data:
return ConfigStatus.model_validate_json(data)
return RecipeStatus.model_validate_json(data)
return None
def list_all_configs() -> list[ConfigStatus]:
"""List all configs from Redis."""
configs = []
for key in redis_client.scan_iter(f"{CONFIGS_KEY_PREFIX}*"):
def list_all_recipes() -> list[RecipeStatus]:
"""List all recipes from Redis."""
recipes = []
for key in redis_client.scan_iter(f"{RECIPES_KEY_PREFIX}*"):
data = redis_client.get(key)
if data:
configs.append(ConfigStatus.model_validate_json(data))
return sorted(configs, key=lambda c: c.uploaded_at, reverse=True)
recipes.append(RecipeStatus.model_validate_json(data))
return sorted(recipes, key=lambda c: c.uploaded_at, reverse=True)
def delete_config_from_redis(config_id: str) -> bool:
"""Delete config from Redis."""
return redis_client.delete(f"{CONFIGS_KEY_PREFIX}{config_id}") > 0
def delete_recipe_from_redis(recipe_id: str) -> bool:
"""Delete recipe from Redis."""
return redis_client.delete(f"{RECIPES_KEY_PREFIX}{recipe_id}") > 0
def parse_config_yaml(yaml_content: str, config_hash: str, uploader: str) -> ConfigStatus:
"""Parse a config YAML file and extract metadata."""
def parse_recipe_yaml(yaml_content: str, recipe_hash: str, uploader: str) -> RecipeStatus:
"""Parse a recipe YAML file and extract metadata."""
config = yaml.safe_load(yaml_content)
# Extract basic info
@@ -235,8 +248,8 @@ def parse_config_yaml(yaml_content: str, config_hash: str, uploader: str) -> Con
content_hash=asset_info.get("hash", "")
))
return ConfigStatus(
config_id=config_hash,
return RecipeStatus(
recipe_id=recipe_hash,
name=name,
version=version,
description=description,
@@ -305,7 +318,7 @@ def cache_file(source: Path, node_type: str = "output") -> str:
Uses artdag's Cache internally for proper tracking.
"""
cached = cache_manager.put(source, node_type=node_type)
cached, ipfs_cid = cache_manager.put(source, node_type=node_type)
return cached.content_hash
@@ -520,7 +533,7 @@ async def discard_run(run_id: str, username: str = Depends(get_required_user)):
Enforces deletion rules:
- Cannot discard if output is published to L2 (pinned)
- Deletes outputs and intermediate cache entries
- Preserves inputs (cache items and configs are NOT deleted)
- Preserves inputs (cache items and recipes are NOT deleted)
"""
run = load_run(run_id)
if not run:
@@ -987,11 +1000,11 @@ async def list_runs(request: Request, page: int = 1, limit: int = 20):
}
# ============ Config Endpoints ============
# ============ Recipe Endpoints ============
@app.post("/configs/upload")
async def upload_config(file: UploadFile = File(...), username: str = Depends(get_required_user)):
"""Upload a config YAML file. Requires authentication."""
@app.post("/recipes/upload")
async def upload_recipe(file: UploadFile = File(...), username: str = Depends(get_required_user)):
"""Upload a recipe YAML file. Requires authentication."""
import tempfile
# Read file content
@@ -999,7 +1012,7 @@ async def upload_config(file: UploadFile = File(...), username: str = Depends(ge
try:
yaml_content = content.decode('utf-8')
except UnicodeDecodeError:
raise HTTPException(400, "Config file must be valid UTF-8 text")
raise HTTPException(400, "Recipe file must be valid UTF-8 text")
# Validate YAML
try:
@@ -1012,51 +1025,51 @@ async def upload_config(file: UploadFile = File(...), username: str = Depends(ge
tmp.write(content)
tmp_path = Path(tmp.name)
cached = cache_manager.put(tmp_path, node_type="config", move=True)
config_hash = cached.content_hash
cached, ipfs_cid = cache_manager.put(tmp_path, node_type="recipe", move=True)
recipe_hash = cached.content_hash
# Parse and save metadata
actor_id = f"@{username}@{L2_DOMAIN}"
try:
config_status = parse_config_yaml(yaml_content, config_hash, actor_id)
recipe_status = parse_recipe_yaml(yaml_content, recipe_hash, actor_id)
except Exception as e:
raise HTTPException(400, f"Failed to parse config: {e}")
raise HTTPException(400, f"Failed to parse recipe: {e}")
save_config(config_status)
save_recipe(recipe_status)
# Save cache metadata
save_cache_meta(config_hash, actor_id, file.filename, type="config", config_name=config_status.name)
save_cache_meta(recipe_hash, actor_id, file.filename, type="recipe", recipe_name=recipe_status.name)
return {
"config_id": config_hash,
"name": config_status.name,
"version": config_status.version,
"variable_inputs": len(config_status.variable_inputs),
"fixed_inputs": len(config_status.fixed_inputs)
"recipe_id": recipe_hash,
"name": recipe_status.name,
"version": recipe_status.version,
"variable_inputs": len(recipe_status.variable_inputs),
"fixed_inputs": len(recipe_status.fixed_inputs)
}
@app.get("/configs")
async def list_configs_api(request: Request, page: int = 1, limit: int = 20):
"""List configs. HTML for browsers, JSON for APIs."""
@app.get("/recipes")
async def list_recipes_api(request: Request, page: int = 1, limit: int = 20):
"""List recipes. HTML for browsers, JSON for APIs."""
current_user = get_user_from_cookie(request)
all_configs = list_all_configs()
total = len(all_configs)
all_recipes = list_all_recipes()
total = len(all_recipes)
# Pagination
start = (page - 1) * limit
end = start + limit
configs_page = all_configs[start:end]
recipes_page = all_recipes[start:end]
has_more = end < total
if wants_html(request):
# HTML response - redirect to /configs page with proper UI
return RedirectResponse(f"/configs?page={page}")
# HTML response - redirect to /recipes page with proper UI
return RedirectResponse(f"/recipes?page={page}")
# JSON response for APIs
return {
"configs": [c.model_dump() for c in configs_page],
"recipes": [c.model_dump() for c in recipes_page],
"pagination": {
"page": page,
"limit": limit,
@@ -1066,61 +1079,61 @@ async def list_configs_api(request: Request, page: int = 1, limit: int = 20):
}
@app.get("/configs/{config_id}")
async def get_config_api(config_id: str):
"""Get config details."""
config = load_config(config_id)
if not config:
raise HTTPException(404, f"Config {config_id} not found")
return config
@app.get("/recipes/{recipe_id}")
async def get_recipe_api(recipe_id: str):
"""Get recipe details."""
recipe = load_recipe(recipe_id)
if not recipe:
raise HTTPException(404, f"Recipe {recipe_id} not found")
return recipe
@app.delete("/configs/{config_id}")
async def remove_config(config_id: str, username: str = Depends(get_required_user)):
"""Delete a config. Requires authentication."""
config = load_config(config_id)
if not config:
raise HTTPException(404, f"Config {config_id} not found")
@app.delete("/recipes/{recipe_id}")
async def remove_recipe(recipe_id: str, username: str = Depends(get_required_user)):
"""Delete a recipe. Requires authentication."""
recipe = load_recipe(recipe_id)
if not recipe:
raise HTTPException(404, f"Recipe {recipe_id} not found")
# Check ownership
actor_id = f"@{username}@{L2_DOMAIN}"
if config.uploader not in (username, actor_id):
if recipe.uploader not in (username, actor_id):
raise HTTPException(403, "Access denied")
# Check if pinned
pinned, reason = cache_manager.is_pinned(config_id)
pinned, reason = cache_manager.is_pinned(recipe_id)
if pinned:
raise HTTPException(400, f"Cannot delete pinned config: {reason}")
raise HTTPException(400, f"Cannot delete pinned recipe: {reason}")
# Delete from Redis and cache
delete_config_from_redis(config_id)
cache_manager.delete_by_content_hash(config_id)
delete_recipe_from_redis(recipe_id)
cache_manager.delete_by_content_hash(recipe_id)
return {"deleted": True, "config_id": config_id}
return {"deleted": True, "recipe_id": recipe_id}
@app.post("/configs/{config_id}/run")
async def run_config(config_id: str, request: ConfigRunRequest, username: str = Depends(get_required_user)):
"""Run a config with provided variable inputs. Requires authentication."""
config = load_config(config_id)
if not config:
raise HTTPException(404, f"Config {config_id} not found")
@app.post("/recipes/{recipe_id}/run")
async def run_recipe(recipe_id: str, request: RecipeRunRequest, username: str = Depends(get_required_user)):
"""Run a recipe with provided variable inputs. Requires authentication."""
recipe = load_recipe(recipe_id)
if not recipe:
raise HTTPException(404, f"Recipe {recipe_id} not found")
# Validate all required inputs are provided
for var_input in config.variable_inputs:
for var_input in recipe.variable_inputs:
if var_input.required and var_input.node_id not in request.inputs:
raise HTTPException(400, f"Missing required input: {var_input.name}")
# Load config YAML
config_path = cache_manager.get_by_content_hash(config_id)
if not config_path:
raise HTTPException(500, "Config YAML not found in cache")
# Load recipe YAML
recipe_path = cache_manager.get_by_content_hash(recipe_id)
if not recipe_path:
raise HTTPException(500, "Recipe YAML not found in cache")
with open(config_path) as f:
with open(recipe_path) as f:
yaml_config = yaml.safe_load(f)
# Build DAG from config
dag = build_dag_from_config(yaml_config, request.inputs, config)
# Build DAG from recipe
dag = build_dag_from_recipe(yaml_config, request.inputs, recipe)
# Create run
run_id = str(uuid.uuid4())
@@ -1128,16 +1141,16 @@ async def run_config(config_id: str, request: ConfigRunRequest, username: str =
# Collect all input hashes
all_inputs = list(request.inputs.values())
for fixed in config.fixed_inputs:
for fixed in recipe.fixed_inputs:
if fixed.content_hash:
all_inputs.append(fixed.content_hash)
run = RunStatus(
run_id=run_id,
status="pending",
recipe=f"config:{config.name}",
recipe=f"recipe:{recipe.name}",
inputs=all_inputs,
output_name=f"{config.name}-{run_id[:8]}",
output_name=f"{recipe.name}-{run_id[:8]}",
created_at=datetime.now(timezone.utc).isoformat(),
username=actor_id
)
@@ -1152,8 +1165,8 @@ async def run_config(config_id: str, request: ConfigRunRequest, username: str =
return run
def build_dag_from_config(yaml_config: dict, user_inputs: dict[str, str], config: ConfigStatus):
"""Build a DAG from config YAML with user-provided inputs."""
def build_dag_from_recipe(yaml_config: dict, user_inputs: dict[str, str], recipe: RecipeStatus):
"""Build a DAG from recipe YAML with user-provided inputs."""
from artdag import DAG, Node
dag = DAG()
@@ -1207,39 +1220,39 @@ def build_dag_from_config(yaml_config: dict, user_inputs: dict[str, str], config
return dag
# ============ Config UI Pages ============
# ============ Recipe UI Pages ============
@app.get("/configs", response_class=HTMLResponse)
async def configs_page(request: Request, page: int = 1):
"""Configs list page (HTML)."""
@app.get("/recipes", response_class=HTMLResponse)
async def recipes_page(request: Request, page: int = 1):
"""Recipes list page (HTML)."""
current_user = get_user_from_cookie(request)
if not current_user:
return HTMLResponse(render_page(
"Configs",
'<p class="text-gray-400 py-8 text-center"><a href="/login" class="text-blue-400 hover:text-blue-300">Login</a> to see configs.</p>',
"Recipes",
'<p class="text-gray-400 py-8 text-center"><a href="/login" class="text-blue-400 hover:text-blue-300">Login</a> to see recipes.</p>',
None,
active_tab="configs"
active_tab="recipes"
))
all_configs = list_all_configs()
all_recipes = list_all_recipes()
# Filter to user's configs
actor_id = f"@{current_user}@{L2_DOMAIN}"
user_configs = [c for c in all_configs if c.uploader in (current_user, actor_id)]
total = len(user_configs)
user_recipes = [c for c in all_recipes if c.uploader in (current_user, actor_id)]
total = len(user_recipes)
if not user_configs:
if not user_recipes:
content = '''
<h2 class="text-xl font-semibold text-white mb-6">Configs (0)</h2>
<p class="text-gray-400 py-8 text-center">No configs yet. Upload a config YAML file to get started.</p>
<h2 class="text-xl font-semibold text-white mb-6">Recipes (0)</h2>
<p class="text-gray-400 py-8 text-center">No recipes yet. Upload a recipe YAML file to get started.</p>
'''
return HTMLResponse(render_page("Configs", content, current_user, active_tab="configs"))
return HTMLResponse(render_page("Recipes", content, current_user, active_tab="recipes"))
html_parts = []
for config in user_configs:
var_count = len(config.variable_inputs)
fixed_count = len(config.fixed_inputs)
for recipe in user_recipes:
var_count = len(recipe.variable_inputs)
fixed_count = len(recipe.fixed_inputs)
input_info = []
if var_count:
input_info.append(f"{var_count} variable")
@@ -1248,54 +1261,54 @@ async def configs_page(request: Request, page: int = 1):
inputs_str = ", ".join(input_info) if input_info else "no inputs"
html_parts.append(f'''
<a href="/config/{config.config_id}" class="block">
<a href="/recipe/{recipe.recipe_id}" class="block">
<div class="bg-dark-700 rounded-lg p-4 hover:bg-dark-600 transition-colors">
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-purple-600 text-white text-sm font-medium rounded-full">{config.name}</span>
<span class="text-gray-400 text-xs">v{config.version}</span>
<span class="px-3 py-1 bg-purple-600 text-white text-sm font-medium rounded-full">{recipe.name}</span>
<span class="text-gray-400 text-xs">v{recipe.version}</span>
</div>
<span class="text-xs text-gray-400">{inputs_str}</span>
</div>
<div class="text-sm text-gray-400 mb-2">
{config.description or "No description"}
{recipe.description or "No description"}
</div>
<div class="text-xs text-gray-500 font-mono truncate">
{config.config_id[:24]}...
{recipe.recipe_id[:24]}...
</div>
</div>
</a>
''')
content = f'''
<h2 class="text-xl font-semibold text-white mb-6">Configs ({total})</h2>
<h2 class="text-xl font-semibold text-white mb-6">Recipes ({total})</h2>
<div class="space-y-4">
{''.join(html_parts)}
</div>
'''
return HTMLResponse(render_page("Configs", content, current_user, active_tab="configs"))
return HTMLResponse(render_page("Recipes", content, current_user, active_tab="recipes"))
@app.get("/config/{config_id}", response_class=HTMLResponse)
async def config_detail_page(config_id: str, request: Request):
"""Config detail page with run form."""
@app.get("/recipe/{recipe_id}", response_class=HTMLResponse)
async def recipe_detail_page(recipe_id: str, request: Request):
"""Recipe detail page with run form."""
current_user = get_user_from_cookie(request)
config = load_config(config_id)
recipe = load_recipe(recipe_id)
if not config:
if not recipe:
return HTMLResponse(render_page(
"Config Not Found",
f'<p class="text-red-400">Config {config_id} not found.</p>',
"Recipe Not Found",
f'<p class="text-red-400">Recipe {recipe_id} not found.</p>',
current_user,
active_tab="configs"
active_tab="recipes"
), status_code=404)
# Build variable inputs form
var_inputs_html = ""
if config.variable_inputs:
if recipe.variable_inputs:
var_inputs_html = '<div class="space-y-4 mb-6">'
for var_input in config.variable_inputs:
for var_input in recipe.variable_inputs:
required = "required" if var_input.required else ""
var_inputs_html += f'''
<div>
@@ -1310,86 +1323,86 @@ async def config_detail_page(config_id: str, request: Request):
'''
var_inputs_html += '</div>'
else:
var_inputs_html = '<p class="text-gray-400 mb-4">This config has no variable inputs - it uses fixed assets only.</p>'
var_inputs_html = '<p class="text-gray-400 mb-4">This recipe has no variable inputs - it uses fixed assets only.</p>'
# Build fixed inputs display
fixed_inputs_html = ""
if config.fixed_inputs:
if recipe.fixed_inputs:
fixed_inputs_html = '<div class="mt-4"><h4 class="text-sm font-medium text-gray-300 mb-2">Fixed Inputs</h4><ul class="text-sm text-gray-400 space-y-1">'
for fixed in config.fixed_inputs:
for fixed in recipe.fixed_inputs:
fixed_inputs_html += f'<li><span class="text-gray-500">{fixed.asset}:</span> <span class="font-mono text-xs">{fixed.content_hash[:16]}...</span></li>'
fixed_inputs_html += '</ul></div>'
# Check if pinned
pinned, pin_reason = cache_manager.is_pinned(config_id)
pinned, pin_reason = cache_manager.is_pinned(recipe_id)
pinned_badge = ""
if pinned:
pinned_badge = f'<span class="px-2 py-1 bg-yellow-600 text-white text-xs rounded-full ml-2">Pinned: {pin_reason}</span>'
content = f'''
<div class="mb-6">
<a href="/configs" class="text-blue-400 hover:text-blue-300 text-sm">&larr; Back to configs</a>
<a href="/recipes" class="text-blue-400 hover:text-blue-300 text-sm">&larr; Back to recipes</a>
</div>
<div class="bg-dark-700 rounded-lg p-6 mb-6">
<div class="flex items-center gap-3 mb-4">
<h2 class="text-2xl font-bold text-white">{config.name}</h2>
<span class="px-2 py-1 bg-gray-600 text-white text-xs rounded-full">v{config.version}</span>
<h2 class="text-2xl font-bold text-white">{recipe.name}</h2>
<span class="px-2 py-1 bg-gray-600 text-white text-xs rounded-full">v{recipe.version}</span>
{pinned_badge}
</div>
<p class="text-gray-400 mb-4">{config.description or 'No description'}</p>
<div class="text-xs text-gray-500 font-mono">{config.config_id}</div>
<p class="text-gray-400 mb-4">{recipe.description or 'No description'}</p>
<div class="text-xs text-gray-500 font-mono">{recipe.recipe_id}</div>
{fixed_inputs_html}
</div>
<div class="bg-dark-700 rounded-lg p-6">
<h3 class="text-lg font-semibold text-white mb-4">Run this Config</h3>
<form hx-post="/ui/configs/{config_id}/run" hx-target="#run-result" hx-swap="innerHTML">
<h3 class="text-lg font-semibold text-white mb-4">Run this Recipe</h3>
<form hx-post="/ui/recipes/{recipe_id}/run" hx-target="#run-result" hx-swap="innerHTML">
{var_inputs_html}
<div id="run-result"></div>
<button type="submit"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors">
Run Config
Run Recipe
</button>
</form>
</div>
'''
return HTMLResponse(render_page(f"Config: {config.name}", content, current_user, active_tab="configs"))
return HTMLResponse(render_page(f"Recipe: {recipe.name}", content, current_user, active_tab="recipes"))
@app.post("/ui/configs/{config_id}/run", response_class=HTMLResponse)
async def ui_run_config(config_id: str, request: Request):
"""HTMX handler: run a config with form inputs."""
@app.post("/ui/recipes/{recipe_id}/run", response_class=HTMLResponse)
async def ui_run_recipe(recipe_id: str, request: Request):
"""HTMX handler: run a recipe with form inputs."""
current_user = get_user_from_cookie(request)
if not current_user:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Login required</div>'
config = load_config(config_id)
if not config:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Config not found</div>'
recipe = load_recipe(recipe_id)
if not recipe:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Recipe not found</div>'
# Parse form data
form_data = await request.form()
inputs = {}
for var_input in config.variable_inputs:
for var_input in recipe.variable_inputs:
value = form_data.get(var_input.node_id, "").strip()
if var_input.required and not value:
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Missing required input: {var_input.name}</div>'
if value:
inputs[var_input.node_id] = value
# Load config YAML
config_path = cache_manager.get_by_content_hash(config_id)
if not config_path:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Config YAML not found in cache</div>'
# Load recipe YAML
recipe_path = cache_manager.get_by_content_hash(recipe_id)
if not recipe_path:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Recipe YAML not found in cache</div>'
try:
with open(config_path) as f:
with open(recipe_path) as f:
yaml_config = yaml.safe_load(f)
# Build DAG from config
dag = build_dag_from_config(yaml_config, inputs, config)
# Build DAG from recipe
dag = build_dag_from_recipe(yaml_config, inputs, recipe)
# Create run
run_id = str(uuid.uuid4())
@@ -1397,16 +1410,16 @@ async def ui_run_config(config_id: str, request: Request):
# Collect all input hashes
all_inputs = list(inputs.values())
for fixed in config.fixed_inputs:
for fixed in recipe.fixed_inputs:
if fixed.content_hash:
all_inputs.append(fixed.content_hash)
run = RunStatus(
run_id=run_id,
status="pending",
recipe=f"config:{config.name}",
recipe=f"recipe:{recipe.name}",
inputs=all_inputs,
output_name=f"{config.name}-{run_id[:8]}",
output_name=f"{recipe.name}-{run_id[:8]}",
created_at=datetime.now(timezone.utc).isoformat(),
username=actor_id
)
@@ -1428,27 +1441,27 @@ async def ui_run_config(config_id: str, request: Request):
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Error: {str(e)}</div>'
@app.get("/ui/configs-list", response_class=HTMLResponse)
async def ui_configs_list(request: Request):
"""HTMX partial: list of configs."""
@app.get("/ui/recipes-list", response_class=HTMLResponse)
async def ui_recipes_list(request: Request):
"""HTMX partial: list of recipes."""
current_user = get_user_from_cookie(request)
if not current_user:
return '<p class="text-gray-400 py-8 text-center"><a href="/ui/login" class="text-blue-400 hover:text-blue-300">Login</a> to see configs.</p>'
return '<p class="text-gray-400 py-8 text-center"><a href="/ui/login" class="text-blue-400 hover:text-blue-300">Login</a> to see recipes.</p>'
all_configs = list_all_configs()
all_recipes = list_all_recipes()
# Filter to user's configs
actor_id = f"@{current_user}@{L2_DOMAIN}"
user_configs = [c for c in all_configs if c.uploader in (current_user, actor_id)]
user_recipes = [c for c in all_recipes if c.uploader in (current_user, actor_id)]
if not user_configs:
return '<p class="text-gray-400 py-8 text-center">No configs yet. Upload a config YAML file to get started.</p>'
if not user_recipes:
return '<p class="text-gray-400 py-8 text-center">No recipes yet. Upload a recipe YAML file to get started.</p>'
html_parts = ['<div class="space-y-4">']
for config in user_configs:
var_count = len(config.variable_inputs)
fixed_count = len(config.fixed_inputs)
for recipe in user_recipes:
var_count = len(recipe.variable_inputs)
fixed_count = len(recipe.fixed_inputs)
input_info = []
if var_count:
input_info.append(f"{var_count} variable")
@@ -1457,20 +1470,20 @@ async def ui_configs_list(request: Request):
inputs_str = ", ".join(input_info) if input_info else "no inputs"
html_parts.append(f'''
<a href="/config/{config.config_id}" class="block">
<a href="/recipe/{recipe.recipe_id}" class="block">
<div class="bg-dark-700 rounded-lg p-4 hover:bg-dark-600 transition-colors">
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-purple-600 text-white text-sm font-medium rounded-full">{config.name}</span>
<span class="text-gray-400 text-xs">v{config.version}</span>
<span class="px-3 py-1 bg-purple-600 text-white text-sm font-medium rounded-full">{recipe.name}</span>
<span class="text-gray-400 text-xs">v{recipe.version}</span>
</div>
<span class="text-xs text-gray-400">{inputs_str}</span>
</div>
<div class="text-sm text-gray-400 mb-2">
{config.description or "No description"}
{recipe.description or "No description"}
</div>
<div class="text-xs text-gray-500 font-mono truncate">
{config.config_id[:24]}...
{recipe.recipe_id[:24]}...
</div>
</div>
</a>
@@ -1480,34 +1493,34 @@ async def ui_configs_list(request: Request):
return '\n'.join(html_parts)
@app.delete("/ui/configs/{config_id}/discard", response_class=HTMLResponse)
async def ui_discard_config(config_id: str, request: Request):
"""HTMX handler: discard a config."""
@app.delete("/ui/recipes/{recipe_id}/discard", response_class=HTMLResponse)
async def ui_discard_recipe(recipe_id: str, request: Request):
"""HTMX handler: discard a recipe."""
current_user = get_user_from_cookie(request)
if not current_user:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Login required</div>'
config = load_config(config_id)
if not config:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Config not found</div>'
recipe = load_recipe(recipe_id)
if not recipe:
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Recipe not found</div>'
# Check ownership
actor_id = f"@{current_user}@{L2_DOMAIN}"
if config.uploader not in (current_user, actor_id):
if recipe.uploader not in (current_user, actor_id):
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Access denied</div>'
# Check if pinned
pinned, reason = cache_manager.is_pinned(config_id)
pinned, reason = cache_manager.is_pinned(recipe_id)
if pinned:
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Cannot delete: config is pinned ({reason})</div>'
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Cannot delete: recipe is pinned ({reason})</div>'
# Delete from Redis and cache
delete_config_from_redis(config_id)
cache_manager.delete_by_content_hash(config_id)
delete_recipe_from_redis(recipe_id)
cache_manager.delete_by_content_hash(recipe_id)
return '''
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
Config deleted. <a href="/configs" class="underline">Back to configs</a>
Recipe deleted. <a href="/recipes" class="underline">Back to recipes</a>
</div>
'''
@@ -2493,7 +2506,7 @@ async def upload_to_cache(file: UploadFile = File(...), username: str = Depends(
tmp_path = Path(tmp.name)
# Store in cache via cache_manager
cached = cache_manager.put(tmp_path, node_type="upload", move=True)
cached, ipfs_cid = cache_manager.put(tmp_path, node_type="upload", move=True)
content_hash = cached.content_hash
# Save uploader metadata
@@ -2949,7 +2962,7 @@ def render_page(title: str, content: str, username: Optional[str] = None, active
'''
runs_active = "border-b-2 border-blue-500 text-white" if active_tab == "runs" else "text-gray-400 hover:text-white"
configs_active = "border-b-2 border-blue-500 text-white" if active_tab == "configs" else "text-gray-400 hover:text-white"
recipes_active = "border-b-2 border-blue-500 text-white" if active_tab == "recipes" else "text-gray-400 hover:text-white"
cache_active = "border-b-2 border-blue-500 text-white" if active_tab == "cache" else "text-gray-400 hover:text-white"
return f"""
@@ -2972,7 +2985,7 @@ def render_page(title: str, content: str, username: Optional[str] = None, active
<nav class="flex gap-6 mb-6 border-b border-dark-500 pb-0">
<a href="/runs" class="pb-3 px-1 font-medium transition-colors {runs_active}">Runs</a>
<a href="/configs" class="pb-3 px-1 font-medium transition-colors {configs_active}">Configs</a>
<a href="/recipes" class="pb-3 px-1 font-medium transition-colors {recipes_active}">Recipes</a>
<a href="/cache" class="pb-3 px-1 font-medium transition-colors {cache_active}">Cache</a>
</nav>
@@ -3003,13 +3016,13 @@ def render_ui_html(username: Optional[str] = None, tab: str = "runs") -> str:
'''
runs_active = "border-b-2 border-blue-500 text-white" if tab == "runs" else "text-gray-400 hover:text-white"
configs_active = "border-b-2 border-blue-500 text-white" if tab == "configs" else "text-gray-400 hover:text-white"
recipes_active = "border-b-2 border-blue-500 text-white" if tab == "recipes" else "text-gray-400 hover:text-white"
cache_active = "border-b-2 border-blue-500 text-white" if tab == "cache" else "text-gray-400 hover:text-white"
if tab == "runs":
content_url = "/ui/runs"
elif tab == "configs":
content_url = "/ui/configs-list"
elif tab == "recipes":
content_url = "/ui/recipes-list"
else:
content_url = "/ui/cache-list"
@@ -3033,7 +3046,7 @@ def render_ui_html(username: Optional[str] = None, tab: str = "runs") -> str:
<nav class="flex gap-6 mb-6 border-b border-dark-500 pb-0">
<a href="/ui" class="pb-3 px-1 font-medium transition-colors {runs_active}">Runs</a>
<a href="/ui?tab=configs" class="pb-3 px-1 font-medium transition-colors {configs_active}">Configs</a>
<a href="/ui?tab=recipes" class="pb-3 px-1 font-medium transition-colors {recipes_active}">Recipes</a>
<a href="/ui?tab=cache" class="pb-3 px-1 font-medium transition-colors {cache_active}">Cache</a>
</nav>
@@ -3262,17 +3275,17 @@ async def ui_publish_run(run_id: str, request: Request, output_name: str = Form(
for input_hash in run.inputs:
save_cache_meta(input_hash, pinned=True, pin_reason="input_to_published")
# If this was a config-based run, pin the config and its fixed inputs
if run.recipe.startswith("config:"):
config_name = run.recipe.replace("config:", "")
for config in list_all_configs():
if config.name == config_name:
# Pin the config YAML
cache_manager.pin(config.config_id, reason="config_for_published")
# Pin all fixed inputs referenced by the config
for fixed in config.fixed_inputs:
# If this was a recipe-based run, pin the recipe and its fixed inputs
if run.recipe.startswith("recipe:"):
config_name = run.recipe.replace("recipe:", "")
for recipe in list_all_recipes():
if recipe.name == config_name:
# Pin the recipe YAML
cache_manager.pin(recipe.recipe_id, reason="recipe_for_published")
# Pin all fixed inputs referenced by the recipe
for fixed in recipe.fixed_inputs:
if fixed.content_hash:
cache_manager.pin(fixed.content_hash, reason="fixed_input_in_published_config")
cache_manager.pin(fixed.content_hash, reason="fixed_input_in_published_recipe")
break
return HTMLResponse(f'''