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(not(target_os = "linux"))]
165 {
166 false
167 }
168 }
169
170 #[allow(unused_variables)]
171 fn deactivate(&self, unit_path: &Path) {
172 #[cfg(target_os = "linux")]
173 {
174 let mut disable = Command::new("systemctl");
175 disable.args(["--user", "disable", "--now", SERVICE_FILENAME]);
176 let _ = run_step("deactivate", "disable-now", disable);
177 }
178 #[cfg(target_os = "macos")]
179 {
180 let mut unload = Command::new("launchctl");
181 unload.args(["unload", unit_path.to_string_lossy().as_ref()]);
182 let _ = run_step("deactivate", "launchctl-unload", unload);
183 }
184 #[cfg(target_os = "windows")]
185 {
186 let mut delete = Command::new("schtasks");
187 delete.args(["/Delete", "/TN", "MinisStudioWorker", "/F"]);
188 let _ = run_step("deactivate", "schtasks-delete", delete);
189 }
190 }
191}
192
193pub fn install(config_path: Option<&str>) -> Result<()> {
194 install_with(&RealOps, config_path)
195}
196
197pub fn uninstall() -> Result<()> {
198 uninstall_with(&RealOps)
199}
200
201pub fn install_with<O: ServiceOps>(ops: &O, config_path: Option<&str>) -> Result<()> {
205 let bin = ops.binary_path()?;
206 let cfg_arg = config_path
207 .map(|p| format!("--config {p} "))
208 .unwrap_or_default();
209 let dir = ops.unit_dir()?;
210 let path = dir.join(SERVICE_FILENAME);
211
212 let body = render_service(&bin.display().to_string(), &cfg_arg);
213 std::fs::write(&path, &body)
214 .with_context(|| format!("writing service file {}", path.display()))?;
215
216 println!("wrote service unit: {}", path.display());
217
218 let activated = ops.activate(&path);
219 if activated {
220 println!("activated service unit");
221 } else {
222 print_activation_instructions(&path);
223 }
224 info!(
225 target: TRACE_TARGET,
226 op = "install",
227 unit_path = %path.display(),
228 binary_path = %bin.display(),
229 activated,
230 "service install completed"
231 );
232 Ok(())
233}
234
235pub fn uninstall_with<O: ServiceOps>(ops: &O) -> Result<()> {
236 let dir = ops.unit_dir()?;
237 let path = dir.join(SERVICE_FILENAME);
238 ops.deactivate(&path);
239 let removed = if path.exists() {
240 std::fs::remove_file(&path)?;
241 println!("removed service unit: {}", path.display());
242 true
243 } else {
244 println!("no service unit to remove at {}", path.display());
245 false
246 };
247 info!(
248 target: TRACE_TARGET,
249 op = "uninstall",
250 unit_path = %path.display(),
251 removed,
252 "service uninstall completed"
253 );
254 Ok(())
255}
256
257fn print_activation_instructions(path: &Path) {
258 #[cfg(target_os = "linux")]
259 {
260 println!("activate manually:");
261 println!(" systemctl --user daemon-reload");
262 println!(" systemctl --user enable --now {SERVICE_FILENAME}");
263 let _ = path;
264 }
265 #[cfg(target_os = "macos")]
266 println!("load with: launchctl load -w {}", path.display());
267 #[cfg(target_os = "windows")]
268 println!(
269 "register with: schtasks /Create /XML {} /TN MinisStudioWorker",
270 path.display()
271 );
272 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
273 let _ = path;
274}
275
276#[cfg(target_os = "linux")]
277pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
278 format!(
279 r#"[Unit]
280Description=Minis studio worker (pull-based image-generation agent)
281After=network-online.target
282
283[Service]
284Type=simple
285ExecStart={bin} {cfg_arg}run
286Restart=on-failure
287RestartSec=5
288Environment=RUST_LOG=studio_worker=info
289
290[Install]
291WantedBy=default.target
292"#
293 )
294}
295
296#[cfg(target_os = "macos")]
297pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
298 let cfg_args = cfg_arg.trim();
299 let extra = if cfg_args.is_empty() {
300 String::new()
301 } else {
302 cfg_args
303 .split_whitespace()
304 .map(|s| format!(" <string>{}</string>\n", s))
305 .collect::<String>()
306 };
307 format!(
308 r#"<?xml version="1.0" encoding="UTF-8"?>
309<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
310<plist version="1.0">
311<dict>
312 <key>Label</key><string>gg.minis.studio-worker</string>
313 <key>ProgramArguments</key>
314 <array>
315 <string>{bin}</string>
316{extra} <string>run</string>
317 </array>
318 <key>RunAtLoad</key><true/>
319 <key>KeepAlive</key><true/>
320 <key>EnvironmentVariables</key>
321 <dict><key>RUST_LOG</key><string>studio_worker=info</string></dict>
322</dict>
323</plist>
324"#
325 )
326}
327
328#[cfg(target_os = "windows")]
329pub(crate) fn render_service(bin: &str, cfg_arg: &str) -> String {
330 let args = format!("{cfg_arg}run").trim().to_string();
331 format!(
332 r#"<?xml version="1.0" encoding="UTF-16"?>
333<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
334 <Triggers>
335 <LogonTrigger><Enabled>true</Enabled></LogonTrigger>
336 </Triggers>
337 <Settings>
338 <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
339 <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
340 <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
341 <AllowStartOnDemand>true</AllowStartOnDemand>
342 <Enabled>true</Enabled>
343 <Hidden>false</Hidden>
344 <RestartOnFailure>
345 <Interval>PT1M</Interval>
346 <Count>10</Count>
347 </RestartOnFailure>
348 </Settings>
349 <Actions>
350 <Exec>
351 <Command>{bin}</Command>
352 <Arguments>{args}</Arguments>
353 </Exec>
354 </Actions>
355</Task>
356"#
357 )
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use std::cell::RefCell;
364 use std::path::PathBuf;
365 use tempfile::tempdir;
366
367 struct FakeOps {
368 bin: PathBuf,
369 dir: PathBuf,
370 activate_returns: bool,
371 activate_calls: RefCell<Vec<PathBuf>>,
372 deactivate_calls: RefCell<Vec<PathBuf>>,
373 }
374
375 impl ServiceOps for FakeOps {
376 fn unit_dir(&self) -> Result<PathBuf> {
377 Ok(self.dir.clone())
378 }
379 fn binary_path(&self) -> Result<PathBuf> {
380 Ok(self.bin.clone())
381 }
382 fn activate(&self, unit_path: &Path) -> bool {
383 self.activate_calls
384 .borrow_mut()
385 .push(unit_path.to_path_buf());
386 self.activate_returns
387 }
388 fn deactivate(&self, unit_path: &Path) {
389 self.deactivate_calls
390 .borrow_mut()
391 .push(unit_path.to_path_buf());
392 }
393 }
394
395 #[cfg(target_os = "linux")]
396 #[test]
397 fn linux_render_includes_exec_start_and_install_section() {
398 let rendered = render_service("/usr/bin/studio-worker", "");
399 assert!(rendered.contains("ExecStart=/usr/bin/studio-worker run"));
400 assert!(rendered.contains("[Install]"));
401 assert!(rendered.contains("Restart=on-failure"));
402 }
403
404 #[cfg(target_os = "linux")]
405 #[test]
406 fn linux_render_passes_config_arg() {
407 let rendered = render_service("/usr/bin/studio-worker", "--config /etc/conf.toml ");
408 assert!(rendered.contains("--config /etc/conf.toml run"));
409 }
410
411 #[cfg(target_os = "macos")]
412 #[test]
413 fn macos_render_emits_valid_plist_xml() {
414 let rendered = render_service("/usr/local/bin/studio-worker", "");
415 assert!(rendered.contains("<plist version=\"1.0\">"));
416 assert!(rendered.contains("<string>/usr/local/bin/studio-worker</string>"));
417 }
418
419 #[cfg(target_os = "macos")]
420 #[test]
421 fn macos_render_includes_config_args_when_provided() {
422 let rendered = render_service("/usr/local/bin/studio-worker", "--config /etc/conf.toml ");
423 assert!(rendered.contains("<string>--config</string>"));
424 assert!(rendered.contains("<string>/etc/conf.toml</string>"));
425 }
426
427 #[cfg(target_os = "windows")]
428 #[test]
429 fn windows_render_emits_valid_task_xml() {
430 let rendered = render_service("C:\\worker.exe", "");
431 assert!(rendered.contains("<Command>C:\\worker.exe</Command>"));
432 assert!(rendered.contains("<Arguments>run</Arguments>"));
433 }
434
435 #[test]
436 fn install_with_writes_unit_file_and_succeeds_when_activate_returns_true() {
437 let dir = tempdir().unwrap();
438 let ops = FakeOps {
439 bin: PathBuf::from("/usr/bin/studio-worker"),
440 dir: dir.path().to_path_buf(),
441 activate_returns: true,
442 activate_calls: RefCell::new(Vec::new()),
443 deactivate_calls: RefCell::new(Vec::new()),
444 };
445 install_with(&ops, Some("/etc/conf.toml")).unwrap();
446 let written = dir.path().join(SERVICE_FILENAME);
447 assert!(
448 written.exists(),
449 "unit file should exist at {}",
450 written.display()
451 );
452 let body = std::fs::read_to_string(&written).unwrap();
453 assert!(body.contains("studio-worker"));
454 assert_eq!(ops.activate_calls.borrow().len(), 1);
455 assert_eq!(ops.activate_calls.borrow()[0], written);
456 }
457
458 #[test]
459 fn install_with_falls_back_to_manual_instructions_when_activate_fails() {
460 let dir = tempdir().unwrap();
461 let ops = FakeOps {
462 bin: PathBuf::from("/usr/bin/studio-worker"),
463 dir: dir.path().to_path_buf(),
464 activate_returns: false,
465 activate_calls: RefCell::new(Vec::new()),
466 deactivate_calls: RefCell::new(Vec::new()),
467 };
468 install_with(&ops, None).unwrap();
469 assert!(dir.path().join(SERVICE_FILENAME).exists());
470 }
471
472 #[test]
473 fn uninstall_with_removes_file_and_calls_deactivate() {
474 let dir = tempdir().unwrap();
475 let path = dir.path().join(SERVICE_FILENAME);
476 std::fs::write(&path, "dummy").unwrap();
477 let ops = FakeOps {
478 bin: PathBuf::from("/usr/bin/studio-worker"),
479 dir: dir.path().to_path_buf(),
480 activate_returns: false,
481 activate_calls: RefCell::new(Vec::new()),
482 deactivate_calls: RefCell::new(Vec::new()),
483 };
484 uninstall_with(&ops).unwrap();
485 assert!(!path.exists());
486 assert_eq!(ops.deactivate_calls.borrow().len(), 1);
487 }
488
489 #[test]
490 fn uninstall_with_is_idempotent_when_file_missing() {
491 let dir = tempdir().unwrap();
492 let ops = FakeOps {
493 bin: PathBuf::from("/usr/bin/studio-worker"),
494 dir: dir.path().to_path_buf(),
495 activate_returns: false,
496 activate_calls: RefCell::new(Vec::new()),
497 deactivate_calls: RefCell::new(Vec::new()),
498 };
499 uninstall_with(&ops).unwrap();
501 }
502
503 use crate::test_support::capture;
517
518 fn fake_ops(dir: PathBuf, activate_returns: bool) -> FakeOps {
519 FakeOps {
520 bin: PathBuf::from("/usr/bin/studio-worker"),
521 dir,
522 activate_returns,
523 activate_calls: RefCell::new(Vec::new()),
524 deactivate_calls: RefCell::new(Vec::new()),
525 }
526 }
527
528 #[test]
529 fn install_with_emits_info_event_with_activated_true_when_activation_succeeds() {
530 let dir = tempdir().unwrap();
531 let dir_path = dir.path().to_path_buf();
532 let logs = capture(move || {
533 install_with(&fake_ops(dir_path, true), None).unwrap();
534 });
535 assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
536 assert!(
537 logs.contains("studio_worker::service"),
538 "expected service target, got: {logs}"
539 );
540 assert!(logs.contains("op=\"install\""), "expected op field: {logs}");
541 assert!(
542 logs.contains("activated=true"),
543 "expected activated=true: {logs}"
544 );
545 assert!(
546 logs.contains(SERVICE_FILENAME),
547 "expected unit_path in log, got: {logs}"
548 );
549 }
550
551 #[test]
552 fn install_with_emits_info_event_with_activated_false_on_manual_fallback() {
553 let dir = tempdir().unwrap();
554 let dir_path = dir.path().to_path_buf();
555 let logs = capture(move || {
556 install_with(&fake_ops(dir_path, false), None).unwrap();
557 });
558 assert!(
559 logs.contains("activated=false"),
560 "expected activated=false: {logs}"
561 );
562 }
563
564 #[test]
565 fn uninstall_with_emits_info_event_with_removed_true_when_file_existed() {
566 let dir = tempdir().unwrap();
567 let path = dir.path().join(SERVICE_FILENAME);
568 std::fs::write(&path, "dummy").unwrap();
569 let dir_path = dir.path().to_path_buf();
570 let logs = capture(move || {
571 uninstall_with(&fake_ops(dir_path, false)).unwrap();
572 });
573 assert!(
574 logs.contains("op=\"uninstall\""),
575 "expected op field: {logs}"
576 );
577 assert!(
578 logs.contains("removed=true"),
579 "expected removed=true: {logs}"
580 );
581 }
582
583 #[test]
584 fn uninstall_with_emits_info_event_with_removed_false_when_file_missing() {
585 let dir = tempdir().unwrap();
586 let dir_path = dir.path().to_path_buf();
587 let logs = capture(move || {
588 uninstall_with(&fake_ops(dir_path, false)).unwrap();
589 });
590 assert!(
591 logs.contains("removed=false"),
592 "expected removed=false: {logs}"
593 );
594 }
595
596 #[test]
603 fn classify_status_recognises_zero_exit_as_succeeded() {
604 let status = std::process::Command::new(std::env::current_exe().unwrap())
607 .arg("--list")
608 .stdout(std::process::Stdio::null())
609 .status();
610 assert_eq!(classify_status(status), StepOutcome::Succeeded);
611 }
612
613 #[test]
614 fn classify_status_recognises_non_zero_exit_as_failed() {
615 let status = std::process::Command::new(std::env::current_exe().unwrap())
618 .arg("--definitely-not-a-real-flag-zzzqx")
619 .stdout(std::process::Stdio::null())
620 .stderr(std::process::Stdio::null())
621 .status();
622 match classify_status(status) {
623 StepOutcome::Failed { .. } => {}
624 other => panic!("expected Failed, got {other:?}"),
625 }
626 }
627
628 #[test]
629 fn classify_status_recognises_spawn_failure() {
630 let status =
631 std::process::Command::new("definitely-not-on-path-zzzqxq-studio-worker").status();
632 assert_eq!(classify_status(status), StepOutcome::SpawnFailed);
633 }
634
635 #[test]
636 fn step_outcome_is_success_only_for_succeeded() {
637 assert!(StepOutcome::Succeeded.is_success());
638 assert!(!StepOutcome::Failed { code: Some(1) }.is_success());
639 assert!(!StepOutcome::Failed { code: None }.is_success());
640 assert!(!StepOutcome::SpawnFailed.is_success());
641 }
642}