Skip to main content

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}