Add friendly names system for recipes, effects, and media

- Add friendly_names table with unique constraints per actor
- Create NamingService with HMAC-signed timestamp version IDs
- Version IDs use base32-crockford encoding, always increase alphabetically
- Name normalization: spaces/underscores to dashes, lowercase, strip special chars
- Format: "my-effect 01hw3x9k" (space separator ensures uniqueness)
- Integrate naming into recipe, effect, and media uploads
- Resolve friendly names to CIDs during DAG execution
- Update effects UI to display friendly names
- Add 30 tests covering normalization, parsing, and service structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-12 14:02:17 +00:00
parent 98ca2a6c81
commit 19634a4ac5
9 changed files with 814 additions and 8 deletions

View File

@@ -224,9 +224,21 @@ async def upload_content(
if error:
raise HTTPException(400, error)
# Assign friendly name (use IPFS CID if available, otherwise local hash)
final_cid = ipfs_cid or cid
from ..services.naming_service import get_naming_service
naming = get_naming_service()
friendly_entry = await naming.assign_name(
cid=final_cid,
actor_id=ctx.actor_id,
item_type="media",
filename=file.filename,
)
return {
"cid": ipfs_cid or cid,
"cid": final_cid,
"content_hash": cid, # Legacy, for backwards compatibility
"friendly_name": friendly_entry["friendly_name"],
"filename": file.filename,
"size": len(content),
"uploaded": True,

View File

@@ -200,12 +200,24 @@ async def upload_effect(
# Also store metadata in IPFS for discoverability
meta_cid = ipfs_client.add_json(full_meta)
logger.info(f"Uploaded effect '{meta.get('name')}' cid={cid} by {ctx.actor_id}")
# Assign friendly name
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"),
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", []),
@@ -244,6 +256,15 @@ async def get_effect(
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
@@ -295,6 +316,10 @@ async def list_effects(
effects_dir = get_effects_dir()
effects = []
# Get naming service for friendly name lookup
from ..services.naming_service import get_naming_service
naming = get_naming_service()
if effects_dir.exists():
for effect_dir in effects_dir.iterdir():
if effect_dir.is_dir():
@@ -302,6 +327,13 @@ async def list_effects(
if metadata_path.exists():
try:
meta = json.loads(metadata_path.read_text())
# Add friendly name if available
cid = meta.get("cid")
if cid:
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

View File

@@ -158,13 +158,59 @@ def bind_inputs(
return warnings
def prepare_dag_for_execution(
async def resolve_friendly_names_in_registry(
registry: dict,
actor_id: str,
) -> dict:
"""
Resolve friendly names to CIDs in the registry.
Friendly names are identified by containing a space (e.g., "brightness 01hw3x9k")
or by not being a valid CID format.
"""
from ..services.naming_service import get_naming_service
import re
naming = get_naming_service()
resolved = {"assets": {}, "effects": {}}
# CID patterns: IPFS CID (Qm..., bafy...) or SHA256 hash (64 hex chars)
cid_pattern = re.compile(r'^(Qm[a-zA-Z0-9]{44}|bafy[a-zA-Z0-9]+|[a-f0-9]{64})$')
for asset_name, asset_info in registry.get("assets", {}).items():
cid = asset_info.get("cid", "")
if cid and not cid_pattern.match(cid):
# Looks like a friendly name, resolve it
resolved_cid = await naming.resolve(actor_id, cid, item_type="media")
if resolved_cid:
asset_info = dict(asset_info)
asset_info["cid"] = resolved_cid
asset_info["_resolved_from"] = cid
resolved["assets"][asset_name] = asset_info
for effect_name, effect_info in registry.get("effects", {}).items():
cid = effect_info.get("cid", "")
if cid and not cid_pattern.match(cid):
# Looks like a friendly name, resolve it
resolved_cid = await naming.resolve(actor_id, cid, item_type="effect")
if resolved_cid:
effect_info = dict(effect_info)
effect_info["cid"] = resolved_cid
effect_info["_resolved_from"] = cid
resolved["effects"][effect_name] = effect_info
return resolved
async def prepare_dag_for_execution(
recipe: Recipe,
user_inputs: Dict[str, str],
actor_id: str = None,
) -> Tuple[str, List[str]]:
"""
Prepare a recipe DAG for execution by transforming nodes and binding inputs.
Resolves friendly names to CIDs if actor_id is provided.
Returns (dag_json, warnings).
"""
recipe_dag = recipe.get("dag")
@@ -177,6 +223,11 @@ def prepare_dag_for_execution(
# Get registry for resolving references
registry = recipe.get("registry", {})
# Resolve friendly names to CIDs
if actor_id and registry:
registry = await resolve_friendly_names_in_registry(registry, actor_id)
assets = registry.get("assets", {}) if registry else {}
effects = registry.get("effects", {}) if registry else {}
@@ -506,10 +557,10 @@ async def run_recipe(
# Create run using run service
run_service = RunService(database, get_redis_client(), get_cache_manager())
# Prepare DAG for execution (transform nodes, bind inputs)
# Prepare DAG for execution (transform nodes, bind inputs, resolve friendly names)
dag_json = None
if recipe.get("dag"):
dag_json, warnings = prepare_dag_for_execution(recipe, req.inputs)
dag_json, warnings = await prepare_dag_for_execution(recipe, req.inputs, actor_id=ctx.actor_id)
for warning in warnings:
logger.warning(warning)

View File

@@ -0,0 +1,234 @@
"""
Naming service for friendly names.
Handles:
- Name normalization (My Cool Effect -> my-cool-effect)
- Version ID generation (server-signed timestamps)
- Friendly name assignment and resolution
"""
import hmac
import os
import re
import time
from typing import Optional, Tuple
import database
# Base32 Crockford alphabet (excludes I, L, O, U to avoid confusion)
CROCKFORD_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz"
def _get_server_secret() -> bytes:
"""Get server secret for signing version IDs."""
secret = os.environ.get("SERVER_SECRET", "")
if not secret:
# Fall back to a derived secret from other env vars
# In production, SERVER_SECRET should be set explicitly
secret = os.environ.get("SECRET_KEY", "default-dev-secret")
return secret.encode("utf-8")
def _base32_crockford_encode(data: bytes) -> str:
"""Encode bytes as base32-crockford (lowercase)."""
# Convert bytes to integer
num = int.from_bytes(data, "big")
if num == 0:
return CROCKFORD_ALPHABET[0]
result = []
while num > 0:
result.append(CROCKFORD_ALPHABET[num % 32])
num //= 32
return "".join(reversed(result))
def generate_version_id() -> str:
"""
Generate a version ID that is:
- Always increasing (timestamp-based prefix)
- Verifiable as originating from this server (HMAC suffix)
- Short and URL-safe (13 chars)
Format: 6 bytes timestamp (ms) + 2 bytes HMAC = 8 bytes = 13 base32 chars
"""
timestamp_ms = int(time.time() * 1000)
timestamp_bytes = timestamp_ms.to_bytes(6, "big")
# HMAC the timestamp with server secret
secret = _get_server_secret()
sig = hmac.new(secret, timestamp_bytes, "sha256").digest()
# Combine: 6 bytes timestamp + 2 bytes HMAC signature
combined = timestamp_bytes + sig[:2]
# Encode as base32-crockford
return _base32_crockford_encode(combined)
def normalize_name(name: str) -> str:
"""
Normalize a display name to a base name.
- Lowercase
- Replace spaces and underscores with dashes
- Remove special characters (keep alphanumeric and dashes)
- Collapse multiple dashes
- Strip leading/trailing dashes
Examples:
"My Cool Effect" -> "my-cool-effect"
"Brightness_V2" -> "brightness-v2"
"Test!!!Effect" -> "test-effect"
"""
# Lowercase
name = name.lower()
# Replace spaces and underscores with dashes
name = re.sub(r"[\s_]+", "-", name)
# Remove anything that's not alphanumeric or dash
name = re.sub(r"[^a-z0-9-]", "", name)
# Collapse multiple dashes
name = re.sub(r"-+", "-", name)
# Strip leading/trailing dashes
name = name.strip("-")
return name or "unnamed"
def parse_friendly_name(friendly_name: str) -> Tuple[str, Optional[str]]:
"""
Parse a friendly name into base name and optional version.
Args:
friendly_name: Name like "my-effect" or "my-effect 01hw3x9k"
Returns:
Tuple of (base_name, version_id or None)
"""
parts = friendly_name.strip().split(" ", 1)
base_name = parts[0]
version_id = parts[1] if len(parts) > 1 else None
return base_name, version_id
def format_friendly_name(base_name: str, version_id: str) -> str:
"""Format a base name and version into a full friendly name."""
return f"{base_name} {version_id}"
def format_l2_name(actor_id: str, base_name: str, version_id: str) -> str:
"""
Format a friendly name for L2 sharing.
Format: @user@domain base-name version-id
"""
return f"{actor_id} {base_name} {version_id}"
class NamingService:
"""Service for managing friendly names."""
async def assign_name(
self,
cid: str,
actor_id: str,
item_type: str,
display_name: Optional[str] = None,
filename: Optional[str] = None,
) -> dict:
"""
Assign a friendly name to content.
Args:
cid: Content ID
actor_id: User ID
item_type: Type (recipe, effect, media)
display_name: Human-readable name (optional)
filename: Original filename (used as fallback for media)
Returns:
Friendly name entry dict
"""
# Determine display name
if not display_name:
if filename:
# Use filename without extension
display_name = os.path.splitext(filename)[0]
else:
display_name = f"unnamed-{item_type}"
# Normalize to base name
base_name = normalize_name(display_name)
# Generate version ID
version_id = generate_version_id()
# Create database entry
entry = await database.create_friendly_name(
actor_id=actor_id,
base_name=base_name,
version_id=version_id,
cid=cid,
item_type=item_type,
display_name=display_name,
)
return entry
async def get_by_cid(self, actor_id: str, cid: str) -> Optional[dict]:
"""Get friendly name entry by CID."""
return await database.get_friendly_name_by_cid(actor_id, cid)
async def resolve(
self,
actor_id: str,
name: str,
item_type: Optional[str] = None,
) -> Optional[str]:
"""
Resolve a friendly name to a CID.
Args:
actor_id: User ID
name: Friendly name ("base-name" or "base-name version")
item_type: Optional type filter
Returns:
CID or None if not found
"""
return await database.resolve_friendly_name(actor_id, name, item_type)
async def list_names(
self,
actor_id: str,
item_type: Optional[str] = None,
latest_only: bool = False,
) -> list:
"""List friendly names for a user."""
return await database.list_friendly_names(
actor_id=actor_id,
item_type=item_type,
latest_only=latest_only,
)
async def delete(self, actor_id: str, cid: str) -> bool:
"""Delete a friendly name entry."""
return await database.delete_friendly_name(actor_id, cid)
# Module-level instance
_naming_service: Optional[NamingService] = None
def get_naming_service() -> NamingService:
"""Get the naming service singleton."""
global _naming_service
if _naming_service is None:
_naming_service = NamingService()
return _naming_service

View File

@@ -124,6 +124,18 @@ class RecipeService:
cached, ipfs_cid = self.cache.put(tmp_path, node_type="recipe", move=True)
recipe_id = ipfs_cid or cached.cid # Prefer IPFS CID
# Assign friendly name
if uploader:
from .naming_service import get_naming_service
naming = get_naming_service()
display_name = name or compiled.name or "unnamed-recipe"
await naming.assign_name(
cid=recipe_id,
actor_id=uploader,
item_type="recipe",
display_name=display_name,
)
return recipe_id, None
except Exception as e:

View File

@@ -31,8 +31,15 @@
<p class="text-gray-400 mb-6">{{ meta.description }}</p>
{% endif %}
<!-- CID Info -->
<!-- Friendly Name & CID Info -->
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-6">
{% if effect.friendly_name %}
<div class="mb-4 pb-4 border-b border-gray-700">
<span class="text-gray-500 text-sm">Friendly Name</span>
<p class="text-blue-400 font-medium text-lg mt-1">{{ effect.friendly_name }}</p>
<p class="text-gray-500 text-xs mt-1">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">(effect {{ effect.base_name }})</code></p>
</div>
{% endif %}
<div class="flex items-center justify-between">
<div>
<span class="text-gray-500 text-sm">Content ID (CID)</span>

View File

@@ -60,8 +60,12 @@
</div>
{% endif %}
<div class="mt-3 text-gray-600 text-xs font-mono truncate">
{{ effect.cid[:24] }}...
<div class="mt-3 text-xs">
{% if effect.friendly_name %}
<span class="text-blue-400 font-medium">{{ effect.friendly_name }}</span>
{% else %}
<span class="text-gray-600 font-mono truncate">{{ effect.cid[:24] }}...</span>
{% endif %}
</div>
</a>
{% endfor %}