Skip to main content

klieo_ops_redispatcher/
lib.rs

1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! Library API for the klieo-ops redispatcher.
4//!
5//! The `klieo-ops-redispatcher` binary is a thin CLI wrapper. Downstream
6//! integration tests and operator harnesses import this crate directly and
7//! call [`redispatch`].
8
9use anyhow::{Context, Result};
10use chrono::{DateTime, Utc};
11use klieo_ops::worklog::{WorkLog, WorkStatus};
12use std::time::Duration;
13
14const LIST_LIMIT: usize = 10_000;
15
16/// Whether to apply the transition or just report what would happen.
17#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum RedispatchMode {
19    /// Transition every stale item back to Ready.
20    Apply,
21    /// Log and count what would transition but do not write.
22    DryRun,
23}
24
25/// Scan the worklog for stale items and transition them back to `Ready`.
26///
27/// An item is considered stale when its current status is one of `statuses`
28/// AND its `last_transition_at` timestamp is older than `threshold` ago.
29///
30/// Returns the count of items transitioned (or that would have been
31/// transitioned when `mode` is [`RedispatchMode::DryRun`]).
32///
33/// Re-dispatching a [`WorkStatus::AwaitingApproval`] item discards a pending
34/// human-approval (four-eyes) decision; each such transition is logged at WARN.
35/// The downstream consumer of `Ready` items MUST re-wrap effectful tools in
36/// `GatedTool` so a redispatched item re-enters the approval gate rather than
37/// running unguarded.
38pub async fn redispatch(
39    worklog: &dyn WorkLog,
40    threshold: Duration,
41    statuses: &[WorkStatus],
42    mode: RedispatchMode,
43) -> Result<usize> {
44    let cutoff: DateTime<Utc> = Utc::now()
45        - chrono::Duration::from_std(threshold).context("threshold to chrono::Duration")?;
46    let mut transitioned = 0usize;
47
48    for &status in statuses {
49        for item in worklog.list_by_status(status, LIST_LIMIT).await {
50            // A malformed or contended item must not abort the sweep.
51            let ts: DateTime<Utc> = match item.last_transition_at.parse() {
52                Ok(ts) => ts,
53                Err(err) => {
54                    tracing::warn!(
55                        target: "klieo.ops.redispatch",
56                        work_id = %item.id,
57                        error = %err,
58                        "unparseable last_transition_at; skipping item",
59                    );
60                    continue;
61                }
62            };
63
64            if ts >= cutoff {
65                continue;
66            }
67
68            if mode == RedispatchMode::DryRun {
69                tracing::info!(work_id = %item.id, status = ?status, "would re-dispatch (dry_run)");
70                transitioned += 1;
71                continue;
72            }
73
74            if status == WorkStatus::AwaitingApproval {
75                tracing::warn!(
76                    target: "audit.redispatch",
77                    work_id = %item.id,
78                    "re-dispatching an AwaitingApproval item to Ready discards a pending \
79                     human-approval decision; the Ready consumer must re-gate effectful tools",
80                );
81            }
82
83            // CAS to Ready only if the item is still in the stale status we
84            // observed. If another redispatcher or a worker already moved it,
85            // `Ok(false)` — we neither re-transition nor count it.
86            match worklog
87                .transition_if_status(item.id.clone(), status, WorkStatus::Ready)
88                .await
89            {
90                Ok(true) => {
91                    tracing::info!(work_id = %item.id, status = ?status, "re-dispatched to Ready");
92                    transitioned += 1;
93                }
94                Ok(false) => tracing::debug!(
95                    work_id = %item.id,
96                    status = ?status,
97                    "item left stale status before transition; another actor handled it",
98                ),
99                Err(err) => tracing::warn!(
100                    target: "klieo.ops.redispatch",
101                    work_id = %item.id,
102                    error = %err,
103                    "transition failed; skipping item",
104                ),
105            }
106        }
107    }
108
109    Ok(transitioned)
110}
111
112/// Parse a comma-separated list of snake_case `WorkStatus` names.
113///
114/// Accepted values: `in_progress`, `awaiting_approval`, `planned`,
115/// `ready`, `done`, `failed`, `cancelled`.
116pub fn parse_statuses(raw: &str) -> Result<Vec<WorkStatus>> {
117    raw.split(',')
118        .map(|s| match s.trim() {
119            "planned" => Ok(WorkStatus::Planned),
120            "ready" => Ok(WorkStatus::Ready),
121            "in_progress" => Ok(WorkStatus::InProgress),
122            "done" => Ok(WorkStatus::Done),
123            "failed" => Ok(WorkStatus::Failed),
124            "awaiting_approval" => Ok(WorkStatus::AwaitingApproval),
125            "cancelled" => Ok(WorkStatus::Cancelled),
126            other => anyhow::bail!("unknown WorkStatus: {other:?}"),
127        })
128        .collect()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use futures_core::stream::BoxStream;
135    use klieo_bus_memory::MemoryBus;
136    use klieo_ops::worklog::{KvWorkLog, WorkDag, WorkId, WorkItem, WorkLogError};
137    use std::sync::Mutex;
138
139    fn fresh_item(title: &str) -> WorkItem {
140        WorkItem::new(title, serde_json::json!({}), None, vec![])
141    }
142
143    /// Returns a controlled candidate list and records every successful
144    /// `transition_if_status`. The transition outcome is keyed off the id
145    /// prefix: `skip*` → `Ok(false)` (lost the race), `err*` → `Err`, anything
146    /// else → `Ok(true)`. Only the two methods `redispatch` calls are
147    /// meaningful; the rest are never reached.
148    struct MockWorkLog {
149        items: Vec<WorkItem>,
150        transitioned: Mutex<Vec<WorkId>>,
151    }
152
153    fn item_at(id: &str, last_transition_at: &str) -> WorkItem {
154        let mut item = WorkItem::new(id, serde_json::json!({}), None, vec![]);
155        item.id = WorkId(id.to_string());
156        item.last_transition_at = last_transition_at.to_string();
157        item
158    }
159
160    const STALE: &str = "2020-01-01T00:00:00Z";
161
162    #[async_trait::async_trait]
163    impl WorkLog for MockWorkLog {
164        async fn list_by_status(&self, _filter: WorkStatus, _limit: usize) -> Vec<WorkItem> {
165            self.items.clone()
166        }
167
168        async fn transition_if_status(
169            &self,
170            id: WorkId,
171            _from: WorkStatus,
172            _to: WorkStatus,
173        ) -> Result<bool, WorkLogError> {
174            if id.0.starts_with("skip") {
175                return Ok(false);
176            }
177            if id.0.starts_with("err") {
178                return Err(WorkLogError::Storage {
179                    message: "transition failed".into(),
180                    source: None,
181                });
182            }
183            self.transitioned.lock().unwrap().push(id);
184            Ok(true)
185        }
186
187        async fn plan(&self, _: WorkItem) -> Result<WorkId, WorkLogError> {
188            unreachable!()
189        }
190        async fn depend(&self, _: WorkId, _: WorkId) -> Result<(), WorkLogError> {
191            unreachable!()
192        }
193        async fn ready(&self, _: usize) -> Vec<WorkId> {
194            unreachable!()
195        }
196        async fn ready_stream(&self) -> BoxStream<'static, WorkId> {
197            unreachable!()
198        }
199        async fn dispatch(&self, _: WorkId) -> Result<(), WorkLogError> {
200            unreachable!()
201        }
202        async fn transition(&self, _: WorkId, _: WorkStatus) -> Result<(), WorkLogError> {
203            unreachable!()
204        }
205        async fn get(&self, _: WorkId) -> Option<WorkItem> {
206            unreachable!()
207        }
208        async fn dag(&self, _: WorkId) -> WorkDag {
209            unreachable!()
210        }
211    }
212
213    async fn redispatch_apply(items: Vec<WorkItem>) -> (usize, Vec<WorkId>) {
214        let wl = MockWorkLog {
215            items,
216            transitioned: Mutex::new(vec![]),
217        };
218        let n = redispatch(
219            &wl,
220            Duration::ZERO,
221            &[WorkStatus::InProgress],
222            RedispatchMode::Apply,
223        )
224        .await
225        .expect("a per-item failure must not abort the sweep");
226        let transitioned = wl.transitioned.lock().unwrap().clone();
227        (n, transitioned)
228    }
229
230    /// An unparseable `last_transition_at` is logged and skipped — the sweep
231    /// continues and still transitions the valid stale item.
232    #[tokio::test]
233    async fn redispatch_skips_unparseable_item_and_continues() {
234        let (n, transitioned) = redispatch_apply(vec![
235            item_at("bad-1", "not-a-timestamp"),
236            item_at("good-1", STALE),
237        ])
238        .await;
239        assert_eq!(n, 1, "bad item skipped, good item still transitioned");
240        assert_eq!(transitioned.as_slice(), &[WorkId("good-1".into())]);
241    }
242
243    /// An item another actor already moved (`transition_if_status` → `Ok(false)`)
244    /// is not counted, and the sweep still transitions the rest.
245    #[tokio::test]
246    async fn redispatch_skips_item_lost_to_concurrent_actor() {
247        let (n, transitioned) =
248            redispatch_apply(vec![item_at("skip-1", STALE), item_at("good-1", STALE)]).await;
249        assert_eq!(n, 1, "the Ok(false) item is not counted");
250        assert_eq!(transitioned.as_slice(), &[WorkId("good-1".into())]);
251    }
252
253    /// A `transition_if_status` error on one item is logged and skipped — it
254    /// must not abort the sweep.
255    #[tokio::test]
256    async fn redispatch_skips_item_whose_transition_errors() {
257        let (n, transitioned) =
258            redispatch_apply(vec![item_at("err-1", STALE), item_at("good-1", STALE)]).await;
259        assert_eq!(
260            n, 1,
261            "the erroring item is skipped, the good one transitions"
262        );
263        assert_eq!(transitioned.as_slice(), &[WorkId("good-1".into())]);
264    }
265
266    #[tokio::test]
267    async fn redispatch_with_empty_log_returns_zero() {
268        let bus = MemoryBus::new();
269        let wl = KvWorkLog::new(bus.kv.clone());
270        let n = redispatch(
271            &wl,
272            Duration::from_secs(60),
273            &[WorkStatus::InProgress],
274            RedispatchMode::DryRun,
275        )
276        .await
277        .expect("redispatch ok");
278        assert_eq!(n, 0);
279    }
280
281    #[tracing_test::traced_test]
282    #[tokio::test]
283    async fn redispatch_warns_when_discarding_awaiting_approval() {
284        let bus = MemoryBus::new();
285        let wl = KvWorkLog::new(bus.kv.clone());
286        let id = wl.plan(fresh_item("needs approval")).await.expect("plan");
287        wl.transition(id.clone(), WorkStatus::AwaitingApproval)
288            .await
289            .expect("to awaiting_approval");
290
291        let n = redispatch(
292            &wl,
293            Duration::ZERO,
294            &[WorkStatus::AwaitingApproval],
295            RedispatchMode::Apply,
296        )
297        .await
298        .expect("redispatch ok");
299
300        assert_eq!(n, 1, "the stale awaiting-approval item is redispatched");
301        assert!(
302            logs_contain("discards a pending"),
303            "redispatching an AwaitingApproval item must warn (four-eyes decision discarded)"
304        );
305        assert_eq!(
306            wl.get(id).await.expect("item exists").status,
307            WorkStatus::Ready
308        );
309    }
310
311    #[tokio::test]
312    async fn parse_statuses_accepts_valid_input() {
313        let statuses = parse_statuses("in_progress,awaiting_approval").expect("parse ok");
314        assert_eq!(
315            statuses,
316            vec![WorkStatus::InProgress, WorkStatus::AwaitingApproval]
317        );
318    }
319
320    #[tokio::test]
321    async fn parse_statuses_rejects_unknown_name() {
322        assert!(parse_statuses("bogus").is_err());
323    }
324
325    #[test]
326    fn cli_default_statuses_excludes_awaiting_approval() {
327        // The binary's `--statuses` default (main.rs) is this string; redispatching
328        // AwaitingApproval discards a four-eyes decision, so it must NOT be default.
329        let statuses = parse_statuses("in_progress").expect("parse ok");
330        assert_eq!(statuses, vec![WorkStatus::InProgress]);
331        assert!(!statuses.contains(&WorkStatus::AwaitingApproval));
332    }
333
334    #[tokio::test]
335    async fn fresh_item_not_re_dispatched_when_threshold_is_one_hour() {
336        // Items dispatched NOW are not stale against a 3600s threshold.
337        let bus = MemoryBus::new();
338        let wl = KvWorkLog::new(bus.kv.clone());
339        let id = wl.plan(fresh_item("recent")).await.expect("plan");
340        wl.dispatch(id).await.expect("dispatch");
341
342        let n = redispatch(
343            &wl,
344            Duration::from_secs(3600),
345            &[WorkStatus::InProgress],
346            RedispatchMode::Apply,
347        )
348        .await
349        .expect("redispatch");
350        assert_eq!(n, 0, "recently dispatched item must not be re-dispatched");
351    }
352}