Add cache pinning and discard functionality

- Pin items when published (published, input_to_published)
- Discard endpoint (DELETE /cache/{hash}) refuses pinned items
- UI shows pin status and discard button on cache detail
- Run publish also pins output and all inputs for provenance

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-07 21:36:52 +00:00
parent a3a37c568a
commit b2e4f81edf

119
server.py
View File

@@ -596,6 +596,8 @@ async def ui_cache_meta_form(content_hash: str, request: Request):
tags = meta.get("tags", [])
tags_str = ", ".join(tags) if tags else ""
published = meta.get("published", {})
pinned = meta.get("pinned", False)
pin_reason = meta.get("pin_reason", "")
# Detect media type for publish
cache_path = CACHE_DIR / content_hash
@@ -706,6 +708,28 @@ async def ui_cache_meta_form(content_hash: str, request: Request):
<h3 class="text-lg font-semibold text-white mb-4">Publish to L2 (ActivityPub)</h3>
{publish_html}
</div>
<!-- Status & Actions Section -->
<div class="border-t border-dark-500 pt-6 mt-6">
<h3 class="text-lg font-semibold text-white mb-4">Status</h3>
<div class="bg-dark-600 rounded-lg p-4 mb-4">
<div class="flex items-center gap-2 mb-2">
<span class="text-sm text-gray-400">Pinned:</span>
{'<span class="text-green-400">Yes</span>' if pinned else '<span class="text-gray-500">No</span>'}
{f'<span class="text-xs text-gray-500 ml-2">({pin_reason})</span>' if pinned and pin_reason else ''}
</div>
<p class="text-xs text-gray-500">Pinned items cannot be discarded. Items are pinned when published or used as inputs to published content.</p>
</div>
<div id="discard-result"></div>
{'<p class="text-gray-500 text-sm">Cannot discard pinned items.</p>' if pinned else f"""
<button hx-delete="/ui/cache/{content_hash}/discard" hx-target="#discard-result" hx-swap="innerHTML"
hx-confirm="Are you sure you want to discard this item? This cannot be undone."
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">
Discard Item
</button>
"""}
</div>
'''
@@ -826,7 +850,7 @@ async def ui_publish_cache(content_hash: str, request: Request):
"published_at": datetime.now(timezone.utc).isoformat(),
"last_synced_at": datetime.now(timezone.utc).isoformat()
}
save_cache_meta(content_hash, published=publish_info)
save_cache_meta(content_hash, published=publish_info, pinned=True, pin_reason="published")
return f'''
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
@@ -904,6 +928,81 @@ async def list_cache():
return [f.name for f in CACHE_DIR.iterdir() if f.is_file()]
@app.delete("/cache/{content_hash}")
async def discard_cache(content_hash: str, username: str = Depends(get_required_user)):
"""
Discard (delete) a cached item.
Refuses to delete pinned items. Pinned items include:
- Published items
- Inputs to published items
"""
cache_path = CACHE_DIR / content_hash
if not cache_path.exists():
raise HTTPException(404, "Content not found")
# Check ownership
user_hashes = get_user_cache_hashes(username)
if content_hash not in user_hashes:
raise HTTPException(403, "Access denied")
# Check if pinned
meta = load_cache_meta(content_hash)
if meta.get("pinned"):
pin_reason = meta.get("pin_reason", "unknown")
raise HTTPException(400, f"Cannot discard pinned item (reason: {pin_reason})")
# Delete the file and metadata
cache_path.unlink()
meta_path = CACHE_DIR / f"{content_hash}.meta.json"
if meta_path.exists():
meta_path.unlink()
# Also delete transcoded mp4 if exists
mp4_path = CACHE_DIR / f"{content_hash}.mp4"
if mp4_path.exists():
mp4_path.unlink()
return {"discarded": True, "content_hash": content_hash}
@app.delete("/ui/cache/{content_hash}/discard", response_class=HTMLResponse)
async def ui_discard_cache(content_hash: str, request: Request):
"""HTMX handler: discard a cached item."""
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>'
# Check ownership
user_hashes = get_user_cache_hashes(current_user)
if content_hash not in user_hashes:
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>'
cache_path = CACHE_DIR / content_hash
if not cache_path.exists():
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Content not found</div>'
# Check if pinned
meta = load_cache_meta(content_hash)
if meta.get("pinned"):
pin_reason = meta.get("pin_reason", "unknown")
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Cannot discard: item is pinned ({pin_reason})</div>'
# Delete the file and metadata
cache_path.unlink()
meta_path = CACHE_DIR / f"{content_hash}.meta.json"
if meta_path.exists():
meta_path.unlink()
mp4_path = CACHE_DIR / f"{content_hash}.mp4"
if mp4_path.exists():
mp4_path.unlink()
return '''
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
Item discarded. <a href="/ui?tab=cache" class="underline">Back to cache</a>
</div>
'''
# Known assets (bootstrap data)
KNOWN_ASSETS = {
"cat": "33268b6e167deaf018cc538de12dbe562612b33e89a749391cef855b320a269b",
@@ -1181,14 +1280,14 @@ async def publish_cache_to_l2(
except Exception as e:
raise HTTPException(500, f"L2 publish failed: {e}")
# Update local metadata with publish status
# Update local metadata with publish status and pin
publish_info = {
"to_l2": True,
"asset_name": req.asset_name,
"published_at": datetime.now(timezone.utc).isoformat(),
"last_synced_at": datetime.now(timezone.utc).isoformat()
}
save_cache_meta(content_hash, published=publish_info)
save_cache_meta(content_hash, published=publish_info, pinned=True, pin_reason="published")
return {
"published": True,
@@ -1665,6 +1764,11 @@ async def ui_publish_run(run_id: str, request: Request, output_name: str = Form(
if not token:
return HTMLResponse('<div class="error">Not logged in</div>')
# Get the run to pin its output and inputs
run = load_run(run_id)
if not run:
return HTMLResponse('<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg">Run not found</div>')
# Call L2 to publish the run, including this L1's public URL
# Longer timeout because L2 calls back to L1 to fetch run details
try:
@@ -1679,6 +1783,15 @@ async def ui_publish_run(run_id: str, request: Request, output_name: str = Form(
return HTMLResponse(f'<div class="error">Error: {error}</div>')
resp.raise_for_status()
result = resp.json()
# Pin the output
if run.output_hash:
save_cache_meta(run.output_hash, pinned=True, pin_reason="published")
# Pin the inputs (for provenance)
for input_hash in run.inputs:
save_cache_meta(input_hash, pinned=True, pin_reason="input_to_published")
return HTMLResponse(f'''
<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">
Published to L2 as <strong>{result["asset"]["name"]}</strong>