lists of shares. job deletion only deltes outputs
This commit is contained in:
@@ -91,7 +91,7 @@ class L2SharedChecker:
|
||||
|
||||
# Query L2
|
||||
try:
|
||||
url = f"{self.l2_server}/registry/by-hash/{content_hash}"
|
||||
url = f"{self.l2_server}/assets/by-hash/{content_hash}"
|
||||
logger.info(f"L2 check: GET {url}")
|
||||
resp = requests.get(url, timeout=5)
|
||||
logger.info(f"L2 check response: {resp.status_code}")
|
||||
@@ -506,6 +506,61 @@ class L1CacheManager:
|
||||
return True, "Activity discarded"
|
||||
return False, "Failed to discard"
|
||||
|
||||
def discard_activity_outputs_only(self, activity_id: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Discard an activity, deleting only outputs and intermediates.
|
||||
|
||||
Inputs (cache items, configs) are preserved.
|
||||
|
||||
Returns:
|
||||
(success, message) tuple
|
||||
"""
|
||||
activity = self.activity_store.get(activity_id)
|
||||
if not activity:
|
||||
return False, "Activity not found"
|
||||
|
||||
# Check if output is pinned
|
||||
if activity.output_id:
|
||||
entry = self.cache.get_entry(activity.output_id)
|
||||
if entry:
|
||||
pinned, reason = self.is_pinned(entry.content_hash)
|
||||
if pinned:
|
||||
return False, f"Output is pinned ({reason})"
|
||||
|
||||
# Delete output
|
||||
if activity.output_id:
|
||||
entry = self.cache.get_entry(activity.output_id)
|
||||
if entry:
|
||||
# Remove from cache
|
||||
self.cache.remove(activity.output_id)
|
||||
# Remove from content index
|
||||
if entry.content_hash in self._content_index:
|
||||
del self._content_index[entry.content_hash]
|
||||
self._save_content_index()
|
||||
# Delete from legacy dir if exists
|
||||
legacy_path = self.legacy_dir / entry.content_hash
|
||||
if legacy_path.exists():
|
||||
legacy_path.unlink()
|
||||
|
||||
# Delete intermediates
|
||||
for node_id in activity.intermediate_ids:
|
||||
entry = self.cache.get_entry(node_id)
|
||||
if entry:
|
||||
self.cache.remove(node_id)
|
||||
if entry.content_hash in self._content_index:
|
||||
del self._content_index[entry.content_hash]
|
||||
legacy_path = self.legacy_dir / entry.content_hash
|
||||
if legacy_path.exists():
|
||||
legacy_path.unlink()
|
||||
|
||||
if activity.intermediate_ids:
|
||||
self._save_content_index()
|
||||
|
||||
# Remove activity record (inputs remain in cache)
|
||||
self.activity_store.remove(activity_id)
|
||||
|
||||
return True, "Activity discarded (outputs only)"
|
||||
|
||||
def cleanup_intermediates(self) -> int:
|
||||
"""Delete all intermediate cache entries (reconstructible)."""
|
||||
return self.activity_manager.cleanup_intermediates()
|
||||
|
||||
137
server.py
137
server.py
@@ -515,13 +515,12 @@ async def get_run(run_id: str):
|
||||
@app.delete("/runs/{run_id}")
|
||||
async def discard_run(run_id: str, username: str = Depends(get_required_user)):
|
||||
"""
|
||||
Discard (delete) a run and its intermediate cache entries.
|
||||
Discard (delete) a run and its outputs.
|
||||
|
||||
Enforces deletion rules:
|
||||
- Cannot discard if any item (input, output) is published to L2
|
||||
- Deletes intermediate cache entries
|
||||
- Keeps inputs (may be used by other runs)
|
||||
- Deletes orphaned outputs
|
||||
- Cannot discard if output is published to L2 (pinned)
|
||||
- Deletes outputs and intermediate cache entries
|
||||
- Preserves inputs (cache items and configs are NOT deleted)
|
||||
"""
|
||||
run = load_run(run_id)
|
||||
if not run:
|
||||
@@ -534,30 +533,21 @@ async def discard_run(run_id: str, username: str = Depends(get_required_user)):
|
||||
|
||||
# Failed runs can always be deleted (no output to protect)
|
||||
if run.status != "failed":
|
||||
# Check if any items are pinned (published or input to published)
|
||||
items_to_check = list(run.inputs or [])
|
||||
# Only check if output is pinned - inputs are preserved, not deleted
|
||||
if run.output_hash:
|
||||
items_to_check.append(run.output_hash)
|
||||
|
||||
for content_hash in items_to_check:
|
||||
meta = load_cache_meta(content_hash)
|
||||
meta = load_cache_meta(run.output_hash)
|
||||
if meta.get("pinned"):
|
||||
pin_reason = meta.get("pin_reason", "published")
|
||||
raise HTTPException(400, f"Cannot discard run: item {content_hash[:16]}... is pinned ({pin_reason})")
|
||||
raise HTTPException(400, f"Cannot discard run: output {run.output_hash[:16]}... is pinned ({pin_reason})")
|
||||
|
||||
# Check if activity exists for this run
|
||||
activity = cache_manager.get_activity(run_id)
|
||||
|
||||
if activity:
|
||||
# Use activity manager deletion rules
|
||||
can_discard, reason = cache_manager.can_discard_activity(run_id)
|
||||
if not can_discard:
|
||||
raise HTTPException(400, f"Cannot discard run: {reason}")
|
||||
|
||||
# Discard the activity (cleans up cache entries)
|
||||
success, msg = cache_manager.discard_activity(run_id)
|
||||
# Discard the activity - only delete outputs, preserve inputs
|
||||
success, msg = cache_manager.discard_activity_outputs_only(run_id)
|
||||
if not success:
|
||||
raise HTTPException(500, f"Failed to discard: {msg}")
|
||||
raise HTTPException(400, f"Cannot discard run: {msg}")
|
||||
|
||||
# Remove from Redis
|
||||
redis_client.delete(f"{RUNS_KEY_PREFIX}{run_id}")
|
||||
@@ -567,7 +557,7 @@ async def discard_run(run_id: str, username: str = Depends(get_required_user)):
|
||||
|
||||
@app.delete("/ui/runs/{run_id}/discard", response_class=HTMLResponse)
|
||||
async def ui_discard_run(run_id: str, request: Request):
|
||||
"""HTMX handler: discard a run."""
|
||||
"""HTMX handler: discard a run. Only deletes outputs, preserves 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>'
|
||||
@@ -583,28 +573,21 @@ async def ui_discard_run(run_id: str, request: Request):
|
||||
|
||||
# Failed runs can always be deleted
|
||||
if run.status != "failed":
|
||||
# Check if any items are pinned (published or input to published)
|
||||
items_to_check = list(run.inputs or [])
|
||||
# Only check if output is pinned - inputs are preserved, not deleted
|
||||
if run.output_hash:
|
||||
items_to_check.append(run.output_hash)
|
||||
|
||||
for content_hash in items_to_check:
|
||||
meta = load_cache_meta(content_hash)
|
||||
meta = load_cache_meta(run.output_hash)
|
||||
if meta.get("pinned"):
|
||||
pin_reason = meta.get("pin_reason", "published")
|
||||
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 {content_hash[:16]}... is pinned ({pin_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 discard: output is pinned ({pin_reason})</div>'
|
||||
|
||||
# Check if activity exists for this run
|
||||
activity = cache_manager.get_activity(run_id)
|
||||
|
||||
if activity:
|
||||
can_discard, reason = cache_manager.can_discard_activity(run_id)
|
||||
if not can_discard:
|
||||
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: {reason}</div>'
|
||||
|
||||
success, msg = cache_manager.discard_activity(run_id)
|
||||
# Discard the activity - only delete outputs, preserve inputs
|
||||
success, msg = cache_manager.discard_activity_outputs_only(run_id)
|
||||
if not success:
|
||||
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Failed to discard: {msg}</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 discard: {msg}</div>'
|
||||
|
||||
# Remove from Redis
|
||||
redis_client.delete(f"{RUNS_KEY_PREFIX}{run_id}")
|
||||
@@ -1745,7 +1728,7 @@ async def ui_cache_meta_form(content_hash: str, request: Request):
|
||||
description = meta.get("description", "")
|
||||
tags = meta.get("tags", [])
|
||||
tags_str = ", ".join(tags) if tags else ""
|
||||
published = meta.get("published", {})
|
||||
l2_shares = meta.get("l2_shares", [])
|
||||
pinned = meta.get("pinned", False)
|
||||
pin_reason = meta.get("pin_reason", "")
|
||||
|
||||
@@ -1758,18 +1741,31 @@ async def ui_cache_meta_form(content_hash: str, request: Request):
|
||||
self_checked = 'checked' if origin_type == "self" else ''
|
||||
external_checked = 'checked' if origin_type == "external" else ''
|
||||
|
||||
# Build publish section
|
||||
if published.get("to_l2"):
|
||||
asset_name = published.get("asset_name", "")
|
||||
published_at = published.get("published_at", "")[:10]
|
||||
last_synced = published.get("last_synced_at", "")[:10]
|
||||
# Build publish section - show list of L2 shares
|
||||
if l2_shares:
|
||||
shares_html = ""
|
||||
for share in l2_shares:
|
||||
l2_server = share.get("l2_server", "Unknown")
|
||||
asset_name = share.get("asset_name", "")
|
||||
published_at = share.get("published_at", "")[:10] if share.get("published_at") else ""
|
||||
last_synced = share.get("last_synced_at", "")[:10] if share.get("last_synced_at") else ""
|
||||
shares_html += f'''
|
||||
<div class="flex justify-between items-start py-2 border-b border-green-800 last:border-0">
|
||||
<div>
|
||||
<div class="text-white font-medium">{asset_name}</div>
|
||||
<div class="text-xs text-gray-400">{l2_server}</div>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-400">
|
||||
Published: {published_at}<br>
|
||||
Synced: {last_synced}
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
publish_html = f'''
|
||||
<div class="bg-green-900/30 border border-green-700 rounded-lg p-4 mb-4">
|
||||
<div class="text-green-400 font-medium mb-2">Published to L2</div>
|
||||
<div class="text-sm text-gray-300">
|
||||
Asset name: <strong>{asset_name}</strong><br>
|
||||
Published: {published_at}<br>
|
||||
Last synced: {last_synced}
|
||||
<div class="text-green-400 font-medium mb-2">Published to L2 ({len(l2_shares)} share{"s" if len(l2_shares) != 1 else ""})</div>
|
||||
<div class="text-sm">
|
||||
{shares_html}
|
||||
</div>
|
||||
</div>
|
||||
<div id="republish-result"></div>
|
||||
@@ -1965,7 +1961,7 @@ async def ui_publish_cache(content_hash: str, request: Request):
|
||||
# Call L2
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{L2_SERVER}/registry/publish-cache",
|
||||
f"{L2_SERVER}/assets/publish-cache",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"content_hash": content_hash,
|
||||
@@ -1993,14 +1989,26 @@ async def ui_publish_cache(content_hash: str, request: Request):
|
||||
except Exception as e:
|
||||
return f'<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Error: {e}</div>'
|
||||
|
||||
# Update local metadata
|
||||
publish_info = {
|
||||
"to_l2": True,
|
||||
# Update local metadata - add to l2_shares list
|
||||
share_info = {
|
||||
"l2_server": L2_SERVER,
|
||||
"asset_name": 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, pinned=True, pin_reason="published")
|
||||
# Load existing shares and append
|
||||
existing_meta = load_cache_meta(content_hash)
|
||||
l2_shares = existing_meta.get("l2_shares", [])
|
||||
# Update if already shared to this L2, otherwise append
|
||||
updated = False
|
||||
for i, share in enumerate(l2_shares):
|
||||
if share.get("l2_server") == L2_SERVER:
|
||||
l2_shares[i] = share_info
|
||||
updated = True
|
||||
break
|
||||
if not updated:
|
||||
l2_shares.append(share_info)
|
||||
save_cache_meta(content_hash, l2_shares=l2_shares, 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">
|
||||
@@ -2028,19 +2036,28 @@ async def ui_republish_cache(content_hash: str, request: Request):
|
||||
|
||||
# Load metadata
|
||||
meta = load_cache_meta(content_hash)
|
||||
published = meta.get("published", {})
|
||||
l2_shares = meta.get("l2_shares", [])
|
||||
|
||||
if not published.get("to_l2"):
|
||||
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Item not published yet</div>'
|
||||
# Find share for current L2 server
|
||||
current_share = None
|
||||
share_index = -1
|
||||
for i, share in enumerate(l2_shares):
|
||||
if share.get("l2_server") == L2_SERVER:
|
||||
current_share = share
|
||||
share_index = i
|
||||
break
|
||||
|
||||
asset_name = published.get("asset_name")
|
||||
if not current_share:
|
||||
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">Item not published to this L2 yet</div>'
|
||||
|
||||
asset_name = current_share.get("asset_name")
|
||||
if not asset_name:
|
||||
return '<div class="bg-red-900/50 border border-red-700 text-red-300 px-4 py-3 rounded-lg mb-4">No asset name found</div>'
|
||||
|
||||
# Call L2 update
|
||||
try:
|
||||
resp = http_requests.patch(
|
||||
f"{L2_SERVER}/registry/{asset_name}",
|
||||
f"{L2_SERVER}/assets/{asset_name}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"description": meta.get("description"),
|
||||
@@ -2066,8 +2083,8 @@ async def ui_republish_cache(content_hash: 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: {e}</div>'
|
||||
|
||||
# Update local metadata
|
||||
published["last_synced_at"] = datetime.now(timezone.utc).isoformat()
|
||||
save_cache_meta(content_hash, published=published)
|
||||
l2_shares[share_index]["last_synced_at"] = datetime.now(timezone.utc).isoformat()
|
||||
save_cache_meta(content_hash, l2_shares=l2_shares)
|
||||
|
||||
return '<div class="bg-green-900/50 border border-green-700 text-green-300 px-4 py-3 rounded-lg mb-4">Updated on L2!</div>'
|
||||
|
||||
@@ -2601,7 +2618,7 @@ async def publish_cache_to_l2(
|
||||
# Call L2 publish-cache endpoint
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{L2_SERVER}/registry/publish-cache",
|
||||
f"{L2_SERVER}/assets/publish-cache",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"content_hash": content_hash,
|
||||
@@ -2692,7 +2709,7 @@ async def republish_cache_to_l2(
|
||||
# Call L2 update endpoint
|
||||
try:
|
||||
resp = http_requests.patch(
|
||||
f"{L2_SERVER}/registry/{asset_name}",
|
||||
f"{L2_SERVER}/assets/{asset_name}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={
|
||||
"description": meta.get("description"),
|
||||
@@ -3226,7 +3243,7 @@ async def ui_publish_run(run_id: str, request: Request, output_name: str = Form(
|
||||
# Longer timeout because L2 calls back to L1 to fetch run details
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{L2_SERVER}/registry/record-run",
|
||||
f"{L2_SERVER}/assets/record-run",
|
||||
json={"run_id": run_id, "output_name": output_name, "l1_server": L1_PUBLIC_URL},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30
|
||||
|
||||
Reference in New Issue
Block a user