agentmux_launcher/
config.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// LSD-4 — operator-overridable launcher config loaded from
5// `<user_home_dir>/config.toml` (typically `~/.agentmux/config.toml`).
6//
7// Spec: `docs/specs/SPEC_LAUNCHER_SAGA_DURABILITY_2026-05-01.md`
8//   - §3.6 retention default 7 days, configurable via `[saga.launcher]`
9//   - §4 PR4 scope: read this file at startup, apply
10//     `vacuum_older_than(now - retention_days)` once before the
11//     coordinator starts.
12//
13// Design:
14//   - File is OPTIONAL. Missing file, unreadable file, or malformed
15//     TOML all fall back to the compiled-in defaults; we log a WARN
16//     line so operators see the misconfiguration but the launcher
17//     keeps starting. The saga log's behavior shouldn't be load-
18//     bearing on a config file the user may never have created.
19//   - Sections are namespaced under `[saga.launcher]` so future srv
20//     bits can land at `[saga.srv]` without colliding. Top-level
21//     `unknown_keys` are tolerated (serde default) so an older
22//     launcher reading a newer config doesn't crash.
23//
24// Example `~/.agentmux/config.toml`:
25//
26//     [saga.launcher]
27//     retention_days = 14
28//
29// Tests live below in `#[cfg(test)] mod tests` — exercise the parse
30// path on a `tempfile::NamedTempFile` containing valid + malformed
31// TOML.
32
33use std::path::Path;
34
35use serde::Deserialize;
36
37/// LSD spec §3.6 default — 7 days. Tunable via config file.
38pub const DEFAULT_SAGA_RETENTION_DAYS: i64 = 7;
39
40/// Top-level launcher config schema. Only the saga subtree is
41/// populated today; future PRs append siblings.
42#[derive(Debug, Default, Deserialize)]
43struct LauncherConfig {
44    #[serde(default)]
45    saga: SagaConfig,
46}
47
48#[derive(Debug, Default, Deserialize)]
49struct SagaConfig {
50    #[serde(default)]
51    launcher: SagaLauncherConfig,
52}
53
54#[derive(Debug, Default, Deserialize)]
55struct SagaLauncherConfig {
56    /// Days to retain terminal sagas (`completed` / `failed` /
57    /// `failed_compensation`) before the startup vacuum sweeps them.
58    /// In-flight sagas (`running` / `compensating`) are never vacuumed
59    /// regardless of age — see `vacuum_older_than` SQL filter.
60    retention_days: Option<i64>,
61}
62
63/// Read `<user_home_dir>/config.toml` if it exists and return the
64/// configured saga retention days, falling back to
65/// `DEFAULT_SAGA_RETENTION_DAYS` on any error. The optional `log_warn`
66/// closure receives a human-readable diagnostic line per failure path
67/// (file unreadable / malformed / negative value) so callers can
68/// route it through `crate::log()` without introducing a tracing dep.
69pub fn load_saga_retention_days(
70    user_home_dir: &Path,
71    mut log_warn: impl FnMut(&str),
72) -> i64 {
73    let path = user_home_dir.join("config.toml");
74    let raw = match std::fs::read_to_string(&path) {
75        Ok(s) => s,
76        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
77            // Missing file is the expected case for fresh installs;
78            // no warning needed.
79            return DEFAULT_SAGA_RETENTION_DAYS;
80        }
81        Err(e) => {
82            log_warn(&format!(
83                "[config] failed to read {}: {} — using default retention {} days",
84                path.display(),
85                e,
86                DEFAULT_SAGA_RETENTION_DAYS
87            ));
88            return DEFAULT_SAGA_RETENTION_DAYS;
89        }
90    };
91    match toml::from_str::<LauncherConfig>(&raw) {
92        Ok(cfg) => match cfg.saga.launcher.retention_days {
93            Some(d) if d > 0 => d,
94            Some(d) => {
95                log_warn(&format!(
96                    "[config] [saga.launcher] retention_days = {} is non-positive; using default {} days",
97                    d, DEFAULT_SAGA_RETENTION_DAYS
98                ));
99                DEFAULT_SAGA_RETENTION_DAYS
100            }
101            None => DEFAULT_SAGA_RETENTION_DAYS,
102        },
103        Err(e) => {
104            log_warn(&format!(
105                "[config] failed to parse {}: {} — using default retention {} days",
106                path.display(),
107                e,
108                DEFAULT_SAGA_RETENTION_DAYS
109            ));
110            DEFAULT_SAGA_RETENTION_DAYS
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::cell::RefCell;
119    use std::fs;
120    use tempfile::TempDir;
121
122    // Tests collect each warning passed through `log_warn` into a
123    // `RefCell<Vec<String>>` and assert on the resulting list. Keeps
124    // the closure trivial without requiring a generic `FnMut` factory.
125
126    fn run(home: &Path) -> (i64, Vec<String>) {
127        let warnings: RefCell<Vec<String>> = RefCell::new(Vec::new());
128        let days = load_saga_retention_days(home, |w| warnings.borrow_mut().push(w.to_string()));
129        (days, warnings.into_inner())
130    }
131
132    #[test]
133    fn missing_file_returns_default_no_warning() {
134        let dir = TempDir::new().unwrap();
135        let (days, warns) = run(dir.path());
136        assert_eq!(days, DEFAULT_SAGA_RETENTION_DAYS);
137        assert!(warns.is_empty(), "missing file is silent, got: {warns:?}");
138    }
139
140    #[test]
141    fn valid_config_returns_configured_value() {
142        let dir = TempDir::new().unwrap();
143        fs::write(
144            dir.path().join("config.toml"),
145            "[saga.launcher]\nretention_days = 14\n",
146        )
147        .unwrap();
148        let (days, warns) = run(dir.path());
149        assert_eq!(days, 14);
150        assert!(warns.is_empty());
151    }
152
153    #[test]
154    fn malformed_toml_logs_warning_and_returns_default() {
155        let dir = TempDir::new().unwrap();
156        fs::write(dir.path().join("config.toml"), "not = toml = bro\n").unwrap();
157        let (days, warns) = run(dir.path());
158        assert_eq!(days, DEFAULT_SAGA_RETENTION_DAYS);
159        assert_eq!(warns.len(), 1);
160        assert!(warns[0].contains("failed to parse"), "got: {}", warns[0]);
161    }
162
163    #[test]
164    fn missing_section_returns_default_no_warning() {
165        // Empty + irrelevant keys are tolerated.
166        let dir = TempDir::new().unwrap();
167        fs::write(
168            dir.path().join("config.toml"),
169            "[some_other_section]\nfoo = 1\n",
170        )
171        .unwrap();
172        let (days, warns) = run(dir.path());
173        assert_eq!(days, DEFAULT_SAGA_RETENTION_DAYS);
174        assert!(warns.is_empty());
175    }
176
177    #[test]
178    fn non_positive_retention_logs_warning_and_returns_default() {
179        let dir = TempDir::new().unwrap();
180        fs::write(
181            dir.path().join("config.toml"),
182            "[saga.launcher]\nretention_days = 0\n",
183        )
184        .unwrap();
185        let (days, warns) = run(dir.path());
186        assert_eq!(days, DEFAULT_SAGA_RETENTION_DAYS);
187        assert_eq!(warns.len(), 1);
188        assert!(warns[0].contains("non-positive"));
189
190        let dir = TempDir::new().unwrap();
191        fs::write(
192            dir.path().join("config.toml"),
193            "[saga.launcher]\nretention_days = -3\n",
194        )
195        .unwrap();
196        let (days, warns) = run(dir.path());
197        assert_eq!(days, DEFAULT_SAGA_RETENTION_DAYS);
198        assert_eq!(warns.len(), 1);
199    }
200
201}