Return controller to planning after review
This commit is contained in:
+142
-12
@@ -133,6 +133,64 @@ impl SessionController {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn complete_for_review(&mut self) -> Result<()> {
|
||||
let previous_runtime_state = self.runtime_state;
|
||||
let next_runtime_state =
|
||||
transition_runtime(previous_runtime_state, RuntimeAction::CompleteForReview)
|
||||
.context("complete active session for review runtime transition")?;
|
||||
let commitment_id = self
|
||||
.active_commitment
|
||||
.as_ref()
|
||||
.map(|commitment| commitment.id.clone());
|
||||
|
||||
self.event_log
|
||||
.append(
|
||||
EventType::RuntimeTransition,
|
||||
next_runtime_state,
|
||||
commitment_id,
|
||||
serde_json::to_value(RuntimeTransitionPayload {
|
||||
schema_version: POLICY_SCHEMA_VERSION,
|
||||
action: "complete_for_review".to_string(),
|
||||
from: previous_runtime_state,
|
||||
to: next_runtime_state,
|
||||
})
|
||||
.context("serialize runtime transition payload")?,
|
||||
)
|
||||
.context("log runtime transition into review")?;
|
||||
|
||||
self.runtime_state = next_runtime_state;
|
||||
self.active_commitment = None;
|
||||
self.active_policy = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn return_to_planning_after_review(&mut self) -> Result<()> {
|
||||
let previous_runtime_state = self.runtime_state;
|
||||
let next_runtime_state =
|
||||
transition_runtime(previous_runtime_state, RuntimeAction::ContinuePlanning)
|
||||
.context("continue planning after review runtime transition")?;
|
||||
|
||||
self.event_log
|
||||
.append(
|
||||
EventType::RuntimeTransition,
|
||||
next_runtime_state,
|
||||
None,
|
||||
serde_json::to_value(RuntimeTransitionPayload {
|
||||
schema_version: POLICY_SCHEMA_VERSION,
|
||||
action: "return_to_planning_after_review".to_string(),
|
||||
from: previous_runtime_state,
|
||||
to: next_runtime_state,
|
||||
})
|
||||
.context("serialize runtime transition payload")?,
|
||||
)
|
||||
.context("log runtime transition from review to planning")?;
|
||||
|
||||
self.runtime_state = next_runtime_state;
|
||||
self.active_commitment = None;
|
||||
self.active_policy = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start_manual_commitment(
|
||||
&mut self,
|
||||
next_action: impl Into<String>,
|
||||
@@ -302,13 +360,6 @@ fn hydrate_session(
|
||||
let payload: RuntimeTransitionPayload =
|
||||
parse_payload(&record, "runtime transition")?;
|
||||
ensure_schema_version(payload.schema_version, record.sequence)?;
|
||||
if payload.action != "enter_planning" {
|
||||
return Err(anyhow!(
|
||||
"unsupported runtime transition action '{}' at sequence {}",
|
||||
payload.action,
|
||||
record.sequence
|
||||
));
|
||||
}
|
||||
if payload.from != runtime_state {
|
||||
return Err(anyhow!(
|
||||
"runtime transition source mismatch at sequence {}: expected {:?}, found {:?}",
|
||||
@@ -317,10 +368,21 @@ fn hydrate_session(
|
||||
payload.from
|
||||
));
|
||||
}
|
||||
let next = transition_runtime(runtime_state, RuntimeAction::EnterPlanning)
|
||||
.with_context(|| {
|
||||
format!("replay runtime transition at sequence {}", record.sequence)
|
||||
})?;
|
||||
let action = match payload.action.as_str() {
|
||||
"enter_planning" => RuntimeAction::EnterPlanning,
|
||||
"complete_for_review" => RuntimeAction::CompleteForReview,
|
||||
"return_to_planning_after_review" => RuntimeAction::ContinuePlanning,
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"unsupported runtime transition action '{}' at sequence {}",
|
||||
payload.action,
|
||||
record.sequence
|
||||
));
|
||||
}
|
||||
};
|
||||
let next = transition_runtime(runtime_state, action).with_context(|| {
|
||||
format!("replay runtime transition at sequence {}", record.sequence)
|
||||
})?;
|
||||
if payload.to != next || record.runtime_state != next {
|
||||
return Err(anyhow!(
|
||||
"runtime transition target mismatch at sequence {}",
|
||||
@@ -328,6 +390,13 @@ fn hydrate_session(
|
||||
));
|
||||
}
|
||||
runtime_state = next;
|
||||
if matches!(
|
||||
action,
|
||||
RuntimeAction::CompleteForReview | RuntimeAction::ContinuePlanning
|
||||
) {
|
||||
active_commitment = None;
|
||||
active_policy = None;
|
||||
}
|
||||
}
|
||||
EventType::CommitmentCreated => {
|
||||
let payload: CommitmentCreatedPayload =
|
||||
@@ -676,7 +745,10 @@ fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Re
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CommitmentCreatedPayload, PolicyAppliedPayload, TransitionStartedPayload};
|
||||
use super::{
|
||||
CommitmentCreatedPayload, PolicyAppliedPayload, RuntimeTransitionPayload,
|
||||
TransitionStartedPayload,
|
||||
};
|
||||
use crate::domain::{
|
||||
AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, RuntimeState,
|
||||
POLICY_SCHEMA_VERSION,
|
||||
@@ -939,6 +1011,64 @@ mod tests {
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completes_review_returns_to_planning_and_starts_next_commitment() {
|
||||
let path = temp_log_path("review-cycle");
|
||||
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.complete_for_review().unwrap();
|
||||
assert_eq!(session.runtime_state(), RuntimeState::Review);
|
||||
|
||||
session.return_to_planning_after_review().unwrap();
|
||||
assert_eq!(session.runtime_state(), RuntimeState::Planning);
|
||||
|
||||
session
|
||||
.start_manual_commitment(
|
||||
"Start another session",
|
||||
"second session starts",
|
||||
Duration::from_secs(600),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(session.runtime_state(), RuntimeState::Active);
|
||||
assert_eq!(
|
||||
session.active_commitment().unwrap().next_action,
|
||||
"Start another session"
|
||||
);
|
||||
|
||||
drop(session);
|
||||
let reopened = SessionController::new(&path).unwrap();
|
||||
assert_eq!(reopened.runtime_state(), RuntimeState::Active);
|
||||
assert_eq!(
|
||||
reopened.active_commitment().unwrap().next_action,
|
||||
"Start another session"
|
||||
);
|
||||
|
||||
let records = read_records(&path);
|
||||
let review_transition = records
|
||||
.iter()
|
||||
.find(|record| {
|
||||
record.event_type == EventType::RuntimeTransition
|
||||
&& record.runtime_state == RuntimeState::Review
|
||||
})
|
||||
.unwrap();
|
||||
let payload: RuntimeTransitionPayload =
|
||||
serde_json::from_value(review_transition.payload_json.clone()).unwrap();
|
||||
assert_eq!(payload.action, "complete_for_review");
|
||||
assert_eq!(payload.from, RuntimeState::Active);
|
||||
assert_eq!(payload.to, RuntimeState::Review);
|
||||
|
||||
fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_standalone_commitment_created_on_replay() {
|
||||
let path = temp_log_path("standalone-commitment");
|
||||
|
||||
Reference in New Issue
Block a user