diff --git a/src/agent/routine.rs b/src/agent/routine.rs index 2dee6333..0389ac1e 100644 --- a/src/agent/routine.rs +++ b/src/agent/routine.rs @@ -538,11 +538,174 @@ pub fn next_cron_fire( } } +/// Describe common routine cron patterns in plain English. +/// +/// Falls back to `cron: ` for malformed or complex expressions. +pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String { + fn fallback(raw: &str) -> String { + if raw.trim().is_empty() { + "cron: (empty)".to_string() + } else { + format!("cron: {}", raw.trim()) + } + } + + fn parse_u8_token(token: &str) -> Option { + token.parse::().ok() + } + + fn parse_step(token: &str) -> Option { + token + .strip_prefix("*/") + .and_then(parse_u8_token) + .filter(|n| *n > 0) + } + + fn weekday_name(dow: &str) -> Option<&'static str> { + let normalized = dow.trim().to_ascii_uppercase(); + match normalized.as_str() { + "MON" | "1" => Some("Monday"), + "TUE" | "2" => Some("Tuesday"), + "WED" | "3" => Some("Wednesday"), + "THU" | "4" => Some("Thursday"), + "FRI" | "5" => Some("Friday"), + "SAT" | "6" => Some("Saturday"), + "SUN" | "0" | "7" => Some("Sunday"), + _ => None, + } + } + + fn format_time(hour: u8, minute: u8) -> String { + if hour == 0 && minute == 0 { + return "midnight".to_string(); + } + let (display_hour, am_pm) = match hour { + 0 => (12, "AM"), + 1..=11 => (hour, "AM"), + 12 => (12, "PM"), + _ => (hour - 12, "PM"), + }; + format!("{display_hour}:{minute:02} {am_pm}") + } + + fn ordinal(n: u8) -> String { + let suffix = if (11..=13).contains(&(n % 100)) { + "th" + } else { + match n % 10 { + 1 => "st", + 2 => "nd", + 3 => "rd", + _ => "th", + } + }; + format!("{n}{suffix}") + } + + fn describe_inner(raw: &str) -> Option { + let fields: Vec<&str> = raw.split_whitespace().collect(); + let (sec, min, hour, dom, month, dow, year) = match fields.len() { + 5 => ( + "0", fields[0], fields[1], fields[2], fields[3], fields[4], None, + ), + 6 => ( + fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], None, + ), + 7 => ( + fields[0], + fields[1], + fields[2], + fields[3], + fields[4], + fields[5], + Some(fields[6]), + ), + _ => return None, + }; + + if year.is_some_and(|v| v != "*") { + return None; + } + + if sec == "0" + && hour == "*" + && dom == "*" + && month == "*" + && dow == "*" + && let Some(step) = parse_step(min) + { + return Some(match step { + 1 => "Every minute".to_string(), + n => format!("Every {n} minutes"), + }); + } + + if sec == "0" + && min == "0" + && dom == "*" + && month == "*" + && dow == "*" + && let Some(step) = parse_step(hour) + { + return Some(match step { + 1 => "Every hour".to_string(), + n => format!("Every {n} hours"), + }); + } + + let hour = parse_u8_token(hour).filter(|h| *h <= 23)?; + let minute = parse_u8_token(min).filter(|m| *m <= 59)?; + let time = format_time(hour, minute); + let time_phrase = if time == "midnight" { + "at midnight".to_string() + } else { + format!("at {time}") + }; + + if sec == "0" && dom == "*" && month == "*" && dow == "*" { + return Some(format!("Daily {time_phrase}")); + } + + if sec == "0" && dom == "*" && month == "*" && dow.eq_ignore_ascii_case("MON-FRI") { + return Some(format!("Weekdays {time_phrase}")); + } + + if sec == "0" + && dom == "*" + && month == "*" + && let Some(day_name) = weekday_name(dow) + { + return Some(format!("Every {day_name} {time_phrase}")); + } + + if sec == "0" + && month == "*" + && dow == "*" + && let Some(day_of_month) = parse_u8_token(dom).filter(|d| (1..=31).contains(d)) + { + return Some(format!( + "{} of every month {time_phrase}", + ordinal(day_of_month) + )); + } + + None + } + + let mut description = describe_inner(schedule).unwrap_or_else(|| fallback(schedule)); + if let Some(tz) = timezone.map(str::trim).filter(|tz| !tz.is_empty()) { + description.push_str(" ("); + description.push_str(tz); + description.push(')'); + } + description +} + #[cfg(test)] mod tests { use crate::agent::routine::{ MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash, - next_cron_fire, + describe_cron, next_cron_fire, }; #[test] @@ -698,6 +861,40 @@ mod tests { assert_ne!(next_utc, next_est, "timezone should shift the fire time"); } + #[test] + fn test_describe_cron_common_patterns() { + let cases = vec![ + ("0 */30 * * * *", None, "Every 30 minutes"), + ("0 0 9 * * *", None, "Daily at 9:00 AM"), + ("0 0 9 * * MON-FRI", None, "Weekdays at 9:00 AM"), + ("0 0 */2 * * *", None, "Every 2 hours"), + ("0 0 0 * * *", None, "Daily at midnight"), + ("0 0 9 * * 1", None, "Every Monday at 9:00 AM"), + ("0 0 9 1 * *", None, "1st of every month at 9:00 AM"), + ( + "0 0 9 * * MON-FRI", + Some("America/New_York"), + "Weekdays at 9:00 AM (America/New_York)", + ), + ("1 2 3 4 5 6", None, "cron: 1 2 3 4 5 6"), + ]; + + for (schedule, timezone, expected) in cases { + let actual = describe_cron(schedule, timezone); + assert_eq!(actual, expected); // safety: test-only assertion in #[cfg(test)] module + } + } + + #[test] + fn test_describe_cron_edge_cases() { + assert_eq!(describe_cron("", None), "cron: (empty)"); // safety: test-only assertion in #[cfg(test)] module + assert_eq!(describe_cron("not a cron", None), "cron: not a cron"); // safety: test-only assertion in #[cfg(test)] module + let weekdays_5_field = describe_cron("0 9 * * MON-FRI", None); + assert_eq!(weekdays_5_field, "Weekdays at 9:00 AM"); // safety: test-only assertion in #[cfg(test)] module + let weekdays_7_field = describe_cron("0 0 9 * * MON-FRI *", None); + assert_eq!(weekdays_7_field, "Weekdays at 9:00 AM"); // safety: test-only assertion in #[cfg(test)] module + } + #[test] fn test_guardrails_default() { let g = RoutineGuardrails::default(); diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs index f5d8db02..41bfee5a 100644 --- a/src/channels/web/handlers/routines.rs +++ b/src/channels/web/handlers/routines.rs @@ -112,12 +112,16 @@ pub async fn routines_detail_handler( job_id: run.job_id, }) .collect(); + let routine_info = RoutineInfo::from_routine(&routine); Ok(Json(RoutineDetailResponse { id: routine.id, name: routine.name.clone(), description: routine.description.clone(), enabled: routine.enabled, + trigger_type: routine_info.trigger_type, + trigger_raw: routine_info.trigger_raw, + trigger_summary: routine_info.trigger_summary, trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(), action: serde_json::to_value(&routine.action).unwrap_or_default(), guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(), diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 48ef452c..acec3842 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -2346,12 +2346,16 @@ async fn routines_detail_handler( job_id: run.job_id, }) .collect(); + let routine_info = RoutineInfo::from_routine(&routine); Ok(Json(RoutineDetailResponse { id: routine.id, name: routine.name.clone(), description: routine.description.clone(), enabled: routine.enabled, + trigger_type: routine_info.trigger_type, + trigger_raw: routine_info.trigger_raw, + trigger_summary: routine_info.trigger_summary, trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(), action: serde_json::to_value(&routine.action).unwrap_or_default(), guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(), diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index a981d567..081b0f3a 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -3535,10 +3535,13 @@ function renderRoutinesList(routines) { const toggleLabel = r.enabled ? 'Disable' : 'Enable'; const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart'; + const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw) + ? ' title="' + escapeHtml(r.trigger_raw) + '"' + : ''; return '' + '' + escapeHtml(r.name) + '' - + '' + escapeHtml(r.trigger_summary) + '' + + '' + escapeHtml(r.trigger_summary) + '' + '' + escapeHtml(r.action_type) + '' + '' + formatRelativeTime(r.last_run_at) + '' + '' + formatRelativeTime(r.next_fire_at) + '' @@ -3606,8 +3609,23 @@ function renderRoutineDetail(routine) { } // Trigger config - html += '

