kranz_engine/mission_catalog.rs
1//! Mission catalog + mission hygiene — extracted from `orchestrator.rs` in
2//! the monolith split (pure code motion, no behavior change). The catalog
3//! helpers edit `.kranz/missions/index.md` lines (prune, list ids, attach a
4//! report link); the hygiene family (roadmap M2) retires missions
5//! ([`abandon_mission`]) and classifies mission directories for `kranz clean`
6//! ([`cleanable_class`]) — lifecycle helpers kept deliberately OUTSIDE the
7//! run loop, touching neither orchestrator sessions nor worker turns.
8
9use crate::error::{EngineError, Result};
10use crate::event_log::{EventLog, LockForce};
11use crate::events::EventKind;
12use crate::orchestrator::canonical_root;
13use crate::paths::MissionPaths;
14use crate::reducer;
15use crate::types::MissionStatus;
16use std::path::PathBuf;
17use std::time::Duration;
18
19/// Remove a single mission's line from the missions catalog (deletion
20/// counterpart to [`crate::planning::upsert_mission_index`]), matched by
21/// the same `[<id>](` marker. Every other line and the header stay
22/// byte-for-byte; pruning an id with no line is a no-op (modulo
23/// trailing-newline normalization, same as [`mark_mission_index_report`]).
24pub fn prune_mission_index(existing: &str, mission_id: &str) -> String {
25 if existing.trim().is_empty() {
26 return existing.to_string();
27 }
28 let marker = format!("[{mission_id}](");
29 let mut out = String::new();
30 for l in existing.lines() {
31 if l.contains(&marker) {
32 continue;
33 }
34 out.push_str(l);
35 out.push('\n');
36 }
37 out
38}
39
40/// Every mission id appearing as `[<id>](` in the catalog body, in file
41/// order, de-duplicated. Tolerant of the trailing ` · [report](<id>/report.md)`
42/// link: the FIRST bracket on a line (the plan.md link) is taken as the id.
43///
44/// The catalog is repo-controlled data that every consumer joins into
45/// filesystem paths, so an id that is not path-safe (separators, `..`, drive
46/// designators — see [`MissionPaths::is_safe_id`]) is rejected here, before
47/// any filesystem access (P1 mission-path-no-follow).
48pub fn mission_index_ids(existing: &str) -> Vec<String> {
49 let mut ids = Vec::new();
50 for l in existing.lines() {
51 if !l.contains("](") {
52 continue;
53 }
54 let Some(start) = l.find('[') else {
55 continue;
56 };
57 let rest = &l[start + 1..];
58 let Some(end) = rest.find("](") else {
59 continue;
60 };
61 let id = &rest[..end];
62 if !MissionPaths::is_safe_id(id) {
63 continue;
64 }
65 if !ids.iter().any(|existing_id: &String| existing_id == id) {
66 ids.push(id.to_string());
67 }
68 }
69 ids
70}
71
72/// Prune one mission's line from `<repo>/.kranz/missions/index.md` and
73/// write the result back. A missing index file is a no-op — it is never
74/// created here.
75pub fn prune_mission_index_file(repo_root: &std::path::Path, mission_id: &str) {
76 let index = MissionPaths::new(repo_root, "_")
77 .missions_dir()
78 .join("index.md");
79 let Ok(existing) = std::fs::read_to_string(&index) else {
80 return;
81 };
82 let updated = prune_mission_index(&existing, mission_id);
83 let _ = std::fs::write(&index, updated);
84}
85
86/// Add a completion-report link to one mission's line in the missions
87/// catalog, turning
88/// `- <date> · [<id>](<id>/plan.md) — <goal>` into
89/// `- <date> · [<id>](<id>/plan.md) — <goal> · [report](<id>/report.md)`.
90///
91/// Idempotent; every other line — and the line format itself — stays
92/// untouched. When the mission has no line, the index comes back unchanged.
93pub fn mark_mission_index_report(existing: &str, mission_id: &str) -> String {
94 let marker = format!("[{mission_id}](");
95 let link = format!("[report]({mission_id}/report.md)");
96 let mut out = String::new();
97 for l in existing.lines() {
98 out.push_str(l);
99 if l.contains(&marker) && !l.contains(&link) {
100 out.push_str(" · ");
101 out.push_str(&link);
102 }
103 out.push('\n');
104 }
105 out
106}
107
108// ---------------------------------------------------------------------------
109// Mission hygiene (roadmap M2): abandon + clean classification
110//
111// These are lifecycle helpers kept deliberately OUTSIDE the run loop — they
112// never touch the orchestrator session and only ever append the terminal
113// `mission.abandoned` event or classify a directory for removal.
114// ---------------------------------------------------------------------------
115
116/// Retire a mission as [`MissionStatus::Abandoned`] — a terminal, operator-
117/// initiated end-of-life that is *not* a failure (§ roadmap M2 "mission
118/// hygiene"; the contract already defines the event + reducer mapping).
119///
120/// Acquires the single-writer lock via [`EventLog::acquire`], so a live engine
121/// holding it surfaces as [`EngineError::LockHeld`] (the CLI then tells the
122/// operator to stop the running mission; `--force-lock` steals only a lock
123/// whose holder is not provably alive, `--dangerously-steal-live-lock` steals
124/// even a live one). A mission that
125/// is already terminal (Complete/Failed/Abandoned) is rejected with
126/// [`EngineError::InvalidState`] — abandoning is only meaningful for live work.
127/// On success one `mission.abandoned` event is appended, the state snapshot is
128/// refreshed, the throttle-buffered log is flushed (the reconcile below
129/// re-folds from disk and must see the terminal event), and the linked ticket
130/// is reconciled exactly as the run/drain paths do — Abandoned maps to ticket
131/// `failed`, so a direct abandon cannot leave the ticket stuck in `running`.
132/// The reconcile is best-effort: a reconcile error is logged, never fails the
133/// abandon. The lock is released on drop.
134pub fn abandon_mission(
135 repo_root: impl Into<PathBuf>,
136 mission_id: &str,
137 reason: &str,
138 force: LockForce,
139) -> Result<()> {
140 let repo_root = canonical_root(repo_root.into());
141 let paths = MissionPaths::new(&repo_root, mission_id);
142
143 // Fold the existing log first so we can reject an already-terminal mission
144 // before writing anything.
145 let events = EventLog::read_events(&paths.events_file())?;
146 let state = reducer::fold(&events)?;
147 if is_terminal_status(state.mission.status) {
148 return Err(EngineError::InvalidState(format!(
149 "mission '{mission_id}' is already terminal ({:?}); nothing to abandon",
150 state.mission.status
151 )));
152 }
153
154 // Acquire the lock (LockHeld ⇒ a live engine owns this mission).
155 let mut log = EventLog::acquire(
156 &paths,
157 mission_id,
158 Duration::from_millis(state.config.event_stream_throttle_ms),
159 force,
160 )?;
161 let (event, audits) = log.append_with_redaction_audits(EventKind::MissionAbandoned {
162 reason: reason.to_string(),
163 })?;
164 // Fold the new event plus any redaction audits on top of the state we
165 // already have and snapshot, so state.json matches the log without a full
166 // re-fold.
167 let mut state = state;
168 reducer::apply(&mut state, &event)?;
169 for audit in &audits {
170 reducer::apply(&mut state, audit)?;
171 }
172 reducer::write_snapshot(&state, &paths.state_file())?;
173 // Flush before reconciling: appends are throttle-buffered, and the
174 // reconcile re-folds the log FROM DISK — it must see MissionAbandoned.
175 log.flush()?;
176 // Reconcile the linked ticket exactly as the run/drain paths do
177 // (Abandoned maps to ticket Failed): a direct abandon must not leave
178 // the ticket stuck in `running` (observed on m-a5a8fd). Best-effort —
179 // the abandon itself has already succeeded.
180 if let Err(e) = crate::work::reconcile_ticket_for_mission(&repo_root, mission_id) {
181 tracing::warn!(
182 error = %e,
183 mission_id,
184 "abandon: failed to reconcile the linked ticket"
185 );
186 }
187 // `log` drops here: buffer flushed, lock released.
188 Ok(())
189}
190
191/// Terminal mission statuses (no further work will ever run against them).
192pub fn is_terminal_status(status: MissionStatus) -> bool {
193 matches!(
194 status,
195 MissionStatus::Complete | MissionStatus::Failed | MissionStatus::Abandoned
196 )
197}
198
199/// How a mission directory classifies for `kranz clean`. The decision is a
200/// pure function of the folded status and whether a `plan.json` exists, so it
201/// is trivially testable in isolation from the filesystem walk.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum CleanClass {
204 /// Retire by default (`kranz clean`): the mission failed, was abandoned, or
205 /// is an abandoned-in-planning husk (still Planning with no plan.json).
206 Stale,
207 /// Only removed with `--all`: a Complete mission whose branch/report may
208 /// still be under review.
209 CompleteKeepByDefault,
210 /// Never cleaned: the mission is live (Planning-with-plan, Running, Paused,
211 /// Blocked, Validating).
212 Keep,
213}
214
215impl CleanClass {
216 /// Whether this class is removed given the `--all` opt-in.
217 pub fn is_cleaned(self, all: bool) -> bool {
218 match self {
219 CleanClass::Stale => true,
220 CleanClass::CompleteKeepByDefault => all,
221 CleanClass::Keep => false,
222 }
223 }
224}
225
226/// Classify a mission for cleaning from its folded `status` and whether a
227/// `plan.json` is present. Liveness (a held lock) is handled separately by the
228/// caller — a running mission is *never* cleaned regardless of this class.
229pub fn cleanable_class(status: MissionStatus, has_plan: bool) -> CleanClass {
230 match status {
231 MissionStatus::Failed | MissionStatus::Abandoned => CleanClass::Stale,
232 // An abandoned-in-planning husk: never approved a plan, so nothing on a
233 // branch to lose.
234 MissionStatus::Planning if !has_plan => CleanClass::Stale,
235 MissionStatus::Complete => CleanClass::CompleteKeepByDefault,
236 // Planning-with-plan, Running, Paused, Blocked, Validating: live work.
237 _ => CleanClass::Keep,
238 }
239}
240
241/// True when a mission's lock file records a holder that is still alive (a
242/// running engine). Delegates to the event-log module's canonical probe
243/// ([`crate::event_log::lock_holder_is_alive`]) — ONE source of truth for
244/// lock-file format and liveness semantics. A missing lock is not live; a
245/// present-but-unreadable/unparseable one is treated as live (conservative —
246/// a false "alive" only spares a directory from cleaning, while a false
247/// "dead" could delete a mission out from under a running engine); a
248/// provably-dead holder (ESRCH, or a token-proven pid reuse) is not live.
249/// Non-unix platforms cannot probe, so an existing lock reads as live.
250pub fn mission_lock_is_live(paths: &MissionPaths) -> bool {
251 crate::event_log::lock_holder_is_alive(&paths.lock_file())
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 #[test]
259 fn mission_index_ids_rejects_ids_that_are_not_path_safe() {
260 let index = "\
261# Kranz missions
262
263Approved plans, newest last.
264
265- 2026-07-28 · [m-real](m-real/plan.md) — real
266- 2026-07-28 · [../../../tmp/evil](../../../tmp/evil/plan.md) — traversal
267- 2026-07-28 · [a/b](a/b/plan.md) — separator
268- 2026-07-28 · [C:evil](C:evil/plan.md) — drive designator
269- 2026-07-28 · [m-..](m-../plan.md) — dot-dot substring
270";
271 assert_eq!(mission_index_ids(index), vec!["m-real".to_string()]);
272 }
273
274 /// A direct `kranz abandon` must reconcile the linked ticket exactly as
275 /// the run/drain paths do (Abandoned → ticket Failed) — a mission
276 /// abandoned outside those paths must not leave the ticket stuck in
277 /// `running` (observed live on m-a5a8fd).
278 #[test]
279 fn abandon_reconcile_marks_the_linked_ticket_failed() {
280 let tmp = tempfile::tempdir().unwrap();
281 let repo = tmp.path();
282 crate::ticket::Ticket::scaffold(repo, "my-ticket", "fixture ticket", None, None).unwrap();
283 crate::ticket::Ticket::record_mission(repo, "my-ticket", "m-ab").unwrap();
284 crate::ticket::Ticket::write_state(
285 repo,
286 "my-ticket",
287 crate::ticket::TicketState::Running,
288 None,
289 )
290 .unwrap();
291 let dir = repo.join(".kranz").join("missions").join("m-ab");
292 std::fs::create_dir_all(&dir).unwrap();
293 let event = crate::events::Event {
294 seq: 1,
295 ts: chrono::Utc::now(),
296 mission_id: "m-ab".to_string(),
297 kind: EventKind::MissionCreated {
298 goal: "fixture mission".to_string(),
299 base_branch: "main".to_string(),
300 mission_branch: "kranz/mission-fixture".to_string(),
301 config: crate::types::MissionConfig::default(),
302 },
303 };
304 let mut lines = serde_json::to_string(&event).unwrap();
305 lines.push('\n');
306 std::fs::write(dir.join("events.jsonl"), lines).unwrap();
307
308 abandon_mission(repo, "m-ab", "test abandon", LockForce::No).unwrap();
309
310 assert_eq!(
311 crate::ticket::Ticket::read_state(repo, "my-ticket"),
312 crate::ticket::TicketState::Failed
313 );
314 }
315}