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