Files
huskies/server/src/pipeline_state/apply.rs
T
Timmy d78dd9e8f9 feat(934): typed Stage enum replaces directory-string state model
The state machine's `Stage` enum becomes the source of truth for pipeline
state. Six stages of work land together:

  1. Clean wire vocabulary (`coding`, `merge`, `merge_failure`, ...) replaces
     legacy directory-style strings (`2_current`, `4_merge`, ...) on the wire.
     `Stage::from_dir` accepted both during deployment; new writes always
     emit the clean form via `stage_dir_name`. Lexicographic `dir >= "5_done"`
     checks in lifecycle.rs become typed `matches!` checks since the new
     vocabulary doesn't sort in pipeline order.
  2. `crdt_state::write_item` takes typed `&Stage`, serialising via
     `stage_dir_name` at the CRDT boundary. `#[cfg(test)] write_item_str`
     parses legacy strings for test fixtures.
  3. `WorkItem::stage()` returns typed `crdt_state::Stage`; `stage_str()`
     is gone from the public API. Projection dispatches on the typed enum.
  4. `frozen` becomes an orthogonal CRDT register. `Stage::Frozen` and
     `PipelineEvent::Freeze`/`Unfreeze` are removed; `transition_to_frozen`/
     `unfrozen` set the flag directly without touching the stage register.
  5. Watcher sweep and `tool_update_story`'s `blocked` setter route through
     `apply_transition` so the typed transition table validates every
     stage change. `update_story` gains a `frozen` field for symmetry.
  6. One-shot startup migration rewrites pre-934 directory-style stage
     registers (and sets `frozen=true` on items previously at `7_frozen`).
     `Stage::from_dir` drops legacy aliases. The db boundary keeps a small
     normaliser so callers with legacy strings (MCP, tests) still work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 22:31:59 +01:00

131 lines
4.4 KiB
Rust

//! 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())
}
/// Freeze a story.
///
/// Story 934, stage 4: `frozen` is now a CRDT flag orthogonal to [`Stage`],
/// so the story stays at its current stage and only the boolean register
/// changes. Returns `Err(NotFound)` if no item exists for `story_id`.
pub fn transition_to_frozen(story_id: &str) -> Result<(), ApplyError> {
if read_typed(story_id)?.is_none() {
return Err(ApplyError::NotFound(story_id.to_string()));
}
crate::crdt_state::set_frozen(story_id, true);
crate::slog!("[pipeline/transition] #{}: Freeze (flag set)", story_id);
Ok(())
}
/// Unfreeze a story.
///
/// Story 934, stage 4: paired with [`transition_to_frozen`]; clears the
/// CRDT `frozen` flag without touching the stage register. Returns
/// `Err(NotFound)` if no item exists for `story_id`.
pub fn transition_to_unfrozen(story_id: &str) -> Result<(), ApplyError> {
if read_typed(story_id)?.is_none() {
return Err(ApplyError::NotFound(story_id.to_string()));
}
crate::crdt_state::set_frozen(story_id, false);
crate::slog!(
"[pipeline/transition] #{}: Unfreeze (flag cleared)",
story_id
);
Ok(())
}