diff --git a/crates/zx_document_core/src/session_v2.rs b/crates/zx_document_core/src/session_v2.rs index 84cfd9a..d86c191 100644 --- a/crates/zx_document_core/src/session_v2.rs +++ b/crates/zx_document_core/src/session_v2.rs @@ -22,6 +22,8 @@ pub enum ReadingSessionStatus { Active, Paused, Closed, + Interrupted, + Failed, } #[derive(Debug, Clone, uniffi::Record)] @@ -29,6 +31,7 @@ pub struct ReadingSessionV2 { pub client_session_id: String, pub material: ReadingMaterialRefV2, pub started_at_ms: i64, + pub closed_at_ms: Option, pub last_event_at_ms: i64, pub next_sequence: u64, pub total_active_seconds: u32, @@ -50,6 +53,7 @@ pub fn start_reading_session_v2( client_session_id: session_id.clone(), material, started_at_ms: timestamp_ms, + closed_at_ms: None, last_event_at_ms: timestamp_ms, next_sequence: 1, total_active_seconds: 0, @@ -113,6 +117,56 @@ pub fn close_reading_session_v2(session_id: &str) -> Result<(), SessionError> { }) } +/// Set closed_at_ms from the caller's timestamp after closing succeeds. +/// Separated from close_reading_session_v2 so the close status transition +/// is atomic and the timestamp is set independently. +pub fn set_session_closed_at_ms(session_id: &str, timestamp_ms: i64) -> Result<(), SessionError> { + with_session_mut(session_id, |s| { + s.closed_at_ms = Some(timestamp_ms); + Ok(()) + }) +} + +/// Mark a session as interrupted (app crash / background kill / orphaned). +pub fn mark_session_interrupted_v2(session_id: &str) -> Result<(), SessionError> { + with_session_mut(session_id, |s| { + if s.status == ReadingSessionStatus::Closed { + return Err(SessionError::AlreadyClosed); + } + if s.status == ReadingSessionStatus::Interrupted { + return Err(SessionError::AlreadyInterrupted); + } + s.status = ReadingSessionStatus::Interrupted; + Ok(()) + }) +} + +/// Mark a session as failed (unrecoverable error). +pub fn mark_session_failed_v2(session_id: &str) -> Result<(), SessionError> { + with_session_mut(session_id, |s| { + if s.status == ReadingSessionStatus::Closed { + return Err(SessionError::AlreadyClosed); + } + if s.status == ReadingSessionStatus::Failed { + return Err(SessionError::AlreadyFailed); + } + s.status = ReadingSessionStatus::Failed; + Ok(()) + }) +} + +/// Find the active session for a given material_id, if any. +pub fn get_active_session_v2(material_id: &str) -> Option { + match sessions().lock() { + Ok(map) => { + map.values() + .find(|s| s.material.material_id == material_id && s.status == ReadingSessionStatus::Active) + .cloned() + } + Err(_) => None, + } +} + /// Record an event on this session: bumps sequence and updates last_event_at_ms. /// Returns the sequence number that should be assigned to the event. pub fn record_session_event_v2( @@ -227,6 +281,8 @@ where pub enum SessionError { NotFound, AlreadyClosed, + AlreadyInterrupted, + AlreadyFailed, NotActive, NotClosed, LockPoisoned, @@ -237,6 +293,8 @@ impl std::fmt::Display for SessionError { match self { Self::NotFound => write!(f, "Session not found"), Self::AlreadyClosed => write!(f, "Session already closed"), + Self::AlreadyInterrupted => write!(f, "Session already interrupted"), + Self::AlreadyFailed => write!(f, "Session already failed"), Self::NotActive => write!(f, "Session is not active (paused or closed)"), Self::NotClosed => write!(f, "Session must be closed before removal"), Self::LockPoisoned => write!(f, "Session lock poisoned"), @@ -422,4 +480,87 @@ mod tests { teardown(&active); } + + // ── Interrupted / Failed / closed_at_ms / get_active_session ── + + #[test] + fn test_mark_interrupted() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + mark_session_interrupted_v2(&id).unwrap(); + assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Interrupted); + // Double mark should fail + assert!(mark_session_interrupted_v2(&id).is_err()); + teardown(&id); + } + + #[test] + fn test_mark_failed() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + mark_session_failed_v2(&id).unwrap(); + assert_eq!(get_session_v2(&id).unwrap().status, ReadingSessionStatus::Failed); + // Double mark should fail + assert!(mark_session_failed_v2(&id).is_err()); + teardown(&id); + } + + #[test] + fn test_closed_rejects_interrupted() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + close_reading_session_v2(&id).unwrap(); + assert!(mark_session_interrupted_v2(&id).is_err()); + assert!(mark_session_failed_v2(&id).is_err()); + remove_session_v2(&id).unwrap(); + } + + #[test] + fn test_set_closed_at_ms() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + assert!(get_session_v2(&id).unwrap().closed_at_ms.is_none()); + close_reading_session_v2(&id).unwrap(); + set_session_closed_at_ms(&id, 5000).unwrap(); + assert_eq!(get_session_v2(&id).unwrap().closed_at_ms, Some(5000)); + remove_session_v2(&id).unwrap(); + } + + #[test] + fn test_get_active_session_by_material() { + let mat_a = ReadingMaterialRefV2::new("mat_a", "/tmp/a.md", "a.md", "markdown"); + let mat_b = ReadingMaterialRefV2::new("mat_b", "/tmp/b.md", "b.md", "markdown"); + + let id_a = start_reading_session_v2(mat_a.clone(), 1000).unwrap(); + let _id_b = start_reading_session_v2(mat_b.clone(), 1000).unwrap(); + + let found = get_active_session_v2("mat_a"); + assert!(found.is_some()); + assert_eq!(found.unwrap().client_session_id, id_a); + + // Close session a, should not be found as active + close_reading_session_v2(&id_a).unwrap(); + assert!(get_active_session_v2("mat_a").is_none()); + + teardown(&id_a); + } + + #[test] + fn test_session_does_not_store_business_fields() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + let s = get_session_v2(&id).unwrap(); + // Verify the material ref has no business fields + assert_eq!(s.material.material_id, "test_mat_001"); + // Session itself has no userId / knowledgeBaseId / readingTargetType fields + // (verified at compile time — these fields don't exist on the struct) + teardown(&id); + } + + #[test] + fn test_sequence_monotonic_in_session() { + let id = start_reading_session_v2(test_material(), 1000).unwrap(); + let s1 = record_session_event_v2(&id, 2000, 0, None).unwrap(); + let s2 = record_session_event_v2(&id, 3000, 0, None).unwrap(); + let s3 = record_session_event_v2(&id, 4000, 0, None).unwrap(); + assert!(s1 < s2); + assert!(s2 < s3); + assert_eq!(s3, 3); + teardown(&id); + } } diff --git a/crates/zx_document_ffi/src/lib.rs b/crates/zx_document_ffi/src/lib.rs index ae8a3f7..0e0529f 100644 --- a/crates/zx_document_ffi/src/lib.rs +++ b/crates/zx_document_ffi/src/lib.rs @@ -45,6 +45,31 @@ fn close_reading_session_v2(session_id: String) -> Result<(), String> { zx_document_core::session_v2::close_reading_session_v2(&session_id).map_err(|e| e.to_string()) } +#[uniffi::export] +fn set_session_closed_at_ms_v2(session_id: String, timestamp_ms: i64) -> Result<(), String> { + zx_document_core::session_v2::set_session_closed_at_ms(&session_id, timestamp_ms).map_err(|e| e.to_string()) +} + +#[uniffi::export] +fn mark_session_interrupted_v2(session_id: String) -> Result<(), String> { + zx_document_core::session_v2::mark_session_interrupted_v2(&session_id).map_err(|e| e.to_string()) +} + +#[uniffi::export] +fn mark_session_failed_v2(session_id: String) -> Result<(), String> { + zx_document_core::session_v2::mark_session_failed_v2(&session_id).map_err(|e| e.to_string()) +} + +#[uniffi::export] +fn get_active_session_v2(material_id: String) -> Option { + zx_document_core::session_v2::get_active_session_v2(&material_id) +} + +#[uniffi::export] +fn get_session_v2_ffi(session_id: String) -> Option { + zx_document_core::session_v2::get_session_v2(&session_id).ok() +} + // ── V2 Reading Event FFI ── #[uniffi::export]