99//! marked `final` (the speech engine has locked it in).
1010
1111use std:: collections:: VecDeque ;
12- use std:: path:: Path ;
12+ use std:: path:: { Path , PathBuf } ;
1313use std:: time:: Instant ;
1414
1515use chrono:: { DateTime , Utc } ;
@@ -59,7 +59,7 @@ impl SessionState {
5959 /// While this is true the live transcript is the persistence source and
6060 /// must not be replaced by another view's segments.
6161 #[ must_use]
62- pub fn is_active ( self ) -> bool {
62+ pub const fn is_active ( self ) -> bool {
6363 matches ! (
6464 self ,
6565 Self :: Starting | Self :: Recording { .. } | Self :: Stopping
@@ -263,13 +263,24 @@ pub struct AppModel {
263263 /// refreshed whenever a recording finishes or the user returns to the
264264 /// library.
265265 pub library : Vec < StoredSession > ,
266- /// `Some` while a session row exists in the database for the current
267- /// live transcript. Allocated before audio starts and retained after a
268- /// successful stop until the user navigates away or starts a new session.
266+ /// Open database row owned by an active or not-yet-persisted recording.
267+ /// Cleared only when finalisation commits or an unstarted row is deleted.
269268 pub current_session_id : Option < SessionId > ,
269+ /// Persisted row associated with the transcript currently shown in the
270+ /// live view. Unlike the open handle, this remains after a successful
271+ /// finalisation so IPC/export metadata cannot drift to another session.
272+ pub linked_session_id : Option < SessionId > ,
270273 /// A failed storage operation that must be retried before the live
271274 /// transcript can be discarded or another session can start.
272275 pub pending_session_write : Option < PendingSessionWrite > ,
276+ /// Launch metadata retained until the transcript transaction commits.
277+ /// It lets a stop/retry create the database row if the initial `Started`
278+ /// update could not acquire or write storage.
279+ pub current_session_started_at : Option < DateTime < Utc > > ,
280+ pub current_session_dir_name : Option < String > ,
281+ /// Per-run audio directory. Retained with the transcript after a storage
282+ /// failure so a durable recovery snapshot can be written beside the WAVs.
283+ pub current_output_dir : Option < PathBuf > ,
273284 /// The session being viewed in `View::History`, kept around so the
274285 /// header can render its title without re-querying.
275286 pub viewed_session : Option < StoredSession > ,
@@ -288,7 +299,11 @@ impl AppModel {
288299 segments : Vec :: new ( ) ,
289300 library : Vec :: new ( ) ,
290301 current_session_id : None ,
302+ linked_session_id : None ,
291303 pending_session_write : None ,
304+ current_session_started_at : None ,
305+ current_session_dir_name : None ,
306+ current_output_dir : None ,
292307 viewed_session : None ,
293308 recent_log : VecDeque :: new ( ) ,
294309 last_error : None ,
@@ -313,6 +328,20 @@ impl AppModel {
313328 model
314329 }
315330
331+ /// Whether the live transcript still owns worker or persistence state.
332+ /// A retained database handle after a failed finalization is deliberately
333+ /// treated as unsettled so navigation cannot silently discard it.
334+ pub fn has_unsettled_session ( & self ) -> bool {
335+ self . state . is_active ( )
336+ || self . pending_session_write . is_some ( )
337+ || self . current_output_dir . is_some ( )
338+ }
339+
340+ /// A stopped worker whose transcript transaction needs to be retried.
341+ pub fn has_pending_persistence ( & self ) -> bool {
342+ matches ! ( self . state, SessionState :: Failed ) && self . pending_session_write . is_some ( )
343+ }
344+
316345 /// Replace the cached library list. Called after storage reads (launch,
317346 /// recording end, post-delete).
318347 pub fn set_library (
@@ -323,31 +352,42 @@ impl AppModel {
323352 }
324353
325354 /// Move to the library screen and drop any live/historical segments so
326- /// the next view enter starts from a clean slate.
355+ /// the next view enter starts from a clean slate. Navigation is ignored
356+ /// while a worker session is active; otherwise its future events could be
357+ /// attached to the library or to a historical transcript.
327358 pub fn show_library ( & mut self ) {
328- if self . live_session_is_protected ( ) {
359+ if self . has_unsettled_session ( ) {
329360 return ;
330361 }
331362 self . view = View :: Library ;
332363 self . segments . clear ( ) ;
333364 self . viewed_session = None ;
334365 self . current_session_id = None ;
366+ self . linked_session_id = None ;
335367 self . pending_session_write = None ;
368+ self . current_session_started_at = None ;
369+ self . current_session_dir_name = None ;
370+ self . current_output_dir = None ;
336371 self . last_error = None ;
337372 }
338373
339374 /// Move to the live recording screen in idle state. Used by the
340- /// library's "New Session" button.
375+ /// library's "New Session" button. An active session owns this view and
376+ /// cannot be replaced with a new one until it settles.
341377 pub fn show_new_session ( & mut self ) {
342- if self . live_session_is_protected ( ) {
378+ if self . has_unsettled_session ( ) {
343379 return ;
344380 }
345381 self . view = View :: LiveSession ;
346382 self . state = SessionState :: Idle ;
347383 self . segments . clear ( ) ;
348384 self . viewed_session = None ;
349385 self . current_session_id = None ;
386+ self . linked_session_id = None ;
350387 self . pending_session_write = None ;
388+ self . current_session_started_at = None ;
389+ self . current_session_dir_name = None ;
390+ self . current_output_dir = None ;
351391 self . last_error = None ;
352392 }
353393
@@ -358,7 +398,7 @@ impl AppModel {
358398 session : StoredSession ,
359399 segments : Vec < Segment > ,
360400 ) {
361- if self . live_session_is_protected ( ) {
401+ if self . has_unsettled_session ( ) {
362402 return ;
363403 }
364404 self . view = View :: History {
@@ -367,37 +407,35 @@ impl AppModel {
367407 self . segments = segments;
368408 self . viewed_session = Some ( session) ;
369409 self . current_session_id = None ;
410+ self . linked_session_id = None ;
370411 self . pending_session_write = None ;
412+ self . current_session_started_at = None ;
413+ self . current_session_dir_name = None ;
414+ self . current_output_dir = None ;
371415 // Historical segments are already finalized.
372416 self . finalize_all_segments ( ) ;
373417 for seg in & mut self . segments {
374418 seg. refresh_display ( ) ;
375419 }
376420 }
377421
378- /// Prepare a fresh recording while preserving the invariant that the
379- /// visible segments and `current_session_id` always describe the same
380- /// live session. This also makes menu/shortcut starts from Library or
381- /// History enter the live view before runner updates arrive.
382- pub fn begin_session ( & mut self ) {
383- if self . live_session_is_protected ( ) {
384- return ;
422+ /// Normalize any terminal screen to a fresh live transcript, then enter
423+ /// the Starting phase. Global shortcuts call this before enqueueing Start
424+ /// so Library/History can never remain the owner of live worker events.
425+ # [ must_use ]
426+ pub fn begin_session_start ( & mut self ) -> bool {
427+ if self . has_unsettled_session ( ) {
428+ return false ;
385429 }
386- self . view = View :: LiveSession ;
430+ self . show_new_session ( ) ;
387431 self . state = SessionState :: Starting ;
388- self . segments . clear ( ) ;
389- self . viewed_session = None ;
390- self . current_session_id = None ;
391- self . pending_session_write = None ;
392- self . last_error = None ;
432+ true
393433 }
394434
395- /// Whether leaving the live view could discard an in-flight or not-yet
396- /// persisted transcript. A failed finalisation keeps its session id so it
397- /// can be retried without mixing it into a subsequent recording.
398- #[ must_use]
399- pub fn live_session_is_protected ( & self ) -> bool {
400- self . state . is_active ( ) || self . pending_session_write . is_some ( )
435+ /// Compatibility wrapper for callers that do not need the acceptance
436+ /// result. Prefer `begin_session_start` before enqueueing worker commands.
437+ pub fn begin_session ( & mut self ) {
438+ let _ = self . begin_session_start ( ) ;
401439 }
402440
403441 pub fn set_state (
@@ -841,6 +879,50 @@ mod tests {
841879 assert ! ( m. needs_live_ui_tick( ) ) ;
842880 }
843881
882+ #[ test]
883+ fn active_session_rejects_top_level_navigation ( ) {
884+ let active_states = [
885+ SessionState :: Starting ,
886+ SessionState :: Recording {
887+ started_at : std:: time:: Instant :: now ( ) ,
888+ } ,
889+ SessionState :: Stopping ,
890+ ] ;
891+
892+ for state in active_states {
893+ let mut m = AppModel :: new ( ) ;
894+ m. show_new_session ( ) ;
895+ m. ingest ( Event :: Result ( r ( SourceLabel :: Mic , 1 , "live" ) ) ) ;
896+ m. current_session_id = Some ( SessionId :: from ( 42 ) ) ;
897+ m. state = state;
898+
899+ m. show_library ( ) ;
900+ m. show_new_session ( ) ;
901+ m. show_history ( stored_session ( 7 ) , Vec :: new ( ) ) ;
902+
903+ assert_eq ! ( m. view, View :: LiveSession ) ;
904+ assert_eq ! ( m. state, state) ;
905+ assert_eq ! ( m. current_session_id, Some ( SessionId :: from( 42 ) ) ) ;
906+ assert_eq ! ( m. segments. len( ) , 1 ) ;
907+ assert_eq ! ( m. segments[ 0 ] . text, "live" ) ;
908+ }
909+ }
910+
911+ #[ test]
912+ fn global_start_normalizes_history_to_a_fresh_live_view ( ) {
913+ let mut m = AppModel :: new ( ) ;
914+ m. show_history ( stored_session ( 7 ) , Vec :: new ( ) ) ;
915+ assert ! ( matches!( m. view, View :: History { .. } ) ) ;
916+
917+ assert ! ( m. begin_session_start( ) ) ;
918+
919+ assert_eq ! ( m. view, View :: LiveSession ) ;
920+ assert_eq ! ( m. state, SessionState :: Starting ) ;
921+ assert ! ( m. segments. is_empty( ) ) ;
922+ assert ! ( m. viewed_session. is_none( ) ) ;
923+ assert ! ( m. current_session_id. is_none( ) ) ;
924+ }
925+
844926 #[ test]
845927 fn log_buffer_is_bounded ( ) {
846928 let mut m = AppModel :: new ( ) ;
0 commit comments