2026-04-29 17:38:38 +00:00
|
|
|
//! Single entry point for pipeline state transitions.
|
|
|
|
|
//!
|
|
|
|
|
//! [`apply_transition`] is the **only** function that should mutate a story's
|
|
|
|
|
//! pipeline stage. It reads the current typed stage from the CRDT, validates
|
|
|
|
|
//! the transition via the pure [`super::transition()`] function, writes the new
|
|
|
|
|
//! stage back to the CRDT, and returns a [`TransitionFired`] event for
|
|
|
|
|
//! downstream subscribers.
|
|
|
|
|
|
|
|
|
|
use super::{
|
|
|
|
|
PipelineEvent, StoryId, TransitionFired, event_label, read_typed, stage_label, transition,
|
|
|
|
|
};
|
|
|
|
|
use chrono::Utc;
|
|
|
|
|
|
|
|
|
|
/// Error type for [`apply_transition`].
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub enum ApplyError {
|
|
|
|
|
/// The story was not found in the CRDT.
|
|
|
|
|
NotFound(String),
|
|
|
|
|
/// The CRDT projection failed.
|
|
|
|
|
Projection(super::ProjectionError),
|
|
|
|
|
/// The transition was invalid for the current stage.
|
|
|
|
|
InvalidTransition(super::TransitionError),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::fmt::Display for ApplyError {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::NotFound(id) => write!(f, "story '{id}' not found in CRDT"),
|
|
|
|
|
Self::Projection(e) => write!(f, "projection error: {e}"),
|
|
|
|
|
Self::InvalidTransition(e) => write!(f, "{e}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl std::error::Error for ApplyError {}
|
|
|
|
|
|
|
|
|
|
impl From<super::ProjectionError> for ApplyError {
|
|
|
|
|
fn from(e: super::ProjectionError) -> Self {
|
|
|
|
|
Self::Projection(e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl From<super::TransitionError> for ApplyError {
|
|
|
|
|
fn from(e: super::TransitionError) -> Self {
|
|
|
|
|
Self::InvalidTransition(e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Apply a pipeline event to a story, validating the transition and writing
|
|
|
|
|
/// the new stage to the CRDT.
|
|
|
|
|
///
|
|
|
|
|
/// This is the single canonical entry point for all pipeline stage mutations.
|
|
|
|
|
/// Returns a [`TransitionFired`] describing what happened, suitable for
|
|
|
|
|
/// broadcasting to event-bus subscribers and CRDT log consumers.
|
|
|
|
|
///
|
|
|
|
|
/// An optional `content_transform` allows callers to modify the stored content
|
|
|
|
|
/// (e.g. clearing front-matter fields) atomically with the stage change.
|
|
|
|
|
pub fn apply_transition(
|
|
|
|
|
story_id: &str,
|
|
|
|
|
event: PipelineEvent,
|
|
|
|
|
content_transform: Option<&dyn Fn(&str) -> String>,
|
|
|
|
|
) -> Result<TransitionFired, ApplyError> {
|
|
|
|
|
let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?;
|
|
|
|
|
|
|
|
|
|
let before = item.stage.clone();
|
|
|
|
|
let after = transition(before.clone(), event.clone())?;
|
|
|
|
|
let new_dir = after.dir_name();
|
|
|
|
|
|
|
|
|
|
// Write the new stage to the CRDT (with optional content transform).
|
|
|
|
|
crate::db::move_item_stage(story_id, new_dir, content_transform);
|
|
|
|
|
|
|
|
|
|
let fired = TransitionFired {
|
|
|
|
|
story_id: StoryId(story_id.to_string()),
|
|
|
|
|
before,
|
|
|
|
|
after,
|
|
|
|
|
event,
|
|
|
|
|
at: Utc::now(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[pipeline/transition] #{}: {} + {} → {}",
|
|
|
|
|
story_id,
|
|
|
|
|
stage_label(&fired.before),
|
|
|
|
|
event_label(&fired.event),
|
|
|
|
|
stage_label(&fired.after),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Ok(fired)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Convenience: apply a transition, returning an `Err(String)` on failure
|
|
|
|
|
/// (matches the existing lifecycle function signatures).
|
|
|
|
|
pub fn apply_transition_str(
|
|
|
|
|
story_id: &str,
|
|
|
|
|
event: PipelineEvent,
|
|
|
|
|
content_transform: Option<&dyn Fn(&str) -> String>,
|
|
|
|
|
) -> Result<TransitionFired, String> {
|
|
|
|
|
apply_transition(story_id, event, content_transform).map_err(|e| e.to_string())
|
|
|
|
|
}
|
2026-04-29 22:12:23 +00:00
|
|
|
|
|
|
|
|
/// Freeze a story at its current stage.
|
|
|
|
|
///
|
|
|
|
|
/// Transitions the story to `Stage::Frozen { resume_to: current_stage }` and
|
|
|
|
|
/// writes `resume_to_stage` into the front matter so the projection layer can
|
|
|
|
|
/// reconstruct the full typed stage on subsequent reads.
|
|
|
|
|
pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
|
|
|
|
let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?;
|
|
|
|
|
let resume_dir = item.stage.dir_name().to_string();
|
|
|
|
|
let transform = move |content: &str| -> String {
|
|
|
|
|
crate::io::story_metadata::set_front_matter_field(content, "resume_to_stage", &resume_dir)
|
|
|
|
|
};
|
|
|
|
|
apply_transition(story_id, PipelineEvent::Freeze, Some(&transform))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Unfreeze a story, restoring it to the stage it was in before freezing.
|
|
|
|
|
///
|
|
|
|
|
/// Transitions `Stage::Frozen { resume_to }` back to `resume_to` and removes
|
|
|
|
|
/// the `resume_to_stage` field from the front matter.
|
|
|
|
|
pub fn transition_to_unfrozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
|
|
|
|
let transform = |content: &str| -> String {
|
|
|
|
|
crate::io::story_metadata::clear_front_matter_field_in_content(content, "resume_to_stage")
|
|
|
|
|
};
|
|
|
|
|
apply_transition(story_id, PipelineEvent::Unfreeze, Some(&transform))
|
|
|
|
|
}
|