use crate::error::{EngineError, Result};
use crate::event_log::{EventLog, LockForce};
use crate::events::EventKind;
use crate::orchestrator::canonical_root;
use crate::paths::MissionPaths;
use crate::reducer;
use crate::types::MissionStatus;
use std::path::PathBuf;
use std::time::Duration;
pub fn prune_mission_index(existing: &str, mission_id: &str) -> String {
if existing.trim().is_empty() {
return existing.to_string();
}
let marker = format!("[{mission_id}](");
let mut out = String::new();
for l in existing.lines() {
if l.contains(&marker) {
continue;
}
out.push_str(l);
out.push('\n');
}
out
}
pub fn mission_index_ids(existing: &str) -> Vec<String> {
let mut ids = Vec::new();
for l in existing.lines() {
if !l.contains("](") {
continue;
}
let Some(start) = l.find('[') else {
continue;
};
let rest = &l[start + 1..];
let Some(end) = rest.find("](") else {
continue;
};
let id = &rest[..end];
if !MissionPaths::is_safe_id(id) {
continue;
}
if !ids.iter().any(|existing_id: &String| existing_id == id) {
ids.push(id.to_string());
}
}
ids
}
pub fn prune_mission_index_file(repo_root: &std::path::Path, mission_id: &str) {
let index = MissionPaths::new(repo_root, "_")
.missions_dir()
.join("index.md");
let Ok(existing) = std::fs::read_to_string(&index) else {
return;
};
let updated = prune_mission_index(&existing, mission_id);
let _ = std::fs::write(&index, updated);
}
pub fn mark_mission_index_report(existing: &str, mission_id: &str) -> String {
let marker = format!("[{mission_id}](");
let link = format!("[report]({mission_id}/report.md)");
let mut out = String::new();
for l in existing.lines() {
out.push_str(l);
if l.contains(&marker) && !l.contains(&link) {
out.push_str(" · ");
out.push_str(&link);
}
out.push('\n');
}
out
}
pub fn abandon_mission(
repo_root: impl Into<PathBuf>,
mission_id: &str,
reason: &str,
force: LockForce,
) -> Result<()> {
let repo_root = canonical_root(repo_root.into());
let paths = MissionPaths::new(&repo_root, mission_id);
let events = EventLog::read_events(&paths.events_file())?;
let state = reducer::fold(&events)?;
if is_terminal_status(state.mission.status) {
return Err(EngineError::InvalidState(format!(
"mission '{mission_id}' is already terminal ({:?}); nothing to abandon",
state.mission.status
)));
}
let mut log = EventLog::acquire(
&paths,
mission_id,
Duration::from_millis(state.config.event_stream_throttle_ms),
force,
)?;
let (event, audits) = log.append_with_redaction_audits(EventKind::MissionAbandoned {
reason: reason.to_string(),
})?;
let mut state = state;
reducer::apply(&mut state, &event)?;
for audit in &audits {
reducer::apply(&mut state, audit)?;
}
reducer::write_snapshot(&state, &paths.state_file())?;
log.flush()?;
if let Err(e) = crate::work::reconcile_ticket_for_mission(&repo_root, mission_id) {
tracing::warn!(
error = %e,
mission_id,
"abandon: failed to reconcile the linked ticket"
);
}
Ok(())
}
pub fn is_terminal_status(status: MissionStatus) -> bool {
matches!(
status,
MissionStatus::Complete | MissionStatus::Failed | MissionStatus::Abandoned
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CleanClass {
Stale,
CompleteKeepByDefault,
Keep,
}
impl CleanClass {
pub fn is_cleaned(self, all: bool) -> bool {
match self {
CleanClass::Stale => true,
CleanClass::CompleteKeepByDefault => all,
CleanClass::Keep => false,
}
}
}
pub fn cleanable_class(status: MissionStatus, has_plan: bool) -> CleanClass {
match status {
MissionStatus::Failed | MissionStatus::Abandoned => CleanClass::Stale,
MissionStatus::Planning if !has_plan => CleanClass::Stale,
MissionStatus::Complete => CleanClass::CompleteKeepByDefault,
_ => CleanClass::Keep,
}
}
pub fn mission_lock_is_live(paths: &MissionPaths) -> bool {
crate::event_log::lock_holder_is_alive(&paths.lock_file())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mission_index_ids_rejects_ids_that_are_not_path_safe() {
let index = "\
# Kranz missions
Approved plans, newest last.
- 2026-07-28 · [m-real](m-real/plan.md) — real
- 2026-07-28 · [../../../tmp/evil](../../../tmp/evil/plan.md) — traversal
- 2026-07-28 · [a/b](a/b/plan.md) — separator
- 2026-07-28 · [C:evil](C:evil/plan.md) — drive designator
- 2026-07-28 · [m-..](m-../plan.md) — dot-dot substring
";
assert_eq!(mission_index_ids(index), vec!["m-real".to_string()]);
}
#[test]
fn abandon_reconcile_marks_the_linked_ticket_failed() {
let tmp = tempfile::tempdir().unwrap();
let repo = tmp.path();
crate::ticket::Ticket::scaffold(repo, "my-ticket", "fixture ticket", None, None).unwrap();
crate::ticket::Ticket::record_mission(repo, "my-ticket", "m-ab").unwrap();
crate::ticket::Ticket::write_state(
repo,
"my-ticket",
crate::ticket::TicketState::Running,
None,
)
.unwrap();
let dir = repo.join(".kranz").join("missions").join("m-ab");
std::fs::create_dir_all(&dir).unwrap();
let event = crate::events::Event {
seq: 1,
ts: chrono::Utc::now(),
mission_id: "m-ab".to_string(),
kind: EventKind::MissionCreated {
goal: "fixture mission".to_string(),
base_branch: "main".to_string(),
mission_branch: "kranz/mission-fixture".to_string(),
config: crate::types::MissionConfig::default(),
},
};
let mut lines = serde_json::to_string(&event).unwrap();
lines.push('\n');
std::fs::write(dir.join("events.jsonl"), lines).unwrap();
abandon_mission(repo, "m-ab", "test abandon", LockForce::No).unwrap();
assert_eq!(
crate::ticket::Ticket::read_state(repo, "my-ticket"),
crate::ticket::TicketState::Failed
);
}
}