Skip to main content

aster_cli/commands/
schedule.rs

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    // Basic validation and helpful suggestions
10    if cron.trim().is_empty() {
11        bail!("Cron expression cannot be empty");
12    }
13
14    // Check for common mistakes and provide helpful suggestions
15    let parts: Vec<&str> = cron.split_whitespace().collect();
16
17    match parts.len() {
18        5 => {
19            // Standard 5-field cron (minute hour day month weekday)
20            println!("✅ Using standard 5-field cron format: {}", cron);
21        }
22        6 => {
23            // 6-field cron with seconds (second minute hour day month weekday)
24            println!("✅ Using 6-field cron format with seconds: {}", cron);
25        }
26        1 if cron.starts_with('@') => {
27            // Shorthand expressions like @hourly, @daily, etc.
28            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    // Provide examples for common scheduling needs
57    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, // This is expected to be a file path by the Scheduler
70) -> 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    // The Scheduler's add_scheduled_job will handle copying the recipe from recipe_source_arg
79    // to its internal storage and validating the path.
80    let job = ScheduledJob {
81        id: schedule_id.clone(),
82        source: recipe_source_arg.clone(), // Pass the original user-provided path
83        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            // The scheduler has copied the recipe to its internal directory.
100            // We can reconstruct the likely path for display if needed, or adjust success message.
101            let scheduled_recipes_dir = get_default_scheduled_recipes_dir()
102                .unwrap_or_else(|_| Path::new("./.aster_scheduled_recipes").to_path_buf()); // Fallback for display
103            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            // No local file to clean up by the CLI in this revised flow.
118            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, // This source is now the path within scheduled_recipes_dir
163                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                // sessions is now Vec<(String, SessionMetadata)>
212                for (session_name, metadata) in sessions {
213                    println!(
214                        "  - Session ID: {}, Working Dir: {}, Description: \"{}\", Schedule ID: {:?}",
215                        session_name, // Display the session_name as Session ID
216                        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}