Skip to main content

ljos_cli/
reap.rs

1//! Reap idle claims: a sitting nobody finished goes back to ready, and its
2//! seat hears why. A claim is idle when neither its claim time nor the
3//! newest tracker commit naming it falls inside the window.
4
5use anyhow::{bail, Result};
6use chrono::{DateTime, Local, NaiveDateTime, TimeZone};
7use serde_json::Value;
8use std::time::Duration;
9
10/// One claim the reaper looked at.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Held {
13    pub id: String,
14    pub holder: String,
15    pub title: String,
16    /// Newest sign of life: claim time or last tracker commit, Unix seconds.
17    pub last: Option<i64>,
18}
19
20/// `6h`, `90m`, `2d`, `45s`.
21pub fn parse_idle(s: &str) -> Result<Duration> {
22    let s = s.trim();
23    let (n, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
24    let n: u64 = n
25        .parse()
26        .map_err(|_| anyhow::anyhow!("reap: --idle {s:?} wants a number and s, m, h or d"))?;
27    let secs = match unit {
28        "s" => n,
29        "m" | "" => n * 60,
30        "h" => n * 3600,
31        "d" => n * 86400,
32        _ => bail!("reap: --idle {s:?} wants a number and s, m, h or d"),
33    };
34    Ok(Duration::from_secs(secs))
35}
36
37/// The tracker's `[2026-10-13 Tue 16:01]`, read in this machine's zone.
38pub fn org_ts(s: &str) -> Option<i64> {
39    let t = s
40        .trim()
41        .trim_matches(|c| c == '[' || c == '<' || c == ']' || c == '>');
42    let mut parts = t.split_whitespace();
43    let date = parts.next()?;
44    let time = parts.find(|p| p.contains(':')).unwrap_or("00:00");
45    let naive = NaiveDateTime::parse_from_str(&format!("{date} {time}"), "%Y-%m-%d %H:%M").ok()?;
46    Local
47        .from_local_datetime(&naive)
48        .earliest()
49        .map(|d| d.timestamp())
50}
51
52/// The seat a holder names: `eessi@main` is the seat `eessi`, and a legacy
53/// holder is the seat it names (`inky-n9o` is `inky`).
54#[must_use]
55pub fn seat_of(holder: &str) -> String {
56    let seat = crate::canonical_seat_name(holder);
57    if seat.is_empty() {
58        holder
59            .split('@')
60            .next()
61            .unwrap_or(holder)
62            .trim()
63            .to_string()
64    } else {
65        seat
66    }
67}
68
69/// Claims from `vissue claims --json`, `last` set from the claim time.
70pub fn held_from_json(json: &str) -> Result<Vec<Held>> {
71    let v: Value = serde_json::from_str(json)?;
72    Ok(v.as_array()
73        .into_iter()
74        .flatten()
75        .filter_map(|c| {
76            Some(Held {
77                id: c["id"].as_str()?.to_string(),
78                holder: c["holder"].as_str()?.to_string(),
79                title: c["title"].as_str().unwrap_or("").to_string(),
80                last: c["claimed_at"].as_str().and_then(org_ts),
81            })
82        })
83        .collect())
84}
85
86/// The claims idle past `idle` at `now`. A claim with no time is idle.
87pub fn idle_claims(held: &[Held], idle: Duration, now: i64) -> Vec<Held> {
88    let cut = now - idle.as_secs() as i64;
89    held.iter()
90        .filter(|h| h.last.is_none_or(|t| t < cut))
91        .cloned()
92        .collect()
93}
94
95/// Newest tracker commit whose subject names `id`, Unix seconds.
96fn last_commit(id: &str) -> Option<i64> {
97    let root = std::env::var("VISSUE_ROOT").ok()?;
98    let out = crate::git_cmd()
99        .args(["-C", &root, "log", "-1", "--format=%ct", "-F", "--grep", id])
100        .output()
101        .ok()?;
102    String::from_utf8_lossy(&out.stdout).trim().parse().ok()
103}
104
105fn when(t: Option<i64>) -> String {
106    t.and_then(|t| DateTime::from_timestamp(t, 0))
107        .map(|d| d.with_timezone(&Local).format("%Y-%m-%d %H:%M").to_string())
108        .unwrap_or_else(|| "never".to_string())
109}
110
111/// Release every claim idle past `idle` and mail its seat. Without
112/// `apply`, print what would happen and write nothing.
113pub fn reap(idle: Duration, apply: bool) -> Result<String> {
114    let said = crate::run_captured("vissue", &["claims", "--json"])?;
115    let mut held = held_from_json(&said.stdout)?;
116    for h in &mut held {
117        if let Some(c) = last_commit(&h.id) {
118            h.last = Some(h.last.map_or(c, |t| t.max(c)));
119        }
120    }
121    let now = Local::now().timestamp();
122    let idle_ones = idle_claims(&held, idle, now);
123    let mut out = String::new();
124    let hours = idle.as_secs() as f64 / 3600.0;
125    for h in &idle_ones {
126        let seat = seat_of(&h.holder);
127        let note = format!(
128            "auto-released {}: idle since {} (over {hours:.1}h). Run `ljos claim {}` to pick it up again, or `ljos finish {} --status done` when it is done.",
129            h.id, when(h.last), h.id, h.id
130        );
131        if !apply {
132            out.push_str(&format!(
133                "would release {} held by {} (last {}) and mail {seat}\n",
134                h.id,
135                h.holder,
136                when(h.last)
137            ));
138            continue;
139        }
140        match crate::release(&h.id, &h.holder) {
141            Ok(s) => {
142                out.push_str(&s);
143                out.push_str(&crate::persist_tracker(&h.id, "released"));
144            }
145            Err(e) => {
146                out.push_str(&format!(
147                    "reap: {} held by {}: release refused: {e}\n",
148                    h.id, h.holder
149                ));
150                continue;
151            }
152        }
153        let mail = crate::mail::send(&crate::mail::Send {
154            seat: Some(&seat),
155            group: None,
156            text: &note,
157            interrupt: false,
158            issue: Some(&h.id),
159            new_seat: true,
160        });
161        match mail {
162            Ok(s) => out.push_str(&s),
163            Err(e) => out.push_str(&format!("reap: {}: mail to {seat} failed: {e}\n", h.id)),
164        }
165    }
166    out.push_str(&enforce_quorum_deadlines(apply));
167    out.push_str(&crate::converge::reap_runaways(apply));
168    out.push_str(&format!(
169        "reap: {} of {} claims idle over {hours:.1}h{}\n",
170        idle_ones.len(),
171        held.len(),
172        if apply {
173            ""
174        } else {
175            "; dry run, pass --apply to release"
176        }
177    ));
178    Ok(out)
179}
180
181/// The note `ljos file --quorum N --deadline T` leaves on a decision.
182pub const QUORUM_NOTE: &str = "quorum: ";
183
184/// Open issues whose vote is due: the latest `quorum: N by RFC3339` note,
185/// a deadline before `now`, and no `quorum timed out` note yet. From
186/// `vissue export` (one JSON issue a line).
187#[must_use]
188pub fn due_quorums(export: &str, now: DateTime<chrono::Utc>) -> Vec<(String, usize, String)> {
189    let mut due = Vec::new();
190    for line in export.lines() {
191        let Ok(v) = serde_json::from_str::<Value>(line) else {
192            continue;
193        };
194        if matches!(v["state"].as_str(), Some("DONE" | "CANCELLED")) {
195            continue;
196        }
197        let notes: Vec<&str> = v["logbook"]
198            .as_array()
199            .into_iter()
200            .flatten()
201            .filter_map(|e| e["note"].as_str())
202            .collect();
203        if notes.iter().any(|n| n.contains("quorum timed out")) {
204            continue;
205        }
206        // The logbook is newest first.
207        let Some(rest) = notes
208            .iter()
209            .find_map(|n| n.trim().strip_prefix(QUORUM_NOTE))
210        else {
211            continue;
212        };
213        let Some((n, at)) = rest.split_once(" by ") else {
214            continue;
215        };
216        let (Ok(n), Ok(at_t)) = (
217            n.trim().parse::<usize>(),
218            DateTime::parse_from_rfc3339(at.trim()),
219        ) else {
220            continue;
221        };
222        if at_t < now {
223            due.push((
224                v["id"].as_str().unwrap_or("").to_string(),
225                n,
226                at.trim().to_string(),
227            ));
228        }
229    }
230    due
231}
232
233/// What a quorum count decided. The published tracker has no `--quorum`
234/// flag, so the count is the ballots that count: one per session.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum QuorumGate {
237    /// At least as many ballots as the note asked for.
238    Met { have: usize },
239    /// Short, and the deadline has not passed.
240    Short { have: usize, needed: usize },
241    /// Short, and the deadline has passed. The tracker carries one
242    /// `quorum timed out` note.
243    Lapsed { have: usize, needed: usize },
244}
245
246/// Count the ballots on `id` against `needed`. A deadline that has passed
247/// while the count is short notes the lapse once. A session's later
248/// ballots are not in the count.
249///
250/// # Errors
251///
252/// The tracker did not list the ballots.
253pub fn gate_quorum(id: &str, needed: usize, deadline: Option<&str>) -> Result<QuorumGate> {
254    let raw = crate::raw_ballot_rows(id)
255        .map_err(|e| anyhow::anyhow!("quorum: the tracker did not list ballots on {id}: {e:#}"))?;
256    let have = crate::panel_gate::counted_ballots(id, &raw).len();
257    if have >= needed {
258        return Ok(QuorumGate::Met { have });
259    }
260    let past = deadline
261        .map(str::trim)
262        .filter(|s| !s.is_empty())
263        .and_then(|d| DateTime::parse_from_rfc3339(d).ok())
264        .is_some_and(|at| at < chrono::Utc::now());
265    if !past {
266        return Ok(QuorumGate::Short { have, needed });
267    }
268    if !quorum_already_noted(id) {
269        let note = format!("quorum timed out: {have} of {needed}");
270        crate::run_captured("vissue", &["note", id, &note])
271            .map_err(|e| anyhow::anyhow!("quorum: the lapse on {id} was not noted: {e:#}"))?;
272    }
273    Ok(QuorumGate::Lapsed { have, needed })
274}
275
276fn quorum_already_noted(id: &str) -> bool {
277    crate::issue_record(id)
278        .ok()
279        .into_iter()
280        .flat_map(|v| crate::ballot_sig::notes_of(&v))
281        .any(|note| note.contains("quorum timed out"))
282}
283
284/// Run the quorum count on every vote that is due. A vote short of its
285/// quorum lapses (one note); one that met its quorum is left to its settle.
286/// A dry run says which would be checked.
287fn enforce_quorum_deadlines(apply: bool) -> String {
288    let Ok(said) = crate::run_captured("vissue", &["export"]) else {
289        return String::new();
290    };
291    let mut out = String::new();
292    for (id, n, at) in due_quorums(&said.stdout, chrono::Utc::now()) {
293        if !apply {
294            out.push_str(&format!("would check {id}: quorum {n} was due {at}\n"));
295            continue;
296        }
297        match gate_quorum(&id, n, Some(&at)) {
298            Ok(QuorumGate::Lapsed { .. }) => {
299                out.push_str(&format!(
300                    "lapsed {id}: fewer than {n} ballots by {at}; noted, nothing decided\n"
301                ));
302                out.push_str(&crate::persist_tracker(&id, "quorum timed out"));
303            }
304            Ok(QuorumGate::Met { .. }) => {
305                out.push_str(&format!("{id}: quorum {n} met by {at}\n"));
306            }
307            Ok(QuorumGate::Short { have, needed }) => {
308                out.push_str(&format!("{id}: quorum short, {have} of {needed} by {at}\n"))
309            }
310            Err(e) => out.push_str(&format!("reap: {id}: quorum gate did not run: {e:#}\n")),
311        }
312    }
313    out
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    #[test]
321    fn a_vote_past_its_deadline_is_due_once() {
322        let past = r#"{"id":"d-1","state":"TODO","logbook":[{"note":"quorum: 3 by 2000-01-01T00:00:00Z"}]}"#;
323        let future = r#"{"id":"d-2","state":"TODO","logbook":[{"note":"quorum: 3 by 2999-01-01T00:00:00Z"}]}"#;
324        let lapsed = r#"{"id":"d-3","state":"TODO","logbook":[{"note":"quorum timed out: 1 of 3"},{"note":"quorum: 3 by 2000-01-01T00:00:00Z"}]}"#;
325        let closed = r#"{"id":"d-4","state":"DONE","logbook":[{"note":"quorum: 3 by 2000-01-01T00:00:00Z"}]}"#;
326        let plain = r#"{"id":"d-5","state":"TODO","logbook":[]}"#;
327        let all = [past, future, lapsed, closed, plain].join("\n");
328        let due = due_quorums(&all, chrono::Utc::now());
329        assert_eq!(
330            due,
331            vec![("d-1".to_string(), 3, "2000-01-01T00:00:00Z".to_string())]
332        );
333    }
334
335    #[test]
336    fn idle_parses_units() {
337        assert_eq!(parse_idle("6h").unwrap().as_secs(), 21600);
338        assert_eq!(parse_idle("90m").unwrap().as_secs(), 5400);
339        assert_eq!(parse_idle("2d").unwrap().as_secs(), 172800);
340        assert!(parse_idle("6x").is_err());
341        assert!(parse_idle("h").is_err());
342    }
343
344    #[test]
345    fn holder_names_its_seat() {
346        assert_eq!(seat_of("eessi@main"), "eessi");
347        assert_eq!(seat_of("inky"), "inky");
348        assert_eq!(seat_of("inky-n9o"), "inky");
349        assert_eq!(seat_of("ljos-bot@q4s23e2cxd"), "ljosbot");
350    }
351
352    #[test]
353    fn only_old_claims_are_idle() {
354        let json = r#"[
355          {"id":"a-1","holder":"inky","title":"old","claimed_at":"[2026-10-13 Tue 06:00]"},
356          {"id":"a-2","holder":"eessi@main","title":"new","claimed_at":"[2026-10-13 Tue 16:00]"},
357          {"id":"a-3","holder":"travel","title":"no time"}
358        ]"#;
359        let held = held_from_json(json).unwrap();
360        let now = org_ts("[2026-10-13 Tue 19:00]").unwrap();
361        let ids: Vec<_> = idle_claims(&held, parse_idle("6h").unwrap(), now)
362            .into_iter()
363            .map(|h| h.id)
364            .collect();
365        assert_eq!(ids, ["a-1", "a-3"]);
366    }
367}
368
369#[cfg(test)]
370mod log_tests {
371    #[test]
372    fn recall_log_appends_one_line_per_call() {
373        // Its own directory, not XDG_STATE_HOME: other tests set that env
374        // in parallel, and a shared env made this flake.
375        let dir = tempfile::tempdir().unwrap();
376        let logs = dir.path().join("ljos");
377        crate::log_recall_in(&logs, "recall", "a-1");
378        crate::log_recall_in(&logs, "search", "two\nlines");
379        let log = std::fs::read_to_string(logs.join("recall.log")).unwrap();
380        let lines: Vec<_> = log.lines().collect();
381        assert_eq!(lines.len(), 2);
382        assert!(lines[0].ends_with(" recall a-1"));
383        assert!(lines[1].ends_with(" search two lines"));
384    }
385}