diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index 8798e81a3..655e8b61d 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -580,6 +580,17 @@ enum ScheduleCommands { #[arg(long)] json: bool, }, + /// Update only the instructions for one existing schedule + Update { + /// Existing schedule ID; never creates another schedule + schedule_id: i64, + /// Replacement task instructions (preserves timing and agent identity) + #[arg(short, long)] + task: String, + /// Emit machine-readable JSON instead of the human summary + #[arg(long)] + json: bool, + }, /// List scheduled tasks List { /// Emit machine-readable JSON instead of the human summary @@ -2708,6 +2719,17 @@ async fn main() -> Result<()> { ); } } + ScheduleCommands::Update { schedule_id, task, json } => { + let schedule = session::manager::update_scheduled_task_text(&db, schedule_id, &task)?; + if json { + println!("{}", serde_json::to_string_pretty(&schedule)?); + } else { + println!( + "Updated schedule {} without changing its next run {}", + schedule.id, schedule.next_run_at.to_rfc3339() + ); + } + } ScheduleCommands::List { json } => { let schedules = session::manager::list_scheduled_tasks(&db)?; if json { @@ -9026,6 +9048,33 @@ mod tests { } } + #[test] + fn cli_parses_native_schedule_update_without_identity_mutation_options() { + let cli = Cli::try_parse_from([ + "ecc", "schedule", "update", "16", "--task", + "Read original ECC native READY items", "--json", + ]) + .expect("native schedule update must parse"); + match cli.command { + Some(Commands::Schedule { + command: ScheduleCommands::Update { schedule_id, task, json } + }) => { + assert_eq!(schedule_id, 16); + assert!(task.contains("READY items")); + assert!(json); + } + _ => panic!("expected native existing schedule update"), + } + assert!(Cli::try_parse_from([ + "ecc", "schedule", "update", "16", "--task", + "New instructions", "--cron", "* * * * *", + ]).is_err(), "schedule update must not mutate cron"); + assert!(Cli::try_parse_from([ + "ecc", "schedule", "update", "16", "--task", + "New instructions", "--agent", "codex", + ]).is_err(), "schedule update must not mutate the agent"); + } + #[test] fn cli_parses_remote_computer_use_command() { let cli = Cli::try_parse_from([ diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 3368b22f7..c454c19f9 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -245,6 +245,20 @@ pub fn create_scheduled_task( ) } +/// Replace only the instruction text on a native schedule. Keep ID, timing, +/// agent/profile, worktree and other immutable scheduling fields unchanged. +pub fn update_scheduled_task_text( + db: &StateStore, + schedule_id: i64, + task: &str, +) -> Result { + anyhow::ensure!(schedule_id > 0, "Schedule ID must be positive"); + anyhow::ensure!(!task.trim().is_empty(), "Schedule task must not be empty"); + anyhow::ensure!(task.len() <= 16 * 1024, "Schedule task exceeds 16 KiB"); + db.update_scheduled_task_text(schedule_id, task)? + .with_context(|| format!("Scheduled task not found: {schedule_id}")) +} + pub fn list_scheduled_tasks(db: &StateStore) -> Result> { db.list_scheduled_tasks() } diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index d78710c9c..c85ef7a83 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -1576,6 +1576,22 @@ impl StateStore { .ok_or_else(|| anyhow::anyhow!("Scheduled task {id} was not found after insert")) } + /// Update instructions in place; preserve the existing ID and due time. + pub fn update_scheduled_task_text( + &self, + schedule_id: i64, + task: &str, + ) -> Result> { + let modified = self.conn.execute( + "UPDATE scheduled_tasks SET task = ?2, updated_at = ?3 WHERE id = ?1", + rusqlite::params![schedule_id, task, chrono::Utc::now().to_rfc3339()], + )?; + if modified == 0 { + return Ok(None); + } + self.get_scheduled_task(schedule_id) + } + pub fn list_scheduled_tasks(&self) -> Result> { let mut stmt = self.conn.prepare( "SELECT id, cron_expr, task, agent_type, profile_name, working_dir, project, task_group, @@ -7296,6 +7312,51 @@ mod tests { Ok(()) } + #[test] + fn scheduled_task_update_keeps_original_identity_and_next_due_time() -> Result<()> { + let tempdir = TestDir::new("store-schedule-inplace-update")?; + let db = StateStore::open(&tempdir.path().join("state.db"))?; + let next = Utc::now() + ChronoDuration::minutes(2); + let original = db.insert_scheduled_task( + "*/2 * * * *", + "Old instructions", + "claude", + Some("lead-profile"), + tempdir.path(), + "pace", + "g0", + false, + next, + )?; + let previous_run = Utc::now() - ChronoDuration::minutes(2); + db.record_scheduled_task_run(original.id, previous_run, next)?; + let original = db + .get_scheduled_task(original.id)? + .context("recorded schedule must exist before update")?; + assert_eq!(original.last_run_at, Some(previous_run)); + + let updated = crate::session::manager::update_scheduled_task_text( + &db, original.id, "Read ECC native READY items", + )?; + assert_eq!(updated.id, original.id); + assert_eq!(updated.task, "Read ECC native READY items"); + assert_eq!(updated.cron_expr, original.cron_expr); + assert_eq!(updated.next_run_at, original.next_run_at); + assert_eq!(updated.last_run_at, original.last_run_at); + assert_eq!(updated.agent_type, original.agent_type); + assert_eq!(updated.profile_name, original.profile_name); + assert_eq!(updated.working_dir, original.working_dir); + assert_eq!(updated.project, original.project); + assert_eq!(updated.task_group, original.task_group); + assert_eq!(updated.use_worktree, original.use_worktree); + assert_eq!(db.list_scheduled_tasks()?.len(), 1); + assert!(crate::session::manager::update_scheduled_task_text(&db, original.id, " ").is_err()); + assert!(crate::session::manager::update_scheduled_task_text(&db, original.id, &"x".repeat(16385)).is_err()); + assert!(crate::session::manager::update_scheduled_task_text(&db, original.id + 1, "valid task").is_err()); + assert_eq!(db.list_scheduled_tasks()?.len(), 1); + Ok(()) + } + #[test] fn context_graph_detail_includes_incoming_and_outgoing_relations() -> Result<()> { let tempdir = TestDir::new("store-context-relations")?;