Skip to main content

orch/cli/
job.rs

1use crate::config;
2use crate::engine::jobs::{self, Job, TaskTemplate};
3use anyhow::Context;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7/// List scheduled jobs (with runtime state from SQLite).
8pub async fn list() -> anyhow::Result<()> {
9    let path = jobs::resolve_jobs_path();
10    let jobs = jobs::load_jobs(&path)?;
11
12    if jobs.is_empty() {
13        println!("No jobs configured.");
14        println!("Add one with: orch job add \"0 9 * * *\" \"Daily review\"");
15        return Ok(());
16    }
17
18    // Load runtime state from SQLite
19    let store = crate::cli::init_store().await.ok().map(Arc::new);
20    let repo = config::get_current_repo().unwrap_or_default();
21    let states: std::collections::HashMap<String, crate::store::JobState> =
22        if let Some(ref s) = store {
23            s.list_job_states(&repo)
24                .await
25                .unwrap_or_default()
26                .into_iter()
27                .map(|st| (st.job_id.clone(), st))
28                .collect()
29        } else {
30            std::collections::HashMap::new()
31        };
32
33    println!(
34        "{:<20} {:<8} {:<20} {:<10} TITLE/COMMAND",
35        "ID", "TYPE", "SCHEDULE", "ENABLED"
36    );
37    println!("{}", "-".repeat(80));
38
39    for job in &jobs {
40        let desc = match job.r#type.as_str() {
41            "bash" => job.command.as_deref().unwrap_or(""),
42            _ => job.task.as_ref().map(|t| t.title.as_str()).unwrap_or(""),
43        };
44
45        println!(
46            "{:<20} {:<8} {:<20} {:<10} {}",
47            job.id,
48            job.r#type,
49            job.schedule,
50            if job.enabled { "yes" } else { "no" },
51            desc,
52        );
53
54        if let Some(state) = states.get(&job.id) {
55            if let Some(ref last_run) = state.last_run {
56                println!(
57                    "  Last run: {} (status: {})",
58                    last_run,
59                    state.last_task_status.as_deref().unwrap_or("unknown")
60                );
61            }
62        }
63    }
64
65    Ok(())
66}
67
68/// Add a scheduled job.
69pub fn add(
70    schedule: &str,
71    title: &str,
72    body: Option<&str>,
73    job_type: &str,
74    command: Option<&str>,
75) -> anyhow::Result<()> {
76    let path = jobs::resolve_jobs_path();
77    let mut jobs = jobs::load_jobs(&path)?;
78
79    // Generate ID from title
80    let id = title
81        .to_lowercase()
82        .chars()
83        .map(|c| if c.is_alphanumeric() { c } else { '-' })
84        .collect::<String>()
85        .trim_matches('-')
86        .to_string();
87
88    // Check for duplicate ID
89    if jobs.iter().any(|j| j.id == id) {
90        anyhow::bail!("job with id '{}' already exists", id);
91    }
92
93    let job = Job {
94        id: id.clone(),
95        r#type: job_type.to_string(),
96        schedule: schedule.to_string(),
97        task: if job_type == "task" {
98            Some(TaskTemplate {
99                title: title.to_string(),
100                body: body.unwrap_or("").to_string(),
101                prompt: None,
102                labels: vec![],
103                agent: None,
104            })
105        } else {
106            None
107        },
108        command: command.map(String::from),
109        dir: None,
110        enabled: true,
111        external: true,
112        notify: false,
113        notify_target: None,
114        prompt_file: None,
115    };
116
117    jobs.push(job);
118    jobs::save_jobs(&path, &jobs)?;
119
120    println!("Added job '{}' (schedule: {})", id, schedule);
121    Ok(())
122}
123
124/// Remove a job.
125pub fn remove(id: &str) -> anyhow::Result<()> {
126    let path = jobs::resolve_jobs_path();
127    let mut jobs = jobs::load_jobs(&path)?;
128
129    let initial_len = jobs.len();
130    jobs.retain(|j| j.id != id);
131
132    if jobs.len() == initial_len {
133        anyhow::bail!("job '{}' not found", id);
134    }
135
136    jobs::save_jobs(&path, &jobs)?;
137    println!("Removed job '{}'", id);
138    Ok(())
139}
140
141/// Enable a job.
142pub fn enable(id: &str) -> anyhow::Result<()> {
143    toggle_job(id, true)
144}
145
146/// Disable a job.
147pub fn disable(id: &str) -> anyhow::Result<()> {
148    toggle_job(id, false)
149}
150
151fn toggle_job(id: &str, enabled: bool) -> anyhow::Result<()> {
152    let path = jobs::resolve_jobs_path();
153    let mut all_jobs = jobs::load_jobs(&path)?;
154
155    let job = all_jobs
156        .iter_mut()
157        .find(|j| j.id == id)
158        .with_context(|| format!("job '{}' not found", id))?;
159
160    // If the job came from a prompt file, edit the frontmatter in-place rather
161    // than writing it into .orch.yml (which would create a duplicate on the
162    // next load and break the scheduler).
163    if let Some(ref prompt_path) = job.prompt_file.clone() {
164        jobs::set_frontmatter_enabled(prompt_path, enabled)?;
165    } else {
166        job.enabled = enabled;
167        jobs::save_jobs(&path, &all_jobs)?;
168    }
169
170    println!(
171        "Job '{}' {}",
172        id,
173        if enabled { "enabled" } else { "disabled" }
174    );
175    Ok(())
176}
177
178/// Run a single job immediately, ignoring its cron schedule.
179///
180/// Searches all projects for the job ID. If the ID exists in multiple projects,
181/// requires `--project` to disambiguate.
182pub async fn run(job_id: &str, project: Option<&str>) -> anyhow::Result<()> {
183    use crate::backends::github::GitHubBackend;
184    use crate::backends::ExternalBackend;
185
186    // 1. If inside an orch project (or --project given), try that first
187    let mut resolved: Option<(String, jobs::Job, PathBuf)> = None;
188
189    if project.is_none() {
190        // Check if CWD is inside an orch project
191        if let Ok(cwd_repo) = config::get_current_repo() {
192            let jobs_path = jobs::resolve_jobs_path();
193            if let Ok(all_jobs) = jobs::load_jobs(&jobs_path) {
194                if let Some(job) = all_jobs.into_iter().find(|j| j.id == job_id) {
195                    let base_dir = jobs_path
196                        .parent()
197                        .map(PathBuf::from)
198                        .unwrap_or_else(|| PathBuf::from("."));
199                    resolved = Some((cwd_repo, job, base_dir));
200                }
201            }
202        }
203    }
204
205    // 2. If not resolved locally, search all projects
206    if resolved.is_none() {
207        let mut matches: Vec<(String, jobs::Job, PathBuf)> = Vec::new();
208
209        let projects = crate::config::get_projects_with_paths().unwrap_or_default();
210        for (repo, dir) in &projects {
211            if let Some(filter) = project {
212                if !repo.contains(filter) {
213                    continue;
214                }
215            }
216            let orch_yml = dir.join(".orch.yml");
217            if let Ok(all_jobs) = jobs::load_jobs(&orch_yml) {
218                for job in all_jobs {
219                    if job.id == job_id {
220                        matches.push((repo.clone(), job, dir.clone()));
221                    }
222                }
223            }
224        }
225
226        match matches.len() {
227            0 => anyhow::bail!("job '{}' not found in any project", job_id),
228            1 => resolved = matches.pop(),
229            _ => {
230                let repos: Vec<&str> = matches.iter().map(|(r, _, _)| r.as_str()).collect();
231                anyhow::bail!(
232                    "job '{}' exists in multiple projects: {}\nUse --project to specify which one, e.g.: orch job run {} --project {}",
233                    job_id,
234                    repos.join(", "),
235                    job_id,
236                    repos[0],
237                );
238            }
239        }
240    }
241
242    let (repo, job, base_dir) = match resolved {
243        Some(r) => r,
244        None => anyhow::bail!("job '{}' not found", job_id),
245    };
246
247    if !job.enabled {
248        println!("Warning: job '{}' is disabled, running anyway", job_id);
249    }
250
251    let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
252    let store = crate::cli::init_store().await.ok().map(Arc::new);
253
254    // Load or create job state
255    let mut state = if let Some(ref s) = store {
256        s.get_job_state(&repo, job_id)
257            .await?
258            .unwrap_or_else(|| crate::store::JobState {
259                repo: repo.clone(),
260                job_id: job_id.to_string(),
261                last_run: None,
262                last_task_status: None,
263                active_task_id: None,
264            })
265    } else {
266        crate::store::JobState {
267            repo: repo.clone(),
268            job_id: job_id.to_string(),
269            last_run: None,
270            last_task_status: None,
271            active_task_id: None,
272        }
273    };
274
275    println!("Running job '{}' ({}) in {}", job_id, job.r#type, repo);
276
277    // Persist last_run BEFORE execution to avoid duplicate runs if the
278    // process crashes or is restarted during execution. The job runtime
279    // state is loaded from SQLite on restart, so writing last_run first
280    // prevents an immediate catch-up run.
281    state.last_run = Some(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string());
282    if let Some(ref s) = store {
283        if let Err(e) = s.upsert_job_state(&state).await {
284            tracing::error!(
285                job_id = job_id,
286                ?e,
287                "failed to persist job state before execution",
288            );
289        }
290    }
291
292    // Execute the job (may mutate state.active_task_id / last_task_status).
293    jobs::execute_job(
294        &job,
295        &mut state,
296        &backend,
297        store.as_ref(),
298        &repo,
299        None,
300        &base_dir,
301    )
302    .await;
303
304    let status = state.last_task_status.as_deref().unwrap_or("unknown");
305    if let Some(ref task_id) = state.active_task_id {
306        println!("Done (status: {}, task: {})", status, task_id);
307    } else {
308        println!("Done (status: {})", status);
309    }
310
311    Ok(())
312}
313
314/// Run one job scheduler tick.
315pub async fn tick() -> anyhow::Result<()> {
316    use crate::backends::github::GitHubBackend;
317    use crate::backends::ExternalBackend;
318
319    let repo =
320        config::get_current_repo().context("'repo' not set — ensure .orch.yml has gh.repo")?;
321    let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
322    let store = crate::cli::init_store().await.ok().map(std::sync::Arc::new);
323
324    let path = jobs::resolve_jobs_path();
325    jobs::tick(&path, &backend, store.as_ref(), &repo, None).await?;
326
327    println!("Job tick completed");
328    Ok(())
329}