mur_common/agent/
reload.rs1use super::AgentProfile;
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17pub const HOT_PATHS: &[&str] = &[
22 "limits",
23 "disabled_skills",
24 "addons",
25 "hitl.autonomy",
26 "lifecycle.schedule",
27 "lifecycle.idle_triggers",
28];
29
30const NESTED_PARENTS: &[&str] = &["hitl", "lifecycle"];
32
33const IGNORED_PATHS: &[&str] = &["updated_at"];
35
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct ReloadDiff {
39 pub hot: Vec<String>,
41 pub cold: Vec<String>,
43}
44
45impl ReloadDiff {
46 pub fn is_empty(&self) -> bool {
47 self.hot.is_empty() && self.cold.is_empty()
48 }
49
50 pub fn hot_only(&self) -> bool {
52 self.cold.is_empty()
53 }
54}
55
56pub const RELOAD_STATE_FILE: &str = "reload_state.json";
59
60#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
62pub struct ReloadState {
63 pub generation: u64,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub last_reload_error: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub config_limits_error: Option<String>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 pub pending_cold_restart: Vec<String>,
76}
77
78impl ReloadState {
79 pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
81 let tmp = path.with_extension("json.tmp");
82 std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
83 std::fs::rename(&tmp, path)
84 }
85
86 pub fn load(path: &std::path::Path) -> Option<Self> {
88 serde_json::from_slice(&std::fs::read(path).ok()?).ok()
89 }
90}
91
92impl AgentProfile {
93 pub fn reload_diff(old: &AgentProfile, new: &AgentProfile) -> ReloadDiff {
95 let (Ok(a), Ok(b)) = (serde_json::to_value(old), serde_json::to_value(new)) else {
96 return ReloadDiff {
99 hot: vec![],
100 cold: vec!["<profile>".into()],
101 };
102 };
103 let mut diff = ReloadDiff::default();
104 walk(&a, &b, "", &mut diff);
105 diff.hot.sort();
106 diff.cold.sort();
107 diff
108 }
109}
110
111impl AgentProfile {
112 pub fn with_hot_fields_from(&self, newer: &AgentProfile) -> AgentProfile {
119 let mut out = self.clone();
120 out.limits = newer.limits.clone();
121 out.disabled_skills = newer.disabled_skills.clone();
122 out.addons = newer.addons.clone();
123 out.hitl.autonomy = newer.hitl.autonomy;
124 out.lifecycle.schedule = newer.lifecycle.schedule.clone();
125 out.lifecycle.idle_triggers = newer.lifecycle.idle_triggers.clone();
126 out
127 }
128}
129
130fn walk(a: &Value, b: &Value, prefix: &str, diff: &mut ReloadDiff) {
131 let null = Value::Null;
132 let (Some(ma), Some(mb)) = (a.as_object(), b.as_object()) else {
133 if a != b {
134 diff.cold.push(prefix.to_string());
135 }
136 return;
137 };
138 let mut keys: Vec<&String> = ma.keys().chain(mb.keys()).collect();
139 keys.sort();
140 keys.dedup();
141 for k in keys {
142 let path = if prefix.is_empty() {
143 k.clone()
144 } else {
145 format!("{prefix}.{k}")
146 };
147 let (va, vb) = (ma.get(k).unwrap_or(&null), mb.get(k).unwrap_or(&null));
148 if va == vb || IGNORED_PATHS.contains(&path.as_str()) {
149 continue;
150 }
151 if HOT_PATHS.contains(&path.as_str()) {
152 diff.hot.push(path.clone());
153 if path == "addons" {
154 diff.cold.push("addons (mcp servers)".into());
155 }
156 } else if prefix.is_empty() && NESTED_PARENTS.contains(&k.as_str()) {
157 walk(va, vb, &path, diff);
158 } else {
159 diff.cold.push(path);
160 }
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::agent::ScheduleEntry;
168
169 fn base() -> AgentProfile {
170 AgentProfile::default_for_tests()
171 }
172
173 #[test]
174 fn identical_profiles_have_no_diff() {
175 assert!(AgentProfile::reload_diff(&base(), &base()).is_empty());
176 }
177
178 #[test]
179 fn limits_and_autonomy_are_hot() {
180 let mut new = base();
181 new.limits = Some(crate::limits::Limits {
182 deadline: Some("5m".into()),
183 ..Default::default()
184 });
185 new.hitl.autonomy = Some(crate::hitl::Autonomy::Continue);
186 let d = AgentProfile::reload_diff(&base(), &new);
187 assert_eq!(d.hot, vec!["hitl.autonomy", "limits"]);
188 assert!(d.hot_only(), "{d:?}");
189 }
190
191 #[test]
192 fn schedule_is_hot_but_restart_policy_is_cold() {
193 let mut new = base();
194 new.lifecycle.schedule.push(ScheduleEntry {
195 cron: "0 9 * * *".into(),
196 message: "hi".into(),
197 sends_to: None,
198 not_after: None,
199 });
200 new.lifecycle.max_restarts += 1;
201 let d = AgentProfile::reload_diff(&base(), &new);
202 assert_eq!(d.hot, vec!["lifecycle.schedule"]);
203 assert_eq!(d.cold, vec!["lifecycle.max_restarts"]);
204 }
205
206 #[test]
207 fn locked_fields_are_cold() {
208 let mut new = base();
209 new.name = "other".into();
210 new.entitlements
211 .network
212 .outbound
213 .allow_hosts
214 .push("example.com".into());
215 new.disabled_mcp.push("x".into());
216 let d = AgentProfile::reload_diff(&base(), &new);
217 assert!(d.hot.is_empty(), "{d:?}");
218 assert!(d.cold.iter().any(|p| p == "name"), "{d:?}");
219 assert!(
220 d.cold.iter().any(|p| p.starts_with("entitlements")),
221 "{d:?}"
222 );
223 assert!(d.cold.iter().any(|p| p == "disabled_mcp"), "{d:?}");
224 }
225
226 #[test]
227 fn updated_at_alone_is_not_a_change() {
228 let mut new = base();
229 new.updated_at = "2099-01-01T00:00:00Z".into();
230 assert!(AgentProfile::reload_diff(&base(), &new).is_empty());
231 }
232
233 #[test]
234 fn overlay_applies_every_hot_path_and_nothing_else() {
235 let mut new = base();
236 new.limits = Some(crate::limits::Limits {
237 stuck: Some("off".into()),
238 ..Default::default()
239 });
240 new.disabled_skills.push("foo".into());
241 new.hitl.autonomy = Some(crate::hitl::Autonomy::Continue);
242 new.lifecycle.idle_triggers.push(crate::agent::IdleTrigger {
243 after_secs: 60,
244 message: "m".into(),
245 sends_to: None,
246 cooldown_secs: 600,
247 respect_quiet_hours: false,
248 });
249 new.name = "renamed".into();
250 let applied = base().with_hot_fields_from(&new);
251 let rest = AgentProfile::reload_diff(&applied, &new);
252 assert!(rest.hot.is_empty(), "{rest:?}");
254 assert_eq!(rest.cold, vec!["name"]);
255 }
256
257 #[test]
258 fn disabled_skills_is_hot() {
259 let mut new = base();
260 new.disabled_skills.push("foo".into());
261 let d = AgentProfile::reload_diff(&base(), &new);
262 assert_eq!(d.hot, vec!["disabled_skills"]);
263 assert!(d.cold.is_empty());
264 }
265}