1use crate::{
9 config::{self, Config, SharedConfig},
10 engine::Engine,
11 sys,
12 types::*,
13 update, AGENT_VERSION,
14};
15use anyhow::{anyhow, Result};
16use chrono::{DateTime, SecondsFormat, Utc};
17use parking_lot::Mutex;
18use std::{
19 collections::VecDeque,
20 sync::{
21 atomic::{AtomicBool, Ordering},
22 Arc,
23 },
24 time::Duration,
25};
26use tracing::{info, warn};
27
28const TRACE_TARGET: &str = "studio_worker::runtime";
31
32pub const RECENT_JOBS_CAP: usize = 50;
35
36pub const RECENT_LOGS_CAP: usize = 1000;
40
41pub const PROMPT_PREVIEW_CHARS: usize = 200;
45
46pub const LOG_SHIP_QUEUE_CAP: usize = 5_000;
53
54#[derive(Debug, Clone)]
57pub struct CurrentJob {
58 pub job_id: String,
59 pub kind: TaskKind,
60 pub model: String,
61 pub prompt: String,
62 pub started_at: DateTime<Utc>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum JobOutcome {
69 Completed,
70 Failed { reason: String },
71}
72
73#[derive(Debug, Clone)]
75pub struct RecentJob {
76 pub job_id: String,
77 pub kind: TaskKind,
78 pub model: String,
79 pub prompt: String,
80 pub outcome: JobOutcome,
81 pub started_at: DateTime<Utc>,
82 pub finished_at: DateTime<Utc>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum HeartbeatOutcome {
88 Ok,
89 Err { reason: String },
90}
91
92#[derive(Debug, Clone)]
93pub struct HeartbeatStatus {
94 pub last_attempt_at: DateTime<Utc>,
95 pub outcome: HeartbeatOutcome,
96}
97
98#[derive(Clone, Default)]
103pub struct WorkerObservers {
104 pub current_job: Arc<Mutex<Option<CurrentJob>>>,
105 pub recent_jobs: Arc<Mutex<VecDeque<RecentJob>>>,
106 pub local_jobs: Arc<Mutex<VecDeque<RecentJob>>>,
109 pub local_api_url: Arc<Mutex<Option<String>>>,
111 pub last_heartbeat: Arc<Mutex<Option<HeartbeatStatus>>>,
112 pub recent_logs: Arc<Mutex<VecDeque<LogEntry>>>,
117}
118
119pub fn truncate_prompt(s: &str) -> String {
120 if s.chars().count() <= PROMPT_PREVIEW_CHARS {
121 return s.to_string();
122 }
123 let mut out: String = s.chars().take(PROMPT_PREVIEW_CHARS).collect();
124 out.push('…');
125 out
126}
127
128pub fn record_recent_job(observers: &WorkerObservers, entry: RecentJob) {
129 let mut ring = observers.recent_jobs.lock();
130 ring.push_front(entry);
131 while ring.len() > RECENT_JOBS_CAP {
132 ring.pop_back();
133 }
134}
135
136pub fn record_local_job(observers: &WorkerObservers, entry: RecentJob) {
138 let mut ring = observers.local_jobs.lock();
139 ring.push_front(entry);
140 while ring.len() > RECENT_JOBS_CAP {
141 ring.pop_back();
142 }
143}
144
145#[doc(hidden)]
149pub fn push_recent_job_for_tests(observers: &WorkerObservers, job_id: &str) {
150 let now = Utc::now();
151 record_recent_job(
152 observers,
153 RecentJob {
154 job_id: job_id.to_string(),
155 kind: TaskKind::Image,
156 model: "synthetic".into(),
157 prompt: String::new(),
158 outcome: JobOutcome::Completed,
159 started_at: now,
160 finished_at: now,
161 },
162 );
163}
164
165pub const AUTO_UPDATE_TICK: Duration = Duration::from_secs(60);
166pub const AUTO_UPDATE_SHUTDOWN_TICK: Duration = Duration::from_millis(250);
172pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
176
177#[derive(Debug, Clone, Copy)]
179pub struct LoopSchedule {
180 pub ws_session: crate::ws::session::SessionSchedule,
181 pub auto_update_tick: Duration,
182 pub shutdown_tick: Duration,
186}
187
188impl Default for LoopSchedule {
189 fn default() -> Self {
190 Self {
191 ws_session: crate::ws::session::SessionSchedule::default(),
192 auto_update_tick: AUTO_UPDATE_TICK,
193 shutdown_tick: AUTO_UPDATE_SHUTDOWN_TICK,
194 }
195 }
196}
197
198impl LoopSchedule {
199 pub fn fast_for_tests() -> Self {
202 Self {
203 ws_session: crate::ws::session::SessionSchedule::fast_for_tests(),
204 auto_update_tick: Duration::from_millis(1),
205 shutdown_tick: Duration::from_millis(1),
206 }
207 }
208}
209
210#[derive(Debug, Clone, Default)]
216pub struct RegisterArgs {
217 pub api_base_url: Option<String>,
218 pub reset: bool,
219}
220
221pub async fn register(config_path: Option<&str>, args: RegisterArgs) -> Result<()> {
225 let (mut cfg, path) = config::load(config_path)?;
226
227 if args.reset {
228 cfg.worker_id = None;
229 cfg.auth_token = None;
230 cfg.registration_request_id = None;
231 cfg.registration_secret = None;
232 cfg.install_id = None;
233 }
234 if let Some(url) = args.api_base_url {
235 cfg.api_base_url = url;
236 }
237
238 config::save(&cfg, &path)?;
239 if args.reset {
240 info!(
241 config_path = %path.display(),
242 "local registration state cleared; next launch will auto-register"
243 );
244 println!(
245 "local registration state cleared; run `studio-worker run` or \
246 `studio-worker ui` to auto-register"
247 );
248 } else {
249 info!(
250 config_path = %path.display(),
251 "register flags persisted; next launch will auto-register"
252 );
253 println!(
254 "saved; run `studio-worker run` or `studio-worker ui` to auto-register against {}",
255 cfg.api_base_url
256 );
257 }
258 Ok(())
259}
260
261pub async fn status(config_path: Option<&str>) -> Result<()> {
262 let (cfg, path) = config::load(config_path)?;
263 println!("{}", format_status(&cfg, &path));
264 Ok(())
265}
266
267pub fn format_status(cfg: &Config, path: &std::path::Path) -> String {
268 let mut out = String::new();
269 use std::fmt::Write as _;
270 let _ = writeln!(out, "config path: {}", path.display());
271 let _ = writeln!(out, "api_base_url: {}", cfg.api_base_url);
272 let registration_line = if cfg.worker_id.is_some() && cfg.auth_token.is_some() {
273 format!("approved as {}", cfg.worker_id.as_deref().unwrap_or(""))
274 } else if let Some(rid) = cfg.registration_request_id.as_deref() {
275 format!("pending operator approval (request {rid})")
276 } else {
277 "not registered (will auto-register on next launch)".into()
278 };
279 let _ = writeln!(out, "registration: {registration_line}");
280 let _ = writeln!(out, "vram_threshold_gb: {}", cfg.vram_threshold_gb);
281 let _ = writeln!(out, "auto_start: {}", cfg.auto_start);
282 let _ = writeln!(out, "models_root: {}", cfg.models_root.display());
283 let _ = writeln!(out, "auto_update: {}", cfg.auto_update_enabled);
284 let _ = writeln!(
285 out,
286 "update_interval: {}s",
287 cfg.auto_update_interval_secs
288 );
289 out
290}
291
292pub fn set_threshold(config_path: Option<&str>, gb: f32) -> Result<()> {
293 if gb < 0.0 {
294 return Err(anyhow!("threshold must be >= 0"));
295 }
296 let (mut cfg, path) = config::load(config_path)?;
297 cfg.vram_threshold_gb = gb;
298 config::save(&cfg, &path)?;
299 info!(
300 target: TRACE_TARGET,
301 op = "set_threshold",
302 vram_threshold_gb = gb,
303 config_path = path.display().to_string(),
304 "VRAM threshold persisted"
305 );
306 println!("vram_threshold_gb = {gb}");
307 Ok(())
308}
309
310pub fn log_startup_banner(cfg: &Config, path: &std::path::Path) {
315 info!(
316 target: TRACE_TARGET,
317 op = "startup",
318 version = AGENT_VERSION,
319 config_path = path.display().to_string(),
320 api_base_url = cfg.api_base_url.as_str(),
321 vram_threshold_gb = cfg.vram_threshold_gb,
322 auto_start = cfg.auto_start,
323 auto_update_enabled = cfg.auto_update_enabled,
324 auto_update_interval_secs = cfg.auto_update_interval_secs,
325 models_root = cfg.models_root.display().to_string(),
326 worker_id = cfg.worker_id.as_deref().unwrap_or("(unregistered)"),
327 "studio-worker booting"
328 );
329}
330
331pub fn show_config(config_path: Option<&str>) -> Result<()> {
332 let (cfg, path) = config::load(config_path)?;
333 println!("# {}", path.display());
334 print!("{}", toml::to_string_pretty(&cfg)?);
335 Ok(())
336}
337
338pub async fn check_update(config_path: Option<&str>) -> Result<()> {
339 let (cfg, _) = config::load(config_path)?;
340 let current = semver::Version::parse(AGENT_VERSION)
341 .map_err(|e| anyhow!("invalid current version {AGENT_VERSION}: {e}"))?;
342 let outcome = tokio::task::spawn_blocking(move || {
343 update::check(&cfg.auto_update_feed, ¤t, cfg.auto_update_prerelease)
344 })
345 .await??;
346 println!("{}", format_check_outcome(&outcome));
347 Ok(())
348}
349
350pub fn format_check_outcome(outcome: &update::CheckOutcome) -> String {
351 match outcome {
352 update::CheckOutcome::UpToDate { current } => format!("up to date: {current}"),
353 update::CheckOutcome::NewerAvailable { current, latest } => {
354 format!("update available: {current} -> {latest}")
355 }
356 }
357}
358
359pub async fn run(config_path: Option<&str>) -> Result<()> {
364 let (cfg, path) = config::load(config_path)?;
365 log_startup_banner(&cfg, &path);
366
367 let cfg = config::shared(cfg);
368 let stop = Arc::new(AtomicBool::new(false));
369 let busy = Arc::new(AtomicBool::new(false));
370 let paused = Arc::new(AtomicBool::new(false));
373 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
374 let observers = WorkerObservers::default();
375 let registration = crate::auto_register::shared_initial();
376
377 let stop_clone = stop.clone();
378 tokio::spawn(async move {
379 let signal = wait_for_shutdown_signal().await;
380 request_shutdown(&stop_clone, signal);
381 });
382
383 let local_api = spawn_local_api(cfg.clone(), observers.clone(), stop.clone());
391
392 let outcome = match ensure_registered(&cfg, &path, ®istration, &stop).await {
393 Ok(RegistrationGate::Stopped) => {
394 info!(
395 target: TRACE_TARGET,
396 op = "shutdown",
397 "stopped before registration completed; exiting cleanly"
398 );
399 Ok(())
400 }
401 Ok(_) => {
402 run_loops(
403 cfg,
404 stop.clone(),
405 logs,
406 busy,
407 paused,
408 observers,
409 LoopSchedule::default(),
410 )
411 .await
412 }
413 Err(err) => Err(err),
414 };
415
416 stop.store(true, Ordering::SeqCst);
418 if let Some(handle) = local_api {
419 let _ = handle.join();
420 }
421 outcome
422}
423
424pub fn request_shutdown(stop: &AtomicBool, signal: &str) {
430 let already_stopping = stop.swap(true, Ordering::SeqCst);
431 info!(
432 target: TRACE_TARGET,
433 op = "shutdown",
434 signal,
435 already_stopping,
436 "shutdown signal received; stopping worker gracefully"
437 );
438}
439
440#[cfg_attr(coverage_nightly, coverage(off))]
456async fn wait_for_shutdown_signal() -> &'static str {
457 #[cfg(unix)]
458 {
459 use tokio::signal::unix::{signal, SignalKind};
460 let mut sigterm = match signal(SignalKind::terminate()) {
461 Ok(s) => s,
462 Err(e) => {
463 warn!(
464 target: TRACE_TARGET,
465 op = "shutdown",
466 error = %e,
467 "could not install SIGTERM handler; falling back to Ctrl-C only"
468 );
469 let _ = tokio::signal::ctrl_c().await;
470 return "SIGINT";
471 }
472 };
473 tokio::select! {
474 _ = tokio::signal::ctrl_c() => "SIGINT",
475 _ = sigterm.recv() => "SIGTERM",
476 }
477 }
478 #[cfg(not(unix))]
479 {
480 let _ = tokio::signal::ctrl_c().await;
481 "ctrl-c"
482 }
483}
484
485#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum RegistrationGate {
499 Ready,
502 Stopped,
504}
505
506pub async fn ensure_registered(
511 cfg: &SharedConfig,
512 path: &std::path::Path,
513 registration: &crate::auto_register::SharedRegistration,
514 stop: &Arc<AtomicBool>,
515) -> Result<RegistrationGate> {
516 use std::time::Duration;
517 loop {
518 if stop.load(Ordering::SeqCst) {
519 return Ok(RegistrationGate::Stopped);
520 }
521 {
522 let snap = cfg.lock();
523 if snap.worker_id.is_some() && snap.auth_token.is_some() {
524 return Ok(RegistrationGate::Ready);
525 }
526 }
527 let state = crate::auto_register::tick(cfg, path, registration).await;
528 match state {
529 crate::auto_register::RegistrationState::Approved => {
530 return Ok(RegistrationGate::Ready)
531 }
532 crate::auto_register::RegistrationState::Rejected { reason } => {
533 return Err(anyhow!(
534 "registration rejected by the studio operator: {reason}. \
535 Run `studio-worker register --reset` to clear local state \
536 and submit a fresh request."
537 ));
538 }
539 _ => {}
540 }
541 for _ in 0..30 {
543 if stop.load(Ordering::SeqCst) {
544 return Ok(RegistrationGate::Stopped);
545 }
546 tokio::time::sleep(Duration::from_secs(1)).await;
547 }
548 }
549}
550
551pub async fn run_loops(
559 cfg: SharedConfig,
560 stop: Arc<AtomicBool>,
561 logs: Arc<Mutex<Vec<LogEntry>>>,
562 busy: Arc<AtomicBool>,
563 paused: Arc<AtomicBool>,
564 observers: WorkerObservers,
565 schedule: LoopSchedule,
566) -> Result<()> {
567 let session = crate::ws::session::spawn_ws_session(
568 cfg.clone(),
569 stop.clone(),
570 logs.clone(),
571 busy.clone(),
572 paused.clone(),
573 observers.clone(),
574 schedule.ws_session,
575 );
576 let auto_updater = spawn_auto_updater(
577 cfg.clone(),
578 stop.clone(),
579 logs.clone(),
580 busy.clone(),
581 schedule,
582 );
583 let (session_result, _) = tokio::join!(session, auto_updater);
584 session_result
585}
586
587pub const DEFAULT_LOCAL_API_PORT: u16 = 4787;
590
591pub fn spawn_local_api(
595 cfg: SharedConfig,
596 observers: WorkerObservers,
597 stop: Arc<AtomicBool>,
598) -> Option<std::thread::JoinHandle<()>> {
599 let engine: Arc<dyn crate::engine::Engine> = match crate::engine::build(&cfg.lock()) {
600 Ok(engine) => engine.into(),
601 Err(err) => {
602 tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: engine build failed");
603 return None;
604 }
605 };
606
607 let catalog_path = crate::config::default_catalog_path().ok();
608 let catalog = match &catalog_path {
609 Some(path) => crate::catalog::Catalog::load_or_seed(path).unwrap_or_else(|err| {
610 tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: catalog load failed; seeding in-memory");
611 crate::catalog::Catalog::seed()
612 }),
613 None => crate::catalog::Catalog::seed(),
614 };
615 let catalog = Arc::new(Mutex::new(catalog));
616
617 let port = std::env::var("STUDIO_WORKER_LOCAL_API_PORT")
618 .ok()
619 .and_then(|p| p.parse::<u16>().ok())
620 .unwrap_or(DEFAULT_LOCAL_API_PORT);
621
622 let api = crate::local_api::LocalApi::bind(
623 &format!("127.0.0.1:{port}"),
624 engine.clone(),
625 catalog.clone(),
626 catalog_path.clone(),
627 observers.clone(),
628 )
629 .or_else(|_| {
630 crate::local_api::LocalApi::bind(
631 "127.0.0.1:0",
632 engine,
633 catalog,
634 catalog_path,
635 observers.clone(),
636 )
637 });
638
639 let api = match api {
640 Ok(api) => api,
641 Err(err) => {
642 tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: bind failed");
643 return None;
644 }
645 };
646
647 let url = api.url();
648 *observers.local_api_url.lock() = Some(url.clone());
649 tracing::info!(target: "studio_worker::local_api", url = %url, "local image API listening");
650
651 Some(std::thread::spawn(move || api.serve(&stop)))
652}
653
654#[derive(Debug, Clone, PartialEq, Eq)]
665pub enum AutoUpdateDecision {
666 Disabled,
668 SkippedBusy,
670 UpToDate,
672 CheckError(String),
674 Updated,
676 UpdateError(String),
678}
679
680pub async fn auto_update_tick(
681 cfg: &Config,
682 busy: bool,
683 logs: &Arc<Mutex<Vec<LogEntry>>>,
684) -> AutoUpdateDecision {
685 if !cfg.auto_update_enabled {
686 return AutoUpdateDecision::Disabled;
687 }
688 if busy {
689 push_log(
690 logs,
691 "info",
692 "auto-update",
693 "skipping check: worker is busy on a job",
694 None,
695 );
696 return AutoUpdateDecision::SkippedBusy;
697 }
698 let feed = cfg.auto_update_feed.clone();
699 let prerelease = cfg.auto_update_prerelease;
700 let logs_for_task = logs.clone();
701 let outcome = tokio::task::spawn_blocking(move || -> Result<AutoUpdateDecision> {
702 let current = semver::Version::parse(AGENT_VERSION)
703 .map_err(|e| anyhow!("invalid AGENT_VERSION {AGENT_VERSION}: {e}"))?;
704 match update::check(&feed, ¤t, prerelease) {
705 Ok(update::CheckOutcome::UpToDate { current }) => {
706 push_log(
707 &logs_for_task,
708 "info",
709 "auto-update",
710 &format!("up to date at {current}"),
711 None,
712 );
713 Ok(AutoUpdateDecision::UpToDate)
714 }
715 Ok(update::CheckOutcome::NewerAvailable { current, latest }) => {
716 push_log(
717 &logs_for_task,
718 "info",
719 "auto-update",
720 &format!("update available {current} -> {latest}; applying"),
721 None,
722 );
723 match update::apply(&feed, &latest) {
724 Ok(()) => {
725 push_log(
726 &logs_for_task,
727 "info",
728 "auto-update",
729 "binary replaced; restart pending",
730 None,
731 );
732 Ok(AutoUpdateDecision::Updated)
733 }
734 Err(e) => {
735 push_log(
736 &logs_for_task,
737 "error",
738 "auto-update",
739 &format!("update failed: {e}"),
740 None,
741 );
742 Ok(AutoUpdateDecision::UpdateError(e.to_string()))
743 }
744 }
745 }
746 Err(e) => {
747 push_log(
748 &logs_for_task,
749 "warn",
750 "auto-update",
751 &format!("check failed: {e}"),
752 None,
753 );
754 Ok(AutoUpdateDecision::CheckError(e.to_string()))
755 }
756 }
757 })
758 .await;
759 match outcome {
760 Ok(Ok(decision)) => decision,
761 Ok(Err(e)) => AutoUpdateDecision::CheckError(e.to_string()),
762 Err(e) => AutoUpdateDecision::CheckError(e.to_string()),
763 }
764}
765
766pub(crate) async fn wait_with_stop(total: Duration, stop: &Arc<AtomicBool>, tick: Duration) {
781 let mut elapsed = Duration::ZERO;
782 while elapsed < total {
783 if stop.load(Ordering::SeqCst) {
784 return;
785 }
786 let next = tick.min(total - elapsed);
787 tokio::time::sleep(next).await;
788 elapsed += next;
789 }
790}
791
792pub fn spawn_auto_updater(
793 cfg: SharedConfig,
794 stop: Arc<AtomicBool>,
795 logs: Arc<Mutex<Vec<LogEntry>>>,
796 busy: Arc<AtomicBool>,
797 schedule: LoopSchedule,
798) -> tokio::task::JoinHandle<()> {
799 tokio::spawn(async move {
800 let mut elapsed = Duration::from_secs(0);
801 while !stop.load(Ordering::SeqCst) {
802 wait_with_stop(schedule.auto_update_tick, &stop, schedule.shutdown_tick).await;
807 if stop.load(Ordering::SeqCst) {
808 break;
809 }
810 elapsed += schedule.auto_update_tick;
811 let snapshot = cfg.lock().clone();
812 if elapsed < Duration::from_secs(snapshot.auto_update_interval_secs) {
813 continue;
814 }
815 elapsed = Duration::from_secs(0);
816 let busy_now = busy.load(Ordering::SeqCst);
817 let decision = auto_update_tick(&snapshot, busy_now, &logs).await;
818 if matches!(decision, AutoUpdateDecision::Updated) {
819 stop.store(true, Ordering::SeqCst);
820 update::restart_self();
821 }
822 }
823 })
824}
825
826pub fn prompt_for(task: &Task) -> String {
830 match task {
831 Task::Image(p) => p.prompt.clone(),
832 Task::Llm(p) => p
833 .messages
834 .last()
835 .map(|m| m.content.clone())
836 .unwrap_or_default(),
837 Task::AudioStt(p) => p.input_url.clone(),
838 Task::AudioTts(p) => p.text.clone(),
839 Task::Video(p) => p.prompt.clone(),
840 }
841}
842
843pub fn is_unsupported_kind(e: &anyhow::Error) -> bool {
844 e.chain().any(|cause| {
848 cause
849 .downcast_ref::<crate::engine::UnsupportedTask>()
850 .is_some()
851 }) || e.to_string().contains("cannot serve")
852}
853
854pub fn build_capabilities(cfg: &Config, engine: &dyn Engine) -> WorkerCapabilities {
859 build_capabilities_with(cfg, engine, true)
860}
861
862pub fn build_capabilities_with(
867 cfg: &Config,
868 engine: &dyn Engine,
869 auto_enabled: bool,
870) -> WorkerCapabilities {
871 let vram = sys::detect_vram_gb().unwrap_or(0.0);
872 let caps = engine.capabilities();
873 let supported_models_per_kind = caps.supported_models_per_kind.clone();
874 let task_kinds = caps.kinds();
875 let supported_models = {
879 let mut all = caps.flat_models();
880 all.sort();
881 all.dedup();
882 all
883 };
884
885 WorkerCapabilities {
886 machine_name: sys::machine_name(),
887 username: sys::username(),
888 agent_version: AGENT_VERSION.to_string(),
889 engine: engine.name().to_string(),
890 vram_total_gb: vram,
891 vram_threshold_gb: cfg.vram_threshold_gb,
892 auto_enabled,
893 auto_start: cfg.auto_start,
894 supported_models,
895 task_kinds,
896 supported_models_per_kind,
897 }
898}
899
900pub fn summarize_capabilities(caps: &WorkerCapabilities) -> String {
910 let kinds = caps
911 .task_kinds
912 .iter()
913 .map(|k| k.as_str())
914 .collect::<Vec<_>>()
915 .join(", ");
916 format!(
917 "advertising engine={}, vram={:.1}/{:.1}GB threshold, auto_enabled={}, \
918 kinds=[{}], {} model(s)=[{}]",
919 caps.engine,
920 caps.vram_total_gb,
921 caps.vram_threshold_gb,
922 caps.auto_enabled,
923 kinds,
924 caps.supported_models.len(),
925 caps.supported_models.join(", "),
926 )
927}
928
929pub fn vram_threshold_warning(caps: &WorkerCapabilities) -> Option<String> {
949 if caps.vram_total_gb > 0.0 && caps.vram_threshold_gb > caps.vram_total_gb {
950 Some(format!(
951 "configured VRAM threshold {:.1}GB exceeds detected GPU VRAM {:.1}GB; \
952 the studio may offer jobs larger than this card can fit and they will \
953 OOM on load — lower vram_threshold_gb to at or below {:.1}GB",
954 caps.vram_threshold_gb, caps.vram_total_gb, caps.vram_total_gb
955 ))
956 } else {
957 None
958 }
959}
960
961pub fn push_log(
962 logs: &Arc<Mutex<Vec<LogEntry>>>,
963 level: &str,
964 category: &str,
965 message: &str,
966 job_id: Option<String>,
967) {
968 push_log_with_observers(logs, None, level, category, message, job_id);
969}
970
971pub fn push_log_with_observers(
977 logs: &Arc<Mutex<Vec<LogEntry>>>,
978 observers: Option<&WorkerObservers>,
979 level: &str,
980 category: &str,
981 message: &str,
982 job_id: Option<String>,
983) {
984 let entry = LogEntry {
985 ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
986 level: level.to_string(),
987 category: category.to_string(),
988 message: message.to_string(),
989 job_id,
990 };
991 let job_id = entry.job_id.as_deref();
996 if level == "error" {
997 tracing::error!(target: "studio_worker", job_id, "[{category}] {message}");
998 } else if level == "warn" {
999 tracing::warn!(target: "studio_worker", job_id, "[{category}] {message}");
1000 } else {
1001 info!(target: "studio_worker", job_id, "[{category}] {message}");
1002 }
1003 {
1004 let mut queue = logs.lock();
1005 if queue.len() >= LOG_SHIP_QUEUE_CAP {
1006 let overflow = queue.len() + 2 - LOG_SHIP_QUEUE_CAP;
1008 queue.drain(0..overflow);
1009 queue.push(LogEntry {
1010 ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1011 level: "warn".to_string(),
1012 category: "logs".to_string(),
1013 message: format!(
1014 "ship queue full ({LOG_SHIP_QUEUE_CAP} entries); dropped {overflow} oldest"
1015 ),
1016 job_id: None,
1017 });
1018 }
1019 queue.push(entry.clone());
1020 }
1021 if let Some(o) = observers {
1022 let mut ring = o.recent_logs.lock();
1023 ring.push_back(entry);
1024 while ring.len() > RECENT_LOGS_CAP {
1025 ring.pop_front();
1026 }
1027 }
1028}
1029
1030pub fn restore_unshipped(logs: &Arc<Mutex<Vec<LogEntry>>>, mut batch: Vec<LogEntry>) {
1036 let mut queue = logs.lock();
1037 batch.append(&mut queue);
1038 *queue = batch;
1039 if queue.len() > LOG_SHIP_QUEUE_CAP {
1040 let overflow = queue.len() - LOG_SHIP_QUEUE_CAP;
1041 queue.drain(0..overflow);
1042 }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047 use super::*;
1048 use crate::config::Config;
1049 use crate::engine::SyntheticEngine;
1050
1051 #[test]
1052 fn is_unsupported_kind_detects_typed_unsupported_task() {
1053 let err: anyhow::Error =
1054 crate::engine::UnsupportedTask::new("synthetic", TaskKind::Llm).into();
1055 assert!(is_unsupported_kind(&err));
1056 assert!(err.to_string().contains("cannot serve llm"));
1058 }
1059
1060 #[test]
1061 fn is_unsupported_kind_survives_context_wrapping() {
1062 let err = anyhow::Error::from(crate::engine::UnsupportedTask::new(
1066 "sdcpp",
1067 TaskKind::AudioTts,
1068 ))
1069 .context("dispatching job j-1");
1070 assert!(is_unsupported_kind(&err));
1071 }
1072
1073 fn entry(message: &str) -> LogEntry {
1074 LogEntry {
1075 ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1076 level: "info".into(),
1077 category: "test".into(),
1078 message: message.into(),
1079 job_id: None,
1080 }
1081 }
1082
1083 #[test]
1084 fn restore_unshipped_requeues_batch_ahead_of_newer_entries() {
1085 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(vec![entry("newer")]));
1089 restore_unshipped(&logs, vec![entry("batch-1"), entry("batch-2")]);
1090 let queue = logs.lock();
1091 let order: Vec<&str> = queue.iter().map(|e| e.message.as_str()).collect();
1092 assert_eq!(order, vec!["batch-1", "batch-2", "newer"]);
1093 }
1094
1095 #[test]
1096 fn restore_unshipped_respects_the_queue_cap() {
1097 let logs: Arc<Mutex<Vec<LogEntry>>> =
1100 Arc::new(Mutex::new(vec![entry("newest"); LOG_SHIP_QUEUE_CAP]));
1101 restore_unshipped(&logs, vec![entry("old-batch"); 100]);
1102 let queue = logs.lock();
1103 assert_eq!(queue.len(), LOG_SHIP_QUEUE_CAP);
1104 assert_eq!(
1105 queue.last().map(|e| e.message.as_str()),
1106 Some("newest"),
1107 "newest entries must survive the cap"
1108 );
1109 }
1110
1111 #[test]
1112 fn ship_queue_is_bounded_and_records_dropped_entries() {
1113 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1117 for i in 0..(LOG_SHIP_QUEUE_CAP + 100) {
1118 push_log_with_observers(&logs, None, "info", "test", &format!("entry {i}"), None);
1119 }
1120 let queue = logs.lock();
1121 assert!(
1122 queue.len() <= LOG_SHIP_QUEUE_CAP,
1123 "ship queue exceeded its cap: {}",
1124 queue.len()
1125 );
1126 assert_eq!(
1128 queue.last().map(|e| e.message.as_str()),
1129 Some(format!("entry {}", LOG_SHIP_QUEUE_CAP + 99).as_str())
1130 );
1131 assert!(
1133 queue
1134 .iter()
1135 .any(|e| e.level == "warn" && e.message.contains("dropped")),
1136 "overflow must leave a visible drop marker"
1137 );
1138 }
1139
1140 #[test]
1141 fn recent_logs_ring_is_bounded_at_recent_logs_cap() {
1142 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1150 let observers = WorkerObservers::default();
1151 let overflow = 25;
1152 for i in 0..(RECENT_LOGS_CAP + overflow) {
1153 push_log_with_observers(
1154 &logs,
1155 Some(&observers),
1156 "info",
1157 "test",
1158 &format!("entry {i}"),
1159 None,
1160 );
1161 }
1162 let ring = observers.recent_logs.lock();
1163 assert_eq!(
1164 ring.len(),
1165 RECENT_LOGS_CAP,
1166 "the recent-logs ring must cap at RECENT_LOGS_CAP"
1167 );
1168 assert_eq!(
1171 ring.back().map(|e| e.message.as_str()),
1172 Some(format!("entry {}", RECENT_LOGS_CAP + overflow - 1).as_str()),
1173 "the newest entry must survive at the back of the ring"
1174 );
1175 assert_eq!(
1176 ring.front().map(|e| e.message.as_str()),
1177 Some(format!("entry {overflow}").as_str()),
1178 "the oldest surviving entry must be entry #overflow (older evicted)"
1179 );
1180 }
1181
1182 #[test]
1183 fn capabilities_advertises_all_synthetic_kinds() {
1184 let cfg = Config::default();
1185 let engine = SyntheticEngine::new();
1186 let cap = build_capabilities(&cfg, &engine);
1187 assert_eq!(cap.engine, "synthetic");
1188 assert_eq!(cap.task_kinds.len(), TaskKind::ALL.len());
1189 assert!(cap.auto_enabled, "default capability snapshot is unpaused");
1190 for kind in TaskKind::ALL {
1191 assert!(cap.supported_models_per_kind.contains_key(&kind));
1192 }
1193 }
1194
1195 #[test]
1196 fn capabilities_with_paused_flag_drives_auto_enabled() {
1197 let cfg = Config::default();
1198 let engine = SyntheticEngine::new();
1199 let paused_caps = build_capabilities_with(&cfg, &engine, false);
1200 assert!(!paused_caps.auto_enabled);
1201 }
1202
1203 #[test]
1204 fn summarize_capabilities_lists_engine_kinds_models_vram_and_pause_state() {
1205 let cfg = Config {
1206 vram_threshold_gb: 6.0,
1207 ..Config::default()
1208 };
1209 let engine = SyntheticEngine::new();
1210 let caps = build_capabilities_with(&cfg, &engine, true);
1211 let summary = summarize_capabilities(&caps);
1212 assert!(summary.contains("engine=synthetic"), "got: {summary}");
1214 for kind in &caps.task_kinds {
1215 assert!(
1216 summary.contains(kind.as_str()),
1217 "missing kind {} in: {summary}",
1218 kind.as_str()
1219 );
1220 }
1221 assert!(
1223 summary.contains(&format!("{} model(s)", caps.supported_models.len())),
1224 "missing model count in: {summary}"
1225 );
1226 assert!(
1227 summary.contains("synthetic"),
1228 "missing model id in: {summary}"
1229 );
1230 assert!(
1232 summary.contains("6.0"),
1233 "missing vram threshold in: {summary}"
1234 );
1235 assert!(summary.contains("auto_enabled=true"), "got: {summary}");
1236 }
1237
1238 #[test]
1239 fn summarize_capabilities_reflects_paused_state() {
1240 let cfg = Config::default();
1241 let engine = SyntheticEngine::new();
1242 let caps = build_capabilities_with(&cfg, &engine, false);
1243 assert!(
1244 summarize_capabilities(&caps).contains("auto_enabled=false"),
1245 "paused worker must advertise auto_enabled=false"
1246 );
1247 }
1248
1249 fn caps_with_vram(total_gb: f32, threshold_gb: f32) -> WorkerCapabilities {
1253 let mut caps = build_capabilities_with(&Config::default(), &SyntheticEngine::new(), true);
1254 caps.vram_total_gb = total_gb;
1255 caps.vram_threshold_gb = threshold_gb;
1256 caps
1257 }
1258
1259 #[test]
1260 fn vram_threshold_warning_flags_threshold_above_detected_vram() {
1261 let warning = vram_threshold_warning(&caps_with_vram(8.0, 12.0))
1266 .expect("threshold above detected VRAM must warn");
1267 assert!(warning.contains("12.0"), "missing threshold in: {warning}");
1268 assert!(
1269 warning.contains("8.0"),
1270 "missing detected VRAM in: {warning}"
1271 );
1272 assert!(
1273 warning.contains("vram_threshold_gb"),
1274 "must name the config key to change: {warning}"
1275 );
1276 }
1277
1278 #[test]
1279 fn vram_threshold_warning_silent_when_threshold_within_detected_vram() {
1280 assert!(vram_threshold_warning(&caps_with_vram(24.0, 12.0)).is_none());
1282 }
1283
1284 #[test]
1285 fn vram_threshold_warning_silent_when_threshold_equals_detected() {
1286 assert!(vram_threshold_warning(&caps_with_vram(12.0, 12.0)).is_none());
1289 }
1290
1291 #[test]
1292 fn vram_threshold_warning_silent_when_vram_undetected() {
1293 assert!(vram_threshold_warning(&caps_with_vram(0.0, 12.0)).is_none());
1298 }
1299
1300 #[test]
1301 fn prompt_for_extracts_per_kind() {
1302 let image = Task::Image(ImageParams {
1303 prompt: "a stone golem".into(),
1304 ..Default::default()
1305 });
1306 assert_eq!(prompt_for(&image), "a stone golem");
1307
1308 let llm = Task::Llm(LlmParams {
1309 messages: vec![
1310 ChatMessage {
1311 role: "system".into(),
1312 content: "be helpful".into(),
1313 },
1314 ChatMessage {
1315 role: "user".into(),
1316 content: "hi".into(),
1317 },
1318 ],
1319 max_tokens: 32,
1320 temperature: 0.5,
1321 ..Default::default()
1322 });
1323 assert_eq!(prompt_for(&llm), "hi");
1324
1325 let llm_empty = Task::Llm(LlmParams {
1326 messages: vec![],
1327 ..Default::default()
1328 });
1329 assert_eq!(prompt_for(&llm_empty), "");
1330
1331 let stt = Task::AudioStt(AudioSttParams {
1332 input_url: "https://example.com/clip.wav".into(),
1333 ..Default::default()
1334 });
1335 assert_eq!(prompt_for(&stt), "https://example.com/clip.wav");
1336
1337 let tts = Task::AudioTts(AudioTtsParams {
1338 text: "hi there".into(),
1339 voice: "v".into(),
1340 ext: "wav".into(),
1341 ..Default::default()
1342 });
1343 assert_eq!(prompt_for(&tts), "hi there");
1344
1345 let video = Task::Video(VideoParams {
1346 prompt: "a tiny dragon".into(),
1347 seconds: 1.0,
1348 width: 256,
1349 height: 256,
1350 ext: "mp4".into(),
1351 ..Default::default()
1352 });
1353 assert_eq!(prompt_for(&video), "a tiny dragon");
1354 }
1355
1356 #[test]
1357 fn truncate_prompt_passes_short_through_and_clips_long_prompts() {
1358 let short = "a stone golem";
1360 assert_eq!(truncate_prompt(short), short);
1361
1362 let exactly = "x".repeat(PROMPT_PREVIEW_CHARS);
1364 assert_eq!(
1365 truncate_prompt(&exactly),
1366 exactly,
1367 "a prompt exactly at the cap must not be clipped"
1368 );
1369
1370 let over = "y".repeat(PROMPT_PREVIEW_CHARS + 1);
1373 let clipped = truncate_prompt(&over);
1374 assert_eq!(
1375 clipped.chars().count(),
1376 PROMPT_PREVIEW_CHARS + 1,
1377 "clipped preview is the cap plus one ellipsis char"
1378 );
1379 assert!(
1380 clipped.ends_with('\u{2026}'),
1381 "a clipped preview ends with an ellipsis"
1382 );
1383 assert_eq!(
1384 clipped
1385 .chars()
1386 .take(PROMPT_PREVIEW_CHARS)
1387 .collect::<String>(),
1388 "y".repeat(PROMPT_PREVIEW_CHARS),
1389 "the kept prefix is the first PROMPT_PREVIEW_CHARS chars"
1390 );
1391 }
1392
1393 #[test]
1394 fn truncate_prompt_clips_on_char_boundaries_for_multibyte_text() {
1395 let multibyte = "\u{3042}".repeat(PROMPT_PREVIEW_CHARS + 1);
1400 let clipped = truncate_prompt(&multibyte);
1401 assert_eq!(clipped.chars().count(), PROMPT_PREVIEW_CHARS + 1);
1402 assert!(clipped.ends_with('\u{2026}'));
1403 assert_eq!(
1404 clipped.chars().filter(|c| *c == '\u{3042}').count(),
1405 PROMPT_PREVIEW_CHARS,
1406 "exactly PROMPT_PREVIEW_CHARS multibyte chars survive the clip"
1407 );
1408 }
1409
1410 #[test]
1411 fn is_unsupported_kind_matches_engine_message() {
1412 let err = anyhow!("multi engine cannot serve llm tasks");
1413 assert!(is_unsupported_kind(&err));
1414 let other = anyhow!("network timeout");
1415 assert!(!is_unsupported_kind(&other));
1416 }
1417
1418 #[test]
1419 fn format_status_includes_every_field() {
1420 let cfg = Config::default();
1421 let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
1422 assert!(out.contains("config path:"));
1423 assert!(out.contains("api_base_url:"));
1424 assert!(out.contains("registration:"));
1425 assert!(out.contains("not registered"));
1426 assert!(out.contains("models_root:"));
1427 assert!(out.contains("auto_update:"));
1428 assert!(out.contains("update_interval:"));
1429 }
1430
1431 #[test]
1432 fn format_status_shows_worker_id_when_registered() {
1433 let cfg = Config {
1434 worker_id: Some("w-abc".into()),
1435 auth_token: Some("tok".into()),
1436 ..Config::default()
1437 };
1438 let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
1439 assert!(out.contains("w-abc"));
1440 assert!(out.contains("approved"));
1441 }
1442
1443 #[test]
1444 fn format_status_shows_pending_request_id() {
1445 let cfg = Config {
1446 registration_request_id: Some("rr-7".into()),
1447 ..Config::default()
1448 };
1449 let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
1450 assert!(out.contains("pending operator approval"));
1451 assert!(out.contains("rr-7"));
1452 }
1453
1454 #[test]
1455 fn format_check_outcome_handles_both_branches() {
1456 let up = update::CheckOutcome::UpToDate {
1457 current: semver::Version::new(1, 2, 3),
1458 };
1459 assert!(format_check_outcome(&up).contains("up to date"));
1460 let newer = update::CheckOutcome::NewerAvailable {
1461 current: semver::Version::new(1, 2, 3),
1462 latest: semver::Version::new(1, 3, 0),
1463 };
1464 let s = format_check_outcome(&newer);
1465 assert!(s.contains("1.2.3 -> 1.3.0"));
1466 }
1467
1468 #[test]
1469 fn push_log_appends_an_entry() {
1470 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1471 push_log(&logs, "info", "test", "hi", None);
1472 push_log(&logs, "warn", "test", "wat", Some("j-1".into()));
1473 push_log(&logs, "error", "test", "boom", None);
1474 let v = logs.lock();
1475 assert_eq!(v.len(), 3);
1476 assert_eq!(v[0].level, "info");
1477 assert_eq!(v[1].level, "warn");
1478 assert_eq!(v[1].job_id.as_deref(), Some("j-1"));
1479 assert_eq!(v[2].level, "error");
1480 }
1481
1482 #[test]
1483 fn push_log_emits_job_id_as_a_structured_tracing_field() {
1484 use crate::test_support::capture;
1489 let logs = capture(|| {
1490 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1491 push_log(
1492 &logs,
1493 "info",
1494 "ws",
1495 "binary upload ok",
1496 Some("job-42".into()),
1497 );
1498 });
1499 assert!(
1500 logs.contains("job_id=\"job-42\""),
1501 "expected structured job_id field, got: {logs}"
1502 );
1503 assert!(
1504 logs.contains("[ws] binary upload ok"),
1505 "expected the human-readable message to survive, got: {logs}"
1506 );
1507 }
1508
1509 #[test]
1510 fn push_log_omits_job_id_field_when_absent() {
1511 use crate::test_support::capture;
1514 let logs = capture(|| {
1515 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1516 push_log(&logs, "info", "auto-update", "up to date", None);
1517 });
1518 assert!(
1519 !logs.contains("job_id"),
1520 "expected no job_id field for a jobless log, got: {logs}"
1521 );
1522 }
1523
1524 #[test]
1527 fn request_shutdown_sets_the_stop_flag() {
1528 let stop = AtomicBool::new(false);
1529 request_shutdown(&stop, "SIGTERM");
1530 assert!(stop.load(Ordering::SeqCst));
1531 }
1532
1533 #[test]
1534 fn request_shutdown_reconfirms_when_already_stopping() {
1535 let stop = AtomicBool::new(true);
1538 request_shutdown(&stop, "SIGINT");
1539 assert!(stop.load(Ordering::SeqCst));
1540 }
1541
1542 #[test]
1543 fn request_shutdown_emits_a_named_shutdown_breadcrumb() {
1544 use crate::test_support::capture;
1545 let logs = capture(|| {
1546 let stop = AtomicBool::new(false);
1547 request_shutdown(&stop, "SIGTERM");
1548 });
1549 assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
1550 assert!(
1551 logs.contains("studio_worker::runtime"),
1552 "expected runtime target, got: {logs}"
1553 );
1554 assert!(
1555 logs.contains("op=\"shutdown\""),
1556 "expected op field, got: {logs}"
1557 );
1558 assert!(
1559 logs.contains("signal=\"SIGTERM\""),
1560 "expected signal field, got: {logs}"
1561 );
1562 }
1563
1564 #[tokio::test]
1565 async fn auto_update_tick_disabled_when_flag_off() {
1566 let cfg = Config {
1567 auto_update_enabled: false,
1568 ..Config::default()
1569 };
1570 let logs = Arc::new(Mutex::new(Vec::new()));
1571 let decision = auto_update_tick(&cfg, false, &logs).await;
1572 assert_eq!(decision, AutoUpdateDecision::Disabled);
1573 }
1574
1575 #[tokio::test]
1576 async fn auto_update_tick_skipped_when_busy() {
1577 let cfg = Config {
1578 auto_update_enabled: true,
1579 ..Config::default()
1580 };
1581 let logs = Arc::new(Mutex::new(Vec::new()));
1582 let decision = auto_update_tick(&cfg, true, &logs).await;
1583 assert_eq!(decision, AutoUpdateDecision::SkippedBusy);
1584 let entries = logs.lock();
1585 assert!(entries.iter().any(|e| e.message.contains("busy on a job")));
1586 }
1587
1588 #[tokio::test]
1589 async fn wait_with_stop_short_circuits_when_already_stopped() {
1590 let stop = Arc::new(AtomicBool::new(true));
1591 let start = std::time::Instant::now();
1592 wait_with_stop(Duration::from_secs(60), &stop, Duration::from_millis(10)).await;
1593 assert!(
1594 start.elapsed() < Duration::from_millis(100),
1595 "an already-set stop must return without sleeping the full duration"
1596 );
1597 }
1598
1599 #[tokio::test]
1600 async fn auto_updater_stops_promptly_during_idle_wait() {
1601 let cfg = crate::config::shared(Config {
1607 auto_update_enabled: false,
1608 ..Config::default()
1609 });
1610 let stop = Arc::new(AtomicBool::new(false));
1611 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1612 let busy = Arc::new(AtomicBool::new(false));
1613 let schedule = LoopSchedule {
1614 ws_session: crate::ws::session::SessionSchedule::fast_for_tests(),
1615 auto_update_tick: Duration::from_secs(3600),
1616 shutdown_tick: Duration::from_millis(1),
1617 };
1618 let handle = spawn_auto_updater(cfg, stop.clone(), logs, busy, schedule);
1619 tokio::time::sleep(Duration::from_millis(10)).await;
1621 stop.store(true, Ordering::SeqCst);
1622 tokio::time::timeout(Duration::from_millis(250), handle)
1623 .await
1624 .expect("auto-updater did not observe stop promptly")
1625 .expect("auto-updater task panicked");
1626 }
1627}