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