pitchfork-cli 2.6.0

Daemons with DX
Documentation
//! Autostop logic and boot daemon startup
//!
//! Handles automatic stopping of daemons when shells leave directories,
//! and starting daemons configured with `boot_start = true`.

use super::Supervisor;
use crate::Result;
use crate::daemon_id::DaemonId;
use crate::ipc::IpcResponse;
use crate::pitchfork_toml::PitchforkToml;
use crate::settings::settings;
use log::LevelFilter::Info;
use std::path::Path;
use tokio::time;

impl Supervisor {
    /// Handle shell leaving a directory - schedule autostops for daemons
    pub(crate) async fn leave_dir(&self, dir: &Path) -> Result<()> {
        debug!("left dir {}", dir.display());
        let shell_dirs = self.get_dirs_with_shell_pids().await;
        let shell_dirs = shell_dirs.keys().collect::<Vec<_>>();
        let autostop_delay = settings().general_autostop_delay();

        for daemon in self.active_daemons().await {
            if !daemon.autostop {
                continue;
            }
            // if this daemon's dir starts with the left dir
            // and no other shell pid has this dir as a prefix
            // schedule the daemon for autostop
            if let Some(daemon_dir) = daemon.dir.as_ref()
                && daemon_dir.starts_with(dir)
                && !shell_dirs.iter().any(|d| d.starts_with(daemon_dir))
            {
                if autostop_delay.is_zero() {
                    // No delay configured, stop immediately
                    info!("autostopping {daemon}");
                    self.stop(&daemon.id).await?;
                    self.add_notification(Info, format!("autostopped {daemon}"))
                        .await;
                } else {
                    // Schedule autostop with delay
                    let stop_at = time::Instant::now() + autostop_delay;
                    let mut pending = self.pending_autostops.lock().await;
                    if !pending.contains_key(&daemon.id) {
                        info!(
                            "scheduling autostop for {} in {:?}",
                            daemon.id, autostop_delay
                        );
                        pending.insert(daemon.id.clone(), stop_at);
                    }
                }
            }
        }
        Ok(())
    }

    /// Cancel any pending autostop for daemons in the given directory
    /// Also cancels autostops for daemons in parent directories (e.g., entering /project/subdir
    /// cancels pending autostop for daemon in /project)
    pub(crate) async fn cancel_pending_autostops_for_dir(&self, dir: &Path) {
        let mut pending = self.pending_autostops.lock().await;
        let daemons_to_cancel: Vec<DaemonId> = {
            let state_file = self.state_file.lock().await;
            state_file
                .daemons
                .iter()
                .filter(|(_id, d)| {
                    d.dir.as_ref().is_some_and(|daemon_dir| {
                        // Cancel if entering a directory inside or equal to daemon's directory
                        // OR if daemon is in a subdirectory of the entered directory
                        dir.starts_with(daemon_dir) || daemon_dir.starts_with(dir)
                    })
                })
                .map(|(id, _)| id.clone())
                .collect()
        };

        for daemon_id in daemons_to_cancel {
            if pending.remove(&daemon_id).is_some() {
                info!("cancelled pending autostop for {daemon_id}");
            }
        }
    }

    /// Process any pending autostops that have reached their scheduled time
    pub(crate) async fn process_pending_autostops(&self) -> Result<()> {
        let now = time::Instant::now();
        let to_stop: Vec<DaemonId> = {
            let pending = self.pending_autostops.lock().await;
            pending
                .iter()
                .filter(|(_, stop_at)| now >= **stop_at)
                .map(|(id, _)| id.clone())
                .collect()
        };

        for daemon_id in to_stop {
            // Remove from pending first
            {
                let mut pending = self.pending_autostops.lock().await;
                pending.remove(&daemon_id);
            }

            // Check if daemon is still running and should be stopped
            if let Some(daemon) = self.get_daemon(&daemon_id).await
                && daemon.autostop
                && daemon.status.is_running()
            {
                // Verify no shell is in the daemon's directory
                let shell_dirs = self.get_dirs_with_shell_pids().await;
                let shell_dirs = shell_dirs.keys().collect::<Vec<_>>();
                if let Some(daemon_dir) = daemon.dir.as_ref()
                    && !shell_dirs.iter().any(|d| d.starts_with(daemon_dir))
                {
                    info!("autostopping {daemon_id} (after delay)");
                    self.stop(&daemon_id).await?;
                    self.add_notification(Info, format!("autostopped {daemon_id}"))
                        .await;
                }
            }
        }
        Ok(())
    }

    /// Start daemons configured with `boot_start = true`
    pub(crate) async fn start_boot_daemons(&self) -> Result<()> {
        info!("Scanning for boot_start daemons");
        let pt = PitchforkToml::all_merged()?;

        let boot_daemons: Vec<_> = pt
            .daemons
            .iter()
            .filter(|(_id, d)| d.boot_start.unwrap_or(false))
            .collect();

        if boot_daemons.is_empty() {
            info!("No daemons configured with boot_start = true");
            return Ok(());
        }

        info!("Found {} daemon(s) to start at boot", boot_daemons.len());

        for (id, daemon) in boot_daemons {
            info!("Starting boot daemon: {id}");

            let cmd = match shell_words::split(&daemon.run) {
                Ok(cmd) => cmd,
                Err(e) => {
                    error!("failed to parse command for boot daemon {id}: {e}");
                    continue;
                }
            };
            let mut run_opts = daemon.to_run_options(id, cmd);
            run_opts.autostop = false; // Boot daemons should not autostop
            run_opts.wait_ready = false; // Don't block on boot daemons

            match self.run(run_opts).await {
                Ok(IpcResponse::DaemonStart { .. }) | Ok(IpcResponse::DaemonReady { .. }) => {
                    info!("Successfully started boot daemon: {id}");
                }
                Ok(IpcResponse::DaemonAlreadyRunning) => {
                    info!("Boot daemon already running: {id}");
                }
                Ok(other) => {
                    warn!("Unexpected response when starting boot daemon {id}: {other:?}");
                }
                Err(e) => {
                    error!("Failed to start boot daemon {id}: {e}");
                }
            }
        }

        Ok(())
    }
}