//! Freeze and unfreeze work items — CRDT state transitions for pipeline lifecycle control. //! //! Both the Matrix bot commands (`freeze`/`unfreeze`) and the MCP tools //! (`freeze_story`/`unfreeze_story`) delegate here so the CRDT mutation is //! defined in exactly one place. /// Outcome of a successful [`freeze`] call. #[derive(Debug, PartialEq, Eq)] pub enum FreezeStatus { /// The work item was already in the frozen stage; no state change occurred. AlreadyFrozen, /// The work item was successfully transitioned to the frozen stage. Frozen, } /// Outcome of a successful [`unfreeze`] call. #[derive(Debug, PartialEq, Eq)] pub enum UnfreezeStatus { /// The work item was not frozen; no state change occurred. NotFrozen, /// The work item was successfully unfrozen and restored to its prior stage. Unfrozen, } /// Freeze a work item, suppressing pipeline advancement and auto-assign. /// /// Returns [`FreezeStatus::AlreadyFrozen`] if the item is already in the frozen /// stage without making any CRDT writes. Returns `Err` if the state transition /// fails (e.g. the item is not found or is in a terminal stage). pub fn freeze(story_id: &str) -> Result { let already_frozen = crate::pipeline_state::read_typed(story_id) .ok() .flatten() .map(|i| i.stage.is_frozen()) .unwrap_or(false); if already_frozen { return Ok(FreezeStatus::AlreadyFrozen); } crate::pipeline_state::transition_to_frozen(story_id) .map(|_| FreezeStatus::Frozen) .map_err(|e| e.to_string()) } /// Unfreeze a work item, resuming normal pipeline behaviour. /// /// Returns [`UnfreezeStatus::NotFrozen`] if the item is not currently in the /// frozen stage. Returns `Err` if the state transition fails. pub fn unfreeze(story_id: &str) -> Result { let is_frozen = crate::pipeline_state::read_typed(story_id) .ok() .flatten() .map(|i| i.stage.is_frozen()) .unwrap_or(false); if !is_frozen { return Ok(UnfreezeStatus::NotFrozen); } crate::pipeline_state::transition_to_unfrozen(story_id) .map(|_| UnfreezeStatus::Unfrozen) .map_err(|e| e.to_string()) } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; #[test] fn freeze_transitions_item_to_frozen_stage() { crate::crdt_state::init_for_test(); let story_id = "8780_story_freeze_service_basic"; crate::db::write_item_with_content( story_id, "2_current", "---\nname: Freeze Service Test\n---\n", crate::db::ItemMeta::named("Freeze Service Test"), ); let result = freeze(story_id); assert!( matches!(result, Ok(FreezeStatus::Frozen)), "should return Frozen: {result:?}" ); let item = crate::pipeline_state::read_typed(story_id) .expect("read_typed should succeed") .expect("item should be present"); assert!( item.stage.is_frozen(), "stage should be Frozen after freeze: {:?}", item.stage ); } #[test] fn freeze_already_frozen_returns_already_frozen_without_error() { crate::crdt_state::init_for_test(); let story_id = "8781_story_freeze_service_already"; crate::db::write_item_with_content( story_id, "2_current", "---\nname: Already Frozen\n---\n", crate::db::ItemMeta::named("Already Frozen"), ); freeze(story_id).expect("first freeze should succeed"); let result = freeze(story_id); assert!( matches!(result, Ok(FreezeStatus::AlreadyFrozen)), "second freeze should return AlreadyFrozen: {result:?}" ); } #[test] fn unfreeze_transitions_item_back_from_frozen() { crate::crdt_state::init_for_test(); let story_id = "8782_story_unfreeze_service_basic"; crate::db::write_item_with_content( story_id, "2_current", "---\nname: Unfreeze Service Test\n---\n", crate::db::ItemMeta::named("Unfreeze Service Test"), ); freeze(story_id).expect("freeze should succeed"); let result = unfreeze(story_id); assert!( matches!(result, Ok(UnfreezeStatus::Unfrozen)), "should return Unfrozen: {result:?}" ); let item = crate::pipeline_state::read_typed(story_id) .expect("read_typed should succeed") .expect("item should be present"); assert!( !item.stage.is_frozen(), "stage should not be Frozen after unfreeze: {:?}", item.stage ); } #[test] fn unfreeze_not_frozen_item_returns_not_frozen() { crate::crdt_state::init_for_test(); let story_id = "8783_story_unfreeze_service_not_frozen"; crate::db::write_item_with_content( story_id, "2_current", "---\nname: Not Frozen\n---\n", crate::db::ItemMeta::named("Not Frozen"), ); let result = unfreeze(story_id); assert!( matches!(result, Ok(UnfreezeStatus::NotFrozen)), "should return NotFrozen for a non-frozen item: {result:?}" ); } /// Regression: both the chat command path and the MCP tool path delegate to /// `freeze()` / `unfreeze()`, so they must produce identical CRDT state. /// This test proves it by calling the service directly (as both handlers do) /// and asserting the resulting CRDT stages are both frozen. #[test] fn freeze_via_chat_and_mcp_paths_yields_identical_crdt_state() { crate::crdt_state::init_for_test(); // Story A simulates the chat command path. let story_a = "8784_story_freeze_regression_chat"; crate::db::write_item_with_content( story_a, "2_current", "---\nname: Regression Chat Path\n---\n", crate::db::ItemMeta::named("Regression Chat Path"), ); // Story B simulates the MCP tool path. let story_b = "8785_story_freeze_regression_mcp"; crate::db::write_item_with_content( story_b, "2_current", "---\nname: Regression MCP Path\n---\n", crate::db::ItemMeta::named("Regression MCP Path"), ); // Both paths call service::work_item::freeze(). let result_a = freeze(story_a); let result_b = freeze(story_b); assert!( matches!(result_a, Ok(FreezeStatus::Frozen)), "chat-path freeze should succeed: {result_a:?}" ); assert!( matches!(result_b, Ok(FreezeStatus::Frozen)), "MCP-path freeze should succeed: {result_b:?}" ); let state_a = crate::pipeline_state::read_typed(story_a) .expect("read_typed should succeed") .expect("chat-path item should be in CRDT"); let state_b = crate::pipeline_state::read_typed(story_b) .expect("read_typed should succeed") .expect("MCP-path item should be in CRDT"); assert!( state_a.stage.is_frozen(), "chat-path CRDT stage must be frozen: {:?}", state_a.stage ); assert!( state_b.stage.is_frozen(), "MCP-path CRDT stage must be frozen: {:?}", state_b.stage ); // Discriminant comparison: both stages must be the same variant. assert_eq!( std::mem::discriminant(&state_a.stage), std::mem::discriminant(&state_b.stage), "chat-path and MCP-path must produce identical CRDT stage variant" ); } }