personal memory agent
0
fork

Configure Feed

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

transcripts: harden delete surface + segment_path create flag

Summary: ship CPO req_2qn22qml for transcripts-hardening by replacing the transcripts delete confirm() flow with a proper modal, hoisting STREAM_RE into think.utils and validating transcript stream path params, adding is_supervisor_up() plus DELETE-time search_index_warning signaling, and teaching segment_path() to skip mkdir on read paths so missing segments stop materializing phantom chronicle directories.

segment_path audit: apps/speakers/routes.py:434,494,572,613,659 — read — set create=False; apps/speakers/discovery.py:116,223,249 — read — set create=False; apps/speakers/owner.py:345 — read — set create=False; apps/speakers/attribution.py:210 — read — set create=False; apps/activities/routes.py:269,357 — read — set create=False; apps/transcripts/routes.py:254 — read — set create=False; talent/activity_state.py:127 — read — set create=False; talent/activities.py:49,64 — read — set create=False; think/cluster.py:522 — read — set create=False; apps/transcripts/routes.py:509 — rmtree-adjacent — set create=False; apps/transcripts/routes.py:508 — read/day precheck — set day_path(create=False). apps/speakers/routes.py:809,911,1022 — write — left default; apps/speakers/call.py:251 — write — left default; apps/speakers/discovery.py:388 — write — left default; apps/speakers/owner.py:143 — write — left default; apps/speakers/bootstrap.py:151,616 — write — left default; apps/speakers/attribution.py:567 — write — left default; apps/observer/routes.py:432,609 — write — left default; talent/speaker_attribution.py:39,189 — write — left default; convey/chat_stream.py:57 — write — left default.

Known unrelated red gates: make test-app APP=speakers still fails 21 tests on main and 22 on this branch; the +1 delta is resolved by merging main commit fc5d6ac7. make test-app APP=observer fails identically to main from pre-existing chronicle fixture drift. make verify-api only fails on search/search, search/day-results, and graph/graph score/recency drift, while browser verify remains 19/19 green including transcripts/smoke.

T2 carry-forward: the modal now promises "~30 seconds to undo," but the server still shutil.rmtree()s immediately; T2 lands the server-authoritative undo window.

Co-Authored-By: Codex <codex@openai.com>

