atagen

roundabout

public
Star0
Watch0

Commit 9117440444eb

tarball

atagen <boss@atagen.co>2026-10-26 02:57 UTC

4 files changed+152-2
add .resrc support
Modified file: README.md+12 additions
@@ -73,6 +73,18 @@ restart omp after linking. install `.#roundabout` on PATH for the agent's bash t
7373
7474 `~/.config/roundabout/config.toml`, falling back to `/etc/roundabout/config.toml`, or `$ROUNDABOUT_CONFIG` over both. the daemon picks up edits on save, on linux anyway. a broken config gets complained about and ignored rather than taking every agent down with it. everything's optional, these are the defaults
7575
76+project settings can live in a hidden `.resrc` file. each client command starts in its current directory and walks upward until it reaches the git root. the first `.resrc` it finds wins, so a nested file takes precedence over one at the repository root. directories outside a git worktree do not load a project file. `.resrc` uses the same TOML settings, and omitted values keep the user or system settings
77+
78+```toml
79+# .resrc
80+[budget]
81+ram = "16GiB"
82+cores = 8
83+
84+[artifacts]
85+project_cap = "20GiB"
86+```
87+
7688 ```toml
7789 [budget]
7890 ram = "75%" # of total, or "24GiB"
Modified file: src/config.rs+67 additions
@@ -349,6 +349,33 @@ impl Config {
349349 }
350350 }
351351
352+ pub fn load_for(cwd: &std::path::Path) -> Result<Self, String> {
353+ let global = paths::config_file();
354+ let mut base = match fs::read_to_string(&global) {
355+ Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", global.display()))?,
356+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
357+ toml::Value::Table(toml::map::Map::new())
358+ }
359+ Err(e) => return Err(format!("{}: {e}", global.display())),
360+ };
361+ if let Some(path) = paths::project_config_file(cwd) {
362+ let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
363+ let overlay: toml::Value =
364+ toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
365+ if !overlay.is_table() {
366+ return Err(format!("{}: expected a TOML table", path.display()));
367+ }
368+ merge_values(&mut base, overlay);
369+ }
370+ let mut config: Self = base
371+ .try_into()
372+ .map_err(|e| format!("{}: {e}", global.display()))?;
373+ for (name, preset) in Self::default().sizes {
374+ config.sizes.entry(name).or_insert(preset);
375+ }
376+ Ok(config)
377+ }
378+
352379 pub fn parse(text: &str) -> Result<Self, toml::de::Error> {
353380 let mut config: Self = toml::from_str(text)?;
354381 for (name, preset) in Self::default().sizes {
@@ -382,3 +409,43 @@ impl Config {
382409 .map(String::as_str)
383410 }
384411 }
412+
413+fn merge_values(base: &mut toml::Value, overlay: toml::Value) {
414+ match overlay {
415+ toml::Value::Table(overlay) => {
416+ if let toml::Value::Table(base) = base {
417+ for (key, value) in overlay {
418+ if let Some(existing) = base.get_mut(&key) {
419+ merge_values(existing, value);
420+ } else {
421+ base.insert(key, value);
422+ }
423+ }
424+ } else {
425+ *base = toml::Value::Table(overlay);
426+ }
427+ }
428+ value => *base = value,
429+ }
430+}
431+
432+#[cfg(test)]
433+mod tests {
434+ use super::{Config, merge_values};
435+ use crate::units::Bytes;
436+
437+ #[test]
438+ fn project_values_override_individual_defaults() {
439+ let mut base: toml::Value = toml::from_str(
440+ "[budget]\nram = \"60%\"\nram_reserve = \"2GiB\"\n[queue]\nelastic = false\n",
441+ )
442+ .expect("parse global config");
443+ let project: toml::Value =
444+ toml::from_str("[budget]\nram_reserve = \"8GiB\"\n").expect("parse project config");
445+ merge_values(&mut base, project);
446+ let config: Config = base.try_into().expect("decode merged config");
447+ assert_eq!(config.budget.ram_reserve, Bytes::gib(8));
448+ assert_eq!(config.budget.ram, "60%".parse().expect("parse amount"));
449+ assert!(!config.queue.elastic);
450+ }
451+}
Modified file: src/main.rs+9 additions−2 deletions
@@ -24,9 +24,16 @@ fn main() {
2424 }
2525 // a typo must not exit 2, that's a blocking pretooluse verdict and would
2626 // wall off every bash call in every session
27- let cfg = config::Config::load().unwrap_or_else(|e| {
27+ let rabt = cli::Rabt::parse();
28+ let cfg = if matches!(&rabt, cli::Rabt::Daemon) {
29+ config::Config::load()
30+ } else {
31+ let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
32+ config::Config::load_for(&cwd)
33+ }
34+ .unwrap_or_else(|e| {
2835 eprintln!("roundabout: bad config, using defaults. {e}");
2936 config::Config::default()
3037 });
31- std::process::exit(cli::dispatch(&cfg, cli::Rabt::parse()));
38+ std::process::exit(cli::dispatch(&cfg, rabt));
3239 }
Modified file: src/paths.rs+64 additions
@@ -67,6 +67,25 @@ pub fn spawn_lock() -> PathBuf {
6767 runtime_dir().join("roundabout.lock")
6868 }
6969
70+pub fn git_root(cwd: &Path) -> Option<PathBuf> {
71+ cwd.ancestors()
72+ .find(|dir| dir.join(".git").exists())
73+ .map(Path::to_path_buf)
74+}
75+
76+pub fn project_config_file(cwd: &Path) -> Option<PathBuf> {
77+ let root = git_root(cwd)?;
78+ for dir in cwd.ancestors() {
79+ if dir.join(".resrc").is_file() {
80+ return Some(dir.join(".resrc"));
81+ }
82+ if dir == root {
83+ break;
84+ }
85+ }
86+ None
87+}
88+
7089 pub fn project_root(cwd: &Path) -> PathBuf {
7190 const MARKERS: [&str; 6] = [
7291 ".jj",
@@ -87,3 +106,48 @@ pub fn project_root(cwd: &Path) -> PathBuf {
87106 }
88107 best.unwrap_or_else(|| cwd.to_path_buf())
89108 }
109+
110+#[cfg(test)]
111+mod tests {
112+ use std::{
113+ fs,
114+ path::PathBuf,
115+ time::{SystemTime, UNIX_EPOCH},
116+ };
117+
118+ use super::project_config_file;
119+
120+ fn temp_tree() -> PathBuf {
121+ let stamp = SystemTime::now()
122+ .duration_since(UNIX_EPOCH)
123+ .expect("clock before epoch")
124+ .as_nanos();
125+ let root = std::env::temp_dir().join(format!("roundabout-paths-{stamp}"));
126+ fs::create_dir_all(&root).expect("create temporary tree");
127+ root
128+ }
129+
130+ #[test]
131+ fn project_config_is_nearest_and_git_bounded() {
132+ let outer = temp_tree();
133+ let root = outer.join("repo");
134+ fs::create_dir(&root).expect("create repository tree");
135+ let child = root.join("child");
136+ let nested = child.join("nested");
137+ fs::create_dir_all(&nested).expect("create nested tree");
138+ fs::create_dir(root.join(".git")).expect("create git marker");
139+ fs::write(root.join(".resrc"), "").expect("write root config");
140+ fs::write(child.join(".resrc"), "").expect("write child config");
141+ fs::write(nested.join(".resrc"), "").expect("write nested config");
142+ assert_eq!(project_config_file(&nested), Some(nested.join(".resrc")));
143+ fs::remove_file(nested.join(".resrc")).expect("remove nested config");
144+ assert_eq!(project_config_file(&nested), Some(child.join(".resrc")));
145+ fs::remove_file(child.join(".resrc")).expect("remove child config");
146+ assert_eq!(project_config_file(&nested), Some(root.join(".resrc")));
147+ fs::remove_file(root.join(".resrc")).expect("remove root config");
148+ fs::write(outer.join(".resrc"), "").expect("write outside config");
149+ assert_eq!(project_config_file(&nested), None);
150+ fs::remove_file(outer.join(".resrc")).expect("remove outside config");
151+ fs::remove_dir_all(outer).expect("remove temporary tree");
152+ }
153+}