personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

defer-delete: 10s cancel window on segment + journal-entity deletes

Replace the synchronous DELETE paths for transcript segments and journal
entities with a defer-then-commit flow backed by a process-local
threading.Timer registry (`think/deferred_deletes.py`). DELETE returns a
`pending_id` + commit time; a new Cancel endpoint pops the timer before
it fires. Validation still runs synchronously — principal guards, path
checks, containment checks all land their errors before scheduling.
Process restart drops pending timers; audit log retains the orphan
`phase: "pending"` row as an intentional fail-safe. Extended the
notification framework with an `actionButton` slot and wired Cancel into
both workspaces; notification dismiss is distinct from deferred-delete
cancel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+871 -101
+68 -9
apps/entities/routes.py
··· 7 7 8 8 import logging 9 9 import os 10 + import re 11 + import time 12 + import uuid 13 + from datetime import datetime 10 14 from pathlib import Path 11 15 from typing import Any 12 16 ··· 14 18 15 19 logger = logging.getLogger(__name__) 16 20 21 + import think.deferred_deletes as deferred_deletes 17 22 from apps.utils import log_app_action 18 23 from convey import state 19 24 from think.entities import ( 20 25 block_journal_entity, 21 - delete_journal_entity, 22 26 entity_last_active_ts, 23 27 entity_memory_path, 24 28 entity_slug, ··· 26 30 load_detected_entities_recent, 27 31 load_entities, 28 32 load_facet_relationship, 29 - load_journal_entity, 30 33 load_observations, 31 34 rename_entity_memory, 32 35 save_entities, ··· 35 38 unblock_journal_entity, 36 39 validate_aka_uniqueness, 37 40 ) 41 + from think.entities.journal import delete_journal_entity, load_journal_entity 38 42 from think.facets import get_facets 39 43 from think.utils import now_ms 40 44 ··· 43 47 __name__, 44 48 url_prefix="/app/entities", 45 49 ) 50 + ENTITY_DELETE_TTL = 10.0 46 51 47 52 48 53 def _get_entity_metadata(facet_name: str, entity_name: str) -> dict: ··· 905 910 def delete_journal_entity_route(entity_id: str) -> Any: 906 911 """Permanently delete a journal entity and all facet relationships.""" 907 912 try: 908 - result = delete_journal_entity(entity_id) 913 + journal_entity = load_journal_entity(entity_id) 914 + if journal_entity is None: 915 + return jsonify({"error": f"Entity '{entity_id}' not found"}), 400 916 + 917 + if journal_entity.get("is_principal"): 918 + return jsonify({"error": "Cannot delete the principal (self) entity"}), 400 919 + 920 + ttl = ENTITY_DELETE_TTL 921 + pending_id = uuid.uuid4().hex 909 922 923 + def _commit() -> None: 924 + try: 925 + result = delete_journal_entity(entity_id) 926 + facets = result.get("facets_deleted", []) 927 + except Exception: 928 + facets = [] 929 + logger.exception( 930 + "deferred journal_entity_delete failed for %s", entity_id 931 + ) 932 + log_app_action( 933 + app="entities", 934 + facet=None, 935 + action="journal_entity_delete", 936 + params={ 937 + "entity_id": entity_id, 938 + "facets_deleted": facets, 939 + "pending_id": pending_id, 940 + "phase": "committed", 941 + }, 942 + ) 943 + 944 + deferred_deletes.schedule_with_id(pending_id, _commit, ttl_seconds=ttl) 910 945 log_app_action( 911 946 app="entities", 912 - facet=None, # Journal-level action 947 + facet=None, 913 948 action="journal_entity_delete", 914 949 params={ 915 950 "entity_id": entity_id, 916 - "facets_deleted": result.get("facets_deleted", []), 951 + "pending_id": pending_id, 952 + "phase": "pending", 917 953 }, 918 954 ) 919 - 920 - return jsonify(result) 955 + return jsonify( 956 + { 957 + "success": True, 958 + "pending": pending_id, 959 + "commit_at_ms": int((time.time() + ttl) * 1000), 960 + "ttl_seconds": ttl, 961 + } 962 + ) 921 963 922 - except ValueError as e: 923 - return jsonify({"error": str(e)}), 400 924 964 except Exception as e: 925 965 logger.exception("Failed to delete journal entity") 926 966 return jsonify({"error": f"Failed to delete journal entity: {str(e)}"}), 500 967 + 968 + 969 + @entities_bp.route("/api/cancel-delete/<pending_id>", methods=["POST"]) 970 + def cancel_delete_journal_entity(pending_id: str) -> Any: 971 + """Cancel a pending deferred journal-entity deletion.""" 972 + if not re.fullmatch(r"[0-9a-f]{32}", pending_id): 973 + return jsonify({"error": "already committed or unknown"}), 410 974 + 975 + if not deferred_deletes.cancel(pending_id): 976 + return jsonify({"error": "already committed or unknown"}), 410 977 + 978 + log_app_action( 979 + app="entities", 980 + facet=None, 981 + action="journal_entity_delete", 982 + params={"pending_id": pending_id, "phase": "cancelled"}, 983 + day=datetime.now().strftime("%Y%m%d"), 984 + ) 985 + return jsonify({"cancelled": pending_id}) 927 986 928 987 929 988 @entities_bp.route("/api/journal/entity/<entity_id>/block", methods=["POST"])
+139
apps/entities/tests/test_delete_journal_entity.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import json 7 + import re 8 + import time 9 + from datetime import datetime 10 + 11 + from think.entities.journal import save_journal_entity 12 + 13 + 14 + def _action_log_rows(journal_root, day): 15 + log_path = journal_root / "config" / "actions" / f"{day}.jsonl" 16 + if not log_path.exists(): 17 + return [] 18 + return [ 19 + json.loads(line) 20 + for line in log_path.read_text(encoding="utf-8").splitlines() 21 + if line.strip() 22 + ] 23 + 24 + 25 + def _create_journal_entity(entity_id, *, is_principal=False): 26 + save_journal_entity( 27 + { 28 + "id": entity_id, 29 + "name": entity_id.title(), 30 + "type": "Person", 31 + "is_principal": is_principal, 32 + } 33 + ) 34 + 35 + 36 + def test_delete_journal_entity_route_rejects_principal( 37 + client, journal_copy, monkeypatch 38 + ): 39 + entity_id = "principal-delete-test" 40 + today = datetime.now().strftime("%Y%m%d") 41 + _create_journal_entity(entity_id, is_principal=True) 42 + 43 + response = client.delete(f"/app/entities/api/journal/entity/{entity_id}") 44 + 45 + assert response.status_code == 400 46 + assert response.get_json() == {"error": "Cannot delete the principal (self) entity"} 47 + assert (journal_copy / "entities" / entity_id).exists() 48 + rows = _action_log_rows(journal_copy, today) 49 + assert not any( 50 + row["action"] == "journal_entity_delete" 51 + and row["params"].get("entity_id") == entity_id 52 + for row in rows 53 + ) 54 + 55 + 56 + def test_delete_journal_entity_route_rejects_missing_entity(client): 57 + response = client.delete("/app/entities/api/journal/entity/missing-entity") 58 + 59 + assert response.status_code == 400 60 + assert response.get_json() == {"error": "Entity 'missing-entity' not found"} 61 + 62 + 63 + def test_delete_journal_entity_route_returns_pending_response_shape( 64 + client, journal_copy, monkeypatch 65 + ): 66 + entity_id = "pending-delete-test" 67 + today = datetime.now().strftime("%Y%m%d") 68 + _create_journal_entity(entity_id) 69 + monkeypatch.setattr("apps.entities.routes.ENTITY_DELETE_TTL", 0.05) 70 + before_ms = int(time.time() * 1000) 71 + 72 + response = client.delete(f"/app/entities/api/journal/entity/{entity_id}") 73 + 74 + assert response.status_code == 200 75 + data = response.get_json() 76 + assert data["success"] is True 77 + assert re.fullmatch(r"[0-9a-f]{32}", data["pending"]) 78 + assert data["ttl_seconds"] == 0.05 79 + assert data["commit_at_ms"] >= before_ms 80 + assert (journal_copy / "entities" / entity_id).exists() 81 + rows = _action_log_rows(journal_copy, today) 82 + assert any( 83 + row["action"] == "journal_entity_delete" 84 + and row["params"].get("entity_id") == entity_id 85 + and row["params"].get("phase") == "pending" 86 + for row in rows 87 + ) 88 + time.sleep(0.2) 89 + 90 + 91 + def test_cancel_delete_journal_entity_within_window_keeps_entity( 92 + client, journal_copy, monkeypatch 93 + ): 94 + entity_id = "cancel-delete-test" 95 + _create_journal_entity(entity_id) 96 + monkeypatch.setattr("apps.entities.routes.ENTITY_DELETE_TTL", 0.2) 97 + 98 + delete_response = client.delete(f"/app/entities/api/journal/entity/{entity_id}") 99 + pending_id = delete_response.get_json()["pending"] 100 + 101 + cancel_response = client.post(f"/app/entities/api/cancel-delete/{pending_id}") 102 + 103 + assert cancel_response.status_code == 200 104 + assert cancel_response.get_json() == {"cancelled": pending_id} 105 + time.sleep(0.3) 106 + assert (journal_copy / "entities" / entity_id).exists() 107 + 108 + 109 + def test_cancel_delete_journal_entity_too_late_after_commit( 110 + client, journal_copy, monkeypatch 111 + ): 112 + entity_id = "late-delete-test" 113 + today = datetime.now().strftime("%Y%m%d") 114 + _create_journal_entity(entity_id) 115 + monkeypatch.setattr("apps.entities.routes.ENTITY_DELETE_TTL", 0.05) 116 + 117 + delete_response = client.delete(f"/app/entities/api/journal/entity/{entity_id}") 118 + pending_id = delete_response.get_json()["pending"] 119 + 120 + time.sleep(0.2) 121 + cancel_response = client.post(f"/app/entities/api/cancel-delete/{pending_id}") 122 + 123 + assert cancel_response.status_code == 410 124 + assert cancel_response.get_json() == {"error": "already committed or unknown"} 125 + assert not (journal_copy / "entities" / entity_id).exists() 126 + rows = _action_log_rows(journal_copy, today) 127 + assert any( 128 + row["action"] == "journal_entity_delete" 129 + and row["params"].get("entity_id") == entity_id 130 + and row["params"].get("phase") == "committed" 131 + for row in rows 132 + ) 133 + 134 + 135 + def test_cancel_delete_journal_entity_unknown_pending_id_returns_410(client): 136 + response = client.post(f"/app/entities/api/cancel-delete/{'b' * 32}") 137 + 138 + assert response.status_code == 410 139 + assert response.get_json() == {"error": "already committed or unknown"}
+67 -5
apps/entities/workspace.html
··· 2097 2097 2098 2098 function closeDeleteJournalEntityModal() { 2099 2099 hideInlineError('delete-modal-error'); 2100 + const btn = document.getElementById('confirmDeleteJournalEntityBtn'); 2101 + btn.disabled = false; 2102 + btn.textContent = 'delete permanently'; 2100 2103 document.getElementById('deleteJournalEntityModal').style.display = 'none'; 2101 2104 } 2102 2105 ··· 2117 2120 throw new Error(data.error); 2118 2121 } 2119 2122 hideInlineError('delete-modal-error'); 2123 + journalEntitiesData = (journalEntitiesData || []).filter(entity => entity.id !== entityId); 2120 2124 closeDeleteJournalEntityModal(); 2121 2125 navigateToList(); 2122 - loadEntities(); 2126 + renderJournalEntities(document.getElementById('journal-entity-card-search')?.value.trim() || ''); 2127 + 2128 + let pendingNotificationId = null; 2129 + pendingNotificationId = window.AppServices.notifications.show({ 2130 + app: 'entities', 2131 + icon: '🗑️', 2132 + title: 'Deleting entity…', 2133 + message: 'Cancels in 10s', 2134 + dismissible: true, 2135 + autoDismiss: 10000, 2136 + buttons: [{ 2137 + label: 'Cancel', 2138 + dismiss: false, 2139 + onClick: () => cancelJournalEntityDelete(data.pending, pendingNotificationId) 2140 + }] 2141 + }); 2123 2142 }) 2124 2143 .catch(error => { 2125 2144 console.error('Error deleting entity:', error); ··· 2127 2146 btn.disabled = false; 2128 2147 btn.textContent = 'delete permanently'; 2129 2148 }); 2149 + } 2150 + 2151 + async function cancelJournalEntityDelete(pendingId, notificationId) { 2152 + try { 2153 + const response = await fetch(`api/cancel-delete/${pendingId}`, { 2154 + method: 'POST' 2155 + }); 2156 + const data = await response.json().catch(() => ({})); 2157 + 2158 + if (notificationId !== null) { 2159 + window.AppServices.notifications.dismiss(notificationId); 2160 + } 2161 + 2162 + if (response.ok) { 2163 + await loadEntities(); 2164 + window.AppServices.notifications.show({ 2165 + app: 'entities', 2166 + icon: '↩️', 2167 + title: 'Delete cancelled', 2168 + autoDismiss: 3000 2169 + }); 2170 + return; 2171 + } 2172 + 2173 + if (response.status === 410) { 2174 + window.AppServices.notifications.show({ 2175 + app: 'entities', 2176 + icon: '⏱️', 2177 + title: 'Too late — already deleted', 2178 + autoDismiss: 3000 2179 + }); 2180 + return; 2181 + } 2182 + 2183 + throw new Error(data.error || 'Failed to cancel delete'); 2184 + } catch (error) { 2185 + window.AppServices.notifications.show({ 2186 + app: 'entities', 2187 + icon: '⚠️', 2188 + title: 'Couldn\'t cancel delete', 2189 + message: error.message || 'Please try again.', 2190 + autoDismiss: 3000 2191 + }); 2192 + } 2130 2193 } 2131 2194 2132 2195 function renderDetailView(entity, observations) { ··· 2577 2640 2578 2641 if (!currentFacet) { 2579 2642 // All-facet mode: load journal entities 2580 - loadJournalEntities(); 2581 - return; 2643 + return loadJournalEntities(); 2582 2644 } 2583 2645 2584 2646 // Facet-specific mode 2585 - fetch(`api/${encodeURIComponent(currentFacet)}`) 2647 + return fetch(`api/${encodeURIComponent(currentFacet)}`) 2586 2648 .then(response => response.json()) 2587 2649 .then(data => { 2588 2650 if (data.error) { ··· 2614 2676 } 2615 2677 2616 2678 function loadJournalEntities() { 2617 - fetch('api/journal') 2679 + return fetch('api/journal') 2618 2680 .then(response => response.json()) 2619 2681 .then(data => { 2620 2682 if (data.error) {
+65 -17
apps/transcripts/routes.py
··· 11 11 import os 12 12 import re 13 13 import shutil 14 - from datetime import date 14 + import time 15 + import uuid 16 + from datetime import date, datetime 15 17 from glob import glob 16 18 from pathlib import Path 17 19 from typing import Any ··· 25 27 url_for, 26 28 ) 27 29 30 + import think.deferred_deletes as deferred_deletes 28 31 from apps.utils import log_app_action 29 32 from convey import emit, state 30 33 from convey.utils import DATE_RE, error_response, format_date, success_response ··· 42 45 43 46 # Regex for YYYYMM month format validation 44 47 MONTH_RE = re.compile(r"\d{6}") 48 + SEGMENT_DELETE_TTL = 10.0 45 49 46 50 transcripts_bp = Blueprint( 47 51 "app:transcripts", ··· 524 528 return error_response("Invalid segment path", 403) 525 529 526 530 try: 527 - # Remove the entire segment directory 528 - shutil.rmtree(segment_dir) 531 + ttl = SEGMENT_DELETE_TTL 532 + pending_id = uuid.uuid4().hex 533 + search_index_warning = not is_supervisor_up() 529 534 530 - # Log the deletion for audit trail 535 + def _commit() -> None: 536 + shutil.rmtree(segment_dir) 537 + emit( 538 + "supervisor", 539 + "request", 540 + cmd=["sol", "indexer", "--rescan-full"], 541 + ) 542 + log_app_action( 543 + app="transcripts", 544 + facet=None, 545 + action="segment_delete", 546 + params={ 547 + "day": day, 548 + "segment_key": segment_key, 549 + "stream": stream, 550 + "pending_id": pending_id, 551 + "phase": "committed", 552 + }, 553 + day=day, 554 + ) 555 + 556 + deferred_deletes.schedule_with_id(pending_id, _commit, ttl_seconds=ttl) 531 557 log_app_action( 532 558 app="transcripts", 533 - facet=None, # Transcripts are not facet-scoped 559 + facet=None, 534 560 action="segment_delete", 535 - params={"day": day, "segment_key": segment_key}, 561 + params={ 562 + "day": day, 563 + "segment_key": segment_key, 564 + "stream": stream, 565 + "pending_id": pending_id, 566 + "phase": "pending", 567 + }, 536 568 day=day, 537 569 ) 538 570 539 - payload = {"deleted": segment_key} 540 - if not is_supervisor_up(): 571 + payload = { 572 + "deleted": segment_key, 573 + "pending": pending_id, 574 + "commit_at_ms": int((time.time() + ttl) * 1000), 575 + "ttl_seconds": ttl, 576 + } 577 + if search_index_warning: 541 578 payload["search_index_warning"] = True 542 579 543 - # Trigger indexer rescan to remove deleted segment from search index 544 - # Supervisor queues by command name, serializing concurrent indexer requests 545 - emit( 546 - "supervisor", 547 - "request", 548 - cmd=["sol", "indexer", "--rescan-full"], 549 - ) 550 - 551 580 return success_response(payload) 552 581 553 - except OSError as e: 582 + except Exception as e: 554 583 return error_response(f"Failed to delete segment: {e}", 500) 584 + 585 + 586 + @transcripts_bp.route("/api/cancel-delete/<pending_id>", methods=["POST"]) 587 + def cancel_delete_segment(pending_id: str) -> Any: 588 + """Cancel a pending deferred segment deletion.""" 589 + if not re.fullmatch(r"[0-9a-f]{32}", pending_id): 590 + return error_response("already committed or unknown", 410) 591 + 592 + if not deferred_deletes.cancel(pending_id): 593 + return error_response("already committed or unknown", 410) 594 + 595 + log_app_action( 596 + app="transcripts", 597 + facet=None, 598 + action="segment_delete", 599 + params={"pending_id": pending_id, "phase": "cancelled"}, 600 + day=datetime.now().strftime("%Y%m%d"), 601 + ) 602 + return jsonify({"cancelled": pending_id})
+156 -8
apps/transcripts/tests/test_segment_routes.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 + import json 5 + import re 6 + import time 7 + from datetime import datetime 8 + 4 9 import pytest 5 10 6 11 FIXTURE_DAY = "20260304" 7 12 FIXTURE_STREAM = "default" 8 13 FIXTURE_SEGMENT = "090000_300" 14 + 15 + 16 + def _action_log_rows(journal_root, day): 17 + log_path = journal_root / "config" / "actions" / f"{day}.jsonl" 18 + if not log_path.exists(): 19 + return [] 20 + return [ 21 + json.loads(line) 22 + for line in log_path.read_text(encoding="utf-8").splitlines() 23 + if line.strip() 24 + ] 9 25 10 26 11 27 @pytest.mark.parametrize("stream", ["-bad", "Upper", "..bad"]) ··· 68 84 client, journal_copy, monkeypatch 69 85 ): 70 86 monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) 87 + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) 71 88 segment_dir = ( 72 89 journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT 73 90 ) ··· 77 94 ) 78 95 79 96 assert response.status_code == 200 80 - assert response.get_json() == {"success": True, "deleted": FIXTURE_SEGMENT} 97 + assert response.get_json()["deleted"] == FIXTURE_SEGMENT 98 + time.sleep(0.2) 81 99 assert not segment_dir.exists() 82 100 83 101 84 - def test_delete_segment_includes_search_index_warning_when_supervisor_is_down(client): 102 + def test_delete_segment_includes_search_index_warning_when_supervisor_is_down( 103 + client, monkeypatch 104 + ): 105 + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) 85 106 response = client.delete( 86 107 f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 87 108 ) 88 109 89 110 assert response.status_code == 200 90 - assert response.get_json() == { 91 - "success": True, 92 - "deleted": FIXTURE_SEGMENT, 93 - "search_index_warning": True, 94 - } 111 + data = response.get_json() 112 + assert data["success"] is True 113 + assert data["deleted"] == FIXTURE_SEGMENT 114 + assert data["search_index_warning"] is True 115 + time.sleep(0.2) 95 116 96 117 97 118 def test_delete_segment_omits_search_index_warning_when_supervisor_is_up( 98 119 client, monkeypatch 99 120 ): 100 121 monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) 122 + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) 101 123 102 124 response = client.delete( 103 125 f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 104 126 ) 105 127 106 128 assert response.status_code == 200 107 - assert response.get_json() == {"success": True, "deleted": FIXTURE_SEGMENT} 129 + assert response.get_json()["deleted"] == FIXTURE_SEGMENT 130 + time.sleep(0.2) 131 + 132 + 133 + def test_delete_segment_returns_pending_response_shape(client, monkeypatch): 134 + monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) 135 + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) 136 + before_ms = int(time.time() * 1000) 137 + 138 + response = client.delete( 139 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 140 + ) 141 + 142 + assert response.status_code == 200 143 + data = response.get_json() 144 + assert data["success"] is True 145 + assert data["deleted"] == FIXTURE_SEGMENT 146 + assert re.fullmatch(r"[0-9a-f]{32}", data["pending"]) 147 + assert data["ttl_seconds"] == 0.05 148 + assert data["commit_at_ms"] >= before_ms 149 + time.sleep(0.2) 150 + 151 + 152 + def test_cancel_delete_segment_within_window_keeps_directory( 153 + client, journal_copy, monkeypatch 154 + ): 155 + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.2) 156 + segment_dir = ( 157 + journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT 158 + ) 159 + 160 + delete_response = client.delete( 161 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 162 + ) 163 + pending_id = delete_response.get_json()["pending"] 164 + 165 + cancel_response = client.post(f"/app/transcripts/api/cancel-delete/{pending_id}") 166 + 167 + assert cancel_response.status_code == 200 168 + assert cancel_response.get_json() == {"cancelled": pending_id} 169 + time.sleep(0.3) 170 + assert segment_dir.exists() 171 + 172 + 173 + def test_cancel_delete_segment_too_late_after_commit(client, journal_copy, monkeypatch): 174 + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) 175 + segment_dir = ( 176 + journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT 177 + ) 178 + 179 + delete_response = client.delete( 180 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 181 + ) 182 + pending_id = delete_response.get_json()["pending"] 183 + 184 + time.sleep(0.2) 185 + cancel_response = client.post(f"/app/transcripts/api/cancel-delete/{pending_id}") 186 + 187 + assert cancel_response.status_code == 410 188 + assert cancel_response.get_json() == {"error": "already committed or unknown"} 189 + assert not segment_dir.exists() 190 + 191 + 192 + def test_cancel_delete_segment_unknown_pending_id_returns_410(client): 193 + response = client.post(f"/app/transcripts/api/cancel-delete/{'a' * 32}") 194 + 195 + assert response.status_code == 410 196 + assert response.get_json() == {"error": "already committed or unknown"} 197 + 198 + 199 + def test_cancel_delete_segment_malformed_pending_id_returns_410(client): 200 + response = client.post("/app/transcripts/api/cancel-delete/not-hex") 201 + 202 + assert response.status_code == 410 203 + assert response.get_json() == {"error": "already committed or unknown"} 204 + 205 + 206 + def test_delete_segment_writes_pending_and_committed_audit_rows( 207 + client, journal_copy, monkeypatch 208 + ): 209 + monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) 210 + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.05) 211 + 212 + delete_response = client.delete( 213 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 214 + ) 215 + pending_id = delete_response.get_json()["pending"] 216 + 217 + day_rows = _action_log_rows(journal_copy, FIXTURE_DAY) 218 + assert any( 219 + row["action"] == "segment_delete" 220 + and row["params"].get("pending_id") == pending_id 221 + and row["params"].get("phase") == "pending" 222 + for row in day_rows 223 + ) 224 + 225 + time.sleep(0.2) 226 + day_rows = _action_log_rows(journal_copy, FIXTURE_DAY) 227 + assert any( 228 + row["action"] == "segment_delete" 229 + and row["params"].get("pending_id") == pending_id 230 + and row["params"].get("phase") == "committed" 231 + for row in day_rows 232 + ) 233 + 234 + 235 + def test_cancel_delete_segment_writes_cancelled_audit_row( 236 + client, journal_copy, monkeypatch 237 + ): 238 + monkeypatch.setattr("apps.transcripts.routes.SEGMENT_DELETE_TTL", 0.2) 239 + cancel_response = client.delete( 240 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 241 + ) 242 + cancel_pending_id = cancel_response.get_json()["pending"] 243 + cancel_result = client.post( 244 + f"/app/transcripts/api/cancel-delete/{cancel_pending_id}" 245 + ) 246 + 247 + assert cancel_result.status_code == 200 248 + cancel_day = datetime.now().strftime("%Y%m%d") 249 + cancel_rows = _action_log_rows(journal_copy, cancel_day) 250 + assert any( 251 + row["action"] == "segment_delete" 252 + and row["params"].get("pending_id") == cancel_pending_id 253 + and row["params"].get("phase") == "cancelled" 254 + for row in cancel_rows 255 + )
+89 -19
apps/transcripts/workspace.html
··· 3324 3324 3325 3325 deleteSegmentModalBody.innerHTML = ` 3326 3326 <p class="tr-delete-segment-meta"><strong>${escapeHtml(seg.stream)}</strong> · <strong>${escapeHtml(day)}</strong> · <strong>${escapeHtml(seg.start)} - ${escapeHtml(seg.end)}</strong></p> 3327 - <p>You'll have ~30 seconds to undo this after confirming.</p> 3327 + <p>You'll have about 10 seconds to undo this after confirming.</p> 3328 3328 <p>This removes:</p> 3329 3329 <ul class="tr-delete-segment-list"> 3330 3330 <li>audio</li> ··· 3363 3363 await performDeleteSegment(seg); 3364 3364 } 3365 3365 3366 + function sortSegmentsByKey() { 3367 + allSegments.sort((a, b) => a.key.localeCompare(b.key)); 3368 + } 3369 + 3370 + function refreshSegmentIndicators() { 3371 + return fetch(`/app/transcripts/api/ranges/${day}`) 3372 + .then(r => r.ok ? r.json() : Promise.reject('Failed to fetch ranges')) 3373 + .then(data => { 3374 + segmentsLane.innerHTML = ''; 3375 + (data.audio || []).forEach(rg => { 3376 + const [s, e] = rg.map(parseTime); 3377 + addSegmentIndicator('audio', s, e, 0); 3378 + }); 3379 + (data.screen || []).forEach(rg => { 3380 + const [s, e] = rg.map(parseTime); 3381 + addSegmentIndicator('screen', s, e, 1); 3382 + }); 3383 + }) 3384 + .catch(() => { 3385 + // Range indicators may be stale, but the main segment state is already updated. 3386 + }); 3387 + } 3388 + 3366 3389 async function performDeleteSegment(seg) { 3367 3390 try { 3368 3391 const response = await fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`, { ··· 3385 3408 // Re-render zoom timeline 3386 3409 buildZoomSegments(); 3387 3410 3388 - // Refresh range indicators on left timeline 3389 - fetch(`/app/transcripts/api/ranges/${day}`) 3390 - .then(r => r.ok ? r.json() : Promise.reject('Failed to fetch ranges')) 3391 - .then(data => { 3392 - // Clear and rebuild segment indicators 3393 - segmentsLane.innerHTML = ''; 3394 - (data.audio || []).forEach(rg => { 3395 - const [s, e] = rg.map(parseTime); 3396 - addSegmentIndicator('audio', s, e, 0); 3397 - }); 3398 - (data.screen || []).forEach(rg => { 3399 - const [s, e] = rg.map(parseTime); 3400 - addSegmentIndicator('screen', s, e, 1); 3401 - }); 3402 - }) 3403 - .catch(() => { 3404 - // Range indicators may be stale, but segment is deleted 3405 - }); 3411 + refreshSegmentIndicators(); 3412 + 3413 + let pendingNotificationId = null; 3414 + pendingNotificationId = window.AppServices.notifications.show({ 3415 + app: 'transcripts', 3416 + icon: '🗑️', 3417 + title: 'Deleting segment…', 3418 + message: 'Cancels in 10s', 3419 + dismissible: true, 3420 + autoDismiss: 10000, 3421 + buttons: [{ 3422 + label: 'Cancel', 3423 + dismiss: false, 3424 + onClick: () => cancelSegmentDelete(data.pending, seg, pendingNotificationId) 3425 + }] 3426 + }); 3406 3427 3407 3428 if (data.search_index_warning === true) { 3408 3429 window.AppServices.notifications.show({ ··· 3427 3448 notice.style.opacity = '0'; 3428 3449 setTimeout(() => notice.remove(), 300); 3429 3450 }, 5000); 3451 + } 3452 + } 3453 + 3454 + async function cancelSegmentDelete(pendingId, seg, notificationId) { 3455 + try { 3456 + const response = await fetch(`/app/transcripts/api/cancel-delete/${pendingId}`, { 3457 + method: 'POST' 3458 + }); 3459 + const data = await response.json().catch(() => ({})); 3460 + 3461 + if (notificationId !== null) { 3462 + window.AppServices.notifications.dismiss(notificationId); 3463 + } 3464 + 3465 + if (response.ok) { 3466 + if (!allSegments.some(existing => existing.key === seg.key)) { 3467 + allSegments.push(seg); 3468 + sortSegmentsByKey(); 3469 + } 3470 + buildZoomSegments(); 3471 + refreshSegmentIndicators(); 3472 + window.AppServices.notifications.show({ 3473 + app: 'transcripts', 3474 + icon: '↩️', 3475 + title: 'Delete cancelled', 3476 + autoDismiss: 3000 3477 + }); 3478 + return; 3479 + } 3480 + 3481 + if (response.status === 410) { 3482 + window.AppServices.notifications.show({ 3483 + app: 'transcripts', 3484 + icon: '⏱️', 3485 + title: 'Too late — already deleted', 3486 + autoDismiss: 3000 3487 + }); 3488 + return; 3489 + } 3490 + 3491 + throw new Error(data.error || 'Failed to cancel delete'); 3492 + } catch (err) { 3493 + window.AppServices.notifications.show({ 3494 + app: 'transcripts', 3495 + icon: '⚠️', 3496 + title: 'Couldn\'t cancel delete', 3497 + message: err.message || 'Please try again.', 3498 + autoDismiss: 3000 3499 + }); 3430 3500 } 3431 3501 } 3432 3502
+28
convey/static/app.css
··· 1947 1947 margin-top: 8px; 1948 1948 padding-top: 8px; 1949 1949 border-top: 1px solid #e5e7eb; 1950 + display: flex; 1951 + align-items: center; 1952 + gap: 8px; 1950 1953 } 1951 1954 1952 1955 .notification-time { 1953 1956 font-size: 11px; 1954 1957 color: #9ca3af; 1958 + } 1959 + 1960 + .notification-actions { 1961 + display: flex; 1962 + gap: 8px; 1963 + margin-left: auto; 1964 + } 1965 + 1966 + .notification-action { 1967 + padding: 4px 10px; 1968 + border: 1px solid #d1d5db; 1969 + border-radius: 999px; 1970 + background: #f9fafb; 1971 + color: #374151; 1972 + cursor: pointer; 1973 + font-size: 12px; 1974 + font-weight: 600; 1975 + } 1976 + 1977 + .notification-action:hover { 1978 + background: #f3f4f6; 1979 + } 1980 + 1981 + .notification-action:active { 1982 + background: #e5e7eb; 1955 1983 } 1956 1984 1957 1985 /* Responsive */
+97 -43
convey/static/app.js
··· 1540 1540 _container: null, 1541 1541 _dismissTimers: {}, 1542 1542 1543 - /** 1544 - * Show a persistent notification card 1545 - * @param {object} options - {app, icon, title, message, action, facet, dismissible, badge, autoDismiss} 1546 - * @returns {number} Notification ID 1547 - */ 1548 - show(options) { 1549 - const notif = { 1550 - id: this._nextId++, 1551 - app: options.app || 'system', 1552 - icon: options.icon || '📬', 1553 - title: options.title || 'Notification', 1543 + /** 1544 + * Show a persistent notification card 1545 + * @param {object} options - {app, icon, title, message, action, facet, dismissible, badge, autoDismiss, buttons} 1546 + * @returns {number} Notification ID 1547 + */ 1548 + show(options) { 1549 + const buttons = Array.isArray(options.buttons) 1550 + ? options.buttons 1551 + .filter(button => button && button.label) 1552 + .map(button => ({ 1553 + label: String(button.label), 1554 + onClick: typeof button.onClick === 'function' ? button.onClick : null, 1555 + dismiss: button.dismiss !== false 1556 + })) 1557 + : []; 1558 + const notif = { 1559 + id: this._nextId++, 1560 + app: options.app || 'system', 1561 + icon: options.icon || '📬', 1562 + title: options.title || 'Notification', 1554 1563 message: options.message || '', 1555 1564 action: options.action || null, 1556 - facet: options.facet || null, 1557 - dismissible: options.dismissible !== false, 1558 - badge: options.badge || null, 1559 - timestamp: Date.now(), 1560 - autoDismiss: options.autoDismiss || null 1561 - }; 1565 + facet: options.facet || null, 1566 + dismissible: options.dismissible !== false, 1567 + badge: options.badge || null, 1568 + timestamp: Date.now(), 1569 + autoDismiss: options.autoDismiss || null, 1570 + buttons 1571 + }; 1562 1572 1563 1573 this._stack.push(notif); 1564 1574 this._addToHistory(notif); ··· 1772 1782 * Attach click handler to notification card 1773 1783 * @private 1774 1784 */ 1775 - _attachClickHandler(card, n) { 1776 - if (!n.action) return; 1785 + _attachClickHandler(card, n) { 1786 + if (!n.action) return; 1777 1787 1778 - card.onclick = (e) => { 1779 - // Ignore clicks on close button 1780 - if (e.target.closest('.notification-close')) { 1781 - return; 1782 - } 1788 + card.onclick = (e) => { 1789 + // Ignore clicks on controls inside the card 1790 + if (e.target.closest('.notification-close, .notification-action')) { 1791 + return; 1792 + } 1783 1793 1784 1794 // Prevent default for anchor tags 1785 1795 if (card.tagName === 'A') { ··· 1793 1803 1794 1804 // Navigate to the path 1795 1805 window.location.href = n.action; 1796 - }; 1797 - }, 1806 + }; 1807 + }, 1798 1808 1799 - /** 1800 - * Create a new notification card element 1801 - * @private 1809 + _syncButtons(card, n) { 1810 + const footer = card.querySelector('.notification-footer'); 1811 + if (!footer) return; 1812 + 1813 + let actionsEl = footer.querySelector('.notification-actions'); 1814 + if (!n.buttons || n.buttons.length === 0) { 1815 + if (actionsEl) actionsEl.remove(); 1816 + return; 1817 + } 1818 + 1819 + if (!actionsEl) { 1820 + actionsEl = document.createElement('div'); 1821 + actionsEl.className = 'notification-actions'; 1822 + footer.appendChild(actionsEl); 1823 + } 1824 + 1825 + actionsEl.replaceChildren(); 1826 + n.buttons.forEach((button, idx) => { 1827 + const buttonEl = document.createElement('button'); 1828 + buttonEl.type = 'button'; 1829 + buttonEl.className = 'notification-action'; 1830 + buttonEl.dataset.btn = String(idx); 1831 + buttonEl.textContent = button.label; 1832 + actionsEl.appendChild(buttonEl); 1833 + }); 1834 + 1835 + actionsEl.querySelectorAll('.notification-action').forEach((buttonEl) => { 1836 + buttonEl.onclick = (event) => { 1837 + event.preventDefault(); 1838 + event.stopPropagation(); 1839 + const button = n.buttons[Number(buttonEl.dataset.btn)]; 1840 + if (!button) return; 1841 + if (button.onClick) { 1842 + button.onClick(n); 1843 + } 1844 + if (button.dismiss !== false) { 1845 + this.dismiss(n.id); 1846 + } 1847 + }; 1848 + }); 1849 + }, 1850 + 1851 + /** 1852 + * Create a new notification card element 1853 + * @private 1802 1854 */ 1803 1855 _createCard(n) { 1804 1856 // Use anchor tag for semantic HTML when action exists ··· 1829 1881 <div class="notification-title">${window.AppServices._escapeHtml(n.title)}</div> 1830 1882 ${n.message ? `<div class="notification-message">${window.AppServices._escapeHtml(n.message)}</div>` : ''} 1831 1883 ${n.badge ? `<span class="notification-badge">${n.badge}</span>` : ''} 1832 - </div> 1833 - <div class="notification-footer"> 1834 - <span class="notification-time">${relativeTime}</span> 1835 - </div> 1836 - ${n.autoDismiss ? `<div class="notification-countdown" style="animation-duration: ${n.autoDismiss}ms"></div>` : ''} 1837 - `; 1884 + </div> 1885 + <div class="notification-footer"> 1886 + <span class="notification-time">${relativeTime}</span> 1887 + </div> 1888 + ${n.autoDismiss ? `<div class="notification-countdown" style="animation-duration: ${n.autoDismiss}ms"></div>` : ''} 1889 + `; 1890 + this._syncButtons(card, n); 1838 1891 1839 - // Attach click handler 1840 - this._attachClickHandler(card, n); 1892 + // Attach click handler 1893 + this._attachClickHandler(card, n); 1841 1894 1842 1895 if (n.autoDismiss) { 1843 1896 const self = this; ··· 1903 1956 } 1904 1957 1905 1958 // Update time 1906 - const timeEl = card.querySelector('.notification-time'); 1907 - if (timeEl) { 1908 - timeEl.textContent = this._getRelativeTime(n.timestamp); 1909 - } 1959 + const timeEl = card.querySelector('.notification-time'); 1960 + if (timeEl) { 1961 + timeEl.textContent = this._getRelativeTime(n.timestamp); 1962 + } 1963 + this._syncButtons(card, n); 1910 1964 1911 - // Update action and facet 1912 - if (n.action) { 1965 + // Update action and facet 1966 + if (n.action) { 1913 1967 if (card.tagName === 'A') { 1914 1968 card.href = n.action; 1915 1969 }
+102
tests/test_deferred_deletes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + from __future__ import annotations 5 + 6 + import threading 7 + import time 8 + 9 + import pytest 10 + 11 + import think.deferred_deletes as deferred_deletes 12 + 13 + 14 + @pytest.fixture(autouse=True) 15 + def cleanup_deferred_deletes(): 16 + yield 17 + for pending_id in list(deferred_deletes._TIMERS): 18 + deferred_deletes.cancel(pending_id) 19 + 20 + 21 + def test_schedule_runs_commit_once_after_ttl(): 22 + committed = [] 23 + done = threading.Event() 24 + 25 + def commit(): 26 + committed.append("ran") 27 + done.set() 28 + 29 + deferred_deletes.schedule(commit, ttl_seconds=0.05) 30 + 31 + assert done.wait(0.3) 32 + assert committed == ["ran"] 33 + 34 + 35 + def test_cancel_prevents_commit(): 36 + committed = [] 37 + 38 + deferred_id = deferred_deletes.schedule( 39 + lambda: committed.append("ran"), 40 + ttl_seconds=1.0, 41 + ) 42 + 43 + assert deferred_deletes.cancel(deferred_id) is True 44 + time.sleep(1.15) 45 + assert committed == [] 46 + 47 + 48 + def test_double_cancel_returns_false_after_first(): 49 + deferred_id = deferred_deletes.schedule(lambda: None, ttl_seconds=1.0) 50 + 51 + assert deferred_deletes.cancel(deferred_id) is True 52 + assert deferred_deletes.cancel(deferred_id) is False 53 + 54 + 55 + def test_cancel_unknown_id_returns_false(): 56 + assert deferred_deletes.cancel("0" * 32) is False 57 + 58 + 59 + def test_cancel_commit_race_runs_at_most_once(): 60 + iterations = 50 61 + 62 + for _ in range(iterations): 63 + commit_count = 0 64 + commit_lock = threading.Lock() 65 + start = threading.Event() 66 + cancel_results = [] 67 + 68 + def commit(): 69 + nonlocal commit_count 70 + with commit_lock: 71 + commit_count += 1 72 + 73 + deferred_id = deferred_deletes.schedule(commit, ttl_seconds=0.05) 74 + 75 + def attempt_cancel(): 76 + start.wait() 77 + cancel_results.append(deferred_deletes.cancel(deferred_id)) 78 + 79 + threads = [threading.Thread(target=attempt_cancel) for _ in range(8)] 80 + for thread in threads: 81 + thread.start() 82 + 83 + start.set() 84 + time.sleep(0.15) 85 + for thread in threads: 86 + thread.join() 87 + 88 + true_cancels = sum(cancel_results) 89 + assert commit_count in (0, 1) 90 + assert not (true_cancels and commit_count) 91 + assert (true_cancels == 1 and commit_count == 0) or ( 92 + true_cancels == 0 and commit_count == 1 93 + ) 94 + 95 + 96 + def test_scheduled_timers_are_daemon_threads(): 97 + deferred_id = deferred_deletes.schedule(lambda: None, ttl_seconds=1.0) 98 + 99 + with deferred_deletes._LOCK: 100 + timer = deferred_deletes._TIMERS[deferred_id] 101 + 102 + assert timer.daemon is True
+60
think/deferred_deletes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Process-local timer registry for deferred destructive actions.""" 5 + 6 + from __future__ import annotations 7 + 8 + import logging 9 + import threading 10 + import uuid 11 + from collections.abc import Callable 12 + 13 + logger = logging.getLogger(__name__) 14 + 15 + _TIMERS: dict[str, threading.Timer] = {} 16 + _LOCK = threading.Lock() 17 + 18 + 19 + def schedule_with_id( 20 + pending_id: str, 21 + commit_fn: Callable[[], None], 22 + ttl_seconds: float = 10.0, 23 + ) -> str: 24 + """Schedule ``commit_fn`` to run after ``ttl_seconds`` using ``pending_id``.""" 25 + 26 + def _fire(fire_pending_id: str) -> None: 27 + with _LOCK: 28 + timer = _TIMERS.pop(fire_pending_id, None) 29 + if timer is None: 30 + return 31 + 32 + try: 33 + commit_fn() 34 + except Exception: 35 + logger.exception("Deferred delete commit failed for %s", fire_pending_id) 36 + 37 + timer = threading.Timer(ttl_seconds, _fire, args=(pending_id,)) 38 + timer.daemon = True 39 + with _LOCK: 40 + _TIMERS[pending_id] = timer 41 + timer.start() 42 + return pending_id 43 + 44 + 45 + def schedule(commit_fn: Callable[[], None], ttl_seconds: float = 10.0) -> str: 46 + """Schedule ``commit_fn`` using a generated pending id.""" 47 + 48 + return schedule_with_id(uuid.uuid4().hex, commit_fn, ttl_seconds=ttl_seconds) 49 + 50 + 51 + def cancel(pending_id: str) -> bool: 52 + """Cancel a pending deferred delete if it still exists.""" 53 + 54 + with _LOCK: 55 + timer = _TIMERS.pop(pending_id, None) 56 + if timer is None: 57 + return False 58 + 59 + timer.cancel() 60 + return True