diff --git a/src/main.rs b/src/main.rs index e08de57..d2c29b0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; // <--- Add this mod constants; use antidrift::{ - domain::{unix_secs_now, EvidenceHealth, RuntimeState}, + domain::{EvidenceHealth, RuntimeState}, session::SessionController, window, }; @@ -164,9 +164,9 @@ impl App { .as_ref() .map(|commitment| (commitment.timebox_secs / 60).to_string()) .unwrap_or_else(|| constants::DEFAULT_DURATION.to_string()); - let restored_elapsed = active_commitment - .as_ref() - .map(|commitment| restored_commitment_elapsed(commitment.created_at_unix_secs)) + let restored_elapsed = session_controller + .timing_summary()? + .map(|summary| Duration::from_secs(summary.active_elapsed_secs)) .unwrap_or(Duration::ZERO); let restored_elapsed_capped = restored_elapsed.min(user_duration); let now = Instant::now(); @@ -594,10 +594,6 @@ fn update_session_stats(app: &mut App) { } } -fn restored_commitment_elapsed(created_at_unix_secs: u64) -> Duration { - Duration::from_secs(unix_secs_now().saturating_sub(created_at_unix_secs)) -} - fn ui(frame: &mut Frame, app: &App) { let layout = Layout::vertical([ Constraint::Min(3), @@ -836,6 +832,64 @@ mod tests { let _ = fs::remove_file(path); } + #[test] + fn hydrated_active_session_excludes_completed_pause_time() { + let path = unique_event_log_path("hydrated-active-after-pause-timer"); + { + let mut app = App::new_with_event_log_path(&path).unwrap(); + app.user_intention = "write tests".to_string(); + app.user_success_condition = "tests pass".to_string(); + app.user_duration_str = "1".to_string(); + app.to_in_progress().unwrap(); + std::thread::sleep(Duration::from_millis(1200)); + app.pause().unwrap(); + std::thread::sleep(Duration::from_millis(2200)); + app.resume().unwrap(); + } + + let app = App::new_with_event_log_path(&path).unwrap(); + + assert_eq!(app.state, State::InProgress); + assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); + assert!( + app.session_remaining >= Duration::from_secs(58), + "completed pause time should not reduce remaining time, got {:?}", + app.session_remaining + ); + + let _ = fs::remove_file(path); + } + + #[test] + fn hydrated_transition_session_freezes_remaining_at_pause_start() { + let path = unique_event_log_path("hydrated-open-pause-timer"); + { + let mut app = App::new_with_event_log_path(&path).unwrap(); + app.user_intention = "write tests".to_string(); + app.user_success_condition = "tests pass".to_string(); + app.user_duration_str = "1".to_string(); + app.to_in_progress().unwrap(); + std::thread::sleep(Duration::from_millis(1200)); + app.pause().unwrap(); + std::thread::sleep(Duration::from_millis(2200)); + } + + let app = App::new_with_event_log_path(&path).unwrap(); + + assert_eq!(app.state, State::Paused); + assert_eq!( + app.session_controller.runtime_state(), + RuntimeState::Transition + ); + assert!( + app.session_remaining >= Duration::from_secs(58), + "open pause time should not reduce remaining time, got {:?}", + app.session_remaining + ); + + let _ = fs::remove_file(path); + } + #[test] fn hydrated_transition_can_resume_end_and_return_to_planning() { let path = unique_event_log_path("hydrated-transition-resume"); diff --git a/src/session.rs b/src/session.rs index 6521e2d..244eab7 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,5 +1,5 @@ use crate::domain::{ - AllowedContext, Commitment, CommitmentSource, CommitmentState, EnforcementLevel, + unix_secs_now, AllowedContext, Commitment, CommitmentSource, CommitmentState, EnforcementLevel, PolicySnapshot, RuntimeState, POLICY_SCHEMA_VERSION, }; use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord}; @@ -77,6 +77,12 @@ struct PendingTransitionPolicy { allowed_context: AllowedContext, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SessionTimingSummary { + pub active_elapsed_secs: u64, + pub currently_in_transition: bool, +} + #[derive(Debug)] pub struct SessionController { runtime_state: RuntimeState, @@ -116,6 +122,92 @@ impl SessionController { self.active_commitment.as_ref() } + pub fn timing_summary(&self) -> Result> { + let Some(commitment) = self.active_commitment.as_ref() else { + return Ok(None); + }; + self.timing_summary_at(commitment, unix_secs_now()) + } + + fn timing_summary_at( + &self, + commitment: &Commitment, + now_unix_secs: u64, + ) -> Result> { + let commitment_id = commitment.id.as_str(); + let mut active_elapsed_secs = 0_u64; + let mut active_started_at: Option = None; + let mut open_transition_started_at: Option = None; + let mut saw_commitment = false; + + for record in self.event_log.records()? { + if record.commitment_id.as_deref() != Some(commitment_id) { + continue; + } + + match record.event_type { + EventType::CommitmentCreated => { + let payload: CommitmentCreatedPayload = + parse_payload(&record, "commitment created")?; + if payload.commitment_id == commitment_id { + active_started_at = Some(record.timestamp_unix_secs); + open_transition_started_at = None; + saw_commitment = true; + } + } + EventType::TransitionStarted => { + let payload: TransitionStartedPayload = + parse_payload(&record, "transition started")?; + if payload.commitment_id == commitment_id + && open_transition_started_at.is_none() + { + if let Some(started_at) = active_started_at.take() { + active_elapsed_secs = active_elapsed_secs.saturating_add( + record.timestamp_unix_secs.saturating_sub(started_at), + ); + } + open_transition_started_at = Some(record.timestamp_unix_secs); + } + } + EventType::RuntimeTransition => { + let payload: RuntimeTransitionPayload = + parse_payload(&record, "runtime transition")?; + if payload.action == "return_to_active" + && open_transition_started_at.take().is_some() + { + active_started_at = Some(record.timestamp_unix_secs); + } + } + EventType::CommitmentTransition => { + let payload: CommitmentTransitionPayload = + parse_payload(&record, "commitment transition")?; + if payload.commitment_id == commitment_id + && payload.action == "return_from_pause" + && open_transition_started_at.take().is_some() + { + active_started_at = Some(record.timestamp_unix_secs); + } + } + _ => {} + } + } + + if !saw_commitment { + return Ok(None); + } + + if let Some(started_at) = active_started_at { + active_elapsed_secs = + active_elapsed_secs.saturating_add(now_unix_secs.saturating_sub(started_at)); + } + + Ok(Some(SessionTimingSummary { + active_elapsed_secs, + currently_in_transition: self.runtime_state == RuntimeState::Transition + && open_transition_started_at.is_some(), + })) + } + pub fn enter_planning(&mut self) -> Result<()> { let previous_runtime_state = self.runtime_state; let next_runtime_state = @@ -908,8 +1000,8 @@ mod tests { TransitionStartedPayload, }; use crate::domain::{ - AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, RuntimeState, - POLICY_SCHEMA_VERSION, + unix_secs_now, AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, + RuntimeState, POLICY_SCHEMA_VERSION, }; use crate::event_log::{EventLog, EventRecord, EventType, PendingEventRecord}; use crate::session::SessionController; @@ -1885,4 +1977,83 @@ mod tests { fs::remove_file(path).unwrap(); } + + #[test] + fn timing_summary_excludes_completed_transition_interval() { + let path = temp_log_path("timing-completed-transition"); + let mut session = SessionController::new(&path).unwrap(); + session.enter_planning().unwrap(); + session + .start_manual_commitment( + "Implement session controller", + "session tests pass", + Duration::from_secs(1500), + ) + .unwrap(); + + std::thread::sleep(Duration::from_millis(1200)); + session + .start_transition( + "Need to inspect failures", + "Return to session controller", + Duration::from_secs(300), + ) + .unwrap(); + std::thread::sleep(Duration::from_millis(2200)); + session.return_from_transition().unwrap(); + + let summary = session.timing_summary().unwrap().unwrap(); + let created_at = session.active_commitment().unwrap().created_at_unix_secs; + let wall_elapsed = unix_secs_now().saturating_sub(created_at); + + assert_eq!(session.runtime_state(), RuntimeState::Active); + assert!(!summary.currently_in_transition); + assert!(wall_elapsed >= 3); + assert!( + summary.active_elapsed_secs <= 2, + "active elapsed should exclude completed transition time, got {}", + summary.active_elapsed_secs + ); + + fs::remove_file(path).unwrap(); + } + + #[test] + fn timing_summary_freezes_elapsed_during_open_transition() { + let path = temp_log_path("timing-open-transition"); + let mut session = SessionController::new(&path).unwrap(); + session.enter_planning().unwrap(); + session + .start_manual_commitment( + "Implement session controller", + "session tests pass", + Duration::from_secs(1500), + ) + .unwrap(); + + std::thread::sleep(Duration::from_millis(1200)); + session + .start_transition( + "Need to inspect failures", + "Return to session controller", + Duration::from_secs(300), + ) + .unwrap(); + std::thread::sleep(Duration::from_millis(2200)); + + let summary = session.timing_summary().unwrap().unwrap(); + let created_at = session.active_commitment().unwrap().created_at_unix_secs; + let wall_elapsed = unix_secs_now().saturating_sub(created_at); + + assert_eq!(session.runtime_state(), RuntimeState::Transition); + assert!(summary.currently_in_transition); + assert!(wall_elapsed >= 3); + assert!( + summary.active_elapsed_secs <= 2, + "active elapsed should freeze at transition start, got {}", + summary.active_elapsed_secs + ); + + fs::remove_file(path).unwrap(); + } }