Return controller to planning after review
This commit is contained in:
+76
-21
@@ -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()))?;
|
||||||
|
|
||||||
|
let result = (|| -> Result<()> {
|
||||||
while app.state != State::ShouldQuit {
|
while app.state != State::ShouldQuit {
|
||||||
terminal.draw(|frame| ui(frame, &app))?;
|
terminal.draw(|frame| ui(frame, &app))?;
|
||||||
handle_events(&mut app)?;
|
handle_events(&mut app)?;
|
||||||
app.handle_ticks();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+140
-10
@@ -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,8 +368,19 @@ 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,
|
||||||
|
"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)
|
format!("replay runtime transition at sequence {}", record.sequence)
|
||||||
})?;
|
})?;
|
||||||
if payload.to != next || record.runtime_state != next {
|
if payload.to != next || record.runtime_state != next {
|
||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user