diff --git a/src/session.rs b/src/session.rs index c380728..2afaf7a 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1 +1,286 @@ -// Placeholder for upcoming session module. +use crate::domain::{AllowedContext, Commitment, EnforcementLevel, PolicySnapshot, RuntimeState}; +use crate::event_log::{EventLog, EventType}; +use crate::state_machine::{ + self, transition_commitment, transition_runtime, CommitmentAction, RuntimeAction, +}; +use anyhow::{anyhow, Context, Result}; +use serde_json::json; +use std::path::Path; +use std::time::Duration; + +#[derive(Debug)] +pub struct SessionController { + runtime_state: RuntimeState, + active_commitment: Option, + active_policy: Option, + event_log: EventLog, +} + +impl SessionController { + pub fn new(event_log_path: impl AsRef) -> Result { + let event_log = EventLog::open(event_log_path.as_ref()).with_context(|| { + format!( + "open session event log at {}", + event_log_path.as_ref().display() + ) + })?; + + Ok(Self { + runtime_state: RuntimeState::Locked, + active_commitment: None, + active_policy: None, + event_log, + }) + } + + pub fn runtime_state(&self) -> RuntimeState { + self.runtime_state + } + + pub fn active_policy(&self) -> Option<&PolicySnapshot> { + self.active_policy.as_ref() + } + + pub fn active_commitment(&self) -> Option<&Commitment> { + self.active_commitment.as_ref() + } + + pub fn enter_planning(&mut self) -> Result<()> { + let previous_runtime_state = self.runtime_state; + let next_runtime_state = + transition_runtime(previous_runtime_state, RuntimeAction::EnterPlanning) + .context("enter planning runtime transition")?; + + self.event_log + .append( + EventType::RuntimeTransition, + next_runtime_state, + self.active_commitment + .as_ref() + .map(|commitment| commitment.id.clone()), + json!({ + "from": previous_runtime_state, + "to": next_runtime_state, + "action": "enter_planning", + }), + ) + .context("log runtime transition into planning")?; + + self.runtime_state = next_runtime_state; + Ok(()) + } + + pub fn start_manual_commitment( + &mut self, + next_action: impl Into, + success_condition: impl Into, + timebox: Duration, + ) -> Result<()> { + let previous_runtime_state = self.runtime_state; + let next_runtime_state = transition_runtime( + previous_runtime_state, + RuntimeAction::Activate { + policy_accepted: true, + }, + ) + .context("activate runtime for manual commitment")?; + + let mut commitment = Commitment::new_manual(next_action, success_condition, timebox) + .context("create manual commitment")?; + commitment.state = + transition_commitment(commitment.state.clone(), CommitmentAction::Activate) + .context("activate manual commitment")?; + + let policy = PolicySnapshot::try_for_runtime( + commitment.id.clone(), + next_runtime_state, + EnforcementLevel::Warn, + AllowedContext::default(), + ) + .context("create active policy snapshot")?; + + self.event_log + .append( + EventType::CommitmentCreated, + next_runtime_state, + Some(commitment.id.clone()), + serde_json::to_value(&commitment).context("serialize commitment payload")?, + ) + .context("log commitment creation")?; + self.event_log + .append( + EventType::PolicyApplied, + next_runtime_state, + Some(commitment.id.clone()), + serde_json::to_value(&policy).context("serialize policy payload")?, + ) + .context("log policy application")?; + + self.runtime_state = next_runtime_state; + self.active_commitment = Some(commitment); + self.active_policy = Some(policy); + Ok(()) + } + + pub fn start_transition( + &mut self, + reason: impl Into, + return_target: impl Into, + expected_duration: Duration, + ) -> Result<()> { + let reason = reason.into().trim().to_string(); + if reason.is_empty() { + return Err(anyhow!("transition reason is required")); + } + + let return_target = return_target.into().trim().to_string(); + if return_target.is_empty() { + return Err(anyhow!("transition return target is required")); + } + + if expected_duration.is_zero() { + return Err(anyhow!("transition expected duration must be nonzero")); + } + + let active_commitment = self + .active_commitment + .as_ref() + .ok_or_else(|| anyhow!("active commitment is required to start transition"))?; + let composite = + state_machine::start_transition(self.runtime_state, active_commitment.state.clone()) + .context("start transition state change")?; + + let previous_commitment_state = active_commitment.state.clone(); + let next_commitment_state = composite.commitment_state.clone(); + let mut next_commitment = active_commitment.clone(); + next_commitment.state = next_commitment_state.clone(); + + self.event_log + .append( + EventType::TransitionStarted, + composite.runtime_state, + Some(next_commitment.id.clone()), + json!({ + "reason": reason, + "return_target": return_target, + "expected_duration_secs": expected_duration.as_secs(), + "runtime_state": { + "from": self.runtime_state, + "to": composite.runtime_state, + }, + "commitment_state": { + "from": previous_commitment_state, + "to": next_commitment_state, + }, + }), + ) + .context("log transition start")?; + + self.runtime_state = composite.runtime_state; + self.active_commitment = Some(next_commitment); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use crate::domain::{EnforcementLevel, RuntimeState}; + use crate::event_log::{EventRecord, EventType}; + use crate::session::SessionController; + use std::fs; + use std::path::PathBuf; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + fn temp_log_path(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "antidrift-session-{name}-{}-{nonce}.jsonl", + std::process::id() + )) + } + + fn read_records(path: &PathBuf) -> Vec { + fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + #[test] + fn starts_commitment_and_emits_policy() { + let path = temp_log_path("start"); + 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(); + + assert_eq!(session.runtime_state(), RuntimeState::Active); + assert_eq!( + session.active_policy().unwrap().enforcement_level, + EnforcementLevel::Warn + ); + assert!(session.active_commitment().is_some()); + + let records = read_records(&path); + assert!(records + .iter() + .any(|record| record.event_type == EventType::CommitmentCreated)); + assert!(records + .iter() + .any(|record| record.event_type == EventType::PolicyApplied)); + + fs::remove_file(path).unwrap(); + } + + #[test] + fn transition_requires_reason_and_return_target() { + let path = temp_log_path("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(); + + let err = session + .start_transition("", "review test results", Duration::from_secs(300)) + .expect_err("empty reason must fail"); + assert!(err.to_string().contains("reason")); + assert_eq!(session.runtime_state(), RuntimeState::Active); + + let err = session + .start_transition("Need to inspect failures", "", Duration::from_secs(300)) + .expect_err("empty return target must fail"); + assert!(err.to_string().contains("return target")); + assert_eq!(session.runtime_state(), RuntimeState::Active); + + session + .start_transition( + "Need to inspect failures", + "Return to session controller", + Duration::from_secs(300), + ) + .unwrap(); + + assert_eq!(session.runtime_state(), RuntimeState::Transition); + assert_eq!( + session.active_commitment().unwrap().state, + crate::domain::CommitmentState::Paused + ); + + fs::remove_file(path).unwrap(); + } +}