feat(cron): add cron update CLI subcommand for in-place job updates

Add Update variant to CronCommands in both main.rs and lib.rs, with
handler in cron/mod.rs that constructs a CronJobPatch and calls
update_job(). Includes security policy check for command changes.

Fixes from review feedback:
- --tz alone now correctly updates timezone (fetches existing schedule)
- --expression alone preserves existing timezone instead of clearing it
- All-None patch (no flags) now returns an error
- Output uses consistent emoji prefix

Tests exercise handle_command directly to cover schedule construction.

Closes #809

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Edvard 2026-02-18 08:47:52 -05:00 committed by Chummy
parent 0910f2a710
commit 8b4607a1ef
4 changed files with 283 additions and 2 deletions

View file

@ -776,7 +776,7 @@ See [aieos.org](https://aieos.org) for the full schema and live examples.
| `service` | Manage user-level background service | | `service` | Manage user-level background service |
| `doctor` | Diagnose daemon/scheduler/channel freshness | | `doctor` | Diagnose daemon/scheduler/channel freshness |
| `status` | Show full system status | | `status` | Show full system status |
| `cron` | Manage scheduled tasks (`list/add/add-at/add-every/once/remove/pause/resume`) | | `cron` | Manage scheduled tasks (`list/add/add-at/add-every/once/remove/update/pause/resume`) |
| `models` | Refresh provider model catalogs (`models refresh`) | | `models` | Refresh provider model catalogs (`models refresh`) |
| `providers` | List supported providers and aliases | | `providers` | List supported providers and aliases |
| `channel` | List/start/doctor channels and bind Telegram identities | | `channel` | List/start/doctor channels and bind Telegram identities |

View file

@ -1,5 +1,6 @@
use crate::config::Config; use crate::config::Config;
use anyhow::Result; use crate::security::SecurityPolicy;
use anyhow::{bail, Result};
mod schedule; mod schedule;
mod store; mod store;
@ -96,6 +97,58 @@ pub fn handle_command(command: crate::CronCommands, config: &Config) -> Result<(
println!(" Cmd : {}", job.command); println!(" Cmd : {}", job.command);
Ok(()) Ok(())
} }
crate::CronCommands::Update {
id,
expression,
tz,
command,
name,
} => {
if expression.is_none() && tz.is_none() && command.is_none() && name.is_none() {
bail!("At least one of --expression, --tz, --command, or --name must be provided");
}
// Merge expression/tz with the existing schedule so that
// --tz alone updates the timezone and --expression alone
// preserves the existing timezone.
let schedule = if expression.is_some() || tz.is_some() {
let existing = get_job(config, &id)?;
let (existing_expr, existing_tz) = match existing.schedule {
Schedule::Cron {
expr,
tz: existing_tz,
} => (expr, existing_tz),
_ => bail!("Cannot update expression/tz on a non-cron schedule"),
};
Some(Schedule::Cron {
expr: expression.unwrap_or(existing_expr),
tz: tz.or(existing_tz),
})
} else {
None
};
if let Some(ref cmd) = command {
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
if !security.is_command_allowed(cmd) {
bail!("Command blocked by security policy: {cmd}");
}
}
let patch = CronJobPatch {
schedule,
command,
name,
..CronJobPatch::default()
};
let job = update_job(config, &id, patch)?;
println!("\u{2705} Updated cron job {}", job.id);
println!(" Expr: {}", job.expression);
println!(" Next: {}", job.next_run.to_rfc3339());
println!(" Cmd : {}", job.command);
Ok(())
}
crate::CronCommands::Remove { id } => remove_job(config, &id), crate::CronCommands::Remove { id } => remove_job(config, &id),
crate::CronCommands::Pause { id } => { crate::CronCommands::Pause { id } => {
pause_job(config, &id)?; pause_job(config, &id)?;
@ -167,3 +220,197 @@ fn parse_delay(input: &str) -> Result<chrono::Duration> {
}; };
Ok(duration) Ok(duration)
} }
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_config(tmp: &TempDir) -> Config {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
std::fs::create_dir_all(&config.workspace_dir).unwrap();
config
}
fn make_job(config: &Config, expr: &str, tz: Option<&str>, cmd: &str) -> CronJob {
add_shell_job(
config,
None,
Schedule::Cron {
expr: expr.into(),
tz: tz.map(Into::into),
},
cmd,
)
.unwrap()
}
fn run_update(
config: &Config,
id: &str,
expression: Option<&str>,
tz: Option<&str>,
command: Option<&str>,
name: Option<&str>,
) -> Result<()> {
handle_command(
crate::CronCommands::Update {
id: id.into(),
expression: expression.map(Into::into),
tz: tz.map(Into::into),
command: command.map(Into::into),
name: name.map(Into::into),
},
config,
)
}
#[test]
fn update_changes_command_via_handler() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo original");
run_update(&config, &job.id, None, None, Some("echo updated"), None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.command, "echo updated");
assert_eq!(updated.id, job.id);
}
#[test]
fn update_changes_expression_via_handler() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.expression, "0 9 * * *");
}
#[test]
fn update_changes_name_via_handler() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
run_update(&config, &job.id, None, None, None, Some("new-name")).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.name.as_deref(), Some("new-name"));
}
#[test]
fn update_tz_alone_sets_timezone() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
run_update(
&config,
&job.id,
None,
Some("America/Los_Angeles"),
None,
None,
)
.unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(
updated.schedule,
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: Some("America/Los_Angeles".into()),
}
);
}
#[test]
fn update_expression_preserves_existing_tz() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(
&config,
"*/5 * * * *",
Some("America/Los_Angeles"),
"echo test",
);
run_update(&config, &job.id, Some("0 9 * * *"), None, None, None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(
updated.schedule,
Schedule::Cron {
expr: "0 9 * * *".into(),
tz: Some("America/Los_Angeles".into()),
}
);
}
#[test]
fn update_preserves_unchanged_fields() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = add_shell_job(
&config,
Some("original-name".into()),
Schedule::Cron {
expr: "*/5 * * * *".into(),
tz: None,
},
"echo original",
)
.unwrap();
run_update(&config, &job.id, None, None, Some("echo changed"), None).unwrap();
let updated = get_job(&config, &job.id).unwrap();
assert_eq!(updated.command, "echo changed");
assert_eq!(updated.name.as_deref(), Some("original-name"));
assert_eq!(updated.expression, "*/5 * * * *");
}
#[test]
fn update_no_flags_fails() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let job = make_job(&config, "*/5 * * * *", None, "echo test");
let result = run_update(&config, &job.id, None, None, None, None);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("At least one of"));
}
#[test]
fn update_nonexistent_job_fails() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let result = run_update(
&config,
"nonexistent-id",
None,
None,
Some("echo test"),
None,
);
assert!(result.is_err());
}
#[test]
fn update_security_allows_safe_command() {
let tmp = TempDir::new().unwrap();
let config = test_config(&tmp);
let security = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir);
assert!(security.is_command_allowed("echo safe"));
}
}

View file

@ -186,6 +186,23 @@ pub enum CronCommands {
/// Task ID /// Task ID
id: String, id: String,
}, },
/// Update a scheduled task
Update {
/// Task ID
id: String,
/// New cron expression
#[arg(long)]
expression: Option<String>,
/// New IANA timezone
#[arg(long)]
tz: Option<String>,
/// New command to run
#[arg(long)]
command: Option<String>,
/// New job name
#[arg(long)]
name: Option<String>,
},
/// Pause a scheduled task /// Pause a scheduled task
Pause { Pause {
/// Task ID /// Task ID

View file

@ -381,6 +381,23 @@ enum CronCommands {
/// Task ID /// Task ID
id: String, id: String,
}, },
/// Update a scheduled task
Update {
/// Task ID
id: String,
/// New cron expression
#[arg(long)]
expression: Option<String>,
/// New IANA timezone
#[arg(long)]
tz: Option<String>,
/// New command to run
#[arg(long)]
command: Option<String>,
/// New job name
#[arg(long)]
name: Option<String>,
},
/// Pause a scheduled task /// Pause a scheduled task
Pause { Pause {
/// Task ID /// Task ID