Return controller to planning after review

This commit is contained in:
2026-05-25 21:18:53 -04:00
parent 6ce6bb97ab
commit dd6314027c
2 changed files with 222 additions and 37 deletions
+80 -25
View File
@@ -186,13 +186,14 @@ impl App {
}) })
} }
fn handle_ticks(&mut self) { fn handle_ticks(&mut self) -> Result<()> {
if self.last_tick_50ms.elapsed() >= Duration::from_millis(50) { if self.last_tick_50ms.elapsed() >= Duration::from_millis(50) {
self.tick_50ms(); self.tick_50ms()?;
} }
if self.last_tick_1s.elapsed() >= Duration::from_secs(1) { if self.last_tick_1s.elapsed() >= Duration::from_secs(1) {
self.tick_1s(); self.tick_1s();
} }
Ok(())
} }
fn to_in_progress(&mut self) -> Result<()> { fn to_in_progress(&mut self) -> Result<()> {
@@ -229,9 +230,13 @@ impl App {
Ok(()) Ok(())
} }
fn to_end(&mut self) { fn to_end(&mut self) -> Result<()> {
if self.session_controller.runtime_state() == RuntimeState::Active {
self.session_controller.complete_for_review()?;
}
self.state = State::End; self.state = State::End;
self.session_ratings = session_stats_as_vec(&self.session_stats); self.session_ratings = session_stats_as_vec(&self.session_stats);
Ok(())
} }
fn cleanup(&self) { fn cleanup(&self) {
@@ -274,7 +279,7 @@ impl App {
} }
} }
fn tick_50ms(&mut self) { fn tick_50ms(&mut self) -> Result<()> {
match self.state { match self.state {
State::InputIntention | State::InputSuccessCondition | State::InputDuration => { State::InputIntention | State::InputSuccessCondition | State::InputDuration => {
if let Ok(user_duration_mins) = self.user_duration_str.parse::<u64>() { if let Ok(user_duration_mins) = self.user_duration_str.parse::<u64>() {
@@ -291,7 +296,7 @@ impl App {
update_session_stats(self); update_session_stats(self);
if self.session_remaining.is_zero() { if self.session_remaining.is_zero() {
self.to_end(); self.to_end()?;
} }
} }
State::ShouldQuit => {} State::ShouldQuit => {}
@@ -305,6 +310,7 @@ impl App {
} }
self.last_tick_50ms = Instant::now(); self.last_tick_50ms = Instant::now();
Ok(())
} }
fn tick_1s(&mut self) { fn tick_1s(&mut self) {
@@ -320,6 +326,24 @@ impl App {
Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed()) Duration::from_millis(50).saturating_sub(self.last_tick_50ms.elapsed())
} }
fn complete_rating(&mut self) -> Result<()> {
if self.session_controller.runtime_state() == RuntimeState::Review {
self.session_controller.return_to_planning_after_review()?;
}
self.state = State::InputIntention;
let mut session_result = SessionResult {
intention: self.user_intention.clone(),
duration: self.session_start.elapsed(),
session_ratings: std::mem::take(&mut self.session_ratings),
rating: 0,
rating_f64: 0.0,
};
session_result.rate();
save_session(&session_result);
self.session_results.push(session_result);
Ok(())
}
fn get_session_results(&self) -> Vec<Line<'_>> { fn get_session_results(&self) -> Vec<Line<'_>> {
self.session_results self.session_results
.iter() .iter()
@@ -388,22 +412,34 @@ fn session_stats_as_vec(session_stats: &HashMap<Rc<String>, Duration>) -> Vec<Se
} }
fn main() -> Result<()> { fn main() -> Result<()> {
let result = run_app();
let raw_mode_result = disable_raw_mode();
let screen_result = stdout().execute(LeaveAlternateScreen).map(|_| ());
result?;
raw_mode_result?;
screen_result?;
Ok(())
}
fn run_app() -> Result<()> {
let mut app = App::new()?; let mut app = App::new()?;
enable_raw_mode()?; enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?; stdout().execute(EnterAlternateScreen)?;
execute!(stdout(), SetTitle(constants::APP_TITLE))?; execute!(stdout(), SetTitle(constants::APP_TITLE))?;
let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?; let mut terminal = Terminal::new(CrosstermBackend::new(stdout()))?;
while app.state != State::ShouldQuit { let result = (|| -> Result<()> {
terminal.draw(|frame| ui(frame, &app))?; while app.state != State::ShouldQuit {
handle_events(&mut app)?; terminal.draw(|frame| ui(frame, &app))?;
app.handle_ticks(); handle_events(&mut app)?;
} app.handle_ticks()?;
}
Ok(())
})();
app.cleanup(); app.cleanup();
disable_raw_mode()?; result
stdout().execute(LeaveAlternateScreen)?;
Ok(())
} }
fn handle_events(app: &mut App) -> Result<()> { fn handle_events(app: &mut App) -> Result<()> {
@@ -459,7 +495,7 @@ fn handle_events(app: &mut App) -> Result<()> {
}, },
State::InProgress => match key.code { State::InProgress => match key.code {
KeyCode::Char('q') => { KeyCode::Char('q') => {
app.to_end(); app.to_end()?;
} }
KeyCode::Char('p') => { KeyCode::Char('p') => {
app.state = State::Paused; app.state = State::Paused;
@@ -492,17 +528,7 @@ fn handle_events(app: &mut App) -> Result<()> {
} }
if app.session_ratings_index >= app.session_ratings.len() { if app.session_ratings_index >= app.session_ratings.len() {
app.state = State::InputIntention; app.complete_rating()?;
let mut session_result = SessionResult {
intention: app.user_intention.clone(),
duration: app.session_start.elapsed(),
session_ratings: std::mem::take(&mut app.session_ratings),
rating: 0,
rating_f64: 0.0,
};
session_result.rate();
save_session(&session_result);
app.session_results.push(session_result);
} }
} }
} }
@@ -718,4 +744,33 @@ mod tests {
let _ = fs::remove_file(path); let _ = fs::remove_file(path);
} }
#[test]
fn rating_completion_returns_controller_to_planning_for_next_session() {
let path = unique_event_log_path("rating-completion");
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.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
);
app.to_in_progress().unwrap();
assert_eq!(app.state, State::InProgress);
assert_eq!(app.session_controller.runtime_state(), RuntimeState::Active);
let _ = fs::remove_file(path);
}
} }
+142 -12
View File
@@ -133,6 +133,64 @@ impl SessionController {
Ok(()) 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( pub fn start_manual_commitment(
&mut self, &mut self,
next_action: impl Into<String>, next_action: impl Into<String>,
@@ -302,13 +360,6 @@ fn hydrate_session(
let payload: RuntimeTransitionPayload = let payload: RuntimeTransitionPayload =
parse_payload(&record, "runtime transition")?; parse_payload(&record, "runtime transition")?;
ensure_schema_version(payload.schema_version, record.sequence)?; 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 { if payload.from != runtime_state {
return Err(anyhow!( return Err(anyhow!(
"runtime transition source mismatch at sequence {}: expected {:?}, found {:?}", "runtime transition source mismatch at sequence {}: expected {:?}, found {:?}",
@@ -317,10 +368,21 @@ fn hydrate_session(
payload.from payload.from
)); ));
} }
let next = transition_runtime(runtime_state, RuntimeAction::EnterPlanning) let action = match payload.action.as_str() {
.with_context(|| { "enter_planning" => RuntimeAction::EnterPlanning,
format!("replay runtime transition at sequence {}", record.sequence) "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 { if payload.to != next || record.runtime_state != next {
return Err(anyhow!( return Err(anyhow!(
"runtime transition target mismatch at sequence {}", "runtime transition target mismatch at sequence {}",
@@ -328,6 +390,13 @@ fn hydrate_session(
)); ));
} }
runtime_state = next; runtime_state = next;
if matches!(
action,
RuntimeAction::CompleteForReview | RuntimeAction::ContinuePlanning
) {
active_commitment = None;
active_policy = None;
}
} }
EventType::CommitmentCreated => { EventType::CommitmentCreated => {
let payload: CommitmentCreatedPayload = let payload: CommitmentCreatedPayload =
@@ -676,7 +745,10 @@ fn validate_transition_started_payload(payload: &TransitionStartedPayload) -> Re
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{CommitmentCreatedPayload, PolicyAppliedPayload, TransitionStartedPayload}; use super::{
CommitmentCreatedPayload, PolicyAppliedPayload, RuntimeTransitionPayload,
TransitionStartedPayload,
};
use crate::domain::{ use crate::domain::{
AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, RuntimeState, AllowedContext, CommitmentSource, CommitmentState, EnforcementLevel, RuntimeState,
POLICY_SCHEMA_VERSION, POLICY_SCHEMA_VERSION,
@@ -939,6 +1011,64 @@ mod tests {
fs::remove_file(path).unwrap(); 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] #[test]
fn rejects_standalone_commitment_created_on_replay() { fn rejects_standalone_commitment_created_on_replay() {
let path = temp_log_path("standalone-commitment"); let path = temp_log_path("standalone-commitment");