Skip to content

Commit bfe4fde

Browse files
committed
fix: gate active missions against draft voyages
1 parent eba1264 commit bfe4fde

8 files changed

Lines changed: 180 additions & 3 deletions

File tree

crates/keel-cli/src/cli/commands/management/voyage/new.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::path::Path;
66
use anyhow::{Context, Result, anyhow};
77
use chrono::Local;
88

9+
use keel::domain::model::MissionStatus;
910
use keel::infrastructure::duplicate_ids::{self, DuplicateEntity};
1011
use keel::infrastructure::frontmatter_mutation::Mutation;
1112
use keel::infrastructure::loader::load_board;
@@ -42,7 +43,17 @@ fn new_voyage(board_dir: &Path, name: &str, epic_id: &str, goal: &str) -> Result
4243
}
4344

4445
// Verify epic exists
45-
board.require_epic(epic_id)?;
46+
let epic = board.require_epic(epic_id)?;
47+
if let Some(mission_id) = epic.frontmatter.mission.as_deref()
48+
&& let Some(mission) = board.missions.get(mission_id)
49+
&& mission.status() == MissionStatus::Active
50+
{
51+
return Err(anyhow!(
52+
"Cannot create draft voyage under active mission {} via epic {}. Plan voyage work before mission activation or pause the mission before adding draft voyage scope.",
53+
mission.id(),
54+
epic_id,
55+
));
56+
}
4657

4758
// Find next voyage number for this epic
4859
let next_num = find_next_voyage_num(&board, epic_id);
@@ -153,7 +164,7 @@ fn find_next_voyage_num(board: &keel::domain::model::Board, epic_id: &str) -> u3
153164
mod tests {
154165
use super::*;
155166
use keel::infrastructure::validation::{CheckId, structural};
156-
use keel::test_helpers::{TestBoardBuilder, TestEpic, TestVoyage};
167+
use keel::test_helpers::{TestBoardBuilder, TestEpic, TestMission, TestVoyage};
157168
use regex::Regex;
158169

159170
#[test]
@@ -215,6 +226,21 @@ mod tests {
215226
);
216227
}
217228

