1use std::collections::BTreeMap;
2use std::env;
3use std::fmt;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use serde::Deserialize;
8
9use crate::flow::{DEFAULT_FLOW_NAME, Flow};
10use crate::ids::{DEFAULT_PROJECT_PREFIX, DEFAULT_TICKET_PREFIX, valid_prefix};
11use crate::sources::FlowSource;
12use crate::sources::markdown::MarkdownFlowSource;
13
14pub const CONFIG_VERSION: u32 = 1;
15pub const DEFAULT_DELETE_MISSING_AFTER_MS: i64 = 30 * 24 * 60 * 60 * 1000;
16pub const DEFAULT_WORKTREE_RETENTION_MS: i64 = 7 * 24 * 60 * 60 * 1000;
17pub const DEFAULT_WORKTREE_DIR: &str = ".worktrees";
18pub const DEFAULT_PROJECT_DIR: &str = ".agents/sloop/projects";
19pub const DEFAULT_TICKET_DIR: &str = ".agents/sloop/tickets";
20pub const DEFAULT_STALL_REPORT_AFTER_MS: i64 = 10 * 60 * 1000;
21pub const DEFAULT_STALL_AFTER_MS: i64 = 2 * 60 * 60 * 1000;
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Repository {
25 pub root: PathBuf,
26 pub config_path: PathBuf,
27 pub state_dir: PathBuf,
28 pub runtime_dir: PathBuf,
29 pub operator_socket: PathBuf,
30 pub lock_path: PathBuf,
31 pub daemon_log: PathBuf,
32 pub db_path: PathBuf,
33}
34
35impl Repository {
36 pub fn discover(start: &Path) -> Result<Self, ConfigError> {
37 let start = start.canonicalize().map_err(|source| ConfigError::Io {
38 path: start.to_path_buf(),
39 source,
40 })?;
41
42 for directory in start.ancestors() {
43 let config_path = directory.join(".agents/sloop/config.yaml");
44 if config_path.is_file() {
45 let paths = crate::paths::resolve(directory).map_err(ConfigError::Paths)?;
46 return Ok(Self {
47 root: directory.to_path_buf(),
48 config_path,
49 state_dir: paths.state_dir,
50 runtime_dir: paths.runtime_dir,
51 operator_socket: paths.operator_socket,
52 lock_path: paths.lock_path,
53 daemon_log: paths.daemon_log,
54 db_path: paths.db_path,
55 });
56 }
57 }
58
59 Err(ConfigError::RepositoryNotFound(start))
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct Config {
65 pub worktree_dir: PathBuf,
66 pub worktree_retention_ms: Option<i64>,
69 pub project_dir: PathBuf,
70 pub ticket_dir: PathBuf,
71 pub ticket_source: TicketSourceConfig,
72 pub max_parallel_tasks: usize,
73 pub stall_report_after_ms: i64,
74 pub stall_after_ms: i64,
75 pub running_hours: Option<RunningHours>,
76 pub agent: Option<AgentConfig>,
79 pub flows: BTreeMap<String, Flow>,
82 pub default_flow: String,
83 pub flow_test_cmd: Option<Vec<String>>,
87 pub ticket_prefix: String,
90 pub project_prefix: String,
91 pub delete_missing_after_ms: i64,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum TicketSourceConfig {
98 Markdown,
99 Exec(Vec<String>),
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct AgentConfig {
104 pub default_target: String,
105 pub targets: BTreeMap<String, AgentTarget>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct AgentTarget {
110 pub cmd: Vec<String>,
111 pub model: Option<String>,
112 pub effort: Option<String>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct RunningHours {
117 pub start: String,
118 pub end: String,
119 start_minute: u16,
120 end_minute: u16,
121}
122
123impl RunningHours {
124 pub fn is_open(&self, local_minute: u16) -> bool {
125 if self.start_minute < self.end_minute {
126 (self.start_minute..self.end_minute).contains(&local_minute)
127 } else {
128 local_minute >= self.start_minute || local_minute < self.end_minute
129 }
130 }
131
132 pub fn next_opening_ms(&self, clock: &dyn crate::clock::Clock, now_ms: i64) -> i64 {
133 let mut candidate = (now_ms.div_euclid(60_000) + 1) * 60_000;
134 for _ in 0..=(49 * 60) {
139 if self.is_open(clock.local_minute(candidate)) {
140 return candidate;
141 }
142 candidate += 60_000;
143 }
144 candidate
145 }
146}
147
148impl Config {
149 pub fn validate_client_essentials(repository: &Repository) -> Result<(), ConfigError> {
153 let config = read_client_config(&repository.config_path)?;
154 validate_worktree_dir(
155 config
156 .worktree_dir
157 .as_deref()
158 .unwrap_or_else(|| Path::new(DEFAULT_WORKTREE_DIR)),
159 &repository.config_path,
160 )?;
161 Ok(())
162 }
163
164 pub fn load(repository: &Repository) -> Result<Self, ConfigError> {
165 let user_path = user_config_path().filter(|path| path.is_file());
166 let user = user_path
167 .as_ref()
168 .map(|path| read_config(path))
169 .transpose()?;
170 let config = read_config(&repository.config_path)?;
171
172 let worktree_dir = validate_worktree_dir(
173 config
174 .worktree_dir
175 .as_deref()
176 .unwrap_or_else(|| Path::new(DEFAULT_WORKTREE_DIR)),
177 &repository.config_path,
178 )?;
179 let project_dir = validate_repository_dir(
180 "project_dir",
181 config
182 .project_dir
183 .as_deref()
184 .unwrap_or_else(|| Path::new(DEFAULT_PROJECT_DIR)),
185 &repository.config_path,
186 )?;
187 let ticket_dir = validate_repository_dir(
188 "ticket_dir",
189 config
190 .ticket_dir
191 .as_deref()
192 .unwrap_or_else(|| Path::new(DEFAULT_TICKET_DIR)),
193 &repository.config_path,
194 )?;
195
196 if user.as_ref().is_some_and(|config| {
197 config.agent.is_some()
198 || config
199 .defaults
200 .as_ref()
201 .is_some_and(|defaults| defaults.agent.is_some())
202 }) {
203 return Err(ConfigError::Invalid {
204 path: user_path.clone().expect("loaded user config has a path"),
205 message: "agent targets are repository-scoped; configure `agent.default_target` and `agent.targets` in .agents/sloop/config.yaml".into(),
206 });
207 }
208 if user.as_ref().is_some_and(|config| {
209 config.sources.is_some()
210 || config
211 .defaults
212 .as_ref()
213 .is_some_and(|defaults| defaults.sources.is_some())
214 }) {
215 return Err(ConfigError::Invalid {
216 path: user_path.expect("loaded user config has a path"),
217 message: "sources are repository-scoped; configure `sources` in .agents/sloop/config.yaml".into(),
218 });
219 }
220
221 let ticket_source = match config
222 .sources
223 .as_ref()
224 .and_then(|sources| sources.tickets.as_ref())
225 .and_then(|tickets| tickets.exec.clone())
226 {
227 None => TicketSourceConfig::Markdown,
228 Some(argv) if argv.is_empty() => {
229 return Err(ConfigError::Invalid {
230 path: repository.config_path.clone(),
231 message: "sources.tickets.exec must name a command".into(),
232 });
233 }
234 Some(argv) => TicketSourceConfig::Exec(argv),
235 };
236
237 let defaults = user
238 .as_ref()
239 .and_then(|config| config.defaults.as_ref())
240 .and_then(|defaults| defaults.scheduler.as_ref());
241 let repository_scheduler = config.scheduler.as_ref();
242
243 let max_parallel_tasks = repository_scheduler
244 .and_then(|scheduler| scheduler.max_parallel_tasks)
245 .or_else(|| defaults.and_then(|scheduler| scheduler.max_parallel_tasks))
246 .unwrap_or(1);
247 if max_parallel_tasks == 0 {
248 return Err(ConfigError::Invalid {
249 path: repository.config_path.clone(),
250 message: "scheduler.max_parallel_tasks must be greater than zero".into(),
251 });
252 }
253
254 let stall_report_after_ms = repository_scheduler
255 .and_then(|scheduler| scheduler.stall_report_after.as_deref())
256 .or_else(|| defaults.and_then(|scheduler| scheduler.stall_report_after.as_deref()))
257 .map(|value| {
258 parse_duration_ms(value).map_err(|message| ConfigError::Invalid {
259 path: repository.config_path.clone(),
260 message: format!("scheduler.stall_report_after: {message}"),
261 })
262 })
263 .transpose()?
264 .unwrap_or(DEFAULT_STALL_REPORT_AFTER_MS);
265 let stall_after_ms = repository_scheduler
266 .and_then(|scheduler| scheduler.stall_after.as_deref())
267 .or_else(|| defaults.and_then(|scheduler| scheduler.stall_after.as_deref()))
268 .map(|value| {
269 parse_duration_ms(value).map_err(|message| ConfigError::Invalid {
270 path: repository.config_path.clone(),
271 message: format!("scheduler.stall_after: {message}"),
272 })
273 })
274 .transpose()?
275 .unwrap_or(DEFAULT_STALL_AFTER_MS);
276 if stall_after_ms <= stall_report_after_ms {
277 return Err(ConfigError::Invalid {
278 path: repository.config_path.clone(),
279 message: "scheduler.stall_after must be greater than scheduler.stall_report_after"
280 .into(),
281 });
282 }
283
284 let running_hours = repository_scheduler
285 .and_then(|scheduler| scheduler.running_hours.clone())
286 .or_else(|| defaults.and_then(|scheduler| scheduler.running_hours.clone()))
287 .map(|hours| validate_running_hours(hours, &repository.config_path))
288 .transpose()?;
289
290 let agent = config
291 .agent
292 .as_ref()
293 .map(|agent| validate_agent(agent, &repository.config_path))
294 .transpose()?;
295
296 let flow_test_cmd = config
297 .flow
298 .as_ref()
299 .or_else(|| {
300 user.as_ref()
301 .and_then(|config| config.defaults.as_ref())
302 .and_then(|defaults| defaults.flow.as_ref())
303 })
304 .and_then(|flow| flow.test_cmd.clone());
305 if let Some(cmd) = &flow_test_cmd
306 && cmd.is_empty()
307 {
308 return Err(ConfigError::Invalid {
309 path: repository.config_path.clone(),
310 message: "flow.test_cmd must name a command".into(),
311 });
312 }
313
314 let ticket_prefix = config
315 .ids
316 .as_ref()
317 .and_then(|ids| ids.ticket_prefix.clone())
318 .unwrap_or_else(|| DEFAULT_TICKET_PREFIX.into());
319 validate_id_prefix("ids.ticket_prefix", &ticket_prefix, &repository.config_path)?;
320 let project_prefix = config
321 .ids
322 .as_ref()
323 .and_then(|ids| ids.project_prefix.clone())
324 .unwrap_or_else(|| DEFAULT_PROJECT_PREFIX.into());
325 validate_id_prefix(
326 "ids.project_prefix",
327 &project_prefix,
328 &repository.config_path,
329 )?;
330
331 let flows = load_flows(&repository.root)?;
332 validate_flow_targets(&flows, agent.as_ref(), &repository.config_path)?;
333 if flow_test_cmd.is_some()
334 && let Some(flow) = flows
335 .values()
336 .find(|flow| flow.stages.iter().any(|stage| stage.name == "test"))
337 {
338 return Err(ConfigError::Invalid {
339 path: repository.config_path.clone(),
340 message: format!(
341 "flow.test_cmd conflicts with stage `test` in flow `{}`",
342 flow.name
343 ),
344 });
345 }
346
347 let delete_missing_after_ms = config
348 .delete_missing_after
349 .as_deref()
350 .map(|value| {
351 parse_duration_ms(value).map_err(|message| ConfigError::Invalid {
352 path: repository.config_path.clone(),
353 message: format!("delete_missing_after: {message}"),
354 })
355 })
356 .transpose()?
357 .unwrap_or(DEFAULT_DELETE_MISSING_AFTER_MS);
358 let worktree_retention_ms = match config.worktree_retention.as_deref() {
359 Some("never") => None,
360 Some(value) => Some(parse_duration_ms(value).map_err(|message| {
361 ConfigError::Invalid {
362 path: repository.config_path.clone(),
363 message: format!(
364 "worktree_retention: {message}; use a positive duration such as 7d, 24h, or 90m, or use `never`"
365 ),
366 }
367 })?),
368 None => Some(DEFAULT_WORKTREE_RETENTION_MS),
369 };
370
371 Ok(Self {
372 worktree_dir,
373 worktree_retention_ms,
374 project_dir,
375 ticket_dir,
376 ticket_source,
377 max_parallel_tasks,
378 stall_report_after_ms,
379 stall_after_ms,
380 running_hours,
381 agent,
382 flows,
383 default_flow: DEFAULT_FLOW_NAME.into(),
384 flow_test_cmd,
385 ticket_prefix,
386 project_prefix,
387 delete_missing_after_ms,
388 })
389 }
390}
391
392fn validate_flow_targets(
402 flows: &BTreeMap<String, Flow>,
403 agent: Option<&AgentConfig>,
404 path: &Path,
405) -> Result<(), ConfigError> {
406 let known = |target: &String| agent.is_some_and(|agent| agent.targets.contains_key(target));
407 let invalid = |flow: &Flow, stage: &str, position: &str, target: &str| ConfigError::Invalid {
408 path: path.to_path_buf(),
409 message: format!(
410 "flow `{}` stage `{stage}` {position} names unknown agent target `{target}`",
411 flow.name
412 ),
413 };
414 for flow in flows.values() {
415 for stage in &flow.stages {
416 if let crate::flow::Check::Panel(panel) = &stage.result_check {
417 for reviewer in &panel.reviewers {
418 if !known(&reviewer.target) {
419 return Err(invalid(
420 flow,
421 &stage.name,
422 "panel reviewer",
423 &reviewer.target,
424 ));
425 }
426 }
427 }
428 }
429 }
430 Ok(())
431}
432
433fn load_flows(root: &Path) -> Result<BTreeMap<String, Flow>, ConfigError> {
434 let mut flows = BTreeMap::from([(DEFAULT_FLOW_NAME.into(), crate::flow::built_in_default())]);
435 for flow in MarkdownFlowSource::new(root)
436 .pull()
437 .map_err(ConfigError::Source)?
438 {
439 flows.insert(flow.name.clone(), flow);
440 }
441 Ok(flows)
442}
443
444fn validate_worktree_dir(value: &Path, path: &Path) -> Result<PathBuf, ConfigError> {
445 validate_repository_dir("worktree_dir", value, path)
446}
447
448fn validate_repository_dir(key: &str, value: &Path, path: &Path) -> Result<PathBuf, ConfigError> {
449 use std::path::Component;
450
451 if value.is_absolute() {
452 return Err(ConfigError::Invalid {
453 path: path.to_path_buf(),
454 message: format!("{key} must be repository-relative, not an absolute path"),
455 });
456 }
457
458 let mut normalized = PathBuf::new();
459 for component in value.components() {
460 match component {
461 Component::Normal(component) => normalized.push(component),
462 Component::CurDir => {}
463 Component::ParentDir => {
464 if !normalized.pop() {
465 return Err(ConfigError::Invalid {
466 path: path.to_path_buf(),
467 message: format!("{key} must not escape the repository root with `..`"),
468 });
469 }
470 }
471 Component::RootDir | Component::Prefix(_) => {
472 return Err(ConfigError::Invalid {
473 path: path.to_path_buf(),
474 message: format!("{key} must be repository-relative, not an absolute path"),
475 });
476 }
477 }
478 }
479 if normalized.as_os_str().is_empty() {
480 return Err(ConfigError::Invalid {
481 path: path.to_path_buf(),
482 message: format!("{key} must name a directory below the repository root"),
483 });
484 }
485 Ok(normalized)
486}
487
488fn validate_agent(agent: &RawAgent, path: &Path) -> Result<AgentConfig, ConfigError> {
489 if agent.cmd.is_some() {
490 return Err(ConfigError::Invalid {
491 path: path.to_path_buf(),
492 message: "agent.cmd has been removed; use `agent.default_target` and `agent.targets`"
493 .into(),
494 });
495 }
496 let default_target = agent
497 .default_target
498 .as_ref()
499 .ok_or_else(|| ConfigError::Invalid {
500 path: path.to_path_buf(),
501 message: "agent.default_target is required".into(),
502 })?;
503 let targets = agent.targets.as_ref().ok_or_else(|| ConfigError::Invalid {
504 path: path.to_path_buf(),
505 message: "agent.targets is required".into(),
506 })?;
507 if !targets.contains_key(default_target) {
508 return Err(ConfigError::Invalid {
509 path: path.to_path_buf(),
510 message: format!(
511 "agent.default_target `{default_target}` does not name an entry in agent.targets"
512 ),
513 });
514 }
515
516 let mut commands = BTreeMap::new();
517 for (name, target) in targets {
518 if target.cmd.is_empty() {
519 return Err(ConfigError::Invalid {
520 path: path.to_path_buf(),
521 message: format!("agent.targets.{name}.cmd must name a command"),
522 });
523 }
524 let prompt_count = target
525 .cmd
526 .iter()
527 .map(|argument| argument.matches("{prompt}").count())
528 .sum::<usize>();
529 if prompt_count != 1 {
530 return Err(ConfigError::Invalid {
531 path: path.to_path_buf(),
532 message: format!(
533 "agent.targets.{name}.cmd must contain `{{prompt}}` exactly once (found {prompt_count})"
534 ),
535 });
536 }
537 for (key, value) in [("model", &target.model), ("effort", &target.effort)] {
538 if let Some(value) = value
539 && value.trim().is_empty()
540 {
541 return Err(ConfigError::Invalid {
542 path: path.to_path_buf(),
543 message: format!("agent.targets.{name}.{key} must not be empty"),
544 });
545 }
546 }
547 commands.insert(
548 name.clone(),
549 AgentTarget {
550 cmd: target.cmd.clone(),
551 model: target.model.clone(),
552 effort: target.effort.clone(),
553 },
554 );
555 }
556 Ok(AgentConfig {
557 default_target: default_target.clone(),
558 targets: commands,
559 })
560}
561
562pub(crate) fn expand_agent_cmd(
563 target: &AgentTarget,
564 model: Option<&str>,
565 effort: Option<&str>,
566 prompt: &str,
567) -> Result<Vec<String>, String> {
568 let model = model.or(target.model.as_deref());
569 let effort = effort.or(target.effort.as_deref());
570 target
571 .cmd
572 .iter()
573 .map(|argument| {
574 let argument = match (argument.contains("{model}"), model) {
575 (true, Some(model)) => argument.replace("{model}", model),
576 (true, None) => return Err("does not specify `model`".to_owned()),
577 (false, _) => argument.clone(),
578 };
579 let argument = match (argument.contains("{effort}"), effort) {
580 (true, Some(effort)) => argument.replace("{effort}", effort),
581 (true, None) => return Err("does not specify `effort`".to_owned()),
582 (false, _) => argument,
583 };
584 Ok(argument.replace("{prompt}", prompt))
585 })
586 .collect()
587}
588
589fn validate_id_prefix(key: &str, prefix: &str, path: &Path) -> Result<(), ConfigError> {
590 if valid_prefix(prefix) {
591 return Ok(());
592 }
593 Err(ConfigError::Invalid {
594 path: path.to_path_buf(),
595 message: format!(
596 "{key} must be non-empty and contain only ASCII letters, digits, `-`, or `_`, with a letter or digit at each end"
597 ),
598 })
599}
600
601fn user_config_path() -> Option<PathBuf> {
602 env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/sloop/config.yaml"))
603}
604
605fn read_config(path: &Path) -> Result<RawConfig, ConfigError> {
606 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Io {
607 path: path.to_path_buf(),
608 source,
609 })?;
610 let config: RawConfig =
611 serde_yaml::from_str(&contents).map_err(|source| ConfigError::Invalid {
612 path: path.to_path_buf(),
613 message: source.to_string(),
614 })?;
615 if config.version != CONFIG_VERSION {
616 return Err(ConfigError::UnsupportedVersion {
617 path: path.to_path_buf(),
618 version: config.version,
619 });
620 }
621 Ok(config)
622}
623
624fn read_client_config(path: &Path) -> Result<RawClientConfig, ConfigError> {
625 let contents = fs::read_to_string(path).map_err(|source| ConfigError::Io {
626 path: path.to_path_buf(),
627 source,
628 })?;
629 let config: RawClientConfig =
630 serde_yaml::from_str(&contents).map_err(|source| ConfigError::Invalid {
631 path: path.to_path_buf(),
632 message: source.to_string(),
633 })?;
634 if config.version != CONFIG_VERSION {
635 return Err(ConfigError::UnsupportedVersion {
636 path: path.to_path_buf(),
637 version: config.version,
638 });
639 }
640 Ok(config)
641}
642
643fn validate_running_hours(
644 hours: RawRunningHours,
645 path: &Path,
646) -> Result<RunningHours, ConfigError> {
647 let start_minute = parse_local_time(&hours.start);
648 let end_minute = parse_local_time(&hours.end);
649 let (Some(start_minute), Some(end_minute)) = (start_minute, end_minute) else {
650 return Err(ConfigError::Invalid {
651 path: path.to_path_buf(),
652 message: "scheduler.running_hours values must use a valid HH:MM time".into(),
653 });
654 };
655 if start_minute == end_minute {
656 return Err(ConfigError::Invalid {
657 path: path.to_path_buf(),
658 message: "scheduler.running_hours start and end must differ".into(),
659 });
660 }
661 Ok(RunningHours {
662 start: hours.start,
663 end: hours.end,
664 start_minute,
665 end_minute,
666 })
667}
668
669pub(crate) fn parse_local_time(value: &str) -> Option<u16> {
670 let (hour, minute) = value.split_once(':')?;
671 if hour.len() != 2 || minute.len() != 2 {
672 return None;
673 }
674 if !hour.bytes().all(|byte| byte.is_ascii_digit())
675 || !minute.bytes().all(|byte| byte.is_ascii_digit())
676 {
677 return None;
678 }
679 let hour = hour.parse::<u16>().ok()?;
680 let minute = minute.parse::<u16>().ok()?;
681 (hour < 24 && minute < 60).then_some(hour * 60 + minute)
682}
683
684#[derive(Debug, Deserialize)]
685struct RawConfig {
686 version: u32,
687 worktree_dir: Option<PathBuf>,
688 worktree_retention: Option<String>,
689 project_dir: Option<PathBuf>,
690 ticket_dir: Option<PathBuf>,
691 defaults: Option<RawDefaults>,
692 scheduler: Option<RawScheduler>,
693 agent: Option<RawAgent>,
694 sources: Option<RawSources>,
695 flow: Option<RawFlowDefaults>,
696 ids: Option<RawIds>,
697 delete_missing_after: Option<String>,
698}
699
700#[derive(Debug, Deserialize)]
701struct RawClientConfig {
702 version: u32,
703 worktree_dir: Option<PathBuf>,
704}
705
706fn parse_duration_ms(value: &str) -> Result<i64, String> {
709 let value = value.trim();
710 let (digits, unit) = value.split_at(value.len().saturating_sub(1));
711 let scale: i64 = match unit {
712 "s" => 1000,
713 "m" => 60 * 1000,
714 "h" => 60 * 60 * 1000,
715 "d" => 24 * 60 * 60 * 1000,
716 "w" => 7 * 24 * 60 * 60 * 1000,
717 _ => {
718 return Err(format!(
719 "`{value}` must look like 30d, 12h, 30m, 45s, or 2w"
720 ));
721 }
722 };
723 let count: i64 = digits
724 .parse()
725 .map_err(|_| format!("`{value}` must look like 30d, 12h, 30m, 45s, or 2w"))?;
726 if count <= 0 {
727 return Err(format!("`{value}` must be a positive duration"));
728 }
729 count
730 .checked_mul(scale)
731 .ok_or_else(|| format!("`{value}` is too large"))
732}
733
734#[derive(Debug, Deserialize)]
735struct RawIds {
736 ticket_prefix: Option<String>,
737 project_prefix: Option<String>,
738}
739
740#[derive(Debug, Deserialize)]
741struct RawDefaults {
742 scheduler: Option<RawScheduler>,
743 agent: Option<RawAgent>,
744 sources: Option<RawSources>,
745 flow: Option<RawFlowDefaults>,
746}
747
748#[derive(Debug, Deserialize)]
749#[serde(deny_unknown_fields)]
750struct RawSources {
751 tickets: Option<RawTicketSource>,
752}
753
754#[derive(Debug, Deserialize)]
755#[serde(deny_unknown_fields)]
756struct RawTicketSource {
757 exec: Option<Vec<String>>,
758}
759
760#[derive(Debug, Deserialize)]
761struct RawFlowDefaults {
762 test_cmd: Option<Vec<String>>,
763}
764
765#[derive(Debug, Deserialize)]
766struct RawAgent {
767 default_target: Option<String>,
768 targets: Option<BTreeMap<String, RawAgentTarget>>,
769 cmd: Option<Vec<String>>,
770}
771
772#[derive(Debug, Deserialize)]
773struct RawAgentTarget {
774 cmd: Vec<String>,
775 model: Option<String>,
776 effort: Option<String>,
777}
778
779#[derive(Debug, Deserialize)]
780struct RawScheduler {
781 max_parallel_tasks: Option<usize>,
782 stall_report_after: Option<String>,
783 stall_after: Option<String>,
784 running_hours: Option<RawRunningHours>,
785}
786
787#[derive(Debug, Clone, Deserialize)]
788struct RawRunningHours {
789 start: String,
790 end: String,
791}
792
793#[derive(Debug)]
794pub enum ConfigError {
795 RepositoryNotFound(PathBuf),
796 Paths(crate::paths::PathError),
797 Source(crate::sources::SourceError),
798 Io {
799 path: PathBuf,
800 source: std::io::Error,
801 },
802 Invalid {
803 path: PathBuf,
804 message: String,
805 },
806 UnsupportedVersion {
807 path: PathBuf,
808 version: u32,
809 },
810}
811
812impl fmt::Display for ConfigError {
813 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
814 match self {
815 Self::RepositoryNotFound(start) => write!(
816 formatter,
817 "no .agents/sloop/config.yaml found from {}",
818 start.display()
819 ),
820 Self::Paths(error) => write!(formatter, "cannot resolve Sloop runtime paths: {error}"),
821 Self::Source(error) => error.fmt(formatter),
822 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
823 Self::Invalid { path, message } => write!(formatter, "{}: {message}", path.display()),
824 Self::UnsupportedVersion { path, version } => write!(
825 formatter,
826 "{}: unsupported config version {version}",
827 path.display()
828 ),
829 }
830 }
831}
832
833impl std::error::Error for ConfigError {}
834
835#[cfg(test)]
836mod tests {
837 use std::fs;
838 use std::future::Future;
839 use std::path::PathBuf;
840 use std::pin::Pin;
841
842 use tempfile::tempdir;
843
844 use super::{Config, ConfigError, Repository, RunningHours, TicketSourceConfig};
845 use crate::clock::Clock;
846
847 struct SpringForwardClock;
848
849 impl Clock for SpringForwardClock {
850 fn now_ms(&self) -> i64 {
851 30_000
852 }
853
854 fn local_minute(&self, timestamp_ms: i64) -> u16 {
855 if timestamp_ms < 60_000 { 119 } else { 180 }
856 }
857
858 fn sleep_until(&self, _deadline_ms: i64) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
859 Box::pin(std::future::pending())
860 }
861 }
862
863 #[test]
864 fn discovers_the_nearest_repository_from_a_nested_directory() {
865 let root = tempdir().unwrap();
866 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
867 fs::write(
868 root.path().join(".agents/sloop/config.yaml"),
869 "version: 1\n",
870 )
871 .unwrap();
872 let nested = root.path().join("src/deep");
873 fs::create_dir_all(&nested).unwrap();
874
875 let repository = Repository::discover(&nested).unwrap();
876 assert_eq!(repository.root, root.path().canonicalize().unwrap());
877 }
878
879 #[test]
880 fn repository_scheduler_values_are_loaded() {
881 let root = tempdir().unwrap();
882 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
883 fs::write(
884 root.path().join(".agents/sloop/config.yaml"),
885 concat!(
886 "version: 1\n",
887 "scheduler:\n",
888 " max_parallel_tasks: 3\n",
889 " stall_report_after: 7m\n",
890 " stall_after: 3h\n",
891 " running_hours:\n",
892 " start: '22:00'\n",
893 " end: '06:00'\n"
894 ),
895 )
896 .unwrap();
897
898 let repository = Repository::discover(root.path()).unwrap();
899 let config = Config::load(&repository).unwrap();
900 assert_eq!(config.max_parallel_tasks, 3);
901 assert_eq!(config.stall_report_after_ms, 7 * 60 * 1000);
902 assert_eq!(config.stall_after_ms, 3 * 60 * 60 * 1000);
903 assert_eq!(config.running_hours.unwrap().start, "22:00");
904 }
905
906 #[test]
907 fn stall_report_after_defaults_and_rejects_non_positive_durations() {
908 let root = tempdir().unwrap();
909 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
910 let path = root.path().join(".agents/sloop/config.yaml");
911 fs::write(&path, "version: 1\n").unwrap();
912 let repository = Repository::discover(root.path()).unwrap();
913 assert_eq!(
914 Config::load(&repository).unwrap().stall_report_after_ms,
915 10 * 60 * 1000
916 );
917 assert_eq!(
918 Config::load(&repository).unwrap().stall_after_ms,
919 2 * 60 * 60 * 1000
920 );
921
922 for duration in ["0m", "-1m"] {
923 fs::write(
924 &path,
925 format!("version: 1\nscheduler:\n stall_report_after: {duration}\n"),
926 )
927 .unwrap();
928 let error = Config::load(&repository).unwrap_err().to_string();
929 assert!(error.contains("scheduler.stall_report_after"), "{error}");
930 assert!(error.contains("positive duration"), "{error}");
931 }
932 }
933
934 #[test]
935 fn stall_after_must_be_positive_and_greater_than_the_report_threshold() {
936 let root = tempdir().unwrap();
937 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
938 let path = root.path().join(".agents/sloop/config.yaml");
939 fs::write(&path, "version: 1\n").unwrap();
940 let repository = Repository::discover(root.path()).unwrap();
941
942 for duration in ["0m", "-1m"] {
943 fs::write(
944 &path,
945 format!("version: 1\nscheduler:\n stall_after: {duration}\n"),
946 )
947 .unwrap();
948 let error = Config::load(&repository).unwrap_err().to_string();
949 assert!(error.contains("scheduler.stall_after"), "{error}");
950 assert!(error.contains("positive duration"), "{error}");
951 }
952
953 fs::write(
954 &path,
955 "version: 1\nscheduler:\n stall_report_after: 30m\n stall_after: 30m\n",
956 )
957 .unwrap();
958 let error = Config::load(&repository).unwrap_err().to_string();
959 assert!(
960 error.contains("stall_after must be greater than scheduler.stall_report_after"),
961 "{error}"
962 );
963 }
964
965 #[test]
966 fn worktree_dir_defaults_to_dot_worktrees() {
967 let root = tempdir().unwrap();
968 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
969 fs::write(
970 root.path().join(".agents/sloop/config.yaml"),
971 "version: 1\n",
972 )
973 .unwrap();
974
975 let repository = Repository::discover(root.path()).unwrap();
976 assert_eq!(
977 Config::load(&repository).unwrap().worktree_dir,
978 PathBuf::from(".worktrees")
979 );
980 }
981
982 #[test]
983 fn worktree_retention_defaults_to_seven_days_and_accepts_never() {
984 let root = tempdir().unwrap();
985 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
986 let path = root.path().join(".agents/sloop/config.yaml");
987 fs::write(&path, "version: 1\n").unwrap();
988 let repository = Repository::discover(root.path()).unwrap();
989 assert_eq!(
990 Config::load(&repository).unwrap().worktree_retention_ms,
991 Some(7 * 24 * 60 * 60 * 1000)
992 );
993
994 fs::write(&path, "version: 1\nworktree_retention: never\n").unwrap();
995 assert_eq!(
996 Config::load(&repository).unwrap().worktree_retention_ms,
997 None
998 );
999 fs::write(&path, "version: 1\nworktree_retention: 90m\n").unwrap();
1000 assert_eq!(
1001 Config::load(&repository).unwrap().worktree_retention_ms,
1002 Some(90 * 60 * 1000)
1003 );
1004 }
1005
1006 #[test]
1007 fn invalid_worktree_retention_names_the_key_and_remedy() {
1008 let root = tempdir().unwrap();
1009 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1010 fs::write(
1011 root.path().join(".agents/sloop/config.yaml"),
1012 "version: 1\nworktree_retention: tomorrow\n",
1013 )
1014 .unwrap();
1015 let repository = Repository::discover(root.path()).unwrap();
1016 let error = Config::load(&repository).unwrap_err().to_string();
1017 assert!(error.contains("worktree_retention"), "{error}");
1018 assert!(error.contains("use a positive duration"), "{error}");
1019 assert!(error.contains("`never`"), "{error}");
1020 }
1021
1022 #[test]
1023 fn content_directories_default_to_the_sloop_layout() {
1024 let root = tempdir().unwrap();
1025 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1026 fs::write(
1027 root.path().join(".agents/sloop/config.yaml"),
1028 "version: 1\n",
1029 )
1030 .unwrap();
1031
1032 let repository = Repository::discover(root.path()).unwrap();
1033 let config = Config::load(&repository).unwrap();
1034 assert_eq!(config.project_dir, PathBuf::from(".agents/sloop/projects"));
1035 assert_eq!(config.ticket_dir, PathBuf::from(".agents/sloop/tickets"));
1036 }
1037
1038 #[test]
1039 fn ticket_source_defaults_to_markdown() {
1040 let root = tempdir().unwrap();
1041 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1042 fs::write(
1043 root.path().join(".agents/sloop/config.yaml"),
1044 "version: 1\n",
1045 )
1046 .unwrap();
1047
1048 let repository = Repository::discover(root.path()).unwrap();
1049 assert_eq!(
1050 Config::load(&repository).unwrap().ticket_source,
1051 TicketSourceConfig::Markdown
1052 );
1053 }
1054
1055 #[test]
1056 fn exec_ticket_source_loads_from_repository_configuration() {
1057 let root = tempdir().unwrap();
1058 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1059 fs::write(
1060 root.path().join(".agents/sloop/config.yaml"),
1061 "version: 1\nsources:\n tickets:\n exec: [ticket-source, --repo, .]\n",
1062 )
1063 .unwrap();
1064
1065 let repository = Repository::discover(root.path()).unwrap();
1066 assert_eq!(
1067 Config::load(&repository).unwrap().ticket_source,
1068 TicketSourceConfig::Exec(vec!["ticket-source".into(), "--repo".into(), ".".into()])
1069 );
1070 }
1071
1072 #[test]
1073 fn exec_ticket_source_command_must_be_nonempty() {
1074 let root = tempdir().unwrap();
1075 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1076 fs::write(
1077 root.path().join(".agents/sloop/config.yaml"),
1078 "version: 1\nsources:\n tickets:\n exec: []\n",
1079 )
1080 .unwrap();
1081
1082 let repository = Repository::discover(root.path()).unwrap();
1083 let error = Config::load(&repository).unwrap_err().to_string();
1084 assert!(error.contains("sources.tickets.exec"), "{error}");
1085 assert!(error.contains("must name a command"), "{error}");
1086 }
1087
1088 #[test]
1089 fn content_directories_load_repository_relative_paths() {
1090 let root = tempdir().unwrap();
1091 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1092 fs::write(
1093 root.path().join(".agents/sloop/config.yaml"),
1094 "version: 1\nproject_dir: planning/projects\nticket_dir: planning/tickets\n",
1095 )
1096 .unwrap();
1097
1098 let repository = Repository::discover(root.path()).unwrap();
1099 let config = Config::load(&repository).unwrap();
1100 assert_eq!(config.project_dir, PathBuf::from("planning/projects"));
1101 assert_eq!(config.ticket_dir, PathBuf::from("planning/tickets"));
1102 }
1103
1104 #[test]
1105 fn content_directories_must_stay_below_the_repository_root() {
1106 let root = tempdir().unwrap();
1107 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1108 fs::write(
1109 root.path().join(".agents/sloop/config.yaml"),
1110 "version: 1\nproject_dir: /tmp/projects\nticket_dir: ../tickets\n",
1111 )
1112 .unwrap();
1113
1114 let repository = Repository::discover(root.path()).unwrap();
1115 let error = Config::load(&repository).unwrap_err().to_string();
1116 assert!(error.contains("project_dir"), "{error}");
1117 assert!(error.contains("repository-relative"), "{error}");
1118
1119 fs::write(
1120 root.path().join(".agents/sloop/config.yaml"),
1121 "version: 1\nproject_dir: planning/projects\nticket_dir: ../tickets\n",
1122 )
1123 .unwrap();
1124 let error = Config::load(&repository).unwrap_err().to_string();
1125 assert!(error.contains("ticket_dir"), "{error}");
1126 assert!(error.contains("repository root"), "{error}");
1127 }
1128
1129 #[test]
1130 fn worktree_dir_loads_a_repository_relative_path() {
1131 let root = tempdir().unwrap();
1132 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1133 fs::write(
1134 root.path().join(".agents/sloop/config.yaml"),
1135 "version: 1\nworktree_dir: build/agent-worktrees\n",
1136 )
1137 .unwrap();
1138
1139 let repository = Repository::discover(root.path()).unwrap();
1140 assert_eq!(
1141 Config::load(&repository).unwrap().worktree_dir,
1142 PathBuf::from("build/agent-worktrees")
1143 );
1144 }
1145
1146 #[test]
1147 fn worktree_dir_rejects_an_absolute_path() {
1148 let root = tempdir().unwrap();
1149 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1150 fs::write(
1151 root.path().join(".agents/sloop/config.yaml"),
1152 "version: 1\nworktree_dir: /tmp/sloop-worktrees\n",
1153 )
1154 .unwrap();
1155
1156 let repository = Repository::discover(root.path()).unwrap();
1157 let error = Config::load(&repository).unwrap_err().to_string();
1158 assert!(error.contains("worktree_dir"), "{error}");
1159 assert!(error.contains("repository-relative"), "{error}");
1160 }
1161
1162 #[test]
1163 fn worktree_dir_rejects_parent_traversal_outside_the_repository() {
1164 let root = tempdir().unwrap();
1165 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1166 fs::write(
1167 root.path().join(".agents/sloop/config.yaml"),
1168 "version: 1\nworktree_dir: ../sloop-worktrees\n",
1169 )
1170 .unwrap();
1171
1172 let repository = Repository::discover(root.path()).unwrap();
1173 let error = Config::load(&repository).unwrap_err().to_string();
1174 assert!(error.contains("worktree_dir"), "{error}");
1175 assert!(error.contains("repository root"), "{error}");
1176 }
1177
1178 #[test]
1179 fn repository_agent_loads_multiple_named_targets() {
1180 let root = tempdir().unwrap();
1181 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1182 fs::write(
1183 root.path().join(".agents/sloop/config.yaml"),
1184 concat!(
1185 "version: 1\n",
1186 "agent:\n",
1187 " default_target: claude\n",
1188 " targets:\n",
1189 " claude:\n",
1190 " cmd: [claude, --model, '{model}', '{prompt}']\n",
1191 " codex:\n",
1192 " cmd: [codex, exec, --model, '{model}', '{prompt}']\n",
1193 ),
1194 )
1195 .unwrap();
1196
1197 let repository = Repository::discover(root.path()).unwrap();
1198 let agent = Config::load(&repository).unwrap().agent.unwrap();
1199 assert_eq!(agent.default_target, "claude");
1200 assert_eq!(agent.targets.len(), 2);
1201 assert_eq!(agent.targets["codex"].cmd[0], "codex");
1202 }
1203
1204 #[test]
1205 fn agent_targets_load_default_model_and_effort() {
1206 let root = tempdir().unwrap();
1207 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1208 fs::write(
1209 root.path().join(".agents/sloop/config.yaml"),
1210 concat!(
1211 "version: 1\n",
1212 "agent:\n",
1213 " default_target: claude\n",
1214 " targets:\n",
1215 " claude:\n",
1216 " model: opus\n",
1217 " effort: high\n",
1218 " cmd: [claude, --model, '{model}', --effort, '{effort}', '{prompt}']\n",
1219 ),
1220 )
1221 .unwrap();
1222
1223 let repository = Repository::discover(root.path()).unwrap();
1224 let agent = Config::load(&repository).unwrap().agent.unwrap();
1225 assert_eq!(agent.targets["claude"].model.as_deref(), Some("opus"));
1226 assert_eq!(agent.targets["claude"].effort.as_deref(), Some("high"));
1227 }
1228
1229 #[test]
1230 fn agent_target_rejects_a_blank_default_model() {
1231 let root = tempdir().unwrap();
1232 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1233 fs::write(
1234 root.path().join(".agents/sloop/config.yaml"),
1235 concat!(
1236 "version: 1\n",
1237 "agent:\n",
1238 " default_target: claude\n",
1239 " targets:\n",
1240 " claude:\n",
1241 " model: ' '\n",
1242 " cmd: [claude, --model, '{model}', '{prompt}']\n",
1243 ),
1244 )
1245 .unwrap();
1246
1247 let repository = Repository::discover(root.path()).unwrap();
1248 let error = Config::load(&repository).unwrap_err().to_string();
1249 assert!(error.contains("agent.targets.claude.model"), "{error}");
1250 }
1251
1252 #[test]
1253 fn agent_default_target_must_name_a_configured_target() {
1254 let root = tempdir().unwrap();
1255 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1256 fs::write(
1257 root.path().join(".agents/sloop/config.yaml"),
1258 "version: 1\nagent:\n default_target: missing\n targets:\n fake:\n cmd: [fake]\n",
1259 )
1260 .unwrap();
1261
1262 let repository = Repository::discover(root.path()).unwrap();
1263 let error = Config::load(&repository).unwrap_err().to_string();
1264 assert!(error.contains("agent.default_target `missing`"), "{error}");
1265 assert!(error.contains("agent.targets"), "{error}");
1266 }
1267
1268 #[test]
1269 fn client_essentials_ignore_daemon_only_configuration_validation() {
1270 let root = tempdir().unwrap();
1271 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1272 fs::write(
1273 root.path().join(".agents/sloop/config.yaml"),
1274 "version: 1\nagent:\n default_target: missing\n targets: not-a-map\n",
1275 )
1276 .unwrap();
1277
1278 let repository = Repository::discover(root.path()).unwrap();
1279 Config::validate_client_essentials(&repository).unwrap();
1280 assert!(Config::load(&repository).is_err());
1281 }
1282
1283 #[test]
1284 fn every_agent_target_command_must_be_nonempty() {
1285 let root = tempdir().unwrap();
1286 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1287 fs::write(
1288 root.path().join(".agents/sloop/config.yaml"),
1289 "version: 1\nagent:\n default_target: fake\n targets:\n fake:\n cmd: []\n",
1290 )
1291 .unwrap();
1292
1293 let repository = Repository::discover(root.path()).unwrap();
1294 let error = Config::load(&repository).unwrap_err().to_string();
1295 assert!(error.contains("agent.targets.fake.cmd"), "{error}");
1296 assert!(error.contains("must name a command"), "{error}");
1297 }
1298
1299 #[test]
1300 fn every_agent_target_requires_prompt_exactly_once() {
1301 let root = tempdir().unwrap();
1302 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1303 let config_path = root.path().join(".agents/sloop/config.yaml");
1304 fs::write(
1305 &config_path,
1306 "version: 1\nagent:\n default_target: missing_prompt\n targets:\n missing_prompt:\n cmd: [agent]\n",
1307 )
1308 .unwrap();
1309
1310 let repository = Repository::discover(root.path()).unwrap();
1311 let error = Config::load(&repository).unwrap_err().to_string();
1312 assert!(
1313 error.contains("agent.targets.missing_prompt.cmd"),
1314 "{error}"
1315 );
1316 assert!(error.contains("`{prompt}` exactly once"), "{error}");
1317 assert!(error.contains("found 0"), "{error}");
1318
1319 fs::write(
1320 config_path,
1321 "version: 1\nagent:\n default_target: duplicate_prompt\n targets:\n duplicate_prompt:\n cmd: [agent, '{prompt}', 'again={prompt}']\n",
1322 )
1323 .unwrap();
1324 let error = Config::load(&repository).unwrap_err().to_string();
1325 assert!(
1326 error.contains("agent.targets.duplicate_prompt.cmd"),
1327 "{error}"
1328 );
1329 assert!(error.contains("`{prompt}` exactly once"), "{error}");
1330 assert!(error.contains("found 2"), "{error}");
1331 }
1332
1333 #[test]
1334 fn legacy_singular_agent_command_names_the_new_shape() {
1335 let root = tempdir().unwrap();
1336 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1337 fs::write(
1338 root.path().join(".agents/sloop/config.yaml"),
1339 "version: 1\nagent:\n cmd: [fake]\n",
1340 )
1341 .unwrap();
1342
1343 let repository = Repository::discover(root.path()).unwrap();
1344 let error = Config::load(&repository).unwrap_err().to_string();
1345 assert!(error.contains("agent.cmd has been removed"), "{error}");
1346 assert!(error.contains("agent.default_target"), "{error}");
1347 assert!(error.contains("agent.targets"), "{error}");
1348 }
1349
1350 #[test]
1351 fn id_prefixes_default_and_load_from_repository_configuration() {
1352 let root = tempdir().unwrap();
1353 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1354 fs::write(
1355 root.path().join(".agents/sloop/config.yaml"),
1356 "version: 1\n",
1357 )
1358 .unwrap();
1359 let repository = Repository::discover(root.path()).unwrap();
1360 let defaults = Config::load(&repository).unwrap();
1361 assert_eq!(defaults.ticket_prefix, "TICK");
1362 assert_eq!(defaults.project_prefix, "PROJ");
1363
1364 fs::write(
1365 root.path().join(".agents/sloop/config.yaml"),
1366 "version: 1\nids:\n ticket_prefix: WORK\n project_prefix: TEAM\n",
1367 )
1368 .unwrap();
1369 let configured = Config::load(&repository).unwrap();
1370 assert_eq!(configured.ticket_prefix, "WORK");
1371 assert_eq!(configured.project_prefix, "TEAM");
1372 }
1373
1374 #[test]
1375 fn invalid_id_prefixes_are_clear_configuration_errors() {
1376 let root = tempdir().unwrap();
1377 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1378 fs::write(
1379 root.path().join(".agents/sloop/config.yaml"),
1380 "version: 1\nids:\n ticket_prefix: ''\n",
1381 )
1382 .unwrap();
1383
1384 let repository = Repository::discover(root.path()).unwrap();
1385 let error = Config::load(&repository).unwrap_err().to_string();
1386 assert!(error.contains("ids.ticket_prefix"), "{error}");
1387 assert!(error.contains("non-empty"), "{error}");
1388 }
1389
1390 #[test]
1391 fn running_hours_are_start_inclusive_and_end_exclusive() {
1392 let root = tempdir().unwrap();
1393 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1394 fs::write(
1395 root.path().join(".agents/sloop/config.yaml"),
1396 "version: 1\nscheduler:\n running_hours:\n start: '09:00'\n end: '17:00'\n",
1397 )
1398 .unwrap();
1399
1400 let repository = Repository::discover(root.path()).unwrap();
1401 let hours = Config::load(&repository).unwrap().running_hours.unwrap();
1402 assert!(!hours.is_open(8 * 60 + 59));
1403 assert!(hours.is_open(9 * 60));
1404 assert!(hours.is_open(16 * 60 + 59));
1405 assert!(!hours.is_open(17 * 60));
1406 }
1407
1408 #[test]
1409 fn running_hours_may_cross_midnight() {
1410 let root = tempdir().unwrap();
1411 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1412 fs::write(
1413 root.path().join(".agents/sloop/config.yaml"),
1414 "version: 1\nscheduler:\n running_hours:\n start: '22:00'\n end: '06:00'\n",
1415 )
1416 .unwrap();
1417
1418 let repository = Repository::discover(root.path()).unwrap();
1419 let hours = Config::load(&repository).unwrap().running_hours.unwrap();
1420 assert!(hours.is_open(23 * 60));
1421 assert!(hours.is_open(5 * 60 + 59));
1422 assert!(!hours.is_open(6 * 60));
1423 assert!(!hours.is_open(12 * 60));
1424 }
1425
1426 #[test]
1427 fn equal_running_hour_boundaries_are_rejected() {
1428 let root = tempdir().unwrap();
1429 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1430 fs::write(
1431 root.path().join(".agents/sloop/config.yaml"),
1432 "version: 1\nscheduler:\n running_hours:\n start: '09:00'\n end: '09:00'\n",
1433 )
1434 .unwrap();
1435
1436 let repository = Repository::discover(root.path()).unwrap();
1437 assert!(matches!(
1438 Config::load(&repository),
1439 Err(ConfigError::Invalid { .. })
1440 ));
1441 }
1442
1443 #[test]
1444 fn next_opening_uses_the_first_open_instant_after_a_dst_skip() {
1445 let hours = RunningHours {
1446 start: "02:30".into(),
1447 end: "04:00".into(),
1448 start_minute: 150,
1449 end_minute: 240,
1450 };
1451
1452 assert_eq!(
1453 hours.next_opening_ms(&SpringForwardClock, SpringForwardClock.now_ms()),
1454 60_000
1455 );
1456 }
1457
1458 #[test]
1459 fn unsupported_versions_are_rejected() {
1460 let root = tempdir().unwrap();
1461 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1462 fs::write(
1463 root.path().join(".agents/sloop/config.yaml"),
1464 "version: 2\n",
1465 )
1466 .unwrap();
1467
1468 let repository = Repository::discover(root.path()).unwrap();
1469 assert!(matches!(
1470 Config::load(&repository),
1471 Err(ConfigError::UnsupportedVersion { version: 2, .. })
1472 ));
1473 }
1474
1475 #[test]
1476 fn durations_parse_with_single_letter_units() {
1477 use super::parse_duration_ms;
1478 assert_eq!(parse_duration_ms("45s").unwrap(), 45_000);
1479 assert_eq!(parse_duration_ms("30m").unwrap(), 1_800_000);
1480 assert_eq!(parse_duration_ms("12h").unwrap(), 43_200_000);
1481 assert_eq!(parse_duration_ms("30d").unwrap(), 2_592_000_000);
1482 assert_eq!(parse_duration_ms("2w").unwrap(), 1_209_600_000);
1483 for invalid in ["", "30", "d", "0d", "-1d", "1.5h", "30 days"] {
1484 assert!(parse_duration_ms(invalid).is_err(), "{invalid}");
1485 }
1486 }
1487
1488 #[test]
1489 fn delete_missing_after_is_configurable_and_defaults_to_a_month() {
1490 let root = tempdir().unwrap();
1491 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1492 let config_path = root.path().join(".agents/sloop/config.yaml");
1493
1494 fs::write(&config_path, "version: 1\n").unwrap();
1495 let repository = Repository::discover(root.path()).unwrap();
1496 let config = Config::load(&repository).unwrap();
1497 assert_eq!(
1498 config.delete_missing_after_ms,
1499 super::DEFAULT_DELETE_MISSING_AFTER_MS
1500 );
1501
1502 fs::write(&config_path, "version: 1\ndelete_missing_after: 7d\n").unwrap();
1503 let config = Config::load(&repository).unwrap();
1504 assert_eq!(config.delete_missing_after_ms, 7 * 24 * 60 * 60 * 1000);
1505
1506 fs::write(&config_path, "version: 1\ndelete_missing_after: soon\n").unwrap();
1507 let error = Config::load(&repository).unwrap_err();
1508 assert!(
1509 error.to_string().contains("delete_missing_after"),
1510 "{error}"
1511 );
1512 }
1513
1514 #[test]
1515 fn built_in_default_flow_does_not_reuse_agent_placeholders() {
1516 let root = tempdir().unwrap();
1517 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1518 fs::write(
1519 root.path().join(".agents/sloop/config.yaml"),
1520 "version: 1\nagent:\n default_target: reviewer\n targets:\n reviewer:\n cmd: [review-agent, --model, '{model}', --effort, '{effort}', '{prompt}']\n",
1521 )
1522 .unwrap();
1523
1524 let repository = Repository::discover(root.path()).unwrap();
1525 let config = Config::load(&repository).unwrap();
1526 assert_eq!(config.default_flow, "default");
1527 assert_eq!(
1528 config.flows["default"]
1529 .stages
1530 .iter()
1531 .map(|stage| stage.name.as_str())
1532 .collect::<Vec<_>>(),
1533 ["build", "merge"]
1534 );
1535 }
1536
1537 #[test]
1538 fn flow_test_command_is_not_duplicated_in_the_built_in_default_flow() {
1539 let root = tempdir().unwrap();
1540 fs::create_dir_all(root.path().join(".agents/sloop")).unwrap();
1541 fs::write(
1542 root.path().join(".agents/sloop/config.yaml"),
1543 "version: 1\nflow:\n test_cmd: [cargo, test]\n",
1544 )
1545 .unwrap();
1546
1547 let repository = Repository::discover(root.path()).unwrap();
1548 let config = Config::load(&repository).unwrap();
1549 let flow = &config.flows["default"];
1550 assert_eq!(
1551 flow.stages
1552 .iter()
1553 .map(|stage| stage.name.as_str())
1554 .collect::<Vec<_>>(),
1555 ["build", "merge"]
1556 );
1557 }
1558
1559 #[test]
1560 fn committed_default_flow_overrides_the_built_in_flow() {
1561 let root = tempdir().unwrap();
1562 fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
1563 fs::write(
1564 root.path().join(".agents/sloop/config.yaml"),
1565 "version: 1\nflow:\n test_cmd: [cargo, test]\n",
1566 )
1567 .unwrap();
1568 fs::write(
1569 root.path().join(".agents/sloop/flows/default.yaml"),
1570 "- { name: build, action: agent }\n- { name: ship, action: { exec: [ship] } }\n",
1571 )
1572 .unwrap();
1573
1574 let repository = Repository::discover(root.path()).unwrap();
1575 let config = Config::load(&repository).unwrap();
1576 assert_eq!(
1577 config.flows["default"]
1578 .stages
1579 .iter()
1580 .map(|stage| stage.name.as_str())
1581 .collect::<Vec<_>>(),
1582 ["build", "ship"]
1583 );
1584 }
1585
1586 #[test]
1591 fn panel_reviewers_must_name_configured_agent_targets() {
1592 let root = tempdir().unwrap();
1593 fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
1594 fs::write(
1595 root.path().join(".agents/sloop/config.yaml"),
1596 "version: 1\nagent:\n default_target: fake\n targets:\n fake:\n cmd: [fake, '{prompt}']\n",
1597 )
1598 .unwrap();
1599 fs::write(
1600 root.path().join(".agents/sloop/flows/default.yaml"),
1601 "- name: build\n action: agent\n result_check:\n panel:\n prompt: prompts/review.md\n reviewers: [{ target: fake }, { target: ghost }]\n require: { quorum: 1 }\n",
1602 )
1603 .unwrap();
1604
1605 let repository = Repository::discover(root.path()).unwrap();
1606 let error = Config::load(&repository).unwrap_err().to_string();
1607 assert!(error.contains("stage `build`"), "{error}");
1608 assert!(error.contains("panel reviewer"), "{error}");
1609 assert!(error.contains("unknown agent target `ghost`"), "{error}");
1610 }
1611
1612 #[test]
1613 fn invalid_flow_error_names_the_file_and_problem() {
1614 let root = tempdir().unwrap();
1615 fs::create_dir_all(root.path().join(".agents/sloop/flows")).unwrap();
1616 fs::write(
1617 root.path().join(".agents/sloop/config.yaml"),
1618 "version: 1\n",
1619 )
1620 .unwrap();
1621 fs::write(
1622 root.path().join(".agents/sloop/flows/broken.yaml"),
1623 "- { name: build, action: agent }\n- { name: check, action: { exec: [] } }\n",
1624 )
1625 .unwrap();
1626
1627 let repository = Repository::discover(root.path()).unwrap();
1628 let error = Config::load(&repository).unwrap_err().to_string();
1629 assert!(error.contains("broken.yaml"), "{error}");
1630 assert!(error.contains("non-empty command"), "{error}");
1631 }
1632}