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}