1#![deny(missing_docs)]
2#![deny(rustdoc::broken_intra_doc_links)]
3use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
18pub enum RedispatchMode {
19 Apply,
21 DryRun,
23}
24
25pub 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 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 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
112pub 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 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 #[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 #[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 #[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 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 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}