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, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum JobSource {
58 #[default]
60 Studio,
61 Local,
63 Lane,
65 Stream,
67}
68
69impl JobSource {
70 pub fn as_str(&self) -> &'static str {
71 match self {
72 JobSource::Studio => "studio",
73 JobSource::Local => "local",
74 JobSource::Lane => "lane",
75 JobSource::Stream => "stream",
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq)]
84pub struct CurrentJob {
85 pub job_id: String,
86 pub kind: TaskKind,
87 pub model: String,
88 pub prompt: String,
89 pub started_at: DateTime<Utc>,
90 pub source: JobSource,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum JobOutcome {
97 Completed,
98 Failed { reason: String },
99}
100
101#[derive(Debug, Clone, PartialEq)]
103pub struct RecentJob {
104 pub job_id: String,
105 pub kind: TaskKind,
106 pub model: String,
107 pub prompt: String,
108 pub outcome: JobOutcome,
109 pub started_at: DateTime<Utc>,
110 pub finished_at: DateTime<Utc>,
111 pub source: JobSource,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
116#[serde(tag = "outcome", rename_all = "snake_case")]
117pub enum HeartbeatOutcome {
118 Ok,
119 Err { reason: String },
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
127#[serde(tag = "state", rename_all = "snake_case")]
128pub enum SessionState {
129 #[default]
131 WaitingForApproval,
132 Connecting,
134 Connected,
136 Reconnecting { attempt: u32 },
138 AuthFailed { reason: String },
140 Fatal { reason: String },
142 Stopped,
144}
145
146impl SessionState {
147 pub fn summary(&self) -> String {
150 match self {
151 SessionState::WaitingForApproval => "waiting for studio operator approval".into(),
152 SessionState::Connecting => "connecting to the studio…".into(),
153 SessionState::Connected => "connected — ready for jobs".into(),
154 SessionState::Reconnecting { attempt } => {
155 format!("reconnecting (attempt {attempt})…")
156 }
157 SessionState::AuthFailed { reason } => format!(
158 "authentication failed: {reason}. Re-register with \
159 `studio-worker register --reset`."
160 ),
161 SessionState::Fatal { reason } => {
162 format!("session ended: {reason}")
163 }
164 SessionState::Stopped => "stopped".into(),
165 }
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
170#[serde(rename_all = "camelCase")]
171pub struct HeartbeatStatus {
172 #[serde(flatten)]
173 pub outcome: HeartbeatOutcome,
174 pub last_attempt_at: DateTime<Utc>,
175}
176
177#[derive(Clone, Default)]
182pub struct WorkerObservers {
183 pub current_job: Arc<Mutex<Option<CurrentJob>>>,
184 pub active_jobs: Arc<Mutex<Vec<CurrentJob>>>,
186 pub thumbnails: crate::thumbnail::Thumbnails,
188 pub recent_jobs: Arc<Mutex<VecDeque<RecentJob>>>,
189 pub local_jobs: Arc<Mutex<VecDeque<RecentJob>>>,
192 pub local_api_url: Arc<Mutex<Option<String>>>,
194 pub last_heartbeat: Arc<Mutex<Option<HeartbeatStatus>>>,
195 pub session_state: Arc<Mutex<SessionState>>,
198 pub gpu_runtime: Arc<Mutex<Option<GpuRuntimeStatus>>>,
201 pub catalog: Arc<Mutex<crate::catalog::Catalog>>,
205 pub catalog_path: Arc<Mutex<Option<std::path::PathBuf>>>,
208 pub recent_logs: Arc<Mutex<VecDeque<LogEntry>>>,
213 pub recent_logs_seq: Arc<std::sync::atomic::AtomicU64>,
216}
217
218pub fn recent_logs_after(observers: &WorkerObservers, after: u64) -> (Vec<LogEntry>, u64) {
223 let ring = observers.recent_logs.lock();
224 let newest = observers.recent_logs_seq.load(Ordering::SeqCst);
225 let oldest = newest - ring.len() as u64;
226 let after = if after > newest { 0 } else { after.max(oldest) };
227 let skip = (after - oldest) as usize;
228 (ring.iter().skip(skip).cloned().collect(), newest)
229}
230
231impl WorkerObservers {
232 pub fn with_global_worker_log() -> Self {
235 let ring = crate::job_log::global_worker_log();
236 Self {
237 recent_logs: ring.entries.clone(),
238 recent_logs_seq: ring.seq.clone(),
239 ..Self::default()
240 }
241 }
242
243 pub fn worker_log(&self) -> crate::job_log::WorkerLogRing {
245 crate::job_log::WorkerLogRing {
246 entries: self.recent_logs.clone(),
247 seq: self.recent_logs_seq.clone(),
248 }
249 }
250}
251
252pub fn set_session_state(observers: &WorkerObservers, state: SessionState) {
254 *observers.session_state.lock() = state;
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
261pub struct GpuRuntimeStatus {
262 pub ok: bool,
265 pub detail: String,
267}
268
269pub fn sync_studio_model(
275 observers: &WorkerObservers,
276 model_id: &str,
277 kind: TaskKind,
278 source: &ModelSource,
279) {
280 let incoming = crate::catalog::CatalogModel {
281 id: model_id.to_string(),
282 display_name: model_id.to_string(),
283 kind,
284 vram_gb_estimate: 0.0,
285 description: None,
286 source: source.clone(),
287 enabled: true,
288 origin: "studio".into(),
289 exclusive_group: None,
290 };
291 let changed = observers.catalog.lock().sync_studio_model(incoming);
292 if !changed {
293 return;
294 }
295 let path = observers.catalog_path.lock().clone();
296 if let Some(path) = path {
297 let snapshot = observers.catalog.lock().clone();
298 if let Err(e) = snapshot.save(&path) {
299 warn!(
300 target: TRACE_TARGET,
301 op = "catalog_sync",
302 model_id,
303 error = %e,
304 "failed to persist studio model into the local catalog"
305 );
306 } else {
307 info!(
308 target: TRACE_TARGET,
309 op = "catalog_sync",
310 model_id,
311 kind = kind.as_str(),
312 "mirrored studio model into the local catalog"
313 );
314 }
315 }
316}
317
318pub fn set_gpu_runtime_status(observers: &WorkerObservers, status: Result<()>) {
323 let value = match &status {
324 Ok(()) => GpuRuntimeStatus {
325 ok: true,
326 detail: "GPU runtime available".into(),
327 },
328 Err(e) => {
329 warn!(
330 target: TRACE_TARGET,
331 op = "gpu_preflight",
332 error = %e,
333 "GPU runtime missing at startup; image jobs will fail until it is installed"
334 );
335 GpuRuntimeStatus {
336 ok: false,
337 detail: e.to_string(),
338 }
339 }
340 };
341 *observers.gpu_runtime.lock() = Some(value);
342}
343
344pub fn truncate_prompt(s: &str) -> String {
345 if s.chars().count() <= PROMPT_PREVIEW_CHARS {
346 return s.to_string();
347 }
348 let mut out: String = s.chars().take(PROMPT_PREVIEW_CHARS).collect();
349 out.push('…');
350 out
351}
352
353pub fn record_recent_job(observers: &WorkerObservers, entry: RecentJob) {
354 let mut ring = observers.recent_jobs.lock();
355 ring.push_front(entry);
356 while ring.len() > RECENT_JOBS_CAP {
357 ring.pop_back();
358 }
359}
360
361pub fn record_local_job(observers: &WorkerObservers, entry: RecentJob) {
363 let mut ring = observers.local_jobs.lock();
364 ring.push_front(entry);
365 while ring.len() > RECENT_JOBS_CAP {
366 ring.pop_back();
367 }
368}
369
370#[doc(hidden)]
374pub fn push_recent_job_for_tests(observers: &WorkerObservers, job_id: &str) {
375 let now = Utc::now();
376 record_recent_job(
377 observers,
378 RecentJob {
379 job_id: job_id.to_string(),
380 kind: TaskKind::Image,
381 model: "synthetic".into(),
382 prompt: String::new(),
383 outcome: JobOutcome::Completed,
384 started_at: now,
385 finished_at: now,
386 source: JobSource::Studio,
387 },
388 );
389}
390
391pub const AUTO_UPDATE_TICK: Duration = Duration::from_secs(60);
392pub const AUTO_UPDATE_SHUTDOWN_TICK: Duration = Duration::from_millis(250);
398pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
402
403#[derive(Debug, Clone, Copy)]
405pub struct LoopSchedule {
406 pub ws_session: crate::ws::session::SessionSchedule,
407 pub auto_update_tick: Duration,
408 pub shutdown_tick: Duration,
412}
413
414impl Default for LoopSchedule {
415 fn default() -> Self {
416 Self {
417 ws_session: crate::ws::session::SessionSchedule::default(),
418 auto_update_tick: AUTO_UPDATE_TICK,
419 shutdown_tick: AUTO_UPDATE_SHUTDOWN_TICK,
420 }
421 }
422}
423
424impl LoopSchedule {
425 pub fn fast_for_tests() -> Self {
428 Self {
429 ws_session: crate::ws::session::SessionSchedule::fast_for_tests(),
430 auto_update_tick: Duration::from_millis(1),
431 shutdown_tick: Duration::from_millis(1),
432 }
433 }
434}
435
436#[derive(Debug, Clone, Default)]
442pub struct RegisterArgs {
443 pub api_base_url: Option<String>,
444 pub reset: bool,
445}
446
447pub async fn register(config_path: Option<&str>, args: RegisterArgs) -> Result<()> {
451 let (mut cfg, path) = config::load(config_path)?;
452
453 if args.reset {
454 clear_registration(&mut cfg);
455 }
456 if let Some(url) = args.api_base_url {
457 cfg.api_base_url = url;
458 }
459
460 config::save(&cfg, &path)?;
461 if args.reset {
462 info!(
463 config_path = %path.display(),
464 "local registration state cleared; next launch will auto-register"
465 );
466 println!(
467 "local registration state cleared; run `studio-worker run` or \
468 `studio-worker ui` to auto-register"
469 );
470 } else {
471 info!(
472 config_path = %path.display(),
473 "register flags persisted; next launch will auto-register"
474 );
475 println!(
476 "saved; run `studio-worker run` or `studio-worker ui` to auto-register against {}",
477 cfg.api_base_url
478 );
479 }
480 Ok(())
481}
482
483pub async fn status(config_path: Option<&str>) -> Result<()> {
484 let (cfg, path) = config::load(config_path)?;
485 println!("{}", format_status(&cfg, &path));
486 Ok(())
487}
488
489pub fn format_status(cfg: &Config, path: &std::path::Path) -> String {
490 let mut out = String::new();
491 use std::fmt::Write as _;
492 let _ = writeln!(out, "config path: {}", path.display());
493 let _ = writeln!(out, "api_base_url: {}", cfg.api_base_url);
494 let registration_line = if cfg.worker_id.is_some() && cfg.auth_token.is_some() {
495 format!("approved as {}", cfg.worker_id.as_deref().unwrap_or(""))
496 } else if let Some(rid) = cfg.registration_request_id.as_deref() {
497 format!("pending operator approval (request {rid})")
498 } else {
499 "not registered (will auto-register on next launch)".into()
500 };
501 let _ = writeln!(out, "registration: {registration_line}");
502 let _ = writeln!(out, "vram_threshold_gb: {}", cfg.vram_threshold_gb);
503 let _ = writeln!(out, "models_root: {}", cfg.models_root.display());
504 let _ = writeln!(out, "auto_update: {}", cfg.auto_update_enabled);
505 let _ = writeln!(
506 out,
507 "update_interval: {}s",
508 cfg.auto_update_interval_secs
509 );
510 out
511}
512
513pub fn set_threshold(config_path: Option<&str>, gb: f32) -> Result<()> {
514 if gb < 0.0 {
515 return Err(anyhow!("threshold must be >= 0"));
516 }
517 let (mut cfg, path) = config::load(config_path)?;
518 cfg.vram_threshold_gb = gb;
519 config::save(&cfg, &path)?;
520 info!(
521 target: TRACE_TARGET,
522 op = "set_threshold",
523 vram_threshold_gb = gb,
524 config_path = path.display().to_string(),
525 "VRAM threshold persisted"
526 );
527 println!("vram_threshold_gb = {gb}");
528 Ok(())
529}
530
531pub fn log_startup_banner(cfg: &Config, path: &std::path::Path) {
536 info!(
537 target: TRACE_TARGET,
538 op = "startup",
539 version = AGENT_VERSION,
540 config_path = path.display().to_string(),
541 api_base_url = cfg.api_base_url.as_str(),
542 vram_threshold_gb = cfg.vram_threshold_gb,
543 auto_update_enabled = cfg.auto_update_enabled,
544 auto_update_interval_secs = cfg.auto_update_interval_secs,
545 models_root = cfg.models_root.display().to_string(),
546 worker_id = cfg.worker_id.as_deref().unwrap_or("(unregistered)"),
547 "studio-worker booting"
548 );
549}
550
551pub fn show_config(config_path: Option<&str>) -> Result<()> {
552 let (cfg, path) = config::load(config_path)?;
553 println!("# {}", path.display());
554 print!("{}", toml::to_string_pretty(&cfg)?);
555 Ok(())
556}
557
558pub async fn check_update(config_path: Option<&str>) -> Result<()> {
559 let (cfg, _) = config::load(config_path)?;
560 let current = semver::Version::parse(AGENT_VERSION)
561 .map_err(|e| anyhow!("invalid current version {AGENT_VERSION}: {e}"))?;
562 let outcome = tokio::task::spawn_blocking(move || {
563 update::check(&cfg.auto_update_feed, ¤t, cfg.auto_update_prerelease)
564 })
565 .await??;
566 println!("{}", format_check_outcome(&outcome));
567 Ok(())
568}
569
570pub fn format_check_outcome(outcome: &update::CheckOutcome) -> String {
571 match outcome {
572 update::CheckOutcome::UpToDate { current } => format!("up to date: {current}"),
573 update::CheckOutcome::NewerAvailable { current, latest } => {
574 format!("update available: {current} -> {latest}")
575 }
576 }
577}
578
579pub async fn run(config_path: Option<&str>, wait_for_lock: bool) -> Result<()> {
584 let (cfg, path) = config::load(config_path)?;
585 let _lock = match crate::daemon_lock::acquire(&path)? {
588 crate::daemon_lock::Acquired::Mine(lock) => lock,
589 crate::daemon_lock::Acquired::HeldElsewhere if wait_for_lock => {
590 let path = path.clone();
591 tokio::task::spawn_blocking(move || {
592 crate::daemon_lock::wait_until_acquired(&path, crate::daemon_lock::WAIT_POLL)
593 })
594 .await??
595 }
596 crate::daemon_lock::Acquired::HeldElsewhere => return Ok(()),
597 };
598 log_startup_banner(&cfg, &path);
599
600 let control = crate::control::DaemonControl::new(
601 config::shared(cfg),
602 path,
603 sys::detect_vram_gb().unwrap_or(0.0),
604 );
605 let busy = Arc::new(AtomicBool::new(false));
606 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
607 let observers = WorkerObservers::with_global_worker_log();
608
609 let stop_clone = control.stop.clone();
610 tokio::spawn(async move {
611 let signal = wait_for_shutdown_signal().await;
612 request_shutdown(&stop_clone, signal);
613 });
614
615 let gate = crate::job_gate::JobGate::from_shared(busy.clone());
620 let local_api = spawn_local_api(&control, observers.clone(), gate);
621
622 let outcome = serve_studio(&control, logs, busy, observers, LoopSchedule::default()).await;
623
624 control.stop.store(true, Ordering::SeqCst);
626 if let Some(handle) = local_api {
627 let _ = handle.join();
628 }
629 outcome
630}
631
632pub const REGISTRATION_RESET_POLL: Duration = Duration::from_millis(250);
634
635pub async fn serve_studio(
639 control: &crate::control::DaemonControl,
640 logs: Arc<Mutex<Vec<LogEntry>>>,
641 busy: Arc<AtomicBool>,
642 observers: WorkerObservers,
643 schedule: LoopSchedule,
644) -> Result<()> {
645 loop {
646 match ensure_registered(
647 &control.cfg,
648 &control.config_path,
649 &control.registration,
650 &control.stop,
651 )
652 .await
653 {
654 Ok(RegistrationGate::Stopped) => {
655 info!(
656 target: TRACE_TARGET,
657 op = "shutdown",
658 "stopped before registration completed; exiting cleanly"
659 );
660 return Ok(());
661 }
662 Ok(RegistrationGate::Ready) => {
663 return run_loops(
664 control.cfg.clone(),
665 control.stop.clone(),
666 logs,
667 busy,
668 control.paused.clone(),
669 observers,
670 schedule,
671 )
672 .await;
673 }
674 Err(err) => {
675 tracing::error!(
676 target: TRACE_TARGET,
677 op = "registration",
678 error = %err,
679 "studio registration rejected; the local API keeps serving; \
680 reset the registration from the tray UI to ask again"
681 );
682 if !wait_for_registration_reset(control).await {
683 return Ok(());
684 }
685 reset_registration(control)?;
686 }
687 }
688 }
689}
690
691async fn wait_for_registration_reset(control: &crate::control::DaemonControl) -> bool {
693 loop {
694 if control.stop.load(Ordering::SeqCst) {
695 return false;
696 }
697 if control.reset_requested.swap(false, Ordering::SeqCst) {
698 return true;
699 }
700 tokio::time::sleep(REGISTRATION_RESET_POLL).await;
701 }
702}
703
704pub fn clear_registration(cfg: &mut Config) {
706 cfg.worker_id = None;
707 cfg.auth_token = None;
708 cfg.registration_request_id = None;
709 cfg.registration_secret = None;
710 cfg.install_id = None;
711}
712
713fn reset_registration(control: &crate::control::DaemonControl) -> Result<()> {
716 let snapshot = {
717 let mut cfg = control.cfg.lock();
718 clear_registration(&mut cfg);
719 cfg.clone()
720 };
721 config::save(&snapshot, &control.config_path)?;
722 *control.registration.lock() = crate::auto_register::RegistrationState::Pristine;
723 info!(
724 target: TRACE_TARGET,
725 op = "registration",
726 "registration reset; asking the studio again"
727 );
728 Ok(())
729}
730
731pub fn request_shutdown(stop: &AtomicBool, signal: &str) {
737 let already_stopping = stop.swap(true, Ordering::SeqCst);
738 info!(
739 target: TRACE_TARGET,
740 op = "shutdown",
741 signal,
742 already_stopping,
743 "shutdown signal received; stopping worker gracefully"
744 );
745}
746
747#[cfg_attr(coverage_nightly, coverage(off))]
763async fn wait_for_shutdown_signal() -> &'static str {
764 #[cfg(unix)]
765 {
766 use tokio::signal::unix::{signal, SignalKind};
767 let mut sigterm = match signal(SignalKind::terminate()) {
768 Ok(s) => s,
769 Err(e) => {
770 warn!(
771 target: TRACE_TARGET,
772 op = "shutdown",
773 error = %e,
774 "could not install SIGTERM handler; falling back to Ctrl-C only"
775 );
776 let _ = tokio::signal::ctrl_c().await;
777 return "SIGINT";
778 }
779 };
780 tokio::select! {
781 _ = tokio::signal::ctrl_c() => "SIGINT",
782 _ = sigterm.recv() => "SIGTERM",
783 }
784 }
785 #[cfg(not(unix))]
786 {
787 let _ = tokio::signal::ctrl_c().await;
788 "ctrl-c"
789 }
790}
791
792#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805pub enum RegistrationGate {
806 Ready,
809 Stopped,
811}
812
813pub async fn ensure_registered(
818 cfg: &SharedConfig,
819 path: &std::path::Path,
820 registration: &crate::auto_register::SharedRegistration,
821 stop: &Arc<AtomicBool>,
822) -> Result<RegistrationGate> {
823 use std::time::Duration;
824 loop {
825 if stop.load(Ordering::SeqCst) {
826 return Ok(RegistrationGate::Stopped);
827 }
828 {
829 let snap = cfg.lock();
830 if snap.worker_id.is_some() && snap.auth_token.is_some() {
831 return Ok(RegistrationGate::Ready);
832 }
833 }
834 let state = crate::auto_register::tick(cfg, path, registration).await;
835 match state {
836 crate::auto_register::RegistrationState::Approved => {
837 return Ok(RegistrationGate::Ready)
838 }
839 crate::auto_register::RegistrationState::Rejected { reason } => {
840 return Err(anyhow!(
841 "registration rejected by the studio operator: {reason}. \
842 Run `studio-worker register --reset` to clear local state \
843 and submit a fresh request."
844 ));
845 }
846 _ => {}
847 }
848 for _ in 0..30 {
850 if stop.load(Ordering::SeqCst) {
851 return Ok(RegistrationGate::Stopped);
852 }
853 tokio::time::sleep(Duration::from_secs(1)).await;
854 }
855 }
856}
857
858pub async fn run_loops(
866 cfg: SharedConfig,
867 stop: Arc<AtomicBool>,
868 logs: Arc<Mutex<Vec<LogEntry>>>,
869 busy: Arc<AtomicBool>,
870 paused: Arc<AtomicBool>,
871 observers: WorkerObservers,
872 schedule: LoopSchedule,
873) -> Result<()> {
874 let session = crate::ws::session::spawn_ws_session(
875 cfg.clone(),
876 stop.clone(),
877 logs.clone(),
878 busy.clone(),
879 paused.clone(),
880 observers.clone(),
881 schedule.ws_session,
882 );
883 let auto_updater = spawn_auto_updater(
884 cfg.clone(),
885 stop.clone(),
886 logs.clone(),
887 busy.clone(),
888 schedule,
889 );
890 let (session_result, _) = tokio::join!(session, auto_updater);
891 session_result
892}
893
894pub const DEFAULT_LOCAL_API_PORT: u16 = 4787;
897
898pub fn resolve_local_api_port(env_value: Option<&str>, cfg_port: Option<u16>) -> u16 {
905 resolve_port(
906 "STUDIO_WORKER_LOCAL_API_PORT",
907 env_value,
908 cfg_port,
909 DEFAULT_LOCAL_API_PORT,
910 )
911}
912
913pub fn resolve_stream_port(env_value: Option<&str>, cfg_port: Option<u16>) -> u16 {
916 resolve_port(
917 "STUDIO_WORKER_STREAM_PORT",
918 env_value,
919 cfg_port,
920 crate::stt_stream::server::DEFAULT_STREAM_PORT,
921 )
922}
923
924fn resolve_port(
926 env_name: &str,
927 env_value: Option<&str>,
928 cfg_port: Option<u16>,
929 default: u16,
930) -> u16 {
931 let fallback = cfg_port.unwrap_or(default);
932 match env_value {
933 Some(raw) => match raw.parse::<u16>() {
934 Ok(port) => port,
935 Err(_) => {
936 warn!(
937 target: "studio_worker::local_api",
938 op = "resolve_port",
939 invalid = raw,
940 fallback,
941 "{env_name} is not a valid port; falling back"
942 );
943 fallback
944 }
945 },
946 None => fallback,
947 }
948}
949
950pub fn ensure_local_api_token(cfg: &SharedConfig, config_path: &std::path::Path) -> String {
955 let mut snap = cfg.lock();
956 if let Some(token) = snap.local_api_token.clone() {
957 return token;
958 }
959 let token = crate::secrets::new_secret_hex();
960 snap.local_api_token = Some(token.clone());
961 let snapshot = snap.clone();
962 drop(snap);
963 if let Err(e) = config::save(&snapshot, config_path) {
964 warn!(
965 target: "studio_worker::local_api",
966 op = "ensure_token",
967 config_path = %config_path.display(),
968 error = %e,
969 "failed to persist the local api token; a fresh one will be minted next launch"
970 );
971 }
972 token
973}
974
975#[cfg_attr(coverage_nightly, coverage(off))]
979fn spawn_stream_listener(
980 cfg: &SharedConfig,
981 services: &crate::local_api::ModelServices,
982 observers: &WorkerObservers,
983 stop: Arc<AtomicBool>,
984) {
985 let port = resolve_stream_port(
986 std::env::var("STUDIO_WORKER_STREAM_PORT").ok().as_deref(),
987 cfg.lock().stream_port,
988 );
989 let addr = format!("0.0.0.0:{port}");
990 match crate::stt_stream::server::StreamServer::bind(
991 &addr,
992 services.host.clone(),
993 services.tokens.clone(),
994 observers.clone(),
995 ) {
996 Ok(server) => {
997 let bound = server.local_addr().port();
998 services.stream_port.store(bound, Ordering::SeqCst);
999 tracing::info!(target: "studio_worker::stt_stream", addr = %server.local_addr(), "stream listener listening");
1000 std::thread::spawn(move || server.serve(&stop));
1001 }
1002 Err(err) => tracing::warn!(
1003 target: "studio_worker::stt_stream",
1004 %addr,
1005 error = %err,
1006 "stream listener could not bind; streaming speech is unavailable"
1007 ),
1008 }
1009}
1010
1011pub fn spawn_local_api(
1015 control: &crate::control::DaemonControl,
1016 observers: WorkerObservers,
1017 gate: crate::job_gate::JobGate,
1018) -> Option<std::thread::JoinHandle<()>> {
1019 let cfg = control.cfg.clone();
1020 let config_path = control.config_path.as_path();
1021 let stop = control.stop.clone();
1022 let engine: Arc<dyn crate::engine::Engine> = match crate::engine::build(&cfg.lock()) {
1023 Ok(engine) => engine.into(),
1024 Err(err) => {
1025 tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: engine build failed");
1026 return None;
1027 }
1028 };
1029
1030 let (loaded, catalog_path) =
1036 crate::catalog::Catalog::load_for_serving(crate::config::catalog_path_for(config_path));
1037 *observers.catalog.lock() = loaded;
1038 *observers.catalog_path.lock() = catalog_path.clone();
1039 let catalog = observers.catalog.clone();
1040
1041 let token = ensure_local_api_token(&cfg, config_path);
1042 let port = resolve_local_api_port(
1043 std::env::var("STUDIO_WORKER_LOCAL_API_PORT")
1044 .ok()
1045 .as_deref(),
1046 cfg.lock().local_api_port,
1047 );
1048
1049 let models_root = Some(cfg.lock().models_root.clone());
1050 let host = crate::host::ModelHost::new(
1051 catalog.clone(),
1052 Arc::new(crate::loaders::Loaders::new(cfg.lock().models_root.clone())),
1053 Arc::new(crate::admission::SystemProbe),
1054 crate::residency::Residency::load_for_serving(crate::config::residency_path_for(
1055 config_path,
1056 )),
1057 );
1058 let services = crate::local_api::ModelServices::new(host.clone());
1059 spawn_stream_listener(&cfg, &services, &observers, stop.clone());
1060 let api = crate::local_api::LocalApi::bind(
1061 &format!("127.0.0.1:{port}"),
1062 engine.clone(),
1063 catalog.clone(),
1064 catalog_path.clone(),
1065 observers.clone(),
1066 token.clone(),
1067 gate.clone(),
1068 models_root.clone(),
1069 services.clone(),
1070 )
1071 .or_else(|_| {
1072 crate::local_api::LocalApi::bind(
1073 "127.0.0.1:0",
1074 engine,
1075 catalog,
1076 catalog_path,
1077 observers.clone(),
1078 token.clone(),
1079 gate.clone(),
1080 models_root,
1081 services.clone(),
1082 )
1083 });
1084
1085 let api = match api {
1086 Ok(api) => api.with_control(control.clone()),
1087 Err(err) => {
1088 tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: bind failed");
1089 return None;
1090 }
1091 };
1092
1093 set_gpu_runtime_status(
1097 &observers,
1098 crate::engine::sd_provision::vulkan_runtime_status(),
1099 );
1100
1101 host.restore_residents();
1103
1104 let url = api.url();
1105 *observers.local_api_url.lock() = Some(url.clone());
1106 tracing::info!(target: "studio_worker::local_api", url = %url, "local image API listening");
1107
1108 let discovery_path = crate::config::local_api_discovery_path_for(config_path);
1111 if let Some(path) = &discovery_path {
1112 if let Err(e) = crate::local_api::write_discovery_file(path, &url, &token) {
1113 tracing::warn!(
1114 target: "studio_worker::local_api",
1115 error = %e,
1116 path = %path.display(),
1117 "failed to write the local api discovery file"
1118 );
1119 }
1120 }
1121
1122 Some(std::thread::spawn(move || {
1123 api.serve(&stop);
1124 if let Some(path) = &discovery_path {
1125 crate::local_api::remove_discovery_file(path);
1126 }
1127 }))
1128}
1129
1130#[derive(Debug, Clone, PartialEq, Eq)]
1141pub enum AutoUpdateDecision {
1142 Disabled,
1144 SkippedBusy,
1146 UpToDate,
1148 CheckError(String),
1150 Updated,
1152 UpdateError(String),
1154}
1155
1156pub async fn auto_update_tick(
1157 cfg: &Config,
1158 gate: &crate::job_gate::JobGate,
1159 logs: &Arc<Mutex<Vec<LogEntry>>>,
1160) -> AutoUpdateDecision {
1161 if !cfg.auto_update_enabled {
1162 return AutoUpdateDecision::Disabled;
1163 }
1164 if gate.is_busy() {
1168 push_log(
1169 logs,
1170 "info",
1171 "auto-update",
1172 "skipping check: worker is busy on a job",
1173 None,
1174 );
1175 return AutoUpdateDecision::SkippedBusy;
1176 }
1177 let feed = cfg.auto_update_feed.clone();
1178 let prerelease = cfg.auto_update_prerelease;
1179 let logs_for_task = logs.clone();
1180 let gate = gate.clone();
1181 let outcome = tokio::task::spawn_blocking(move || -> Result<AutoUpdateDecision> {
1182 let current = semver::Version::parse(AGENT_VERSION)
1183 .map_err(|e| anyhow!("invalid AGENT_VERSION {AGENT_VERSION}: {e}"))?;
1184 match update::check(&feed, ¤t, prerelease) {
1185 Ok(update::CheckOutcome::UpToDate { current }) => {
1186 push_log(
1187 &logs_for_task,
1188 "info",
1189 "auto-update",
1190 &format!("up to date at {current}"),
1191 None,
1192 );
1193 Ok(AutoUpdateDecision::UpToDate)
1194 }
1195 Ok(update::CheckOutcome::NewerAvailable { current, latest }) => {
1196 let Some(_reservation) = gate.try_reserve() else {
1203 push_log(
1204 &logs_for_task,
1205 "info",
1206 "auto-update",
1207 "update available but a job started; deferring install",
1208 None,
1209 );
1210 return Ok(AutoUpdateDecision::SkippedBusy);
1211 };
1212 push_log(
1213 &logs_for_task,
1214 "info",
1215 "auto-update",
1216 &format!("update available {current} -> {latest}; applying"),
1217 None,
1218 );
1219 match update::apply(&feed, &latest) {
1220 Ok(()) => {
1221 push_log(
1222 &logs_for_task,
1223 "info",
1224 "auto-update",
1225 "binary replaced; restart pending",
1226 None,
1227 );
1228 Ok(AutoUpdateDecision::Updated)
1229 }
1230 Err(e) => {
1231 push_log(
1232 &logs_for_task,
1233 "error",
1234 "auto-update",
1235 &format!("update failed: {e}"),
1236 None,
1237 );
1238 Ok(AutoUpdateDecision::UpdateError(e.to_string()))
1239 }
1240 }
1241 }
1242 Err(e) => {
1243 push_log(
1244 &logs_for_task,
1245 "warn",
1246 "auto-update",
1247 &format!("check failed: {e}"),
1248 None,
1249 );
1250 Ok(AutoUpdateDecision::CheckError(e.to_string()))
1251 }
1252 }
1253 })
1254 .await;
1255 match outcome {
1256 Ok(Ok(decision)) => decision,
1257 Ok(Err(e)) => AutoUpdateDecision::CheckError(e.to_string()),
1258 Err(e) => AutoUpdateDecision::CheckError(e.to_string()),
1259 }
1260}
1261
1262pub(crate) async fn wait_with_stop(total: Duration, stop: &Arc<AtomicBool>, tick: Duration) {
1277 let mut elapsed = Duration::ZERO;
1278 while elapsed < total {
1279 if stop.load(Ordering::SeqCst) {
1280 return;
1281 }
1282 let next = tick.min(total - elapsed);
1283 tokio::time::sleep(next).await;
1284 elapsed += next;
1285 }
1286}
1287
1288pub fn spawn_auto_updater(
1289 cfg: SharedConfig,
1290 stop: Arc<AtomicBool>,
1291 logs: Arc<Mutex<Vec<LogEntry>>>,
1292 busy: Arc<AtomicBool>,
1293 schedule: LoopSchedule,
1294) -> tokio::task::JoinHandle<()> {
1295 tokio::spawn(async move {
1296 let mut elapsed = Duration::from_secs(0);
1297 while !stop.load(Ordering::SeqCst) {
1298 wait_with_stop(schedule.auto_update_tick, &stop, schedule.shutdown_tick).await;
1303 if stop.load(Ordering::SeqCst) {
1304 break;
1305 }
1306 elapsed += schedule.auto_update_tick;
1307 let snapshot = cfg.lock().clone();
1308 if elapsed < Duration::from_secs(snapshot.auto_update_interval_secs) {
1309 continue;
1310 }
1311 elapsed = Duration::from_secs(0);
1312 let gate = crate::job_gate::JobGate::from_shared(busy.clone());
1313 let decision = auto_update_tick(&snapshot, &gate, &logs).await;
1314 if matches!(decision, AutoUpdateDecision::Updated) {
1315 stop.store(true, Ordering::SeqCst);
1316 update::restart_self();
1317 }
1318 }
1319 })
1320}
1321
1322pub fn prompt_for(task: &Task) -> String {
1326 match task {
1327 Task::Image(p) => p.prompt.clone(),
1328 Task::Llm(p) => p
1329 .messages
1330 .last()
1331 .map(|m| m.content.clone())
1332 .unwrap_or_default(),
1333 Task::AudioStt(p) => p.input_url.clone(),
1334 Task::AudioTts(p) => p.text.clone(),
1335 Task::Video(p) => p.prompt.clone(),
1336 }
1337}
1338
1339pub fn is_unsupported_kind(e: &anyhow::Error) -> bool {
1340 e.chain().any(|cause| {
1344 cause
1345 .downcast_ref::<crate::engine::UnsupportedTask>()
1346 .is_some()
1347 }) || e.to_string().contains("cannot serve")
1348}
1349
1350pub fn build_capabilities(cfg: &Config, engine: &dyn Engine) -> WorkerCapabilities {
1355 build_capabilities_with(cfg, engine, true)
1356}
1357
1358pub fn build_capabilities_with(
1363 cfg: &Config,
1364 engine: &dyn Engine,
1365 auto_enabled: bool,
1366) -> WorkerCapabilities {
1367 let vram = sys::detect_vram_gb().unwrap_or(0.0);
1368 let caps = engine.capabilities();
1369 let supported_models_per_kind = caps.supported_models_per_kind.clone();
1370 let task_kinds = caps.kinds();
1371 let supported_models = {
1375 let mut all = caps.flat_models();
1376 all.sort();
1377 all.dedup();
1378 all
1379 };
1380
1381 WorkerCapabilities {
1382 machine_name: sys::machine_name(),
1383 username: sys::username(),
1384 agent_version: AGENT_VERSION.to_string(),
1385 engine: engine.name().to_string(),
1386 vram_total_gb: vram,
1387 vram_threshold_gb: cfg.vram_threshold_gb,
1388 auto_enabled,
1389 auto_start: cfg!(feature = "ui"),
1392 supported_models,
1393 task_kinds,
1394 supported_models_per_kind,
1395 }
1396}
1397
1398pub fn summarize_capabilities(caps: &WorkerCapabilities) -> String {
1408 let kinds = caps
1409 .task_kinds
1410 .iter()
1411 .map(|k| k.as_str())
1412 .collect::<Vec<_>>()
1413 .join(", ");
1414 format!(
1415 "advertising engine={}, vram={:.1}/{:.1}GB threshold, auto_enabled={}, \
1416 kinds=[{}], {} model(s)=[{}]",
1417 caps.engine,
1418 caps.vram_total_gb,
1419 caps.vram_threshold_gb,
1420 caps.auto_enabled,
1421 kinds,
1422 caps.supported_models.len(),
1423 caps.supported_models.join(", "),
1424 )
1425}
1426
1427pub fn vram_threshold_warning(caps: &WorkerCapabilities) -> Option<String> {
1447 if caps.vram_total_gb > 0.0 && caps.vram_threshold_gb > caps.vram_total_gb {
1448 Some(format!(
1449 "configured VRAM threshold {:.1}GB exceeds detected GPU VRAM {:.1}GB; \
1450 the studio may offer jobs larger than this card can fit and they will \
1451 OOM on load — lower vram_threshold_gb to at or below {:.1}GB",
1452 caps.vram_threshold_gb, caps.vram_total_gb, caps.vram_total_gb
1453 ))
1454 } else {
1455 None
1456 }
1457}
1458
1459pub fn push_log(
1460 logs: &Arc<Mutex<Vec<LogEntry>>>,
1461 level: &str,
1462 category: &str,
1463 message: &str,
1464 job_id: Option<String>,
1465) {
1466 push_log_with_observers(logs, None, level, category, message, job_id);
1467}
1468
1469pub fn push_log_with_observers(
1475 logs: &Arc<Mutex<Vec<LogEntry>>>,
1476 observers: Option<&WorkerObservers>,
1477 level: &str,
1478 category: &str,
1479 message: &str,
1480 job_id: Option<String>,
1481) {
1482 let entry = LogEntry {
1483 ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1484 level: level.to_string(),
1485 category: category.to_string(),
1486 message: message.to_string(),
1487 job_id,
1488 };
1489 let job_id = entry.job_id.as_deref();
1494 if level == "error" {
1495 tracing::error!(target: "studio_worker", job_id, "[{category}] {message}");
1496 } else if level == "warn" {
1497 tracing::warn!(target: "studio_worker", job_id, "[{category}] {message}");
1498 } else {
1499 info!(target: "studio_worker", job_id, "[{category}] {message}");
1500 }
1501 {
1502 let mut queue = logs.lock();
1503 if queue.len() >= LOG_SHIP_QUEUE_CAP {
1504 let overflow = queue.len() + 2 - LOG_SHIP_QUEUE_CAP;
1506 queue.drain(0..overflow);
1507 queue.push(LogEntry {
1508 ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1509 level: "warn".to_string(),
1510 category: "logs".to_string(),
1511 message: format!(
1512 "ship queue full ({LOG_SHIP_QUEUE_CAP} entries); dropped {overflow} oldest"
1513 ),
1514 job_id: None,
1515 });
1516 }
1517 queue.push(entry.clone());
1518 }
1519 if let Some(o) = observers {
1520 o.worker_log().push(entry);
1521 }
1522}
1523
1524pub fn restore_unshipped(logs: &Arc<Mutex<Vec<LogEntry>>>, mut batch: Vec<LogEntry>) {
1530 let mut queue = logs.lock();
1531 batch.append(&mut queue);
1532 *queue = batch;
1533 if queue.len() > LOG_SHIP_QUEUE_CAP {
1534 let overflow = queue.len() - LOG_SHIP_QUEUE_CAP;
1535 queue.drain(0..overflow);
1536 }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541 use super::*;
1542 use crate::config::Config;
1543 use crate::engine::SyntheticEngine;
1544
1545 fn push_messages(observers: &WorkerObservers, count: usize) {
1546 let logs = Arc::new(Mutex::new(Vec::new()));
1547 for i in 0..count {
1548 push_log_with_observers(&logs, Some(observers), "info", "t", &format!("m{i}"), None);
1549 }
1550 }
1551
1552 #[test]
1553 fn recent_logs_after_answers_only_newer_entries() {
1554 let observers = WorkerObservers::default();
1555 push_messages(&observers, 3);
1556 let (all, newest) = recent_logs_after(&observers, 0);
1557 assert_eq!((all.len(), newest), (3, 3));
1558 let (newer, _) = recent_logs_after(&observers, 2);
1559 assert_eq!(newer.len(), 1);
1560 assert_eq!(newer[0].message, "m2");
1561 assert!(recent_logs_after(&observers, 3).0.is_empty());
1562 }
1563
1564 #[test]
1565 fn the_daemons_observers_share_the_global_worker_log() {
1566 let a = WorkerObservers::with_global_worker_log();
1567 let b = WorkerObservers::with_global_worker_log();
1568 assert!(Arc::ptr_eq(&a.recent_logs, &b.recent_logs));
1569 assert!(!Arc::ptr_eq(
1570 &a.recent_logs,
1571 &WorkerObservers::default().recent_logs
1572 ));
1573 }
1574
1575 #[test]
1576 fn recent_logs_after_a_restart_answers_the_whole_ring() {
1577 let observers = WorkerObservers::default();
1578 push_messages(&observers, 2);
1579 assert_eq!(recent_logs_after(&observers, 99).0.len(), 2);
1580 }
1581
1582 #[test]
1583 fn recent_logs_after_skips_entries_that_left_the_ring() {
1584 let observers = WorkerObservers::default();
1585 push_messages(&observers, RECENT_LOGS_CAP + 5);
1586 let (all, newest) = recent_logs_after(&observers, 0);
1587 assert_eq!(all.len(), RECENT_LOGS_CAP);
1588 assert_eq!(all[0].message, "m5");
1589 assert_eq!(newest, (RECENT_LOGS_CAP + 5) as u64);
1590 }
1591
1592 #[test]
1593 fn is_unsupported_kind_detects_typed_unsupported_task() {
1594 let err: anyhow::Error =
1595 crate::engine::UnsupportedTask::new("synthetic", TaskKind::Llm).into();
1596 assert!(is_unsupported_kind(&err));
1597 assert!(err.to_string().contains("cannot serve llm"));
1599 }
1600
1601 #[test]
1602 fn is_unsupported_kind_survives_context_wrapping() {
1603 let err = anyhow::Error::from(crate::engine::UnsupportedTask::new(
1607 "sdcpp",
1608 TaskKind::AudioTts,
1609 ))
1610 .context("dispatching job j-1");
1611 assert!(is_unsupported_kind(&err));
1612 }
1613
1614 fn entry(message: &str) -> LogEntry {
1615 LogEntry {
1616 ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1617 level: "info".into(),
1618 category: "test".into(),
1619 message: message.into(),
1620 job_id: None,
1621 }
1622 }
1623
1624 #[test]
1625 fn restore_unshipped_requeues_batch_ahead_of_newer_entries() {
1626 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(vec![entry("newer")]));
1630 restore_unshipped(&logs, vec![entry("batch-1"), entry("batch-2")]);
1631 let queue = logs.lock();
1632 let order: Vec<&str> = queue.iter().map(|e| e.message.as_str()).collect();
1633 assert_eq!(order, vec!["batch-1", "batch-2", "newer"]);
1634 }
1635
1636 #[test]
1637 fn restore_unshipped_respects_the_queue_cap() {
1638 let logs: Arc<Mutex<Vec<LogEntry>>> =
1641 Arc::new(Mutex::new(vec![entry("newest"); LOG_SHIP_QUEUE_CAP]));
1642 restore_unshipped(&logs, vec![entry("old-batch"); 100]);
1643 let queue = logs.lock();
1644 assert_eq!(queue.len(), LOG_SHIP_QUEUE_CAP);
1645 assert_eq!(
1646 queue.last().map(|e| e.message.as_str()),
1647 Some("newest"),
1648 "newest entries must survive the cap"
1649 );
1650 }
1651
1652 #[test]
1653 fn ship_queue_is_bounded_and_records_dropped_entries() {
1654 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1658 for i in 0..(LOG_SHIP_QUEUE_CAP + 100) {
1659 push_log_with_observers(&logs, None, "info", "test", &format!("entry {i}"), None);
1660 }
1661 let queue = logs.lock();
1662 assert!(
1663 queue.len() <= LOG_SHIP_QUEUE_CAP,
1664 "ship queue exceeded its cap: {}",
1665 queue.len()
1666 );
1667 assert_eq!(
1669 queue.last().map(|e| e.message.as_str()),
1670 Some(format!("entry {}", LOG_SHIP_QUEUE_CAP + 99).as_str())
1671 );
1672 assert!(
1674 queue
1675 .iter()
1676 .any(|e| e.level == "warn" && e.message.contains("dropped")),
1677 "overflow must leave a visible drop marker"
1678 );
1679 }
1680
1681 #[test]
1682 fn recent_logs_ring_is_bounded_at_recent_logs_cap() {
1683 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1691 let observers = WorkerObservers::default();
1692 let overflow = 25;
1693 for i in 0..(RECENT_LOGS_CAP + overflow) {
1694 push_log_with_observers(
1695 &logs,
1696 Some(&observers),
1697 "info",
1698 "test",
1699 &format!("entry {i}"),
1700 None,
1701 );
1702 }
1703 let ring = observers.recent_logs.lock();
1704 assert_eq!(
1705 ring.len(),
1706 RECENT_LOGS_CAP,
1707 "the recent-logs ring must cap at RECENT_LOGS_CAP"
1708 );
1709 assert_eq!(
1712 ring.back().map(|e| e.message.as_str()),
1713 Some(format!("entry {}", RECENT_LOGS_CAP + overflow - 1).as_str()),
1714 "the newest entry must survive at the back of the ring"
1715 );
1716 assert_eq!(
1717 ring.front().map(|e| e.message.as_str()),
1718 Some(format!("entry {overflow}").as_str()),
1719 "the oldest surviving entry must be entry #overflow (older evicted)"
1720 );
1721 }
1722
1723 #[test]
1724 fn session_state_summaries_carry_recovery_actions_for_terminal_states() {
1725 assert!(SessionState::default() == SessionState::WaitingForApproval);
1726 assert!(SessionState::Connected.summary().contains("connected"));
1727 assert!(SessionState::Reconnecting { attempt: 3 }
1728 .summary()
1729 .contains("attempt 3"));
1730 let auth = SessionState::AuthFailed {
1733 reason: "bad token".into(),
1734 }
1735 .summary();
1736 assert!(auth.contains("register --reset"), "got: {auth}");
1737 assert!(auth.contains("bad token"));
1738 assert!(SessionState::Fatal {
1739 reason: "boom".into()
1740 }
1741 .summary()
1742 .contains("boom"));
1743 }
1744
1745 #[test]
1746 fn set_session_state_updates_the_observer_slot() {
1747 let observers = WorkerObservers::default();
1748 assert_eq!(
1749 *observers.session_state.lock(),
1750 SessionState::WaitingForApproval
1751 );
1752 set_session_state(&observers, SessionState::Connected);
1753 assert_eq!(*observers.session_state.lock(), SessionState::Connected);
1754 }
1755
1756 #[test]
1757 fn sync_studio_model_mirrors_into_the_shared_catalog_and_persists() {
1758 use crate::types::{ModelCliDefaults, ModelEngine, ModelSource};
1759 let dir = tempfile::tempdir().unwrap();
1760 let path = dir.path().join("models.json");
1761 let observers = WorkerObservers::default();
1762 *observers.catalog_path.lock() = Some(path.clone());
1763
1764 let source = ModelSource {
1765 engine: ModelEngine::Synthetic,
1766 files: vec![],
1767 cli_defaults: ModelCliDefaults::default(),
1768 };
1769 sync_studio_model(&observers, "studio-llm", TaskKind::Llm, &source);
1770
1771 assert!(observers.catalog.lock().get("studio-llm").is_some());
1773 assert_eq!(
1774 observers.catalog.lock().get("studio-llm").unwrap().origin,
1775 "studio"
1776 );
1777 let reloaded = crate::catalog::Catalog::load_or_seed(&path).unwrap();
1779 assert!(reloaded.get("studio-llm").is_some());
1780 }
1781
1782 #[test]
1783 fn sync_studio_model_without_a_path_stays_in_memory_only() {
1784 use crate::types::{ModelCliDefaults, ModelEngine, ModelSource};
1785 let observers = WorkerObservers::default();
1786 let source = ModelSource {
1789 engine: ModelEngine::Synthetic,
1790 files: vec![],
1791 cli_defaults: ModelCliDefaults::default(),
1792 };
1793 sync_studio_model(&observers, "m", TaskKind::Image, &source);
1794 assert!(observers.catalog.lock().get("m").is_some());
1795 }
1796
1797 #[test]
1798 fn set_gpu_runtime_status_records_ok_without_warning() {
1799 let observers = WorkerObservers::default();
1800 assert!(observers.gpu_runtime.lock().is_none(), "unprobed at first");
1801 let out = crate::test_support::capture({
1802 let observers = observers.clone();
1803 move || set_gpu_runtime_status(&observers, Ok(()))
1804 });
1805 let status = observers.gpu_runtime.lock().clone().unwrap();
1806 assert!(status.ok);
1807 assert!(status.detail.contains("available"));
1808 assert!(
1809 !out.contains("GPU runtime missing"),
1810 "the ok path must not warn: {out}"
1811 );
1812 }
1813
1814 #[test]
1815 fn set_gpu_runtime_status_records_and_warns_the_remedy_when_missing() {
1816 let observers = WorkerObservers::default();
1817 let out = crate::test_support::capture({
1818 let observers = observers.clone();
1819 move || {
1820 set_gpu_runtime_status(
1821 &observers,
1822 Err(anyhow!("Vulkan runtime not available: install libvulkan1")),
1823 )
1824 }
1825 });
1826 let status = observers.gpu_runtime.lock().clone().unwrap();
1827 assert!(!status.ok);
1828 assert!(
1829 status.detail.contains("libvulkan1"),
1830 "got: {}",
1831 status.detail
1832 );
1833 assert!(
1834 out.contains("GPU runtime missing") && out.contains("WARN"),
1835 "a missing runtime must warn with the remedy: {out}"
1836 );
1837 }
1838
1839 #[test]
1840 fn capabilities_advertises_all_synthetic_kinds() {
1841 let cfg = Config::default();
1842 let engine = SyntheticEngine::new();
1843 let cap = build_capabilities(&cfg, &engine);
1844 assert_eq!(cap.engine, "synthetic");
1845 assert_eq!(cap.task_kinds.len(), TaskKind::ALL.len());
1846 assert!(cap.auto_enabled, "default capability snapshot is unpaused");
1847 for kind in TaskKind::ALL {
1848 assert!(cap.supported_models_per_kind.contains_key(&kind));
1849 }
1850 }
1851
1852 #[test]
1853 fn capabilities_with_paused_flag_drives_auto_enabled() {
1854 let cfg = Config::default();
1855 let engine = SyntheticEngine::new();
1856 let paused_caps = build_capabilities_with(&cfg, &engine, false);
1857 assert!(!paused_caps.auto_enabled);
1858 }
1859
1860 #[test]
1861 fn summarize_capabilities_lists_engine_kinds_models_vram_and_pause_state() {
1862 let cfg = Config {
1863 vram_threshold_gb: 6.0,
1864 ..Config::default()
1865 };
1866 let engine = SyntheticEngine::new();
1867 let caps = build_capabilities_with(&cfg, &engine, true);
1868 let summary = summarize_capabilities(&caps);
1869 assert!(summary.contains("engine=synthetic"), "got: {summary}");
1871 for kind in &caps.task_kinds {
1872 assert!(
1873 summary.contains(kind.as_str()),
1874 "missing kind {} in: {summary}",
1875 kind.as_str()
1876 );
1877 }
1878 assert!(
1880 summary.contains(&format!("{} model(s)", caps.supported_models.len())),
1881 "missing model count in: {summary}"
1882 );
1883 assert!(
1884 summary.contains("synthetic"),
1885 "missing model id in: {summary}"
1886 );
1887 assert!(
1889 summary.contains("6.0"),
1890 "missing vram threshold in: {summary}"
1891 );
1892 assert!(summary.contains("auto_enabled=true"), "got: {summary}");
1893 }
1894
1895 #[test]
1896 fn summarize_capabilities_reflects_paused_state() {
1897 let cfg = Config::default();
1898 let engine = SyntheticEngine::new();
1899 let caps = build_capabilities_with(&cfg, &engine, false);
1900 assert!(
1901 summarize_capabilities(&caps).contains("auto_enabled=false"),
1902 "paused worker must advertise auto_enabled=false"
1903 );
1904 }
1905
1906 fn caps_with_vram(total_gb: f32, threshold_gb: f32) -> WorkerCapabilities {
1910 let mut caps = build_capabilities_with(&Config::default(), &SyntheticEngine::new(), true);
1911 caps.vram_total_gb = total_gb;
1912 caps.vram_threshold_gb = threshold_gb;
1913 caps
1914 }
1915
1916 #[test]
1917 fn vram_threshold_warning_flags_threshold_above_detected_vram() {
1918 let warning = vram_threshold_warning(&caps_with_vram(8.0, 12.0))
1923 .expect("threshold above detected VRAM must warn");
1924 assert!(warning.contains("12.0"), "missing threshold in: {warning}");
1925 assert!(
1926 warning.contains("8.0"),
1927 "missing detected VRAM in: {warning}"
1928 );
1929 assert!(
1930 warning.contains("vram_threshold_gb"),
1931 "must name the config key to change: {warning}"
1932 );
1933 }
1934
1935 #[test]
1936 fn vram_threshold_warning_silent_when_threshold_within_detected_vram() {
1937 assert!(vram_threshold_warning(&caps_with_vram(24.0, 12.0)).is_none());
1939 }
1940
1941 #[test]
1942 fn vram_threshold_warning_silent_when_threshold_equals_detected() {
1943 assert!(vram_threshold_warning(&caps_with_vram(12.0, 12.0)).is_none());
1946 }
1947
1948 #[test]
1949 fn vram_threshold_warning_silent_when_vram_undetected() {
1950 assert!(vram_threshold_warning(&caps_with_vram(0.0, 12.0)).is_none());
1955 }
1956
1957 #[test]
1958 fn prompt_for_extracts_per_kind() {
1959 let image = Task::Image(ImageParams {
1960 prompt: "a stone golem".into(),
1961 ..Default::default()
1962 });
1963 assert_eq!(prompt_for(&image), "a stone golem");
1964
1965 let llm = Task::Llm(LlmParams {
1966 messages: vec![
1967 ChatMessage {
1968 role: "system".into(),
1969 content: "be helpful".into(),
1970 },
1971 ChatMessage {
1972 role: "user".into(),
1973 content: "hi".into(),
1974 },
1975 ],
1976 max_tokens: 32,
1977 temperature: 0.5,
1978 ..Default::default()
1979 });
1980 assert_eq!(prompt_for(&llm), "hi");
1981
1982 let llm_empty = Task::Llm(LlmParams {
1983 messages: vec![],
1984 ..Default::default()
1985 });
1986 assert_eq!(prompt_for(&llm_empty), "");
1987
1988 let stt = Task::AudioStt(AudioSttParams {
1989 input_url: "https://example.com/clip.wav".into(),
1990 ..Default::default()
1991 });
1992 assert_eq!(prompt_for(&stt), "https://example.com/clip.wav");
1993
1994 let tts = Task::AudioTts(AudioTtsParams {
1995 text: "hi there".into(),
1996 voice: "v".into(),
1997 ext: "wav".into(),
1998 ..Default::default()
1999 });
2000 assert_eq!(prompt_for(&tts), "hi there");
2001
2002 let video = Task::Video(VideoParams {
2003 prompt: "a tiny dragon".into(),
2004 seconds: 1.0,
2005 width: 256,
2006 height: 256,
2007 ext: "mp4".into(),
2008 ..Default::default()
2009 });
2010 assert_eq!(prompt_for(&video), "a tiny dragon");
2011 }
2012
2013 #[test]
2014 fn truncate_prompt_passes_short_through_and_clips_long_prompts() {
2015 let short = "a stone golem";
2017 assert_eq!(truncate_prompt(short), short);
2018
2019 let exactly = "x".repeat(PROMPT_PREVIEW_CHARS);
2021 assert_eq!(
2022 truncate_prompt(&exactly),
2023 exactly,
2024 "a prompt exactly at the cap must not be clipped"
2025 );
2026
2027 let over = "y".repeat(PROMPT_PREVIEW_CHARS + 1);
2030 let clipped = truncate_prompt(&over);
2031 assert_eq!(
2032 clipped.chars().count(),
2033 PROMPT_PREVIEW_CHARS + 1,
2034 "clipped preview is the cap plus one ellipsis char"
2035 );
2036 assert!(
2037 clipped.ends_with('\u{2026}'),
2038 "a clipped preview ends with an ellipsis"
2039 );
2040 assert_eq!(
2041 clipped
2042 .chars()
2043 .take(PROMPT_PREVIEW_CHARS)
2044 .collect::<String>(),
2045 "y".repeat(PROMPT_PREVIEW_CHARS),
2046 "the kept prefix is the first PROMPT_PREVIEW_CHARS chars"
2047 );
2048 }
2049
2050 #[test]
2051 fn truncate_prompt_clips_on_char_boundaries_for_multibyte_text() {
2052 let multibyte = "\u{3042}".repeat(PROMPT_PREVIEW_CHARS + 1);
2057 let clipped = truncate_prompt(&multibyte);
2058 assert_eq!(clipped.chars().count(), PROMPT_PREVIEW_CHARS + 1);
2059 assert!(clipped.ends_with('\u{2026}'));
2060 assert_eq!(
2061 clipped.chars().filter(|c| *c == '\u{3042}').count(),
2062 PROMPT_PREVIEW_CHARS,
2063 "exactly PROMPT_PREVIEW_CHARS multibyte chars survive the clip"
2064 );
2065 }
2066
2067 #[test]
2068 fn is_unsupported_kind_matches_engine_message() {
2069 let err = anyhow!("multi engine cannot serve llm tasks");
2070 assert!(is_unsupported_kind(&err));
2071 let other = anyhow!("network timeout");
2072 assert!(!is_unsupported_kind(&other));
2073 }
2074
2075 #[test]
2076 fn format_status_includes_every_field() {
2077 let cfg = Config::default();
2078 let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
2079 assert!(out.contains("config path:"));
2080 assert!(out.contains("api_base_url:"));
2081 assert!(out.contains("registration:"));
2082 assert!(out.contains("not registered"));
2083 assert!(out.contains("models_root:"));
2084 assert!(out.contains("auto_update:"));
2085 assert!(out.contains("update_interval:"));
2086 }
2087
2088 #[test]
2089 fn format_status_shows_worker_id_when_registered() {
2090 let cfg = Config {
2091 worker_id: Some("w-abc".into()),
2092 auth_token: Some("tok".into()),
2093 ..Config::default()
2094 };
2095 let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
2096 assert!(out.contains("w-abc"));
2097 assert!(out.contains("approved"));
2098 }
2099
2100 #[test]
2101 fn format_status_shows_pending_request_id() {
2102 let cfg = Config {
2103 registration_request_id: Some("rr-7".into()),
2104 ..Config::default()
2105 };
2106 let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
2107 assert!(out.contains("pending operator approval"));
2108 assert!(out.contains("rr-7"));
2109 }
2110
2111 #[test]
2112 fn format_check_outcome_handles_both_branches() {
2113 let up = update::CheckOutcome::UpToDate {
2114 current: semver::Version::new(1, 2, 3),
2115 };
2116 assert!(format_check_outcome(&up).contains("up to date"));
2117 let newer = update::CheckOutcome::NewerAvailable {
2118 current: semver::Version::new(1, 2, 3),
2119 latest: semver::Version::new(1, 3, 0),
2120 };
2121 let s = format_check_outcome(&newer);
2122 assert!(s.contains("1.2.3 -> 1.3.0"));
2123 }
2124
2125 #[test]
2126 fn push_log_appends_an_entry() {
2127 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2128 push_log(&logs, "info", "test", "hi", None);
2129 push_log(&logs, "warn", "test", "wat", Some("j-1".into()));
2130 push_log(&logs, "error", "test", "boom", None);
2131 let v = logs.lock();
2132 assert_eq!(v.len(), 3);
2133 assert_eq!(v[0].level, "info");
2134 assert_eq!(v[1].level, "warn");
2135 assert_eq!(v[1].job_id.as_deref(), Some("j-1"));
2136 assert_eq!(v[2].level, "error");
2137 }
2138
2139 #[test]
2140 fn push_log_emits_job_id_as_a_structured_tracing_field() {
2141 use crate::test_support::capture;
2146 let logs = capture(|| {
2147 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2148 push_log(
2149 &logs,
2150 "info",
2151 "ws",
2152 "binary upload ok",
2153 Some("job-42".into()),
2154 );
2155 });
2156 assert!(
2157 logs.contains("job_id=\"job-42\""),
2158 "expected structured job_id field, got: {logs}"
2159 );
2160 assert!(
2161 logs.contains("[ws] binary upload ok"),
2162 "expected the human-readable message to survive, got: {logs}"
2163 );
2164 }
2165
2166 #[test]
2167 fn push_log_omits_job_id_field_when_absent() {
2168 use crate::test_support::capture;
2171 let logs = capture(|| {
2172 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2173 push_log(&logs, "info", "auto-update", "up to date", None);
2174 });
2175 assert!(
2176 !logs.contains("job_id"),
2177 "expected no job_id field for a jobless log, got: {logs}"
2178 );
2179 }
2180
2181 #[test]
2184 fn request_shutdown_sets_the_stop_flag() {
2185 let stop = AtomicBool::new(false);
2186 request_shutdown(&stop, "SIGTERM");
2187 assert!(stop.load(Ordering::SeqCst));
2188 }
2189
2190 #[test]
2191 fn request_shutdown_reconfirms_when_already_stopping() {
2192 let stop = AtomicBool::new(true);
2195 request_shutdown(&stop, "SIGINT");
2196 assert!(stop.load(Ordering::SeqCst));
2197 }
2198
2199 #[test]
2200 fn request_shutdown_emits_a_named_shutdown_breadcrumb() {
2201 use crate::test_support::capture;
2202 let logs = capture(|| {
2203 let stop = AtomicBool::new(false);
2204 request_shutdown(&stop, "SIGTERM");
2205 });
2206 assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
2207 assert!(
2208 logs.contains("studio_worker::runtime"),
2209 "expected runtime target, got: {logs}"
2210 );
2211 assert!(
2212 logs.contains("op=\"shutdown\""),
2213 "expected op field, got: {logs}"
2214 );
2215 assert!(
2216 logs.contains("signal=\"SIGTERM\""),
2217 "expected signal field, got: {logs}"
2218 );
2219 }
2220
2221 #[tokio::test]
2222 async fn auto_update_tick_disabled_when_flag_off() {
2223 let cfg = Config {
2224 auto_update_enabled: false,
2225 ..Config::default()
2226 };
2227 let logs = Arc::new(Mutex::new(Vec::new()));
2228 let decision = auto_update_tick(&cfg, &crate::job_gate::JobGate::new(), &logs).await;
2229 assert_eq!(decision, AutoUpdateDecision::Disabled);
2230 }
2231
2232 #[tokio::test]
2233 async fn auto_update_tick_skipped_when_busy() {
2234 let cfg = Config {
2235 auto_update_enabled: true,
2236 ..Config::default()
2237 };
2238 let logs = Arc::new(Mutex::new(Vec::new()));
2239 let gate = crate::job_gate::JobGate::new();
2241 let _held = gate.try_reserve().expect("hold the slot");
2242 let decision = auto_update_tick(&cfg, &gate, &logs).await;
2243 assert_eq!(decision, AutoUpdateDecision::SkippedBusy);
2244 let entries = logs.lock();
2245 assert!(entries.iter().any(|e| e.message.contains("busy on a job")));
2246 }
2247
2248 #[tokio::test]
2249 async fn wait_with_stop_short_circuits_when_already_stopped() {
2250 let stop = Arc::new(AtomicBool::new(true));
2251 let start = std::time::Instant::now();
2252 wait_with_stop(Duration::from_secs(60), &stop, Duration::from_millis(10)).await;
2253 assert!(
2254 start.elapsed() < Duration::from_millis(100),
2255 "an already-set stop must return without sleeping the full duration"
2256 );
2257 }
2258
2259 #[tokio::test]
2260 async fn auto_updater_stops_promptly_during_idle_wait() {
2261 let cfg = crate::config::shared(Config {
2267 auto_update_enabled: false,
2268 ..Config::default()
2269 });
2270 let stop = Arc::new(AtomicBool::new(false));
2271 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2272 let busy = Arc::new(AtomicBool::new(false));
2273 let schedule = LoopSchedule {
2274 ws_session: crate::ws::session::SessionSchedule::fast_for_tests(),
2275 auto_update_tick: Duration::from_secs(3600),
2276 shutdown_tick: Duration::from_millis(1),
2277 };
2278 let handle = spawn_auto_updater(cfg, stop.clone(), logs, busy, schedule);
2279 tokio::time::sleep(Duration::from_millis(10)).await;
2281 stop.store(true, Ordering::SeqCst);
2282 tokio::time::timeout(Duration::from_millis(250), handle)
2283 .await
2284 .expect("auto-updater did not observe stop promptly")
2285 .expect("auto-updater task panicked");
2286 }
2287
2288 #[test]
2293 fn resolve_local_api_port_prefers_env_then_config_then_default() {
2294 assert_eq!(resolve_local_api_port(Some("5000"), Some(4000)), 5000);
2295 assert_eq!(resolve_local_api_port(None, Some(4000)), 4000);
2296 assert_eq!(resolve_local_api_port(None, None), DEFAULT_LOCAL_API_PORT);
2297 }
2298
2299 #[test]
2300 fn resolve_local_api_port_warns_on_invalid_env_and_falls_back() {
2301 let logs = crate::test_support::capture(|| {
2304 assert_eq!(
2305 resolve_local_api_port(Some("not-a-port"), None),
2306 DEFAULT_LOCAL_API_PORT
2307 );
2308 assert_eq!(resolve_local_api_port(Some("99999"), Some(4001)), 4001);
2309 });
2310 assert!(logs.contains("WARN"), "expected a WARN, got: {logs}");
2311 assert!(
2312 logs.contains("not-a-port"),
2313 "the warn must name the invalid value, got: {logs}"
2314 );
2315 assert!(
2316 logs.contains("STUDIO_WORKER_LOCAL_API_PORT"),
2317 "the warn must name the env var, got: {logs}"
2318 );
2319 }
2320
2321 #[test]
2322 fn ensure_local_api_token_mints_once_and_persists() {
2323 let dir = tempfile::tempdir().unwrap();
2324 let path = dir.path().join("config.toml");
2325 let cfg = config::shared(Config::default());
2326
2327 let minted = ensure_local_api_token(&cfg, &path);
2328 assert_eq!(minted.len(), 64, "expected a 64-hex token");
2329 assert!(minted.chars().all(|c| c.is_ascii_hexdigit()));
2330
2331 let (loaded, _) = config::load(Some(&path.to_string_lossy())).unwrap();
2333 assert_eq!(loaded.local_api_token.as_deref(), Some(minted.as_str()));
2334
2335 let again = ensure_local_api_token(&cfg, &path);
2337 assert_eq!(again, minted);
2338 }
2339
2340 #[test]
2341 fn ensure_local_api_token_keeps_an_existing_token() {
2342 let dir = tempfile::tempdir().unwrap();
2343 let path = dir.path().join("config.toml");
2344 let cfg = config::shared(Config {
2345 local_api_token: Some("pre-existing".into()),
2346 ..Config::default()
2347 });
2348 assert_eq!(ensure_local_api_token(&cfg, &path), "pre-existing");
2349 assert!(!path.exists(), "no save when nothing changed");
2350 }
2351
2352 #[test]
2353 fn ensure_local_api_token_survives_a_failed_persist() {
2354 let dir = tempfile::tempdir().unwrap();
2357 let blocked = dir.path().join("blocked");
2358 std::fs::write(&blocked, b"a file, not a dir").unwrap();
2359 let path = blocked.join("config.toml");
2360 let cfg = config::shared(Config::default());
2361 let logs = crate::test_support::capture({
2362 let cfg = cfg.clone();
2363 move || {
2364 let token = ensure_local_api_token(&cfg, &path);
2365 assert_eq!(token.len(), 64);
2366 }
2367 });
2368 assert!(
2369 logs.contains("failed to persist the local api token"),
2370 "a failed persist must warn: {logs}"
2371 );
2372 assert!(
2373 cfg.lock().local_api_token.is_some(),
2374 "the in-memory token must survive the failed persist"
2375 );
2376 }
2377}