diff --git a/cache_manager.py b/cache_manager.py index a0f0d3c..82fd32a 100644 --- a/cache_manager.py +++ b/cache_manager.py @@ -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() diff --git a/server.py b/server.py index ef9909c..2fc3a84 100644 --- a/server.py +++ b/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 '