1use crate::backends::{ExternalBackend, ExternalId};
17use crate::channels::notification::TaskNotification;
18use crate::channels::transport::Transport;
19use crate::cmd::CommandErrorContext;
20use crate::config;
21use crate::cron as crate_cron;
22use crate::home;
23use crate::store::TaskStore;
24use crate::store::{JobState, TaskStatus};
25use anyhow::Context;
26use cron::Schedule;
27use serde::{Deserialize, Serialize};
28use std::collections::HashSet;
29use std::fs;
30use std::path::{Path, PathBuf};
31use std::result;
32use std::str::FromStr;
33use std::sync::Arc;
34use std::time::Duration;
35use tokio::process::Command;
36use tokio::task;
37use tokio::time;
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Job {
46 pub id: String,
47 #[serde(default = "default_job_type")]
48 pub r#type: String,
49 pub schedule: String,
50 #[serde(default)]
51 pub task: Option<TaskTemplate>,
52 #[serde(default)]
53 pub command: Option<String>,
54 #[serde(default)]
55 pub dir: Option<String>,
56 #[serde(default = "default_enabled")]
57 pub enabled: bool,
58 #[serde(default = "default_external")]
59 pub external: bool, #[serde(default)]
63 pub notify: bool,
64 #[serde(default)]
67 pub notify_target: Option<String>,
68 #[serde(skip)]
74 pub prompt_file: Option<PathBuf>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct TaskTemplate {
80 pub title: String,
81 #[serde(default)]
82 pub body: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub prompt: Option<String>,
88 #[serde(default)]
89 pub labels: Vec<String>,
90 #[serde(default)]
91 pub agent: Option<String>,
92}
93
94pub fn resolve_template_body(template: &TaskTemplate, base_dir: &Path) -> anyhow::Result<String> {
99 match template.prompt.as_deref() {
100 Some(rel) => {
101 let path = Path::new(rel);
102 let full = if path.is_absolute() {
103 path.to_path_buf()
104 } else {
105 base_dir.join(path)
106 };
107 fs::read_to_string(&full)
108 .with_context(|| format!("reading prompt file {}", full.display()))
109 }
110 None => Ok(template.body.clone()),
111 }
112}
113
114fn default_job_type() -> String {
115 "task".to_string()
116}
117
118fn default_enabled() -> bool {
119 true
120}
121
122fn default_external() -> bool {
123 false }
125
126#[derive(Debug, Serialize, Deserialize)]
128struct ConfigFile {
129 #[serde(default)]
130 jobs: Vec<Job>,
131 #[serde(flatten)]
133 other: serde_norway::Mapping,
134}
135
136pub fn resolve_jobs_path() -> PathBuf {
143 let project = PathBuf::from(".orch.yml");
145 if project.exists() {
146 return project;
147 }
148
149 if let Ok(paths) = config::get_project_paths() {
151 for path_str in &paths {
152 let candidate = PathBuf::from(path_str).join(".orch.yml");
153 if candidate.exists() {
154 return candidate;
155 }
156 }
157 }
158
159 home::config_path().unwrap_or_else(|_| PathBuf::from(".orch/config.yml"))
161}
162
163pub fn load_jobs(path: &PathBuf) -> anyhow::Result<Vec<Job>> {
169 let base_dir = path
170 .parent()
171 .map_or_else(|| PathBuf::from("."), Path::to_path_buf);
172 let mut jobs: Vec<Job> = if path.exists() {
173 let content =
174 fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
175 let file: ConfigFile = serde_norway::from_str(&content)
176 .with_context(|| format!("parsing {}", path.display()))?;
177 file.jobs
178 } else {
179 vec![]
180 };
181
182 let scanned = scan_prompt_jobs(&base_dir)?;
183 let mut seen: HashSet<String> = jobs.iter().map(|j| j.id.clone()).collect();
184 for job in scanned {
185 if !seen.insert(job.id.clone()) {
186 anyhow::bail!(
187 "duplicate job id '{}' — defined in both '{}' and prompts/jobs/",
188 job.id,
189 path.display()
190 );
191 }
192 jobs.push(job);
193 }
194
195 for job in &jobs {
196 validate_job(job, &base_dir)?;
197 }
198 Ok(jobs)
199}
200
201fn validate_job(job: &Job, base_dir: &Path) -> anyhow::Result<()> {
202 let expanded = crate_cron::expand_alias(&job.schedule)
203 .with_context(|| format!("job '{}': invalid cron schedule '{}'", job.id, job.schedule))?;
204 let normalized = crate_cron::normalize_dow(&expanded);
205 let full_expr = format!("0 {normalized} *");
206 Schedule::from_str(&full_expr)
207 .with_context(|| format!("job '{}': invalid cron schedule '{}'", job.id, job.schedule))?;
208 if let Some(template) = job.task.as_ref() {
209 if template.prompt.is_some() && !template.body.is_empty() {
210 anyhow::bail!(
211 "job '{}': set either 'task.body' or 'task.prompt', not both",
212 job.id
213 );
214 }
215 if let Some(rel) = template.prompt.as_deref() {
216 let candidate = Path::new(rel);
217 let full = if candidate.is_absolute() {
218 candidate.to_path_buf()
219 } else {
220 base_dir.join(candidate)
221 };
222 if !full.exists() {
223 anyhow::bail!(
224 "job '{}': prompt file not found: {}",
225 job.id,
226 full.display()
227 );
228 }
229 }
230 }
231 Ok(())
232}
233
234#[derive(Debug, Deserialize)]
236struct JobFrontmatter {
237 id: String,
238 schedule: String,
239 title: String,
240 #[serde(default = "default_job_type")]
241 r#type: String,
242 #[serde(default = "default_enabled")]
243 enabled: bool,
244 #[serde(default = "default_external")]
245 external: bool,
246 #[serde(default)]
247 labels: Vec<String>,
248 #[serde(default)]
249 agent: Option<String>,
250 #[serde(default)]
251 notify: bool,
252 #[serde(default)]
253 notify_target: Option<String>,
254 #[serde(default)]
255 command: Option<String>,
256 #[serde(default)]
257 dir: Option<String>,
258}
259
260fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
263 let rest = content
264 .strip_prefix("---\n")
265 .or_else(|| content.strip_prefix("---\r\n"))?;
266 let end = rest.find("\n---\n").or_else(|| rest.find("\n---\r\n"))?;
267 let fm = &rest[..end];
268 let after = &rest[end..];
269 let body = after
270 .strip_prefix("\n---\n")
271 .or_else(|| after.strip_prefix("\n---\r\n"))
272 .unwrap_or(after);
273 Some((fm, body.trim_start_matches('\n')))
274}
275
276pub fn scan_prompt_jobs(base_dir: &Path) -> anyhow::Result<Vec<Job>> {
278 let dir = base_dir.join("prompts").join("jobs");
279 if !dir.is_dir() {
280 return Ok(vec![]);
281 }
282 let mut entries: Vec<PathBuf> = fs::read_dir(&dir)
283 .with_context(|| format!("reading {}", dir.display()))?
284 .filter_map(result::Result::ok)
285 .map(|e| e.path())
286 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
287 .collect();
288 entries.sort();
289
290 let mut jobs = Vec::with_capacity(entries.len());
291 for path in entries {
292 let content =
293 fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
294 let Some((fm, body)) = split_frontmatter(&content) else {
295 continue; };
297 let meta: JobFrontmatter = serde_norway::from_str(fm)
298 .with_context(|| format!("parsing frontmatter in {}", path.display()))?;
299 jobs.push(Job {
300 id: meta.id,
301 r#type: meta.r#type,
302 schedule: meta.schedule,
303 task: Some(TaskTemplate {
304 title: meta.title,
305 body: body.to_string(),
306 prompt: None,
307 labels: meta.labels,
308 agent: meta.agent,
309 }),
310 command: meta.command,
311 dir: meta.dir,
312 enabled: meta.enabled,
313 external: meta.external,
314 notify: meta.notify,
315 notify_target: meta.notify_target,
316 prompt_file: Some(path.clone()),
317 });
318 }
319 Ok(jobs)
320}
321
322pub fn save_jobs(path: &PathBuf, jobs: &[Job]) -> anyhow::Result<()> {
324 let mut file: ConfigFile = if path.exists() {
326 let content =
327 fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
328 serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?
329 } else {
330 ConfigFile {
331 jobs: vec![],
332 other: serde_norway::Mapping::new(),
333 }
334 };
335
336 file.jobs = jobs
341 .iter()
342 .filter(|j| j.prompt_file.is_none())
343 .cloned()
344 .collect();
345 let content = serde_norway::to_string(&file)?;
346 fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
347 Ok(())
348}
349
350pub fn set_frontmatter_enabled(path: &Path, enabled: bool) -> anyhow::Result<()> {
356 let original =
357 fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
358
359 let value = if enabled { "true" } else { "false" };
360
361 let rewritten = rewrite_frontmatter_enabled(&original, value);
365 fs::write(path, rewritten).with_context(|| format!("writing {}", path.display()))?;
366 Ok(())
367}
368
369fn rewrite_frontmatter_enabled(content: &str, value: &str) -> String {
374 let Some(rest) = content
376 .strip_prefix("---\n")
377 .or_else(|| content.strip_prefix("---\r\n"))
378 else {
379 return content.to_string();
381 };
382
383 let Some(close_pos) = rest.find("\n---\n").or_else(|| rest.find("\n---\r\n")) else {
385 return content.to_string();
386 };
387
388 let fm = &rest[..close_pos];
389 let after_close = &rest[close_pos..]; let new_line = format!("enabled: {value}");
393 let mut found = false;
394 let new_fm: String = fm
395 .lines()
396 .map(|line| {
397 if line == "enabled: true" || line == "enabled: false" || line.starts_with("enabled: ")
398 {
399 found = true;
400 new_line.clone()
401 } else {
402 line.to_string()
403 }
404 })
405 .collect::<Vec<_>>()
406 .join("\n");
407
408 let new_fm = if found {
409 new_fm
410 } else {
411 format!("{new_fm}\n{new_line}")
412 };
413
414 format!("---\n{new_fm}{after_close}")
415}
416
417fn is_not_found_error(e: &anyhow::Error) -> bool {
423 e.chain().any(|cause| cause.to_string().contains("404"))
427}
428
429pub async fn tick(
434 jobs_path: &Path,
435 backend: &Arc<dyn ExternalBackend>,
436 store: Option<&Arc<TaskStore>>,
437 repo: &str,
438 transport: Option<&Arc<Transport>>,
439) -> anyhow::Result<()> {
440 let jobs_path_clone = jobs_path.to_path_buf();
441 let jobs = match task::spawn_blocking(move || load_jobs(&jobs_path_clone))
442 .await
443 .map_err(|e| anyhow::anyhow!("load_jobs panicked: {e}"))
444 .and_then(|r| r)
445 {
446 Ok(j) => j,
447 Err(e) => {
448 tracing::error!(
449 path = %jobs_path.display(),
450 repo = %repo,
451 ?e,
452 "job config invalid — skipping scheduler tick for this project"
453 );
454 return Ok(());
455 }
456 };
457 let now = chrono::Utc::now();
458
459 for job in &jobs {
460 if !job.enabled {
461 continue;
462 }
463
464 let mut state = if let Some(s) = store {
466 match s.get_job_state(repo, &job.id).await {
467 Ok(Some(st)) => st,
468 Ok(None) => JobState::new(repo, &job.id),
469 Err(e) => {
470 tracing::error!(job_id = job.id, ?e, "failed to load job state, skipping");
471 continue;
472 }
473 }
474 } else {
475 JobState::new(repo, &job.id)
476 };
477
478 let is_due = match &state.last_run {
480 Some(last) => match crate_cron::check(&job.schedule, Some(last)) {
481 Ok(v) => v,
482 Err(e) => {
483 tracing::error!(
484 job_id = job.id,
485 schedule = job.schedule,
486 ?e,
487 "invalid cron expression, skipping"
488 );
489 continue;
490 }
491 },
492 None => match crate_cron::check(&job.schedule, None) {
493 Ok(v) => v,
494 Err(e) => {
495 tracing::error!(
496 job_id = job.id,
497 schedule = job.schedule,
498 ?e,
499 "invalid cron expression, skipping"
500 );
501 continue;
502 }
503 },
504 };
505
506 if !is_due {
507 continue;
508 }
509
510 let mut should_clear_task_id = false;
512 let mut should_skip = false;
513
514 if let Some(ref task_id) = state.active_task_id
515 && task_id.is_empty()
516 {
517 state.active_task_id = None;
518 }
519
520 if let Some(ref task_id) = state.active_task_id {
521 let task_id_clone = task_id.clone();
522 let is_active = if job.external {
523 match backend.get_task(&ExternalId(task_id_clone.clone())).await {
525 Ok(task) => {
526 let status = task.labels.iter().find(|l| l.starts_with("status:"));
527 match status.map(String::as_str) {
528 Some(
529 "status:in_progress"
530 | "status:routed"
531 | "status:new"
532 | "status:needs_review"
533 | "status:in_review"
534 | "status:approved"
535 | "status:waiting",
536 )
537 | None => true, Some(_) => false, }
540 }
541 Err(e) => {
542 if is_not_found_error(&e) {
543 tracing::warn!(
544 job_id = job.id,
545 task_id = task_id_clone,
546 "active task not found (404), clearing active_task_id"
547 );
548 should_clear_task_id = true;
549 state.last_task_status = Some("error".to_string());
550 } else {
551 tracing::warn!(
552 job_id = job.id,
553 task_id = task_id_clone,
554 ?e,
555 "transient error fetching active task, skipping tick (active_task_id preserved)"
556 );
557 should_skip = true;
558 }
559 false
560 }
561 }
562 } else {
563 if let Some(s) = store {
565 if let Ok(Some(store_id)) = s.resolve_task_id(repo, &task_id_clone).await {
566 match s.get(store_id).await {
567 Ok(task) => match task.status {
568 TaskStatus::New
569 | TaskStatus::Routed
570 | TaskStatus::InProgress
571 | TaskStatus::NeedsReview
572 | TaskStatus::InReview
573 | TaskStatus::Approved
574 | TaskStatus::Waiting => true,
575 _ => false, },
577 Err(e) => {
578 tracing::warn!(
579 job_id = job.id,
580 task_id = task_id_clone,
581 ?e,
582 "cannot fetch internal task from store, clearing active_task_id"
583 );
584 should_clear_task_id = true;
585 false
586 }
587 }
588 } else {
589 tracing::warn!(
590 job_id = job.id,
591 task_id = task_id_clone,
592 "internal task not found in store, clearing active_task_id"
593 );
594 should_clear_task_id = true;
595 false
596 }
597 } else {
598 should_clear_task_id = true;
599 false
600 }
601 };
602
603 if is_active {
604 tracing::debug!(
605 job_id = job.id,
606 task_id = task_id_clone,
607 external = job.external,
608 "skipping: previous task still active"
609 );
610 should_skip = true;
611 }
612 }
613
614 if should_clear_task_id {
616 state.active_task_id = None;
617 }
618
619 if should_skip {
620 continue;
621 }
622
623 tracing::info!(job_id = job.id, r#type = job.r#type, "job due, executing");
624
625 state.last_run = Some(now.format("%Y-%m-%dT%H:%M:%SZ").to_string());
630 if let Some(s) = store
631 && let Err(e) = s.upsert_job_state(&state).await
632 {
633 tracing::error!(
634 job_id = job.id,
635 ?e,
636 "failed to persist job state before execution — skipping this tick to preserve restart safety"
637 );
638 continue; }
640
641 let base_dir = jobs_path.parent().unwrap_or_else(|| Path::new("."));
643 execute_job(job, &mut state, backend, store, repo, transport, base_dir).await;
644
645 if let Some(s) = store
647 && let Err(e) = s.upsert_job_state(&state).await
648 {
649 tracing::error!(
650 job_id = job.id,
651 ?e,
652 "failed to persist job state after execution"
653 );
654 }
655 }
656
657 Ok(())
658}
659
660pub async fn execute_job(
665 job: &Job,
666 state: &mut JobState,
667 backend: &Arc<dyn ExternalBackend>,
668 store: Option<&Arc<TaskStore>>,
669 repo: &str,
670 transport: Option<&Arc<Transport>>,
671 base_dir: &Path,
672) {
673 match job.r#type.as_str() {
674 "task" => {
675 if let Some(ref template) = job.task {
676 let body = match resolve_template_body(template, base_dir) {
677 Ok(b) => b,
678 Err(e) => {
679 tracing::error!(job_id = job.id, ?e, "failed to resolve task body");
680 state.last_task_status = Some("failed".to_string());
681 return;
682 }
683 };
684
685 if job.external {
686 let mut labels = template.labels.clone();
687 labels.push("scheduled".to_string());
688 labels.push(format!("job:{}", job.id));
689
690 if let Some(ref agent) = template.agent
691 && !agent.is_empty()
692 {
693 labels.push(format!("agent:{agent}"));
694 }
695
696 match backend.create_task(&template.title, &body, &labels).await {
697 Ok(ext_id) => {
698 tracing::info!(
699 job_id = job.id,
700 task_id = ext_id.0,
701 "created external task"
702 );
703 state.active_task_id = Some(ext_id.0);
704 state.last_task_status = Some("new".to_string());
705 }
706 Err(e) => {
707 tracing::error!(job_id = job.id, ?e, "failed to create external task");
708 state.last_task_status = Some("failed".to_string());
709 }
710 }
711 } else if let Some(s) = store {
712 match s
713 .create_internal(repo, &template.title, &body, "cron", &job.id, None)
714 .await
715 {
716 Ok(internal_id) => {
717 let task_id = format!("internal:{internal_id}");
718 tracing::info!(job_id = job.id, task_id, "created internal task");
719 state.active_task_id = Some(task_id);
720 state.last_task_status = Some("new".to_string());
721 }
722 Err(e) => {
723 tracing::error!(job_id = job.id, ?e, "failed to create internal task");
724 state.last_task_status = Some("failed".to_string());
725 }
726 }
727 } else {
728 tracing::error!(
729 job_id = job.id,
730 "no store available for internal task creation"
731 );
732 state.last_task_status = Some("failed".to_string());
733 }
734 }
735 }
736 "bash" => {
737 if let Some(ref cmd) = job.command {
738 let dir = job.dir.as_deref().unwrap_or(".");
739 tracing::info!(job_id = job.id, cmd, dir, "running bash command");
740
741 let timeout_secs: u64 = config::get("jobs.bash_timeout_seconds")
745 .ok()
746 .and_then(|s| s.parse().ok())
747 .unwrap_or(300);
748 let timeout = Duration::from_secs(timeout_secs);
749
750 let output = time::timeout(
751 timeout,
752 Command::new("bash")
753 .arg("-c")
754 .arg(cmd)
755 .current_dir(dir)
756 .output_with_context(),
757 )
758 .await;
759
760 match output {
761 Ok(Ok(o)) if o.status.success() => {
762 state.last_task_status = Some("done".to_string());
763 }
764 Ok(Ok(o)) => {
765 let stderr = String::from_utf8_lossy(&o.stderr);
766 tracing::warn!(
767 job_id = job.id,
768 code = o.status.code(),
769 %stderr,
770 "bash command failed"
771 );
772 state.last_task_status = Some("failed".to_string());
773 }
774 Ok(Err(e)) => {
775 tracing::error!(job_id = job.id, ?e, "bash command error");
776 state.last_task_status = Some("failed".to_string());
777 }
778 Err(_) => {
779 tracing::error!(
780 job_id = job.id,
781 secs = timeout_secs,
782 "bash job timed out after {}s — killing to unblock tick loop",
783 timeout_secs
784 );
785 state.last_task_status = Some("timeout".to_string());
786 }
787 }
788 }
789 }
790 other => {
796 tracing::warn!(job_id = job.id, r#type = other, "unknown job type");
797 }
798 }
799
800 if job.notify
802 && let Some(t) = transport
803 {
804 let status = state
805 .last_task_status
806 .as_deref()
807 .unwrap_or("unknown")
808 .to_string();
809 let summary = match status.as_str() {
810 "done" => format!("Job `{}` completed successfully.", job.id),
811 "failed" => format!("Job `{}` failed.", job.id),
812 "new" => format!("Job `{}` created a new task.", job.id),
813 other => format!("Job `{}` finished with status: {other}.", job.id),
814 };
815 let target = job
817 .notify_target
818 .clone()
819 .or_else(|| config::get("channels.telegram.chat_id").ok());
820 let notification = TaskNotification {
821 task_id: job.id.clone(),
822 title: format!("Job: {}", job.id),
823 status,
824 agent: "scheduler".to_string(),
825 duration_seconds: 0.0,
826 summary,
827 repo: Some(repo.to_string()),
828 notify_target: target,
829 pr_number: None,
830 };
831 t.push_notification(notification);
832 }
833}
834
835#[cfg(test)]
840mod tests {
841 use super::*;
842 use std::fs;
843 use tempfile::tempdir;
844
845 fn write_config(dir: &Path, content: &str) -> PathBuf {
846 let path = dir.join(".orch.yml");
847 fs::write(&path, content).unwrap();
848 path
849 }
850
851 #[test]
852 fn load_jobs_reads_prompt_from_file() {
853 let tmp = tempdir().unwrap();
854 fs::create_dir(tmp.path().join("prompts")).unwrap();
855 fs::write(tmp.path().join("prompts/hello.md"), "hello from file").unwrap();
856 let cfg = write_config(
857 tmp.path(),
858 r#"
859jobs:
860 - id: with-prompt
861 schedule: "0 9 * * *"
862 task:
863 title: "Daily"
864 prompt: prompts/hello.md
865"#,
866 );
867
868 let jobs = load_jobs(&cfg).unwrap();
869 assert_eq!(jobs.len(), 1);
870 let body = resolve_template_body(jobs[0].task.as_ref().unwrap(), tmp.path()).unwrap();
871 assert_eq!(body, "hello from file");
872 }
873
874 #[test]
875 fn load_jobs_rejects_both_body_and_prompt() {
876 let tmp = tempdir().unwrap();
877 fs::write(tmp.path().join("p.md"), "x").unwrap();
878 let cfg = write_config(
879 tmp.path(),
880 r#"
881jobs:
882 - id: conflict
883 schedule: "0 9 * * *"
884 task:
885 title: "x"
886 body: "inline"
887 prompt: p.md
888"#,
889 );
890
891 let err = load_jobs(&cfg).unwrap_err().to_string();
892 assert!(err.contains("not both"), "unexpected error: {err}");
893 }
894
895 #[test]
896 fn load_jobs_rejects_missing_prompt_file() {
897 let tmp = tempdir().unwrap();
898 let cfg = write_config(
899 tmp.path(),
900 r#"
901jobs:
902 - id: missing
903 schedule: "0 9 * * *"
904 task:
905 title: "x"
906 prompt: does-not-exist.md
907"#,
908 );
909
910 let err = load_jobs(&cfg).unwrap_err().to_string();
911 assert!(
912 err.contains("prompt file not found"),
913 "unexpected error: {err}"
914 );
915 }
916
917 #[test]
918 fn resolve_template_body_falls_back_to_inline_body() {
919 let tmp = tempdir().unwrap();
920 let template = TaskTemplate {
921 title: "t".to_string(),
922 body: "inline body".to_string(),
923 prompt: None,
924 labels: vec![],
925 agent: None,
926 };
927 assert_eq!(
928 resolve_template_body(&template, tmp.path()).unwrap(),
929 "inline body"
930 );
931 }
932
933 fn write_prompt_job(dir: &Path, name: &str, content: &str) {
934 let jobs_dir = dir.join("prompts").join("jobs");
935 fs::create_dir_all(&jobs_dir).unwrap();
936 fs::write(jobs_dir.join(name), content).unwrap();
937 }
938
939 #[test]
940 fn scan_prompt_jobs_picks_up_frontmatter_files() {
941 let tmp = tempdir().unwrap();
942 write_prompt_job(
943 tmp.path(),
944 "morning.md",
945 "---\nid: morning\nschedule: '0 9 * * *'\ntitle: Morning\nlabels: [daily]\nagent: claude\n---\n\nDo morning stuff.\n",
946 );
947 let jobs = scan_prompt_jobs(tmp.path()).unwrap();
948 assert_eq!(jobs.len(), 1);
949 assert_eq!(jobs[0].id, "morning");
950 assert_eq!(jobs[0].schedule, "0 9 * * *");
951 let task = jobs[0].task.as_ref().unwrap();
952 assert_eq!(task.title, "Morning");
953 assert_eq!(task.body, "Do morning stuff.\n");
954 assert_eq!(task.labels, vec!["daily".to_string()]);
955 assert_eq!(task.agent.as_deref(), Some("claude"));
956 }
957
958 #[test]
959 fn load_jobs_merges_inline_and_scanned() {
960 let tmp = tempdir().unwrap();
961 write_prompt_job(
962 tmp.path(),
963 "scanned.md",
964 "---\nid: scanned\nschedule: '0 9 * * *'\ntitle: Scanned\n---\n\nbody\n",
965 );
966 let cfg = write_config(
967 tmp.path(),
968 r#"
969jobs:
970 - id: inline
971 schedule: "0 10 * * *"
972 task:
973 title: "Inline"
974 body: "x"
975"#,
976 );
977 let jobs = load_jobs(&cfg).unwrap();
978 let ids: Vec<&str> = jobs.iter().map(|j| j.id.as_str()).collect();
979 assert!(ids.contains(&"inline"));
980 assert!(ids.contains(&"scanned"));
981 assert_eq!(jobs.len(), 2);
982 }
983
984 #[test]
985 fn load_jobs_rejects_duplicate_id_across_sources() {
986 let tmp = tempdir().unwrap();
987 write_prompt_job(
988 tmp.path(),
989 "dup.md",
990 "---\nid: dup\nschedule: '0 9 * * *'\ntitle: Dup\n---\n\nbody\n",
991 );
992 let cfg = write_config(
993 tmp.path(),
994 r#"
995jobs:
996 - id: dup
997 schedule: "0 10 * * *"
998 task:
999 title: "Dup inline"
1000 body: "x"
1001"#,
1002 );
1003 let err = load_jobs(&cfg).unwrap_err().to_string();
1004 assert!(err.contains("duplicate job id"), "unexpected error: {err}");
1005 }
1006
1007 #[test]
1008 fn scan_prompt_jobs_ignores_files_without_frontmatter() {
1009 let tmp = tempdir().unwrap();
1010 write_prompt_job(
1011 tmp.path(),
1012 "plain.md",
1013 "just a prompt body, no frontmatter\n",
1014 );
1015 let jobs = scan_prompt_jobs(tmp.path()).unwrap();
1016 assert_eq!(jobs.len(), 0);
1017 }
1018
1019 #[test]
1020 fn load_jobs_works_without_config_file_when_directory_present() {
1021 let tmp = tempdir().unwrap();
1022 write_prompt_job(
1023 tmp.path(),
1024 "solo.md",
1025 "---\nid: solo\nschedule: '0 9 * * *'\ntitle: Solo\n---\n\nbody\n",
1026 );
1027 let missing_cfg = tmp.path().join(".orch.yml");
1028 let jobs = load_jobs(&missing_cfg).unwrap();
1029 assert_eq!(jobs.len(), 1);
1030 assert_eq!(jobs[0].id, "solo");
1031 }
1032
1033 #[test]
1036 fn scan_prompt_jobs_respects_enabled_false() {
1037 let tmp = tempdir().unwrap();
1038 write_prompt_job(
1039 tmp.path(),
1040 "disabled.md",
1041 "---\nid: disabled\nschedule: '0 9 * * *'\ntitle: Disabled\nenabled: false\n---\n\nbody\n",
1042 );
1043 let jobs = scan_prompt_jobs(tmp.path()).unwrap();
1044 assert_eq!(jobs.len(), 1);
1045 assert!(!jobs[0].enabled, "job should be disabled via frontmatter");
1046 }
1047
1048 #[test]
1049 fn scan_prompt_jobs_sets_prompt_file() {
1050 let tmp = tempdir().unwrap();
1051 write_prompt_job(
1052 tmp.path(),
1053 "morning.md",
1054 "---\nid: morning\nschedule: '0 9 * * *'\ntitle: Morning\n---\n\nbody\n",
1055 );
1056 let jobs = scan_prompt_jobs(tmp.path()).unwrap();
1057 assert_eq!(jobs.len(), 1);
1058 assert!(
1059 jobs[0].prompt_file.is_some(),
1060 "prompt_file should be set for frontmatter jobs"
1061 );
1062 }
1063
1064 #[test]
1065 fn save_jobs_does_not_persist_prompt_based_jobs() {
1066 let tmp = tempdir().unwrap();
1067 write_prompt_job(
1069 tmp.path(),
1070 "prompt.md",
1071 "---\nid: from-prompt\nschedule: '0 9 * * *'\ntitle: Prompt Job\n---\n\nbody\n",
1072 );
1073 let cfg = write_config(
1075 tmp.path(),
1076 "jobs:\n - id: inline\n schedule: \"0 10 * * *\"\n task:\n title: Inline\n body: x\n",
1077 );
1078 let jobs = load_jobs(&cfg).unwrap();
1079 assert_eq!(jobs.len(), 2);
1080
1081 save_jobs(&cfg, &jobs).unwrap();
1083
1084 let saved = load_jobs(&cfg).unwrap();
1085 assert_eq!(saved.len(), 2, "both jobs should still be loadable");
1086 let ids: Vec<&str> = saved.iter().map(|j| j.id.as_str()).collect();
1087 assert!(ids.contains(&"inline"));
1088 assert!(ids.contains(&"from-prompt"));
1089 }
1090
1091 #[test]
1092 fn rewrite_frontmatter_enabled_replaces_existing_value() {
1093 let input = "---\nid: foo\nschedule: daily\nenabled: true\ntitle: Foo\n---\n\nbody\n";
1094 let out = rewrite_frontmatter_enabled(input, "false");
1095 assert!(
1096 out.contains("enabled: false"),
1097 "should replace enabled: true"
1098 );
1099 assert!(!out.contains("enabled: true"), "old value must be gone");
1100 assert!(out.contains("body"), "body must be preserved");
1101 }
1102
1103 #[test]
1104 fn rewrite_frontmatter_enabled_inserts_when_absent() {
1105 let input = "---\nid: foo\nschedule: daily\ntitle: Foo\n---\n\nbody\n";
1106 let out = rewrite_frontmatter_enabled(input, "false");
1107 assert!(
1108 out.contains("enabled: false"),
1109 "should insert enabled: false"
1110 );
1111 assert!(out.contains("body"), "body must be preserved");
1112 }
1113
1114 #[test]
1115 fn set_frontmatter_enabled_writes_file() {
1116 let tmp = tempdir().unwrap();
1117 let path = tmp.path().join("job.md");
1118 fs::write(
1119 &path,
1120 "---\nid: foo\nschedule: daily\ntitle: Foo\nenabled: true\n---\n\nbody\n",
1121 )
1122 .unwrap();
1123 set_frontmatter_enabled(&path, false).unwrap();
1124 let content = fs::read_to_string(&path).unwrap();
1125 assert!(content.contains("enabled: false"));
1126 assert!(!content.contains("enabled: true"));
1127 }
1128}