use anyhow::Result;
use tracing::warn;
use super::style::*;
use crate::{cli::CronCommand, config};
pub async fn cmd_cron(sub: CronCommand) -> Result<()> {
match sub {
CronCommand::List | CronCommand::Status => {
banner(&format!("rsclaw cron v{}", option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev")));
let (jobs, parse_ok) = crate::cron::load_cron_jobs();
if !parse_ok {
warn_msg("cron.json5 has syntax errors - fix the file before modifying jobs");
}
if jobs.is_empty() {
warn_msg("no cron jobs configured");
} else {
println!(
" {:<36} {:<10} {:<14} {}",
bold("ID"),
bold("STATUS"),
bold("AGENT"),
bold("SCHEDULE")
);
for j in &jobs {
let enabled = j.enabled;
let agent = if j.agent_id.is_empty() { "(default)" } else { &j.agent_id };
let status = if enabled {
green("enabled")
} else {
red("disabled")
};
let schedule = j.cron_expr();
println!(
" {:<36} {:<10} {:<14} {}",
cyan(&j.id),
status,
agent,
dim(schedule)
);
}
}
}
CronCommand::Run { id } => {
let config = config::load()?;
let port = config.gateway.port;
let (jobs, _) = crate::cron::load_cron_jobs();
let job = jobs
.iter()
.find(|j| j.id == id)
.ok_or_else(|| anyhow::anyhow!("cron job '{id}' not found"))?;
let url = format!("http://127.0.0.1:{port}/api/v1/message");
let body = serde_json::json!({
"text": job.effective_message(),
"agent_id": job.agent_id,
"session_key": format!("cron:{id}:manual"),
});
let client = reqwest::Client::new();
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| anyhow::anyhow!("gateway unreachable at {url}: {e}"))?;
if resp.status().is_success() {
ok(&format!("cron job '{}' triggered", cyan(&id)));
} else {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
anyhow::bail!("gateway error {status}: {text}");
}
}
CronCommand::Runs { id } => {
banner(&format!("rsclaw cron runs: {id}"));
let log_file = config::loader::base_dir()
.join("var/data/cron")
.join(format!("{id}.jsonl"));
if !log_file.exists() {
warn_msg(&format!("no run history for '{}'", cyan(&id)));
} else {
let content = std::fs::read_to_string(&log_file)?;
for line in content.lines().rev().take(20) {
if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) {
let success = entry["success"].as_bool().unwrap_or(false);
let status = if success { green("ok") } else { red("fail") };
let ts = entry["startedAt"].as_str().unwrap_or("?");
let error = entry["error"].as_str().unwrap_or("");
println!(
" [{}] {} {}",
dim(ts),
status,
if error.is_empty() {
String::new()
} else {
red(error)
}
);
}
}
}
}
CronCommand::Add(args) => {
validate_cron_schedule(&args.schedule)?;
let (mut jobs, parse_ok) = crate::cron::load_cron_jobs();
if !parse_ok {
anyhow::bail!("cron.json5 has syntax errors - fix the file before adding jobs");
}
let id = format!("job-{}", jobs.len() + 1);
let job = crate::cron::CronJob {
id: id.clone(),
name: None,
agent_id: args.agent.clone().unwrap_or_else(|| "main".to_string()),
session_key: None,
enabled: true,
schedule: crate::cron::CronSchedule::Flat(args.schedule.clone()),
payload: None,
message: Some(args.message.clone()),
delivery: None,
session_target: None,
wake_mode: None,
state: Some(crate::cron::CronJobState::default()),
iter: None,
created_at_ms: Some(chrono::Utc::now().timestamp_millis() as u64),
updated_at_ms: None,
};
jobs.push(job);
crate::cron::save_cron_jobs(&jobs)?;
if let Ok(config) = config::load() {
let port = config.gateway.port;
let url = format!("http://127.0.0.1:{port}/api/v1/cron/reload");
if let Ok(client) = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(3))
.build()
{
if let Err(e) = client.post(&url).send() {
warn!("failed to notify gateway of cron reload: {e}");
}
}
}
ok(&format!(
"added cron job '{}' ({})",
cyan(&id),
dim(&args.schedule)
));
}
CronCommand::Edit { id } => {
let (jobs, _) = crate::cron::load_cron_jobs();
jobs.iter()
.find(|j| j.id == id)
.ok_or_else(|| anyhow::anyhow!("cron job '{id}' not found"))?;
let jobs_file = config::loader::base_dir().join("cron.json5");
let editor = std::env::var("EDITOR").unwrap_or_else(|_| {
if cfg!(windows) { "notepad".to_owned() } else { "vi".to_owned() }
});
let status = std::process::Command::new(&editor).arg(&jobs_file).status()?;
if !status.success() {
anyhow::bail!("editor exited with {status}");
}
ok(&format!("edited cron jobs file"));
}
CronCommand::Enable { id } => cron_set_enabled(&id, true)?,
CronCommand::Disable { id } => cron_set_enabled(&id, false)?,
CronCommand::Rm { id } => {
let (mut jobs, parse_ok) = crate::cron::load_cron_jobs();
if !parse_ok {
anyhow::bail!("cron.json5 has syntax errors - fix the file before removing jobs");
}
let before = jobs.len();
jobs.retain(|j| j.id != id);
if jobs.len() == before {
anyhow::bail!("cron job '{id}' not found");
}
crate::cron::save_cron_jobs(&jobs)?;
if let Ok(config) = config::load() {
let port = config.gateway.port;
let url = format!("http://127.0.0.1:{port}/api/v1/cron/reload");
if let Ok(client) = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(3))
.build()
{
if let Err(e) = client.post(&url).send() {
warn!("failed to notify gateway of cron reload: {e}");
}
}
}
ok(&format!("removed cron job '{}'", cyan(&id)));
}
}
Ok(())
}
fn validate_cron_schedule(schedule: &str) -> Result<()> {
let fields: Vec<&str> = schedule.split_whitespace().collect();
if fields.len() != 5 {
anyhow::bail!(
"cron schedule must have exactly 5 fields (got {}): \"{}\"",
fields.len(),
schedule
);
}
let ranges = [(0u32, 59u32), (0, 23), (1, 31), (1, 12), (0, 7)];
let names = ["minute", "hour", "day", "month", "weekday"];
for (i, (field, &(lo, hi))) in fields.iter().zip(ranges.iter()).enumerate() {
validate_cron_field(field, lo, hi)
.map_err(|e| anyhow::anyhow!("{} field: {e}", names[i]))?;
}
Ok(())
}
fn validate_cron_field(field: &str, min: u32, max: u32) -> Result<()> {
if field == "*" {
return Ok(());
}
for part in field.split(',') {
validate_cron_part(part, min, max)?;
}
Ok(())
}
fn validate_cron_part(part: &str, min: u32, max: u32) -> Result<()> {
if let Some(step) = part.strip_prefix("*/") {
let n: u32 = step
.parse()
.map_err(|_| anyhow::anyhow!("invalid step '{step}'"))?;
if n == 0 {
anyhow::bail!("step cannot be 0");
}
return Ok(());
}
if part.contains('-') {
let (range_part, step_opt) = match part.split_once('/') {
Some((r, s)) => {
let n: u32 = s
.parse()
.map_err(|_| anyhow::anyhow!("invalid step '{s}'"))?;
if n == 0 {
anyhow::bail!("step cannot be 0");
}
(r, Some(n))
}
None => (part, None),
};
let (a, b) = range_part
.split_once('-')
.ok_or_else(|| anyhow::anyhow!("invalid range '{range_part}'"))?;
let lo: u32 = a
.parse()
.map_err(|_| anyhow::anyhow!("invalid value '{a}'"))?;
let hi: u32 = b
.parse()
.map_err(|_| anyhow::anyhow!("invalid value '{b}'"))?;
if lo < min || hi > max || lo > hi {
anyhow::bail!("range {lo}-{hi} out of bounds ({min}-{max})");
}
let _ = step_opt;
return Ok(());
}
let n: u32 = part
.parse()
.map_err(|_| anyhow::anyhow!("invalid value '{part}'"))?;
if n < min || n > max {
anyhow::bail!("value {n} out of range ({min}-{max})");
}
Ok(())
}
pub fn cron_set_enabled(id: &str, enabled: bool) -> Result<()> {
let (mut jobs, parse_ok) = crate::cron::load_cron_jobs();
if !parse_ok {
anyhow::bail!("cron.json5 has syntax errors - fix the file before modifying jobs");
}
let mut found = false;
for job in &mut jobs {
if job.id == id {
job.enabled = enabled;
job.updated_at_ms = Some(chrono::Utc::now().timestamp_millis() as u64);
found = true;
break;
}
}
if !found {
anyhow::bail!("cron job '{id}' not found");
}
crate::cron::save_cron_jobs(&jobs)?;
if let Ok(config) = config::load() {
let port = config.gateway.port;
let url = format!("http://127.0.0.1:{port}/api/v1/cron/reload");
if let Ok(client) = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(3))
.build()
{
if let Err(e) = client.post(&url).send() {
warn!("failed to notify gateway of cron reload: {e}");
}
}
}
if enabled {
ok(&format!("cron job '{}' enabled", cyan(id)));
} else {
ok(&format!("cron job '{}' disabled", cyan(id)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::validate_cron_schedule;
#[test]
fn valid_schedules() {
assert!(validate_cron_schedule("* * * * *").is_ok());
assert!(validate_cron_schedule("0 9 * * 1").is_ok());
assert!(validate_cron_schedule("*/15 * * * *").is_ok());
assert!(validate_cron_schedule("0 0 1 1 *").is_ok());
assert!(validate_cron_schedule("0,30 8-18 * * 1-5").is_ok());
assert!(validate_cron_schedule("0 12 * * 7").is_ok()); }
#[test]
fn wrong_field_count() {
assert!(validate_cron_schedule("* * * *").is_err());
assert!(validate_cron_schedule("* * * * * *").is_err());
assert!(validate_cron_schedule("").is_err());
}
#[test]
fn out_of_range_values() {
assert!(validate_cron_schedule("60 * * * *").is_err()); assert!(validate_cron_schedule("* 24 * * *").is_err()); assert!(validate_cron_schedule("* * 0 * *").is_err()); assert!(validate_cron_schedule("* * * 13 *").is_err()); assert!(validate_cron_schedule("* * * * 8").is_err()); }
#[test]
fn invalid_step_zero() {
assert!(validate_cron_schedule("*/0 * * * *").is_err());
}
}