Skip to main content

orch/cli/
job.rs

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