1use anyhow::{bail, Context, Result};
2use aster::scheduler::{
3 get_default_scheduled_recipes_dir, get_default_scheduler_storage_path, ScheduledJob, Scheduler,
4 SchedulerError,
5};
6use std::path::Path;
7
8fn validate_cron_expression(cron: &str) -> Result<()> {
9 if cron.trim().is_empty() {
11 bail!("Cron expression cannot be empty");
12 }
13
14 let parts: Vec<&str> = cron.split_whitespace().collect();
16
17 match parts.len() {
18 5 => {
19 println!("✅ Using standard 5-field cron format: {}", cron);
21 }
22 6 => {
23 println!("✅ Using 6-field cron format with seconds: {}", cron);
25 }
26 1 if cron.starts_with('@') => {
27 let valid_shorthands = [
29 "@yearly",
30 "@annually",
31 "@monthly",
32 "@weekly",
33 "@daily",
34 "@midnight",
35 "@hourly",
36 ];
37 if valid_shorthands.contains(&cron) {
38 println!("✅ Using cron shorthand: {}", cron);
39 } else {
40 println!(
41 "⚠️ Unknown cron shorthand '{}'. Valid options: {}",
42 cron,
43 valid_shorthands.join(", ")
44 );
45 }
46 }
47 _ => {
48 println!("⚠️ Unusual cron format detected: '{}'", cron);
49 println!(" Common formats:");
50 println!(" - 5 fields: '0 * * * *' (minute hour day month weekday)");
51 println!(" - 6 fields: '0 0 * * * *' (second minute hour day month weekday)");
52 println!(" - Shorthand: '@hourly', '@daily', '@weekly', '@monthly'");
53 }
54 }
55
56 if cron == "* * * * *" {
58 println!("⚠️ This will run every minute! Did you mean:");
59 println!(" - '0 * * * *' for every hour?");
60 println!(" - '0 0 * * *' for every day?");
61 }
62
63 Ok(())
64}
65
66pub async fn handle_schedule_add(
67 schedule_id: String,
68 cron: String,
69 recipe_source_arg: String, ) -> Result<()> {
71 println!(
72 "[CLI Debug] Scheduling job ID: {}, Cron: {}, Recipe Source Path: {}",
73 schedule_id, cron, recipe_source_arg
74 );
75
76 validate_cron_expression(&cron)?;
77
78 let job = ScheduledJob {
81 id: schedule_id.clone(),
82 source: recipe_source_arg.clone(), cron,
84 last_run: None,
85 currently_running: false,
86 paused: false,
87 current_session_id: None,
88 process_start_time: None,
89 };
90
91 let scheduler_storage_path =
92 get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
93 let scheduler = Scheduler::new(scheduler_storage_path)
94 .await
95 .context("Failed to initialize scheduler")?;
96
97 match scheduler.add_scheduled_job(job, true).await {
98 Ok(_) => {
99 let scheduled_recipes_dir = get_default_scheduled_recipes_dir()
102 .unwrap_or_else(|_| Path::new("./.aster_scheduled_recipes").to_path_buf()); let extension = Path::new(&recipe_source_arg)
104 .extension()
105 .and_then(|ext| ext.to_str())
106 .unwrap_or("yaml");
107 let final_recipe_path =
108 scheduled_recipes_dir.join(format!("{}.{}", schedule_id, extension));
109
110 println!(
111 "Scheduled job '{}' added. Recipe expected at {:?}",
112 schedule_id, final_recipe_path
113 );
114 Ok(())
115 }
116 Err(e) => {
117 match e {
119 SchedulerError::JobIdExists(job_id) => {
120 bail!("Error: Job with ID '{}' already exists.", job_id);
121 }
122 SchedulerError::RecipeLoadError(msg) => {
123 bail!(
124 "Error with recipe source: {}. Path: {}",
125 msg,
126 recipe_source_arg
127 );
128 }
129 _ => Err(anyhow::Error::new(e))
130 .context(format!("Failed to add job '{}' to scheduler", schedule_id)),
131 }
132 }
133 }
134}
135
136pub async fn handle_schedule_list() -> Result<()> {
137 let scheduler_storage_path =
138 get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
139 let scheduler = Scheduler::new(scheduler_storage_path)
140 .await
141 .context("Failed to initialize scheduler")?;
142
143 let jobs = scheduler.list_scheduled_jobs().await;
144 if jobs.is_empty() {
145 println!("No scheduled jobs found.");
146 } else {
147 println!("Scheduled Jobs:");
148 for job in jobs {
149 let status = if job.currently_running {
150 "🟢 RUNNING"
151 } else if job.paused {
152 "⏸️ PAUSED"
153 } else {
154 "⏹️ IDLE"
155 };
156
157 println!(
158 "- ID: {}\n Status: {}\n Cron: {}\n Recipe Source (in store): {}\n Last Run: {}",
159 job.id,
160 status,
161 job.cron,
162 job.source, job.last_run
164 .map_or_else(|| "Never".to_string(), |dt| dt.to_rfc3339())
165 );
166 }
167 }
168 Ok(())
169}
170
171pub async fn handle_schedule_remove(schedule_id: String) -> Result<()> {
172 let scheduler_storage_path =
173 get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
174 let scheduler = Scheduler::new(scheduler_storage_path)
175 .await
176 .context("Failed to initialize scheduler")?;
177
178 match scheduler.remove_scheduled_job(&schedule_id, true).await {
179 Ok(_) => {
180 println!(
181 "Scheduled job '{}' and its associated recipe removed.",
182 schedule_id
183 );
184 Ok(())
185 }
186 Err(e) => match e {
187 SchedulerError::JobNotFound(job_id) => {
188 bail!("Error: Job with ID '{}' not found.", job_id);
189 }
190 _ => Err(anyhow::Error::new(e)).context(format!(
191 "Failed to remove job '{}' from scheduler",
192 schedule_id
193 )),
194 },
195 }
196}
197
198pub async fn handle_schedule_sessions(schedule_id: String, limit: Option<usize>) -> Result<()> {
199 let scheduler_storage_path =
200 get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
201 let scheduler = Scheduler::new(scheduler_storage_path)
202 .await
203 .context("Failed to initialize scheduler")?;
204
205 match scheduler.sessions(&schedule_id, limit.unwrap_or(50)).await {
206 Ok(sessions) => {
207 if sessions.is_empty() {
208 println!("No sessions found for schedule ID '{}'.", schedule_id);
209 } else {
210 println!("Sessions for schedule ID '{}':", schedule_id);
211 for (session_name, metadata) in sessions {
213 println!(
214 " - Session ID: {}, Working Dir: {}, Description: \"{}\", Schedule ID: {:?}",
215 session_name, metadata.working_dir.display(),
217 metadata.name,
218 metadata.schedule_id.as_deref().unwrap_or("N/A")
219 );
220 }
221 }
222 }
223 Err(e) => {
224 bail!(
225 "Failed to get sessions for schedule '{}': {:?}",
226 schedule_id,
227 e
228 );
229 }
230 }
231 Ok(())
232}
233
234pub async fn handle_schedule_run_now(schedule_id: String) -> Result<()> {
235 let scheduler_storage_path =
236 get_default_scheduler_storage_path().context("Failed to get scheduler storage path")?;
237 let scheduler = Scheduler::new(scheduler_storage_path)
238 .await
239 .context("Failed to initialize scheduler")?;
240
241 match scheduler.run_now(&schedule_id).await {
242 Ok(session_id) => {
243 println!(
244 "Successfully triggered schedule '{}'. New session ID: {}",
245 schedule_id, session_id
246 );
247 }
248 Err(e) => match e {
249 SchedulerError::JobNotFound(job_id) => {
250 bail!("Error: Job with ID '{}' not found.", job_id);
251 }
252 _ => bail!("Failed to run schedule '{}' now: {:?}", schedule_id, e),
253 },
254 }
255 Ok(())
256}
257
258pub async fn handle_schedule_services_status() -> Result<()> {
259 println!("Service management has been removed as Temporal scheduler is no longer supported.");
260 println!(
261 "The built-in scheduler runs within the aster process and requires no external services."
262 );
263 Ok(())
264}
265
266pub async fn handle_schedule_services_stop() -> Result<()> {
267 println!("Service management has been removed as Temporal scheduler is no longer supported.");
268 println!(
269 "The built-in scheduler runs within the aster process and requires no external services."
270 );
271 Ok(())
272}
273
274pub async fn handle_schedule_cron_help() -> Result<()> {
275 println!("📅 Cron Expression Guide for aster Scheduler");
276 println!("===========================================\\n");
277
278 println!("🕐 HOURLY SCHEDULES (Most Common Request):");
279 println!(" 0 * * * * - Every hour at minute 0 (e.g., 1:00, 2:00, 3:00...)");
280 println!(" 30 * * * * - Every hour at minute 30 (e.g., 1:30, 2:30, 3:30...)");
281 println!(" 0 */2 * * * - Every 2 hours at minute 0 (e.g., 2:00, 4:00, 6:00...)");
282 println!(" 0 */3 * * * - Every 3 hours at minute 0 (e.g., 3:00, 6:00, 9:00...)");
283 println!(" @hourly - Every hour (same as \"0 * * * *\")\\n");
284
285 println!("📅 DAILY SCHEDULES:");
286 println!(" 0 9 * * * - Every day at 9:00 AM");
287 println!(" 30 14 * * * - Every day at 2:30 PM");
288 println!(" 0 0 * * * - Every day at midnight");
289 println!(" @daily - Every day at midnight\\n");
290
291 println!("📆 WEEKLY SCHEDULES:");
292 println!(" 0 9 * * 1 - Every Monday at 9:00 AM");
293 println!(" 0 17 * * 5 - Every Friday at 5:00 PM");
294 println!(" 0 0 * * 0 - Every Sunday at midnight");
295 println!(" @weekly - Every Sunday at midnight\\n");
296
297 println!("🗓️ MONTHLY SCHEDULES:");
298 println!(" 0 9 1 * * - First day of every month at 9:00 AM");
299 println!(" 0 0 15 * * - 15th of every month at midnight");
300 println!(" @monthly - First day of every month at midnight\\n");
301
302 println!("📝 CRON FORMAT:");
303 println!(" Standard 5-field: minute hour day month weekday");
304 println!(" ┌───────────── minute (0 - 59)");
305 println!(" │ ┌─────────── hour (0 - 23)");
306 println!(" │ │ ┌───────── day of month (1 - 31)");
307 println!(" │ │ │ ┌─────── month (1 - 12)");
308 println!(" │ │ │ │ ┌───── day of week (0 - 7, Sunday = 0 or 7)");
309 println!(" │ │ │ │ │");
310 println!(" * * * * *\\n");
311
312 println!("🔧 SPECIAL CHARACTERS:");
313 println!(" * - Any value (every minute, hour, day, etc.)");
314 println!(" */n - Every nth interval (*/5 = every 5 minutes)");
315 println!(" n-m - Range (1-5 = 1,2,3,4,5)");
316 println!(" n,m - List (1,3,5 = 1 or 3 or 5)\\n");
317
318 println!("⚡ SHORTHAND EXPRESSIONS:");
319 println!(" @yearly - Once a year (0 0 1 1 *)");
320 println!(" @monthly - Once a month (0 0 1 * *)");
321 println!(" @weekly - Once a week (0 0 * * 0)");
322 println!(" @daily - Once a day (0 0 * * *)");
323 println!(" @hourly - Once an hour (0 * * * *)\\n");
324
325 println!("💡 EXAMPLES:");
326 println!(
327 " aster schedule add --schedule-id hourly-report --cron \"0 * * * *\" --recipe-source report.yaml"
328 );
329 println!(
330 " aster schedule add --schedule-id daily-backup --cron \"@daily\" --recipe-source backup.yaml"
331 );
332 println!(" aster schedule add --schedule-id weekly-summary --cron \"0 9 * * 1\" --recipe-source summary.yaml");
333
334 Ok(())
335}