diff --git a/src/config.rs b/src/config.rs index 2187f0d7..406d6b0e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -339,6 +339,8 @@ pub struct CronJobConfig { /// Whether this cronjob is active (default: true) #[serde(default = "default_true")] pub enabled: bool, + /// Stable unique identifier (required when disable_on_success is set) + pub id: Option, /// Cron expression (5-field POSIX format) pub schedule: String, /// Target channel ID @@ -356,6 +358,14 @@ pub struct CronJobConfig { /// Timezone (default: "UTC") #[serde(default = "default_cron_timezone")] pub timezone: String, + /// Shell command to evaluate goal; exit 0 (+ optional match) = goal achieved, auto-disable job. + pub disable_on_success: Option, + /// If set, stdout of disable_on_success must contain this string (in addition to exit 0). + pub disable_on_success_match: Option, + /// Timeout in seconds for disable_on_success command (default: 60). + pub disable_on_success_timeout_secs: Option, + /// Working directory for disable_on_success command. + pub disable_on_success_working_dir: Option, } fn default_cron_platform() -> String { diff --git a/src/cron.rs b/src/cron.rs index a570e96e..88ee42ba 100644 --- a/src/cron.rs +++ b/src/cron.rs @@ -321,6 +321,10 @@ pub fn load_usercron_file(path: &Path, configured_platforms: &[&str]) -> Vec= baseline_len; + if is_usercron + && job.config.disable_on_success.is_some() + && evaluate_disable_on_success(&job.config).await + { + info!( + schedule = %job.config.schedule, + channel = %job.config.channel, + "✅ disable_on_success: goal achieved, disabling job" + ); + // Write enabled=false back to usercron file + if let Some(ref path) = usercron_path { + if let Err(e) = disable_job_in_usercron(path, &job.config) { + error!(error = %e, "failed to disable job in usercron file"); + } + } + // Post success notification to channel + if let Some(adapter) = adapters.get(&job.config.platform) { + let channel = ChannelRef { + platform: job.config.platform.clone(), + channel_id: job.config.channel.clone(), + thread_id: job.config.thread_id.clone(), + parent_id: None, + origin_event_id: None, + }; + let msg = format!("✅ Goal achieved: {}", job.config.message); + let _ = adapter.send_message(&channel, &msg).await; + } + // Trigger hot-reload on next tick + last_usercron_mtime = None; + continue; + } + info!( schedule = %job.config.schedule, channel = %job.config.channel, @@ -601,7 +641,7 @@ async fn fire_cronjob( .or(Some(reply_channel.channel_id.clone())), is_bot: true, timestamp: Some(Utc::now().to_rfc3339()), - message_id: None, // cron jobs don't originate from a message + message_id: None, // cron jobs don't originate from a message receiver_id: None, // cron jobs are self-triggered, no external receiver }; let sender_json = match serde_json::to_string(&sender) { @@ -633,6 +673,142 @@ async fn fire_cronjob( } } +/// Evaluate the `disable_on_success` command for a cron job. +/// Returns `true` if the goal is achieved (command exits 0 and optional match passes). +async fn evaluate_disable_on_success(job: &CronJobConfig) -> bool { + let cmd = match &job.disable_on_success { + Some(c) => c, + None => return false, + }; + + let timeout_secs = job.disable_on_success_timeout_secs.unwrap_or(60); + let mut command = tokio::process::Command::new("sh"); + command + .arg("-c") + .arg(cmd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + if let Some(ref dir) = job.disable_on_success_working_dir { + command.current_dir(dir); + } + + let result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + command.output(), + ) + .await; + + let output = match result { + Ok(Ok(output)) => output, + Ok(Err(e)) => { + warn!(command = %cmd, error = %e, "disable_on_success: failed to execute command"); + return false; + } + Err(_) => { + warn!(command = %cmd, timeout_secs, "disable_on_success: command timed out"); + return false; + } + }; + + if !output.status.success() { + return false; + } + + // Check optional match string against stdout + if let Some(ref match_str) = job.disable_on_success_match { + let stdout = String::from_utf8_lossy(&output.stdout); + if !stdout.contains(match_str.as_str()) { + info!( + command = %cmd, + match_str = %match_str, + "disable_on_success: exit 0 but stdout does not contain match string" + ); + return false; + } + } + + true +} + +/// Disable a job in the usercron TOML file by setting `enabled = false`. +/// Matches by `id` field. Uses line-based editing to preserve comments and formatting. +fn disable_job_in_usercron(path: &Path, job: &CronJobConfig) -> Result<(), String> { + let job_id = job + .id + .as_deref() + .ok_or_else(|| "job has no id, cannot disable in usercron".to_string())?; + + let content = + std::fs::read_to_string(path).map_err(|e| format!("failed to read usercron file: {e}"))?; + + let lines: Vec<&str> = content.lines().collect(); + let mut result = Vec::with_capacity(lines.len() + 1); + let mut in_target_job = false; + let mut found = false; + let mut has_enabled_field = false; + + for line in &lines { + // Detect [[jobs]] header + if line.trim() == "[[jobs]]" { + // If we were in the target job and it had no enabled field, insert one + if in_target_job && !has_enabled_field { + result.push("enabled = false".to_string()); + } + in_target_job = false; + has_enabled_field = false; + result.push(line.to_string()); + continue; + } + + // Detect id field to identify target job + if line.trim().starts_with("id") { + let value = line.split('=').nth(1).map(|v| { + // Strip inline comments before trimming quotes + let v = v.split('#').next().unwrap_or(v); + v.trim().trim_matches('"').trim_matches('\'').to_string() + }); + if value.as_deref() == Some(job_id) { + in_target_job = true; + found = true; + } + } + + // If in target job and we find enabled field, replace it + if in_target_job && line.trim().starts_with("enabled") { + has_enabled_field = true; + result.push("enabled = false".to_string()); + continue; + } + + result.push(line.to_string()); + } + + // Handle case where target job is the last one and has no enabled field + if in_target_job && !has_enabled_field { + result.push("enabled = false".to_string()); + } + + if !found { + return Err(format!( + "could not find job with id {:?} in usercron file", + job_id + )); + } + + let new_content = result.join("\n"); + // Preserve trailing newline if original had one + let new_content = if content.ends_with('\n') && !new_content.ends_with('\n') { + new_content + "\n" + } else { + new_content + }; + + std::fs::write(path, new_content).map_err(|e| format!("failed to write usercron file: {e}"))?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -1090,6 +1266,7 @@ platform = "slack" #[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 +1275,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: None, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -1105,6 +1286,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 +1295,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: None, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid cron expression")); @@ -1121,6 +1307,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 +1316,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: None, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("invalid timezone")); @@ -1137,6 +1328,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 +1337,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: None, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("unknown platform")); @@ -1153,6 +1349,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 +1358,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: None, + disable_on_success_working_dir: None, }]; let err = validate_cronjobs(&jobs, &["discord"]).unwrap_err(); assert!(err.to_string().contains("not configured")); @@ -1169,6 +1370,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 +1379,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: None, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_ok()); } @@ -1184,6 +1390,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 +1399,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: None, + disable_on_success_working_dir: None, }]; assert!(validate_cronjobs(&jobs, &["discord"]).is_err()); } @@ -1269,4 +1480,296 @@ command = "echo" let jobs = load_usercron_file(&path, &["discord"]); assert!(jobs.is_empty()); } + + // --- disable_on_success --- + + #[tokio::test] + async fn evaluate_disable_on_success_none_returns_false() { + let job = CronJobConfig { + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "ch".into(), + message: "msg".into(), + platform: "discord".into(), + sender_name: "cron".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: None, + disable_on_success_match: None, + disable_on_success_timeout_secs: None, + disable_on_success_working_dir: None, + }; + assert!(!evaluate_disable_on_success(&job).await); + } + + #[tokio::test] + async fn evaluate_disable_on_success_exit_zero_returns_true() { + let job = CronJobConfig { + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "ch".into(), + message: "msg".into(), + platform: "discord".into(), + sender_name: "cron".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("true".into()), + disable_on_success_match: None, + disable_on_success_timeout_secs: Some(5), + disable_on_success_working_dir: None, + }; + assert!(evaluate_disable_on_success(&job).await); + } + + #[tokio::test] + async fn evaluate_disable_on_success_exit_nonzero_returns_false() { + let job = CronJobConfig { + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "ch".into(), + message: "msg".into(), + platform: "discord".into(), + sender_name: "cron".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("false".into()), + disable_on_success_match: None, + disable_on_success_timeout_secs: Some(5), + disable_on_success_working_dir: None, + }; + assert!(!evaluate_disable_on_success(&job).await); + } + + #[tokio::test] + async fn evaluate_disable_on_success_match_present_returns_true() { + let job = CronJobConfig { + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "ch".into(), + message: "msg".into(), + platform: "discord".into(), + sender_name: "cron".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: Some(5), + disable_on_success_working_dir: None, + }; + assert!(evaluate_disable_on_success(&job).await); + } + + #[tokio::test] + async fn evaluate_disable_on_success_match_absent_returns_false() { + let job = CronJobConfig { + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "ch".into(), + message: "msg".into(), + platform: "discord".into(), + sender_name: "cron".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("echo NOPE".into()), + disable_on_success_match: Some("SUCCESS".into()), + disable_on_success_timeout_secs: Some(5), + disable_on_success_working_dir: None, + }; + assert!(!evaluate_disable_on_success(&job).await); + } + + #[tokio::test] + async fn evaluate_disable_on_success_timeout_returns_false() { + let job = CronJobConfig { + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "ch".into(), + message: "msg".into(), + platform: "discord".into(), + sender_name: "cron".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("sleep 10".into()), + disable_on_success_match: None, + disable_on_success_timeout_secs: Some(1), + disable_on_success_working_dir: None, + }; + assert!(!evaluate_disable_on_success(&job).await); + } + + #[tokio::test] + async fn evaluate_disable_on_success_working_dir() { + let dir = tempfile::tempdir().unwrap(); + let job = CronJobConfig { + id: None, + enabled: true, + schedule: "* * * * *".into(), + channel: "ch".into(), + message: "msg".into(), + platform: "discord".into(), + sender_name: "cron".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("pwd".into()), + disable_on_success_match: Some(dir.path().to_str().unwrap().into()), + disable_on_success_timeout_secs: Some(5), + disable_on_success_working_dir: Some(dir.path().to_str().unwrap().into()), + }; + assert!(evaluate_disable_on_success(&job).await); + } + + #[test] + fn disable_job_in_usercron_sets_enabled_false() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + let content = r#"[[jobs]] +id = "test-goal" +schedule = "*/10 * * * *" +channel = "123" +message = "test goal" +platform = "discord" +disable_on_success = "npm test" +"#; + std::fs::write(&path, content).unwrap(); + + let job = CronJobConfig { + id: Some("test-goal".into()), + enabled: true, + schedule: "*/10 * * * *".into(), + channel: "123".into(), + message: "test goal".into(), + platform: "discord".into(), + sender_name: "openab-cron".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("npm test".into()), + disable_on_success_match: None, + disable_on_success_timeout_secs: None, + disable_on_success_working_dir: None, + }; + + disable_job_in_usercron(&path, &job).unwrap(); + + let updated = std::fs::read_to_string(&path).unwrap(); + assert!(updated.contains("enabled = false")); + } + + #[test] + fn disable_job_in_usercron_preserves_other_jobs() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + let content = r#"[[jobs]] +id = "daily-report" +schedule = "0 9 * * *" +channel = "456" +message = "daily report" +platform = "discord" + +[[jobs]] +id = "test-goal" +schedule = "*/10 * * * *" +channel = "123" +message = "test goal" +platform = "discord" +disable_on_success = "npm test" +"#; + std::fs::write(&path, content).unwrap(); + + let job = CronJobConfig { + id: Some("test-goal".into()), + enabled: true, + schedule: "*/10 * * * *".into(), + channel: "123".into(), + message: "test goal".into(), + platform: "discord".into(), + sender_name: "openab-cron".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("npm test".into()), + disable_on_success_match: None, + disable_on_success_timeout_secs: None, + disable_on_success_working_dir: None, + }; + + disable_job_in_usercron(&path, &job).unwrap(); + + let updated = std::fs::read_to_string(&path).unwrap(); + // Only the second job should have enabled = false + let lines: Vec<&str> = updated.lines().collect(); + let enabled_lines: Vec<&&str> = lines + .iter() + .filter(|l| l.contains("enabled = false")) + .collect(); + assert_eq!(enabled_lines.len(), 1); + // First job should not have enabled = false + let first_job_end = updated.find("[[jobs]]\nid = \"test-goal\"").unwrap(); + assert!(!updated[..first_job_end].contains("enabled = false")); + } + + #[test] + fn disable_job_in_usercron_preserves_comments() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cronjob.toml"); + let content = r#"# My usercron config +[[jobs]] +id = "test-goal" +schedule = "*/10 * * * *" +channel = "123" +message = "test goal" # important goal +platform = "discord" +disable_on_success = "npm test" +"#; + std::fs::write(&path, content).unwrap(); + + let job = CronJobConfig { + id: Some("test-goal".into()), + enabled: true, + schedule: "*/10 * * * *".into(), + channel: "123".into(), + message: "test goal".into(), + platform: "discord".into(), + sender_name: "openab-cron".into(), + thread_id: None, + timezone: "UTC".into(), + disable_on_success: Some("npm test".into()), + disable_on_success_match: None, + disable_on_success_timeout_secs: None, + disable_on_success_working_dir: None, + }; + + disable_job_in_usercron(&path, &job).unwrap(); + + let updated = std::fs::read_to_string(&path).unwrap(); + assert!(updated.contains("# My usercron config")); + assert!(updated.contains("# important goal")); + assert!(updated.contains("enabled = false")); + } + + #[test] + fn cronjob_config_parses_disable_on_success_fields() { + let toml_str = r#" +[[jobs]] +id = "my-goal" +schedule = "*/10 * * * *" +channel = "123" +message = "goal" +disable_on_success = "npm test" +disable_on_success_match = "SUCCESS" +disable_on_success_timeout_secs = 30 +disable_on_success_working_dir = "/repo" +"#; + let parsed: UsercronFile = toml::from_str(toml_str).unwrap(); + let job = &parsed.jobs[0]; + assert_eq!(job.id.as_deref(), Some("my-goal")); + 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, Some(30)); + assert_eq!(job.disable_on_success_working_dir.as_deref(), Some("/repo")); + } }