Use local pinned metadata for deletion checks instead of L2 API

- Add is_pinned(), pin(), _load_meta(), _save_meta() to L1CacheManager
- Update can_delete() and can_discard_activity() to check local pinned status
- Update run deletion endpoints (API and UI) to check pinned metadata
- Remove L2 shared check fallback from run deletion
- Fix L2SharedChecker to return True on error (safer - prevents accidental deletion)
- Update tests for new pinned behavior

When items are published to L2, the publish flow marks them as pinned
locally. This ensures items remain non-deletable even if L2 is unreachable,
and both outputs AND inputs of published runs are protected.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-08 02:44:18 +00:00
parent f61c82dd51
commit f23a721816
3 changed files with 85 additions and 44 deletions

View File

@@ -86,19 +86,20 @@ class L2SharedChecker:
if content_hash in self._cache:
is_shared, cached_at = self._cache[content_hash]
if now - cached_at < self.cache_ttl:
logger.debug(f"L2 check (cached): {content_hash[:16]}... = {is_shared}")
return is_shared
# Query L2
try:
resp = requests.get(
f"{self.l2_server}/registry/by-hash/{content_hash}",
timeout=5
)
url = f"{self.l2_server}/registry/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}")
is_shared = resp.status_code == 200
except Exception as e:
logger.warning(f"Failed to check L2 for {content_hash}: {e}")
# On error, assume not shared (safer for deletion)
is_shared = False
# On error, assume IS shared (safer - prevents accidental deletion)
is_shared = True
self._cache[content_hash] = (is_shared, now)
return is_shared
@@ -184,6 +185,39 @@ class L1CacheManager:
"""Check if a content_hash is shared via L2."""
return self.l2_checker.is_shared(content_hash)
def _load_meta(self, content_hash: str) -> dict:
"""Load metadata for a cached file."""
meta_path = self.cache_dir / f"{content_hash}.meta.json"
if meta_path.exists():
with open(meta_path) as f:
return json.load(f)
return {}
def is_pinned(self, content_hash: str) -> tuple[bool, str]:
"""
Check if a content_hash is pinned (non-deletable).
Returns:
(is_pinned, reason) tuple
"""
meta = self._load_meta(content_hash)
if meta.get("pinned"):
return True, meta.get("pin_reason", "published")
return False, ""
def _save_meta(self, content_hash: str, **updates) -> dict:
"""Save/update metadata for a cached file."""
meta = self._load_meta(content_hash)
meta.update(updates)
meta_path = self.cache_dir / f"{content_hash}.meta.json"
with open(meta_path, "w") as f:
json.dump(meta, f, indent=2)
return meta
def pin(self, content_hash: str, reason: str = "published") -> None:
"""Mark an item as pinned (non-deletable)."""
self._save_meta(content_hash, pinned=True, pin_reason=reason)
# ============ File Storage ============
def put(
@@ -396,9 +430,10 @@ class L1CacheManager:
Returns:
(can_delete, reason) tuple
"""
# Check if shared via L2
if self.l2_checker.is_shared(content_hash):
return False, "Item is published to L2"
# Check if pinned (published or input to published)
pinned, reason = self.is_pinned(content_hash)
if pinned:
return False, f"Item is pinned ({reason})"
# Find node_id for this content
node_id = self._content_index.get(content_hash, content_hash)
@@ -423,11 +458,13 @@ class L1CacheManager:
if not activity:
return False, "Activity not found"
# Check if any item is shared
# Check if any item is pinned
for node_id in activity.all_node_ids:
entry = self.cache.get_entry(node_id)
if entry and self.l2_checker.is_shared(entry.content_hash):
return False, f"Item {node_id} is published to L2"
if entry:
pinned, reason = self.is_pinned(entry.content_hash)
if pinned:
return False, f"Item {node_id} is pinned ({reason})"
return True, "OK"