1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub(crate) enum StepOutcome {
23 Succeeded,
25 Failed { code: Option<i32> },
27 SpawnFailed,
29}
30
31impl StepOutcome {
32 pub(crate) fn is_success(self) -> bool {
33 matches!(self, StepOutcome::Succeeded)
34 }
35}
36
37pub(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
46fn 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 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
131pub trait ServiceOps {
134 fn unit_dir(&self) -> Result<PathBuf>;
135 fn binary_path(&self) -> Result<PathBuf>;
136 fn activate(&self, _unit_path: &Path) -> bool {
141 false
142 }
143 fn deactivate(&self, _unit_path: &Path) {}
144}
145
146pub 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 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 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
229pub 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 uninstall_with(&ops).unwrap();
529 }
530
531 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 #[test]
631 fn classify_status_recognises_zero_exit_as_succeeded() {
632 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 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 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}