Trigger

' - + '
' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '
'; + if (routine.trigger_type === 'cron') { + const summary = routine.trigger_summary || 'cron'; + const raw = routine.trigger_raw || ''; + const timezone = routine.trigger && routine.trigger.timezone ? String(routine.trigger.timezone) : ''; + html += '

Trigger

' + + '
' + escapeHtml(summary) + '
'; + if (raw) { + html += '
' + + 'Raw' + + '' + escapeHtml(raw + (timezone ? ' (' + timezone + ')' : '')) + '' + + '
'; + } + html += '
'; + } else { + html += '

Trigger

' + + '
' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '
'; + } // Action config html += '

Action

' diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index b8690b78..129a7071 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -735,6 +735,7 @@ pub struct RoutineInfo { pub description: String, pub enabled: bool, pub trigger_type: String, + pub trigger_raw: String, pub trigger_summary: String, pub action_type: String, pub last_run_at: Option, @@ -747,25 +748,34 @@ pub struct RoutineInfo { impl RoutineInfo { /// Convert a `Routine` to the trimmed `RoutineInfo` for list display. pub fn from_routine(r: &crate::agent::routine::Routine) -> Self { - let (trigger_type, trigger_summary) = match &r.trigger { - crate::agent::routine::Trigger::Cron { schedule, .. } => { - ("cron".to_string(), format!("cron: {}", schedule)) - } + let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger { + crate::agent::routine::Trigger::Cron { schedule, timezone } => ( + "cron".to_string(), + schedule.clone(), + crate::agent::routine::describe_cron(schedule, timezone.as_deref()), + ), crate::agent::routine::Trigger::Event { pattern, channel, .. } => { let ch = channel.as_deref().unwrap_or("any"); - ("event".to_string(), format!("on {} /{}/", ch, pattern)) + ( + "event".to_string(), + String::new(), + format!("on {} /{}/", ch, pattern), + ) } crate::agent::routine::Trigger::SystemEvent { source, event_type, .. } => ( "system_event".to_string(), + String::new(), format!("event: {}.{}", source, event_type), ), - crate::agent::routine::Trigger::Manual => { - ("manual".to_string(), "manual only".to_string()) - } + crate::agent::routine::Trigger::Manual => ( + "manual".to_string(), + String::new(), + "manual only".to_string(), + ), }; let action_type = match &r.action { @@ -787,6 +797,7 @@ impl RoutineInfo { description: r.description.clone(), enabled: r.enabled, trigger_type, + trigger_raw, trigger_summary, action_type: action_type.to_string(), last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()), @@ -818,6 +829,9 @@ pub struct RoutineDetailResponse { pub name: String, pub description: String, pub enabled: bool, + pub trigger_type: String, + pub trigger_raw: String, + pub trigger_summary: String, pub trigger: serde_json::Value, pub action: serde_json::Value, pub guardrails: serde_json::Value,