1use std::str::FromStr;
13
14use axum::extract::{Path, State};
15use axum::http::StatusCode;
16use axum::response::Json;
17use chrono::Utc;
18use cron::Schedule;
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Value};
21use uuid::Uuid;
22
23use crate::backend::storage::cron::CronJob;
24use super::AppState;
25
26#[derive(Debug, Deserialize)]
27pub struct CronCreateRequest {
28 pub name: String,
29 pub expression: String,
31 pub prompt: String,
32 pub target: String,
34 #[serde(default)]
38 pub target_uid: String,
39 #[serde(default)]
40 pub created_by: String,
41 pub max_fires: Option<i64>,
42 #[serde(default)]
47 pub max_age_secs: Option<i64>,
48}
49
50#[derive(Debug, Deserialize)]
51pub struct CronPatchRequest {
52 pub action: String,
54}
55
56#[derive(Debug, Serialize)]
57pub struct CronJobView {
58 pub id: String,
59 pub name: String,
60 pub expression: String,
61 pub prompt: String,
62 pub target: String,
63 pub enabled: bool,
64 pub last_fired: Option<i64>,
65 pub fire_count: i64,
66 pub max_fires: Option<i64>,
67 pub created_at: i64,
68 pub max_age_secs: Option<i64>,
69 pub expires_in_secs: Option<i64>,
76 pub next_fire: Option<String>,
78}
79
80fn to_view(job: &CronJob) -> CronJobView {
81 let next_fire = if job.enabled {
82 next_fire_utc(&job.expression)
83 } else {
84 None
85 };
86 let expires_in_secs = job.max_age_secs.map(|max_age| {
87 let elapsed = Utc::now().timestamp() - job.created_at;
88 (max_age - elapsed).max(0)
89 });
90 CronJobView {
91 id: job.id.clone(),
92 name: job.name.clone(),
93 expression: job.expression.clone(),
94 prompt: job.prompt.clone(),
95 target: job.target.clone(),
96 enabled: job.enabled,
97 last_fired: job.last_fired,
98 fire_count: job.fire_count,
99 max_fires: job.max_fires,
100 created_at: job.created_at,
101 expires_in_secs,
102 max_age_secs: job.max_age_secs,
103 next_fire,
104 }
105}
106
107fn next_fire_utc(expression: &str) -> Option<String> {
110 let full = format!("0 {}", expression);
111 let schedule = Schedule::from_str(&full).ok()?;
112 let next = schedule.upcoming(chrono::Utc).next()?;
113 Some(next.to_rfc3339())
114}
115
116#[derive(Debug, Serialize)]
121pub struct CronSummary {
122 pub id: String,
123 pub block_id: String,
124 pub name: String,
125 pub expression: String,
126 pub target: String,
127 pub created_by: String,
128 pub enabled: bool,
129 pub last_fired: Option<i64>,
130 pub fire_count: i64,
131 pub max_fires: Option<i64>,
132 pub next_fire: Option<String>,
134}
135
136pub(crate) fn to_summary(job: &CronJob, block_id: String) -> CronSummary {
137 let next_fire = if job.enabled {
138 next_fire_utc(&job.expression)
139 } else {
140 None
141 };
142 CronSummary {
143 id: job.id.clone(),
144 block_id,
145 name: job.name.clone(),
146 expression: job.expression.clone(),
147 target: job.target.clone(),
148 created_by: job.created_by.clone(),
149 enabled: job.enabled,
150 last_fired: job.last_fired,
151 fire_count: job.fire_count,
152 max_fires: job.max_fires,
153 next_fire,
154 }
155}
156
157fn publish_cron_changed(state: &AppState) {
158 state.broker.publish_event::<crate::backend::mps_events::CronChanged>(&(), vec![], 0);
159}
160
161pub(super) async fn handle_cron_create(
162 State(state): State<AppState>,
163 caller: Option<axum::Extension<super::caller::Caller>>,
164 Json(req): Json<CronCreateRequest>,
165) -> (StatusCode, Json<Value>) {
166 super::actor::check_actor(
167 &state,
168 caller.as_deref(),
169 super::actor::ActorSite::CronCreate,
170 Some(&req.created_by),
171 );
172 let full_expr = format!("0 {}", req.expression);
174 if Schedule::from_str(&full_expr).is_err() {
175 return (
176 StatusCode::BAD_REQUEST,
177 Json(json!({"error": format!("invalid cron expression: '{}'", req.expression)})),
178 );
179 }
180
181 let store = match &state.shared_store {
182 Some(s) => s.clone(),
183 None => return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": "shared store unavailable"}))),
184 };
185
186 let target_uid = req.target_uid.trim().to_string();
193 if target_uid.is_empty() && !req.target.trim().is_empty() {
194 crate::backend::agent_resolve::record_uid_fallback("cron_create.uid_not_carried");
195 }
196
197 let job = CronJob {
198 id: Uuid::new_v4().to_string(),
199 name: req.name.clone(),
200 expression: req.expression.clone(),
201 prompt: req.prompt.clone(),
202 target: req.target.clone(),
203 target_uid,
204 created_by: req.created_by.clone(),
205 created_by_uid: super::caller::attributed_uid(caller.as_deref()),
208 enabled: true,
209 last_fired: None,
210 fire_count: 0,
211 max_fires: req.max_fires,
212 created_at: Utc::now().timestamp(),
213 max_age_secs: req.max_age_secs,
214 };
215
216 if let Err(e) = store.cron_create(&job) {
217 return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()})));
218 }
219
220 state.cron_scheduler.schedule_job(&job);
222 publish_cron_changed(&state);
223
224 (StatusCode::CREATED, Json(json!({"job": to_view(&job)})))
225}
226
227pub(super) async fn handle_cron_list(
228 State(state): State<AppState>,
229) -> (StatusCode, Json<Value>) {
230 let store = match &state.shared_store {
231 Some(s) => s.clone(),
232 None => return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": "shared store unavailable"}))),
233 };
234
235 match store.cron_list() {
236 Ok(jobs) => {
237 let views: Vec<_> = jobs.iter().map(to_view).collect();
238 (StatusCode::OK, Json(json!({"jobs": views})))
239 }
240 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))),
241 }
242}
243
244pub(super) async fn handle_cron_delete(
245 State(state): State<AppState>,
246 Path(id): Path<String>,
247) -> (StatusCode, Json<Value>) {
248 let store = match &state.shared_store {
249 Some(s) => s.clone(),
250 None => return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": "shared store unavailable"}))),
251 };
252
253 state.cron_scheduler.cancel_job(&id);
255
256 match store.cron_delete(&id) {
257 Ok(true) => {
258 publish_cron_changed(&state);
259 (StatusCode::OK, Json(json!({"ok": true})))
260 }
261 Ok(false) => (StatusCode::NOT_FOUND, Json(json!({"error": "job not found"}))),
262 Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))),
263 }
264}
265
266pub(super) async fn handle_cron_patch(
267 State(state): State<AppState>,
268 Path(id): Path<String>,
269 Json(req): Json<CronPatchRequest>,
270) -> (StatusCode, Json<Value>) {
271 let store = match &state.shared_store {
272 Some(s) => s.clone(),
273 None => return (StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": "shared store unavailable"}))),
274 };
275
276 let enabled = match req.action.as_str() {
277 "resume" => true,
278 "pause" => false,
279 other => return (StatusCode::BAD_REQUEST, Json(json!({"error": format!("unknown action '{}' (use 'pause' or 'resume')", other)}))),
280 };
281
282 match store.cron_set_enabled(&id, enabled) {
283 Ok(true) => {}
284 Ok(false) => return (StatusCode::NOT_FOUND, Json(json!({"error": "job not found"}))),
285 Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))),
286 }
287
288 if enabled {
289 if let Ok(Some(job)) = store.cron_get(&id) {
290 state.cron_scheduler.schedule_job(&job);
291 }
292 } else {
293 state.cron_scheduler.cancel_job(&id);
294 }
295 publish_cron_changed(&state);
296
297 (StatusCode::OK, Json(json!({"ok": true, "enabled": enabled})))
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 fn mk_job(enabled: bool, max_fires: Option<i64>, fire_count: i64) -> CronJob {
305 CronJob {
306 id: "job-1".to_string(),
307 name: "nightly build".to_string(),
308 expression: "0 9 * * *".to_string(),
309 prompt: "run the build".to_string(),
310 target: "target-agent".to_string(),
311 target_uid: String::new(),
312 created_by: "creator-agent".to_string(),
313 created_by_uid: String::new(),
314 enabled,
315 last_fired: Some(1_700_000_000),
316 fire_count,
317 max_fires,
318 created_at: 1_699_000_000,
319 max_age_secs: None,
320 }
321 }
322
323 #[test]
324 fn to_summary_carries_the_resolved_block_id() {
325 let job = mk_job(true, None, 3);
326 let summary = to_summary(&job, "block-42".to_string());
327 assert_eq!(summary.id, "job-1");
328 assert_eq!(summary.block_id, "block-42");
329 assert_eq!(summary.created_by, "creator-agent");
330 assert_eq!(summary.fire_count, 3);
331 assert_eq!(summary.max_fires, None);
332 }
333
334 #[test]
335 fn to_summary_omits_next_fire_when_disabled() {
336 let job = mk_job(false, Some(5), 5);
337 let summary = to_summary(&job, "block-1".to_string());
338 assert!(!summary.enabled);
339 assert_eq!(summary.next_fire, None);
340 }
341
342 #[test]
343 fn to_summary_computes_next_fire_when_enabled() {
344 let job = mk_job(true, None, 0);
345 let summary = to_summary(&job, "block-1".to_string());
346 assert!(summary.enabled);
347 assert!(summary.next_fire.is_some());
348 }
349}