huskies: merge 867

This commit is contained in:
dave
2026-04-29 22:12:23 +00:00
parent e56bd2d834
commit a49f668b5a
17 changed files with 286 additions and 61 deletions
+25
View File
@@ -97,3 +97,28 @@ pub fn apply_transition_str(
) -> Result<TransitionFired, String> {
apply_transition(story_id, event, content_transform).map_err(|e| e.to_string())
}
/// 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))
}
+4 -1
View File
@@ -54,7 +54,10 @@ pub use projection::{ProjectionError, project_stage};
pub use projection::{read_all_typed, read_typed};
#[allow(unused_imports)]
pub use apply::{ApplyError, apply_transition, apply_transition_str};
pub use apply::{
ApplyError, apply_transition, apply_transition_str, transition_to_frozen,
transition_to_unfrozen,
};
#[allow(unused_imports)]
pub use subscribers::{
+16
View File
@@ -119,6 +119,21 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
reason,
})
}
"7_frozen" => {
// The stage to resume to is stored in front matter as `resume_to_stage`.
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
let resume_to = crate::db::read_content(&view.story_id)
.and_then(|content| {
crate::io::story_metadata::parse_front_matter(&content)
.ok()
.and_then(|m| m.resume_to_stage)
.and_then(|dir| Stage::from_dir(&dir))
})
.unwrap_or(Stage::Coding);
Ok(Stage::Frozen {
resume_to: Box::new(resume_to),
})
}
other => Err(ProjectionError::UnknownStage(other.to_string())),
}
}
@@ -137,6 +152,7 @@ impl PipelineItem {
..
}
);
// Frozen stories map to "7_frozen"; they are not "blocked" in the CRDT sense.
(dir, blocked)
}
}
+120
View File
@@ -538,4 +538,124 @@ fn cannot_reject_from_archived() {
));
}
// ── Freeze / Unfreeze ───────────────────────────────────────────────
#[test]
fn freeze_from_active_stages() {
for s in [Stage::Upcoming, Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(s.clone(), PipelineEvent::Freeze).unwrap();
assert!(
matches!(result, Stage::Frozen { .. }),
"expected Frozen from {s:?}"
);
if let Stage::Frozen { resume_to } = result {
assert_eq!(*resume_to, s);
}
}
}
#[test]
fn freeze_from_merge() {
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(m.clone(), PipelineEvent::Freeze).unwrap();
assert!(matches!(result, Stage::Frozen { .. }));
if let Stage::Frozen { resume_to } = result {
assert_eq!(*resume_to, m);
}
}
#[test]
fn unfreeze_restores_prior_stage() {
let prior = Stage::Coding;
let frozen = Stage::Frozen {
resume_to: Box::new(prior.clone()),
};
let result = transition(frozen, PipelineEvent::Unfreeze).unwrap();
assert_eq!(result, prior);
}
#[test]
fn cannot_freeze_done() {
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("abc"),
};
let result = transition(s, PipelineEvent::Freeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_freeze_archived() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::Completed,
};
let result = transition(s, PipelineEvent::Freeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_unfreeze_coding() {
let result = transition(Stage::Coding, PipelineEvent::Unfreeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
/// Regression test: freeze → unfreeze round-trip via `apply_transition`.
/// Verifies that the CRDT shows the correct prior stage restored.
#[test]
fn regression_freeze_unfreeze_restores_crdt_stage() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "9950_story_freeze_regression";
let content = "---\nname: Freeze Regression\n---\n# Story\n";
crate::db::write_item_with_content(story_id, "2_current", content);
// Confirm starting stage.
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
"should start at Coding"
);
// Freeze.
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Frozen { .. }),
"should be Frozen after freeze: {:?}",
item.stage
);
if let Stage::Frozen { ref resume_to } = item.stage {
assert!(
matches!(**resume_to, Stage::Coding),
"resume_to should be Coding: {:?}",
resume_to
);
}
// Unfreeze.
super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
"should be restored to Coding after unfreeze: {:?}",
item.stage
);
}
// ── ProjectionError Display ─────────────────────────────────────────
+17
View File
@@ -56,6 +56,10 @@ pub enum PipelineEvent {
Close,
/// Manual demotion back to backlog from an active stage.
Demote,
/// Freeze the story at its current stage (suspends pipeline and auto-assign).
Freeze,
/// Unfreeze the story, restoring it to the stage it was at when frozen.
Unfreeze,
}
// ── Per-node execution events ───────────────────────────────────────────────
@@ -94,6 +98,8 @@ pub fn event_label(e: &PipelineEvent) -> &'static str {
PipelineEvent::Triage => "Triage",
PipelineEvent::Close => "Close",
PipelineEvent::Demote => "Demote",
PipelineEvent::Freeze => "Freeze",
PipelineEvent::Unfreeze => "Unfreeze",
}
}
@@ -231,6 +237,17 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
Unblock,
) => Ok(Backlog),
// ── Freeze: any active stage → Frozen(resume_to=current) ────────
(stage @ (Upcoming | Backlog | Coding | Qa), Freeze) => Ok(Frozen {
resume_to: Box::new(stage),
}),
(stage @ Merge { .. }, Freeze) => Ok(Frozen {
resume_to: Box::new(stage),
}),
// ── Unfreeze: Frozen → resume_to ─────────────────────────────────
(Frozen { resume_to }, Unfreeze) => Ok(*resume_to),
// ── Everything else is invalid ──────────────────────────────────
_ => Err(invalid()),
}
+16
View File
@@ -100,6 +100,10 @@ pub enum Stage {
archived_at: DateTime<Utc>,
reason: ArchiveReason,
},
/// Pipeline advancement and auto-assign are suspended. Resumes to
/// `resume_to` when unfrozen.
Frozen { resume_to: Box<Stage> },
}
/// Why a story was archived. Subsumes the old `blocked`, `merge_failure`,
@@ -130,6 +134,11 @@ impl Stage {
matches!(self, Stage::Coding | Stage::Qa | Stage::Merge { .. })
}
/// Returns true if this stage is `Frozen`.
pub fn is_frozen(&self) -> bool {
matches!(self, Stage::Frozen { .. })
}
/// Returns true if this is the Upcoming variant.
pub fn is_upcoming(&self) -> bool {
matches!(self, Stage::Upcoming)
@@ -177,6 +186,11 @@ impl Stage {
archived_at: DateTime::<Utc>::UNIX_EPOCH,
reason: ArchiveReason::Completed,
}),
// Frozen: stub with Coding as resume_to — rich resume_to is loaded
// from front matter by the projection layer.
"7_frozen" => Some(Stage::Frozen {
resume_to: Box::new(Stage::Coding),
}),
_ => None,
}
}
@@ -256,6 +270,7 @@ pub fn stage_label(s: &Stage) -> &'static str {
Stage::Merge { .. } => "Merge",
Stage::Done { .. } => "Done",
Stage::Archived { .. } => "Archived",
Stage::Frozen { .. } => "Frozen",
}
}
@@ -269,5 +284,6 @@ pub fn stage_dir_name(s: &Stage) -> &'static str {
Stage::Merge { .. } => "4_merge",
Stage::Done { .. } => "5_done",
Stage::Archived { .. } => "6_archived",
Stage::Frozen { .. } => "7_frozen",
}
}