Skip to main content

studio_worker/
service.rs

1//! OS service install/uninstall.  Linux: systemd --user.  macOS: launchd
2//! plist template (written but not loaded — operator runs `launchctl load`).
3//! Windows: schtasks template (written but not registered — operator runs
4//! the printed command).
5//!
6//! All system side-effects (Command::status, fs writes) flow through the
7//! `ServiceOps` trait so the public install/uninstall functions can be
8//! unit-tested without touching the real OS.
9use anyhow::{anyhow, Context, Result};
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use tracing::{info, warn};
13
14const TRACE_TARGET: &str = "studio_worker::service";
15
16/// Outcome of running a single OS command step (e.g. `systemctl
17/// daemon-reload`, `launchctl unload`, `schtasks /Delete`).  Splits the
18/// three observable states so callers can compose them into an overall
19/// activation/deactivation success and so each one emits a distinct
20/// structured tracing event instead of being silently swallowed.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub(crate) enum StepOutcome {
23    /// Command spawned and exited with a zero status.
24    Succeeded,
25    /// Command spawned but exited non-zero (with the captured code if any).
26    Failed { code: Option<i32> },
27    /// Spawn itself failed — tool missing on PATH, permission denied, etc.
28    SpawnFailed,
29}
30
31impl StepOutcome {
32    pub(crate) fn is_success(self) -> bool {
33        matches!(self, StepOutcome::Succeeded)
34    }
35}
36
37/// Pure mapping from a `Command::status()` result onto [`StepOutcome`].
38pub(crate) fn classify_status(status: std::io::Result<std::process::ExitStatus>) -> StepOutcome {
39    match status {
40        Ok(s) if s.success() => StepOutcome::Succeeded,
41        Ok(s) => StepOutcome::Failed { code: s.code() },
42        Err(_) => StepOutcome::SpawnFailed,
43    }
44}
45
46/// Run a single command step and emit a structured tracing event for
47/// the outcome.  Returns the classified [`StepOutcome`] so callers can
48/// chain steps and short-circuit on failure.  Without this every
49/// `RealOps::activate` / `deactivate` step would silently swallow
50/// failures of systemctl / launchctl / schtasks.
51fn run_step(op: &'static str, step: &'static str, mut cmd: Command) -> StepOutcome {
52    let started = std::time::Instant::now();
53    let status = cmd.status();
54    let elapsed_ms = started.elapsed().as_millis() as u64;
55    // Capture the spawn error's text before `classify_status` consumes the
56    // result and drops it; a SpawnFailed otherwise loses its root cause.
57    let spawn_error = match &status {
58        Err(e) => Some(e.to_string()),
59        Ok(_) => None,
60    };
61    let outcome = classify_status(status);
62    match outcome {
63        StepOutcome::Succeeded => {
64            info!(
65                target: TRACE_TARGET,
66                op,
67                step,
68                elapsed_ms,
69                "service step succeeded"
70            );
71        }
72        StepOutcome::Failed { code } => {
73            warn!(
74                target: TRACE_TARGET,
75                op,
76                step,
77                elapsed_ms,
78                exit_code = code,
79                "service step exited non-zero"
80            );
81        }
82        StepOutcome::SpawnFailed => {
83            warn!(
84                target: TRACE_TARGET,
85                op,
86                step,
87                elapsed_ms,
88                error = spawn_error.as_deref().unwrap_or("unknown"),
89                "service step could not be spawned (tool missing on PATH?)"
90            );
91        }
92    }
93    outcome
94}
95
96#[cfg(target_os = "linux")]
97const SERVICE_FILENAME: &str = "minis-studio-worker.service";
98#[cfg(target_os = "macos")]
99const SERVICE_FILENAME: &str = "gg.minis.studio-worker.plist";
100#[cfg(target_os = "windows")]
101const SERVICE_FILENAME: &str = "minis-studio-worker.task.xml";
102
103fn binary_path() -> Result<PathBuf> {
104    std::env::current_exe().context("resolving current executable path")
105}
106
107#[cfg(target_os = "linux")]
108fn default_unit_dir() -> Result<PathBuf> {
109    let dirs = directories::BaseDirs::new().ok_or_else(|| anyhow!("cannot resolve user dirs"))?;
110    let path = dirs.config_dir().join("systemd").join("user");
111    std::fs::create_dir_all(&path)?;
112    Ok(path)
113}
114
115#[cfg(target_os = "macos")]
116fn default_unit_dir() -> Result<PathBuf> {
117    let home = std::env::var("HOME").context("HOME not set")?;
118    let path = PathBuf::from(home).join("Library").join("LaunchAgents");
119    std::fs::create_dir_all(&path)?;
120    Ok(path)
121}
122
123#[cfg(target_os = "windows")]
124fn default_unit_dir() -> Result<PathBuf> {
125    let app_data = std::env::var("APPDATA").context("APPDATA not set")?;
126    let path = PathBuf::from(app_data).join("minis-studio-worker");
127    std::fs::create_dir_all(&path)?;
128    Ok(path)
129}
130
131/// Abstraction over the side-effecting parts of install/uninstall so the
132/// install logic itself is fully unit-testable.
133pub trait ServiceOps {
134    fn unit_dir(&self) -> Result<PathBuf>;
135    fn binary_path(&self) -> Result<PathBuf>;
136    /// Activate the unit (systemctl --user enable / launchctl load /
137    /// schtasks /Create).  Implementations return false if the platform
138    /// tool isn't available so install() can still succeed (file
139    /// written, manual activation instructions printed).
140    fn activate(&self, _unit_path: &Path) -> bool {
141        false
142    }
143    fn deactivate(&self, _unit_path: &Path) {}
144}
145
146/// Real, system-touching implementation used by the CLI.
147pub struct RealOps;
148
149impl ServiceOps for RealOps {
150    fn unit_dir(&self) -> Result<PathBuf> {
151        default_unit_dir()
152    }
153
154    fn binary_path(&self) -> Result<PathBuf> {
155        binary_path()
156    }
157
158    #[allow(unused_variables)]
159    fn activate(&self, unit_path: &Path) -> bool {
160        #[cfg(target_os = "linux")]
161        {
162            let mut reload = Command::new("systemctl");
163            reload.args(["--user", "daemon-reload"]);
164            if !run_step("activate", "daemon-reload", reload).is_success() {
165                return false;
166            }
167            let mut enable = Command::new("systemctl");
168            enable.args(["--user", "enable", "--now", SERVICE_FILENAME]);
169            run_step("activate", "enable-now", enable).is_success()
170        }
171        #[cfg(target_os = "macos")]
172        {
173            // Load the LaunchAgent so it runs now and at every login.
174            let mut load = Command::new("launchctl");
175            load.args(["load", "-w", unit_path.to_string_lossy().as_ref()]);
176            run_step("activate", "launchctl-load", load).is_success()
177        }
178        #[cfg(target_os = "windows")]
179        {
180            // Register the scheduled task from the XML we just wrote.
181            let mut create = Command::new("schtasks");
182            create.args([
183                "/Create",
184                "/XML",
185                unit_path.to_string_lossy().as_ref(),
186                "/TN",
187                "MinisStudioWorker",
188                "/F",
189            ]);
190            run_step("activate", "schtasks-create", create).is_success()
191        }
192        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
193        {
194            false
195        }
196    }
197
198    #[allow(unused_variables)]
199    fn deactivate(&self, unit_path: &Path) {
200        #[cfg(target_os = "linux")]
201        {
202            let mut disable = Command::new("systemctl");
203            disable.args(["--user", "disable", "--now", SERVICE_FILENAME]);
204            let _ = run_step("deactivate", "disable-now", disable);
205        }
206        #[cfg(target_os = "macos")]
207        {
208            let mut unload = Command::new("launchctl");
209            unload.args(["unload", unit_path.to_string_lossy().as_ref()]);
210            let _ = run_step("deactivate", "launchctl-unload", unload);
211        }
212        #[cfg(target_os = "windows")]
213        {
214            let mut delete = Command::new("schtasks");
215            delete.args(["/Delete", "/TN", "MinisStudioWorker", "/F"]);
216            let _ = run_step("deactivate", "schtasks-delete", delete);
217        }
218    }
219}
220
221pub fn install(config_path: Option<&str>) -> Result<()> {
222    install_with(&RealOps, config_path)
223}
224
225pub fn uninstall() -> Result<()> {
226    uninstall_with(&RealOps)
227}
228
229/// Write the unit file using the supplied ops and print manual activation
230/// instructions if the platform tool isn't available.  Public-but-`pub`
231/// so tests in `tests/` can drive it with a fake ops.
232pub fn install_with<O: ServiceOps>(ops: &O, config_path: Option<&str>) -> Result<()> {
233    let bin = ops.binary_path()?;
234    let cfg_arg = config_path
235        .map(|p| format!("--config {p} "))
236        .unwrap_or_default();
237    let dir = ops.unit_dir()?;
238    let path = dir.join(SERVICE_FILENAME);
239
240    let body = render_service(&bin.display().to_string(), &cfg_arg);
241    std::fs::write(&path, &body)
242        .with_context(|| format!("writing service file {}", path.display()))?;
243
244    println!("wrote service unit: {}", path.display());
245
246    let activated = ops.activate(&path);
247    if activated {
248        println!("activated service unit");
249    } else {
250        print_activation_instructions(&path);
251    }
252    info!(
253        target: TRACE_TARGET,
254        op = "install",
255        unit_path = %path.display(),
256        binary_path = %bin.display(),
257        activated,
258        "service install completed"
259    );
260    Ok(())
261}
262
263pub fn uninstall_with<O: ServiceOps>(ops: &O) -> Result<()> {
264    let dir = ops.unit_dir()?;
265    let path = dir.join(SERVICE_FILENAME);
266    ops.deactivate(&path);
267    let removed = if path.exists() {
268        std::fs::remove_file(&path)?;
269        println!("removed service unit: {}", path.display());
270        true
271    } else {
272        println!("no service unit to remove at {}", path.display());
273        false
274    };
275    info!(
276        target: TRACE_TARGET,
277        op = "uninstall",
278        unit_path = %path.display(),
279        removed,
280        "service uninstall completed"
281    );
282    Ok(())
283}
284
285fn print_activation_instructions(path: &Path) {
286    #[cfg(target_os = "linux")]
287    {
288        println!("activate manually:");
289        println!("  systemctl --user daemon-reload");
290        println!("  systemctl --user enable --now {SERVICE_FILENAME}");
291        let _ = path;
292    }
293    #[cfg(target_os = "macos")]
294    println!("load with: launchctl load -w {}", path.display());
295    #[cfg(target_os = "windows")]
296    println!(
297        "register with: schtasks /Create /XML {} /TN MinisStudioWorker",
298        path.display()
299    );
300    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
301    let _ = path;
302}
303
304#[cfg(target_os = "linux")]
305pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
306    format!(
307        r#"[Unit]
308Description=Minis studio worker (pull-based image-generation agent)
309After=network-online.target
310
311[Service]
312Type=simple
313ExecStart={bin} {cfg_arg}run
314Restart=on-failure
315RestartSec=5
316Environment=RUST_LOG=studio_worker=info
317
318[Install]
319WantedBy=default.target
320"#
321    )
322}
323
324#[cfg(target_os = "macos")]
325pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
326    let cfg_args = cfg_arg.trim();
327    let extra = if cfg_args.is_empty() {
328        String::new()
329    } else {
330        cfg_args
331            .split_whitespace()
332            .map(|s| format!("    <string>{}</string>\n", s))
333            .collect::<String>()
334    };
335    format!(
336        r#"<?xml version="1.0" encoding="UTF-8"?>
337<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
338<plist version="1.0">
339<dict>
340  <key>Label</key><string>gg.minis.studio-worker</string>
341  <key>ProgramArguments</key>
342  <array>
343    <string>{bin}</string>
344{extra}    <string>run</string>
345  </array>
346  <key>RunAtLoad</key><true/>
347  <key>KeepAlive</key><true/>
348  <key>EnvironmentVariables</key>
349  <dict><key>RUST_LOG</key><string>studio_worker=info</string></dict>
350</dict>
351</plist>
352"#
353    )
354}
355
356#[cfg(target_os = "windows")]
357pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
358    let args = format!("{cfg_arg}run").trim().to_string();
359    format!(
360        r#"<?xml version="1.0" encoding="UTF-16"?>
361<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
362  <Triggers>
363    <LogonTrigger><Enabled>true</Enabled></LogonTrigger>
364  </Triggers>
365  <Settings>
366    <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
367    <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
368    <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
369    <AllowStartOnDemand>true</AllowStartOnDemand>
370    <Enabled>true</Enabled>
371    <Hidden>false</Hidden>
372    <RestartOnFailure>
373      <Interval>PT1M</Interval>
374      <Count>10</Count>
375    </RestartOnFailure>
376  </Settings>
377  <Actions>
378    <Exec>
379      <Command>{bin}</Command>
380      <Arguments>{args}</Arguments>
381    </Exec>
382  </Actions>
383</Task>
384"#
385    )
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use std::cell::RefCell;
392    use std::path::PathBuf;
393    use tempfile::tempdir;
394
395    struct FakeOps {
396        bin: PathBuf,
397        dir: PathBuf,
398        activate_returns: bool,
399        activate_calls: RefCell<Vec<PathBuf>>,
400        deactivate_calls: RefCell<Vec<PathBuf>>,
401    }
402
403    impl ServiceOps for FakeOps {
404        fn unit_dir(&self) -> Result<PathBuf> {
405            Ok(self.dir.clone())
406        }
407        fn binary_path(&self) -> Result<PathBuf> {
408            Ok(self.bin.clone())
409        }
410        fn activate(&self, unit_path: &Path) -> bool {
411            self.activate_calls
412                .borrow_mut()
413                .push(unit_path.to_path_buf());
414            self.activate_returns
415        }
416        fn deactivate(&self, unit_path: &Path) {
417            self.deactivate_calls
418                .borrow_mut()
419                .push(unit_path.to_path_buf());
420        }
421    }
422
423    #[cfg(target_os = "linux")]
424    #[test]
425    fn linux_render_includes_exec_start_and_install_section() {
426        let rendered = render_service("/usr/bin/studio-worker", "");
427        assert!(rendered.contains("ExecStart=/usr/bin/studio-worker run"));
428        assert!(rendered.contains("[Install]"));
429        assert!(rendered.contains("Restart=on-failure"));
430    }
431
432    #[cfg(target_os = "linux")]
433    #[test]
434    fn linux_render_passes_config_arg() {
435        let rendered = render_service("/usr/bin/studio-worker", "--config /etc/conf.toml ");
436        assert!(rendered.contains("--config /etc/conf.toml run"));
437    }
438
439    #[cfg(target_os = "macos")]
440    #[test]
441    fn macos_render_emits_valid_plist_xml() {
442        let rendered = render_service("/usr/local/bin/studio-worker", "");
443        assert!(rendered.contains("<plist version=\"1.0\">"));
444        assert!(rendered.contains("<string>/usr/local/bin/studio-worker</string>"));
445    }
446
447    #[cfg(target_os = "macos")]
448    #[test]
449    fn macos_render_includes_config_args_when_provided() {
450        let rendered = render_service("/usr/local/bin/studio-worker", "--config /etc/conf.toml ");
451        assert!(rendered.contains("<string>--config</string>"));
452        assert!(rendered.contains("<string>/etc/conf.toml</string>"));
453    }
454
455    #[cfg(target_os = "windows")]
456    #[test]
457    fn windows_render_emits_valid_task_xml() {
458        let rendered = render_service("C:\\worker.exe", "");
459        assert!(rendered.contains("<Command>C:\\worker.exe</Command>"));
460        assert!(rendered.contains("<Arguments>run</Arguments>"));
461    }
462
463    #[test]
464    fn install_with_writes_unit_file_and_succeeds_when_activate_returns_true() {
465        let dir = tempdir().unwrap();
466        let ops = FakeOps {
467            bin: PathBuf::from("/usr/bin/studio-worker"),
468            dir: dir.path().to_path_buf(),
469            activate_returns: true,
470            activate_calls: RefCell::new(Vec::new()),
471            deactivate_calls: RefCell::new(Vec::new()),
472        };
473        install_with(&ops, Some("/etc/conf.toml")).unwrap();
474        let written = dir.path().join(SERVICE_FILENAME);
475        assert!(
476            written.exists(),
477            "unit file should exist at {}",
478            written.display()
479        );
480        let body = std::fs::read_to_string(&written).unwrap();
481        assert!(body.contains("studio-worker"));
482        assert_eq!(ops.activate_calls.borrow().len(), 1);
483        assert_eq!(ops.activate_calls.borrow()[0], written);
484    }
485
486    #[test]
487    fn install_with_falls_back_to_manual_instructions_when_activate_fails() {
488        let dir = tempdir().unwrap();
489        let ops = FakeOps {
490            bin: PathBuf::from("/usr/bin/studio-worker"),
491            dir: dir.path().to_path_buf(),
492            activate_returns: false,
493            activate_calls: RefCell::new(Vec::new()),
494            deactivate_calls: RefCell::new(Vec::new()),
495        };
496        install_with(&ops, None).unwrap();
497        assert!(dir.path().join(SERVICE_FILENAME).exists());
498    }
499
500    #[test]
501    fn uninstall_with_removes_file_and_calls_deactivate() {
502        let dir = tempdir().unwrap();
503        let path = dir.path().join(SERVICE_FILENAME);
504        std::fs::write(&path, "dummy").unwrap();
505        let ops = FakeOps {
506            bin: PathBuf::from("/usr/bin/studio-worker"),
507            dir: dir.path().to_path_buf(),
508            activate_returns: false,
509            activate_calls: RefCell::new(Vec::new()),
510            deactivate_calls: RefCell::new(Vec::new()),
511        };
512        uninstall_with(&ops).unwrap();
513        assert!(!path.exists());
514        assert_eq!(ops.deactivate_calls.borrow().len(), 1);
515    }
516
517    #[test]
518    fn uninstall_with_is_idempotent_when_file_missing() {
519        let dir = tempdir().unwrap();
520        let ops = FakeOps {
521            bin: PathBuf::from("/usr/bin/studio-worker"),
522            dir: dir.path().to_path_buf(),
523            activate_returns: false,
524            activate_calls: RefCell::new(Vec::new()),
525            deactivate_calls: RefCell::new(Vec::new()),
526        };
527        // No file written; uninstall should still succeed.
528        uninstall_with(&ops).unwrap();
529    }
530
531    // -----------------------------------------------------------------
532    // Structured tracing — proves install/uninstall and the per-platform
533    // RealOps steps emit operator-visible breadcrumbs.  Without these,
534    // a failing `systemctl enable --now`, `launchctl unload` or
535    // `schtasks /Delete` would silently leave the worker un-activated
536    // (or still registered) with no log trail to diagnose it.
537    //
538    // The shared `test_support::capture` helper installs one
539    // process-global subscriber + thread-local sink — see that module
540    // for the why (it dodges the tracing callsite-cache flake we used
541    // to hit with `with_default`).
542    // -----------------------------------------------------------------
543
544    use crate::test_support::capture;
545
546    fn fake_ops(dir: PathBuf, activate_returns: bool) -> FakeOps {
547        FakeOps {
548            bin: PathBuf::from("/usr/bin/studio-worker"),
549            dir,
550            activate_returns,
551            activate_calls: RefCell::new(Vec::new()),
552            deactivate_calls: RefCell::new(Vec::new()),
553        }
554    }
555
556    #[test]
557    fn install_with_emits_info_event_with_activated_true_when_activation_succeeds() {
558        let dir = tempdir().unwrap();
559        let dir_path = dir.path().to_path_buf();
560        let logs = capture(move || {
561            install_with(&fake_ops(dir_path, true), None).unwrap();
562        });
563        assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
564        assert!(
565            logs.contains("studio_worker::service"),
566            "expected service target, got: {logs}"
567        );
568        assert!(logs.contains("op=\"install\""), "expected op field: {logs}");
569        assert!(
570            logs.contains("activated=true"),
571            "expected activated=true: {logs}"
572        );
573        assert!(
574            logs.contains(SERVICE_FILENAME),
575            "expected unit_path in log, got: {logs}"
576        );
577    }
578
579    #[test]
580    fn install_with_emits_info_event_with_activated_false_on_manual_fallback() {
581        let dir = tempdir().unwrap();
582        let dir_path = dir.path().to_path_buf();
583        let logs = capture(move || {
584            install_with(&fake_ops(dir_path, false), None).unwrap();
585        });
586        assert!(
587            logs.contains("activated=false"),
588            "expected activated=false: {logs}"
589        );
590    }
591
592    #[test]
593    fn uninstall_with_emits_info_event_with_removed_true_when_file_existed() {
594        let dir = tempdir().unwrap();
595        let path = dir.path().join(SERVICE_FILENAME);
596        std::fs::write(&path, "dummy").unwrap();
597        let dir_path = dir.path().to_path_buf();
598        let logs = capture(move || {
599            uninstall_with(&fake_ops(dir_path, false)).unwrap();
600        });
601        assert!(
602            logs.contains("op=\"uninstall\""),
603            "expected op field: {logs}"
604        );
605        assert!(
606            logs.contains("removed=true"),
607            "expected removed=true: {logs}"
608        );
609    }
610
611    #[test]
612    fn uninstall_with_emits_info_event_with_removed_false_when_file_missing() {
613        let dir = tempdir().unwrap();
614        let dir_path = dir.path().to_path_buf();
615        let logs = capture(move || {
616            uninstall_with(&fake_ops(dir_path, false)).unwrap();
617        });
618        assert!(
619            logs.contains("removed=false"),
620            "expected removed=false: {logs}"
621        );
622    }
623
624    // -----------------------------------------------------------------
625    // StepOutcome — pure helper that classifies the outcome of an OS
626    // command spawned by RealOps so install/uninstall never silently
627    // swallow non-zero exits or missing tools.
628    // -----------------------------------------------------------------
629
630    #[test]
631    fn classify_status_recognises_zero_exit_as_succeeded() {
632        // Spawn the running test binary with `--list` (the cargo test
633        // harness accepts it and exits 0).
634        let status = std::process::Command::new(std::env::current_exe().unwrap())
635            .arg("--list")
636            .stdout(std::process::Stdio::null())
637            .status();
638        assert_eq!(classify_status(status), StepOutcome::Succeeded);
639    }
640
641    #[test]
642    fn classify_status_recognises_non_zero_exit_as_failed() {
643        // The cargo test harness rejects an unknown long flag with a
644        // non-zero exit.
645        let status = std::process::Command::new(std::env::current_exe().unwrap())
646            .arg("--definitely-not-a-real-flag-zzzqx")
647            .stdout(std::process::Stdio::null())
648            .stderr(std::process::Stdio::null())
649            .status();
650        match classify_status(status) {
651            StepOutcome::Failed { .. } => {}
652            other => panic!("expected Failed, got {other:?}"),
653        }
654    }
655
656    #[test]
657    fn classify_status_recognises_spawn_failure() {
658        let status =
659            std::process::Command::new("definitely-not-on-path-zzzqxq-studio-worker").status();
660        assert_eq!(classify_status(status), StepOutcome::SpawnFailed);
661    }
662
663    #[test]
664    fn run_step_spawn_failure_logs_underlying_io_error() {
665        // A spawn failure can be ENOENT (tool missing) *or* a permission
666        // error, a broken interpreter, etc.  The warn must carry the real
667        // OS error so an operator isn't misled by the generic "missing on
668        // PATH?" hint when the true cause is something else.
669        let cmd = std::process::Command::new("definitely-not-on-path-zzzqxq-studio-worker");
670        let logs = capture(move || {
671            assert_eq!(run_step("activate", "smoke", cmd), StepOutcome::SpawnFailed);
672        });
673        assert!(logs.contains("WARN"), "expected WARN event, got: {logs}");
674        assert!(
675            logs.contains("could not be spawned"),
676            "expected spawn-failure message, got: {logs}"
677        );
678        assert!(
679            logs.contains("error="),
680            "expected structured error field, got: {logs}"
681        );
682        assert!(
683            logs.contains("os error"),
684            "expected the underlying io::Error text, got: {logs}"
685        );
686    }
687
688    #[test]
689    fn step_outcome_is_success_only_for_succeeded() {
690        assert!(StepOutcome::Succeeded.is_success());
691        assert!(!StepOutcome::Failed { code: Some(1) }.is_success());
692        assert!(!StepOutcome::Failed { code: None }.is_success());
693        assert!(!StepOutcome::SpawnFailed.is_success());
694    }
695}