Skip to main content

kranz_engine/
deps.rs

1//! The `blocked-by` dependency primitive: shared checks the CLI and REST
2//! approve paths both call so a ticket can't be approved (or drafted into a
3//! cycle) while a dependency is outstanding.
4
5use crate::error::{EngineError, Result};
6use crate::event_log::EventLog;
7use crate::paths::MissionPaths;
8use crate::queue::{self, QueueEntry};
9use crate::reducer;
10use crate::ticket::{Ticket, TicketState};
11use crate::types::MissionStatus;
12use std::collections::HashSet;
13use std::path::Path;
14
15/// The current status of a mission by id, read by folding its event log.
16/// `None` when the mission has no event log at all (never drafted, or the
17/// slug's recorded `missionId` is stale).
18fn mission_status(repo_root: &Path, mission_id: &str) -> Option<MissionStatus> {
19    let paths = MissionPaths::new(repo_root, mission_id);
20    let events = EventLog::read_events(&paths.events_file()).ok()?;
21    let state = reducer::fold(&events).ok()?;
22    Some(state.mission.status)
23}
24
25/// Blocker slugs for `slug` whose mission has not reached
26/// [`MissionStatus::Complete`]. A blocker with no ticket file, no recorded
27/// mission, or any non-Complete status counts as unsatisfied — satisfaction
28/// is authoritative on mission status, never on ticket/queue state.
29pub fn unsatisfied_blockers(repo_root: &Path, slug: &str) -> Result<Vec<String>> {
30    Ticket::ensure_valid_slug(slug)?;
31    let path = Ticket::tickets_dir(repo_root).join(format!("{slug}.md"));
32    let ticket = Ticket::load(&path)?;
33
34    let mut unsatisfied = Vec::new();
35    for blocker in &ticket.blocked_by {
36        Ticket::ensure_valid_slug(blocker)?;
37        let satisfied = Ticket::mission_for(repo_root, blocker)
38            .and_then(|mission_id| mission_status(repo_root, &mission_id))
39            .is_some_and(|status| status == MissionStatus::Complete);
40        if !satisfied {
41            unsatisfied.push(blocker.clone());
42        }
43    }
44    Ok(unsatisfied)
45}
46
47/// Single source of blocked-ness. Approve gates (dashboard + Slack) and
48/// every rendering surface MUST call this, never re-derive.
49pub fn is_blocked(repo_root: &Path, slug: &str) -> Result<bool> {
50    Ok(!unsatisfied_blockers(repo_root, slug)?.is_empty())
51}
52
53/// DFS the `blocked-by` edges across ticket files starting from `slug`. When
54/// a cycle is reachable, returns `Some(path)` listing the slugs that form it
55/// in order (e.g. `[a, b, a]`); a ticket file missing along the way
56/// terminates that branch since it cannot extend a cycle.
57pub fn detect_cycle(repo_root: &Path, slug: &str) -> Result<Option<Vec<String>>> {
58    Ticket::ensure_valid_slug(slug)?;
59    let mut path = vec![slug.to_string()];
60    let mut on_path: HashSet<String> = HashSet::new();
61    on_path.insert(slug.to_string());
62    dfs(repo_root, slug, &mut path, &mut on_path)
63}
64
65fn dfs(
66    repo_root: &Path,
67    current: &str,
68    path: &mut Vec<String>,
69    on_path: &mut HashSet<String>,
70) -> Result<Option<Vec<String>>> {
71    let ticket_path = Ticket::tickets_dir(repo_root).join(format!("{current}.md"));
72    let Ok(ticket) = Ticket::load(&ticket_path) else {
73        // No ticket file here: this branch cannot extend a cycle.
74        return Ok(None);
75    };
76
77    for blocker in &ticket.blocked_by {
78        Ticket::ensure_valid_slug(blocker)?;
79
80        if on_path.contains(blocker) {
81            let mut cycle = path.clone();
82            cycle.push(blocker.clone());
83            let start = cycle
84                .iter()
85                .position(|s| s == blocker)
86                .expect("blocker is in on_path, so it is in path");
87            return Ok(Some(cycle[start..].to_vec()));
88        }
89
90        path.push(blocker.clone());
91        on_path.insert(blocker.clone());
92        if let Some(cycle) = dfs(repo_root, blocker, path, on_path)? {
93            return Ok(Some(cycle));
94        }
95        path.pop();
96        on_path.remove(blocker);
97    }
98
99    Ok(None)
100}
101
102// ---------------------------------------------------------------------------
103// approve_ticket — the shared gate + side effects (CLI `ticket approve` and
104// REST `POST /api/tickets/:slug/approve`)
105// ---------------------------------------------------------------------------
106
107/// A ticket approved into the queue: the mission id and priority it was
108/// enqueued with.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct ApprovedTicket {
111    pub mission_id: String,
112    pub priority: u8,
113}
114
115/// The one approve gate + enqueue side effect shared by the CLI (`kranz
116/// ticket approve`) and the REST `POST /api/tickets/:slug/approve` handler,
117/// so the two surfaces can never drift apart on what "approvable" means.
118///
119/// Refuses (via [`EngineError::InvalidState`]) when: the ticket's
120/// `defer-until` is still in the future and `force` is false (D-BW-3 — the
121/// refusal names the defer time); the ticket is not [`TicketState::Review`]
122/// or [`TicketState::Parked`]; a `blocked-by` cycle is reachable from `slug`
123/// (never overridable by `force`); or an unsatisfied blocker exists and
124/// `force` is false. On success, enqueues the ticket's drafted mission
125/// (`explicit_mission`, else the recorded/discovered one) and sets the ticket
126/// [`TicketState::Queued`].
127///
128/// [`TicketState::Parked`] is accepted so a readiness park can be re-queued
129/// after the operator fixes auth/binaries — the plan is already committed.
130pub fn approve_ticket(
131    repo_root: &Path,
132    slug: &str,
133    explicit_mission: Option<&str>,
134    force: bool,
135) -> Result<ApprovedTicket> {
136    Ticket::ensure_valid_slug(slug)?;
137    let ticket_path = Ticket::tickets_dir(repo_root).join(format!("{slug}.md"));
138    let ticket = Ticket::load(&ticket_path)?;
139
140    // Deferral (D-BW-3): a ticket whose `defer-until` is still in the future
141    // is fail-closed refused, naming the time — never silently skipped. The
142    // clock is the only arbiter (no scheduler); `--force` overrides.
143    if let Some(defer_until) = ticket.defer_until {
144        if defer_until > chrono::Utc::now() && !force {
145            return Err(EngineError::InvalidState(format!(
146                "cannot approve {slug}: deferred until {} (re-run with --force \
147                 to queue it anyway)",
148                defer_until.to_rfc3339()
149            )));
150        }
151    }
152
153    let state = Ticket::read_state(repo_root, slug);
154    if !matches!(state, TicketState::Review | TicketState::Parked) {
155        return Err(EngineError::InvalidState(format!(
156            "ticket '{slug}' is {} — only a REVIEW or PARKED ticket \
157             can be queued; run `kranz draft {slug}` first",
158            ticket_state_label(state)
159        )));
160    }
161
162    if let Some(cycle) = detect_cycle(repo_root, slug)? {
163        return Err(EngineError::InvalidState(format!(
164            "blocked-by cycle: {}",
165            cycle.join(" -> ")
166        )));
167    }
168    let unsatisfied = unsatisfied_blockers(repo_root, slug)?;
169    if !unsatisfied.is_empty() && !force {
170        return Err(EngineError::InvalidState(format!(
171            "cannot approve {slug}: blocked by {} (its mission is not Complete)",
172            unsatisfied.join(", ")
173        )));
174    }
175
176    let mission_id = match explicit_mission {
177        Some(id) => id.to_string(),
178        None => Ticket::mission_for(repo_root, slug)
179            .or_else(|| find_mission_for_ticket(repo_root, &ticket))
180            .ok_or_else(|| {
181                EngineError::InvalidState(format!(
182                    "could not find the drafted mission for ticket '{slug}' automatically — \
183                     pass one explicitly (see `kranz missions`)"
184                ))
185            })?,
186    };
187
188    let entry = queue::enqueue(
189        repo_root,
190        QueueEntry {
191            mission_id,
192            ticket_slug: Some(slug.to_string()),
193            priority: ticket.priority,
194            seq: 0,
195        },
196    )?;
197    Ticket::write_state(repo_root, slug, TicketState::Queued, None)?;
198    Ok(ApprovedTicket {
199        mission_id: entry.mission_id,
200        priority: entry.priority,
201    })
202}
203
204/// UPPERCASE label for a ticket pipeline state, matching
205/// `kranz_cli::backlog::ticket_state_label` — duplicated here (rather than
206/// depended on) since the CLI crate depends on this one, not the reverse.
207/// Only feeds internal error messaging (never a terminal/Done label surfaced
208/// to an operator); the CLI's Delivered/Landed split for `Done` lives
209/// entirely in `kranz_cli::backlog::ticket_terminal_label`.
210fn ticket_state_label(state: TicketState) -> &'static str {
211    match state {
212        TicketState::New => "NEW",
213        TicketState::Drafting => "DRAFTING",
214        TicketState::NeedsContext => "NEEDS-CONTEXT",
215        TicketState::WrongPlan => "WRONG-PLAN",
216        TicketState::Review => "REVIEW",
217        TicketState::Queued => "QUEUED",
218        TicketState::Running => "RUNNING",
219        TicketState::Done => "DONE",
220        TicketState::Failed => "FAILED",
221        TicketState::Parked => "PARKED",
222        TicketState::Superseded => "SUPERSEDED",
223        TicketState::Wontfix => "WONTFIX",
224    }
225}
226
227/// Legacy fallback when no recorded link exists (missions drafted before the
228/// sidecar carried `missionId`): newest mission whose goal matches.
229fn find_mission_for_ticket(repo_root: &Path, ticket: &Ticket) -> Option<String> {
230    let goal = ticket.mission_goal();
231    let mut best: Option<(std::time::SystemTime, String)> = None;
232    for id in MissionPaths::list_missions(repo_root) {
233        let paths = MissionPaths::new(repo_root, &id);
234        let Ok(events) = EventLog::read_events(&paths.events_file()) else {
235            continue;
236        };
237        let Ok(state) = reducer::fold(&events) else {
238            continue;
239        };
240        if state.mission.goal != goal {
241            continue;
242        }
243        let mtime = std::fs::metadata(paths.events_file())
244            .and_then(|m| m.modified())
245            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
246        if best.as_ref().is_none_or(|(t, _)| mtime >= *t) {
247            best = Some((mtime, id));
248        }
249    }
250    best.map(|(_, id)| id)
251}