Skip to main content

agentmux_srv/server/
cron.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! HTTP handlers for the persistent cron scheduler.
5//!
6//! Routes (all auth-gated via `X-AuthKey`):
7//!   POST   /agentmux/cron          — create a job
8//!   DELETE /agentmux/cron/:id      — delete a job
9//!   GET    /agentmux/cron          — list all jobs
10//!   PATCH  /agentmux/cron/:id      — pause / resume
11
12use 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    /// 5-field UTC cron expression: "min hour dom mon dow"
30    pub expression: String,
31    pub prompt: String,
32    /// Target agent id. Required — no implicit self-targeting from HTTP.
33    pub target: String,
34    /// Identity M3: the target's UID, resolved at the MCP boundary (spec
35    /// §5.4 — resolution at authoring time) and CARRIED here. Absent → the
36    /// server resolves `target` itself as a counted fallback.
37    #[serde(default)]
38    pub target_uid: String,
39    #[serde(default)]
40    pub created_by: String,
41    pub max_fires: Option<i64>,
42    /// Hard expiry bound in seconds since creation. Optional — omit for no
43    /// expiry (matching native CronCreate's 7-day default is a caller
44    /// choice, not enforced here; AgentMux's cross-agent jobs are often
45    /// meant to run indefinitely, e.g. a recurring standup check).
46    #[serde(default)]
47    pub max_age_secs: Option<i64>,
48}
49
50#[derive(Debug, Deserialize)]
51pub struct CronPatchRequest {
52    /// "pause" or "resume"
53    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    /// Seconds remaining before `max_age_secs` expiry, computed server-side
70    /// (clamped to 0, never negative) so callers don't need their own clock/
71    /// date-math to display something meaningful — `None` when there's no
72    /// `max_age_secs` bound. Fixes a review finding on the first cut of this
73    /// field: the MCP CLI was printing the raw `max_age_secs` bound under an
74    /// "expires_in" label, which is only correct at the instant of creation.
75    pub expires_in_secs: Option<i64>,
76    /// ISO-8601 string of the next scheduled fire (UTC), or null if disabled.
77    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
107/// Compute the next UTC fire time for a 5-field cron expression, returned
108/// as an ISO-8601 string. Returns `None` on parse error.
109fn 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/// Swarm-pane row shape for one cron job (SPEC_SWARM_LONG_RUNNING_PROCESS_
117/// ROWS_2026_07_20 Phase 2) — `CronJobView` plus the `created_by` agent's
118/// resolved `block_id`, so the frontend can group it under that agent's row
119/// the same way `ShellSummary` does for the Shell bucket.
120#[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    /// ISO-8601 string of the next scheduled fire (UTC), or null if disabled.
133    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    // Validate expression.
173    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    // Identity M3: the target's UID is stored only if the caller CARRIED
187    // one, resolved at the boundary where the name was typed (spec §5.4).
188    // This handler never turns a name into an identity (§2 rule 1): the job
189    // fires by `target_uid` when set, so a server-side guess would deliver
190    // to whichever agent it found, in any channel (review on #3563). A named
191    // target with no carried UID keeps firing by name until M5, counted.
192    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        // Identity M4c-1 (§6.5.9): the creator's UID is the request's
206        // `Caller` — its token — never a body field; `""` when Unattributed.
207        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    // Register the new job with the live scheduler.
221    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    // Cancel live task first.
254    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}