Skip to main content

roboticus_core/config/
agent_paths.rs

1/// Controls orchestrator autonomy during subagent composition.
2#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3#[serde(rename_all = "snake_case")]
4pub enum CompositionPolicy {
5    Autonomous,
6    #[default]
7    Propose,
8    Manual,
9}
10
11/// Controls validation rigor when autonomously creating skills.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
13#[serde(rename_all = "snake_case")]
14pub enum SkillCreationRigor {
15    Generate,
16    Validate,
17    #[default]
18    Full,
19}
20
21/// Controls delegation output quality evaluation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
23#[serde(rename_all = "snake_case")]
24pub enum OutputValidationPolicy {
25    #[default]
26    Strict,
27    Sample,
28    Off,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct AgentConfig {
33    pub name: String,
34    pub id: String,
35    #[serde(default = "default_workspace")]
36    pub workspace: PathBuf,
37    #[serde(default = "default_log_level")]
38    pub log_level: String,
39    #[serde(default = "default_true")]
40    pub delegation_enabled: bool,
41    #[serde(default = "default_min_decomposition_complexity")]
42    pub delegation_min_complexity: f64,
43    #[serde(default = "default_min_delegation_utility_margin")]
44    pub delegation_min_utility_margin: f64,
45    #[serde(default = "default_true")]
46    pub specialist_creation_requires_approval: bool,
47    #[serde(default = "default_autonomy_max_react_turns")]
48    pub autonomy_max_react_turns: usize,
49    #[serde(default = "default_autonomy_max_turn_duration_seconds")]
50    pub autonomy_max_turn_duration_seconds: u64,
51    #[serde(default)]
52    pub composition_policy: CompositionPolicy,
53    #[serde(default)]
54    pub skill_creation_rigor: SkillCreationRigor,
55    #[serde(default)]
56    pub output_validation_policy: OutputValidationPolicy,
57    #[serde(default = "default_output_validation_sample_rate")]
58    pub output_validation_sample_rate: f64,
59    #[serde(default = "default_max_output_retries")]
60    pub max_output_retries: u32,
61    #[serde(default = "default_retirement_threshold")]
62    pub retirement_success_threshold: f64,
63    #[serde(default = "default_retirement_min_delegations")]
64    pub retirement_min_delegations: i64,
65}
66
67fn default_workspace() -> PathBuf {
68    dirs_next().join("workspace")
69}
70
71/// Public accessor for the default workspace path (`~/.roboticus/workspace`).
72pub fn default_workspace_path() -> PathBuf {
73    default_workspace()
74}
75
76fn default_log_level() -> String {
77    "info".into()
78}
79
80fn default_min_decomposition_complexity() -> f64 {
81    0.35
82}
83
84fn default_min_delegation_utility_margin() -> f64 {
85    0.15
86}
87
88fn default_autonomy_max_react_turns() -> usize {
89    10
90}
91
92fn default_autonomy_max_turn_duration_seconds() -> u64 {
93    90
94}
95
96fn default_output_validation_sample_rate() -> f64 {
97    0.25
98}
99
100fn default_max_output_retries() -> u32 {
101    2
102}
103
104fn default_retirement_threshold() -> f64 {
105    0.3
106}
107
108fn default_retirement_min_delegations() -> i64 {
109    5
110}
111
112impl Default for AgentConfig {
113    fn default() -> Self {
114        Self {
115            name: String::new(),
116            id: String::new(),
117            workspace: default_workspace(),
118            log_level: default_log_level(),
119            delegation_enabled: true,
120            delegation_min_complexity: default_min_decomposition_complexity(),
121            delegation_min_utility_margin: default_min_delegation_utility_margin(),
122            specialist_creation_requires_approval: true,
123            autonomy_max_react_turns: default_autonomy_max_react_turns(),
124            autonomy_max_turn_duration_seconds: default_autonomy_max_turn_duration_seconds(),
125            composition_policy: CompositionPolicy::default(),
126            skill_creation_rigor: SkillCreationRigor::default(),
127            output_validation_policy: OutputValidationPolicy::default(),
128            output_validation_sample_rate: default_output_validation_sample_rate(),
129            max_output_retries: default_max_output_retries(),
130            retirement_success_threshold: default_retirement_threshold(),
131            retirement_min_delegations: default_retirement_min_delegations(),
132        }
133    }
134}
135
136fn default_log_dir() -> PathBuf {
137    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
138    PathBuf::from(home).join(".roboticus").join("logs")
139}
140
141fn default_log_max_days() -> u32 {
142    7
143}
144
145fn dirs_next() -> PathBuf {
146    let home = home_dir();
147    let new_dir = home.join(".roboticus");
148    migrate_legacy_data_dir(&home, &new_dir);
149    new_dir
150}
151
152/// One-shot migration from `~/.ironclad` → `~/.roboticus`.
153///
154/// Handles:
155///  1. Atomic rename (same filesystem) with copy+delete fallback (cross-filesystem).
156///  2. Renames `ironclad.toml` → `roboticus.toml` inside the migrated directory.
157///  3. Rewrites internal paths (`/.ironclad/` → `/.roboticus/`) in the config TOML.
158///  4. Warns (but does not clobber) if both directories already exist.
159fn migrate_legacy_data_dir(home: &Path, new_dir: &Path) {
160    use std::sync::Once;
161    static MIGRATE_ONCE: Once = Once::new();
162
163    MIGRATE_ONCE.call_once(|| {
164        let legacy = home.join(".ironclad");
165
166        // Check for sentinel from a previous partial migration (copy succeeded, delete failed).
167        let sentinel = new_dir.join(".migration_pending_delete");
168        if sentinel.exists()
169            && let Ok(source) = std::fs::read_to_string(&sentinel)
170        {
171            let source_path = Path::new(source.trim());
172            if source_path.exists() {
173                match std::fs::remove_dir_all(source_path) {
174                    Ok(()) => {
175                        eprintln!("[roboticus] Completed deferred cleanup of {}", source_path.display());
176                        let _ = std::fs::remove_file(&sentinel);
177                    }
178                    Err(e) => {
179                        eprintln!(
180                            "[roboticus] Still cannot remove {}: {e} — will retry on next run",
181                            source_path.display()
182                        );
183                    }
184                }
185            } else {
186                // Source no longer exists; sentinel is stale.
187                let _ = std::fs::remove_file(&sentinel);
188            }
189        }
190
191        if !legacy.exists() {
192            return;
193        }
194        if new_dir.exists() {
195            eprintln!(
196                "[roboticus] Both ~/.ironclad and ~/.roboticus exist; skipping automatic migration. \
197                 Merge manually and remove ~/.ironclad to silence this warning."
198            );
199            return;
200        }
201
202        // Attempt rename (fast, atomic on the same filesystem).
203        if std::fs::rename(&legacy, new_dir).is_err() {
204            // Cross-filesystem: recursive copy then delete.
205            if let Err(e) = copy_dir_recursive(&legacy, new_dir) {
206                eprintln!("[roboticus] failed to copy ~/.ironclad to ~/.roboticus: {e}");
207                return;
208            }
209            if let Err(e) = std::fs::remove_dir_all(&legacy) {
210                eprintln!(
211                    "[roboticus] copied ~/.ironclad to ~/.roboticus but could not remove the original: {e}"
212                );
213                // Write sentinel so the next run re-attempts the delete.
214                let _ = std::fs::write(&sentinel, legacy.to_string_lossy().as_ref());
215            }
216        }
217        eprintln!("[roboticus] Migrated data directory from ~/.ironclad to ~/.roboticus");
218
219        // Rename ironclad.toml → roboticus.toml inside the new dir.
220        let old_config = new_dir.join("ironclad.toml");
221        let new_config = new_dir.join("roboticus.toml");
222        if old_config.exists() && !new_config.exists() {
223            if let Err(e) = std::fs::rename(&old_config, &new_config) {
224                eprintln!("[roboticus] failed to rename ironclad.toml to roboticus.toml: {e}");
225            } else {
226                eprintln!("[roboticus] Renamed ironclad.toml → roboticus.toml");
227            }
228        }
229
230        // Rewrite legacy paths in ALL .toml files under the migrated directory.
231        rewrite_all_toml_files(new_dir);
232    });
233}
234
235/// Walk `dir` recursively and rewrite legacy ironclad paths in every `.toml` file found.
236pub fn rewrite_all_toml_files(dir: &Path) {
237    let walker = match std::fs::read_dir(dir) {
238        Ok(w) => w,
239        Err(_) => return,
240    };
241    for entry in walker.flatten() {
242        let path = entry.path();
243        if path.is_dir() {
244            rewrite_all_toml_files(&path);
245        } else if path.extension().and_then(|e| e.to_str()) == Some("toml") {
246            rewrite_legacy_paths_in_config(&path);
247        }
248    }
249}
250
251/// Rewrite `/.ironclad/` → `/.roboticus/` inside a TOML config file.
252/// Handles both Unix forward-slash and Windows backslash path styles,
253/// as well as end-of-value patterns where the path ends at a quote boundary.
254pub fn rewrite_legacy_paths_in_config(path: &Path) {
255    let Ok(content) = std::fs::read_to_string(path) else {
256        return;
257    };
258    let rewritten = content
259        // Mid-path patterns (path continues after .ironclad)
260        .replace("/.ironclad/", "/.roboticus/")
261        .replace("\\.ironclad\\", "\\.roboticus\\")
262        .replace("\\\\.ironclad\\\\", "\\\\.roboticus\\\\")
263        // End-of-value patterns (path ends at .ironclad with a quote boundary)
264        .replace("/.ironclad\"", "/.roboticus\"")
265        .replace("/.ironclad'", "/.roboticus'")
266        .replace("\\.ironclad\"", "\\.roboticus\"")
267        .replace("\\.ironclad'", "\\.roboticus'")
268        .replace("\\\\.ironclad\"", "\\\\.roboticus\"")
269        .replace("\\\\.ironclad'", "\\\\.roboticus'");
270    if rewritten != content {
271        if let Err(e) = std::fs::write(path, &rewritten) {
272            eprintln!(
273                "[roboticus] failed to rewrite legacy paths in {}: {e}",
274                path.display()
275            );
276        } else {
277            eprintln!(
278                "[roboticus] Rewrote legacy paths in {}",
279                path.display()
280            );
281        }
282    }
283}
284
285/// Recursively copy a directory tree. Does not follow symlinks.
286fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
287    std::fs::create_dir_all(dst)?;
288    for entry in std::fs::read_dir(src)? {
289        let entry = entry?;
290        let ty = entry.file_type()?;
291        let dest_path = dst.join(entry.file_name());
292        if ty.is_dir() {
293            copy_dir_recursive(&entry.path(), &dest_path)?;
294        } else if ty.is_file() {
295            std::fs::copy(entry.path(), &dest_path)?;
296        }
297        // Skip symlinks and special files.
298    }
299    Ok(())
300}
301
302/// Returns the user's home directory, checking `HOME` first (Unix / MSYS2 / Git Bash)
303/// then `USERPROFILE` (native Windows). Falls back to the platform temp directory.
304pub fn home_dir() -> PathBuf {
305    std::env::var("HOME")
306        .or_else(|_| std::env::var("USERPROFILE"))
307        .map(PathBuf::from)
308        .unwrap_or_else(|_| std::env::temp_dir())
309}
310
311/// Resolves the configuration file path using a standard precedence chain:
312///
313/// 1. Explicit path (from `--config` flag or `ROBOTICUS_CONFIG` env var)
314/// 2. `~/.roboticus/roboticus.toml` (if it exists)
315/// 3. `./roboticus.toml` in the current working directory (if it exists)
316/// 4. Legacy fallbacks: `~/.roboticus/ironclad.toml`, `./ironclad.toml`
317/// 5. `None` — caller decides the fallback (e.g., built-in defaults or error)
318pub fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf> {
319    if let Some(p) = explicit {
320        return Some(expand_tilde(Path::new(p)));
321    }
322
323    // Legacy env var fallback: IRONCLAD_CONFIG → ROBOTICUS_CONFIG equivalent.
324    if let Ok(legacy_env) = std::env::var("IRONCLAD_CONFIG") {
325        eprintln!(
326            "[roboticus] IRONCLAD_CONFIG is deprecated; use ROBOTICUS_CONFIG instead. \
327             Falling back to: {legacy_env}"
328        );
329        return Some(expand_tilde(Path::new(&legacy_env)));
330    }
331
332    // Ensure legacy data dir migration has run.
333    let home = home_dir();
334    let roboticus_dir = home.join(".roboticus");
335    migrate_legacy_data_dir(&home, &roboticus_dir);
336
337    let home_config = roboticus_dir.join("roboticus.toml");
338    if home_config.exists() {
339        return Some(home_config);
340    }
341    let cwd_config = PathBuf::from("roboticus.toml");
342    if cwd_config.exists() {
343        return Some(cwd_config);
344    }
345
346    // Legacy fallback: ironclad.toml (user may have explicit references to old name).
347    let legacy_home = roboticus_dir.join("ironclad.toml");
348    if legacy_home.exists() {
349        tracing::info!("Using legacy config file: {}", legacy_home.display());
350        return Some(legacy_home);
351    }
352    let legacy_cwd = PathBuf::from("ironclad.toml");
353    if legacy_cwd.exists() {
354        tracing::info!("Using legacy config file: ironclad.toml in current directory");
355        return Some(legacy_cwd);
356    }
357    None
358}
359
360/// Expands a leading `~` in `path` to the user's home directory; otherwise returns the path unchanged.
361fn expand_tilde(path: &Path) -> PathBuf {
362    if let Ok(stripped) = path.strip_prefix("~") {
363        home_dir().join(stripped)
364    } else {
365        path.to_path_buf()
366    }
367}