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>
This commit is contained in:
@@ -98,20 +98,33 @@ pub fn apply_transition_str(
|
||||
apply_transition(story_id, event, content_transform).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Freeze a story at its current stage.
|
||||
/// Freeze a story.
|
||||
///
|
||||
/// Story 929: the YAML write of `resume_to_stage` is gone; the projection
|
||||
/// layer no longer reads it (defaults to Coding). Story 934 will make
|
||||
/// frozen a flag orthogonal to Stage, so the story stays in its current
|
||||
/// Stage rather than encoding a "where to resume" payload — at which point
|
||||
/// the read-side default also becomes moot.
|
||||
pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
||||
apply_transition(story_id, PipelineEvent::Freeze, None)
|
||||
/// 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 929: paired with `transition_to_frozen`, no longer touches YAML.
|
||||
pub fn transition_to_unfrozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
|
||||
apply_transition(story_id, PipelineEvent::Unfreeze, None)
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
@@ -19,8 +19,11 @@
|
||||
//! - [`projection`] — CRDT → typed projection layer (`read_typed`, `read_all_typed`)
|
||||
//! - [`subscribers`] — concrete subscriber stubs
|
||||
|
||||
// Some items are exercised by tests or used only in non-active code paths;
|
||||
// the dead_code lint is suppressed for the module.
|
||||
// Scaffolding types (AgentName, NodePubkey, ExecutionState, ExecutionEvent,
|
||||
// execution_transition, apply_transition_str, to_crdt_fields, is_upcoming,
|
||||
// MissingField/InvalidField) exist for stages 2–5 of story 934 and the
|
||||
// per-node execution-state work; they are only exercised by tests today.
|
||||
// Revisit and drop the allow once those stages land.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod apply;
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
//! Projection layer — converts loose CRDT views into typed `PipelineItem` enums.
|
||||
|
||||
#![allow(unused_imports, dead_code)]
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::fmt;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
use crate::crdt_state::{PipelineItemView, read_all_items, read_item};
|
||||
use crate::crdt_state::PipelineItemView;
|
||||
|
||||
use super::{
|
||||
ArchiveReason, BranchName, ExecutionState, GitSha, PipelineItem, Stage, StoryId, stage_dir_name,
|
||||
};
|
||||
use super::{ArchiveReason, BranchName, GitSha, PipelineItem, Stage, StoryId, stage_dir_name};
|
||||
|
||||
/// Errors from projecting loose CRDT data into typed enums.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -61,27 +58,33 @@ impl TryFrom<&PipelineItemView> for PipelineItem {
|
||||
stage,
|
||||
depends_on,
|
||||
retry_count,
|
||||
frozen: view.frozen(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Project the stage string + associated fields from a WorkItem into
|
||||
/// a typed Stage enum. This is the one carefully-controlled boundary where
|
||||
/// loose CRDT data becomes typed.
|
||||
/// Project the typed low-level [`crdt_state::Stage`] plus the view's
|
||||
/// associated fields into a rich [`Stage`] with payload defaults.
|
||||
///
|
||||
/// This is the one carefully-controlled boundary where the CRDT's
|
||||
/// stringly-typed stage register gains payload fields (merge metadata,
|
||||
/// archive reason, etc.) synthesised from sibling registers and sane
|
||||
/// defaults. Unknown stage strings (forward-compat aliases) surface as
|
||||
/// [`ProjectionError::UnknownStage`].
|
||||
pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError> {
|
||||
match view.stage_str() {
|
||||
"0_upcoming" => Ok(Stage::Upcoming),
|
||||
"1_backlog" => Ok(Stage::Backlog),
|
||||
"2_blocked" => Ok(Stage::Blocked {
|
||||
use crate::crdt_state::Stage as LowStage;
|
||||
match view.stage() {
|
||||
LowStage::Upcoming => Ok(Stage::Upcoming),
|
||||
LowStage::Backlog => Ok(Stage::Backlog),
|
||||
LowStage::Blocked => Ok(Stage::Blocked {
|
||||
reason: String::new(),
|
||||
}),
|
||||
"2_current" => Ok(Stage::Coding),
|
||||
"3_qa" => Ok(Stage::Qa),
|
||||
"4_merge" => {
|
||||
LowStage::Coding => Ok(Stage::Coding),
|
||||
LowStage::Qa => Ok(Stage::Qa),
|
||||
LowStage::Merge => {
|
||||
// Merge stage in the current CRDT doesn't carry feature_branch or
|
||||
// commits_ahead — those are computed at transition time. For
|
||||
// projection from existing CRDT data, we synthesize defaults.
|
||||
// The feature branch follows the naming convention.
|
||||
let branch = format!("feature/story-{}", view.story_id());
|
||||
// Existing CRDT data doesn't track commits_ahead, so we use 1 as
|
||||
// a safe non-zero default (the item is in merge, so there must be
|
||||
@@ -91,19 +94,19 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
||||
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
|
||||
})
|
||||
}
|
||||
"4_merge_failure" => {
|
||||
// The reason is persisted in front-matter (merge_failure: "...") but
|
||||
// is not part of the raw CRDT view; the projection uses an empty
|
||||
// string here. Consumers that need the reason should read content.
|
||||
LowStage::MergeFailure => {
|
||||
// The reason is persisted in the content body but is not part of
|
||||
// the raw CRDT view; the projection uses an empty string here.
|
||||
// Consumers that need the reason should read content directly.
|
||||
Ok(Stage::MergeFailure {
|
||||
reason: String::new(),
|
||||
})
|
||||
}
|
||||
"5_done" => {
|
||||
LowStage::Done => {
|
||||
// Use the stored merged_at timestamp if present. Legacy items
|
||||
// that pre-date this field have merged_at = None, so we fall back
|
||||
// to UNIX_EPOCH, which makes them older than any retention window
|
||||
// and therefore eligible for immediate sweep to 6_archived.
|
||||
// and therefore eligible for immediate sweep to archived.
|
||||
let merged_at = view
|
||||
.merged_at()
|
||||
.map(|ts| {
|
||||
@@ -115,14 +118,12 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
||||
merge_commit: GitSha("legacy".to_string()),
|
||||
})
|
||||
}
|
||||
"6_archived" => {
|
||||
// Determine the archive reason from the CRDT fields.
|
||||
LowStage::Archived => {
|
||||
let reason = if view.blocked() {
|
||||
ArchiveReason::Blocked {
|
||||
reason: "migrated from legacy blocked field".to_string(),
|
||||
}
|
||||
} else {
|
||||
// Default to Completed for legacy archived items.
|
||||
ArchiveReason::Completed
|
||||
};
|
||||
Ok(Stage::Archived {
|
||||
@@ -130,16 +131,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
|
||||
reason,
|
||||
})
|
||||
}
|
||||
"7_frozen" => {
|
||||
// Story 929: resume_to was previously read from YAML front matter;
|
||||
// we default to Coding here. Story 934 will obviate this — frozen
|
||||
// becomes a flag orthogonal to Stage, so the story stays in its
|
||||
// current Stage rather than encoding a "where to go next" payload.
|
||||
Ok(Stage::Frozen {
|
||||
resume_to: Box::new(Stage::Coding),
|
||||
})
|
||||
}
|
||||
other => Err(ProjectionError::UnknownStage(other.to_string())),
|
||||
LowStage::Unknown(s) => Err(ProjectionError::UnknownStage(s)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +150,6 @@ impl PipelineItem {
|
||||
..
|
||||
}
|
||||
);
|
||||
// Frozen stories map to "7_frozen"; they are not "blocked" in the CRDT sense.
|
||||
(dir, blocked)
|
||||
}
|
||||
}
|
||||
@@ -199,7 +190,6 @@ pub fn read_typed(story_id: &str) -> Result<Option<PipelineItem>, ProjectionErro
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeZone;
|
||||
use std::num::NonZeroU32;
|
||||
|
||||
fn nz(n: u32) -> NonZeroU32 {
|
||||
@@ -232,12 +222,13 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_upcoming_item() {
|
||||
let view = make_view("42_story_test", "0_upcoming", Some("Test Story"));
|
||||
let view = make_view("42_story_test", "upcoming", Some("Test Story"));
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(item.stage, Stage::Upcoming));
|
||||
}
|
||||
@@ -246,7 +237,7 @@ mod tests {
|
||||
fn project_backlog_item() {
|
||||
let view = PipelineItemView::for_test(
|
||||
"42_story_test",
|
||||
"1_backlog",
|
||||
"backlog",
|
||||
Some("Test Story".to_string()),
|
||||
None,
|
||||
None,
|
||||
@@ -260,6 +251,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
|
||||
@@ -273,7 +265,7 @@ mod tests {
|
||||
fn project_current_item() {
|
||||
let view = PipelineItemView::for_test(
|
||||
"42_story_test",
|
||||
"2_current",
|
||||
"coding",
|
||||
Some("Test".to_string()),
|
||||
Some("coder-1".to_string()),
|
||||
Some(2),
|
||||
@@ -287,6 +279,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(item.stage, Stage::Coding));
|
||||
@@ -295,7 +288,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn project_merge_item() {
|
||||
let view = make_view("42_story_test", "4_merge", Some("Test"));
|
||||
let view = make_view("42_story_test", "merge", Some("Test"));
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(item.stage, Stage::Merge { .. }));
|
||||
if let Stage::Merge {
|
||||
@@ -310,7 +303,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn project_blocked_item() {
|
||||
let view = make_view("42_story_test", "2_blocked", Some("Test"));
|
||||
let view = make_view("42_story_test", "blocked", Some("Test"));
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(item.stage, Stage::Blocked { .. }));
|
||||
}
|
||||
@@ -319,7 +312,7 @@ mod tests {
|
||||
fn project_archived_blocked_item() {
|
||||
let view = PipelineItemView::for_test(
|
||||
"42_story_test",
|
||||
"6_archived",
|
||||
"archived",
|
||||
Some("Test".to_string()),
|
||||
None,
|
||||
None,
|
||||
@@ -333,6 +326,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(
|
||||
@@ -348,7 +342,7 @@ mod tests {
|
||||
fn project_archived_completed_item() {
|
||||
let view = PipelineItemView::for_test(
|
||||
"42_story_test",
|
||||
"6_archived",
|
||||
"archived",
|
||||
Some("Test".to_string()),
|
||||
None,
|
||||
None,
|
||||
@@ -362,6 +356,7 @@ mod tests {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let item = PipelineItem::try_from(&view).unwrap();
|
||||
assert!(matches!(
|
||||
@@ -388,23 +383,23 @@ mod tests {
|
||||
#[test]
|
||||
fn reverse_projection_stage_dirs() {
|
||||
let cases: Vec<(Stage, &str, bool)> = vec![
|
||||
(Stage::Upcoming, "0_upcoming", false),
|
||||
(Stage::Backlog, "1_backlog", false),
|
||||
(Stage::Coding, "2_current", false),
|
||||
(Stage::Upcoming, "upcoming", false),
|
||||
(Stage::Backlog, "backlog", false),
|
||||
(Stage::Coding, "coding", false),
|
||||
(
|
||||
Stage::Blocked {
|
||||
reason: "stuck".into(),
|
||||
},
|
||||
"2_blocked",
|
||||
"blocked",
|
||||
true,
|
||||
),
|
||||
(Stage::Qa, "3_qa", false),
|
||||
(Stage::Qa, "qa", false),
|
||||
(
|
||||
Stage::Merge {
|
||||
feature_branch: fb("f"),
|
||||
commits_ahead: nz(1),
|
||||
},
|
||||
"4_merge",
|
||||
"merge",
|
||||
false,
|
||||
),
|
||||
(
|
||||
@@ -412,7 +407,7 @@ mod tests {
|
||||
merged_at: Utc::now(),
|
||||
merge_commit: sha("abc"),
|
||||
},
|
||||
"5_done",
|
||||
"done",
|
||||
false,
|
||||
),
|
||||
(
|
||||
@@ -420,7 +415,7 @@ mod tests {
|
||||
archived_at: Utc::now(),
|
||||
reason: ArchiveReason::Completed,
|
||||
},
|
||||
"6_archived",
|
||||
"archived",
|
||||
false,
|
||||
),
|
||||
(
|
||||
@@ -430,7 +425,7 @@ mod tests {
|
||||
reason: "stuck".into(),
|
||||
},
|
||||
},
|
||||
"6_archived",
|
||||
"archived",
|
||||
true,
|
||||
),
|
||||
];
|
||||
@@ -442,6 +437,7 @@ mod tests {
|
||||
stage,
|
||||
depends_on: vec![],
|
||||
retry_count: 0,
|
||||
frozen: false,
|
||||
};
|
||||
let (dir, blocked) = item.to_crdt_fields();
|
||||
assert_eq!(dir, expected_dir);
|
||||
|
||||
@@ -571,129 +571,51 @@ fn cannot_reject_from_archived() {
|
||||
));
|
||||
}
|
||||
|
||||
// ── Freeze / Unfreeze ───────────────────────────────────────────────
|
||||
// ── Freeze / Unfreeze (story 934, stage 4: orthogonal flag) ────────────────
|
||||
|
||||
/// Freeze sets the `frozen` flag without changing the stage register.
|
||||
/// Unfreeze clears the flag — the stage was never touched so there's nothing
|
||||
/// to "restore". Tests the freeze/unfreeze API on the apply layer, since
|
||||
/// freeze/unfreeze are no longer pure stage transitions.
|
||||
#[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() {
|
||||
fn freeze_sets_flag_without_changing_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,
|
||||
"---\nname: Freeze Regression\n---\n# Story\n",
|
||||
crate::db::ItemMeta::named("Freeze Regression"),
|
||||
);
|
||||
|
||||
// Confirm starting stage.
|
||||
let item = read_typed(story_id).unwrap().unwrap();
|
||||
assert!(
|
||||
matches!(item.stage, Stage::Coding),
|
||||
"should start at Coding"
|
||||
);
|
||||
assert!(matches!(item.stage, Stage::Coding));
|
||||
assert!(!item.is_frozen());
|
||||
|
||||
// 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: {:?}",
|
||||
matches!(item.stage, Stage::Coding),
|
||||
"stage register stays at Coding 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
|
||||
);
|
||||
}
|
||||
assert!(item.is_frozen(), "frozen flag should be set after freeze");
|
||||
|
||||
// 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: {:?}",
|
||||
"stage register still at Coding after unfreeze: {:?}",
|
||||
item.stage
|
||||
);
|
||||
assert!(
|
||||
!item.is_frozen(),
|
||||
"frozen flag should be cleared after unfreeze"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Story 868: MergeFailure regression ─────────────────────────────
|
||||
@@ -745,7 +667,7 @@ fn merge_failure_transition_emits_event_with_full_reason() {
|
||||
.expect("item should exist");
|
||||
assert_eq!(
|
||||
item.stage.dir_name(),
|
||||
"4_merge_failure",
|
||||
"merge_failure",
|
||||
"CRDT stage should be 4_merge_failure"
|
||||
);
|
||||
}
|
||||
@@ -781,7 +703,7 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
|
||||
let story_id = "99913_story_merge_failure_selfloop";
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"4_merge_failure",
|
||||
"merge_failure",
|
||||
"---\nname: MergeFailure Self-loop Test\n---\n# Story\n",
|
||||
crate::db::ItemMeta::named("MergeFailure Self-loop Test"),
|
||||
);
|
||||
@@ -809,14 +731,14 @@ fn repeated_merge_failure_apply_transition_no_error_no_duplicate_notification()
|
||||
fired.after
|
||||
);
|
||||
|
||||
// Verify the CRDT stage is still 4_merge_failure.
|
||||
// Verify the CRDT stage is still merge_failure.
|
||||
let item = read_typed(story_id)
|
||||
.expect("CRDT read should succeed")
|
||||
.expect("item should still exist");
|
||||
assert_eq!(
|
||||
item.stage.dir_name(),
|
||||
"4_merge_failure",
|
||||
"CRDT stage should remain 4_merge_failure after self-loop"
|
||||
"merge_failure",
|
||||
"CRDT stage should remain merge_failure after self-loop"
|
||||
);
|
||||
|
||||
// Simulate the caller's de-dup logic: since fired.before is already MergeFailure,
|
||||
@@ -854,7 +776,7 @@ fn merge_failure_accept_moves_to_done_via_crdt() {
|
||||
let story_id = "99892_story_merge_failure_accept";
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"4_merge_failure",
|
||||
"merge_failure",
|
||||
"---\nname: MergeFailure Accept Test\n---\n# Story\n",
|
||||
crate::db::ItemMeta::named("MergeFailure Accept Test"),
|
||||
);
|
||||
@@ -883,14 +805,14 @@ fn merge_failure_accept_moves_to_done_via_crdt() {
|
||||
fired.event
|
||||
);
|
||||
|
||||
// CRDT reflects 5_done.
|
||||
// CRDT reflects done.
|
||||
let item = read_typed(story_id)
|
||||
.expect("CRDT read should succeed")
|
||||
.expect("item should exist");
|
||||
assert_eq!(
|
||||
item.stage.dir_name(),
|
||||
"5_done",
|
||||
"CRDT stage should be 5_done after MergeFailure + Accepted"
|
||||
"done",
|
||||
"CRDT stage should be done after MergeFailure + Accepted"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,10 +59,6 @@ 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 ───────────────────────────────────────────────
|
||||
@@ -102,8 +98,6 @@ pub fn event_label(e: &PipelineEvent) -> &'static str {
|
||||
PipelineEvent::Triage => "Triage",
|
||||
PipelineEvent::Close => "Close",
|
||||
PipelineEvent::Demote => "Demote",
|
||||
PipelineEvent::Freeze => "Freeze",
|
||||
PipelineEvent::Unfreeze => "Unfreeze",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,17 +261,6 @@ 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()),
|
||||
}
|
||||
|
||||
@@ -110,10 +110,6 @@ pub enum Stage {
|
||||
/// awaiting human intervention or retry. Unlike `Archived(MergeFailed)`,
|
||||
/// this is a recoverable intermediate state — `Unblock` returns to `Backlog`.
|
||||
MergeFailure { reason: String },
|
||||
|
||||
/// 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`,
|
||||
@@ -144,11 +140,6 @@ 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)
|
||||
@@ -183,35 +174,28 @@ impl Stage {
|
||||
/// accessing the rich metadata fields.
|
||||
pub fn from_dir(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"0_upcoming" => Some(Stage::Upcoming),
|
||||
"1_backlog" => Some(Stage::Backlog),
|
||||
"2_blocked" => Some(Stage::Blocked {
|
||||
"upcoming" => Some(Stage::Upcoming),
|
||||
"backlog" => Some(Stage::Backlog),
|
||||
"coding" => Some(Stage::Coding),
|
||||
"blocked" => Some(Stage::Blocked {
|
||||
reason: String::new(),
|
||||
}),
|
||||
"2_current" => Some(Stage::Coding),
|
||||
"3_qa" => Some(Stage::Qa),
|
||||
"4_merge" => Some(Stage::Merge {
|
||||
"qa" => Some(Stage::Qa),
|
||||
"merge" => Some(Stage::Merge {
|
||||
feature_branch: BranchName(String::new()),
|
||||
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
|
||||
}),
|
||||
"5_done" => Some(Stage::Done {
|
||||
"merge_failure" => Some(Stage::MergeFailure {
|
||||
reason: String::new(),
|
||||
}),
|
||||
"done" => Some(Stage::Done {
|
||||
merged_at: DateTime::<Utc>::UNIX_EPOCH,
|
||||
merge_commit: GitSha(String::new()),
|
||||
}),
|
||||
"6_archived" => Some(Stage::Archived {
|
||||
"archived" => Some(Stage::Archived {
|
||||
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.
|
||||
"4_merge_failure" => Some(Stage::MergeFailure {
|
||||
reason: String::new(),
|
||||
}),
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -257,6 +241,18 @@ pub struct PipelineItem {
|
||||
pub stage: Stage,
|
||||
pub depends_on: Vec<StoryId>,
|
||||
pub retry_count: u32,
|
||||
/// Whether the item is frozen — orthogonal to [`Self::stage`].
|
||||
/// Frozen items remain at their current stage but are skipped by the
|
||||
/// auto-assigner until explicitly unfrozen (story 934, stage 4).
|
||||
pub frozen: bool,
|
||||
}
|
||||
|
||||
impl PipelineItem {
|
||||
/// Whether the item is frozen. Frozen items stay at their current
|
||||
/// [`Stage`] but are skipped by the auto-assigner until unfrozen.
|
||||
pub fn is_frozen(&self) -> bool {
|
||||
self.frozen
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transition errors ───────────────────────────────────────────────────────
|
||||
@@ -293,22 +289,24 @@ pub fn stage_label(s: &Stage) -> &'static str {
|
||||
Stage::Done { .. } => "Done",
|
||||
Stage::Blocked { .. } => "Blocked",
|
||||
Stage::Archived { .. } => "Archived",
|
||||
Stage::Frozen { .. } => "Frozen",
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a Stage to the filesystem directory name used by the work pipeline.
|
||||
/// Map a Stage to its canonical wire-format string (story 934).
|
||||
///
|
||||
/// Post-934 emits clean vocabulary with no numeric prefix. Legacy directory
|
||||
/// strings (`"2_current"`, `"4_merge"`, etc.) are still accepted by `from_dir`
|
||||
/// for migration but are never produced here.
|
||||
pub fn stage_dir_name(s: &Stage) -> &'static str {
|
||||
match s {
|
||||
Stage::Upcoming => "0_upcoming",
|
||||
Stage::Backlog => "1_backlog",
|
||||
Stage::Coding => "2_current",
|
||||
Stage::Blocked { .. } => "2_blocked",
|
||||
Stage::Qa => "3_qa",
|
||||
Stage::Merge { .. } => "4_merge",
|
||||
Stage::MergeFailure { .. } => "4_merge_failure",
|
||||
Stage::Done { .. } => "5_done",
|
||||
Stage::Archived { .. } => "6_archived",
|
||||
Stage::Frozen { .. } => "7_frozen",
|
||||
Stage::Upcoming => "upcoming",
|
||||
Stage::Backlog => "backlog",
|
||||
Stage::Coding => "coding",
|
||||
Stage::Blocked { .. } => "blocked",
|
||||
Stage::Qa => "qa",
|
||||
Stage::Merge { .. } => "merge",
|
||||
Stage::MergeFailure { .. } => "merge_failure",
|
||||
Stage::Done { .. } => "done",
|
||||
Stage::Archived { .. } => "archived",
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user