Add session controller
This commit is contained in:
+286
-1
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user