1use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use serde::Deserialize;
11
12use crate::agent::{Agent, AgentName};
13use crate::cost::{RateCard, Reserve};
14use crate::pipeline::{Pipeline, PipelineName};
15use crate::route::Route;
16
17#[derive(Debug, Clone, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Paths {
21 #[serde(default = "default_state_dir")]
23 pub state_dir: PathBuf,
24 #[serde(default = "default_work_dir")]
26 pub work_dir: PathBuf,
27 #[serde(default = "default_logbook")]
29 pub logbook: PathBuf,
30 #[serde(default = "default_prompt_dir")]
32 pub prompt_dir: PathBuf,
33 #[serde(default = "default_http_addr")]
35 pub http_addr: String,
36}
37
38impl Default for Paths {
39 fn default() -> Self {
40 Self {
41 state_dir: default_state_dir(),
42 work_dir: default_work_dir(),
43 logbook: default_logbook(),
44 prompt_dir: default_prompt_dir(),
45 http_addr: default_http_addr(),
46 }
47 }
48}
49
50#[derive(Debug, Clone, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct Defaults {
54 #[serde(default)]
56 pub runner: Option<String>,
57 #[serde(default)]
64 pub env_from: Vec<String>,
65 #[serde(default = "default_max_hops")]
70 pub max_hops: u32,
71 #[serde(default = "default_fuel_usd")]
73 pub fuel_usd: f64,
74 #[serde(default = "default_max_runs")]
79 pub max_runs: u32,
80 #[serde(default = "default_timeout_sec")]
82 pub timeout_sec: u64,
83 #[serde(default = "default_max_recovery_attempts")]
88 pub max_recovery_attempts: u32,
89 #[serde(default = "default_max_concurrent_runs")]
97 pub max_concurrent_runs: usize,
98 #[serde(default = "default_max_spawn_generations")]
104 pub max_spawn_generations: u32,
105}
106
107impl Default for Defaults {
108 fn default() -> Self {
109 Self {
110 runner: None,
111 env_from: Vec::new(),
112 max_hops: default_max_hops(),
113 fuel_usd: default_fuel_usd(),
114 max_runs: default_max_runs(),
115 timeout_sec: default_timeout_sec(),
116 max_recovery_attempts: default_max_recovery_attempts(),
117 max_concurrent_runs: default_max_concurrent_runs(),
118 max_spawn_generations: default_max_spawn_generations(),
119 }
120 }
121}
122
123#[derive(Debug, Clone, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct McpWiring {
127 pub flag: String,
129 pub format: String,
131 #[serde(default)]
139 pub prefix: String,
140}
141
142impl McpWiring {
143 #[must_use]
145 pub fn argument(&self, path: &str) -> String {
146 format!("{}{path}", self.prefix)
147 }
148}
149
150#[derive(Debug, Clone, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct Runner {
154 pub command: Vec<String>,
168 #[serde(default)]
170 pub mcp: Option<McpWiring>,
171}
172
173impl Runner {
174 pub const PROMPT_PATH: &'static str = "{prompt}";
176
177 pub const MODEL: &'static str = "{model}";
184
185 pub const MCP: &'static str = "{mcp}";
193
194 #[must_use]
198 pub fn takes_prompt_path(&self) -> bool {
199 self.command
200 .iter()
201 .any(|arg| arg.contains(Self::PROMPT_PATH))
202 }
203
204 #[must_use]
210 pub fn takes_model(&self) -> bool {
211 self.command.iter().any(|arg| arg.contains(Self::MODEL))
212 }
213
214 #[must_use]
224 pub fn invocation(&self, prompt_path: Option<&str>, model: Option<&str>) -> Vec<String> {
225 self.invocation_with_mcp(prompt_path, model, None)
226 }
227
228 #[must_use]
234 pub fn invocation_with_mcp(
235 &self,
236 prompt_path: Option<&str>,
237 model: Option<&str>,
238 mcp_config: Option<&str>,
239 ) -> Vec<String> {
240 let wiring = self.mcp.as_ref().zip(mcp_config);
241 let mut out = Vec::with_capacity(self.command.len() + 2);
242
243 for arg in &self.command {
244 if arg == Self::MODEL && model.is_none() {
245 continue;
246 }
247
248 if arg == Self::MCP {
249 if let Some((mcp, path)) = wiring {
250 out.push(mcp.flag.clone());
251 out.push(mcp.argument(path));
252 }
253 continue;
256 }
257
258 let mut rendered = arg.clone();
259 if let Some(path) = prompt_path {
260 rendered = rendered.replace(Self::PROMPT_PATH, path);
261 }
262 if let Some(model) = model {
263 rendered = rendered.replace(Self::MODEL, model);
264 }
265
266 out.push(rendered);
267 }
268
269 if let Some((mcp, path)) = wiring
270 && !self.command.iter().any(|arg| arg == Self::MCP)
271 {
272 out.push(mcp.flag.clone());
273 out.push(mcp.argument(path));
274 }
275
276 out
277 }
278}
279
280#[derive(Debug, Clone, Deserialize)]
286#[serde(deny_unknown_fields)]
287pub struct ReserveConfig {
288 #[serde(default = "default_reserve_usd")]
290 pub fuel_usd: f64,
291 #[serde(default = "default_reserve_window_hours")]
296 pub window_hours: u64,
297}
298
299impl ReserveConfig {
300 #[must_use]
302 pub fn window(&self) -> Duration {
303 Duration::from_secs(self.window_hours.saturating_mul(3_600))
304 }
305
306 #[must_use]
308 pub fn to_reserve(&self) -> Reserve {
309 Reserve::new(self.fuel_usd, self.window())
310 }
311
312 #[must_use]
314 pub fn is_unlimited(&self) -> bool {
315 !(self.fuel_usd.is_finite() && self.fuel_usd > 0.0)
316 }
317}
318
319impl Default for ReserveConfig {
320 fn default() -> Self {
321 Self {
322 fuel_usd: default_reserve_usd(),
323 window_hours: default_reserve_window_hours(),
324 }
325 }
326}
327
328#[derive(Debug, Clone, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct Config {
332 #[serde(default)]
334 pub layover: Paths,
335 #[serde(default)]
337 pub defaults: Defaults,
338 #[serde(default)]
340 pub reserve: ReserveConfig,
341 #[serde(default)]
343 pub rates: RateCard,
344 #[serde(default)]
346 pub runners: BTreeMap<String, Runner>,
347 #[serde(default)]
349 pub agents: BTreeMap<AgentName, Agent>,
350 #[serde(default)]
352 pub pipelines: BTreeMap<PipelineName, Pipeline>,
353 #[serde(default)]
355 pub routes: Vec<Route>,
356}
357
358impl Config {
359 pub fn from_toml(text: &str, origin: impl Into<PathBuf>) -> Result<Self, ConfigError> {
366 toml::from_str(text).map_err(|source| ConfigError::Parse {
367 path: origin.into(),
368 source: Box::new(source),
369 })
370 }
371
372 pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
379 let path = path.as_ref();
380 let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
381 path: path.to_path_buf(),
382 source,
383 })?;
384 Self::from_toml(&text, path)
385 }
386
387 pub fn entry_agents(&self) -> impl Iterator<Item = &AgentName> {
395 let marked = self
396 .agents
397 .iter()
398 .filter(|(_, agent)| agent.entry)
399 .map(|(name, _)| name);
400
401 let piped = self.pipelines.values().map(|pipeline| &pipeline.entry);
402
403 marked.chain(piped).collect::<BTreeSet<_>>().into_iter()
404 }
405
406 #[must_use]
408 pub fn fuel_for(&self, agent: &AgentName) -> f64 {
409 self.agents
410 .get(agent)
411 .and_then(|a| a.fuel_usd)
412 .unwrap_or(self.defaults.fuel_usd)
413 }
414
415 pub fn declared_flags(&self) -> impl Iterator<Item = &str> {
417 self.pipelines
418 .values()
419 .flat_map(|pipeline| pipeline.flags.keys().map(String::as_str))
420 }
421
422 pub fn scheduled_pipelines(&self) -> impl Iterator<Item = (&PipelineName, &Pipeline)> {
424 self.pipelines
425 .iter()
426 .filter(|(_, pipeline)| !pipeline.trigger.is_manual())
427 }
428}
429
430#[derive(Debug, thiserror::Error)]
432pub enum ConfigError {
433 #[error("could not read factory definition at {path}")]
435 Read {
436 path: PathBuf,
438 #[source]
440 source: std::io::Error,
441 },
442 #[error("could not parse factory definition at {path}")]
444 Parse {
445 path: PathBuf,
447 #[source]
449 source: Box<toml::de::Error>,
450 },
451}
452
453fn default_state_dir() -> PathBuf {
454 PathBuf::from(".layover/state")
455}
456
457fn default_work_dir() -> PathBuf {
458 PathBuf::from("workspace")
459}
460
461fn default_logbook() -> PathBuf {
462 PathBuf::from(".layover/logbook.md")
463}
464
465fn default_prompt_dir() -> PathBuf {
466 PathBuf::from("prompts")
467}
468
469fn default_http_addr() -> String {
470 "127.0.0.1:7878".to_owned()
471}
472
473const fn default_max_hops() -> u32 {
474 8
475}
476
477const fn default_fuel_usd() -> f64 {
478 5.0
479}
480
481const fn default_max_concurrent_runs() -> usize {
483 4
484}
485
486const fn default_max_spawn_generations() -> u32 {
488 1
489}
490
491const fn default_max_runs() -> u32 {
492 64
493}
494
495const fn default_max_recovery_attempts() -> u32 {
496 2
497}
498
499const fn default_reserve_usd() -> f64 {
500 100.0
501}
502
503const fn default_reserve_window_hours() -> u64 {
504 24
505}
506
507const fn default_timeout_sec() -> u64 {
508 900
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::pipeline::Trigger;
515
516 #[test]
517 fn omitted_sections_fall_back_to_defaults() {
518 let config = Config::from_toml("", "test.toml").expect("an empty factory parses");
519
520 assert_eq!(config.defaults.max_hops, 8);
521 assert_eq!(config.defaults.max_runs, 64);
522 assert_eq!(config.layover.http_addr, "127.0.0.1:7878");
523 assert_eq!(config.layover.state_dir, PathBuf::from(".layover/state"));
524 assert_eq!(config.layover.prompt_dir, PathBuf::from("prompts"));
525 assert!(config.pipelines.is_empty());
526 }
527
528 #[test]
529 fn a_typo_is_rejected_rather_than_ignored() {
530 let error = Config::from_toml(
531 r#"
532 [agents.planner]
533 prompt = "plan"
534 entrypoint = true
535 "#,
536 "test.toml",
537 )
538 .expect_err("an unrecognised field must not be silently dropped");
539
540 assert!(matches!(error, ConfigError::Parse { .. }));
541 }
542
543 #[test]
544 fn a_per_agent_fuel_override_wins() {
545 let config = Config::from_toml(
546 r#"
547 [defaults]
548 fuel_usd = 5.0
549
550 [agents.thrifty]
551 prompt = "be brief"
552 fuel_usd = 1.0
553
554 [agents.spendy]
555 prompt = "take your time"
556 "#,
557 "test.toml",
558 )
559 .expect("config parses");
560
561 assert!((config.fuel_for(&"thrifty".into()) - 1.0).abs() < 1e-9);
562 assert!((config.fuel_for(&"spendy".into()) - 5.0).abs() < 1e-9);
563 }
564
565 #[test]
566 fn entry_agents_include_marked_agents_and_pipeline_entries() {
567 let config = Config::from_toml(
568 r#"
569 [agents.front_door]
570 prompt = "start here"
571 entry = true
572
573 [agents.scanner]
574 prompt = "poll for work"
575
576 [agents.inner]
577 prompt = "not directly reachable"
578
579 [pipelines.review-bot]
580 entry = "scanner"
581 trigger = { every = "1h" }
582 "#,
583 "test.toml",
584 )
585 .expect("config parses");
586
587 let mut entries: Vec<String> = config.entry_agents().map(ToString::to_string).collect();
588 entries.sort();
589
590 assert_eq!(entries, ["front_door", "scanner"]);
591 }
592
593 #[test]
594 fn an_agent_that_is_both_marked_and_piped_is_listed_once() {
595 let config = Config::from_toml(
596 r#"
597 [agents.analyst]
598 prompt = "analyse"
599 entry = true
600
601 [pipelines.development]
602 entry = "analyst"
603 "#,
604 "test.toml",
605 )
606 .expect("config parses");
607
608 assert_eq!(config.entry_agents().count(), 1);
609 }
610
611 #[test]
612 fn two_pipelines_sharing_an_entry_agent_list_it_once() {
613 let config = Config::from_toml(
614 r#"
615 [agents.analyst]
616 prompt = "analyse"
617
618 [pipelines.development]
619 entry = "analyst"
620
621 [pipelines.nightly]
622 entry = "analyst"
623 trigger = { every = "1d" }
624 "#,
625 "test.toml",
626 )
627 .expect("config parses");
628
629 assert_eq!(
630 config.entry_agents().collect::<Vec<_>>(),
631 vec![&AgentName::from("analyst")]
632 );
633 }
634
635 #[test]
636 fn scheduled_pipelines_are_separable_from_manual_ones() {
637 let config = Config::from_toml(
638 r#"
639 [agents.analyst]
640 prompt = "analyse"
641
642 [agents.scanner]
643 prompt = "scan"
644
645 [pipelines.development]
646 entry = "analyst"
647 trigger = "manual"
648
649 [pipelines.review-bot]
650 entry = "scanner"
651 trigger = { cron = "0 * * * *" }
652 "#,
653 "test.toml",
654 )
655 .expect("config parses");
656
657 let scheduled: Vec<&str> = config
658 .scheduled_pipelines()
659 .map(|(name, _)| name.as_str())
660 .collect();
661
662 assert_eq!(scheduled, ["review-bot"]);
663 assert_eq!(
664 config.pipelines[&PipelineName::from("development")].trigger,
665 Trigger::Manual
666 );
667 }
668
669 #[test]
670 fn declared_flags_are_collected_across_pipelines() {
671 let config = Config::from_toml(
672 r#"
673 [agents.analyst]
674 prompt = "analyse"
675
676 [pipelines.development]
677 entry = "analyst"
678
679 [pipelines.development.flags]
680 run_e2e = { default = false }
681
682 [pipelines.nightly]
683 entry = "analyst"
684 trigger = { every = "1d" }
685
686 [pipelines.nightly.flags]
687 deep_scan = { default = true }
688 "#,
689 "test.toml",
690 )
691 .expect("config parses");
692
693 let mut flags: Vec<&str> = config.declared_flags().collect();
694 flags.sort_unstable();
695
696 assert_eq!(flags, ["deep_scan", "run_e2e"]);
697 }
698}
699
700#[cfg(test)]
701mod invocation_tests {
702 use crate::config::Runner;
703
704 fn runner(args: &[&str]) -> Runner {
705 toml::from_str(&format!(
706 "command = [{}]",
707 args.iter()
708 .map(|a| format!("\"{a}\""))
709 .collect::<Vec<_>>()
710 .join(", ")
711 ))
712 .expect("parses")
713 }
714
715 #[test]
716 fn a_model_placeholder_is_substituted_wherever_it_sits() {
717 let separate = runner(&["claude", "-p", "--model", "{model}"]);
720 assert_eq!(
721 separate.invocation(None, Some("claude-opus-5")),
722 ["claude", "-p", "--model", "claude-opus-5"]
723 );
724
725 let joined = runner(&["codex", "exec", "--model={model}"]);
726 assert_eq!(
727 joined.invocation(None, Some("gpt-5.4")),
728 ["codex", "exec", "--model=gpt-5.4"]
729 );
730 }
731
732 #[test]
733 fn a_bare_model_placeholder_disappears_when_no_model_is_set() {
734 let r = runner(&["claude", "-p", "{model}"]);
736 assert_eq!(r.invocation(None, None), ["claude", "-p"]);
737 }
738
739 fn mcp_runner(args: &[&str], flag: &str) -> Runner {
740 toml::from_str(&format!(
741 "command = [{}]\nmcp = {{ flag = \"{flag}\", format = \"claude_json\" }}",
742 args.iter()
743 .map(|a| format!("\"{a}\""))
744 .collect::<Vec<_>>()
745 .join(", ")
746 ))
747 .expect("parses")
748 }
749
750 #[test]
751 fn mcp_wiring_is_appended_when_the_command_does_not_place_it() {
752 let r = mcp_runner(&["copilot", "--allow-all-tools"], "--mcp-config");
754 assert_eq!(
755 r.invocation_with_mcp(None, None, Some("/h/mcp.json")),
756 [
757 "copilot",
758 "--allow-all-tools",
759 "--mcp-config",
760 "/h/mcp.json"
761 ]
762 );
763 }
764
765 #[test]
766 fn a_prefix_is_prepended_to_the_path_rather_than_passed_separately() {
767 let runner: Runner = toml::from_str(
772 r#"command = ["copilot", "--allow-all-tools"]
773mcp = { flag = "--additional-mcp-config", format = "claude_json", prefix = "@" }"#,
774 )
775 .expect("parses");
776
777 assert_eq!(
778 runner.invocation_with_mcp(None, None, Some("/h/mcp.json")),
779 [
780 "copilot",
781 "--allow-all-tools",
782 "--additional-mcp-config",
783 "@/h/mcp.json"
784 ]
785 );
786 }
787
788 #[test]
789 fn a_prefix_applies_where_the_command_places_the_wiring_too() {
790 let runner: Runner = toml::from_str(
793 r#"command = ["agent", "{mcp}", "-"]
794mcp = { flag = "--cfg", format = "claude_json", prefix = "@" }"#,
795 )
796 .expect("parses");
797
798 assert_eq!(
799 runner.invocation_with_mcp(None, None, Some("/h/mcp.json")),
800 ["agent", "--cfg", "@/h/mcp.json", "-"]
801 );
802 }
803
804 #[test]
805 fn a_runner_without_a_prefix_still_gets_a_bare_path() {
806 let runner = mcp_runner(&["claude", "-p"], "--mcp-config");
807
808 assert_eq!(
809 runner.invocation_with_mcp(None, None, Some("/h/mcp.json")),
810 ["claude", "-p", "--mcp-config", "/h/mcp.json"]
811 );
812 }
813
814 #[test]
815 fn mcp_wiring_goes_where_the_command_puts_it_when_it_says() {
816 let r = mcp_runner(&["codex", "exec", "{mcp}", "-"], "-c");
819 assert_eq!(
820 r.invocation_with_mcp(None, None, Some("/h/mcp.toml")),
821 ["codex", "exec", "-c", "/h/mcp.toml", "-"]
822 );
823 }
824
825 #[test]
826 fn an_mcp_placeholder_disappears_when_there_is_nothing_to_wire() {
827 let r = mcp_runner(&["codex", "exec", "{mcp}", "-"], "-c");
829 assert_eq!(
830 r.invocation_with_mcp(None, None, None),
831 ["codex", "exec", "-"]
832 );
833 }
834
835 #[test]
836 fn a_runner_with_no_mcp_block_is_wired_to_nothing_even_if_a_path_exists() {
837 let r = runner(&["echo", "hello"]);
840 assert_eq!(
841 r.invocation_with_mcp(None, None, Some("/h/mcp.json")),
842 ["echo", "hello"]
843 );
844 }
845
846 #[test]
847 fn the_prompt_path_is_substituted_independently_of_the_model() {
848 let r = runner(&["agent", "--file", "{prompt}", "--model", "{model}"]);
849 assert_eq!(
850 r.invocation(Some("/run/prompt.md"), Some("m1")),
851 ["agent", "--file", "/run/prompt.md", "--model", "m1"]
852 );
853 }
854
855 #[test]
856 fn a_runner_without_placeholders_is_passed_through_untouched() {
857 let r = runner(&["copilot", "--allow-all-tools"]);
858 assert_eq!(
859 r.invocation(Some("/x"), Some("m")),
860 ["copilot", "--allow-all-tools"]
861 );
862 assert!(!r.takes_model());
863 assert!(!r.takes_prompt_path());
864 }
865
866 #[test]
867 fn declaring_a_model_a_runner_cannot_carry_is_a_warning() {
868 let config: crate::config::Config = toml::from_str(
869 r#"
870[layover]
871work_dir = "work"
872
873[defaults]
874runner = "claude"
875
876[runners.claude]
877command = ["claude", "-p"]
878
879[agents.analyst]
880prompt = "analyse"
881model = "claude-opus-5"
882entry = true
883"#,
884 )
885 .expect("parses");
886
887 let said: Vec<_> = crate::validate::validate(&config)
888 .iter()
889 .map(|d| d.message.clone())
890 .collect();
891
892 assert!(
893 said.iter().any(|m| m.contains("no `{model}` placeholder")),
894 "{said:?}"
895 );
896 }
897}