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 outcome = classify_status(status);
56 match outcome {
57 StepOutcome::Succeeded => {
58 info!(
59 target: TRACE_TARGET,
60 op,
61 step,
62 elapsed_ms,
63 "service step succeeded"
64 );
65 }
66 StepOutcome::Failed { code } => {
67 warn!(
68 target: TRACE_TARGET,
69 op,
70 step,
71 elapsed_ms,
72 exit_code = code,
73 "service step exited non-zero"
74 );
75 }
76 StepOutcome::SpawnFailed => {
77 warn!(
78 target: TRACE_TARGET,
79 op,
80 step,
81 elapsed_ms,
82 "service step could not be spawned (tool missing on PATH?)"
83 );
84 }
85 }
86 outcome
87}
88
89#[cfg(target_os = "linux")]
90const SERVICE_FILENAME: &str = "minis-studio-worker.service";
91#[cfg(target_os = "macos")]
92const SERVICE_FILENAME: &str = "gg.minis.studio-worker.plist";
93#[cfg(target_os = "windows")]
94const SERVICE_FILENAME: &str = "minis-studio-worker.task.xml";
95
96fn binary_path() -> Result<PathBuf> {
97 std::env::current_exe().context("resolving current executable path")
98}
99
100#[cfg(target_os = "linux")]
101fn default_unit_dir() -> Result<PathBuf> {
102 let dirs = directories::BaseDirs::new().ok_or_else(|| anyhow!("cannot resolve user dirs"))?;
103 let path = dirs.config_dir().join("systemd").join("user");
104 std::fs::create_dir_all(&path)?;
105 Ok(path)
106}
107
108#[cfg(target_os = "macos")]
109fn default_unit_dir() -> Result<PathBuf> {
110 let home = std::env::var("HOME").context("HOME not set")?;
111 let path = PathBuf::from(home).join("Library").join("LaunchAgents");
112 std::fs::create_dir_all(&path)?;
113 Ok(path)
114}
115
116#[cfg(target_os = "windows")]
117fn default_unit_dir() -> Result<PathBuf> {
118 let app_data = std::env::var("APPDATA").context("APPDATA not set")?;
119 let path = PathBuf::from(app_data).join("minis-studio-worker");
120 std::fs::create_dir_all(&path)?;
121 Ok(path)
122}
123
124pub trait ServiceOps {
127 fn unit_dir(&self) -> Result<PathBuf>;
128 fn binary_path(&self) -> Result<PathBuf>;
129 fn activate(&self, _unit_path: &Path) -> bool {
134 false
135 }
136 fn deactivate(&self, _unit_path: &Path) {}
137}
138
139pub struct RealOps;
141
142impl ServiceOps for RealOps {
143 fn unit_dir(&self) -> Result<PathBuf> {
144 default_unit_dir()
145 }
146
147 fn binary_path(&self) -> Result<PathBuf> {
148 binary_path()
149 }
150
151 #[allow(unused_variables)]
152 fn activate(&self, unit_path: &Path) -> bool {
153 #[cfg(target_os = "linux")]
154 {
155 let mut reload = Command::new("systemctl");
156 reload.args(["--user", "daemon-reload"]);
157 if !run_step("activate", "daemon-reload", reload).is_success() {
158 return false;
159 }
160 let mut enable = Command::new("systemctl");
161 enable.args(["--user", "enable", "--now", SERVICE_FILENAME]);
162 run_step("activate", "enable-now", enable).is_success()
163 }
164 #[cfg(target_os = "macos")]
165 {
166 let mut load = Command::new("launchctl");
168 load.args(["load", "-w", unit_path.to_string_lossy().as_ref()]);
169 run_step("activate", "launchctl-load", load).is_success()
170 }
171 #[cfg(target_os = "windows")]
172 {
173 let mut create = Command::new("schtasks");
175 create.args([
176 "/Create",
177 "/XML",
178 unit_path.to_string_lossy().as_ref(),
179 "/TN",
180 "MinisStudioWorker",
181 "/F",
182 ]);
183 run_step("activate", "schtasks-create", create).is_success()
184 }
185 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
186 {
187 false
188 }
189 }
190
191 #[allow(unused_variables)]
192 fn deactivate(&self, unit_path: &Path) {
193 #[cfg(target_os = "linux")]
194 {
195 let mut disable = Command::new("systemctl");
196 disable.args(["--user", "disable", "--now", SERVICE_FILENAME]);
197 let _ = run_step("deactivate", "disable-now", disable);
198 }
199 #[cfg(target_os = "macos")]
200 {
201 let mut unload = Command::new("launchctl");
202 unload.args(["unload", unit_path.to_string_lossy().as_ref()]);
203 let _ = run_step("deactivate", "launchctl-unload", unload);
204 }
205 #[cfg(target_os = "windows")]
206 {
207 let mut delete = Command::new("schtasks");
208 delete.args(["/Delete", "/TN", "MinisStudioWorker", "/F"]);
209 let _ = run_step("deactivate", "schtasks-delete", delete);
210 }
211 }
212}
213
214pub fn install(config_path: Option<&str>) -> Result<()> {
215 install_with(&RealOps, config_path)
216}
217
218pub fn uninstall() -> Result<()> {
219 uninstall_with(&RealOps)
220}
221
222pub fn install_with<O: ServiceOps>(ops: &O, config_path: Option<&str>) -> Result<()> {
226 let bin = ops.binary_path()?;
227 let cfg_arg = config_path
228 .map(|p| format!("--config {p} "))
229 .unwrap_or_default();
230 let dir = ops.unit_dir()?;
231 let path = dir.join(SERVICE_FILENAME);
232
233 let body = render_service(&bin.display().to_string(), &cfg_arg);
234 std::fs::write(&path, &body)
235 .with_context(|| format!("writing service file {}", path.display()))?;
236
237 println!("wrote service unit: {}", path.display());
238
239 let activated = ops.activate(&path);
240 if activated {
241 println!("activated service unit");
242 } else {
243 print_activation_instructions(&path);
244 }
245 info!(
246 target: TRACE_TARGET,
247 op = "install",
248 unit_path = %path.display(),
249 binary_path = %bin.display(),
250 activated,
251 "service install completed"
252 );
253 Ok(())
254}
255
256pub fn uninstall_with<O: ServiceOps>(ops: &O) -> Result<()> {
257 let dir = ops.unit_dir()?;
258 let path = dir.join(SERVICE_FILENAME);
259 ops.deactivate(&path);
260 let removed = if path.exists() {
261 std::fs::remove_file(&path)?;
262 println!("removed service unit: {}", path.display());
263 true
264 } else {
265 println!("no service unit to remove at {}", path.display());
266 false
267 };
268 info!(
269 target: TRACE_TARGET,
270 op = "uninstall",
271 unit_path = %path.display(),
272 removed,
273 "service uninstall completed"
274 );
275 Ok(())
276}
277
278fn print_activation_instructions(path: &Path) {
279 #[cfg(target_os = "linux")]
280 {
281 println!("activate manually:");
282 println!(" systemctl --user daemon-reload");
283 println!(" systemctl --user enable --now {SERVICE_FILENAME}");
284 let _ = path;
285 }
286 #[cfg(target_os = "macos")]
287 println!("load with: launchctl load -w {}", path.display());
288 #[cfg(target_os = "windows")]
289 println!(
290 "register with: schtasks /Create /XML {} /TN MinisStudioWorker",
291 path.display()
292 );
293 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
294 let _ = path;
295}
296
297#[cfg(target_os = "linux")]
298pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
299 format!(
300 r#"[Unit]
301Description=Minis studio worker (pull-based image-generation agent)
302After=network-online.target
303
304[Service]
305Type=simple
306ExecStart={bin} {cfg_arg}run
307Restart=on-failure
308RestartSec=5
309Environment=RUST_LOG=studio_worker=info
310
311[Install]
312WantedBy=default.target
313"#
314 )
315}
316
317#[cfg(target_os = "macos")]
318pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
319 let cfg_args = cfg_arg.trim();
320 let extra = if cfg_args.is_empty() {
321 String::new()
322 } else {
323 cfg_args
324 .split_whitespace()
325 .map(|s| format!(" <string>{}</string>\n", s))
326 .collect::<String>()
327 };
328 format!(
329 r#"<?xml version="1.0" encoding="UTF-8"?>
330<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
331<plist version="1.0">
332<dict>
333 <key>Label</key><string>gg.minis.studio-worker</string>
334 <key>ProgramArguments</key>
335 <array>
336 <string>{bin}</string>
337{extra} <string>run</string>
338 </array>
339 <key>RunAtLoad</key><true/>
340 <key>KeepAlive</key><true/>
341 <key>EnvironmentVariables</key>
342 <dict><key>RUST_LOG</key><string>studio_worker=info</string></dict>
343</dict>
344</plist>
345"#
346 )
347}
348
349#[cfg(target_os = "windows")]
350pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
351 let args = format!("{cfg_arg}run").trim().to_string();
352 format!(
353 r#"<?xml version="1.0" encoding="UTF-16"?>
354<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
355 <Triggers>
356 <LogonTrigger><Enabled>true</Enabled></LogonTrigger>
357 </Triggers>
358 <Settings>
359 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
360 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
361 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
362 <AllowStartOnDemand>true</AllowStartOnDemand>
363 <Enabled>true</Enabled>
364 <Hidden>false</Hidden>
365 <RestartOnFailure>
366 <Interval>PT1M</Interval>
367 <Count>10</Count>
368 </RestartOnFailure>
369 </Settings>
370 <Actions>
371 <Exec>
372 <Command>{bin}</Command>
373 <Arguments>{args}</Arguments>
374 </Exec>
375 </Actions>
376</Task>
377"#
378 )
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use std::cell::RefCell;
385 use std::path::PathBuf;
386 use tempfile::tempdir;
387
388 struct FakeOps {
389 bin: PathBuf,
390 dir: PathBuf,
391 activate_returns: bool,
392 activate_calls: RefCell<Vec<PathBuf>>,
393 deactivate_calls: RefCell<Vec<PathBuf>>,
394 }
395
396 impl ServiceOps for FakeOps {
397 fn unit_dir(&self) -> Result<PathBuf> {
398 Ok(self.dir.clone())
399 }
400 fn binary_path(&self) -> Result<PathBuf> {
401 Ok(self.bin.clone())
402 }
403 fn activate(&self, unit_path: &Path) -> bool {
404 self.activate_calls
405 .borrow_mut()
406 .push(unit_path.to_path_buf());
407 self.activate_returns
408 }
409 fn deactivate(&self, unit_path: &Path) {
410 self.deactivate_calls
411 .borrow_mut()
412 .push(unit_path.to_path_buf());
413 }
414 }
415
416 #[cfg(target_os = "linux")]
417 #[test]
418 fn linux_render_includes_exec_start_and_install_section() {
419 let rendered = render_service("/usr/bin/studio-worker", "");
420 assert!(rendered.contains("ExecStart=/usr/bin/studio-worker run"));
421 assert!(rendered.contains("[Install]"));
422 assert!(rendered.contains("Restart=on-failure"));
423 }
424
425 #[cfg(target_os = "linux")]
426 #[test]
427 fn linux_render_passes_config_arg() {
428 let rendered = render_service("/usr/bin/studio-worker", "--config /etc/conf.toml ");
429 assert!(rendered.contains("--config /etc/conf.toml run"));
430 }
431
432 #[cfg(target_os = "macos")]
433 #[test]
434 fn macos_render_emits_valid_plist_xml() {
435 let rendered = render_service("/usr/local/bin/studio-worker", "");
436 assert!(rendered.contains("<plist version=\"1.0\">"));
437 assert!(rendered.contains("<string>/usr/local/bin/studio-worker</string>"));
438 }
439
440 #[cfg(target_os = "macos")]
441 #[test]
442 fn macos_render_includes_config_args_when_provided() {
443 let rendered = render_service("/usr/local/bin/studio-worker", "--config /etc/conf.toml ");
444 assert!(rendered.contains("<string>--config</string>"));
445 assert!(rendered.contains("<string>/etc/conf.toml</string>"));
446 }
447
448 #[cfg(target_os = "windows")]
449 #[test]
450 fn windows_render_emits_valid_task_xml() {
451 let rendered = render_service("C:\\worker.exe", "");
452 assert!(rendered.contains("<Command>C:\\worker.exe</Command>"));
453 assert!(rendered.contains("<Arguments>run</Arguments>"));
454 }
455
456 #[test]
457 fn install_with_writes_unit_file_and_succeeds_when_activate_returns_true() {
458 let dir = tempdir().unwrap();
459 let ops = FakeOps {
460 bin: PathBuf::from("/usr/bin/studio-worker"),
461 dir: dir.path().to_path_buf(),
462 activate_returns: true,
463 activate_calls: RefCell::new(Vec::new()),
464 deactivate_calls: RefCell::new(Vec::new()),
465 };
466 install_with(&ops, Some("/etc/conf.toml")).unwrap();
467 let written = dir.path().join(SERVICE_FILENAME);
468 assert!(
469 written.exists(),
470 "unit file should exist at {}",
471 written.display()
472 );
473 let body = std::fs::read_to_string(&written).unwrap();
474 assert!(body.contains("studio-worker"));
475 assert_eq!(ops.activate_calls.borrow().len(), 1);
476 assert_eq!(ops.activate_calls.borrow()[0], written);
477 }
478
479 #[test]
480 fn install_with_falls_back_to_manual_instructions_when_activate_fails() {
481 let dir = tempdir().unwrap();
482 let ops = FakeOps {
483 bin: PathBuf::from("/usr/bin/studio-worker"),
484 dir: dir.path().to_path_buf(),
485 activate_returns: false,
486 activate_calls: RefCell::new(Vec::new()),
487 deactivate_calls: RefCell::new(Vec::new()),
488 };
489 install_with(&ops, None).unwrap();
490 assert!(dir.path().join(SERVICE_FILENAME).exists());
491 }
492
493 #[test]
494 fn uninstall_with_removes_file_and_calls_deactivate() {
495 let dir = tempdir().unwrap();
496 let path = dir.path().join(SERVICE_FILENAME);
497 std::fs::write(&path, "dummy").unwrap();
498 let ops = FakeOps {
499 bin: PathBuf::from("/usr/bin/studio-worker"),
500 dir: dir.path().to_path_buf(),
501 activate_returns: false,
502 activate_calls: RefCell::new(Vec::new()),
503 deactivate_calls: RefCell::new(Vec::new()),
504 };
505 uninstall_with(&ops).unwrap();
506 assert!(!path.exists());
507 assert_eq!(ops.deactivate_calls.borrow().len(), 1);
508 }
509
510 #[test]
511 fn uninstall_with_is_idempotent_when_file_missing() {
512 let dir = tempdir().unwrap();
513 let ops = FakeOps {
514 bin: PathBuf::from("/usr/bin/studio-worker"),
515 dir: dir.path().to_path_buf(),
516 activate_returns: false,
517 activate_calls: RefCell::new(Vec::new()),
518 deactivate_calls: RefCell::new(Vec::new()),
519 };
520 uninstall_with(&ops).unwrap();
522 }
523
524 use crate::test_support::capture;
538
539 fn fake_ops(dir: PathBuf, activate_returns: bool) -> FakeOps {
540 FakeOps {
541 bin: PathBuf::from("/usr/bin/studio-worker"),
542 dir,
543 activate_returns,
544 activate_calls: RefCell::new(Vec::new()),
545 deactivate_calls: RefCell::new(Vec::new()),
546 }
547 }
548
549 #[test]
550 fn install_with_emits_info_event_with_activated_true_when_activation_succeeds() {
551 let dir = tempdir().unwrap();
552 let dir_path = dir.path().to_path_buf();
553 let logs = capture(move || {
554 install_with(&fake_ops(dir_path, true), None).unwrap();
555 });
556 assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
557 assert!(
558 logs.contains("studio_worker::service"),
559 "expected service target, got: {logs}"
560 );
561 assert!(logs.contains("op=\"install\""), "expected op field: {logs}");
562 assert!(
563 logs.contains("activated=true"),
564 "expected activated=true: {logs}"
565 );
566 assert!(
567 logs.contains(SERVICE_FILENAME),
568 "expected unit_path in log, got: {logs}"
569 );
570 }
571
572 #[test]
573 fn install_with_emits_info_event_with_activated_false_on_manual_fallback() {
574 let dir = tempdir().unwrap();
575 let dir_path = dir.path().to_path_buf();
576 let logs = capture(move || {
577 install_with(&fake_ops(dir_path, false), None).unwrap();
578 });
579 assert!(
580 logs.contains("activated=false"),
581 "expected activated=false: {logs}"
582 );
583 }
584
585 #[test]
586 fn uninstall_with_emits_info_event_with_removed_true_when_file_existed() {
587 let dir = tempdir().unwrap();
588 let path = dir.path().join(SERVICE_FILENAME);
589 std::fs::write(&path, "dummy").unwrap();
590 let dir_path = dir.path().to_path_buf();
591 let logs = capture(move || {
592 uninstall_with(&fake_ops(dir_path, false)).unwrap();
593 });
594 assert!(
595 logs.contains("op=\"uninstall\""),
596 "expected op field: {logs}"
597 );
598 assert!(
599 logs.contains("removed=true"),
600 "expected removed=true: {logs}"
601 );
602 }
603
604 #[test]
605 fn uninstall_with_emits_info_event_with_removed_false_when_file_missing() {
606 let dir = tempdir().unwrap();
607 let dir_path = dir.path().to_path_buf();
608 let logs = capture(move || {
609 uninstall_with(&fake_ops(dir_path, false)).unwrap();
610 });
611 assert!(
612 logs.contains("removed=false"),
613 "expected removed=false: {logs}"
614 );
615 }
616
617 #[test]
624 fn classify_status_recognises_zero_exit_as_succeeded() {
625 let status = std::process::Command::new(std::env::current_exe().unwrap())
628 .arg("--list")
629 .stdout(std::process::Stdio::null())
630 .status();
631 assert_eq!(classify_status(status), StepOutcome::Succeeded);
632 }
633
634 #[test]
635 fn classify_status_recognises_non_zero_exit_as_failed() {
636 let status = std::process::Command::new(std::env::current_exe().unwrap())
639 .arg("--definitely-not-a-real-flag-zzzqx")
640 .stdout(std::process::Stdio::null())
641 .stderr(std::process::Stdio::null())
642 .status();
643 match classify_status(status) {
644 StepOutcome::Failed { .. } => {}
645 other => panic!("expected Failed, got {other:?}"),
646 }
647 }
648
649 #[test]
650 fn classify_status_recognises_spawn_failure() {
651 let status =
652 std::process::Command::new("definitely-not-on-path-zzzqxq-studio-worker").status();
653 assert_eq!(classify_status(status), StepOutcome::SpawnFailed);
654 }
655
656 #[test]
657 fn step_outcome_is_success_only_for_succeeded() {
658 assert!(StepOutcome::Succeeded.is_success());
659 assert!(!StepOutcome::Failed { code: Some(1) }.is_success());
660 assert!(!StepOutcome::Failed { code: None }.is_success());
661 assert!(!StepOutcome::SpawnFailed.is_success());
662 }
663}