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_or("", |t| t.title.as_str()),
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            && let Some(ref last_run) = state.last_run
56        {
57            println!(
58                "  Last run: {} (status: {})",
59                last_run,
60                state.last_task_status.as_deref().unwrap_or("unknown")
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 '{id}' already exists");
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 '{id}' (schedule: {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 '{id}' not found");
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 '{id}' not found"))?;
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::ExternalBackend;
184    use crate::backends::github::GitHubBackend;
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                && let Some(job) = all_jobs.into_iter().find(|j| j.id == job_id)
195            {
196                let base_dir = jobs_path
197                    .parent()
198                    .map_or_else(|| PathBuf::from("."), PathBuf::from);
199                resolved = Some((cwd_repo, job, base_dir));
200            }
201        }
202    }
203
204    // 2. If not resolved locally, search all projects
205    if resolved.is_none() {
206        let mut matches: Vec<(String, jobs::Job, PathBuf)> = Vec::new();
207
208        let projects = crate::config::get_projects_with_paths().unwrap_or_default();
209        for (repo, dir) in &projects {
210            if let Some(filter) = project
211                && !repo.contains(filter)
212            {
213                continue;
214            }
215            let orch_yml = dir.join(".orch.yml");
216            if let Ok(all_jobs) = jobs::load_jobs(&orch_yml) {
217                for job in all_jobs {
218                    if job.id == job_id {
219                        matches.push((repo.clone(), job, dir.clone()));
220                    }
221                }
222            }
223        }
224
225        match matches.len() {
226            0 => anyhow::bail!("job '{job_id}' not found in any project"),
227            1 => resolved = matches.pop(),
228            _ => {
229                let repos: Vec<&str> = matches.iter().map(|(r, _, _)| r.as_str()).collect();
230                anyhow::bail!(
231                    "job '{}' exists in multiple projects: {}\nUse --project to specify which one, e.g.: orch job run {} --project {}",
232                    job_id,
233                    repos.join(", "),
234                    job_id,
235                    repos[0],
236                );
237            }
238        }
239    }
240
241    let Some((repo, job, base_dir)) = resolved else {
242        anyhow::bail!("job '{job_id}' not found")
243    };
244
245    if !job.enabled {
246        println!("Warning: job '{job_id}' is disabled, running anyway");
247    }
248
249    let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
250    let store = crate::cli::init_store().await.ok().map(Arc::new);
251
252    // Load or create job state
253    let mut state = if let Some(ref s) = store {
254        s.get_job_state(&repo, job_id)
255            .await?
256            .unwrap_or_else(|| crate::store::JobState {
257                repo: repo.clone(),
258                job_id: job_id.to_string(),
259                last_run: None,
260                last_task_status: None,
261                active_task_id: None,
262            })
263    } else {
264        crate::store::JobState {
265            repo: repo.clone(),
266            job_id: job_id.to_string(),
267            last_run: None,
268            last_task_status: None,
269            active_task_id: None,
270        }
271    };
272
273    println!("Running job '{}' ({}) in {}", job_id, job.r#type, repo);
274
275    // Persist last_run BEFORE execution to avoid duplicate runs if the
276    // process crashes or is restarted during execution. The job runtime
277    // state is loaded from SQLite on restart, so writing last_run first
278    // prevents an immediate catch-up run.
279    state.last_run = Some(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string());
280    if let Some(ref s) = store
281        && let Err(e) = s.upsert_job_state(&state).await
282    {
283        tracing::error!(
284            job_id = job_id,
285            ?e,
286            "failed to persist job state before execution",
287        );
288    }
289
290    // Execute the job (may mutate state.active_task_id / last_task_status).
291    jobs::execute_job(
292        &job,
293        &mut state,
294        &backend,
295        store.as_ref(),
296        &repo,
297        None,
298        &base_dir,
299    )
300    .await;
301
302    let status = state.last_task_status.as_deref().unwrap_or("unknown");
303    if let Some(ref task_id) = state.active_task_id {
304        println!("Done (status: {status}, task: {task_id})");
305    } else {
306        println!("Done (status: {status})");
307    }
308
309    Ok(())
310}
311
312/// Run one job scheduler tick.
313pub async fn tick() -> anyhow::Result<()> {
314    use crate::backends::ExternalBackend;
315    use crate::backends::github::GitHubBackend;
316
317    let repo =
318        config::get_current_repo().context("'repo' not set — ensure .orch.yml has gh.repo")?;
319    let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
320    let store = crate::cli::init_store().await.ok().map(std::sync::Arc::new);
321
322    let path = jobs::resolve_jobs_path();
323    jobs::tick(&path, &backend, store.as_ref(), &repo, None).await?;
324
325    println!("Job tick completed");
326    Ok(())
327}