+577 -67
+2 -2
apps/activities/routes.py
··· 266 266 return "", 404 267 267 268 268 # Check if the screen.jsonl file exists in segment 269 - segment_dir = str(get_segment_path(day, timestamp, stream)) 269 + segment_dir = str(get_segment_path(day, timestamp, stream, create=False)) 270 270 jsonl_path = os.path.join(segment_dir, filename) 271 271 if not os.path.isfile(jsonl_path): 272 272 return "", 404 ··· 354 354 if not filename.endswith("screen.jsonl"): 355 355 return "", 404 356 356 357 - segment_dir = str(get_segment_path(day, timestamp, stream)) 357 + segment_dir = str(get_segment_path(day, timestamp, stream, create=False)) 358 358 jsonl_path = os.path.join(segment_dir, filename) 359 359 360 360 if not os.path.isfile(jsonl_path):
+2 -3
apps/import/ingest.py
··· 31 31 save_journal_entity, 32 32 ) 33 33 from think.entities.matching import find_matching_entity 34 - from think.utils import DEFAULT_STREAM, day_path 34 + from think.utils import DEFAULT_STREAM, STREAM_RE, day_path 35 35 36 36 from .journal_sources import ( 37 37 get_state_directory, ··· 43 43 44 44 _DAY_RE = re.compile(r"^\d{8}$") 45 45 _SEGMENT_RE = re.compile(r"^\d{6}_\d+$") 46 - _STREAM_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") 47 46 _FACET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") 48 47 _IMPORT_ID_RE = re.compile(r"^\d{8}_\d{6}$") 49 48 ··· 150 149 151 150 if not _DAY_RE.match(day): 152 151 raise ValueError("Invalid day format") 153 - if stream != DEFAULT_STREAM and not _STREAM_RE.match(stream): 152 + if stream != DEFAULT_STREAM and not STREAM_RE.fullmatch(stream): 154 153 raise ValueError("Invalid stream format") 155 154 if not _SEGMENT_RE.match(segment_key): 156 155 raise ValueError("Invalid segment_key format")
+1 -1
apps/speakers/attribution.py
··· 564 564 return {} 565 565 owner_centroid, owner_threshold = centroid_data 566 566 567 - seg_dir = segment_path(day, segment_key, stream) 567 + seg_dir = segment_path(day, segment_key, stream, create=False) 568 568 emb_data = load_embeddings_file(seg_dir / f"{source}.npz") 569 569 if emb_data is None: 570 570 return {}
+3 -3
apps/speakers/discovery.py
··· 113 113 for segment in scan_segment_embeddings(day): 114 114 stream = segment["stream"] 115 115 seg_key = segment["key"] 116 - seg_dir = segment_path(day, seg_key, stream) 116 + seg_dir = segment_path(day, seg_key, stream, create=False) 117 117 118 118 labels_data = load_speaker_labels(seg_dir) 119 119 attributed_sids: set[int] = set() ··· 221 221 continue 222 222 seen_segments.add(seg_triplet) 223 223 seg_dir = segment_path( 224 - record["day"], record["segment_key"], record["stream"] 224 + record["day"], record["segment_key"], record["stream"], create=False 225 225 ) 226 226 samples.append( 227 227 { ··· 247 247 for pos in sorted_positions: 248 248 record = provenance[int(cluster_indices[int(pos)])] 249 249 seg_dir = segment_path( 250 - record["day"], record["segment_key"], record["stream"] 250 + record["day"], record["segment_key"], record["stream"], create=False 251 251 ) 252 252 sample = { 253 253 **record,
+1 -1
apps/speakers/owner.py
··· 342 342 343 343 centroid, threshold = centroid_data 344 344 emb_data = load_embeddings_file( 345 - segment_path(day, segment_key, stream) / f"{source}.npz" 345 + segment_path(day, segment_key, stream, create=False) / f"{source}.npz" 346 346 ) 347 347 if emb_data is None: 348 348 return []
+5 -5
apps/speakers/routes.py
··· 433 433 - emb_data: Tuple of (embeddings, statement_ids) or None if no embeddings 434 434 """ 435 435 if stream: 436 - segment_dir = get_segment_path(day, segment_key, stream) 436 + segment_dir = get_segment_path(day, segment_key, stream, create=False) 437 437 else: 438 438 segment_dir = day_path(day) / segment_key 439 439 ··· 493 493 ) -> np.ndarray | None: 494 494 """Get a specific sentence's embedding, normalized.""" 495 495 if stream: 496 - segment_dir = get_segment_path(day, segment_key, stream) 496 + segment_dir = get_segment_path(day, segment_key, stream, create=False) 497 497 else: 498 498 segment_dir = day_path(day) / segment_key 499 499 npz_path = segment_dir / f"{source}.npz" ··· 571 571 principal = get_journal_principal() 572 572 principal_id = principal["id"] if principal else None 573 573 for seg in segments: 574 - seg_dir = get_segment_path(day, seg["key"], seg["stream"]) 574 + seg_dir = get_segment_path(day, seg["key"], seg["stream"], create=False) 575 575 labels_data = _load_speaker_labels(seg_dir) 576 576 if labels_data: 577 577 labels = labels_data.get("labels", []) ··· 612 612 return error_response("Invalid segment key", 400) 613 613 614 614 # Load speakers from speakers.json 615 - segment_dir = get_segment_path(day, segment_key, stream) 615 + segment_dir = get_segment_path(day, segment_key, stream, create=False) 616 616 speakers = _load_segment_speakers(segment_dir) 617 617 if not speakers: 618 618 return jsonify({"matched": [], "unmatched": []}) ··· 658 658 if not sentences: 659 659 return error_response("No transcript found", 404) 660 660 661 - segment_dir = get_segment_path(day, segment_key, stream) 661 + segment_dir = get_segment_path(day, segment_key, stream, create=False) 662 662 labels_data = _load_speaker_labels(segment_dir) 663 663 label_map: dict[int, dict] = {} 664 664 if labels_data:
+16 -5
apps/transcripts/routes.py
··· 34 34 from think.cluster import cluster_scan, cluster_segments, scan_day 35 35 from think.entities.journal import get_journal_principal, load_journal_entity 36 36 from think.models import get_usage_cost 37 - from think.utils import day_dirs, day_path, segment_path 37 + from think.supervisor import is_supervisor_up 38 + from think.utils import STREAM_RE, day_dirs, day_path, segment_path 38 39 from think.utils import segment_key as validate_segment_key 39 40 40 41 logger = logging.getLogger(__name__) ··· 248 249 if not DATE_RE.fullmatch(day): 249 250 return error_response("Invalid day format", 404) 250 251 252 + if not STREAM_RE.fullmatch(stream): 253 + return error_response("Invalid stream format", 404) 254 + 251 255 if not validate_segment_key(segment_key): 252 256 return error_response("Invalid segment key format", 404) 253 257 254 - segment_dir = str(segment_path(day, segment_key, stream)) 258 + segment_dir = str(segment_path(day, segment_key, stream, create=False)) 255 259 if not os.path.isdir(segment_dir): 256 260 return error_response("Segment directory not found", 404) 257 261 ··· 505 509 if not validate_segment_key(segment_key): 506 510 return error_response("Invalid segment key format", 400) 507 511 508 - day_dir = str(day_path(day)) 509 - segment_dir = str(segment_path(day, segment_key, stream)) 512 + if not STREAM_RE.fullmatch(stream): 513 + return error_response("Invalid stream format", 400) 514 + 515 + day_dir = str(day_path(day, create=False)) 516 + segment_dir = str(segment_path(day, segment_key, stream, create=False)) 510 517 511 518 # Verify segment exists 512 519 if not os.path.isdir(segment_dir): ··· 529 536 day=day, 530 537 ) 531 538 539 + payload = {"deleted": segment_key} 540 + if not is_supervisor_up(): 541 + payload["search_index_warning"] = True 542 + 532 543 # Trigger indexer rescan to remove deleted segment from search index 533 544 # Supervisor queues by command name, serializing concurrent indexer requests 534 545 emit( ··· 537 548 cmd=["sol", "indexer", "--rescan-full"], 538 549 ) 539 550 540 - return success_response({"deleted": segment_key}) 551 + return success_response(payload) 541 552 542 553 except OSError as e: 543 554 return error_response(f"Failed to delete segment: {e}", 500)
+33 -2
apps/transcripts/tests/conftest.py
··· 4 4 """Fixtures for transcripts app tests.""" 5 5 6 6 import os 7 + import sys 8 + from pathlib import Path 7 9 8 10 import pytest 9 11 12 + from convey import create_app 13 + 14 + ROOT = Path(__file__).resolve().parents[3] 15 + if str(ROOT) not in sys.path: 16 + sys.path.insert(0, str(ROOT)) 17 + 18 + from tests._baseline_harness import copytree_tracked 19 + 10 20 11 21 @pytest.fixture(autouse=True) 12 - def _journal_env(monkeypatch): 13 - """Point _SOLSTONE_JOURNAL_OVERRIDE at the test fixtures.""" 22 + def _journal_env(request, monkeypatch): 23 + """Point tests at a copied journal when needed, otherwise the tracked fixture.""" 24 + if "journal_copy" in request.fixturenames: 25 + journal_copy = request.getfixturevalue("journal_copy") 26 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_copy)) 27 + return 28 + 14 29 monkeypatch.setenv( 15 30 "_SOLSTONE_JOURNAL_OVERRIDE", 16 31 os.path.join(os.getcwd(), "tests", "fixtures", "journal"), 17 32 ) 33 + 34 + 35 + @pytest.fixture 36 + def client(journal_copy, monkeypatch): 37 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal_copy)) 38 + app = create_app(str(journal_copy)) 39 + return app.test_client() 40 + 41 + 42 + @pytest.fixture 43 + def journal_copy(tmp_path, monkeypatch): 44 + src = Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "journal" 45 + dst = tmp_path / "journal" 46 + copytree_tracked(src, dst) 47 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(dst.resolve())) 48 + return dst
+107
apps/transcripts/tests/test_segment_routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + import pytest 5 + 6 + FIXTURE_DAY = "20260304" 7 + FIXTURE_STREAM = "default" 8 + FIXTURE_SEGMENT = "090000_300" 9 + 10 + 11 + @pytest.mark.parametrize("stream", ["-bad", "Upper", "..bad"]) 12 + def test_segment_content_rejects_invalid_stream(client, stream): 13 + response = client.get( 14 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{stream}/{FIXTURE_SEGMENT}" 15 + ) 16 + 17 + assert response.status_code == 404 18 + assert response.get_json() == {"error": "Invalid stream format"} 19 + 20 + 21 + @pytest.mark.parametrize("stream", ["-bad", "Upper", "..bad"]) 22 + def test_delete_segment_rejects_invalid_stream(client, stream): 23 + response = client.delete( 24 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{stream}/{FIXTURE_SEGMENT}" 25 + ) 26 + 27 + assert response.status_code == 400 28 + assert response.get_json() == {"error": "Invalid stream format"} 29 + 30 + 31 + def test_segment_content_missing_segment_does_not_create_phantom_directory( 32 + client, journal_copy 33 + ): 34 + response = client.get("/app/transcripts/api/segment/29990101/default/090000_300") 35 + 36 + assert response.status_code == 404 37 + assert response.get_json() == {"error": "Segment directory not found"} 38 + assert not (journal_copy / "chronicle" / "29990101").exists() 39 + assert not ( 40 + journal_copy / "chronicle" / "29990101" / "default" / "090000_300" 41 + ).exists() 42 + 43 + 44 + def test_delete_missing_segment_does_not_create_phantom_directory(client, journal_copy): 45 + response = client.delete("/app/transcripts/api/segment/29990101/default/090000_300") 46 + 47 + assert response.status_code == 404 48 + assert response.get_json() == {"error": "Segment not found"} 49 + assert not (journal_copy / "chronicle" / "29990101").exists() 50 + assert not ( 51 + journal_copy / "chronicle" / "29990101" / "default" / "090000_300" 52 + ).exists() 53 + 54 + 55 + def test_segment_content_happy_path_returns_segment_payload(client): 56 + response = client.get( 57 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 58 + ) 59 + 60 + assert response.status_code == 200 61 + data = response.get_json() 62 + assert data["segment_key"] == FIXTURE_SEGMENT 63 + assert data["chunks"] 64 + assert "media_sizes" in data 65 + 66 + 67 + def test_delete_segment_happy_path_removes_segment_directory( 68 + client, journal_copy, monkeypatch 69 + ): 70 + monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) 71 + segment_dir = ( 72 + journal_copy / "chronicle" / FIXTURE_DAY / FIXTURE_STREAM / FIXTURE_SEGMENT 73 + ) 74 + 75 + response = client.delete( 76 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 77 + ) 78 + 79 + assert response.status_code == 200 80 + assert response.get_json() == {"success": True, "deleted": FIXTURE_SEGMENT} 81 + assert not segment_dir.exists() 82 + 83 + 84 + def test_delete_segment_includes_search_index_warning_when_supervisor_is_down(client): 85 + response = client.delete( 86 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 87 + ) 88 + 89 + assert response.status_code == 200 90 + assert response.get_json() == { 91 + "success": True, 92 + "deleted": FIXTURE_SEGMENT, 93 + "search_index_warning": True, 94 + } 95 + 96 + 97 + def test_delete_segment_omits_search_index_warning_when_supervisor_is_up( 98 + client, monkeypatch 99 + ): 100 + monkeypatch.setattr("apps.transcripts.routes.is_supervisor_up", lambda: True) 101 + 102 + response = client.delete( 103 + f"/app/transcripts/api/segment/{FIXTURE_DAY}/{FIXTURE_STREAM}/{FIXTURE_SEGMENT}" 104 + ) 105 + 106 + assert response.status_code == 200 107 + assert response.get_json() == {"success": True, "deleted": FIXTURE_SEGMENT}
+267 -36
apps/transcripts/workspace.html
··· 787 787 flex-shrink: 0; 788 788 } 789 789 790 + #trDeleteSegmentModal { 791 + position: fixed; 792 + inset: 0; 793 + display: none; 794 + align-items: center; 795 + justify-content: center; 796 + padding: 24px; 797 + background: rgba(15, 23, 42, 0.55); 798 + z-index: 1100; 799 + } 800 + 801 + #trDeleteSegmentModal .modal-content { 802 + width: min(520px, 100%); 803 + background: #ffffff; 804 + border-radius: 16px; 805 + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.22); 806 + overflow: hidden; 807 + } 808 + 809 + #trDeleteSegmentModal .modal-header { 810 + display: flex; 811 + align-items: center; 812 + justify-content: space-between; 813 + padding: 20px 24px 16px; 814 + border-bottom: 1px solid #e5e7eb; 815 + } 816 + 817 + #trDeleteSegmentModal .modal-header h3 { 818 + margin: 0; 819 + font-size: 20px; 820 + font-weight: 700; 821 + color: #111827; 822 + } 823 + 824 + #trDeleteSegmentModal .modal-header.danger h3 { 825 + color: #991b1b; 826 + } 827 + 828 + #trDeleteSegmentModal .modal-body { 829 + padding: 20px 24px; 830 + } 831 + 832 + #trDeleteSegmentModal .modal-footer { 833 + display: flex; 834 + justify-content: flex-end; 835 + gap: 12px; 836 + padding: 0 24px 24px; 837 + } 838 + 839 + #trDeleteSegmentModal .close { 840 + color: #9ca3af; 841 + font-size: 28px; 842 + line-height: 1; 843 + cursor: pointer; 844 + transition: color 0.15s ease; 845 + } 846 + 847 + #trDeleteSegmentModal .close:hover { 848 + color: #4b5563; 849 + } 850 + 851 + #trDeleteSegmentModal .close:active { 852 + color: #111827; 853 + } 854 + 855 + #trDeleteSegmentModal .btn-secondary, 856 + #trDeleteSegmentModal .btn-danger { 857 + border: none; 858 + border-radius: 10px; 859 + padding: 10px 18px; 860 + font-size: 14px; 861 + font-weight: 600; 862 + cursor: pointer; 863 + transition: background 0.15s ease, color 0.15s ease, transform 0.15s ease; 864 + } 865 + 866 + #trDeleteSegmentModal .btn-secondary { 867 + background: #f3f4f6; 868 + color: #374151; 869 + } 870 + 871 + #trDeleteSegmentModal .btn-secondary:hover { 872 + background: #e5e7eb; 873 + } 874 + 875 + #trDeleteSegmentModal .btn-secondary:active { 876 + transform: translateY(1px); 877 + } 878 + 879 + #trDeleteSegmentModal .btn-danger { 880 + background: #dc2626; 881 + color: #ffffff; 882 + } 883 + 884 + #trDeleteSegmentModal .btn-danger:hover { 885 + background: #b91c1c; 886 + } 887 + 888 + #trDeleteSegmentModal .btn-danger:active { 889 + transform: translateY(1px); 890 + } 891 + 892 + #trDeleteSegmentModal .delete-warning { 893 + padding: 16px 18px; 894 + border-radius: 12px; 895 + background: #fef2f2; 896 + border-left: 4px solid #dc2626; 897 + color: #7f1d1d; 898 + } 899 + 900 + #trDeleteSegmentModal .delete-warning p { 901 + margin: 0 0 12px; 902 + } 903 + 904 + #trDeleteSegmentModal .delete-warning p:last-child { 905 + margin-bottom: 0; 906 + } 907 + 908 + #trDeleteSegmentModal .tr-delete-segment-meta { 909 + font-size: 14px; 910 + color: #991b1b; 911 + } 912 + 913 + #trDeleteSegmentModal .tr-delete-segment-list { 914 + margin: 0 0 12px; 915 + padding-left: 20px; 916 + } 917 + 918 + #trDeleteSegmentModal .tr-delete-segment-list li + li { 919 + margin-top: 6px; 920 + } 921 + 922 + #trDeleteSegmentModal .tr-delete-segment-size { 923 + font-size: 13px; 924 + color: #7f1d1d; 925 + } 926 + 790 927 /* Unified timeline view */ 791 928 .tr-unified { 792 929 display: flex; ··· 1290 1427 </div> 1291 1428 </div> 1292 1429 1430 + <div id="trDeleteSegmentModal" class="modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="trDeleteSegmentModalTitle"> 1431 + <div class="modal-content"> 1432 + <div class="modal-header danger"> 1433 + <h3 id="trDeleteSegmentModalTitle">Delete segment?</h3> 1434 + <span class="close" id="trDeleteSegmentModalClose" onclick="closeDeleteSegmentModal()">&times;</span> 1435 + </div> 1436 + <div class="modal-body"> 1437 + <div id="trDeleteSegmentModalBody" class="delete-warning"></div> 1438 + </div> 1439 + <div class="modal-footer"> 1440 + <button type="button" class="btn-secondary" onclick="closeDeleteSegmentModal()">Cancel</button> 1441 + <button type="button" id="trDeleteSegmentModalConfirm" class="btn-danger" onclick="confirmDeleteSegment()">Delete</button> 1442 + </div> 1443 + </div> 1444 + </div> 1445 + 1293 1446 <script src="{{ vendor_lib('marked') }}"></script> 1294 1447 <script> 1295 1448 (() => { ··· 1340 1493 }); 1341 1494 const panel = document.getElementById('trPanel'); 1342 1495 const deleteBtn = document.getElementById('trDeleteBtn'); 1496 + const deleteSegmentModal = document.getElementById('trDeleteSegmentModal'); 1497 + const deleteSegmentModalBody = document.getElementById('trDeleteSegmentModalBody'); 1498 + const deleteSegmentModalConfirm = document.getElementById('trDeleteSegmentModalConfirm'); 1343 1499 const emptyIcons = { 1344 1500 day: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>', 1345 1501 nothing: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>', ··· 1366 1522 let allSegments = []; 1367 1523 let selectedSegment = null; 1368 1524 let updateNowPosition = null; 1525 + let deleteModalReturnFocus = null; 1526 + let deleteModalPrevOverflow = ''; 1369 1527 1370 1528 // Zoom state 1371 1529 let zoomHeight = zoom.clientHeight; ··· 3066 3224 3067 3225 // Keyboard navigation for segment stepping 3068 3226 document.addEventListener('keydown', (e) => { 3227 + if (e.key === 'Escape' && deleteSegmentModal.style.display !== 'none') { 3228 + e.preventDefault(); 3229 + closeDeleteSegmentModal(); 3230 + return; 3231 + } 3069 3232 const tag = e.target.tagName; 3070 3233 if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return; 3071 3234 if (document.getElementById('trImageModal')) return; 3235 + if (deleteSegmentModal.style.display !== 'none') return; 3072 3236 if (e.key === ']') { e.preventDefault(); navigateSegment(1); } 3073 3237 else if (e.key === '[') { e.preventDefault(); navigateSegment(-1); } 3238 + }); 3239 + 3240 + window.addEventListener('click', (event) => { 3241 + if (event.target === deleteSegmentModal) { 3242 + closeDeleteSegmentModal(); 3243 + } 3074 3244 }); 3075 3245 3076 3246 // Click on timeline to set range ··· 3143 3313 return pos.charAt(0).toUpperCase() + pos.slice(1); 3144 3314 } 3145 3315 3146 - // Clear selection and reset UI state 3147 - function clearSegmentSelection() { 3148 - selectedSegment = null; 3149 - segmentData = null; 3150 - currentVideoFiles = {}; 3151 - activeTab = null; 3152 - tabPanes = {}; 3153 - screenDecoded = false; 3154 - frameCapture.clear(); 3155 - allScreenFrames = []; 3156 - currentFrameIndex = -1; 3157 - groupEntriesByIdx.clear(); 3316 + function showDeleteSegmentModal() { 3317 + if (!selectedSegment) return; 3158 3318 3159 - // Stop and clear audio player reference 3160 - panel.querySelectorAll('audio').forEach(audio => audio.pause()); 3319 + const seg = selectedSegment; 3320 + const totalBytes = (segmentData?.media_sizes?.audio || 0) + (segmentData?.media_sizes?.screen || 0); 3321 + const sizeLine = totalBytes > 0 3322 + ? `<p class="tr-delete-segment-size">Current raw media size: ${formatSize(totalBytes)}</p>` 3323 + : ''; 3161 3324 3162 - // Hide delete button 3163 - deleteBtn.classList.remove('visible'); 3325 + deleteSegmentModalBody.innerHTML = ` 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> 3328 + <p>This removes:</p> 3329 + <ul class="tr-delete-segment-list"> 3330 + <li>audio</li> 3331 + <li>screen recording</li> 3332 + <li>transcripts</li> 3333 + <li>derived insights</li> 3334 + </ul> 3335 + ${sizeLine} 3336 + `; 3164 3337 3165 - // Clear URL hash 3166 - history.replaceState(null, '', window.location.pathname); 3338 + deleteModalReturnFocus = document.activeElement; 3339 + deleteModalPrevOverflow = document.body.style.overflow; 3340 + deleteSegmentModal.style.display = 'flex'; 3341 + document.body.style.overflow = 'hidden'; 3342 + deleteSegmentModalConfirm.focus(); 3343 + } 3167 3344 3168 - // Reset UI 3169 - titleEl.textContent = 'Transcripts'; 3170 - rangeText.textContent = ''; 3171 - tabsContainer.innerHTML = ''; 3172 - tabsContainer.classList.remove('visible'); 3173 - document.getElementById('trWarningNotice').classList.remove('visible'); 3174 - panel.innerHTML = emptyStateHTML(emptyIcons.day, 'your day at a glance', 'select a segment from the timeline to view its transcript'); 3345 + function closeDeleteSegmentModal(restoreFocus = true) { 3346 + if (deleteSegmentModal.style.display === 'none') return; 3175 3347 3176 - // Clear active state in zoom view 3177 - zoomSegments.querySelectorAll('.tr-zoom-pill').forEach(pill => { 3178 - pill.classList.remove('tr-active'); 3179 - }); 3348 + deleteSegmentModal.style.display = 'none'; 3349 + deleteSegmentModalBody.innerHTML = ''; 3350 + document.body.style.overflow = deleteModalPrevOverflow; 3351 + deleteModalPrevOverflow = ''; 3352 + if (restoreFocus && deleteModalReturnFocus && document.contains(deleteModalReturnFocus)) { 3353 + deleteModalReturnFocus.focus(); 3354 + } 3355 + deleteModalReturnFocus = null; 3180 3356 } 3181 3357 3182 - // Delete segment handler 3183 - deleteBtn.addEventListener('click', async () => { 3358 + async function confirmDeleteSegment() { 3184 3359 if (!selectedSegment) return; 3185 3360 3186 3361 const seg = selectedSegment; 3187 - const confirmMsg = `Delete segment ${seg.start} - ${seg.end}?\n\n` + 3188 - `This will permanently remove all audio, screen recordings, and transcripts for this segment.\n\n` + 3189 - `This cannot be undone.`; 3362 + closeDeleteSegmentModal(false); 3363 + await performDeleteSegment(seg); 3364 + } 3190 3365 3191 - if (!confirm(confirmMsg)) return; 3192 - 3366 + async function performDeleteSegment(seg) { 3193 3367 try { 3194 3368 const response = await fetch(`/app/transcripts/api/segment/${day}/${seg.stream}/${seg.key}`, { 3195 3369 method: 'DELETE' ··· 3199 3373 const data = await response.json().catch(() => ({})); 3200 3374 throw new Error(data.error || 'Failed to delete segment'); 3201 3375 } 3376 + 3377 + const data = await response.json(); 3202 3378 3203 3379 // Remove segment from local state 3204 3380 allSegments = allSegments.filter(s => s.key !== seg.key); ··· 3228 3404 // Range indicators may be stale, but segment is deleted 3229 3405 }); 3230 3406 3407 + if (data.search_index_warning === true) { 3408 + window.AppServices.notifications.show({ 3409 + app: 'transcripts', 3410 + icon: '⚠️', 3411 + title: 'Search index may be stale', 3412 + message: 'Search results may be briefly stale until the supervisor is restarted and the index is rebuilt.', 3413 + dismissible: true, 3414 + autoDismiss: 10000 3415 + }); 3416 + } 3417 + 3231 3418 } catch (err) { 3232 3419 const notice = document.createElement('div'); 3233 3420 notice.className = 'tr-warning-notice visible'; ··· 3241 3428 setTimeout(() => notice.remove(), 300); 3242 3429 }, 5000); 3243 3430 } 3431 + } 3432 + 3433 + window.closeDeleteSegmentModal = closeDeleteSegmentModal; 3434 + window.confirmDeleteSegment = confirmDeleteSegment; 3435 + 3436 + // Clear selection and reset UI state 3437 + function clearSegmentSelection() { 3438 + selectedSegment = null; 3439 + segmentData = null; 3440 + currentVideoFiles = {}; 3441 + activeTab = null; 3442 + tabPanes = {}; 3443 + screenDecoded = false; 3444 + frameCapture.clear(); 3445 + allScreenFrames = []; 3446 + currentFrameIndex = -1; 3447 + groupEntriesByIdx.clear(); 3448 + 3449 + // Stop and clear audio player reference 3450 + panel.querySelectorAll('audio').forEach(audio => audio.pause()); 3451 + 3452 + // Hide delete button 3453 + deleteBtn.classList.remove('visible'); 3454 + 3455 + // Clear URL hash 3456 + history.replaceState(null, '', window.location.pathname); 3457 + 3458 + // Reset UI 3459 + titleEl.textContent = 'Transcripts'; 3460 + rangeText.textContent = ''; 3461 + tabsContainer.innerHTML = ''; 3462 + tabsContainer.classList.remove('visible'); 3463 + document.getElementById('trWarningNotice').classList.remove('visible'); 3464 + panel.innerHTML = emptyStateHTML(emptyIcons.day, 'your day at a glance', 'select a segment from the timeline to view its transcript'); 3465 + 3466 + // Clear active state in zoom view 3467 + zoomSegments.querySelectorAll('.tr-zoom-pill').forEach(pill => { 3468 + pill.classList.remove('tr-active'); 3469 + }); 3470 + } 3471 + 3472 + // Delete segment handler 3473 + deleteBtn.addEventListener('click', () => { 3474 + showDeleteSegmentModal(); 3244 3475 }); 3245 3476 3246 3477 })();
+1 -1
docs/design/voice-server.md
··· 470 470 - Fixture briefing date drift: without the `_today()` seam, `briefing.get` and date-window tools would fail when run against the real system clock. 471 471 - `commitments.list.resolution` diminishment is accepted as a Wave 2 known-limit. The ledger surface stores resolution nuance in close-note edits rather than a typed field, so `commitments.list` surfaces `"dropped"` only for dropped items and omits the field otherwise. Full resolution carry-through remains available for a follow-up if post-ship live validation reveals a real need. 472 472 - `sources` leakage: `LedgerItem.sources` is provenance, not model-facing data. The tool layer must strip it before returning commitments to OpenAI. 473 - - `segment_path()` is write-creating: `journal.get_day` must not call `think.utils.segment_path()` when reading segment summaries because that helper creates directories. 473 + - `segment_path()` still creates directories by default for write paths; read-only callers (for example segment-summary readers) must pass `create=False`.
+5 -2
talent/activities.py
··· 46 46 47 47 def _list_facets_with_activity_state(day: str, segment: str, stream: str) -> list[str]: 48 48 """Find all facets that have activity_state.json in a segment.""" 49 - agents_dir = segment_path(day, segment, stream) / "talents" 49 + agents_dir = segment_path(day, segment, stream, create=False) / "talents" 50 50 if not agents_dir.is_dir(): 51 51 return [] 52 52 ··· 61 61 def _load_activity_state(day: str, segment: str, facet: str, stream: str) -> list[dict]: 62 62 """Load activity_state.json for a facet in a segment. Returns [] on failure.""" 63 63 state_path = ( 64 - segment_path(day, segment, stream) / "talents" / facet / "activity_state.json" 64 + segment_path(day, segment, stream, create=False) 65 + / "talents" 66 + / facet 67 + / "activity_state.json" 65 68 ) 66 69 if not state_path.exists(): 67 70 return []
+4 -1
talent/activity_state.py
··· 124 124 parsed JSON array or None if not found/invalid. 125 125 """ 126 126 state_path = ( 127 - segment_path(day, segment, stream) / "talents" / facet / "activity_state.json" 127 + segment_path(day, segment, stream, create=False) 128 + / "talents" 129 + / facet 130 + / "activity_state.json" 128 131 ) 129 132 if not state_path.exists(): 130 133 return None, None
+12
tests/test_cluster.py
··· 635 635 636 636 created = day_path("29990101") 637 637 assert created.exists() 638 + 639 + 640 + def test_find_segment_dir_missing_streamed_segment_does_not_create_directory( 641 + tmp_path, monkeypatch 642 + ): 643 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 644 + 645 + mod = importlib.import_module("think.cluster") 646 + result = mod._find_segment_dir("29990101", "090000_300", "default") 647 + 648 + assert result is None 649 + assert not (tmp_path / "chronicle" / "29990101").exists()
+66
tests/test_supervisor.py
··· 8 8 import sys 9 9 from unittest.mock import MagicMock 10 10 11 + import psutil 11 12 import pytest 12 13 13 14 ··· 610 611 assert (tmp_path / "health" / "supervisor.pid").read_text().strip() == str( 611 612 os.getpid() 612 613 ) 614 + start_time = float( 615 + (tmp_path / "health" / "supervisor.start_time").read_text().strip() 616 + ) 617 + assert start_time > 0 613 618 614 619 615 620 def test_supervisor_singleton_lock_blocked(tmp_path, monkeypatch, capsys): ··· 672 677 assert "PID 12345" in output 673 678 health_mock.assert_called_once_with() 674 679 start_mock.assert_not_called() 680 + 681 + 682 + def test_is_supervisor_up_without_pid_file(tmp_path, monkeypatch): 683 + mod = importlib.reload(importlib.import_module("think.supervisor")) 684 + 685 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 686 + (tmp_path / "health").mkdir(parents=True, exist_ok=True) 687 + 688 + assert mod.is_supervisor_up() is False 689 + 690 + 691 + def test_is_supervisor_up_with_dead_pid(tmp_path, monkeypatch): 692 + mod = importlib.reload(importlib.import_module("think.supervisor")) 693 + 694 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 695 + health_dir = tmp_path / "health" 696 + health_dir.mkdir(parents=True, exist_ok=True) 697 + 698 + proc = subprocess.Popen(["true"]) 699 + proc.wait() 700 + (health_dir / "supervisor.pid").write_text(str(proc.pid)) 701 + 702 + assert mod.is_supervisor_up() is False 703 + 704 + 705 + def test_is_supervisor_up_with_live_pid_missing_start_time(tmp_path, monkeypatch): 706 + mod = importlib.reload(importlib.import_module("think.supervisor")) 707 + 708 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 709 + health_dir = tmp_path / "health" 710 + health_dir.mkdir(parents=True, exist_ok=True) 711 + (health_dir / "supervisor.pid").write_text(str(os.getpid())) 712 + 713 + assert mod.is_supervisor_up() is False 714 + 715 + 716 + def test_is_supervisor_up_with_live_pid_mismatched_start_time(tmp_path, monkeypatch): 717 + mod = importlib.reload(importlib.import_module("think.supervisor")) 718 + 719 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 720 + health_dir = tmp_path / "health" 721 + health_dir.mkdir(parents=True, exist_ok=True) 722 + (health_dir / "supervisor.pid").write_text(str(os.getpid())) 723 + create_time = psutil.Process(os.getpid()).create_time() 724 + (health_dir / "supervisor.start_time").write_text(str(create_time + 60)) 725 + 726 + assert mod.is_supervisor_up() is False 727 + 728 + 729 + def test_is_supervisor_up_with_matching_process_identity(tmp_path, monkeypatch): 730 + mod = importlib.reload(importlib.import_module("think.supervisor")) 731 + 732 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 733 + health_dir = tmp_path / "health" 734 + health_dir.mkdir(parents=True, exist_ok=True) 735 + (health_dir / "supervisor.pid").write_text(str(os.getpid())) 736 + (health_dir / "supervisor.start_time").write_text( 737 + str(psutil.Process(os.getpid()).create_time()) 738 + ) 739 + 740 + assert mod.is_supervisor_up() is True
+1 -1
think/cluster.py
··· 519 519 from think.utils import segment_path as _segment_path 520 520 521 521 if stream: 522 - path = _segment_path(day, segment, stream) 522 + path = _segment_path(day, segment, stream, create=False) 523 523 return path if path.is_dir() else None 524 524 525 525 # Search all streams for this segment
+43
think/supervisor.py
··· 17 17 from datetime import datetime 18 18 from pathlib import Path 19 19 20 + import psutil 20 21 from desktop_notifier import DesktopNotifier, Urgency 21 22 22 23 from think import routines, scheduler ··· 511 512 512 513 def _get_journal_path() -> Path: 513 514 return Path(get_journal()) 515 + 516 + 517 + def is_supervisor_up() -> bool: 518 + """Return True when supervisor.pid and supervisor.start_time identify a live supervisor process for the current journal.""" 519 + health_dir = Path(get_journal()) / "health" 520 + pid_path = health_dir / "supervisor.pid" 521 + try: 522 + pid = int(pid_path.read_text().strip()) 523 + except FileNotFoundError: 524 + return False 525 + except (OSError, ValueError): 526 + return False 527 + 528 + try: 529 + os.kill(pid, 0) 530 + except ProcessLookupError: 531 + return False 532 + except PermissionError: 533 + return False 534 + except OSError: 535 + return False 536 + 537 + start_time_path = health_dir / "supervisor.start_time" 538 + try: 539 + recorded_start = float(start_time_path.read_text().strip()) 540 + except FileNotFoundError: 541 + return False 542 + except (OSError, ValueError): 543 + return False 544 + 545 + try: 546 + create_time = psutil.Process(pid).create_time() 547 + except psutil.NoSuchProcess: 548 + return False 549 + except psutil.Error: 550 + return False 551 + 552 + tolerance = 1.5 # drift between time.time() and psutil create_time() 553 + return abs(recorded_start - create_time) <= tolerance 514 554 515 555 516 556 class RestartPolicy: ··· 1510 1550 print(f"Supervisor already running{pid_msg}") 1511 1551 sys.exit(1) 1512 1552 pid_path.write_text(str(os.getpid())) 1553 + start_time_path = health_dir / "supervisor.start_time" 1554 + # Written here, not at _supervisor_start, to minimize drift from psutil create_time(). 1555 + start_time_path.write_text(str(time.time())) 1513 1556 logging.info("Singleton lock acquired (PID %d)", os.getpid()) 1514 1557 1515 1558 # Set up signal handlers
+8 -4
think/utils.py
··· 29 29 from media import MIME_TYPES 30 30 31 31 DATE_RE = re.compile(r"\d{8}") 32 + STREAM_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") 32 33 CHRONICLE_DIR = "chronicle" 33 34 DEFAULT_STREAM = "_default" 34 35 EXIT_TEMPFAIL = 75 ··· 245 246 return updated 246 247 247 248 248 - def segment_path(day: str, segment: str, stream: str) -> Path: 249 + def segment_path(day: str, segment: str, stream: str, *, create: bool = True) -> Path: 249 250 """Return absolute path for a segment directory within a stream. 250 251 251 252 Parameters ··· 256 257 Segment key in HHMMSS_LEN format. 257 258 stream : str 258 259 Stream name (e.g., "archon", "import.apple"). 260 + create : bool, optional 261 + Create the segment directory if it does not exist. Defaults to True. 259 262 260 263 Returns 261 264 ------- 262 265 Path 263 - Absolute path to the segment directory (created if it doesn't exist). 266 + Absolute path to the segment directory. 264 267 """ 265 - path = day_path(day) / stream / segment 266 - path.mkdir(parents=True, exist_ok=True) 268 + path = day_path(day, create=create) / stream / segment 269 + if create: 270 + path.mkdir(parents=True, exist_ok=True) 267 271 return path 268 272 269 273