use super::AgentProfile;
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub const HOT_PATHS: &[&str] = &[
"limits",
"disabled_skills",
"addons",
"hitl.autonomy",
"lifecycle.schedule",
"lifecycle.idle_triggers",
];
const NESTED_PARENTS: &[&str] = &["hitl", "lifecycle"];
const IGNORED_PATHS: &[&str] = &["updated_at"];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ReloadDiff {
pub hot: Vec<String>,
pub cold: Vec<String>,
}
impl ReloadDiff {
pub fn is_empty(&self) -> bool {
self.hot.is_empty() && self.cold.is_empty()
}
pub fn hot_only(&self) -> bool {
self.cold.is_empty()
}
}
pub const RELOAD_STATE_FILE: &str = "reload_state.json";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReloadState {
pub generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_reload_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_limits_error: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pending_cold_restart: Vec<String>,
}
impl ReloadState {
pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, serde_json::to_vec_pretty(self)?)?;
std::fs::rename(&tmp, path)
}
pub fn load(path: &std::path::Path) -> Option<Self> {
serde_json::from_slice(&std::fs::read(path).ok()?).ok()
}
}
impl AgentProfile {
pub fn reload_diff(old: &AgentProfile, new: &AgentProfile) -> ReloadDiff {
let (Ok(a), Ok(b)) = (serde_json::to_value(old), serde_json::to_value(new)) else {
return ReloadDiff {
hot: vec![],
cold: vec!["<profile>".into()],
};
};
let mut diff = ReloadDiff::default();
walk(&a, &b, "", &mut diff);
diff.hot.sort();
diff.cold.sort();
diff
}
}
impl AgentProfile {
pub fn with_hot_fields_from(&self, newer: &AgentProfile) -> AgentProfile {
let mut out = self.clone();
out.limits = newer.limits.clone();
out.disabled_skills = newer.disabled_skills.clone();
out.addons = newer.addons.clone();
out.hitl.autonomy = newer.hitl.autonomy;
out.lifecycle.schedule = newer.lifecycle.schedule.clone();
out.lifecycle.idle_triggers = newer.lifecycle.idle_triggers.clone();
out
}
}
fn walk(a: &Value, b: &Value, prefix: &str, diff: &mut ReloadDiff) {
let null = Value::Null;
let (Some(ma), Some(mb)) = (a.as_object(), b.as_object()) else {
if a != b {
diff.cold.push(prefix.to_string());
}
return;
};
let mut keys: Vec<&String> = ma.keys().chain(mb.keys()).collect();
keys.sort();
keys.dedup();
for k in keys {
let path = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
let (va, vb) = (ma.get(k).unwrap_or(&null), mb.get(k).unwrap_or(&null));
if va == vb || IGNORED_PATHS.contains(&path.as_str()) {
continue;
}
if HOT_PATHS.contains(&path.as_str()) {
diff.hot.push(path.clone());
if path == "addons" {
diff.cold.push("addons (mcp servers)".into());
}
} else if prefix.is_empty() && NESTED_PARENTS.contains(&k.as_str()) {
walk(va, vb, &path, diff);
} else {
diff.cold.push(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::ScheduleEntry;
fn base() -> AgentProfile {
AgentProfile::default_for_tests()
}
#[test]
fn identical_profiles_have_no_diff() {
assert!(AgentProfile::reload_diff(&base(), &base()).is_empty());
}
#[test]
fn limits_and_autonomy_are_hot() {
let mut new = base();
new.limits = Some(crate::limits::Limits {
deadline: Some("5m".into()),
..Default::default()
});
new.hitl.autonomy = Some(crate::hitl::Autonomy::Continue);
let d = AgentProfile::reload_diff(&base(), &new);
assert_eq!(d.hot, vec!["hitl.autonomy", "limits"]);
assert!(d.hot_only(), "{d:?}");
}
#[test]
fn schedule_is_hot_but_restart_policy_is_cold() {
let mut new = base();
new.lifecycle.schedule.push(ScheduleEntry {
cron: "0 9 * * *".into(),
message: "hi".into(),
sends_to: None,
not_after: None,
});
new.lifecycle.max_restarts += 1;
let d = AgentProfile::reload_diff(&base(), &new);
assert_eq!(d.hot, vec!["lifecycle.schedule"]);
assert_eq!(d.cold, vec!["lifecycle.max_restarts"]);
}
#[test]
fn locked_fields_are_cold() {
let mut new = base();
new.name = "other".into();
new.entitlements
.network
.outbound
.allow_hosts
.push("example.com".into());
new.disabled_mcp.push("x".into());
let d = AgentProfile::reload_diff(&base(), &new);
assert!(d.hot.is_empty(), "{d:?}");
assert!(d.cold.iter().any(|p| p == "name"), "{d:?}");
assert!(
d.cold.iter().any(|p| p.starts_with("entitlements")),
"{d:?}"
);
assert!(d.cold.iter().any(|p| p == "disabled_mcp"), "{d:?}");
}
#[test]
fn updated_at_alone_is_not_a_change() {
let mut new = base();
new.updated_at = "2099-01-01T00:00:00Z".into();
assert!(AgentProfile::reload_diff(&base(), &new).is_empty());
}
#[test]
fn overlay_applies_every_hot_path_and_nothing_else() {
let mut new = base();
new.limits = Some(crate::limits::Limits {
stuck: Some("off".into()),
..Default::default()
});
new.disabled_skills.push("foo".into());
new.hitl.autonomy = Some(crate::hitl::Autonomy::Continue);
new.lifecycle.idle_triggers.push(crate::agent::IdleTrigger {
after_secs: 60,
message: "m".into(),
sends_to: None,
cooldown_secs: 600,
respect_quiet_hours: false,
});
new.name = "renamed".into();
let applied = base().with_hot_fields_from(&new);
let rest = AgentProfile::reload_diff(&applied, &new);
assert!(rest.hot.is_empty(), "{rest:?}");
assert_eq!(rest.cold, vec!["name"]);
}
#[test]
fn disabled_skills_is_hot() {
let mut new = base();
new.disabled_skills.push("foo".into());
let d = AgentProfile::reload_diff(&base(), &new);
assert_eq!(d.hot, vec!["disabled_skills"]);
assert!(d.cold.is_empty());
}
}