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