1use crate::config;
2use crate::engine::jobs::{self, Job, TaskTemplate};
3use anyhow::Context;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7pub 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 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
68pub 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 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 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
124pub 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
141pub fn enable(id: &str) -> anyhow::Result<()> {
143 toggle_job(id, true)
144}
145
146pub 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 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
178pub async fn run(job_id: &str, project: Option<&str>) -> anyhow::Result<()> {
183 use crate::backends::ExternalBackend;
184 use crate::backends::github::GitHubBackend;
185
186 let mut resolved: Option<(String, jobs::Job, PathBuf)> = None;
188
189 if project.is_none() {
190 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 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 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 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 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
312pub 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}