Squashed 'l1/' content from commit 670aa58
git-subtree-dir: l1 git-subtree-split: 670aa582df99e87fca7c247b949baf452e8c234f
This commit is contained in:
415
app/routers/effects.py
Normal file
415
app/routers/effects.py
Normal file
@@ -0,0 +1,415 @@
|
||||
"""
|
||||
Effects routes for L1 server.
|
||||
|
||||
Handles effect upload, listing, and metadata.
|
||||
Effects are S-expression files stored in IPFS like all other content-addressed data.
|
||||
"""
|
||||
|
||||
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, Form
|
||||
from fastapi.responses import HTMLResponse, PlainTextResponse
|
||||
|
||||
from artdag_common import render
|
||||
from artdag_common.middleware import wants_html, wants_json
|
||||
from artdag_common.middleware.auth import UserContext
|
||||
|
||||
from ..dependencies import (
|
||||
require_auth, get_templates, get_redis_client,
|
||||
get_cache_manager,
|
||||
)
|
||||
from ..services.auth_service import AuthService
|
||||
import ipfs_client
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_effects_dir() -> Path:
|
||||
"""Get effects storage directory."""
|
||||
cache_mgr = get_cache_manager()
|
||||
effects_dir = Path(cache_mgr.cache_dir) / "_effects"
|
||||
effects_dir.mkdir(parents=True, exist_ok=True)
|
||||
return effects_dir
|
||||
|
||||
|
||||
def parse_effect_metadata(source: str) -> dict:
|
||||
"""
|
||||
Parse effect metadata from S-expression source code.
|
||||
|
||||
Extracts metadata from comment headers (;; @key value format)
|
||||
or from (defeffect name ...) form.
|
||||
"""
|
||||
metadata = {
|
||||
"name": "",
|
||||
"version": "1.0.0",
|
||||
"author": "",
|
||||
"temporal": False,
|
||||
"description": "",
|
||||
"params": [],
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
# Remove comment prefix
|
||||
comment = stripped.lstrip(";").strip()
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_effect(
|
||||
file: UploadFile = File(...),
|
||||
display_name: Optional[str] = Form(None),
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""
|
||||
Upload an S-expression effect to IPFS.
|
||||
|
||||
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()
|
||||
|
||||
try:
|
||||
source = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise HTTPException(400, "Effect must be valid UTF-8 text")
|
||||
|
||||
# Parse metadata from sexp source
|
||||
try:
|
||||
meta = parse_effect_metadata(source)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse effect metadata: {e}")
|
||||
meta = {"name": file.filename or "unknown"}
|
||||
|
||||
if not meta.get("name"):
|
||||
meta["name"] = Path(file.filename).stem if file.filename else "unknown"
|
||||
|
||||
# Store effect source in IPFS
|
||||
cid = ipfs_client.add_bytes(content)
|
||||
if not cid:
|
||||
raise HTTPException(500, "Failed to store effect in IPFS")
|
||||
|
||||
# Also keep local cache for fast worker access
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
effect_dir.mkdir(parents=True, exist_ok=True)
|
||||
(effect_dir / "effect.sexp").write_text(source, encoding="utf-8")
|
||||
|
||||
# Store metadata (locally and in IPFS)
|
||||
full_meta = {
|
||||
"cid": cid,
|
||||
"meta": meta,
|
||||
"uploader": ctx.actor_id,
|
||||
"uploaded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"filename": file.filename,
|
||||
}
|
||||
(effect_dir / "metadata.json").write_text(json.dumps(full_meta, indent=2))
|
||||
|
||||
# Also store metadata in IPFS for discoverability
|
||||
meta_cid = ipfs_client.add_json(full_meta)
|
||||
|
||||
# Track ownership in item_types
|
||||
import database
|
||||
await database.save_item_metadata(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
item_type="effect",
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
# 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=display_name or meta.get("name"),
|
||||
filename=file.filename,
|
||||
)
|
||||
|
||||
logger.info(f"Uploaded effect '{meta.get('name')}' cid={cid} friendly_name='{friendly_entry['friendly_name']}' by {ctx.actor_id}")
|
||||
|
||||
return {
|
||||
"cid": cid,
|
||||
"metadata_cid": meta_cid,
|
||||
"name": meta.get("name"),
|
||||
"friendly_name": friendly_entry["friendly_name"],
|
||||
"version": meta.get("version"),
|
||||
"temporal": meta.get("temporal", False),
|
||||
"params": meta.get("params", []),
|
||||
"uploaded": True,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{cid}")
|
||||
async def get_effect(
|
||||
cid: str,
|
||||
request: Request,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Get effect metadata by CID."""
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
metadata_path = effect_dir / "metadata.json"
|
||||
|
||||
# Try local cache first
|
||||
if metadata_path.exists():
|
||||
meta = json.loads(metadata_path.read_text())
|
||||
else:
|
||||
# Fetch from IPFS
|
||||
source_bytes = ipfs_client.get_bytes(cid)
|
||||
if not source_bytes:
|
||||
raise HTTPException(404, f"Effect {cid[:16]}... not found")
|
||||
|
||||
# Cache locally
|
||||
effect_dir.mkdir(parents=True, exist_ok=True)
|
||||
source = source_bytes.decode("utf-8")
|
||||
(effect_dir / "effect.sexp").write_text(source)
|
||||
|
||||
# Parse metadata from source
|
||||
parsed_meta = parse_effect_metadata(source)
|
||||
meta = {"cid": cid, "meta": parsed_meta}
|
||||
(effect_dir / "metadata.json").write_text(json.dumps(meta, indent=2))
|
||||
|
||||
# Add friendly name if available
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
friendly = await naming.get_by_cid(ctx.actor_id, cid)
|
||||
if friendly:
|
||||
meta["friendly_name"] = friendly["friendly_name"]
|
||||
meta["base_name"] = friendly["base_name"]
|
||||
meta["version_id"] = friendly["version_id"]
|
||||
|
||||
if wants_json(request):
|
||||
return meta
|
||||
|
||||
# HTML response
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "effects/detail.html", request,
|
||||
effect=meta,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
active_tab="effects",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{cid}/source")
|
||||
async def get_effect_source(
|
||||
cid: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Get effect source code."""
|
||||
effects_dir = get_effects_dir()
|
||||
source_path = effects_dir / cid / "effect.sexp"
|
||||
|
||||
# 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:
|
||||
raise HTTPException(404, f"Effect {cid[:16]}... not found")
|
||||
|
||||
# Cache locally
|
||||
source_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = source_bytes.decode("utf-8")
|
||||
source_path.write_text(source)
|
||||
|
||||
return PlainTextResponse(source)
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_effects(
|
||||
request: Request,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""List user's effects with pagination."""
|
||||
import database
|
||||
effects_dir = get_effects_dir()
|
||||
effects = []
|
||||
|
||||
# Get user's effect CIDs from item_types
|
||||
user_items = await database.get_user_items(ctx.actor_id, item_type="effect", limit=1000)
|
||||
effect_cids = [item["cid"] for item in user_items]
|
||||
|
||||
# Get naming service for friendly name lookup
|
||||
from ..services.naming_service import get_naming_service
|
||||
naming = get_naming_service()
|
||||
|
||||
for cid in effect_cids:
|
||||
effect_dir = effects_dir / cid
|
||||
metadata_path = effect_dir / "metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
meta = json.loads(metadata_path.read_text())
|
||||
# Add friendly name if available
|
||||
friendly = await naming.get_by_cid(ctx.actor_id, cid)
|
||||
if friendly:
|
||||
meta["friendly_name"] = friendly["friendly_name"]
|
||||
meta["base_name"] = friendly["base_name"]
|
||||
effects.append(meta)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Sort by upload time (newest first)
|
||||
effects.sort(key=lambda e: e.get("uploaded_at", ""), reverse=True)
|
||||
|
||||
# Apply pagination
|
||||
total = len(effects)
|
||||
paginated_effects = effects[offset:offset + limit]
|
||||
has_more = offset + limit < total
|
||||
|
||||
if wants_json(request):
|
||||
return {"effects": paginated_effects, "offset": offset, "limit": limit, "has_more": has_more}
|
||||
|
||||
from ..dependencies import get_nav_counts
|
||||
nav_counts = await get_nav_counts(ctx.actor_id)
|
||||
|
||||
templates = get_templates(request)
|
||||
return render(templates, "effects/list.html", request,
|
||||
effects=paginated_effects,
|
||||
user=ctx,
|
||||
nav_counts=nav_counts,
|
||||
active_tab="effects",
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{cid}/publish")
|
||||
async def publish_effect(
|
||||
cid: str,
|
||||
request: Request,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Publish effect to L2 ActivityPub server."""
|
||||
from ..services.cache_service import CacheService
|
||||
import database
|
||||
|
||||
# Verify effect exists
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
if not effect_dir.exists():
|
||||
error = "Effect not found"
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-red-400">{error}</span>')
|
||||
raise HTTPException(404, error)
|
||||
|
||||
# Use cache service to publish
|
||||
cache_service = CacheService(database, get_cache_manager())
|
||||
ipfs_cid, error = await cache_service.publish_to_l2(
|
||||
cid=cid,
|
||||
actor_id=ctx.actor_id,
|
||||
l2_server=ctx.l2_server,
|
||||
auth_token=request.cookies.get("auth_token"),
|
||||
)
|
||||
|
||||
if error:
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-red-400">{error}</span>')
|
||||
raise HTTPException(400, error)
|
||||
|
||||
logger.info(f"Published effect {cid[:16]}... to L2 by {ctx.actor_id}")
|
||||
|
||||
if wants_html(request):
|
||||
return HTMLResponse(f'<span class="text-green-400">Shared: {ipfs_cid[:16]}...</span>')
|
||||
|
||||
return {"ipfs_cid": ipfs_cid, "cid": cid, "published": True}
|
||||
|
||||
|
||||
@router.delete("/{cid}")
|
||||
async def delete_effect(
|
||||
cid: str,
|
||||
ctx: UserContext = Depends(require_auth),
|
||||
):
|
||||
"""Remove user's ownership link to an effect."""
|
||||
import database
|
||||
|
||||
# Remove user's ownership link from item_types
|
||||
await database.delete_item_type(cid, ctx.actor_id, "effect")
|
||||
|
||||
# Remove friendly name
|
||||
await database.delete_friendly_name(ctx.actor_id, cid)
|
||||
|
||||
# Check if anyone still owns this effect
|
||||
remaining_owners = await database.get_item_types(cid)
|
||||
|
||||
# Only delete local files if no one owns it anymore
|
||||
if not remaining_owners:
|
||||
effects_dir = get_effects_dir()
|
||||
effect_dir = effects_dir / cid
|
||||
if effect_dir.exists():
|
||||
import shutil
|
||||
shutil.rmtree(effect_dir)
|
||||
|
||||
# Unpin from IPFS
|
||||
ipfs_client.unpin(cid)
|
||||
logger.info(f"Garbage collected effect {cid[:16]}... (no remaining owners)")
|
||||
|
||||
logger.info(f"Removed effect {cid[:16]}... ownership for {ctx.actor_id}")
|
||||
return {"deleted": True}
|
||||
Reference in New Issue
Block a user