1use crate::backends::{ExternalBackend, ExternalId};
17use crate::channels::notification::TaskNotification;
18use crate::channels::transport::Transport;
19use crate::cmd::CommandErrorContext;
20use crate::store::{JobState, TaskStatus};
21use anyhow::Context;
22use cron::Schedule;
23use serde::{Deserialize, Serialize};
24use std::path::{Path, PathBuf};
25use std::str::FromStr;
26use std::sync::Arc;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Job {
35 pub id: String,
36 #[serde(default = "default_job_type")]
37 pub r#type: String,
38 pub schedule: String,
39 #[serde(default)]
40 pub task: Option<TaskTemplate>,
41 #[serde(default)]
42 pub command: Option<String>,
43 #[serde(default)]
44 pub dir: Option<String>,
45 #[serde(default = "default_enabled")]
46 pub enabled: bool,
47 #[serde(default = "default_external")]
48 pub external: bool, #[serde(default)]
52 pub notify: bool,
53 #[serde(default)]
56 pub notify_target: Option<String>,
57 #[serde(skip)]
63 pub prompt_file: Option<PathBuf>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct TaskTemplate {
69 pub title: String,
70 #[serde(default)]
71 pub body: String,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub prompt: Option<String>,
77 #[serde(default)]
78 pub labels: Vec<String>,
79 #[serde(default)]
80 pub agent: Option<String>,
81}
82
83pub fn resolve_template_body(template: &TaskTemplate, base_dir: &Path) -> anyhow::Result<String> {
88 match template.prompt.as_deref() {
89 Some(rel) => {
90 let path = Path::new(rel);
91 let full = if path.is_absolute() {
92 path.to_path_buf()
93 } else {
94 base_dir.join(path)
95 };
96 std::fs::read_to_string(&full)
97 .with_context(|| format!("reading prompt file {}", full.display()))
98 }
99 None => Ok(template.body.clone()),
100 }
101}
102
103fn default_job_type() -> String {
104 "task".to_string()
105}
106
107fn default_enabled() -> bool {
108 true
109}
110
111fn default_external() -> bool {
112 false }
114
115#[derive(Debug, Serialize, Deserialize)]
117struct ConfigFile {
118 #[serde(default)]
119 jobs: Vec<Job>,
120 #[serde(flatten)]
122 other: serde_norway::Mapping,
123}
124
125pub fn resolve_jobs_path() -> PathBuf {
132 let project = PathBuf::from(".orch.yml");
134 if project.exists() {
135 return project;
136 }
137
138 if let Ok(paths) = crate::config::get_project_paths() {
140 for path_str in &paths {
141 let candidate = PathBuf::from(path_str).join(".orch.yml");
142 if candidate.exists() {
143 return candidate;
144 }
145 }
146 }
147
148 crate::home::config_path().unwrap_or_else(|_| PathBuf::from(".orch/config.yml"))
150}
151
152pub fn load_jobs(path: &PathBuf) -> anyhow::Result<Vec<Job>> {
158 let base_dir = path
159 .parent()
160 .map(Path::to_path_buf)
161 .unwrap_or_else(|| PathBuf::from("."));
162 let mut jobs: Vec<Job> = if path.exists() {
163 let content =
164 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
165 let file: ConfigFile = serde_norway::from_str(&content)
166 .with_context(|| format!("parsing {}", path.display()))?;
167 file.jobs
168 } else {
169 vec![]
170 };
171
172 let scanned = scan_prompt_jobs(&base_dir)?;
173 let mut seen: std::collections::HashSet<String> = jobs.iter().map(|j| j.id.clone()).collect();
174 for job in scanned {
175 if !seen.insert(job.id.clone()) {
176 anyhow::bail!(
177 "duplicate job id '{}' — defined in both '{}' and prompts/jobs/",
178 job.id,
179 path.display()
180 );
181 }
182 jobs.push(job);
183 }
184
185 for job in &jobs {
186 validate_job(job, &base_dir)?;
187 }
188 Ok(jobs)
189}
190
191fn validate_job(job: &Job, base_dir: &Path) -> anyhow::Result<()> {
192 let expanded = crate::cron::expand_alias(&job.schedule)
193 .with_context(|| format!("job '{}': invalid cron schedule '{}'", job.id, job.schedule))?;
194 let normalized = crate::cron::normalize_dow(&expanded);
195 let full_expr = format!("0 {normalized} *");
196 Schedule::from_str(&full_expr)
197 .with_context(|| format!("job '{}': invalid cron schedule '{}'", job.id, job.schedule))?;
198 if let Some(template) = job.task.as_ref() {
199 if template.prompt.is_some() && !template.body.is_empty() {
200 anyhow::bail!(
201 "job '{}': set either 'task.body' or 'task.prompt', not both",
202 job.id
203 );
204 }
205 if let Some(rel) = template.prompt.as_deref() {
206 let candidate = Path::new(rel);
207 let full = if candidate.is_absolute() {
208 candidate.to_path_buf()
209 } else {
210 base_dir.join(candidate)
211 };
212 if !full.exists() {
213 anyhow::bail!(
214 "job '{}': prompt file not found: {}",
215 job.id,
216 full.display()
217 );
218 }
219 }
220 }
221 Ok(())
222}
223
224#[derive(Debug, Deserialize)]
226struct JobFrontmatter {
227 id: String,
228 schedule: String,
229 title: String,
230 #[serde(default = "default_job_type")]
231 r#type: String,
232 #[serde(default = "default_enabled")]
233 enabled: bool,
234 #[serde(default = "default_external")]
235 external: bool,
236 #[serde(default)]
237 labels: Vec<String>,
238 #[serde(default)]
239 agent: Option<String>,
240 #[serde(default)]
241 notify: bool,
242 #[serde(default)]
243 notify_target: Option<String>,
244 #[serde(default)]
245 command: Option<String>,
246 #[serde(default)]
247 dir: Option<String>,
248}
249
250fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
253 let rest = content
254 .strip_prefix("---\n")
255 .or_else(|| content.strip_prefix("---\r\n"))?;
256 let end = rest.find("\n---\n").or_else(|| rest.find("\n---\r\n"))?;
257 let fm = &rest[..end];
258 let after = &rest[end..];
259 let body = after
260 .strip_prefix("\n---\n")
261 .or_else(|| after.strip_prefix("\n---\r\n"))
262 .unwrap_or(after);
263 Some((fm, body.trim_start_matches('\n')))
264}
265
266pub fn scan_prompt_jobs(base_dir: &Path) -> anyhow::Result<Vec<Job>> {
268 let dir = base_dir.join("prompts").join("jobs");
269 if !dir.is_dir() {
270 return Ok(vec![]);
271 }
272 let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir)
273 .with_context(|| format!("reading {}", dir.display()))?
274 .filter_map(|e| e.ok())
275 .map(|e| e.path())
276 .filter(|p| p.extension().and_then(|s| s.to_str()) == Some("md"))
277 .collect();
278 entries.sort();
279
280 let mut jobs = Vec::with_capacity(entries.len());
281 for path in entries {
282 let content = std::fs::read_to_string(&path)
283 .with_context(|| format!("reading {}", path.display()))?;
284 let Some((fm, body)) = split_frontmatter(&content) else {
285 continue; };
287 let meta: JobFrontmatter = serde_norway::from_str(fm)
288 .with_context(|| format!("parsing frontmatter in {}", path.display()))?;
289 jobs.push(Job {
290 id: meta.id,
291 r#type: meta.r#type,
292 schedule: meta.schedule,
293 task: Some(TaskTemplate {
294 title: meta.title,
295 body: body.to_string(),
296 prompt: None,
297 labels: meta.labels,
298 agent: meta.agent,
299 }),
300 command: meta.command,
301 dir: meta.dir,
302 enabled: meta.enabled,
303 external: meta.external,
304 notify: meta.notify,
305 notify_target: meta.notify_target,
306 prompt_file: Some(path.clone()),
307 });
308 }
309 Ok(jobs)
310}
311
312pub fn save_jobs(path: &PathBuf, jobs: &[Job]) -> anyhow::Result<()> {
314 let mut file: ConfigFile = if path.exists() {
316 let content =
317 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
318 serde_norway::from_str(&content).with_context(|| format!("parsing {}", path.display()))?
319 } else {
320 ConfigFile {
321 jobs: vec![],
322 other: serde_norway::Mapping::new(),
323 }
324 };
325
326 file.jobs = jobs
331 .iter()
332 .filter(|j| j.prompt_file.is_none())
333 .cloned()
334 .collect();
335 let content = serde_norway::to_string(&file)?;
336 std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
337 Ok(())
338}
339
340pub fn set_frontmatter_enabled(path: &Path, enabled: bool) -> anyhow::Result<()> {
346 let original =
347 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
348
349 let value = if enabled { "true" } else { "false" };
350
351 let rewritten = rewrite_frontmatter_enabled(&original, value);
355 std::fs::write(path, rewritten).with_context(|| format!("writing {}", path.display()))?;
356 Ok(())
357}
358
359fn rewrite_frontmatter_enabled(content: &str, value: &str) -> String {
364 let Some(rest) = content
366 .strip_prefix("---\n")
367 .or_else(|| content.strip_prefix("---\r\n"))
368 else {
369 return content.to_string();
371 };
372
373 let Some(close_pos) = rest.find("\n---\n").or_else(|| rest.find("\n---\r\n")) else {
375 return content.to_string();
376 };
377
378 let fm = &rest[..close_pos];
379 let after_close = &rest[close_pos..]; let new_line = format!("enabled: {value}");
383 let mut found = false;
384 let new_fm: String = fm
385 .lines()
386 .map(|line| {
387 if line == "enabled: true" || line == "enabled: false" || line.starts_with("enabled: ")
388 {
389 found = true;
390 new_line.clone()
391 } else {
392 line.to_string()
393 }
394 })
395 .collect::<Vec<_>>()
396 .join("\n");
397
398 let new_fm = if found {
399 new_fm
400 } else {
401 format!("{new_fm}\n{new_line}")
402 };
403
404 format!("---\n{new_fm}{after_close}")
405}
406
407fn is_not_found_error(e: &anyhow::Error) -> bool {
413 e.chain().any(|cause| cause.to_string().contains("404"))
417}
418
419pub async fn tick(
424 jobs_path: &Path,
425 backend: &Arc<dyn ExternalBackend>,
426 store: Option<&Arc<crate::store::TaskStore>>,
427 repo: &str,
428 transport: Option<&Arc<Transport>>,
429) -> anyhow::Result<()> {
430 let jobs_path_clone = jobs_path.to_path_buf();
431 let jobs = match tokio::task::spawn_blocking(move || load_jobs(&jobs_path_clone))
432 .await
433 .map_err(|e| anyhow::anyhow!("load_jobs panicked: {e}"))
434 .and_then(|r| r)
435 {
436 Ok(j) => j,
437 Err(e) => {
438 tracing::error!(
439 path = %jobs_path.display(),
440 repo = %repo,
441 ?e,
442 "job config invalid — skipping scheduler tick for this project"
443 );
444 return Ok(());
445 }
446 };
447 let now = chrono::Utc::now();
448
449 for job in &jobs {
450 if !job.enabled {
451 continue;
452 }
453
454 let mut state = if let Some(s) = store {
456 match s.get_job_state(repo, &job.id).await {
457 Ok(Some(st)) => st,
458 Ok(None) => JobState {
459 repo: repo.to_string(),
460 job_id: job.id.clone(),
461 last_run: None,
462 last_task_status: None,
463 active_task_id: None,
464 },
465 Err(e) => {
466 tracing::error!(job_id = job.id, ?e, "failed to load job state, skipping");
467 continue;
468 }
469 }
470 } else {
471 JobState {
472 repo: repo.to_string(),
473 job_id: job.id.clone(),
474 last_run: None,
475 last_task_status: None,
476 active_task_id: None,
477 }
478 };
479
480 let is_due = match &state.last_run {
482 Some(last) => match crate::cron::check(&job.schedule, Some(last)) {
483 Ok(v) => v,
484 Err(e) => {
485 tracing::error!(
486 job_id = job.id,
487 schedule = job.schedule,
488 ?e,
489 "invalid cron expression, skipping"
490 );
491 continue;
492 }
493 },
494 None => match crate::cron::check(&job.schedule, None) {
495 Ok(v) => v,
496 Err(e) => {
497 tracing::error!(
498 job_id = job.id,
499 schedule = job.schedule,
500 ?e,
501 "invalid cron expression, skipping"
502 );
503 continue;
504 }
505 },
506 };
507
508 if !is_due {
509 continue;
510 }
511
512 let mut should_clear_task_id = false;
514 let mut should_skip = false;
515
516 if let Some(ref task_id) = state.active_task_id {
517 if task_id.is_empty() {
518 state.active_task_id = None;
519 }
520 }
521
522 if let Some(ref task_id) = state.active_task_id {
523 let task_id_clone = task_id.clone();
524 let is_active = if job.external {
525 match backend.get_task(&ExternalId(task_id_clone.clone())).await {
527 Ok(task) => {
528 let status = task.labels.iter().find(|l| l.starts_with("status:"));
529 match status.map(|s| s.as_str()) {
530 Some("status:in_progress")
531 | Some("status:routed")
532 | Some("status:new")
533 | Some("status:needs_review")
534 | Some("status:in_review")
535 | Some("status:approved")
536 | Some("status:waiting") => true,
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 if let Err(e) = s.upsert_job_state(&state).await {
632 tracing::error!(
633 job_id = job.id,
634 ?e,
635 "failed to persist job state before execution — skipping this tick to preserve restart safety"
636 );
637 continue; }
639 }
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 if let Err(e) = s.upsert_job_state(&state).await {
648 tracing::error!(
649 job_id = job.id,
650 ?e,
651 "failed to persist job state after execution"
652 );
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<crate::store::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 if !agent.is_empty() {
692 labels.push(format!("agent:{agent}"));
693 }
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 = crate::config::get("jobs.bash_timeout_seconds")
745 .ok()
746 .and_then(|s| s.parse().ok())
747 .unwrap_or(300);
748 let timeout = std::time::Duration::from_secs(timeout_secs);
749
750 let output = tokio::time::timeout(
751 timeout,
752 tokio::process::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 if let Some(t) = transport {
803 let status = state
804 .last_task_status
805 .as_deref()
806 .unwrap_or("unknown")
807 .to_string();
808 let summary = match status.as_str() {
809 "done" => format!("Job `{}` completed successfully.", job.id),
810 "failed" => format!("Job `{}` failed.", job.id),
811 "new" => format!("Job `{}` created a new task.", job.id),
812 other => format!("Job `{}` finished with status: {other}.", job.id),
813 };
814 let target = job
816 .notify_target
817 .clone()
818 .or_else(|| crate::config::get("channels.telegram.chat_id").ok());
819 let notification = TaskNotification {
820 task_id: job.id.clone(),
821 title: format!("Job: {}", job.id),
822 status,
823 agent: "scheduler".to_string(),
824 duration_seconds: 0.0,
825 summary,
826 repo: Some(repo.to_string()),
827 notify_target: target,
828 pr_number: None,
829 };
830 t.push_notification(notification);
831 }
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}