From f8d3d163721a7ceda5a8842b122239b01854ca85 Mon Sep 17 00:00:00 2001 From: chaodufashi Date: Wed, 13 May 2026 23:07:48 +0000 Subject: [PATCH] feat(cron): auto-disable usercron jobs on success --- Cargo.lock | 1 + Cargo.toml | 1 + docs/config-reference.md | 27 +++ docs/cronjob.md | 30 ++- src/config.rs | 16 ++ src/cron.rs | 455 +++++++++++++++++++++++++++++++++++++-- 6 files changed, 511 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6950fc24..474531ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1070,6 +1070,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "toml", + "toml_edit", "tracing", "tracing-subscriber", "unicode-width", diff --git a/Cargo.toml b/Cargo.toml index 8c19bcf5..b6f16b09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" +toml_edit = "0.22" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] } serenity = { version = "0.12", default-features = false, features = ["client", "gateway", "model", "rustls_backend", "cache"] } diff --git a/docs/config-reference.md b/docs/config-reference.md index 9ddaf40f..f3dcc489 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -257,6 +257,33 @@ timezone = "UTC" The external `cronjob.toml` uses `[[jobs]]` (same fields). See [Usercron docs](cronjob.md#usercron--hot-reload-with-cronjobtoml) for details. +### Usercron-only `[[jobs]]` fields + +These fields are valid only in the external usercron file, for example `$HOME/.openab/cronjob.toml`. They are rejected in baseline `[[cron.jobs]]` because OpenAB only writes state back to the user-managed cron file. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `id` | string | *required with `disable_on_success`* | Stable job ID used when the scheduler writes `enabled = false` or `thread_id` back to `cronjob.toml`. | +| `disable_on_success` | string | — | Command to run before sending the scheduled prompt. | +| `disable_on_success_match` | string | *required with `disable_on_success`* | Marker that must appear in stdout or stderr, in addition to exit code `0`, before the job is considered complete. | +| `disable_on_success_timeout_secs` | integer | `60` | Timeout for the completion check command. | +| `disable_on_success_working_dir` | string | — | Working directory for the completion check command. | + +Example: + +```toml +[[jobs]] +id = "fix-unit-tests" +enabled = true +schedule = "*/10 * * * *" +channel = "123456789" +message = "Unit tests are still failing. Continue fixing them." +disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS" +disable_on_success_match = "OPENAB_GOAL_SUCCESS" +disable_on_success_timeout_secs = 120 +disable_on_success_working_dir = "/workspace/my-project" +``` + **Cron expression format:** ``` diff --git a/docs/cronjob.md b/docs/cronjob.md index e754177e..f0201941 100644 --- a/docs/cronjob.md +++ b/docs/cronjob.md @@ -256,6 +256,33 @@ Agent: ✅ Written to cronjob.toml, takes effect within 1 minute This enables mobile-friendly schedule management — talk to your agent from your phone, and it updates the cron file for you. +### Goal-Driven Auto-Disable + +Usercron jobs can stop themselves once a goal is complete. Add `disable_on_success` to run a command before the scheduled prompt is sent. The job is considered complete only when the command exits `0` **and** stdout or stderr contains `disable_on_success_match`. + +```toml +[[jobs]] +id = "fix-unit-tests" # required for scheduler writeback +enabled = true +schedule = "*/10 * * * *" +channel = "1490282656913559673" +message = "Unit tests are still failing. Continue fixing them and report progress." + +disable_on_success = "npm test && echo OPENAB_GOAL_SUCCESS" +disable_on_success_match = "OPENAB_GOAL_SUCCESS" +disable_on_success_timeout_secs = 120 +disable_on_success_working_dir = "/workspace/my-project" +``` + +Execution flow: + +1. The schedule matches. +2. The scheduler runs `disable_on_success`. +3. If the command exits `0` and output contains `disable_on_success_match`, OpenAB posts `✅ Goal achieved`, writes `enabled = false` back to `$HOME/.openab/cronjob.toml`, and skips the regular prompt. +4. Otherwise, OpenAB sends the regular `message` and the agent continues working. + +`disable_on_success` is supported only in usercron `[[jobs]]`, not baseline `[[cron.jobs]]`. This keeps scheduler writeback limited to the user-managed cron file. + ### Kubernetes Deployment Mount `cronjob.toml` on a PVC so it persists across pod restarts, and set `usercron_path` in your config.toml: @@ -273,7 +300,7 @@ usercron_path = "cronjob.toml" - **Minute-aligned**: The scheduler aligns to minute boundaries (`:00`), so `0 9 * * *` fires at exactly 09:00:00, not at whatever second the process started. - **Overlap protection**: If a previous execution of the same job is still running, the next tick is skipped. - **Isolation**: Cron failures are logged but never block interactive chat traffic. -- **Stateless**: No persistence needed. Schedules are re-evaluated from config on restart. +- **Usercron persistence**: For usercron jobs, the scheduler may write `thread_id` and `enabled = false` back to `cronjob.toml`. - **Graceful shutdown**: In-flight cron tasks are waited on (up to 30 seconds) during shutdown. ## Sender Identity @@ -320,3 +347,4 @@ See [Kubernetes CronJob Reference Architecture](cronjob_k8s_refarch.md) for the | Channel not found | Bot not in channel | Invite the bot to the target channel | | Usercron not reloading | File not saved / wrong path | Check logs for `usercron file changed, reloading` | | Usercron parse error | Invalid TOML syntax | Check logs for `failed to parse usercron file` | +| Goal job does not auto-disable | Command did not exit `0` or output did not include `disable_on_success_match` | Run the command manually and confirm both conditions | diff --git a/src/config.rs b/src/config.rs index 2187f0d7..77aad434 100644 --- a/src/config.rs +++ b/src/config.rs @@ -336,6 +336,8 @@ pub struct PoolConfig { #[derive(Debug, Clone, Deserialize)] pub struct CronJobConfig { + /// Stable ID for usercron jobs that need scheduler writeback. + pub id: Option, /// Whether this cronjob is active (default: true) #[serde(default = "default_true")] pub enabled: bool, @@ -356,6 +358,17 @@ pub struct CronJobConfig { /// Timezone (default: "UTC") #[serde(default = "default_cron_timezone")] pub timezone: String, + /// Usercron-only: command to run before firing. Exit 0 plus a matching + /// `disable_on_success_match` means the goal is complete and the scheduler + /// disables the job in the usercron file. + pub disable_on_success: Option, + /// Usercron-only: required output marker for `disable_on_success`. + pub disable_on_success_match: Option, + /// Usercron-only: timeout for `disable_on_success`. + #[serde(default = "default_disable_on_success_timeout_secs")] + pub disable_on_success_timeout_secs: u64, + /// Usercron-only: working directory for `disable_on_success`. + pub disable_on_success_working_dir: Option, } fn default_cron_platform() -> String { @@ -367,6 +380,9 @@ fn default_cron_sender() -> String { fn default_cron_timezone() -> String { "UTC".into() } +fn default_disable_on_success_timeout_secs() -> u64 { + 60 +} /// Controls how tool calls are rendered in chat messages. /// diff --git a/src/cron.rs b/src/cron.rs index a570e96e..d05c7e3f 100644 --- a/src/cron.rs +++ b/src/cron.rs @@ -9,7 +9,10 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use std::time::SystemTime; +use tokio::process::Command; use tokio::sync::Mutex; +use tokio::time::timeout; +use toml_edit::{value, DocumentMut}; use tracing::{debug, error, info, warn}; /// Parse a 5-field POSIX cron expression into a `Schedule`. @@ -269,6 +272,11 @@ pub fn validate_cronjobs( job.platform ); } + if job.disable_on_success.is_some() { + anyhow::bail!( + "cronjobs[{i}]: disable_on_success is only supported in usercron [[jobs]], not baseline [[cron.jobs]]" + ); + } } Ok(()) } @@ -321,6 +329,20 @@ pub fn load_usercron_file(path: &Path, configured_platforms: &[&str]) -> Vec, } /// Parse a list of CronJobConfig into ParsedJob, filtering out disabled/invalid entries. -fn parse_job_list(configs: &[CronJobConfig], source: &str) -> Vec { +fn parse_job_list( + configs: &[CronJobConfig], + source: &str, + usercron_path: Option<&Path>, +) -> Vec { configs.iter().filter(|job| { if !job.enabled { info!(schedule = %job.schedule, channel = %job.channel, source, "cronjob disabled, skipping"); @@ -365,7 +392,12 @@ fn parse_job_list(configs: &[CronJobConfig], source: &str) -> Vec { message = %job.message, source, "cronjob registered" ); - Some(ParsedJob { schedule, tz, config: job.clone() }) + Some(ParsedJob { + schedule, + tz, + config: job.clone(), + usercron_path: usercron_path.map(Path::to_path_buf), + }) }).collect() } @@ -382,7 +414,7 @@ pub async fn run_scheduler( let platform_refs: Vec<&str> = configured_platforms.iter().map(|s| s.as_str()).collect(); // Parse baseline jobs from config.toml - let baseline_jobs = parse_job_list(&cronjobs, "config.toml"); + let baseline_jobs = parse_job_list(&cronjobs, "config.toml", None); // Load initial usercron jobs let mut usercron_jobs = if let Some(ref path) = usercron_path { @@ -390,7 +422,7 @@ pub async fn run_scheduler( if !configs.is_empty() { info!(count = configs.len(), path = %path.display(), "loaded usercron jobs"); } - parse_job_list(&configs, "cronjob.toml") + parse_job_list(&configs, "cronjob.toml", Some(path.as_path())) } else { vec![] }; @@ -443,18 +475,12 @@ pub async fn run_scheduler( if current_mtime != last_usercron_mtime { let configs = load_usercron_file(path, &platform_refs); info!(count = configs.len(), path = %path.display(), "usercron file changed, reloading"); - // Clear in-flight tracking for usercron jobs (indices shift on reload). - // Design note: if a still-running old usercron task's InFlightGuard - // drops after this point, the remove is a no-op (index already cleared). - // A new job at the same index *could* fire concurrently in this tick — - // probability is negligible (reload + fire on same tick + same index) - // and acceptable for a hot-reload feature. - { - let mut running = in_flight.lock().await; - let baseline_len = baseline_jobs.len(); - running.retain(|idx| *idx < baseline_len); - } - usercron_jobs = parse_job_list(&configs, "cronjob.toml"); + // Keep in-flight indices across reload. A scheduler writeback + // (thread_id or enabled=false) changes mtime deterministically; + // clearing usercron indices here would allow the same job to + // overlap on the next tick while its previous run is still active. + usercron_jobs = + parse_job_list(&configs, "cronjob.toml", Some(path.as_path())); last_usercron_mtime = current_mtime; } } @@ -483,11 +509,13 @@ pub async fn run_scheduler( in_flight.lock().await.insert(idx); let config = job.config.clone(); + let usercron_path = job.usercron_path.clone(); let router = router.clone(); let adapters = adapters.clone(); let in_flight = in_flight.clone(); tasks.spawn(async move { - fire_cronjob(idx, &config, &router, &adapters, in_flight).await; + fire_cronjob(idx, &config, usercron_path, &router, &adapters, in_flight) + .await; }); } while tasks.try_join_next().is_some() {} @@ -523,6 +551,7 @@ impl Drop for InFlightGuard { async fn fire_cronjob( idx: usize, job: &CronJobConfig, + usercron_path: Option, router: &Arc, adapters: &HashMap>, in_flight: Arc>>, @@ -540,6 +569,62 @@ async fn fire_cronjob( } }; + if let Some(command) = non_empty_opt(job.disable_on_success.as_deref()) { + let marker = match non_empty_opt(job.disable_on_success_match.as_deref()) { + Some(marker) => marker, + None => { + warn!( + id = job.id.as_deref().unwrap_or(""), + "disable_on_success configured without disable_on_success_match, treating as not achieved" + ); + "" + } + }; + if !marker.is_empty() { + match check_disable_on_success(job, command, marker).await { + DisableOnSuccessResult::Achieved => { + let channel = ChannelRef { + platform: job.platform.clone(), + channel_id: job.channel.clone(), + thread_id: job.thread_id.clone(), + parent_id: None, + origin_event_id: None, + }; + if let Err(e) = adapter + .send_message( + &channel, + &format!( + "✅ Goal achieved: `{}` matched `{}`. Disabling cronjob.", + command, marker + ), + ) + .await + { + error!(channel = %job.channel, error = %e, "failed to send goal achieved message"); + } + + if let (Some(path), Some(id)) = + (usercron_path.as_deref(), non_empty_opt(job.id.as_deref())) + { + if let Err(e) = update_usercron_job(path, id, Some(false), None) { + error!(path = %path.display(), id, error = %e, "failed to disable completed usercron job"); + } + } else { + warn!("completed disable_on_success job has no usercron path or id, cannot write enabled=false"); + } + return; + } + DisableOnSuccessResult::NotAchieved(reason) => { + info!( + id = job.id.as_deref().unwrap_or(""), + reason, + "disable_on_success not achieved, firing cronjob normally" + ); + } + } + } + } + let thread_channel = ChannelRef { platform: job.platform.clone(), channel_id: job.channel.clone(), @@ -570,7 +655,18 @@ async fn fire_cronjob( .create_thread(&thread_channel, &trigger_msg, &thread_name) .await { - Ok(ch) => ch, + Ok(ch) => { + if let (Some(path), Some(id), Some(thread_id)) = ( + usercron_path.as_deref(), + non_empty_opt(job.id.as_deref()), + ch.thread_id.as_deref().or(Some(ch.channel_id.as_str())), + ) { + if let Err(e) = update_usercron_job(path, id, None, Some(thread_id)) { + warn!(path = %path.display(), id, error = %e, "failed to persist usercron thread_id"); + } + } + ch + } Err(e) => { error!(channel = %job.channel, error = %e, "failed to create cron thread"); let _ = adapter @@ -633,6 +729,120 @@ async fn fire_cronjob( } } +enum DisableOnSuccessResult { + Achieved, + NotAchieved(&'static str), +} + +fn non_empty_opt(value: Option<&str>) -> Option<&str> { + value.and_then(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +async fn check_disable_on_success( + job: &CronJobConfig, + command: &str, + marker: &str, +) -> DisableOnSuccessResult { + let timeout_secs = job.disable_on_success_timeout_secs.max(1); + let mut child = shell_command(command); + if let Some(dir) = non_empty_opt(job.disable_on_success_working_dir.as_deref()) { + child.current_dir(dir); + } + + let output = match timeout(std::time::Duration::from_secs(timeout_secs), child.output()).await + { + Ok(Ok(output)) => output, + Ok(Err(e)) => { + warn!( + id = job.id.as_deref().unwrap_or(""), + command, + error = %e, + "disable_on_success command failed to start" + ); + return DisableOnSuccessResult::NotAchieved("command failed to start"); + } + Err(_) => { + warn!( + id = job.id.as_deref().unwrap_or(""), + command, + timeout_secs, + "disable_on_success command timed out" + ); + return DisableOnSuccessResult::NotAchieved("command timed out"); + } + }; + + if !output.status.success() { + return DisableOnSuccessResult::NotAchieved("command exited non-zero"); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if stdout.contains(marker) || stderr.contains(marker) { + DisableOnSuccessResult::Achieved + } else { + DisableOnSuccessResult::NotAchieved("success marker not found") + } +} + +fn shell_command(command: &str) -> Command { + #[cfg(windows)] + { + let mut child = Command::new("cmd"); + child.arg("/C").arg(command); + child + } + #[cfg(not(windows))] + { + let mut child = Command::new("sh"); + child.arg("-c").arg(command); + child + } +} + +fn update_usercron_job( + path: &Path, + id: &str, + enabled: Option, + thread_id: Option<&str>, +) -> anyhow::Result<()> { + let content = std::fs::read_to_string(path)?; + let mut doc = content.parse::()?; + let jobs = doc + .get_mut("jobs") + .and_then(|item| item.as_array_of_tables_mut()) + .ok_or_else(|| anyhow::anyhow!("usercron file has no [[jobs]] array"))?; + + let mut found = false; + for table in jobs.iter_mut() { + if table.get("id").and_then(|item| item.as_str()) != Some(id) { + continue; + } + if let Some(enabled) = enabled { + table["enabled"] = value(enabled); + } + if let Some(thread_id) = thread_id { + table["thread_id"] = value(thread_id); + } + found = true; + break; + } + + if !found { + anyhow::bail!("usercron job id {:?} not found", id); + } + + std::fs::write(path, doc.to_string())?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -966,6 +1176,11 @@ message = "hello" assert_eq!(job.sender_name, "openab-cron"); assert_eq!(job.timezone, "UTC"); assert!(job.thread_id.is_none()); + assert!(job.id.is_none()); + assert!(job.disable_on_success.is_none()); + assert!(job.disable_on_success_match.is_none()); + assert_eq!(job.disable_on_success_timeout_secs, 60); + assert!(job.disable_on_success_working_dir.is_none()); } #[test] @@ -992,6 +1207,11 @@ platform = "slack" sender_name = "DailyOps" timezone = "Asia/Taipei" thread_id = "789" +id = "daily-report" +disable_on_success = "npm test" +disable_on_success_match = "SUCCESS" +disable_on_success_timeout_secs = 30 +disable_on_success_working_dir = "/tmp/project" "#; let cfg: UsercronFile = toml::from_str(toml_str).unwrap(); let job = &cfg.jobs[0]; @@ -999,6 +1219,14 @@ thread_id = "789" assert_eq!(job.sender_name, "DailyOps"); assert_eq!(job.timezone, "Asia/Taipei"); assert_eq!(job.thread_id.as_deref(), Some("789")); + assert_eq!(job.id.as_deref(), Some("daily-report")); + assert_eq!(job.disable_on_success.as_deref(), Some("npm test")); + assert_eq!(job.disable_on_success_match.as_deref(), Some("SUCCESS")); + assert_eq!(job.disable_on_success_timeout_secs, 30); + assert_eq!( + job.disable_on_success_working_dir.as_deref(), + Some("/tmp/project") + ); } #[test] @@ -1085,11 +1313,168 @@ platform = "slack" assert_eq!(jobs[0].message, "discord job"); } + #[test] + fn load_usercron_skips_disable_on_success_without_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +schedule = "* * * * *" +channel = "123" +message = "missing id" +disable_on_success = "echo SUCCESS" +disable_on_success_match = "SUCCESS" +"#, + ) + .unwrap(); + let jobs = load_usercron_file(&path, &["discord"]); + assert!(jobs.is_empty()); + } + + #[test] + fn load_usercron_skips_disable_on_success_without_match() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +id = "goal" +schedule = "* * * * *" +channel = "123" +message = "missing marker" +disable_on_success = "echo SUCCESS" +"#, + ) + .unwrap(); + let jobs = load_usercron_file(&path, &["discord"]); + assert!(jobs.is_empty()); + } + + #[test] + fn validate_cronjobs_rejects_baseline_disable_on_success() { + let jobs = vec![CronJobConfig { + id: Some("baseline-goal".into()), + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("echo SUCCESS".into()), + disable_on_success_match: Some("SUCCESS".into()), + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, + }]; + let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); + assert!(err.to_string().contains("only supported in usercron")); + } + + #[test] + fn update_usercron_job_sets_enabled_and_thread_id_by_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +id = "goal-a" +enabled = true +schedule = "* * * * *" +channel = "123" +message = "a" + +[[jobs]] +id = "goal-b" +enabled = true +schedule = "* * * * *" +channel = "456" +message = "b" +"#, + ) + .unwrap(); + + update_usercron_job(&path, "goal-b", Some(false), Some("thread-456")).unwrap(); + + let updated = std::fs::read_to_string(&path).unwrap(); + let doc = updated.parse::().unwrap(); + let jobs = doc["jobs"].as_array_of_tables().unwrap(); + let job_a = jobs.iter().next().unwrap(); + let job_b = jobs.iter().nth(1).unwrap(); + assert_eq!(job_a["id"].as_str(), Some("goal-a")); + assert_eq!(job_a["enabled"].as_bool(), Some(true)); + assert!(job_a.get("thread_id").is_none()); + assert_eq!(job_b["id"].as_str(), Some("goal-b")); + assert_eq!(job_b["enabled"].as_bool(), Some(false)); + assert_eq!(job_b["thread_id"].as_str(), Some("thread-456")); + } + + #[test] + fn update_usercron_job_errors_for_missing_id() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + std::fs::write( + &path, + r#" +[[jobs]] +id = "goal-a" +schedule = "* * * * *" +channel = "123" +message = "a" +"#, + ) + .unwrap(); + let err = update_usercron_job(&path, "missing", Some(false), None).unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[tokio::test] + async fn disable_on_success_requires_exit_zero_and_marker() { + let mut job = test_cron_job(); + job.disable_on_success_timeout_secs = 5; + + assert!(matches!( + check_disable_on_success(&job, "printf SUCCESS", "SUCCESS").await, + DisableOnSuccessResult::Achieved + )); + assert!(matches!( + check_disable_on_success(&job, "printf DONE", "SUCCESS").await, + DisableOnSuccessResult::NotAchieved("success marker not found") + )); + assert!(matches!( + check_disable_on_success(&job, "printf SUCCESS; exit 1", "SUCCESS").await, + DisableOnSuccessResult::NotAchieved("command exited non-zero") + )); + } + + fn test_cron_job() -> CronJobConfig { + CronJobConfig { + id: Some("goal".into()), + enabled: true, + schedule: "* * * * *".into(), + channel: "123".into(), + message: "hi".into(), + platform: "discord".into(), + sender_name: "test".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("echo SUCCESS".into()), + disable_on_success_match: Some("SUCCESS".into()), + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, + } + } + // --- validate_cronjobs tests --- #[test] fn validate_cronjobs_valid_passes() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "0 9 * * 1-5".into(), channel: "123".into(), @@ -1098,6 +1483,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -1105,6 +1494,7 @@ platform = "slack" #[test] fn validate_cronjobs_invalid_cron_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "bad".into(), channel: "123".into(), @@ -1113,6 +1503,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid cron expression")); @@ -1121,6 +1515,7 @@ platform = "slack" #[test] fn validate_cronjobs_invalid_timezone_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "* * * * *".into(), channel: "123".into(), @@ -1129,6 +1524,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "Mars/Olympus".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid timezone")); @@ -1137,6 +1536,7 @@ platform = "slack" #[test] fn validate_cronjobs_unknown_platform_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "* * * * *".into(), channel: "123".into(), @@ -1145,6 +1545,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("unknown platform")); @@ -1153,6 +1557,7 @@ platform = "slack" #[test] fn validate_cronjobs_unconfigured_platform_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "* * * * *".into(), channel: "123".into(), @@ -1161,6 +1566,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("not configured")); @@ -1169,6 +1578,7 @@ platform = "slack" #[test] fn validate_cronjobs_disabled_with_invalid_cron_passes() { let jobs = vec![CronJobConfig { + id: None, enabled: false, schedule: "bad".into(), channel: "123".into(), @@ -1177,6 +1587,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -1184,6 +1598,7 @@ platform = "slack" #[test] fn validate_cronjobs_enabled_with_invalid_cron_still_fails() { let jobs = vec![CronJobConfig { + id: None, enabled: true, schedule: "bad".into(), channel: "123".into(), @@ -1192,6 +1607,10 @@ platform = "slack" sender_name: "test".into(), thread_id: None, timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: 60, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_err()); }