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:
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user