diff --git a/src/main.rs b/src/main.rs index aaf60b1..e08de57 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::{EvidenceHealth, RuntimeState}, + domain::{unix_secs_now, EvidenceHealth, RuntimeState}, session::SessionController, window, }; @@ -164,6 +164,14 @@ 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)) + .unwrap_or(Duration::ZERO); + let restored_elapsed_capped = restored_elapsed.min(user_duration); + let now = Instant::now(); + let session_start = now.checked_sub(restored_elapsed_capped).unwrap_or(now); + let session_remaining = user_duration.saturating_sub(restored_elapsed); Ok(App { state, @@ -174,15 +182,15 @@ impl App { current_window_title: window_snapshot.title.into(), evidence_health: window_snapshot.health, session_controller, - current_window_time: Instant::now(), - session_start: Instant::now(), + current_window_time: now, + session_start, session_stats: HashMap::new(), - session_remaining: user_duration, + session_remaining, session_ratings: Vec::new(), session_ratings_index: 0, session_results: Vec::new(), - last_tick_50ms: Instant::now(), - last_tick_1s: Instant::now(), + last_tick_50ms: now, + last_tick_1s: now, }) } @@ -230,9 +238,36 @@ impl App { Ok(()) } - fn to_end(&mut self) -> Result<()> { + fn pause(&mut self) -> Result<()> { if self.session_controller.runtime_state() == RuntimeState::Active { - self.session_controller.complete_for_review()?; + self.session_controller.start_transition( + "legacy pause", + self.user_intention.clone(), + Duration::from_secs(60), + )?; + } + self.state = State::Paused; + Ok(()) + } + + fn resume(&mut self) -> Result<()> { + if self.session_controller.runtime_state() == RuntimeState::Transition { + self.session_controller.return_from_transition()?; + } + self.state = State::InProgress; + Ok(()) + } + + fn to_end(&mut self) -> Result<()> { + match self.session_controller.runtime_state() { + RuntimeState::Transition => { + self.session_controller.return_from_transition()?; + self.session_controller.complete_for_review()?; + } + RuntimeState::Active => { + self.session_controller.complete_for_review()?; + } + _ => {} } self.state = State::End; self.session_ratings = session_stats_as_vec(&self.session_stats); @@ -498,7 +533,7 @@ fn handle_events(app: &mut App) -> Result<()> { app.to_end()?; } KeyCode::Char('p') => { - app.state = State::Paused; + app.pause()?; } KeyCode::Char('a') => { app.user_duration = app.user_duration.saturating_add(Duration::from_secs(60)); @@ -510,7 +545,7 @@ fn handle_events(app: &mut App) -> Result<()> { }, State::Paused => { if key.code == KeyCode::Char('p') { - app.state = State::InProgress; + app.resume()?; } } State::ShouldQuit => (), @@ -559,6 +594,10 @@ 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), @@ -773,4 +812,64 @@ mod tests { let _ = fs::remove_file(path); } + + #[test] + fn hydrated_active_session_keeps_elapsed_time() { + let path = unique_event_log_path("hydrated-active-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(1100)); + + 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_start.elapsed() >= Duration::from_secs(1)); + assert!(app.session_remaining <= Duration::from_secs(59)); + + 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"); + { + 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(); + app.pause().unwrap(); + } + + let mut app = App::new_with_event_log_path(&path).unwrap(); + assert_eq!(app.state, State::Paused); + assert_eq!( + app.session_controller.runtime_state(), + RuntimeState::Transition + ); + + app.resume().unwrap(); + assert_eq!(app.state, State::InProgress); + assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active); + + app.to_end().unwrap(); + assert_eq!(app.state, State::End); + assert_eq!(app.session_controller.runtime_state(), RuntimeState::Review); + + app.complete_rating().unwrap(); + assert_eq!(app.state, State::InputIntention); + assert_eq!( + app.session_controller.runtime_state(), + RuntimeState::Planning + ); + + let _ = fs::remove_file(path); + } } diff --git a/src/session.rs b/src/session.rs index dc92d28..6521e2d 100644 --- a/src/session.rs +++ b/src/session.rs @@ -20,6 +20,16 @@ struct RuntimeTransitionPayload { to: RuntimeState, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct CommitmentTransitionPayload { + schema_version: u16, + commitment_id: String, + action: String, + from: CommitmentState, + to: CommitmentState, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct CommitmentCreatedPayload { @@ -337,6 +347,90 @@ impl SessionController { self.active_policy = Some(transition_policy); Ok(()) } + + pub fn return_from_transition(&mut self) -> Result<()> { + let active_commitment = self + .active_commitment + .as_ref() + .ok_or_else(|| anyhow!("active commitment is required to return from transition"))?; + let active_policy = self + .active_policy + .as_ref() + .ok_or_else(|| anyhow!("active policy is required to return from transition"))?; + + let previous_runtime_state = self.runtime_state; + let next_runtime_state = + transition_runtime(previous_runtime_state, RuntimeAction::ReturnToActive) + .context("return from transition runtime transition")?; + + let previous_commitment_state = active_commitment.state.clone(); + let next_commitment_state = transition_commitment( + previous_commitment_state.clone(), + CommitmentAction::ReturnFromPause, + ) + .context("return paused commitment to active")?; + + let mut next_commitment = active_commitment.clone(); + next_commitment.state = next_commitment_state.clone(); + next_commitment.transition_policy_id = None; + + let active_policy = PolicySnapshot::try_for_runtime( + next_commitment.id.clone(), + next_runtime_state, + active_policy.enforcement_level, + active_policy.allowed_context.clone(), + ) + .context("create returned active policy snapshot")?; + + self.event_log + .append_batch([ + PendingEventRecord { + event_type: EventType::RuntimeTransition, + runtime_state: next_runtime_state, + commitment_id: Some(next_commitment.id.clone()), + payload_json: serde_json::to_value(RuntimeTransitionPayload { + schema_version: POLICY_SCHEMA_VERSION, + action: "return_to_active".to_string(), + from: previous_runtime_state, + to: next_runtime_state, + }) + .context("serialize return runtime transition payload")?, + }, + PendingEventRecord { + event_type: EventType::CommitmentTransition, + runtime_state: next_runtime_state, + commitment_id: Some(next_commitment.id.clone()), + payload_json: serde_json::to_value(CommitmentTransitionPayload { + schema_version: POLICY_SCHEMA_VERSION, + commitment_id: next_commitment.id.clone(), + action: "return_from_pause".to_string(), + from: previous_commitment_state, + to: next_commitment_state, + }) + .context("serialize return commitment transition payload")?, + }, + PendingEventRecord { + event_type: EventType::PolicyApplied, + runtime_state: next_runtime_state, + commitment_id: Some(next_commitment.id.clone()), + payload_json: serde_json::to_value(PolicyAppliedPayload { + schema_version: POLICY_SCHEMA_VERSION, + policy_id: active_policy.id.clone(), + commitment_id: active_policy.commitment_id.clone(), + runtime_state: active_policy.runtime_state, + enforcement_level: active_policy.enforcement_level, + allowed_context: active_policy.allowed_context.clone(), + }) + .context("serialize returned active policy payload")?, + }, + ]) + .context("log return from transition")?; + + self.runtime_state = next_runtime_state; + self.active_commitment = Some(next_commitment); + self.active_policy = Some(active_policy); + Ok(()) + } } fn hydrate_session( @@ -371,6 +465,7 @@ fn hydrate_session( let action = match payload.action.as_str() { "enter_planning" => RuntimeAction::EnterPlanning, "complete_for_review" => RuntimeAction::CompleteForReview, + "return_to_active" => RuntimeAction::ReturnToActive, "return_to_planning_after_review" => RuntimeAction::ContinuePlanning, _ => { return Err(anyhow!( @@ -398,6 +493,64 @@ fn hydrate_session( active_policy = None; } } + EventType::CommitmentTransition => { + let payload: CommitmentTransitionPayload = + parse_payload(&record, "commitment transition")?; + ensure_schema_version(payload.schema_version, record.sequence)?; + ensure_record_commitment(&record, &payload.commitment_id)?; + if record.runtime_state != runtime_state { + return Err(anyhow!( + "commitment transition runtime mismatch at sequence {}", + record.sequence + )); + } + let commitment = active_commitment.as_mut().ok_or_else(|| { + anyhow!( + "commitment transition references unknown commitment '{}' at sequence {}", + payload.commitment_id, + record.sequence + ) + })?; + if commitment.id != payload.commitment_id { + return Err(anyhow!( + "commitment transition commitment mismatch at sequence {}", + record.sequence + )); + } + if commitment.state != payload.from { + return Err(anyhow!( + "commitment transition source mismatch at sequence {}", + record.sequence + )); + } + let action = match payload.action.as_str() { + "return_from_pause" => CommitmentAction::ReturnFromPause, + _ => { + return Err(anyhow!( + "unsupported commitment transition action '{}' at sequence {}", + payload.action, + record.sequence + )); + } + }; + let next = + transition_commitment(commitment.state.clone(), action).with_context(|| { + format!( + "replay commitment transition at sequence {}", + record.sequence + ) + })?; + if payload.to != next { + return Err(anyhow!( + "commitment transition target mismatch at sequence {}", + record.sequence + )); + } + commitment.state = next; + if commitment.state == CommitmentState::Active { + commitment.transition_policy_id = None; + } + } EventType::CommitmentCreated => { let payload: CommitmentCreatedPayload = parse_payload(&record, "commitment created")?; @@ -472,16 +625,21 @@ fn hydrate_session( record.sequence )); } - let next = match commitment.state { - CommitmentState::Active => transition_runtime( - runtime_state, - RuntimeAction::Activate { - policy_accepted: true, - }, - ) - .with_context(|| { - format!("replay policy application at sequence {}", record.sequence) - })?, + let next = match (runtime_state, &commitment.state, payload.runtime_state) { + (RuntimeState::Planning, CommitmentState::Active, RuntimeState::Active) => { + transition_runtime( + runtime_state, + RuntimeAction::Activate { + policy_accepted: true, + }, + ) + .with_context(|| { + format!("replay policy application at sequence {}", record.sequence) + })? + } + (RuntimeState::Active, CommitmentState::Active, RuntimeState::Active) => { + RuntimeState::Active + } _ => { return Err(anyhow!( "policy requires active commitment at sequence {}", @@ -1381,6 +1539,58 @@ mod tests { fs::remove_file(path).unwrap(); } + #[test] + fn returns_from_transition_and_replays_active_state() { + let path = temp_log_path("return-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(); + session + .start_transition( + "Need to inspect failures", + "Return to session controller", + Duration::from_secs(300), + ) + .unwrap(); + session.return_from_transition().unwrap(); + + assert_eq!(session.runtime_state(), RuntimeState::Active); + assert_eq!( + session.active_commitment().unwrap().state, + CommitmentState::Active + ); + assert_eq!( + session.active_policy().unwrap().runtime_state, + RuntimeState::Active + ); + } + + let reopened = SessionController::new(&path).unwrap(); + assert_eq!(reopened.runtime_state(), RuntimeState::Active); + assert_eq!( + reopened.active_commitment().unwrap().state, + CommitmentState::Active + ); + assert_eq!( + reopened.active_policy().unwrap().runtime_state, + RuntimeState::Active + ); + + let records = read_records(&path); + assert!(records + .iter() + .any(|record| record.event_type == EventType::CommitmentTransition)); + + fs::remove_file(path).unwrap(); + } + #[test] fn rejects_transition_started_without_policy_on_replay() { let path = temp_log_path("transition-without-policy");