Add session controller

This commit is contained in:
2026-05-25 15:12:48 -04:00
parent c02002825d
commit aebebade87
+286 -1
View File
@@ -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<Commitment>,
active_policy: Option<PolicySnapshot>,
event_log: EventLog,
}
impl SessionController {
pub fn new(event_log_path: impl AsRef<Path>) -> Result<Self> {
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<String>,
success_condition: impl Into<String>,
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<String>,
return_target: impl Into<String>,
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<EventRecord> {
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();
}
}