From 142e9675b51e7c17abdc3af5064cb0d5a18d81b0 Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Wed, 17 Jun 2026 19:37:58 -0400 Subject: [PATCH 1/2] feat(library): allow admin delete of Daedalus-managed library via shared cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin/HTML library delete previously hard-blocked workspace-scoped (Daedalus-managed) libraries, leaving no way to clear an orphaned Library node — e.g. one left behind when a Daedalus workspace delete failed to propagate. A recreate of that workspace then collides on the global Library.name unique constraint and 500s, freezing ingest. Allow the delete behind the existing confirm warning (low risk: source content lives in Daedalus and is recreated + re-embedded on next sync), and route both the API and HTML delete paths through one shared cascade. - Add library/services/library_delete.delete_library_cascade(lib), keyed on Library uid so it covers global and workspace-scoped libraries. It removes Chunks, Images/ImageEmbeddings, Items, Collections, the Library, then GCs orphan-only Concepts (verbatim from the API view, re-keyed workspace_id->uid). - workspace_detail_or_delete (API) now calls the shared helper. - library_delete (HTML) no longer blocks workspace_id libraries; it calls the cascade instead of a bare lib.delete() (which leaked child nodes — also a latent bug for global libraries with content). - Confirm-delete template shows a caution banner for Daedalus-managed libraries. No migration: Mnemosyne library data is in Neo4j (neomodel); no schema change. Co-Authored-By: Claude Opus 4.8 --- mnemosyne/library/api/workspaces.py | 70 +----------- mnemosyne/library/services/library_delete.py | 108 ++++++++++++++++++ .../library/library_confirm_delete.html | 12 ++ mnemosyne/library/views.py | 24 ++-- 4 files changed, 138 insertions(+), 76 deletions(-) create mode 100644 mnemosyne/library/services/library_delete.py diff --git a/mnemosyne/library/api/workspaces.py b/mnemosyne/library/api/workspaces.py index 4715761..1d91d2b 100644 --- a/mnemosyne/library/api/workspaces.py +++ b/mnemosyne/library/api/workspaces.py @@ -23,6 +23,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from library.content_types import get_library_type_config +from library.services.library_delete import delete_library_cascade from .serializers import WorkspaceCreateSerializer, WorkspaceStatusSerializer @@ -165,74 +166,15 @@ def workspace_detail_or_delete(request, workspace_id): if lib is None: return Response(status=status.HTTP_204_NO_CONTENT) - library_uid = lib.uid - library_name = lib.name - - # Step 1-4: delete chunks, items, collections, then the library itself. - # We collect Item s3_keys first so the caller can clean up S3 - # asynchronously (a future enhancement — for now, the keys are logged). - s3_rows, _ = db.cypher_query( - "MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(:Collection)" - "-[:CONTAINS]->(i:Item) RETURN i.uid, i.s3_key", - {"wsid": workspace_id}, - ) - item_s3_keys = [(r[0], r[1]) for r in s3_rows if r[1]] - - db.cypher_query( - """ - MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(:Collection) - -[:CONTAINS]->(i:Item)-[:HAS_CHUNK]->(c:Chunk) - DETACH DELETE c - """, - {"wsid": workspace_id}, - ) - db.cypher_query( - """ - MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(:Collection) - -[:CONTAINS]->(i:Item)-[:HAS_IMAGE]->(img:Image) - OPTIONAL MATCH (img)-[:HAS_EMBEDDING]->(emb:ImageEmbedding) - DETACH DELETE img, emb - """, - {"wsid": workspace_id}, - ) - db.cypher_query( - """ - MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(:Collection) - -[:CONTAINS]->(i:Item) - DETACH DELETE i - """, - {"wsid": workspace_id}, - ) - db.cypher_query( - """ - MATCH (l:Library {workspace_id: $wsid})-[:CONTAINS]->(col:Collection) - DETACH DELETE col - """, - {"wsid": workspace_id}, - ) - db.cypher_query( - "MATCH (l:Library {workspace_id: $wsid}) DETACH DELETE l", - {"wsid": workspace_id}, - ) - - # Step 5: orphan Concept garbage collection. - orphan_result, _ = db.cypher_query( - """ - MATCH (con:Concept) - WHERE NOT (con)<-[:REFERENCES]-() AND NOT (con)<-[:MENTIONS]-() - AND NOT (con)<-[:DEPICTS]-() - WITH con - DETACH DELETE con - RETURN count(con) AS deleted - """ - ) - orphans_deleted = orphan_result[0][0] if orphan_result else 0 + # Delete the Library and everything reachable + unique to it, plus + # orphan-Concept GC. Shared with the admin/HTML delete path. + result = delete_library_cascade(lib) logger.info( "Workspace deleted workspace_id=%s library_uid=%s name=%s " "items=%d orphans_deleted=%d", - workspace_id, library_uid, library_name, - len(item_s3_keys), orphans_deleted, + workspace_id, result["library_uid"], result["name"], + result["item_count"], result["orphans_deleted"], ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/mnemosyne/library/services/library_delete.py b/mnemosyne/library/services/library_delete.py new file mode 100644 index 0000000..403f928 --- /dev/null +++ b/mnemosyne/library/services/library_delete.py @@ -0,0 +1,108 @@ +""" +Shared Library deletion cascade. + +Deletes a Library node and everything reachable AND unique to it +(Collections, Items, Chunks, Images + ImageEmbeddings), then garbage-collects +Concepts that are no longer referenced by any other Library. + +Keyed on the Library ``uid`` so it works for *both* global libraries +(``workspace_id`` is null) and workspace-scoped libraries. This is the single +source of truth used by: + + * the Daedalus integration API (``DELETE /library/api/workspaces/{id}/``), and + * the admin/HTML delete view (``library_delete``). + +Concept-safe: orphan-only Concept GC happens at the end. Concepts still +referenced by another library (workspace or global) are preserved. +""" + +import logging + +from neomodel import db + +logger = logging.getLogger(__name__) + + +def delete_library_cascade(lib) -> dict: + """Delete ``lib`` and all content reachable and unique to it. + + :param lib: A ``library.models.Library`` node instance. + :returns: Dict with ``library_uid``, ``name``, ``item_count``, + ``item_s3_keys`` (list of ``(uid, s3_key)`` for async S3 cleanup), + and ``orphans_deleted`` (Concept GC count). + """ + library_uid = lib.uid + library_name = lib.name + + # Collect Item s3_keys first so the caller can clean up S3 asynchronously + # (a future enhancement — for now, the keys are returned/logged). + s3_rows, _ = db.cypher_query( + "MATCH (l:Library {uid: $uid})-[:CONTAINS]->(:Collection)" + "-[:CONTAINS]->(i:Item) RETURN i.uid, i.s3_key", + {"uid": library_uid}, + ) + item_s3_keys = [(r[0], r[1]) for r in s3_rows if r[1]] + + db.cypher_query( + """ + MATCH (l:Library {uid: $uid})-[:CONTAINS]->(:Collection) + -[:CONTAINS]->(i:Item)-[:HAS_CHUNK]->(c:Chunk) + DETACH DELETE c + """, + {"uid": library_uid}, + ) + db.cypher_query( + """ + MATCH (l:Library {uid: $uid})-[:CONTAINS]->(:Collection) + -[:CONTAINS]->(i:Item)-[:HAS_IMAGE]->(img:Image) + OPTIONAL MATCH (img)-[:HAS_EMBEDDING]->(emb:ImageEmbedding) + DETACH DELETE img, emb + """, + {"uid": library_uid}, + ) + db.cypher_query( + """ + MATCH (l:Library {uid: $uid})-[:CONTAINS]->(:Collection) + -[:CONTAINS]->(i:Item) + DETACH DELETE i + """, + {"uid": library_uid}, + ) + db.cypher_query( + """ + MATCH (l:Library {uid: $uid})-[:CONTAINS]->(col:Collection) + DETACH DELETE col + """, + {"uid": library_uid}, + ) + db.cypher_query( + "MATCH (l:Library {uid: $uid}) DETACH DELETE l", + {"uid": library_uid}, + ) + + # Orphan Concept garbage collection: drop Concepts no longer referenced + # by any Item (REFERENCES/MENTIONS) or Image (DEPICTS). + orphan_result, _ = db.cypher_query( + """ + MATCH (con:Concept) + WHERE NOT (con)<-[:REFERENCES]-() AND NOT (con)<-[:MENTIONS]-() + AND NOT (con)<-[:DEPICTS]-() + WITH con + DETACH DELETE con + RETURN count(con) AS deleted + """ + ) + orphans_deleted = orphan_result[0][0] if orphan_result else 0 + + logger.info( + "Library cascade-deleted library_uid=%s name=%s items=%d orphans_deleted=%d", + library_uid, library_name, len(item_s3_keys), orphans_deleted, + ) + + return { + "library_uid": library_uid, + "name": library_name, + "item_count": len(item_s3_keys), + "item_s3_keys": item_s3_keys, + "orphans_deleted": orphans_deleted, + } diff --git a/mnemosyne/library/templates/library/library_confirm_delete.html b/mnemosyne/library/templates/library/library_confirm_delete.html index 5ff8ec9..8474ea9 100644 --- a/mnemosyne/library/templates/library/library_confirm_delete.html +++ b/mnemosyne/library/templates/library/library_confirm_delete.html @@ -12,6 +12,18 @@
Are you sure you want to delete {{ library.name }}? This action cannot be undone.
+ {% if library.workspace_id %} +
+ + This Library is managed by Daedalus + (workspace {{ library.workspace_id }}). + Deleting it here removes its embedded content from Mnemosyne, but the + source files still live in Daedalus — it will be recreated and + re-embedded on the next Daedalus sync. Use this to clear an + orphaned Library that is blocking workspace re-registration. + +
+ {% endif %}
{% csrf_token %}
diff --git a/mnemosyne/library/views.py b/mnemosyne/library/views.py index da7c9bf..a48032c 100644 --- a/mnemosyne/library/views.py +++ b/mnemosyne/library/views.py @@ -319,20 +319,20 @@ def library_delete(request, uid): messages.error(request, f"Library not found: {e}") return redirect("library:library-list") - # Daedalus owns the lifecycle of workspace-scoped libraries — they can - # only be deleted via DELETE /library/api/workspaces/{workspace_id}/. - # Block the human delete path so a stray click can't desync state. - if lib.workspace_id: - messages.error( - request, - f'"{lib.name}" is managed by Daedalus workspace ' - f"{lib.workspace_id}. Delete it from Daedalus, not here.", - ) - return redirect("library:library-detail", uid=uid) - + # Daedalus owns the lifecycle of workspace-scoped libraries. Deleting one + # here is allowed but discouraged: the confirm page warns that Daedalus + # still holds the source content and will recreate + re-embed it on the + # next sync. The risk is low (no data loss — only re-embedding cost), and + # this is the supported escape hatch for clearing an orphaned Library that + # blocks workspace re-registration. if request.method == "POST": name = lib.name - lib.delete() + # Use the shared cascade so child nodes (Collections/Items/Chunks/ + # Images) and orphan Concepts are removed too — a bare lib.delete() + # would leak them. + from .services.library_delete import delete_library_cascade + + delete_library_cascade(lib) messages.success(request, f'Library "{name}" deleted.') return redirect("library:library-list") return render(request, "library/library_confirm_delete.html", {"library": lib}) From dd06f923cd13fbdcf8c4acfd86987a9db2c52bd6 Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Wed, 17 Jun 2026 20:26:43 -0400 Subject: [PATCH 2/2] feat(workspaces): return 409 name_conflict instead of 500 on Library name clash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recreate of a workspace whose Mnemosyne Library was orphaned (left behind by a failed Daedalus delete-propagate) collides on the global Library.name unique constraint. neomodel raised UniqueProperty unguarded, so workspace_create 500'd and ingest then 404'd forever — the queue froze silently. Guard lib.save() and return a structured 409 with a machine code so Daedalus can classify the failure without string-matching: - name_conflict — the new name-collision case - owner_conflict, library_type_immutable — codes added to the two existing 409s Cypher-touching paths stay covered by the manual end-to-end plan, per the test module's stated convention. Co-Authored-By: Claude Opus 4.8 --- mnemosyne/library/api/workspaces.py | 33 ++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/mnemosyne/library/api/workspaces.py b/mnemosyne/library/api/workspaces.py index 1d91d2b..c7ee4c5 100644 --- a/mnemosyne/library/api/workspaces.py +++ b/mnemosyne/library/api/workspaces.py @@ -17,6 +17,7 @@ across users. import logging from neomodel import db +from neomodel.exceptions import UniqueProperty from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated @@ -85,7 +86,10 @@ def workspace_create(request): data["workspace_id"], request.user.username, ) return Response( - {"detail": "Workspace id is already in use."}, + { + "detail": "Workspace id is already in use.", + "code": "owner_conflict", + }, status=status.HTTP_409_CONFLICT, ) if existing.library_type != data["library_type"]: @@ -95,7 +99,8 @@ def workspace_create(request): "library_type is immutable for an existing workspace " f"(have '{existing.library_type}', " f"got '{data['library_type']}')." - ) + ), + "code": "library_type_immutable", }, status=status.HTTP_409_CONFLICT, ) @@ -120,7 +125,29 @@ def workspace_create(request): reranker_instruction=defaults["reranker_instruction"], llm_context_prompt=defaults["llm_context_prompt"], ) - lib.save() + try: + lib.save() + except UniqueProperty: + # Library.name is globally unique. A name collision here almost always + # means an orphaned Library survived a failed Daedalus workspace delete + # (the old node kept the name), and the recreate under a new + # workspace_id now clashes. Surface a clean 409 instead of a 500 so + # Daedalus can record + report it; the operator clears the orphan + # (admin delete) or renames the workspace. + logger.warning( + "workspace_create name_conflict workspace_id=%s name=%s", + data["workspace_id"], data["name"], + ) + return Response( + { + "detail": ( + f"A library named '{data['name']}' already exists in " + "Mnemosyne." + ), + "code": "name_conflict", + }, + status=status.HTTP_409_CONFLICT, + ) logger.info( "Workspace created workspace_id=%s library_uid=%s library_type=%s", data["workspace_id"], lib.uid, lib.library_type,