pitchfork_cli/daemon.rs
1use crate::config_types::OneshotWait;
2use crate::daemon_id::DaemonId;
3use crate::daemon_status::DaemonStatus;
4use crate::pitchfork_toml::{
5 CpuLimit, CronRetrigger, Dir, HealthCmd, HealthHttp, HealthPort, MemoryLimit, PortConfig,
6 ReadyCmd, ReadyHttp, ReadyOutput, ReadyPort, Retry, StopConfig, WatchMode,
7};
8use indexmap::IndexMap;
9use std::fmt::Display;
10use std::path::PathBuf;
11
12/// Validates a daemon ID to ensure it's safe for use in file paths and IPC.
13///
14/// A valid daemon ID:
15/// - Is not empty
16/// - Does not contain backslashes (`\`)
17/// - Does not contain parent directory references (`..`)
18/// - Does not contain spaces
19/// - Does not contain `--` (reserved for path encoding of `/`)
20/// - Is not `.` (current directory)
21/// - Contains only printable ASCII characters
22/// - If qualified (contains `/`), has exactly one `/` separating namespace and short ID
23///
24/// Format: `[namespace/]short_id`
25/// - Qualified: `project/api`, `global/web`
26/// - Short: `api`, `web`
27///
28/// This validation prevents path traversal attacks when daemon IDs are used
29/// to construct log file paths or other filesystem operations.
30pub fn is_valid_daemon_id(id: &str) -> bool {
31 if id.contains('/') {
32 DaemonId::parse(id).is_ok()
33 } else {
34 DaemonId::try_new("global", id).is_ok()
35 }
36}
37
38#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
39pub struct Daemon {
40 pub id: DaemonId,
41 pub title: Option<String>,
42 pub pid: Option<u32>,
43 /// High-resolution kernel start token recorded at spawn. Together with
44 /// `pid` this identifies the process across a supervisor crash: a recycled
45 /// PID has a different token, so orphan cleanup can tell a genuine orphan
46 /// from an unrelated process.
47 #[serde(skip_serializing_if = "Option::is_none", default)]
48 pub start_time: Option<u64>,
49 /// System boot time (seconds since epoch) recorded at spawn. Lets orphan
50 /// reconciliation tell a daemon that died under a crashed supervisor
51 /// during this boot from one whose process died with the machine, which
52 /// need different terminal states. Unlike `start_time` this is a
53 /// wall-clock value comparable across processes and platforms.
54 #[serde(skip_serializing_if = "Option::is_none", default)]
55 pub boot_time: Option<u64>,
56 pub shell_pid: Option<u32>,
57 pub status: DaemonStatus,
58 pub dir: Option<PathBuf>,
59 #[serde(skip_serializing_if = "Option::is_none", default)]
60 pub cmd: Option<Vec<String>>,
61 /// Original shell command string, persisted for retry/watch restarts.
62 #[serde(skip_serializing_if = "Option::is_none", default)]
63 pub run: Option<String>,
64 pub autostop: bool,
65 #[serde(skip_serializing_if = "Option::is_none", default)]
66 pub cron_schedule: Option<String>,
67 #[serde(skip_serializing_if = "Option::is_none", default)]
68 pub cron_retrigger: Option<CronRetrigger>,
69 #[serde(skip_serializing_if = "Option::is_none", default)]
70 pub cron_immediate: Option<bool>,
71 #[serde(skip_serializing_if = "Option::is_none", default)]
72 pub last_cron_triggered: Option<chrono::DateTime<chrono::Local>>,
73 #[serde(skip_serializing_if = "Option::is_none", default)]
74 pub last_exit_success: Option<bool>,
75 #[serde(default)]
76 pub retry: Retry,
77 #[serde(default)]
78 pub retry_count: u32,
79 #[serde(skip_serializing_if = "Option::is_none", default)]
80 pub ready_delay: Option<u64>,
81 #[serde(skip_serializing_if = "Option::is_none", default)]
82 pub ready_output: Option<ReadyOutput>,
83 #[serde(skip_serializing_if = "Option::is_none", default)]
84 pub ready_http: Option<ReadyHttp>,
85 #[serde(skip_serializing_if = "Option::is_none", default)]
86 pub ready_port: Option<ReadyPort>,
87 #[serde(skip_serializing_if = "Option::is_none", default)]
88 pub ready_cmd: Option<ReadyCmd>,
89 #[serde(skip_serializing_if = "Option::is_none", default)]
90 pub health_cmd: Option<HealthCmd>,
91 #[serde(skip_serializing_if = "Option::is_none", default)]
92 pub health_http: Option<HealthHttp>,
93 #[serde(skip_serializing_if = "Option::is_none", default)]
94 pub health_port: Option<HealthPort>,
95 /// Port configuration (expected ports and auto-bump settings)
96 #[serde(skip_serializing_if = "Option::is_none", default)]
97 pub port: Option<PortConfig>,
98 /// Resolved ports actually used after auto-bump (may differ from expected)
99 #[serde(skip_serializing_if = "Vec::is_empty", default)]
100 pub resolved_port: Vec<u16>,
101 /// The first port the process is actually listening on (detected at runtime via listeners crate).
102 /// This is the source of truth for the reverse proxy. Cleared when the daemon stops.
103 #[serde(skip_serializing_if = "Option::is_none", default)]
104 pub active_port: Option<u16>,
105 /// Optional stable slug alias for this daemon (used in proxy URLs and CLI commands).
106 #[serde(skip_serializing_if = "Option::is_none", default)]
107 pub slug: Option<String>,
108 /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
109 #[serde(skip_serializing_if = "Option::is_none", default)]
110 pub proxy: Option<bool>,
111 #[serde(skip_serializing_if = "Vec::is_empty", default)]
112 pub depends: Vec<DaemonId>,
113 #[serde(skip_serializing_if = "Option::is_none", default)]
114 pub env: Option<IndexMap<String, String>>,
115 #[serde(skip_serializing_if = "Vec::is_empty", default)]
116 pub watch: Vec<String>,
117 #[serde(default)]
118 pub watch_mode: WatchMode,
119 #[serde(skip_serializing_if = "Option::is_none", default)]
120 pub watch_base_dir: Option<PathBuf>,
121 /// Whether to use mise for this daemon (None = inherit global general.mise setting).
122 ///
123 /// # Schema compatibility note
124 /// This field changed from `bool` to `Option<bool>` with `skip_serializing_if = "Option::is_none"`.
125 /// - **Upgrade (old → new):** safe — old files contain `mise = true/false`, which deserialize
126 /// correctly as `Some(true)` / `Some(false)`.
127 /// - **Downgrade (new → old):** if `mise` is `None` (inherit global), the key is omitted from
128 /// the state file. An old binary reads the missing key as `false`, ignoring `general.mise = true`.
129 /// Any daemon that relied on the global setting would silently stop using mise after a downgrade.
130 #[serde(skip_serializing_if = "Option::is_none", default)]
131 pub mise: Option<bool>,
132 /// Template context handed to `mise x` for a `run` command left unrendered, kept
133 /// so a restart rebuilt from this record can pass it again.
134 #[serde(skip_serializing_if = "Option::is_none", default)]
135 pub deferred_template_context: Option<String>,
136 /// The mise binary the daemon's project set with `general.mise_bin`, kept
137 /// so a retry or a cron run wraps the command with the same mise. `None`
138 /// leaves it to the supervisor's own settings and search.
139 ///
140 /// # Schema compatibility note
141 /// Omitted when `None`, and read as `None` when missing, so state files
142 /// from older binaries load unchanged. An older binary reading a newer
143 /// file ignores the key and falls back to its own `mise_bin` lookup.
144 #[serde(skip_serializing_if = "Option::is_none", default)]
145 pub mise_bin: Option<PathBuf>,
146 /// Unix user to run this daemon as.
147 #[serde(skip_serializing_if = "Option::is_none", default)]
148 pub user: Option<String>,
149 /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
150 #[serde(skip_serializing_if = "Option::is_none", default)]
151 pub memory_limit: Option<MemoryLimit>,
152 /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
153 #[serde(skip_serializing_if = "Option::is_none", default)]
154 pub cpu_limit: Option<CpuLimit>,
155 /// Unix signal to send for graceful shutdown (default: SIGTERM)
156 #[serde(skip_serializing_if = "Option::is_none", default)]
157 pub stop_signal: Option<StopConfig>,
158 /// Archive hook command invoked before retention prunes this daemon's logs.
159 #[serde(skip_serializing_if = "Option::is_none", default)]
160 pub archive_hook: Option<String>,
161 /// Log format for this daemon.
162 #[serde(skip_serializing_if = "Option::is_none", default)]
163 pub log_format: Option<String>,
164 /// Allocate a pseudo-terminal for the daemon process.
165 #[serde(skip_serializing_if = "Option::is_none", default)]
166 pub pty: Option<bool>,
167 /// True for daemons auto-registered from config by the cron watcher,
168 /// not yet started. Treated as "available" by list/status/stats.
169 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
170 pub config_registered: bool,
171 /// Run-to-completion task rather than a long-running service. Readiness is
172 /// a zero exit code, and the terminal state is `completed` instead of
173 /// `stopped`. See `DaemonStatus::Completed`.
174 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
175 pub oneshot: bool,
176 /// Set when the proxy started this run and it may be stopped for
177 /// inactivity: how long, in milliseconds, it may go without proxy
178 /// activity. `None` for a daemon started any other way, or claimed since
179 /// by an explicit start.
180 #[serde(default)]
181 pub proxy_idle_timeout_ms: Option<u64>,
182 /// When the cron watcher last actually started this daemon.
183 ///
184 /// Distinct from `last_cron_triggered`, which advances on every scheduled
185 /// tick the watcher observes -- including the anchoring tick that
186 /// `immediate = false` uses to skip the first window, and ticks where the
187 /// `retrigger` policy declines to run. Only this field means "it ran",
188 /// which is what `last_exit_success` describes the outcome of.
189 #[serde(skip_serializing_if = "Option::is_none", default)]
190 pub last_cron_run: Option<chrono::DateTime<chrono::Local>>,
191 /// Start `cmd` directly, without a shell. See `RunOptions::no_shell`.
192 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
193 pub no_shell: bool,
194 /// Registered from config by the cron watcher and only ever started by
195 /// its schedule since, so each scheduled run is built from the current
196 /// config, templates rendered, rather than from what was stored at
197 /// registration. Cleared once a client starts the daemon: a run it asked
198 /// for carries its own options, which the schedule then keeps.
199 ///
200 /// Appended after `no_shell` for the positional IPC encoding.
201 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
202 pub scheduled_from_config: bool,
203 /// The ready port as the start gave it. `ready_port` holds the port the
204 /// run actually checked, moved along with a port bump, so it cannot tell
205 /// a ready port that was bumped from one given as the bumped number. A
206 /// restart of an ad-hoc daemon starts from this one and bumps it afresh.
207 ///
208 /// Appended after `scheduled_from_config` for the positional IPC encoding.
209 #[serde(skip_serializing_if = "Option::is_none", default)]
210 pub configured_ready_port: Option<ReadyPort>,
211}
212
213#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
214pub struct RunOptions {
215 pub id: DaemonId,
216 pub cmd: Vec<String>,
217 /// Original shell command string (from config `run`), passed verbatim to the shell.
218 /// Falls back to joining `cmd` when None (e.g. ad-hoc `pitchfork run -- cmd args`).
219 #[serde(skip_serializing_if = "Option::is_none", default)]
220 pub run: Option<String>,
221 pub force: bool,
222 pub shell_pid: Option<u32>,
223 pub dir: Dir,
224 pub autostop: bool,
225 pub cron_schedule: Option<String>,
226 pub cron_retrigger: Option<CronRetrigger>,
227 pub cron_immediate: Option<bool>,
228 pub retry: Retry,
229 pub retry_count: u32,
230 pub ready_delay: Option<u64>,
231 pub ready_output: Option<ReadyOutput>,
232 pub ready_http: Option<ReadyHttp>,
233 pub ready_port: Option<ReadyPort>,
234 pub ready_cmd: Option<ReadyCmd>,
235 pub health_cmd: Option<HealthCmd>,
236 pub health_http: Option<HealthHttp>,
237 pub health_port: Option<HealthPort>,
238 pub port: Option<PortConfig>,
239 pub wait_ready: bool,
240 #[serde(skip_serializing_if = "Vec::is_empty", default)]
241 pub depends: Vec<DaemonId>,
242 #[serde(skip_serializing_if = "Option::is_none", default)]
243 pub env: Option<IndexMap<String, String>>,
244 /// Template context for a `run` command left unrendered for `mise x`, which
245 /// is started with it in `PITCHFORK_TEMPLATE_CONTEXT`. Kept apart from `env`
246 /// so a variable of that name the user configured stays theirs.
247 #[serde(skip_serializing_if = "Option::is_none", default)]
248 pub deferred_template_context: Option<String>,
249 #[serde(skip_serializing_if = "Vec::is_empty", default)]
250 pub watch: Vec<String>,
251 #[serde(default)]
252 pub watch_mode: WatchMode,
253 #[serde(skip_serializing_if = "Option::is_none", default)]
254 pub watch_base_dir: Option<PathBuf>,
255 /// Whether to use mise for this daemon (None = inherit global general.mise setting).
256 ///
257 /// # Schema compatibility note
258 /// See `Daemon::mise` for downgrade implications when this field is `None`.
259 #[serde(skip_serializing_if = "Option::is_none", default)]
260 pub mise: Option<bool>,
261 /// The mise binary to wrap the command with, from the daemon's project
262 /// settings (`general.mise_bin`). Resolved by the client, because the
263 /// supervisor is long-lived and may have been started from a different
264 /// directory. `None` falls back to the supervisor's `resolve_mise_bin`.
265 #[serde(skip_serializing_if = "Option::is_none", default)]
266 pub mise_bin: Option<PathBuf>,
267 /// Optional stable slug alias for this daemon.
268 #[serde(skip_serializing_if = "Option::is_none", default)]
269 pub slug: Option<String>,
270 /// Whether to proxy this daemon (None = inherit global proxy.enable setting).
271 #[serde(skip_serializing_if = "Option::is_none", default)]
272 pub proxy: Option<bool>,
273 /// Unix user to run this daemon as.
274 #[serde(skip_serializing_if = "Option::is_none", default)]
275 pub user: Option<String>,
276 /// Memory limit for the daemon process (e.g. "50MB", "1GiB")
277 #[serde(skip_serializing_if = "Option::is_none", default)]
278 pub memory_limit: Option<MemoryLimit>,
279 /// CPU usage limit as a percentage (e.g. 80 for 80%, 200 for 2 cores)
280 #[serde(skip_serializing_if = "Option::is_none", default)]
281 pub cpu_limit: Option<CpuLimit>,
282 /// Unix signal to send for graceful shutdown (default: SIGTERM)
283 #[serde(skip_serializing_if = "Option::is_none", default)]
284 pub stop_signal: Option<StopConfig>,
285 /// Archive hook command invoked before retention prunes this daemon's logs.
286 #[serde(skip_serializing_if = "Option::is_none", default)]
287 pub archive_hook: Option<String>,
288 /// Log format for this daemon: `json`, `logfmt`, `auto`, or `text`.
289 #[serde(skip_serializing_if = "Option::is_none", default)]
290 pub log_format: Option<String>,
291 /// Hook triggered when the daemon produces matching output
292 #[serde(skip_serializing_if = "Option::is_none", default)]
293 pub on_output_hook: Option<crate::pitchfork_toml::OnOutputHook>,
294 /// Allocate a pseudo-terminal for the daemon process.
295 #[serde(skip_serializing_if = "Option::is_none", default)]
296 pub pty: Option<bool>,
297 /// This start describes the daemon in full, so the configuration fields
298 /// it leaves unset are cleared from the saved record instead of kept. Set
299 /// by every start except a restart of an ad-hoc daemon, which carries only
300 /// part of its record.
301 ///
302 /// False by default so that a request from an older client, which does
303 /// not send it, keeps merging into the record as it always did.
304 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
305 pub replaces_saved_record: bool,
306 /// Run-to-completion task rather than a long-running service.
307 #[serde(default)]
308 pub oneshot: bool,
309 /// How long to wait for a oneshot to finish, already resolved from the
310 /// project's `supervisor.oneshot_timeout`.
311 ///
312 /// Resolved by the client and carried on the request because the
313 /// supervisor is long-lived and may have started in another directory, so
314 /// its own `settings()` would not see the project's value. `None` leaves
315 /// the supervisor to fall back to whatever it can resolve.
316 #[serde(skip_serializing_if = "Option::is_none", default)]
317 pub oneshot_wait: Option<OneshotWait>,
318 /// This start came from entering a directory rather than from a person
319 /// asking for it, so a completed `oneshot` is left alone. Decided by the
320 /// supervisor because only it holds authoritative state: the state file
321 /// lags it by up to the flush interval, which is exactly the window a
322 /// second directory entry lands in.
323 #[serde(default)]
324 pub on_directory_enter: bool,
325 /// The proxy is starting this daemon, and it may be stopped after this
326 /// many milliseconds without proxy activity. `None` for every other start,
327 /// which is what makes such a start explicit. Carried over by restarts
328 /// (retry, file watch), which continue the same ownership.
329 #[serde(default)]
330 pub proxy_idle_timeout_ms: Option<u64>,
331 /// The cron watcher is starting this run, so a successful spawn is what
332 /// `Daemon::last_cron_run` records.
333 ///
334 /// Set only by the watcher's own call. A retry, file-watch or manual
335 /// restart of a scheduled daemon carries the daemon's `cron_schedule` but
336 /// not this, because the schedule did not ask for it.
337 #[serde(default)]
338 pub cron_started: bool,
339 /// Start `cmd` directly, without a shell: the config's `run` was an
340 /// array. `run` is `None` then, since there is no command line.
341 #[serde(default)]
342 pub no_shell: bool,
343 /// This start's ready checks are the daemon's only ones: those it leaves
344 /// unset are cleared from the record instead of kept. Set by an ad-hoc
345 /// restart given readiness flags, which replace how it was waited for.
346 ///
347 /// Appended after `no_shell` for the positional IPC encoding.
348 #[serde(default)]
349 pub replaces_ready_checks: bool,
350 /// Set by the supervisor on a start a client asked for over IPC; never
351 /// sent. See `Daemon::scheduled_from_config`.
352 #[serde(skip)]
353 pub requested_by_client: bool,
354}
355
356impl Daemon {
357 /// Whether this record was made by `pitchfork run` rather than from
358 /// config. Every start from config records `watch_base_dir`, the
359 /// directory of the project that defined it; a config daemon recorded
360 /// before that directory was stored still has the cron schedule it was
361 /// started with, which `pitchfork run` never gives a daemon.
362 pub fn is_adhoc(&self) -> bool {
363 self.watch_base_dir.is_none() && self.cron_schedule.is_none()
364 }
365
366 /// The next time the cron watcher will consider this daemon due, or
367 /// `None` when it has no schedule or the schedule does not parse.
368 ///
369 /// Anchored exactly the way `check_cron_schedules` anchors itself, so the
370 /// answer is what the watcher will actually do rather than an independent
371 /// reading of the schedule. That includes the case where the supervisor
372 /// was down across a window: the anchor is still the old tick, so the
373 /// result is a time in the past -- the overdue run the watcher takes on
374 /// its next check.
375 pub fn next_cron_run(
376 &self,
377 now: chrono::DateTime<chrono::Local>,
378 ) -> Option<chrono::DateTime<chrono::Local>> {
379 use std::str::FromStr;
380 let schedule = cron::Schedule::from_str(self.cron_schedule.as_ref()?).ok()?;
381 let anchor = match self.last_cron_triggered {
382 Some(t) => t,
383 // Mirrors the watcher's first-sighting branch: `immediate` looks
384 // back ten seconds, the default anchors to now.
385 None if self.cron_immediate.unwrap_or(false) => now - chrono::Duration::seconds(10),
386 None => now,
387 };
388 schedule.after(&anchor).next()
389 }
390
391 /// Build RunOptions from persisted daemon state.
392 ///
393 /// Carries over all configuration fields from the daemon state.
394 /// Callers can override specific fields on the returned value.
395 pub async fn to_run_options(&self, cmd: Vec<String>) -> RunOptions {
396 // Re-read on_output_hook from fresh config so restarts (retry, watch,
397 // cron) always pick up the current hook configuration.
398 // Read it from the daemon's project directory, which need not be in
399 // the supervisor's cwd ancestry (e.g. daemons started via slugs), nor
400 // contain the daemon's working directory when `dir` points elsewhere.
401 // Reading config walks the filesystem, so it runs on a blocking worker.
402 let id = self.id.clone();
403 let project_dir = self.watch_base_dir.clone().or_else(|| self.dir.clone());
404 let on_output_hook = tokio::task::spawn_blocking(move || {
405 project_dir
406 .as_deref()
407 .and_then(|dir| crate::pitchfork_toml::PitchforkToml::all_merged_from(dir).ok())
408 .or_else(|| {
409 crate::pitchfork_toml::PitchforkToml::all_merged_all_namespaces_blocking().ok()
410 })
411 .and_then(|pt| {
412 pt.daemons
413 .get(&id)
414 .and_then(|d| d.hooks.as_ref())
415 .and_then(|h| h.on_output.clone())
416 })
417 })
418 .await
419 .ok()
420 .flatten();
421
422 RunOptions {
423 id: self.id.clone(),
424 cmd,
425 run: self.run.clone(),
426 force: false,
427 shell_pid: self.shell_pid,
428 dir: Dir(self.dir.clone().unwrap_or_else(|| crate::env::CWD.clone())),
429 autostop: self.autostop,
430 oneshot: self.oneshot,
431 // Re-resolved by the client on the paths that have a project to
432 // resolve it from; a supervisor-internal restart keeps None and
433 // falls back.
434 oneshot_wait: None,
435 on_directory_enter: false,
436 // A restart continues whatever ownership the run it replaces had.
437 proxy_idle_timeout_ms: self.proxy_idle_timeout_ms,
438 // A restart of a scheduled daemon is not the schedule starting a
439 // run; only the cron watcher's own call sets this.
440 cron_started: false,
441 no_shell: self.no_shell,
442 // Built from the whole record, whose ready checks it carries.
443 replaces_ready_checks: false,
444 // Set by the IPC handler for a client's own request.
445 requested_by_client: false,
446 cron_schedule: self.cron_schedule.clone(),
447 cron_retrigger: self.cron_retrigger,
448 cron_immediate: self.cron_immediate,
449 retry: self.retry,
450 retry_count: self.retry_count,
451 ready_delay: self.ready_delay,
452 ready_output: self.ready_output.clone(),
453 ready_http: self.ready_http.clone(),
454 ready_port: self.ready_port.clone(),
455 ready_cmd: self.ready_cmd.clone(),
456 health_cmd: self.health_cmd.clone(),
457 health_http: self.health_http.clone(),
458 health_port: self.health_port.clone(),
459 port: self.port.clone(),
460 wait_ready: false,
461 depends: self.depends.clone(),
462 env: self.env.clone(),
463 watch: self.watch.clone(),
464 watch_mode: self.watch_mode,
465 watch_base_dir: self.watch_base_dir.clone(),
466 mise: self.mise,
467 deferred_template_context: self.deferred_template_context.clone(),
468 mise_bin: self.mise_bin.clone(),
469 slug: self.slug.clone(),
470 proxy: self.proxy,
471 user: self.user.clone(),
472 memory_limit: self.memory_limit,
473 cpu_limit: self.cpu_limit,
474 stop_signal: self.stop_signal,
475 archive_hook: self.archive_hook.clone(),
476 log_format: self.log_format.clone(),
477 on_output_hook,
478 pty: self.pty,
479 // Built from the whole record, so it describes the daemon in full.
480 replaces_saved_record: true,
481 }
482 }
483}
484
485impl Display for Daemon {
486 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487 write!(f, "{}", self.id.qualified())
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use chrono::TimeZone;
495
496 fn at(h: u32, m: u32, sec: u32) -> chrono::DateTime<chrono::Local> {
497 chrono::Local
498 .with_ymd_and_hms(2026, 9, 21, h, m, sec)
499 .unwrap()
500 }
501
502 /// Daily at 03:00, the schedule from the report that asked for this.
503 fn daily_3am(last_triggered: Option<chrono::DateTime<chrono::Local>>) -> Daemon {
504 Daemon {
505 cron_schedule: Some("0 0 3 * * *".to_string()),
506 last_cron_triggered: last_triggered,
507 ..Daemon::default()
508 }
509 }
510
511 #[tokio::test]
512 async fn a_restart_from_the_saved_record_keeps_the_deferred_template_context() {
513 let daemon = Daemon {
514 deferred_template_context: Some("{\"name\":\"api\"}".to_string()),
515 ..Daemon::default()
516 };
517 let opts = daemon.to_run_options(vec!["echo".to_string()]).await;
518 assert_eq!(
519 opts.deferred_template_context.as_deref(),
520 Some("{\"name\":\"api\"}")
521 );
522 }
523
524 #[test]
525 fn next_cron_run_is_none_without_a_schedule() {
526 assert!(Daemon::default().next_cron_run(at(7, 0, 0)).is_none());
527 }
528
529 /// The watcher warns and skips an expression it cannot parse; there is no
530 /// next run to report for one.
531 #[test]
532 fn next_cron_run_is_none_for_an_invalid_schedule() {
533 let d = Daemon {
534 cron_schedule: Some("not a cron expression".to_string()),
535 ..Daemon::default()
536 };
537 assert!(d.next_cron_run(at(7, 0, 0)).is_none());
538 }
539
540 #[test]
541 fn next_cron_run_follows_the_last_tick() {
542 let d = daily_3am(Some(at(3, 0, 4)));
543 assert_eq!(
544 d.next_cron_run(at(7, 0, 0)),
545 Some(
546 chrono::Local
547 .with_ymd_and_hms(2026, 9, 22, 3, 0, 0)
548 .unwrap()
549 )
550 );
551 }
552
553 /// A supervisor that was down across 03:00 has a stale anchor, so the next
554 /// run is in the past: the window it still owes, which is what the watcher
555 /// will take on its next check.
556 #[test]
557 fn next_cron_run_reports_a_missed_window_as_past() {
558 let d = daily_3am(Some(
559 chrono::Local
560 .with_ymd_and_hms(2026, 9, 20, 3, 0, 0)
561 .unwrap(),
562 ));
563 let next = d.next_cron_run(at(7, 0, 0)).unwrap();
564 assert_eq!(next, at(3, 0, 0));
565 assert!(next < at(7, 0, 0));
566 }
567
568 /// Never triggered, `immediate = false`: the watcher will anchor to now
569 /// and skip the current window, so the answer is the next one.
570 #[test]
571 fn next_cron_run_skips_the_current_window_without_immediate() {
572 let d = daily_3am(None);
573 assert_eq!(
574 d.next_cron_run(at(2, 59, 0)),
575 Some(at(3, 0, 0)),
576 "a window still ahead of now is reported as-is"
577 );
578 assert_eq!(
579 d.next_cron_run(at(3, 0, 30)),
580 Some(
581 chrono::Local
582 .with_ymd_and_hms(2026, 9, 22, 3, 0, 0)
583 .unwrap()
584 ),
585 "a window that just passed is not claimed: immediate=false skips it"
586 );
587 }
588
589 /// `immediate = true` keeps the watcher's ten-second look-back, so a
590 /// window that just passed is still due.
591 #[test]
592 fn next_cron_run_honors_the_immediate_lookback() {
593 let d = Daemon {
594 cron_immediate: Some(true),
595 ..daily_3am(None)
596 };
597 assert_eq!(d.next_cron_run(at(3, 0, 5)), Some(at(3, 0, 0)));
598 }
599
600 #[test]
601 fn test_valid_daemon_ids() {
602 // Short IDs
603 assert!(is_valid_daemon_id("myapp"));
604 assert!(is_valid_daemon_id("my-app"));
605 assert!(is_valid_daemon_id("my_app"));
606 assert!(is_valid_daemon_id("my.app"));
607 assert!(is_valid_daemon_id("MyApp123"));
608
609 // Qualified IDs (namespace/short_id)
610 assert!(is_valid_daemon_id("project/api"));
611 assert!(is_valid_daemon_id("global/web"));
612 assert!(is_valid_daemon_id("my-project/my-app"));
613 }
614
615 #[test]
616 fn test_invalid_daemon_ids() {
617 // Empty
618 assert!(!is_valid_daemon_id(""));
619
620 // Multiple slashes (invalid qualified format)
621 assert!(!is_valid_daemon_id("a/b/c"));
622 assert!(!is_valid_daemon_id("../etc/passwd"));
623
624 // Invalid qualified format (empty parts)
625 assert!(!is_valid_daemon_id("/api"));
626 assert!(!is_valid_daemon_id("project/"));
627
628 // Backslashes
629 assert!(!is_valid_daemon_id("foo\\bar"));
630
631 // Parent directory reference
632 assert!(!is_valid_daemon_id(".."));
633 assert!(!is_valid_daemon_id("foo..bar"));
634
635 // Double dash (reserved for path encoding)
636 assert!(!is_valid_daemon_id("my--app"));
637 assert!(!is_valid_daemon_id("project--api"));
638 assert!(!is_valid_daemon_id("--app"));
639 assert!(!is_valid_daemon_id("app--"));
640
641 // Spaces
642 assert!(!is_valid_daemon_id("my app"));
643 assert!(!is_valid_daemon_id(" myapp"));
644 assert!(!is_valid_daemon_id("myapp "));
645
646 // Current directory
647 assert!(!is_valid_daemon_id("."));
648
649 // Control characters
650 assert!(!is_valid_daemon_id("my\x00app"));
651 assert!(!is_valid_daemon_id("my\napp"));
652 assert!(!is_valid_daemon_id("my\tapp"));
653
654 // Non-ASCII
655 assert!(!is_valid_daemon_id("myäpp"));
656 assert!(!is_valid_daemon_id("приложение"));
657
658 // Unsupported punctuation under DaemonId rules
659 assert!(!is_valid_daemon_id("app@host"));
660 assert!(!is_valid_daemon_id("app:8080"));
661 }
662}