Skip to main content

pitchfork_cli/supervisor/
state.rs

1//! State access layer for the supervisor
2//!
3//! All state getter/setter operations for daemons, shell directories, and notifications.
4
5use super::Supervisor;
6use crate::Result;
7use crate::daemon::Daemon;
8use crate::daemon::RunOptions;
9use crate::daemon_id::DaemonId;
10use crate::daemon_status::DaemonStatus;
11use crate::pitchfork_toml::CpuLimit;
12use crate::pitchfork_toml::CronRetrigger;
13use crate::pitchfork_toml::HealthCmd;
14use crate::pitchfork_toml::HealthHttp;
15use crate::pitchfork_toml::HealthPort;
16use crate::pitchfork_toml::MemoryLimit;
17use crate::pitchfork_toml::PitchforkToml;
18use crate::pitchfork_toml::PortConfig;
19use crate::pitchfork_toml::ReadyCmd;
20use crate::pitchfork_toml::ReadyHttp;
21use crate::pitchfork_toml::ReadyOutput;
22use crate::pitchfork_toml::ReadyPort;
23use crate::pitchfork_toml::Retry;
24use crate::pitchfork_toml::StopConfig;
25use crate::pitchfork_toml::WatchMode;
26use crate::procs::PROCS;
27use crate::state_file::DiskRecord;
28use indexmap::IndexMap;
29use std::collections::HashMap;
30use std::path::PathBuf;
31
32fn should_clean_daemon(
33    id: &DaemonId,
34    daemon: &Daemon,
35    namespaces: &[String],
36    daemons: &[DaemonId],
37) -> bool {
38    let namespace_matches =
39        namespaces.is_empty() || namespaces.iter().any(|ns| ns == id.namespace());
40    let daemon_matches = daemons.is_empty() || daemons.contains(id);
41    daemon.pid.is_none() && namespace_matches && daemon_matches
42}
43
44fn prune_candidate(
45    id: &DaemonId,
46    daemon: &Daemon,
47    namespaces: &[String],
48    daemons: &[DaemonId],
49) -> Option<(DaemonId, PathBuf)> {
50    should_clean_daemon(id, daemon, namespaces, daemons)
51        .then(|| daemon.dir.clone())
52        .flatten()
53        .map(|dir| (id.clone(), dir))
54}
55
56/// Options for upserting a daemon's state.
57///
58/// Use `UpsertDaemonOpts::builder(id)` to create, then set fields directly and call `.build()`.
59#[derive(Debug, Default)]
60pub(crate) struct UpsertDaemonOpts {
61    pub id: DaemonId,
62    pub pid: Option<u32>,
63    pub status: DaemonStatus,
64    pub shell_pid: Option<u32>,
65    pub dir: Option<PathBuf>,
66    pub cmd: Option<Vec<String>>,
67    pub run: Option<String>,
68    /// Start `cmd` directly, without a shell. `None` inherits the existing
69    /// record's value, like `oneshot`.
70    pub no_shell: Option<bool>,
71    /// `None` inherits the existing record's value; see
72    /// `Daemon::scheduled_from_config`.
73    pub scheduled_from_config: Option<bool>,
74    /// Stop the daemon when its directory is left. A start sets it from its
75    /// `RunOptions`, so removing `auto = ["stop"]` takes effect; `None`
76    /// inherits the existing record's value, like `oneshot`.
77    pub autostop: Option<bool>,
78    /// Run-to-completion task rather than a long-running service.
79    /// `None` inherits the existing record's value, so a status-only upsert
80    /// (stop, exit finalization) does not reclassify the daemon.
81    pub oneshot: Option<bool>,
82    /// A start sets it, `None` included, as it does `watch_base_dir`: an
83    /// ad-hoc `pitchfork run` of a former config cron daemon's id must not keep
84    /// that daemon's schedule, which also marks the record as made from
85    /// config. Every other upsert inherits it.
86    pub cron_schedule: Option<Option<String>>,
87    pub cron_retrigger: Option<CronRetrigger>,
88    pub cron_immediate: Option<bool>,
89    pub last_exit_success: Option<bool>,
90    pub retry: Option<Retry>,
91    pub retry_count: Option<u32>,
92    pub ready_delay: Option<u64>,
93    pub ready_output: Option<ReadyOutput>,
94    pub ready_http: Option<ReadyHttp>,
95    pub ready_port: Option<ReadyPort>,
96    /// See `Daemon::configured_ready_port`.
97    pub configured_ready_port: Option<ReadyPort>,
98    pub ready_cmd: Option<ReadyCmd>,
99    /// The ready checks above replace the record's: one left `None` is
100    /// cleared rather than inherited. See `RunOptions::replaces_ready_checks`.
101    pub replaces_ready_checks: bool,
102    pub health_cmd: Option<HealthCmd>,
103    pub health_http: Option<HealthHttp>,
104    pub health_port: Option<HealthPort>,
105    /// Port configuration
106    pub port: Option<PortConfig>,
107    /// Resolved ports actually used after auto-bump (may differ from expected).
108    /// `None` inherits the existing record's value; `Some` sets it explicitly,
109    /// including an empty vec, which clears ports left by a previous run.
110    pub resolved_port: Option<Vec<u16>>,
111    /// The first port the process is actually listening on (detected at runtime).
112    pub active_port: Option<u16>,
113    /// Optional stable slug alias for this daemon.
114    pub slug: Option<String>,
115    /// Whether to proxy this daemon (None = use global proxy.enable setting).
116    pub proxy: Option<bool>,
117    pub depends: Option<Vec<DaemonId>>,
118    pub env: Option<IndexMap<String, String>>,
119    pub watch: Option<Vec<String>>,
120    pub watch_mode: Option<WatchMode>,
121    /// The directory of the project whose config defined the daemon. A
122    /// start sets it, `None` included: an ad-hoc `pitchfork run` of a former
123    /// config daemon's id must not keep that daemon's directory, which marks
124    /// the record as made from config. Every other upsert inherits it.
125    pub watch_base_dir: Option<Option<PathBuf>>,
126    pub mise: Option<bool>,
127    /// `Some` replaces the saved template context for a deferred `run`, including
128    /// with `None` when a start no longer defers; `None` keeps what is saved.
129    pub deferred_template_context: Option<Option<String>>,
130    /// The project's mise binary. `None` keeps the record's, so a status-only
131    /// upsert (stop, exit finalization) does not lose it; a start sets it from
132    /// its `RunOptions`, so one removed from the project's settings is dropped.
133    pub mise_bin: Option<Option<PathBuf>>,
134    /// Unix user to run this daemon as
135    pub user: Option<String>,
136    /// Memory limit for the daemon process
137    pub memory_limit: Option<MemoryLimit>,
138    /// CPU usage limit as a percentage
139    pub cpu_limit: Option<CpuLimit>,
140    /// Unix signal to send for graceful shutdown
141    pub stop_signal: Option<StopConfig>,
142    /// Archive hook command invoked before retention prunes this daemon's logs.
143    pub archive_hook: Option<String>,
144    /// Log format for this daemon.
145    pub log_format: Option<String>,
146    /// Allocate a pseudo-terminal for the daemon process.
147    pub pty: Option<bool>,
148    /// True for config-only cron daemons auto-registered into state.
149    pub config_registered: bool,
150    /// A start that describes the daemon in full, so an unset configuration
151    /// field clears the existing record's value instead of inheriting it. An
152    /// ad-hoc run under a config daemon's id must not keep that daemon's
153    /// command, schedule or environment. See `RunOptions::replaces_saved_record`.
154    pub replaces_config: bool,
155    /// Idle-shutdown ownership. `None` inherits the existing record's, so a
156    /// status-only upsert (stop, exit finalization) keeps it; a start sets it
157    /// from its `RunOptions`, which is what makes an explicit start explicit.
158    pub proxy_idle_timeout_ms: Option<Option<u64>>,
159}
160
161/// Builder for UpsertDaemonOpts - ensures daemon ID is always provided.
162///
163/// # Example
164/// ```ignore
165/// let opts = UpsertDaemonOpts::builder(daemon_id)
166///     .set(|o| {
167///         o.pid = Some(pid);
168///         o.status = DaemonStatus::Running;
169///     })
170///     .build();
171/// ```
172#[derive(Debug)]
173pub(crate) struct UpsertDaemonOptsBuilder {
174    pub opts: UpsertDaemonOpts,
175}
176
177impl UpsertDaemonOpts {
178    /// Create a builder with the required daemon ID.
179    pub fn builder(id: DaemonId) -> UpsertDaemonOptsBuilder {
180        UpsertDaemonOptsBuilder {
181            opts: UpsertDaemonOpts {
182                id,
183                ..Default::default()
184            },
185        }
186    }
187
188    /// Build an `UpsertDaemonOptsBuilder` from `RunOptions`, mapping all
189    /// config-carried fields. Callers chain `.set()` to add runtime-specific
190    /// fields (pid, resolved_port, etc.) and then call `.build()`.
191    pub(crate) fn from_run_options(
192        opts: &RunOptions,
193        status: DaemonStatus,
194    ) -> UpsertDaemonOptsBuilder {
195        UpsertDaemonOpts::builder(opts.id.clone()).set(|o| {
196            o.replaces_config = opts.replaces_saved_record;
197            o.status = status;
198            o.shell_pid = opts.shell_pid;
199            o.dir = Some(opts.dir.0.clone());
200            o.cmd = Some(opts.cmd.clone());
201            o.run = opts.run.clone();
202            o.no_shell = Some(opts.no_shell);
203            // Only a client clears it; the supervisor's own starts (the
204            // schedule, retries, file watching) leave it as it was.
205            o.scheduled_from_config = opts.requested_by_client.then_some(false);
206            o.autostop = Some(opts.autostop);
207            o.oneshot = Some(opts.oneshot);
208            o.cron_schedule = Some(opts.cron_schedule.clone());
209            o.cron_retrigger = opts.cron_retrigger;
210            o.cron_immediate = opts.cron_immediate;
211            o.retry = Some(opts.retry);
212            o.retry_count = Some(opts.retry_count);
213            o.ready_delay = opts.ready_delay;
214            o.ready_output = opts.ready_output.clone();
215            o.ready_http = opts.ready_http.clone();
216            o.ready_port = opts.ready_port.clone();
217            o.configured_ready_port = opts.ready_port.clone();
218            o.ready_cmd = opts.ready_cmd.clone();
219            o.replaces_ready_checks = opts.replaces_ready_checks;
220            o.health_cmd = opts.health_cmd.clone();
221            o.health_http = opts.health_http.clone();
222            o.health_port = opts.health_port.clone();
223            o.port = opts.port.clone();
224            o.depends = Some(opts.depends.clone());
225            o.env = opts.env.clone();
226            o.watch = Some(opts.watch.clone());
227            o.watch_mode = Some(opts.watch_mode);
228            o.watch_base_dir = Some(opts.watch_base_dir.clone());
229            o.mise = opts.mise;
230            o.deferred_template_context = Some(opts.deferred_template_context.clone());
231            o.mise_bin = Some(opts.mise_bin.clone());
232            o.user = opts.user.clone();
233            o.memory_limit = opts.memory_limit;
234            o.cpu_limit = opts.cpu_limit;
235            o.stop_signal = opts.stop_signal;
236            o.pty = opts.pty;
237            o.archive_hook = opts.archive_hook.clone();
238            o.log_format = opts.log_format.clone();
239            o.proxy_idle_timeout_ms = Some(opts.proxy_idle_timeout_ms);
240        })
241    }
242}
243
244impl UpsertDaemonOptsBuilder {
245    /// Modify opts fields with a closure.
246    pub fn set<F: FnOnce(&mut UpsertDaemonOpts)>(mut self, f: F) -> Self {
247        f(&mut self.opts);
248        self
249    }
250
251    /// Build the UpsertDaemonOpts.
252    pub fn build(self) -> UpsertDaemonOpts {
253        self.opts
254    }
255}
256
257impl Supervisor {
258    /// Put this supervisor's record back into the state file if the file no
259    /// longer has it.
260    ///
261    /// The CLI decides whether a supervisor is running, and which process
262    /// `supervisor stop` signals, from this record. The supervisor only
263    /// writes the file when its own state changes, so if the file is replaced
264    /// or rewritten by something else the record could stay missing
265    /// indefinitely, leaving this supervisor running but unaccounted for.
266    /// Only the record is restored; the rest of the file is left as it is.
267    pub(crate) async fn restore_own_record(&self) {
268        if self
269            .shutting_down
270            .load(std::sync::atomic::Ordering::Acquire)
271        {
272            return;
273        }
274        let pitchfork_id = DaemonId::pitchfork();
275        let state = self.state_file.lock().await;
276        // Checked again under the lock: `close()` may have begun while this
277        // waited for it. (Its removal of the record happens under this lock
278        // too, so a restore can never outlive it; this just skips the work.)
279        if self
280            .shutting_down
281            .load(std::sync::atomic::Ordering::Acquire)
282        {
283            return;
284        }
285        let Some(own) = state
286            .daemons
287            .get(&pitchfork_id)
288            .filter(|d| d.pid.is_some())
289            .cloned()
290        else {
291            return;
292        };
293        let own_pid = own.pid.unwrap_or_default();
294        let path = state.path.clone();
295        // The state lock stays held across the file work, as it is for a
296        // flush, so the two cannot interleave.
297        let result = tokio::task::spawn_blocking(move || {
298            crate::state_file::StateFile::restore_daemon_in_file(&path, &own)
299        })
300        .await;
301        match result {
302            Ok(Ok(DiskRecord::Present)) => {}
303            Ok(Ok(DiskRecord::Restored)) => {
304                // Not flushed yet (e.g. a client connecting right after
305                // startup): the record was simply written early.
306                if state.is_dirty() {
307                    debug!("recorded this supervisor in the state file ahead of the next flush");
308                } else {
309                    warn!(
310                        "state file {} no longer recorded this supervisor (pid {own_pid}); restored it",
311                        state.path.display()
312                    );
313                }
314                // The file now differs from what this supervisor last wrote,
315                // so its next flush must not be skipped as unchanged.
316                state.forget_written_snapshot();
317            }
318            Ok(Ok(DiskRecord::Unparseable)) => {
319                warn!(
320                    "state file {} cannot be parsed; rewriting it from the supervisor's state",
321                    state.path.display()
322                );
323                state.force_next_write();
324            }
325            Ok(Err(e)) => warn!("failed to restore the supervisor record in the state file: {e}"),
326            Err(e) => warn!("failed to restore the supervisor record in the state file: {e}"),
327        }
328    }
329
330    /// Run options for starting `id` from its config, as `pitchfork start`
331    /// would build them: templates rendered and the top-level `[env]` merged.
332    ///
333    /// Other daemons' ports come from the state held here rather than the file
334    /// on disk, which can trail it — a daemon started a moment ago may not be
335    /// written out yet, and a template referring to its port would fail.
336    pub(crate) async fn run_options_from_config(
337        &self,
338        id: &DaemonId,
339        config: &crate::pitchfork_toml::PitchforkTomlDaemon,
340        pt: &PitchforkToml,
341    ) -> Result<RunOptions> {
342        let resolved_daemons: std::collections::HashMap<DaemonId, Vec<u16>> = {
343            let state = self.state_file.lock().await;
344            state
345                .daemons
346                .iter()
347                .filter(|(_, d)| !d.resolved_port.is_empty())
348                .map(|(id, d)| (id.clone(), d.resolved_port.clone()))
349                .collect()
350        };
351        // On a blocking worker, as for a client's start: building the context
352        // reads configuration and derives hostnames by walking the project's
353        // checkouts, which must not hold up the supervisor's other tasks.
354        let id = id.clone();
355        let mut config = config.clone();
356        let pt = pt.clone();
357        tokio::task::spawn_blocking(move || {
358            crate::ipc::batch::render_daemon_config_with(&id, &mut config, &pt, &resolved_daemons)?;
359            let cmd = config
360                .run
361                .argv()
362                .map_err(|e| miette::miette!("failed to parse command for daemon {id}: {e}"))?;
363            let mut opts = config.to_run_options(&id, cmd);
364            // A client's start resolves these from the daemon's project before
365            // it reaches the supervisor, which would otherwise use its own
366            // settings. Rendering assumed the project's, so launch has to as well.
367            let (mise, mise_bin) = crate::ipc::batch::project_mise_options(&config);
368            opts.mise = Some(mise);
369            opts.mise_bin = mise_bin;
370            Ok(opts)
371        })
372        .await
373        .map_err(|e| miette::miette!("rendering daemon config panicked: {e}"))?
374    }
375
376    /// Upsert a daemon's state, merging with existing values
377    pub(crate) async fn upsert_daemon(&self, opts: UpsertDaemonOpts) -> Result<Daemon> {
378        info!(
379            "upserting daemon: {} pid: {} status: {}",
380            opts.id,
381            opts.pid.unwrap_or(0),
382            opts.status
383        );
384        let mut state_file = self.state_file.lock().await;
385        let existing = state_file.daemons.get(&opts.id);
386        // What configuration fields fall back to: nothing for a start, which
387        // sets them all; see `UpsertDaemonOpts::replaces_config`.
388        let configured = existing.filter(|_| !opts.replaces_config);
389        // What unset ready checks fall back to: also nothing when this start's
390        // ready checks replace the daemon's; see `replaces_ready_checks`.
391        let ready_base = configured.filter(|_| !opts.replaces_ready_checks);
392        let daemon = Daemon {
393            id: opts.id.clone(),
394            // title/start_time identify the process for orphan cleanup after a
395            // supervisor crash. They are looked up from the process cache; if
396            // the cache has no entry (e.g. an upsert between refreshes) fall
397            // back to the existing values as long as the PID is unchanged, so
398            // the recorded identity is not wiped mid-lifetime.
399            title: opts.pid.and_then(|pid| {
400                PROCS.title(pid).or_else(|| {
401                    existing
402                        .filter(|d| d.pid == Some(pid))
403                        .and_then(|d| d.title.clone())
404                })
405            }),
406            start_time: opts.pid.and_then(|pid| {
407                PROCS.start_time(pid).or_else(|| {
408                    existing
409                        .filter(|d| d.pid == Some(pid))
410                        .and_then(|d| d.start_time)
411                })
412            }),
413            // Boot time is a system-wide constant, so it needs no cache
414            // fallback — it is recorded for any daemon with a live PID and
415            // dropped when the PID is cleared.
416            boot_time: opts.pid.map(|_| PROCS.boot_time()),
417            pid: opts.pid,
418            status: opts.status,
419            shell_pid: opts.shell_pid,
420            autostop: opts
421                .autostop
422                .unwrap_or_else(|| existing.is_some_and(|d| d.autostop)),
423            // A start carries the current config value (including a removed
424            // `oneshot = true`, which must reclassify the daemon); every other
425            // upsert leaves it None and inherits, so finalizing a completed
426            // oneshot's exit does not forget what it is.
427            oneshot: opts
428                .oneshot
429                .unwrap_or_else(|| existing.is_some_and(|d| d.oneshot)),
430            dir: opts.dir.or(configured.and_then(|d| d.dir.clone())),
431            cmd: opts.cmd.or(configured.and_then(|d| d.cmd.clone())),
432            // A start in the argv form has no command line, and must not
433            // inherit the one a shell-form run left behind.
434            run: if opts.no_shell == Some(true) {
435                None
436            } else {
437                opts.run.or(configured.and_then(|d| d.run.clone()))
438            },
439            no_shell: opts
440                .no_shell
441                .unwrap_or_else(|| existing.is_some_and(|d| d.no_shell)),
442            scheduled_from_config: opts
443                .scheduled_from_config
444                .unwrap_or_else(|| existing.is_some_and(|d| d.scheduled_from_config)),
445            cron_schedule: opts
446                .cron_schedule
447                .unwrap_or_else(|| configured.and_then(|d| d.cron_schedule.clone())),
448            cron_retrigger: opts
449                .cron_retrigger
450                .or(configured.and_then(|d| d.cron_retrigger)),
451            cron_immediate: opts
452                .cron_immediate
453                .or(configured.and_then(|d| d.cron_immediate)),
454            last_cron_triggered: existing.and_then(|d| d.last_cron_triggered),
455            last_cron_run: existing.and_then(|d| d.last_cron_run),
456            last_exit_success: opts
457                .last_exit_success
458                .or(existing.and_then(|d| d.last_exit_success)),
459            retry: opts
460                .retry
461                .unwrap_or_else(|| existing.map(|d| d.retry).unwrap_or_default()),
462            retry_count: opts
463                .retry_count
464                .unwrap_or(existing.map(|d| d.retry_count).unwrap_or(0)),
465            ready_delay: opts.ready_delay.or(ready_base.and_then(|d| d.ready_delay)),
466            ready_output: opts
467                .ready_output
468                .or(ready_base.and_then(|d| d.ready_output.clone())),
469            ready_http: opts
470                .ready_http
471                .or(ready_base.and_then(|d| d.ready_http.clone())),
472            ready_port: opts
473                .ready_port
474                .or(ready_base.and_then(|d| d.ready_port.clone())),
475            configured_ready_port: opts
476                .configured_ready_port
477                .or(ready_base.and_then(|d| d.configured_ready_port.clone())),
478            ready_cmd: opts
479                .ready_cmd
480                .or(ready_base.and_then(|d| d.ready_cmd.clone())),
481            health_cmd: opts
482                .health_cmd
483                .or(configured.and_then(|d| d.health_cmd.clone())),
484            health_http: opts
485                .health_http
486                .or(configured.and_then(|d| d.health_http.clone())),
487            health_port: opts
488                .health_port
489                .or(configured.and_then(|d| d.health_port.clone())),
490            port: opts
491                .port
492                .or_else(|| configured.and_then(|d| d.port.clone())),
493            resolved_port: match opts.resolved_port {
494                Some(ports) => ports,
495                None => existing
496                    .map(|d| d.resolved_port.clone())
497                    .unwrap_or_default(),
498            },
499            depends: opts
500                .depends
501                .unwrap_or_else(|| existing.map(|d| d.depends.clone()).unwrap_or_default()),
502            env: opts.env.or(configured.and_then(|d| d.env.clone())),
503            watch: opts
504                .watch
505                .unwrap_or_else(|| existing.map(|d| d.watch.clone()).unwrap_or_default()),
506            watch_mode: opts
507                .watch_mode
508                .unwrap_or_else(|| existing.map(|d| d.watch_mode).unwrap_or_default()),
509            watch_base_dir: opts
510                .watch_base_dir
511                .unwrap_or_else(|| configured.and_then(|d| d.watch_base_dir.clone())),
512            mise: opts.mise.or(configured.and_then(|d| d.mise)),
513            deferred_template_context: opts
514                .deferred_template_context
515                .unwrap_or_else(|| existing.and_then(|d| d.deferred_template_context.clone())),
516            mise_bin: opts
517                .mise_bin
518                .unwrap_or_else(|| configured.and_then(|d| d.mise_bin.clone())),
519            user: opts.user.or(configured.and_then(|d| d.user.clone())),
520            proxy: opts.proxy.or(existing.and_then(|d| d.proxy)),
521            // active_port is intentionally NOT inherited from the existing daemon.
522            // When a daemon restarts, the new process has not yet bound a port, so
523            // carrying over the old process's active_port would cause the proxy to
524            // route to a port that is no longer listening.  The port will be
525            // re-detected by detect_and_store_active_port once the new process is ready.
526            active_port: opts.active_port,
527            slug: opts.slug.or(existing.and_then(|d| d.slug.clone())),
528            memory_limit: opts
529                .memory_limit
530                .or(configured.and_then(|d| d.memory_limit)),
531            cpu_limit: opts.cpu_limit.or(configured.and_then(|d| d.cpu_limit)),
532            stop_signal: opts.stop_signal.or(configured.and_then(|d| d.stop_signal)),
533            archive_hook: opts
534                .archive_hook
535                .or(configured.and_then(|d| d.archive_hook.clone())),
536            log_format: opts
537                .log_format
538                .or(configured.and_then(|d| d.log_format.clone())),
539            pty: opts.pty.or(configured.and_then(|d| d.pty)),
540            config_registered: opts.config_registered,
541            proxy_idle_timeout_ms: opts
542                .proxy_idle_timeout_ms
543                .unwrap_or_else(|| existing.and_then(|d| d.proxy_idle_timeout_ms)),
544        };
545        state_file.insert_daemon(&opts.id, daemon.clone());
546        Ok(daemon)
547    }
548
549    /// Enable a daemon (remove from disabled set)
550    pub async fn enable(&self, id: &DaemonId) -> Result<bool> {
551        info!("enabling daemon: {id}");
552        let config = PitchforkToml::all_merged_all_namespaces().await?;
553        let mut state_file = self.state_file.lock().await;
554        let exists = state_file.daemons.contains_key(id) || config.daemons.contains_key(id);
555        if !exists {
556            return Err(miette::miette!("daemon '{}' not found", id));
557        }
558        let result = state_file.enable_daemon(id);
559        Ok(result)
560    }
561
562    /// Disable a daemon (add to disabled set)
563    pub async fn disable(&self, id: &DaemonId) -> Result<bool> {
564        info!("disabling daemon: {id}");
565        let config = PitchforkToml::all_merged_all_namespaces().await?;
566        let not_found = || miette::miette!("daemon '{}' not found", id);
567        // Checked before `stop_lock`, whose entries are never removed, so an
568        // unknown ID does not leave one behind.
569        if !config.daemons.contains_key(id)
570            && !self.state_file.lock().await.daemons.contains_key(id)
571        {
572            return Err(not_found());
573        }
574        // Taken before the state file, in `run_inner`'s order, so a retry
575        // that has already checked the disabled set finishes its start first.
576        let lock = self.stop_lock(id).await;
577        let _guard = lock.lock().await;
578        let mut state_file = self.state_file.lock().await;
579        let exists = state_file.daemons.contains_key(id) || config.daemons.contains_key(id);
580        if !exists {
581            return Err(not_found());
582        }
583        let result = state_file.disable_daemon(id);
584        // A start sleeping out a retry backoff makes no further attempt, so
585        // it is woken now rather than when the backoff ends.
586        self.cancel_retrying(id);
587        Ok(result)
588    }
589
590    /// Get a daemon by ID
591    pub(crate) async fn get_daemon(&self, id: &DaemonId) -> Option<Daemon> {
592        self.state_file.lock().await.daemons.get(id).cloned()
593    }
594
595    /// Get all active daemons (those with PIDs, excluding pitchfork itself)
596    pub(crate) async fn active_daemons(&self) -> Vec<Daemon> {
597        let pitchfork_id = DaemonId::pitchfork();
598        self.state_file
599            .lock()
600            .await
601            .daemons
602            .values()
603            .filter(|d| d.pid.is_some() && d.id != pitchfork_id)
604            .cloned()
605            .collect()
606    }
607
608    /// Remove a daemon from state
609    pub(crate) async fn remove_daemon(&self, id: &DaemonId) -> Result<()> {
610        let mut state_file = self.state_file.lock().await;
611        state_file.remove_daemon(id);
612        Ok(())
613    }
614
615    /// Set the shell's working directory
616    pub(crate) async fn set_shell_dir(&self, shell_pid: u32, dir: PathBuf) -> Result<()> {
617        let mut state_file = self.state_file.lock().await;
618        state_file.set_shell_dir(shell_pid, dir);
619        Ok(())
620    }
621
622    /// Get the shell's working directory
623    pub(crate) async fn get_shell_dir(&self, shell_pid: u32) -> Option<PathBuf> {
624        self.state_file
625            .lock()
626            .await
627            .shell_dirs
628            .get(&shell_pid.to_string())
629            .cloned()
630    }
631
632    /// Remove a shell PID from tracking
633    #[cfg(unix)]
634    pub(crate) async fn remove_shell_pid(&self, shell_pid: u32) -> Result<()> {
635        let mut state_file = self.state_file.lock().await;
636        state_file.remove_shell_dir(shell_pid);
637        Ok(())
638    }
639
640    /// Get all directories with their associated shell PIDs
641    pub(crate) async fn get_dirs_with_shell_pids(&self) -> HashMap<PathBuf, Vec<u32>> {
642        self.state_file.lock().await.shell_dirs.iter().fold(
643            HashMap::new(),
644            |mut acc, (pid, dir)| {
645                if let Ok(pid) = pid.parse() {
646                    acc.entry(dir.clone()).or_default().push(pid);
647                }
648                acc
649            },
650        )
651    }
652
653    /// Get pending notifications and clear the queue
654    pub(crate) async fn get_notifications(&self) -> Vec<(log::LevelFilter, String)> {
655        self.pending_notifications.lock().await.drain(..).collect()
656    }
657
658    /// Clean up daemons that have no PID
659    pub(crate) async fn clean(&self) -> Result<()> {
660        self.clean_filtered(&[], &[], false).await?;
661        Ok(())
662    }
663
664    /// Clean up PID-less daemon registrations matching all supplied filters.
665    pub(crate) async fn clean_filtered(
666        &self,
667        namespaces: &[String],
668        daemons: &[DaemonId],
669        prune: bool,
670    ) -> Result<u64> {
671        if prune {
672            let candidates: Vec<(DaemonId, PathBuf)> = {
673                let state_file = self.state_file.lock().await;
674                state_file
675                    .daemons
676                    .iter()
677                    .filter_map(|(id, daemon)| prune_candidate(id, daemon, namespaces, daemons))
678                    .collect()
679            };
680
681            let mut missing = HashMap::new();
682            for (id, dir) in candidates {
683                match tokio::fs::try_exists(&dir).await {
684                    Ok(false) => {
685                        missing.insert(id, dir);
686                    }
687                    Ok(true) => {}
688                    // Unknown is not gone: keep the record, prune the rest.
689                    Err(e) => warn!("cannot check {} to prune {id}: {e}", dir.display()),
690                }
691            }
692
693            let mut removed = 0;
694            let mut state_file = self.state_file.lock().await;
695            state_file.retain_daemons(|id, daemon| {
696                let remove = should_clean_daemon(id, daemon, namespaces, daemons)
697                    && missing
698                        .get(id)
699                        .is_some_and(|missing_dir| daemon.dir.as_ref() == Some(missing_dir));
700                removed += u64::from(remove);
701                !remove
702            });
703            return Ok(removed);
704        }
705
706        let mut removed = 0;
707        let mut state_file = self.state_file.lock().await;
708        state_file.retain_daemons(|id, daemon| {
709            let remove = should_clean_daemon(id, daemon, namespaces, daemons);
710            removed += u64::from(remove);
711            !remove
712        });
713        Ok(removed)
714    }
715
716    /// Return the union of active directories from shell tracking and project
717    /// sessions. These are the directories that should keep auto-stop daemons
718    /// alive.
719    pub(crate) async fn get_active_directories(&self) -> Vec<PathBuf> {
720        self.state_file.lock().await.active_directories()
721    }
722
723    /// Collect all project sessions as `(pid, dir, liveness_title)`. Every
724    /// project session carries a host PID in its key, so every session is a
725    /// liveness session. Used by the refresh loop to clean up stale sessions.
726    pub(crate) async fn get_liveness_sessions(&self) -> Vec<(u32, PathBuf, Option<String>)> {
727        self.state_file
728            .lock()
729            .await
730            .iter_project_sessions()
731            .into_iter()
732            .filter_map(|(pid_str, dir, session)| {
733                pid_str
734                    .parse()
735                    .ok()
736                    .map(|pid| (pid, dir.clone(), session.liveness_title.clone()))
737            })
738            .collect()
739    }
740
741    /// Build a snapshot of all project sessions with live liveness status
742    /// filled in from `PROCS`. Used to answer `GetProjectSessions` IPC
743    /// requests.
744    pub(crate) async fn get_project_sessions_info(&self) -> Vec<crate::ipc::ProjectSessionInfo> {
745        let sessions: Vec<(u32, PathBuf, Option<String>)> = self
746            .state_file
747            .lock()
748            .await
749            .iter_project_sessions()
750            .into_iter()
751            .filter_map(|(pid_str, dir, session)| {
752                pid_str
753                    .parse()
754                    .ok()
755                    .map(|pid| (pid, dir.clone(), session.liveness_title.clone()))
756            })
757            .collect();
758        let pids: Vec<u32> = sessions.iter().map(|(pid, _, _)| *pid).collect();
759        if !pids.is_empty() {
760            PROCS.refresh_pids(&pids);
761        }
762        sessions
763            .into_iter()
764            .map(
765                |(pid, directory, liveness_title)| crate::ipc::ProjectSessionInfo {
766                    pid,
767                    directory,
768                    liveness_title,
769                    alive: PROCS.is_running(pid),
770                    current_title: PROCS.title(pid),
771                },
772            )
773            .collect()
774    }
775
776    /// Atomically enter (or replace) a project session for `(pid, dir)`.
777    /// Returns the previous session, if any, so the caller can evaluate the
778    /// previous entry for autostop.
779    pub(crate) async fn enter_project_session(
780        &self,
781        pid: u32,
782        dir: PathBuf,
783    ) -> Result<Option<crate::state_file::ProjectSession>> {
784        if pid == 0 {
785            return Err(miette::miette!("invalid host PID 0"));
786        }
787        if pid > i32::MAX as u32 {
788            return Err(miette::miette!("host PID {pid} exceeds i32::MAX"));
789        }
790        PROCS.refresh_pids(&[pid]);
791        let liveness_title = PROCS.title(pid);
792        // On Unix, reject dead host PIDs up front so we don't register a
793        // session the refresh loop would immediately evict. On Windows, Git
794        // Bash `$$` is a Cygwin-internal PID invisible to sysinfo, so the
795        // liveness check is skipped and sessions rely on explicit
796        // `project leave`.
797        #[cfg(unix)]
798        if !PROCS.is_running(pid) {
799            return Err(miette::miette!("host PID {pid} is not running"));
800        }
801        let mut state_file = self.state_file.lock().await;
802        let previous = state_file.set_project_session(
803            pid,
804            dir,
805            crate::state_file::ProjectSession { liveness_title },
806        );
807        Ok(previous)
808    }
809
810    /// Atomically remove a project session for `(pid, dir)`. Returns the
811    /// directory of the removed session so the caller can evaluate it for
812    /// autostop.
813    pub(crate) async fn leave_project_session(
814        &self,
815        pid: u32,
816        dir: &std::path::Path,
817    ) -> Result<Option<PathBuf>> {
818        let mut state_file = self.state_file.lock().await;
819        if state_file.remove_project_session(pid, dir).is_some() {
820            Ok(Some(dir.to_path_buf()))
821        } else {
822            Ok(None)
823        }
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    fn daemon(id: &DaemonId, pid: Option<u32>, dir: Option<PathBuf>) -> Daemon {
832        Daemon {
833            id: id.clone(),
834            pid,
835            dir,
836            ..Daemon::default()
837        }
838    }
839
840    #[test]
841    fn clean_filters_intersect_and_preserve_running_daemons() {
842        let api = DaemonId::new("project-a", "api");
843        let worker = DaemonId::new("project-a", "worker");
844        let namespaces = vec!["project-a".to_string()];
845        let daemons = vec![api.clone()];
846
847        assert!(should_clean_daemon(
848            &api,
849            &daemon(&api, None, None),
850            &namespaces,
851            &daemons,
852        ));
853        assert!(!should_clean_daemon(
854            &worker,
855            &daemon(&worker, None, None),
856            &namespaces,
857            &daemons,
858        ));
859        assert!(!should_clean_daemon(
860            &api,
861            &daemon(&api, Some(42), None),
862            &namespaces,
863            &daemons,
864        ));
865    }
866
867    #[test]
868    fn prune_candidates_require_a_recorded_directory() {
869        let id = DaemonId::new("project-a", "api");
870        assert!(prune_candidate(&id, &daemon(&id, None, None), &[], &[]).is_none());
871        assert_eq!(
872            prune_candidate(
873                &id,
874                &daemon(&id, None, Some(PathBuf::from("missing"))),
875                &[],
876                &[]
877            ),
878            Some((id, PathBuf::from("missing")))
879        );
880    }
881}