229+
#[test]
230+
fn test_new_voyage_rejects_active_mission_epic() {
231+
let temp = TestBoardBuilder::new()
232+
.mission(TestMission::new("M1").status("active"))
233+
.epic(TestEpic::new("test-epic").mission("M1"))
234+
.build();
235+
236+
let err = new_voyage(temp.path(), "New Voyage", "test-epic", "My goal")
237+
.unwrap_err()
238+
.to_string();
239+
240+
assert!(err.contains("active mission M1"), "{err}");
241+
assert!(err.contains("draft voyage"), "{err}");
242+
}
243+
218244
#[test]
219245
fn test_find_next_voyage_num() {
220246
let temp = TestBoardBuilder::new()

crates/keel-core/src/application/mission_lifecycle.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@ impl MissionLifecycleService {
4444
}
4545

4646
pub fn resume(board_dir: &Path, id: &str) -> Result<()> {
47+
let board = load_board(board_dir)?;
48+
let mission = board.require_mission(id)?;
49+
50+
let problems = evaluate_mission_transition(&board, mission, MissionTransition::Resume);
51+
if !problems.is_empty() {
52+
return Err(anyhow!(format_gate_error("mission", "resume", &problems)));
53+
}
54+
4755
execute(board_dir, id, &mission_transitions::RESUME)?;
4856
println!("Resumed mission: {}", id);
4957
Ok(())
@@ -494,6 +502,43 @@ mod tests {
494502
);
495503
}
496504

505+
#[test]
506+
fn test_mission_activate_fails_with_draft_voyage() {
507+
let temp = TestBoardBuilder::new()
508+
.mission(
509+
TestMission::new("M1")
510+
.title("Mission One")
511+
.status("defining"),
512+
)
513+
.epic(TestEpic::new("E1").mission("M1"))
514+
.voyage(TestVoyage::new("V1", "E1").status("draft"))
515+
.build();
516+
517+
let charter_path = temp.path().join("missions/M1/CHARTER.md");
518+
fs::write(charter_path, authored_charter("E1")).unwrap();
519+
520+
let res = MissionLifecycleService::activate(temp.path(), "M1");
521+
assert!(res.is_err());
522+
let err = res.unwrap_err().to_string();
523+
assert!(err.contains("draft voyage"), "{err}");
524+
assert!(err.contains("E1/V1"), "{err}");
525+
}
526+
527+
#[test]
528+
fn test_mission_resume_fails_with_draft_voyage() {
529+
let temp = TestBoardBuilder::new()
530+
.mission(TestMission::new("M1").title("Mission One").status("paused"))
531+
.epic(TestEpic::new("E1").mission("M1"))
532+
.voyage(TestVoyage::new("V1", "E1").status("draft"))
533+
.build();
534+
535+
let res = MissionLifecycleService::resume(temp.path(), "M1");
536+
assert!(res.is_err());
537+
let err = res.unwrap_err().to_string();
538+
assert!(err.contains("draft voyage"), "{err}");
539+
assert!(err.contains("E1/V1"), "{err}");
540+
}
541+
497542
#[test]
498543
fn test_mission_activate_allows_bearing_without_planned_epic() {
499544
let temp = TestBoardBuilder::new()

crates/keel-core/src/domain/state_machine/gating.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ pub fn evaluate_mission_transition(
7272
) -> Vec<Problem> {
7373
match transition {
7474
MissionTransition::Activate => evaluate_mission_activation(board, mission),
75+
MissionTransition::Resume => {
76+
crate::infrastructure::validation::missions::check_mission_draft_voyage_coherence(
77+
board, mission,
78+
)
79+
}
7580
MissionTransition::Achieve => evaluate_mission_achieve(board, mission),
7681
MissionTransition::Verify => evaluate_mission_verification(board, mission),
7782
_ => Vec::new(),
@@ -121,6 +126,12 @@ fn evaluate_mission_activation(
121126
),
122127
);
123128

129+
problems.extend(
130+
crate::infrastructure::validation::missions::check_mission_draft_voyage_coherence(
131+
board, mission,
132+
),
133+
);
134+
124135
problems.extend(
125136
crate::infrastructure::validation::charter::check_mission_charter_readiness(board, mission),
126137
);

crates/keel-core/src/infrastructure/validation/missions.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::domain::model::{Board, EpicState, Mission};
1+
use crate::domain::model::{Board, EpicState, Mission, VoyageState};
22

33
use super::{CheckId, GapCategory, Problem};
44

@@ -35,6 +35,36 @@ pub fn check_mission_actionable_lineage_readiness(
3535
]
3636
}
3737

38+
/// Active missions must not carry draft voyages in linked epics.
39+
pub fn check_mission_draft_voyage_coherence(board: &Board, mission: &Mission) -> Vec<Problem> {
40+
let mut draft_voyages: Vec<_> = board
41+
.epics_for_mission(mission.id())
42+
.into_iter()
43+
.flat_map(|epic| board.voyages_for_epic_id(epic.id()))
44+
.filter(|voyage| voyage.status() == VoyageState::Draft)
45+
.map(|voyage| voyage.scope_path())
46+
.collect();
47+
draft_voyages.sort();
48+
49+
if draft_voyages.is_empty() {
50+
return Vec::new();
51+
}
52+
53+
vec![
54+
Problem::error(
55+
mission.path.clone(),
56+
format!(
57+
"Mission {} cannot be active while linked epics contain draft voyage(s): {}. Plan or remove draft voyages before activating or continuing the mission.",
58+
mission.id(),
59+
draft_voyages.join(", "),
60+
),
61+
)
62+
.with_scope(mission.id())
63+
.with_category(GapCategory::Coherence)
64+
.with_check_id(CheckId::MissionDraftVoyageCoherence),
65+
]
66+
}
67+
3868
pub fn mission_non_terminal_children(board: &Board, mission: &Mission) -> Vec<String> {
3969
let mut children = Vec::new();
4070

crates/keel-core/src/infrastructure/validation/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ pub enum CheckId {
223223
BearingDanglingDependency,
224224
BearingDependencyCycle,
225225
MissionDefinitionReadiness,
226+
MissionDraftVoyageCoherence,
226227
MissionGoalAchieved,
227228
MissionActiveNoWork,
228229
MissionOrphanedLineage,

crates/keel-core/src/read_model/diagnostics/catalog.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,11 @@ pub const ALL_DOCTOR_CHECKS: &[DoctorCheckDefinition] = &[
321321
section: "Missions",
322322
name: "Active mission work coherence",
323323
},
324+
DoctorCheckDefinition {
325+
id: "mission-active-draft-voyages",
326+
section: "Missions",
327+
name: "Active mission draft voyages",
328+
},
324329
DoctorCheckDefinition {
325330
id: "mission-orphaned-lineage",
326331
section: "Missions",

crates/keel-core/src/read_model/diagnostics/checks/missions.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,23 @@ pub fn check_mission_active_no_work(board: &Board) -> Vec<Problem> {
421421
problems
422422
}
423423

424+
/// Check for active missions carrying draft voyages in linked epics.
425+
pub fn check_mission_active_draft_voyages(board: &Board) -> Vec<Problem> {
426+
let mut problems = Vec::new();
427+
428+
for mission in board.missions.values() {
429+
if mission.status() != MissionStatus::Active {
430+
continue;
431+
}
432+
433+
problems.extend(missions::check_mission_draft_voyage_coherence(
434+
board, mission,
435+
));
436+
}
437+
438+
problems
439+
}
440+
424441
/// Check for orphaned mission lineage
425442
pub fn check_mission_orphans(board: &Board) -> Vec<Problem> {
426443
let mut problems = Vec::new();
@@ -870,6 +887,38 @@ mod tests {
870887
assert_eq!(problems[0].severity, Severity::Error);
871888
}
872889

890+
#[test]
891+
fn test_check_mission_active_draft_voyages_flags_active_mission() {
892+
let temp = TestBoardBuilder::new()
893+
.mission(TestMission::new("M1").status("active"))
894+
.epic(TestEpic::new("E1").mission("M1"))
895+
.voyage(TestVoyage::new("V1", "E1").status("draft"))
896+
.build();
897+
898+
let board = load_board(temp.path()).unwrap();
899+
let problems = check_mission_active_draft_voyages(&board);
900+
901+
assert_eq!(problems.len(), 1);
902+
assert_eq!(problems[0].check_id, CheckId::MissionDraftVoyageCoherence);
903+
assert_eq!(problems[0].severity, Severity::Error);
904+
assert!(problems[0].message.contains("draft voyage"));
905+
assert!(problems[0].message.contains("E1/V1"));
906+
}
907+
908+
#[test]
909+
fn test_check_mission_active_draft_voyages_ignores_defining_mission() {
910+
let temp = TestBoardBuilder::new()
911+
.mission(TestMission::new("M1").status("defining"))
912+
.epic(TestEpic::new("E1").mission("M1"))
913+
.voyage(TestVoyage::new("V1", "E1").status("draft"))
914+
.build();
915+
916+
let board = load_board(temp.path()).unwrap();
917+
let problems = check_mission_active_draft_voyages(&board);
918+
919+
assert!(problems.is_empty());
920+
}
921+
873922
#[test]
874923
fn test_check_mission_completion_evidence() {
875924
let temp = TestBoardBuilder::new()

crates/keel-core/src/read_model/diagnostics/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,16 @@ fn validate_with_config_internal(
666666
mission_work_problems,
667667
));
668668

669+
let mission_draft_voyage_problems =
670+
checks::missions::check_mission_active_draft_voyages(&board);
671+
mission_checks.push(configured_check(
672+
doctor_config,
673+
"mission-active-draft-voyages",
674+
"Active mission draft voyages",
675+
board.missions.len(),
676+
mission_draft_voyage_problems,
677+
));
678+
669679
let mission_orphan_problems = checks::missions::check_mission_orphans(&board);
670680
mission_checks.push(configured_check(
671681
doctor_config,

0 commit comments

Comments
 (0)