use std::{
collections::{HashMap, HashSet},
io,
path::Path,
sync::mpsc,
thread,
time::Duration,
};
use notify_debouncer_full::{
DebounceEventResult, Debouncer, RecommendedCache, new_debouncer,
notify::{RecommendedWatcher, RecursiveMode},
};
use serde_json::json;
use sha2::{Digest, Sha256};
use crate::configuration::{
bundle_configuration_path, bundles_configuration_directory, load_bundle_configuration,
};
use crate::runtime::error::RuntimeError;
use crate::runtime::inscriptions::emit_inscription;
use crate::runtime::paths::{BundleRuntimePaths, ensure_bundle_runtime_directory};
use super::connection::{BundleCatalog, HostingIntent};
use super::lifecycle::{
register_configured_bundle_principals, shutdown_bundle_runtime, startup_bundle,
};
use super::stream::evict_streams_for_bundle;
use super::{RelayError, RelayResponse};
const BUNDLE_WATCH_DEBOUNCE: Duration = Duration::from_millis(200);
type BundleDebouncer = Debouncer<RecommendedWatcher, RecommendedCache>;
pub struct BundleWatcher {
debouncer: Option<BundleDebouncer>,
consumer: Option<thread::JoinHandle<()>>,
}
impl Drop for BundleWatcher {
fn drop(&mut self) {
self.debouncer.take();
if let Some(consumer) = self.consumer.take() {
let _ = consumer.join();
}
}
}
pub fn spawn_bundle_watcher(
configuration_root: impl AsRef<Path>,
state_root: impl AsRef<Path>,
catalog: BundleCatalog,
no_autostart: bool,
) -> Result<BundleWatcher, RuntimeError> {
let configuration_root = configuration_root.as_ref().to_path_buf();
let state_root = state_root.as_ref().to_path_buf();
let bundles_directory = bundles_configuration_directory(&configuration_root);
let (sender, receiver) = mpsc::channel::<DebounceEventResult>();
let mut debouncer = new_debouncer(BUNDLE_WATCH_DEBOUNCE, None, sender).map_err(|source| {
RuntimeError::validation(
"runtime_bundle_watch_unavailable",
format!("failed to create bundle file watcher: {source}"),
)
})?;
debouncer
.watch(&bundles_directory, RecursiveMode::NonRecursive)
.map_err(|source| {
RuntimeError::validation(
"runtime_bundle_watch_unavailable",
format!(
"failed to watch bundles directory {}: {source}",
bundles_directory.display()
),
)
})?;
let mut state = ReconcileState {
fingerprints: seed_fingerprints(&configuration_root, &catalog),
failed: HashMap::new(),
};
let consumer = thread::Builder::new()
.name("agentmux-bundle-watcher".to_string())
.spawn(move || {
for result in receiver {
match result {
Ok(_events) => {
reconcile_bundles(
&configuration_root,
&state_root,
&catalog,
&mut state,
no_autostart,
);
}
Err(errors) => {
emit_inscription(
"relay.bundle.watch.error",
&json!({ "cause": format!("{errors:?}") }),
);
}
}
}
})
.map_err(|source| RuntimeError::io("spawn bundle watcher thread", source))?;
emit_inscription(
"relay.bundle.watch.started",
&json!({ "bundles_directory": bundles_directory.display().to_string() }),
);
Ok(BundleWatcher {
debouncer: Some(debouncer),
consumer: Some(consumer),
})
}
struct ReconcileState {
fingerprints: HashMap<String, [u8; 32]>,
failed: HashMap<String, [u8; 32]>,
}
fn reconcile_bundles(
configuration_root: &Path,
state_root: &Path,
catalog: &BundleCatalog,
state: &mut ReconcileState,
no_autostart: bool,
) {
let bundles_directory = bundles_configuration_directory(configuration_root);
let on_disk = match scan_bundle_names(&bundles_directory) {
Ok(names) => names,
Err(source) => {
emit_inscription(
"relay.bundle.watch.scan_failed",
&json!({
"bundles_directory": bundles_directory.display().to_string(),
"cause": source.to_string(),
}),
);
return;
}
};
let loaded = catalog.loaded_bundle_names();
for bundle_name in loaded.difference(&on_disk) {
unload_bundle(catalog, bundle_name, state);
}
for bundle_name in &on_disk {
let fingerprint = match fingerprint_bundle_file(configuration_root, bundle_name) {
Ok(fingerprint) => fingerprint,
Err(_) => continue,
};
if loaded.contains(bundle_name) {
if state.fingerprints.get(bundle_name) == Some(&fingerprint) {
continue;
}
if catalog.is_held(bundle_name) {
state
.fingerprints
.insert(bundle_name.to_string(), fingerprint);
emit_inscription(
"relay.bundle.reload_suppressed_held",
&json!({ "bundle_name": bundle_name }),
);
continue;
}
reload_bundle(
configuration_root,
state_root,
catalog,
bundle_name,
fingerprint,
state,
);
} else {
if state.failed.get(bundle_name) == Some(&fingerprint) {
continue;
}
load_new_bundle(
configuration_root,
state_root,
catalog,
bundle_name,
fingerprint,
state,
no_autostart,
);
}
}
}
fn load_new_bundle(
configuration_root: &Path,
state_root: &Path,
catalog: &BundleCatalog,
bundle_name: &str,
fingerprint: [u8; 32],
state: &mut ReconcileState,
no_autostart: bool,
) {
let paths = match BundleRuntimePaths::resolve(state_root, bundle_name) {
Ok(paths) => paths,
Err(source) => {
record_load_failure(
bundle_name,
&source.to_string(),
None,
None,
state,
fingerprint,
);
return;
}
};
if let Err(source) = ensure_bundle_runtime_directory(&paths) {
record_load_failure(
bundle_name,
&source.to_string(),
None,
None,
state,
fingerprint,
);
return;
}
let configuration = match load_bundle_configuration(configuration_root, bundle_name) {
Ok(configuration) => configuration,
Err(source) => {
record_load_failure(
bundle_name,
&source.to_string(),
None,
None,
state,
fingerprint,
);
return;
}
};
if no_autostart || !configuration.autostart {
if let Err(error) = register_configured_bundle_principals(&configuration) {
record_load_failure(
bundle_name,
&error.message,
Some(&error.code),
error.details.as_ref(),
state,
fingerprint,
);
return;
}
catalog.insert(paths, HostingIntent::Hold);
state
.fingerprints
.insert(bundle_name.to_string(), fingerprint);
state.failed.remove(bundle_name);
emit_inscription(
"relay.bundle.loaded_held",
&json!({
"bundle_name": bundle_name,
"reason": if no_autostart {
"relay_no_autostart"
} else {
"bundle_autostart_disabled"
},
}),
);
return;
}
match startup_bundle(configuration_root, bundle_name, &paths.runtime_directory) {
Ok(report) if report.ready_session_count > 0 => {
catalog.insert(paths, HostingIntent::Run);
state
.fingerprints
.insert(bundle_name.to_string(), fingerprint);
state.failed.remove(bundle_name);
emit_inscription(
"relay.bundle.loaded",
&json!({
"bundle_name": bundle_name,
"ready_session_count": report.ready_session_count,
}),
);
}
Ok(_report) => {
record_load_failure(
bundle_name,
"zero configured sessions reached ready state",
None,
None,
state,
fingerprint,
);
}
Err(error) => {
record_load_failure(
bundle_name,
&error.message,
Some(&error.code),
error.details.as_ref(),
state,
fingerprint,
);
}
}
}
fn reload_bundle(
configuration_root: &Path,
state_root: &Path,
catalog: &BundleCatalog,
bundle_name: &str,
fingerprint: [u8; 32],
state: &mut ReconcileState,
) {
let evicted_session_count =
evict_streams_for_bundle(bundle_name, &bundle_reloaded_response(bundle_name));
let paths = match BundleRuntimePaths::resolve(state_root, bundle_name) {
Ok(paths) => paths,
Err(source) => {
catalog.remove(bundle_name);
state.fingerprints.remove(bundle_name);
record_load_failure(
bundle_name,
&source.to_string(),
None,
None,
state,
fingerprint,
);
return;
}
};
let _ = shutdown_bundle_runtime(&paths.tmux_socket);
match startup_bundle(configuration_root, bundle_name, &paths.runtime_directory) {
Ok(report) if report.ready_session_count > 0 => {
catalog.insert(paths, HostingIntent::Run);
state
.fingerprints
.insert(bundle_name.to_string(), fingerprint);
state.failed.remove(bundle_name);
emit_inscription(
"relay.bundle.reloaded",
&json!({
"bundle_name": bundle_name,
"evicted_session_count": evicted_session_count,
"ready_session_count": report.ready_session_count,
}),
);
}
outcome => {
catalog.remove(bundle_name);
state.fingerprints.remove(bundle_name);
match outcome {
Err(error) => record_load_failure(
bundle_name,
&error.message,
Some(&error.code),
error.details.as_ref(),
state,
fingerprint,
),
_ => record_load_failure(
bundle_name,
"zero configured sessions reached ready state",
None,
None,
state,
fingerprint,
),
}
}
}
}
fn unload_bundle(catalog: &BundleCatalog, bundle_name: &str, state: &mut ReconcileState) {
let removed = catalog.remove(bundle_name);
let evicted_session_count =
evict_streams_for_bundle(bundle_name, &bundle_unloaded_response(bundle_name));
if let Some(paths) = removed {
let _ = shutdown_bundle_runtime(&paths.tmux_socket);
}
state.fingerprints.remove(bundle_name);
state.failed.remove(bundle_name);
emit_inscription(
"relay.bundle.unloaded",
&json!({
"bundle_name": bundle_name,
"evicted_session_count": evicted_session_count,
}),
);
}
fn record_load_failure(
bundle_name: &str,
reason: &str,
code: Option<&str>,
details: Option<&serde_json::Value>,
state: &mut ReconcileState,
fingerprint: [u8; 32],
) {
state.failed.insert(bundle_name.to_string(), fingerprint);
emit_inscription(
"relay.bundle.load_failed",
&json!({
"bundle_name": bundle_name,
"reason": reason,
"code": code,
"details": details,
}),
);
}
fn bundle_unloaded_response(bundle_name: &str) -> RelayResponse {
RelayResponse::Error {
error: RelayError {
code: "runtime_bundle_unloaded".to_string(),
message: "bundle configuration file was removed; the relay unloaded the bundle"
.to_string(),
details: Some(json!({ "bundle_name": bundle_name })),
},
}
}
fn bundle_reloaded_response(bundle_name: &str) -> RelayResponse {
RelayResponse::Error {
error: RelayError {
code: "runtime_bundle_reloaded".to_string(),
message: "bundle configuration file changed; the relay reloaded the bundle".to_string(),
details: Some(json!({ "bundle_name": bundle_name })),
},
}
}
fn seed_fingerprints(
configuration_root: &Path,
catalog: &BundleCatalog,
) -> HashMap<String, [u8; 32]> {
catalog
.loaded_bundle_names()
.into_iter()
.filter_map(|bundle_name| {
fingerprint_bundle_file(configuration_root, &bundle_name)
.ok()
.map(|fingerprint| (bundle_name, fingerprint))
})
.collect()
}
fn scan_bundle_names(bundles_directory: &Path) -> io::Result<HashSet<String>> {
if !bundles_directory.exists() {
return Ok(HashSet::new());
}
let mut names = HashSet::new();
for entry in std::fs::read_dir(bundles_directory)? {
let entry = entry?;
let file_name = entry.file_name();
let Some(file_name) = file_name.to_str() else {
continue;
};
if let Some(bundle_name) = file_name.strip_suffix(".toml") {
names.insert(bundle_name.to_string());
}
}
Ok(names)
}
fn fingerprint_bundle_file(configuration_root: &Path, bundle_name: &str) -> io::Result<[u8; 32]> {
let path = bundle_configuration_path(configuration_root, bundle_name);
let bytes = std::fs::read(&path)?;
Ok(Sha256::digest(&bytes).into())
}