Skip to main content

leviath_cli/daemon/
config_reload.rs

1//! Hot-reloading of the daemon's spawn-time config.
2//!
3//! The daemon loads `~/.leviath/config.toml` once at startup and would
4//! otherwise serve that snapshot for its whole life - so a user who granted a
5//! `[read_paths]` path, flipped a tool permission, or changed a limit had to
6//! restart the daemon before the next `lev run` saw it. That is a surprising
7//! loop to be stuck in: the spawn warning tells you to edit the config, and
8//! editing it appears to do nothing.
9//!
10//! [`ConfigReloader`] closes that gap for the config an agent reads *at spawn*
11//! (permissions, `[read_paths]`, sandbox defaults, limits, taint). It reloads
12//! the file when its mtime changes, mirroring the script-provider hot-reload
13//! (`leviath_runtime::script_provider`), and keeps the last good config if a
14//! reload fails so an edit saved mid-keystroke never breaks a spawn.
15//!
16//! What it deliberately does **not** reload is the infrastructure established
17//! once at boot: the provider registry, MCP connections, the outbound-network
18//! policy, and the telemetry sink. Those hold live connections and
19//! process-wide state; re-initializing them on a file write is a much larger
20//! change with its own failure modes. Adding a provider key or an MCP server
21//! still needs a daemon restart; see `daemon.md`.
22
23use std::path::PathBuf;
24use std::sync::{Arc, Mutex, PoisonError};
25use std::time::SystemTime;
26
27use crate::config::Config;
28
29/// The config as of a given file mtime.
30struct Cached {
31    /// The file mtime this config was loaded from. `None` means the file did
32    /// not exist at load time (defaults in use); it reloads if the file later
33    /// appears.
34    mtime: Option<SystemTime>,
35    config: Arc<Config>,
36}
37
38/// Serves the freshest spawn-time [`Config`], reloading `config.toml` when it
39/// changes on disk.
40pub struct ConfigReloader {
41    /// The file watched for changes - [`Config::config_path`] at construction.
42    /// `None` for a [`fixed`](Self::fixed) reloader that never watches a file.
43    path: Option<PathBuf>,
44    cache: Mutex<Cached>,
45}
46
47impl ConfigReloader {
48    /// Wrap the boot-loaded `initial` config, watching `path` (normally
49    /// [`Config::config_path`]). The file's current mtime is recorded so the
50    /// first [`current`](Self::current) call does not reload a config that has
51    /// not changed.
52    pub fn new(path: PathBuf, initial: Config) -> Self {
53        let mtime = file_mtime(&path);
54        Self {
55            path: Some(path),
56            cache: Mutex::new(Cached {
57                mtime,
58                config: Arc::new(initial),
59            }),
60        }
61    }
62
63    /// A reloader that never watches a file: [`current`](Self::current) always
64    /// returns `config`. For contexts that hold a config snapshot but do not
65    /// hot-reload (tests, and any caller that wants a fixed config).
66    pub fn fixed(config: Config) -> Self {
67        Self {
68            path: None,
69            cache: Mutex::new(Cached {
70                mtime: None,
71                config: Arc::new(config),
72            }),
73        }
74    }
75
76    /// The current spawn-time config: the cached copy when `config.toml` is
77    /// unchanged, or a freshly loaded one when its mtime moved.
78    ///
79    /// A reload that fails to parse (an edit saved half-written, a syntax
80    /// error) does not fail the caller - it logs a warning and returns the
81    /// last good config, so a broken file degrades to "your last saved config"
82    /// rather than a broken spawn. The stale mtime is retained, so the next
83    /// successful save is picked up.
84    pub fn current(&self) -> Arc<Config> {
85        let Some(path) = &self.path else {
86            // A fixed reloader: nothing to watch.
87            return self
88                .cache
89                .lock()
90                .unwrap_or_else(PoisonError::into_inner)
91                .config
92                .clone();
93        };
94        let mtime = file_mtime(path);
95        let mut cached = self.cache.lock().unwrap_or_else(PoisonError::into_inner);
96        if mtime == cached.mtime {
97            return cached.config.clone();
98        }
99        // Bind the displayed path in a plain statement rather than as a lazy
100        // `%path.display()` tracing field: the method-call region inside a
101        // structured field is only reached when the callsite is enabled, and
102        // tracing caches callsite interest process-globally, so it is
103        // unreachable under a coverage run whose other tests hit it with no
104        // subscriber. A pre-bound value sidesteps that.
105        let displayed = path.display();
106        match Config::load_from_path_public(path) {
107            Ok(config) => {
108                let config = Arc::new(config);
109                cached.mtime = mtime;
110                cached.config = config.clone();
111                tracing::info!(path = %displayed, "reloaded config after an on-disk change");
112                config
113            }
114            Err(e) => {
115                // Keep the last-good config AND its mtime: retrying the same
116                // broken file every spawn would spam the log, and we want the
117                // *next* good save (a new mtime) to reload.
118                tracing::warn!(
119                    path = %displayed,
120                    error = %e,
121                    "config changed on disk but failed to reload; keeping the last good config"
122                );
123                cached.config.clone()
124            }
125        }
126    }
127}
128
129/// The file's modification time, or `None` if it does not exist or cannot be
130/// stat'd (treated as "no file" - a missing config means defaults).
131fn file_mtime(path: &std::path::Path) -> Option<SystemTime> {
132    std::fs::metadata(path).and_then(|m| m.modified()).ok()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use std::time::Duration;
139
140    fn write(path: &std::path::Path, body: &str) {
141        std::fs::write(path, body).unwrap();
142    }
143
144    /// Force a file's mtime strictly newer, so a reload is observable even when
145    /// two writes land in the same clock tick (mirrors the script-provider
146    /// hot-reload test helper).
147    fn bump_mtime(path: &std::path::Path) {
148        let later = SystemTime::now() + Duration::from_secs(5);
149        let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
150        f.set_modified(later).unwrap();
151    }
152
153    /// A complete, valid config TOML (several top-level fields have no serde
154    /// default, so a partial document would not parse) granting `agent` one
155    /// read path.
156    fn config_with_grant(agent: &str, path: &str) -> String {
157        let mut c = Config::default();
158        c.agent_read_paths.insert(
159            agent.to_string(),
160            crate::config::ReadPathGrants {
161                allow: vec![path.to_string()],
162            },
163        );
164        toml::to_string(&c).unwrap()
165    }
166
167    fn empty_config() -> String {
168        toml::to_string(&Config::default()).unwrap()
169    }
170
171    #[test]
172    fn an_unchanged_file_returns_the_cached_config_without_reloading() {
173        let dir = tempfile::tempdir().unwrap();
174        let path = dir.path().join("config.toml");
175        write(&path, &empty_config());
176        let reloader = ConfigReloader::new(path.clone(), Config::default());
177
178        let a = reloader.current();
179        let b = reloader.current();
180        // Same Arc: no reload happened.
181        assert!(Arc::ptr_eq(&a, &b));
182    }
183
184    #[test]
185    fn an_edited_file_is_reloaded_on_the_next_read() {
186        let dir = tempfile::tempdir().unwrap();
187        let path = dir.path().join("config.toml");
188        write(&path, &empty_config());
189        let reloader = ConfigReloader::new(path.clone(), Config::default());
190        assert!(
191            reloader
192                .current()
193                .read_path_grants_for_agent("cto")
194                .is_empty()
195        );
196
197        // The user grants a read path and saves.
198        write(&path, &config_with_grant("cto", "~/.leviath/runs"));
199        bump_mtime(&path);
200
201        // Subscriber active so the "reloaded config" info-log field evaluates.
202        let reloaded = reloader.current();
203        assert_eq!(
204            reloaded.read_path_grants_for_agent("cto"),
205            vec!["~/.leviath/runs".to_string()],
206            "the new grant must be visible without a restart"
207        );
208    }
209
210    #[test]
211    fn a_config_that_appears_after_boot_is_picked_up() {
212        let dir = tempfile::tempdir().unwrap();
213        let path = dir.path().join("config.toml");
214        // No file at construction: defaults, mtime None.
215        let reloader = ConfigReloader::new(path.clone(), Config::default());
216        assert!(
217            reloader
218                .current()
219                .read_path_grants_for_agent("cto")
220                .is_empty()
221        );
222
223        write(&path, &config_with_grant("cto", "~/docs"));
224        let reloaded = reloader.current();
225        assert_eq!(
226            reloaded.read_path_grants_for_agent("cto"),
227            vec!["~/docs".to_string()]
228        );
229    }
230
231    #[test]
232    fn a_broken_edit_keeps_the_last_good_config() {
233        let dir = tempfile::tempdir().unwrap();
234        let path = dir.path().join("config.toml");
235        write(&path, &config_with_grant("cto", "~/good"));
236        // Mirror boot: the reloader is seeded with the config already on disk.
237        let reloader =
238            ConfigReloader::new(path.clone(), Config::load_from_path_public(&path).unwrap());
239        assert_eq!(
240            reloader.current().read_path_grants_for_agent("cto"),
241            vec!["~/good".to_string()]
242        );
243
244        // A half-saved, unparseable edit.
245        write(&path, "this is not valid : : toml");
246        bump_mtime(&path);
247
248        // Subscriber active so the "failed to reload" warn-log field evaluates.
249        let after = reloader.current();
250        assert_eq!(
251            after.read_path_grants_for_agent("cto"),
252            vec!["~/good".to_string()],
253            "a broken file must not break the spawn - keep the last good config"
254        );
255    }
256
257    #[test]
258    fn a_good_save_after_a_broken_one_recovers() {
259        let dir = tempfile::tempdir().unwrap();
260        let path = dir.path().join("config.toml");
261        write(&path, &config_with_grant("cto", "~/good"));
262        let reloader =
263            ConfigReloader::new(path.clone(), Config::load_from_path_public(&path).unwrap());
264        let _ = reloader.current();
265
266        write(&path, "broken : :");
267        bump_mtime(&path);
268        let _ = reloader.current(); // keeps last-good
269
270        // The user fixes it.
271        write(&path, &config_with_grant("cto", "~/fixed"));
272        bump_mtime(&path);
273        assert_eq!(
274            reloader.current().read_path_grants_for_agent("cto"),
275            vec!["~/fixed".to_string()]
276        );
277    }
278}