Skip to content

Commit e291d3b

Browse files
authored
feat(routines): human-readable cron schedule summaries in web UI (#1154)
* feat(routines): render cron triggers as human-readable summaries * test(routines): annotate multiline cron assertions for no-panics CI * test(routines): avoid multiline assert lint false positives
1 parent 994a0b1 commit e291d3b

5 files changed

Lines changed: 249 additions & 12 deletions

File tree

src/agent/routine.rs

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,11 +538,174 @@ pub fn next_cron_fire(
538538
}
539539
}
540540

541+
/// Describe common routine cron patterns in plain English.
542+
///
543+
/// Falls back to `cron: <raw>` for malformed or complex expressions.
544+
pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
545+
fn fallback(raw: &str) -> String {
546+
if raw.trim().is_empty() {
547+
"cron: (empty)".to_string()
548+
} else {
549+
format!("cron: {}", raw.trim())
550+
}
551+
}
552+
553+
fn parse_u8_token(token: &str) -> Option<u8> {
554+
token.parse::<u8>().ok()
555+
}
556+
557+
fn parse_step(token: &str) -> Option<u8> {
558+
token
559+
.strip_prefix("*/")
560+
.and_then(parse_u8_token)
561+
.filter(|n| *n > 0)
562+
}
563+
564+
fn weekday_name(dow: &str) -> Option<&'static str> {
565+
let normalized = dow.trim().to_ascii_uppercase();
566+
match normalized.as_str() {
567+
"MON" | "1" => Some("Monday"),
568+
"TUE" | "2" => Some("Tuesday"),
569+
"WED" | "3" => Some("Wednesday"),
570+
"THU" | "4" => Some("Thursday"),
571+
"FRI" | "5" => Some("Friday"),
572+
"SAT" | "6" => Some("Saturday"),
573+
"SUN" | "0" | "7" => Some("Sunday"),
574+
_ => None,
575+
}
576+
}
577+
578+
fn format_time(hour: u8, minute: u8) -> String {
579+
if hour == 0 && minute == 0 {
580+
return "midnight".to_string();
581+
}
582+
let (display_hour, am_pm) = match hour {
583+
0 => (12, "AM"),
584+
1..=11 => (hour, "AM"),
585+
12 => (12, "PM"),
586+
_ => (hour - 12, "PM"),
587+
};
588+
format!("{display_hour}:{minute:02} {am_pm}")
589+
}
590+
591+
fn ordinal(n: u8) -> String {
592+
let suffix = if (11..=13).contains(&(n % 100)) {
593+
"th"
594+
} else {
595+
match n % 10 {
596+
1 => "st",
597+
2 => "nd",
598+
3 => "rd",
599+
_ => "th",
600+
}
601+
};
602+
format!("{n}{suffix}")
603+
}
604+
605+
fn describe_inner(raw: &str) -> Option<String> {
606+
let fields: Vec<&str> = raw.split_whitespace().collect();
607+
let (sec, min, hour, dom, month, dow, year) = match fields.len() {
608+
5 => (
609+
"0", fields[0], fields[1], fields[2], fields[3], fields[4], None,
610+
),
611+
6 => (
612+
fields[0], fields[1], fields[2], fields[3], fields[4], fields[5], None,
613+
),
614+
7 => (
615+
fields[0],
616+
fields[1],
617+
fields[2],
618+
fields[3],
619+
fields[4],
620+
fields[5],
621+
Some(fields[6]),
622+
),
623+
_ => return None,
624+
};
625+
626+
if year.is_some_and(|v| v != "*") {
627+
return None;
628+
}
629+
630+
if sec == "0"
631+
&& hour == "*"
632+
&& dom == "*"
633+
&& month == "*"
634+
&& dow == "*"
635+
&& let Some(step) = parse_step(min)
636+
{
637+
return Some(match step {
638+
1 => "Every minute".to_string(),
639+
n => format!("Every {n} minutes"),
640+
});
641+
}
642+
643+
if sec == "0"
644+
&& min == "0"
645+
&& dom == "*"
646+
&& month == "*"
647+
&& dow == "*"
648+
&& let Some(step) = parse_step(hour)
649+
{
650+
return Some(match step {
651+
1 => "Every hour".to_string(),
652+
n => format!("Every {n} hours"),
653+
});
654+
}
655+
656+
let hour = parse_u8_token(hour).filter(|h| *h <= 23)?;
657+
let minute = parse_u8_token(min).filter(|m| *m <= 59)?;
658+
let time = format_time(hour, minute);
659+
let time_phrase = if time == "midnight" {
660+
"at midnight".to_string()
661+
} else {
662+
format!("at {time}")
663+
};
664+
665+
if sec == "0" && dom == "*" && month == "*" && dow == "*" {
666+
return Some(format!("Daily {time_phrase}"));
667+
}
668+
669+
if sec == "0" && dom == "*" && month == "*" && dow.eq_ignore_ascii_case("MON-FRI") {
670+
return Some(format!("Weekdays {time_phrase}"));
671+
}
672+
673+
if sec == "0"
674+
&& dom == "*"
675+
&& month == "*"
676+
&& let Some(day_name) = weekday_name(dow)
677+
{
678+
return Some(format!("Every {day_name} {time_phrase}"));
679+
}
680+
681+
if sec == "0"
682+
&& month == "*"
683+
&& dow == "*"
684+
&& let Some(day_of_month) = parse_u8_token(dom).filter(|d| (1..=31).contains(d))
685+
{
686+
return Some(format!(
687+
"{} of every month {time_phrase}",
688+
ordinal(day_of_month)
689+
));
690+
}
691+
692+
None
693+
}
694+
695+
let mut description = describe_inner(schedule).unwrap_or_else(|| fallback(schedule));
696+
if let Some(tz) = timezone.map(str::trim).filter(|tz| !tz.is_empty()) {
697+
description.push_str(" (");
698+
description.push_str(tz);
699+
description.push(')');
700+
}
701+
description
702+
}
703+
541704
#[cfg(test)]
542705
mod tests {
543706
use crate::agent::routine::{
544707
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
545-
next_cron_fire,
708+
describe_cron, next_cron_fire,
546709
};
547710

548711
#[test]
@@ -698,6 +861,40 @@ mod tests {
698861
assert_ne!(next_utc, next_est, "timezone should shift the fire time");
699862
}
700863

864+
#[test]
865+
fn test_describe_cron_common_patterns() {
866+
let cases = vec![
867+
("0 */30 * * * *", None, "Every 30 minutes"),
868+
("0 0 9 * * *", None, "Daily at 9:00 AM"),
869+
("0 0 9 * * MON-FRI", None, "Weekdays at 9:00 AM"),
870+
("0 0 */2 * * *", None, "Every 2 hours"),
871+
("0 0 0 * * *", None, "Daily at midnight"),
872+
("0 0 9 * * 1", None, "Every Monday at 9:00 AM"),
873+
("0 0 9 1 * *", None, "1st of every month at 9:00 AM"),
874+
(
875+
"0 0 9 * * MON-FRI",
876+
Some("America/New_York"),
877+
"Weekdays at 9:00 AM (America/New_York)",
878+
),
879+
("1 2 3 4 5 6", None, "cron: 1 2 3 4 5 6"),
880+
];
881+
882+
for (schedule, timezone, expected) in cases {
883+
let actual = describe_cron(schedule, timezone);
884+
assert_eq!(actual, expected); // safety: test-only assertion in #[cfg(test)] module
885+
}
886+
}
887+
888+
#[test]
889+
fn test_describe_cron_edge_cases() {
890+
assert_eq!(describe_cron("", None), "cron: (empty)"); // safety: test-only assertion in #[cfg(test)] module
891+
assert_eq!(describe_cron("not a cron", None), "cron: not a cron"); // safety: test-only assertion in #[cfg(test)] module
892+
let weekdays_5_field = describe_cron("0 9 * * MON-FRI", None);
893+
assert_eq!(weekdays_5_field, "Weekdays at 9:00 AM"); // safety: test-only assertion in #[cfg(test)] module
894+
let weekdays_7_field = describe_cron("0 0 9 * * MON-FRI *", None);
895+
assert_eq!(weekdays_7_field, "Weekdays at 9:00 AM"); // safety: test-only assertion in #[cfg(test)] module
896+
}
897+
701898
#[test]
702899
fn test_guardrails_default() {
703900
let g = RoutineGuardrails::default();

src/channels/web/handlers/routines.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,16 @@ pub async fn routines_detail_handler(
112112
job_id: run.job_id,
113113
})
114114
.collect();
115+
let routine_info = RoutineInfo::from_routine(&routine);
115116

116117
Ok(Json(RoutineDetailResponse {
117118
id: routine.id,
118119
name: routine.name.clone(),
119120
description: routine.description.clone(),
120121
enabled: routine.enabled,
122+
trigger_type: routine_info.trigger_type,
123+
trigger_raw: routine_info.trigger_raw,
124+
trigger_summary: routine_info.trigger_summary,
121125
trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(),
122126
action: serde_json::to_value(&routine.action).unwrap_or_default(),
123127
guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(),

src/channels/web/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2346,12 +2346,16 @@ async fn routines_detail_handler(
23462346
job_id: run.job_id,
23472347
})
23482348
.collect();
2349+
let routine_info = RoutineInfo::from_routine(&routine);
23492350

23502351
Ok(Json(RoutineDetailResponse {
23512352
id: routine.id,
23522353
name: routine.name.clone(),
23532354
description: routine.description.clone(),
23542355
enabled: routine.enabled,
2356+
trigger_type: routine_info.trigger_type,
2357+
trigger_raw: routine_info.trigger_raw,
2358+
trigger_summary: routine_info.trigger_summary,
23552359
trigger: serde_json::to_value(&routine.trigger).unwrap_or_default(),
23562360
action: serde_json::to_value(&routine.action).unwrap_or_default(),
23572361
guardrails: serde_json::to_value(&routine.guardrails).unwrap_or_default(),

src/channels/web/static/app.js

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3535,10 +3535,13 @@ function renderRoutinesList(routines) {
35353535

35363536
const toggleLabel = r.enabled ? 'Disable' : 'Enable';
35373537
const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart';
3538+
const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw)
3539+
? ' title="' + escapeHtml(r.trigger_raw) + '"'
3540+
: '';
35383541

35393542
return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">'
35403543
+ '<td>' + escapeHtml(r.name) + '</td>'
3541-
+ '<td>' + escapeHtml(r.trigger_summary) + '</td>'
3544+
+ '<td' + triggerTitle + '>' + escapeHtml(r.trigger_summary) + '</td>'
35423545
+ '<td>' + escapeHtml(r.action_type) + '</td>'
35433546
+ '<td>' + formatRelativeTime(r.last_run_at) + '</td>'
35443547
+ '<td>' + formatRelativeTime(r.next_fire_at) + '</td>'
@@ -3606,8 +3609,23 @@ function renderRoutineDetail(routine) {
36063609
}
36073610

36083611
// Trigger config
3609-
html += '<div class="job-description"><h3>Trigger</h3>'
3610-
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '</pre></div>';
3612+
if (routine.trigger_type === 'cron') {
3613+
const summary = routine.trigger_summary || 'cron';
3614+
const raw = routine.trigger_raw || '';
3615+
const timezone = routine.trigger && routine.trigger.timezone ? String(routine.trigger.timezone) : '';
3616+
html += '<div class="job-description"><h3>Trigger</h3>'
3617+
+ '<div class="job-description-body"><strong>' + escapeHtml(summary) + '</strong></div>';
3618+
if (raw) {
3619+
html += '<div class="job-meta-item">'
3620+
+ '<span class="job-meta-label">Raw</span>'
3621+
+ '<span class="job-meta-value">' + escapeHtml(raw + (timezone ? ' (' + timezone + ')' : '')) + '</span>'
3622+
+ '</div>';
3623+
}
3624+
html += '</div>';
3625+
} else {
3626+
html += '<div class="job-description"><h3>Trigger</h3>'
3627+
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '</pre></div>';
3628+
}
36113629

36123630
// Action config
36133631
html += '<div class="job-description"><h3>Action</h3>'

src/channels/web/types.rs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,7 @@ pub struct RoutineInfo {
735735
pub description: String,
736736
pub enabled: bool,
737737
pub trigger_type: String,
738+
pub trigger_raw: String,
738739
pub trigger_summary: String,
739740
pub action_type: String,
740741
pub last_run_at: Option<String>,
@@ -747,25 +748,34 @@ pub struct RoutineInfo {
747748
impl RoutineInfo {
748749
/// Convert a `Routine` to the trimmed `RoutineInfo` for list display.
749750
pub fn from_routine(r: &crate::agent::routine::Routine) -> Self {
750-
let (trigger_type, trigger_summary) = match &r.trigger {
751-
crate::agent::routine::Trigger::Cron { schedule, .. } => {
752-
("cron".to_string(), format!("cron: {}", schedule))
753-
}
751+
let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger {
752+
crate::agent::routine::Trigger::Cron { schedule, timezone } => (
753+
"cron".to_string(),
754+
schedule.clone(),
755+
crate::agent::routine::describe_cron(schedule, timezone.as_deref()),
756+
),
754757
crate::agent::routine::Trigger::Event {
755758
pattern, channel, ..
756759
} => {
757760
let ch = channel.as_deref().unwrap_or("any");
758-
("event".to_string(), format!("on {} /{}/", ch, pattern))
761+
(
762+
"event".to_string(),
763+
String::new(),
764+
format!("on {} /{}/", ch, pattern),
765+
)
759766
}
760767
crate::agent::routine::Trigger::SystemEvent {
761768
source, event_type, ..
762769
} => (
763770
"system_event".to_string(),
771+
String::new(),
764772
format!("event: {}.{}", source, event_type),
765773
),
766-
crate::agent::routine::Trigger::Manual => {
767-
("manual".to_string(), "manual only".to_string())
768-
}
774+
crate::agent::routine::Trigger::Manual => (
775+
"manual".to_string(),
776+
String::new(),
777+
"manual only".to_string(),
778+
),
769779
};
770780

771781
let action_type = match &r.action {
@@ -787,6 +797,7 @@ impl RoutineInfo {
787797
description: r.description.clone(),
788798
enabled: r.enabled,
789799
trigger_type,
800+
trigger_raw,
790801
trigger_summary,
791802
action_type: action_type.to_string(),
792803
last_run_at: r.last_run_at.map(|dt| dt.to_rfc3339()),
@@ -818,6 +829,9 @@ pub struct RoutineDetailResponse {
818829
pub name: String,
819830
pub description: String,
820831
pub enabled: bool,
832+
pub trigger_type: String,
833+
pub trigger_raw: String,
834+
pub trigger_summary: String,
821835
pub trigger: serde_json::Value,
822836
pub action: serde_json::Value,
823837
pub guardrails: serde_json::Value,

0 commit comments

Comments
 (0)