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>) -> 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 => return Ok(()),
590 };
591 log_startup_banner(&cfg, &path);
592
593 let control = crate::control::DaemonControl::new(
594 config::shared(cfg),
595 path,
596 sys::detect_vram_gb().unwrap_or(0.0),
597 );
598 let busy = Arc::new(AtomicBool::new(false));
599 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
600 let observers = WorkerObservers::with_global_worker_log();
601
602 let stop_clone = control.stop.clone();
603 tokio::spawn(async move {
604 let signal = wait_for_shutdown_signal().await;
605 request_shutdown(&stop_clone, signal);
606 });
607
608 let gate = crate::job_gate::JobGate::from_shared(busy.clone());
613 let local_api = spawn_local_api(&control, observers.clone(), gate);
614
615 let outcome = serve_studio(&control, logs, busy, observers, LoopSchedule::default()).await;
616
617 control.stop.store(true, Ordering::SeqCst);
619 if let Some(handle) = local_api {
620 let _ = handle.join();
621 }
622 outcome
623}
624
625pub const REGISTRATION_RESET_POLL: Duration = Duration::from_millis(250);
627
628pub async fn serve_studio(
632 control: &crate::control::DaemonControl,
633 logs: Arc<Mutex<Vec<LogEntry>>>,
634 busy: Arc<AtomicBool>,
635 observers: WorkerObservers,
636 schedule: LoopSchedule,
637) -> Result<()> {
638 loop {
639 match ensure_registered(
640 &control.cfg,
641 &control.config_path,
642 &control.registration,
643 &control.stop,
644 )
645 .await
646 {
647 Ok(RegistrationGate::Stopped) => {
648 info!(
649 target: TRACE_TARGET,
650 op = "shutdown",
651 "stopped before registration completed; exiting cleanly"
652 );
653 return Ok(());
654 }
655 Ok(RegistrationGate::Ready) => {
656 return run_loops(
657 control.cfg.clone(),
658 control.stop.clone(),
659 logs,
660 busy,
661 control.paused.clone(),
662 observers,
663 schedule,
664 )
665 .await;
666 }
667 Err(err) => {
668 tracing::error!(
669 target: TRACE_TARGET,
670 op = "registration",
671 error = %err,
672 "studio registration rejected; the local API keeps serving; \
673 reset the registration from the tray UI to ask again"
674 );
675 if !wait_for_registration_reset(control).await {
676 return Ok(());
677 }
678 reset_registration(control)?;
679 }
680 }
681 }
682}
683
684async fn wait_for_registration_reset(control: &crate::control::DaemonControl) -> bool {
686 loop {
687 if control.stop.load(Ordering::SeqCst) {
688 return false;
689 }
690 if control.reset_requested.swap(false, Ordering::SeqCst) {
691 return true;
692 }
693 tokio::time::sleep(REGISTRATION_RESET_POLL).await;
694 }
695}
696
697pub fn clear_registration(cfg: &mut Config) {
699 cfg.worker_id = None;
700 cfg.auth_token = None;
701 cfg.registration_request_id = None;
702 cfg.registration_secret = None;
703 cfg.install_id = None;
704}
705
706fn reset_registration(control: &crate::control::DaemonControl) -> Result<()> {
709 let snapshot = {
710 let mut cfg = control.cfg.lock();
711 clear_registration(&mut cfg);
712 cfg.clone()
713 };
714 config::save(&snapshot, &control.config_path)?;
715 *control.registration.lock() = crate::auto_register::RegistrationState::Pristine;
716 info!(
717 target: TRACE_TARGET,
718 op = "registration",
719 "registration reset; asking the studio again"
720 );
721 Ok(())
722}
723
724pub fn request_shutdown(stop: &AtomicBool, signal: &str) {
730 let already_stopping = stop.swap(true, Ordering::SeqCst);
731 info!(
732 target: TRACE_TARGET,
733 op = "shutdown",
734 signal,
735 already_stopping,
736 "shutdown signal received; stopping worker gracefully"
737 );
738}
739
740#[cfg_attr(coverage_nightly, coverage(off))]
756async fn wait_for_shutdown_signal() -> &'static str {
757 #[cfg(unix)]
758 {
759 use tokio::signal::unix::{signal, SignalKind};
760 let mut sigterm = match signal(SignalKind::terminate()) {
761 Ok(s) => s,
762 Err(e) => {
763 warn!(
764 target: TRACE_TARGET,
765 op = "shutdown",
766 error = %e,
767 "could not install SIGTERM handler; falling back to Ctrl-C only"
768 );
769 let _ = tokio::signal::ctrl_c().await;
770 return "SIGINT";
771 }
772 };
773 tokio::select! {
774 _ = tokio::signal::ctrl_c() => "SIGINT",
775 _ = sigterm.recv() => "SIGTERM",
776 }
777 }
778 #[cfg(not(unix))]
779 {
780 let _ = tokio::signal::ctrl_c().await;
781 "ctrl-c"
782 }
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
798pub enum RegistrationGate {
799 Ready,
802 Stopped,
804}
805
806pub async fn ensure_registered(
811 cfg: &SharedConfig,
812 path: &std::path::Path,
813 registration: &crate::auto_register::SharedRegistration,
814 stop: &Arc<AtomicBool>,
815) -> Result<RegistrationGate> {
816 use std::time::Duration;
817 loop {
818 if stop.load(Ordering::SeqCst) {
819 return Ok(RegistrationGate::Stopped);
820 }
821 {
822 let snap = cfg.lock();
823 if snap.worker_id.is_some() && snap.auth_token.is_some() {
824 return Ok(RegistrationGate::Ready);
825 }
826 }
827 let state = crate::auto_register::tick(cfg, path, registration).await;
828 match state {
829 crate::auto_register::RegistrationState::Approved => {
830 return Ok(RegistrationGate::Ready)
831 }
832 crate::auto_register::RegistrationState::Rejected { reason } => {
833 return Err(anyhow!(
834 "registration rejected by the studio operator: {reason}. \
835 Run `studio-worker register --reset` to clear local state \
836 and submit a fresh request."
837 ));
838 }
839 _ => {}
840 }
841 for _ in 0..30 {
843 if stop.load(Ordering::SeqCst) {
844 return Ok(RegistrationGate::Stopped);
845 }
846 tokio::time::sleep(Duration::from_secs(1)).await;
847 }
848 }
849}
850
851pub async fn run_loops(
859 cfg: SharedConfig,
860 stop: Arc<AtomicBool>,
861 logs: Arc<Mutex<Vec<LogEntry>>>,
862 busy: Arc<AtomicBool>,
863 paused: Arc<AtomicBool>,
864 observers: WorkerObservers,
865 schedule: LoopSchedule,
866) -> Result<()> {
867 let session = crate::ws::session::spawn_ws_session(
868 cfg.clone(),
869 stop.clone(),
870 logs.clone(),
871 busy.clone(),
872 paused.clone(),
873 observers.clone(),
874 schedule.ws_session,
875 );
876 let auto_updater = spawn_auto_updater(
877 cfg.clone(),
878 stop.clone(),
879 logs.clone(),
880 busy.clone(),
881 schedule,
882 );
883 let (session_result, _) = tokio::join!(session, auto_updater);
884 session_result
885}
886
887pub const DEFAULT_LOCAL_API_PORT: u16 = 4787;
890
891pub fn resolve_local_api_port(env_value: Option<&str>, cfg_port: Option<u16>) -> u16 {
898 resolve_port(
899 "STUDIO_WORKER_LOCAL_API_PORT",
900 env_value,
901 cfg_port,
902 DEFAULT_LOCAL_API_PORT,
903 )
904}
905
906pub fn resolve_stream_port(env_value: Option<&str>, cfg_port: Option<u16>) -> u16 {
909 resolve_port(
910 "STUDIO_WORKER_STREAM_PORT",
911 env_value,
912 cfg_port,
913 crate::stt_stream::server::DEFAULT_STREAM_PORT,
914 )
915}
916
917fn resolve_port(
919 env_name: &str,
920 env_value: Option<&str>,
921 cfg_port: Option<u16>,
922 default: u16,
923) -> u16 {
924 let fallback = cfg_port.unwrap_or(default);
925 match env_value {
926 Some(raw) => match raw.parse::<u16>() {
927 Ok(port) => port,
928 Err(_) => {
929 warn!(
930 target: "studio_worker::local_api",
931 op = "resolve_port",
932 invalid = raw,
933 fallback,
934 "{env_name} is not a valid port; falling back"
935 );
936 fallback
937 }
938 },
939 None => fallback,
940 }
941}
942
943pub fn ensure_local_api_token(cfg: &SharedConfig, config_path: &std::path::Path) -> String {
948 let mut snap = cfg.lock();
949 if let Some(token) = snap.local_api_token.clone() {
950 return token;
951 }
952 let token = crate::secrets::new_secret_hex();
953 snap.local_api_token = Some(token.clone());
954 let snapshot = snap.clone();
955 drop(snap);
956 if let Err(e) = config::save(&snapshot, config_path) {
957 warn!(
958 target: "studio_worker::local_api",
959 op = "ensure_token",
960 config_path = %config_path.display(),
961 error = %e,
962 "failed to persist the local api token; a fresh one will be minted next launch"
963 );
964 }
965 token
966}
967
968#[cfg_attr(coverage_nightly, coverage(off))]
972fn spawn_stream_listener(
973 cfg: &SharedConfig,
974 services: &crate::local_api::ModelServices,
975 observers: &WorkerObservers,
976 stop: Arc<AtomicBool>,
977) {
978 let port = resolve_stream_port(
979 std::env::var("STUDIO_WORKER_STREAM_PORT").ok().as_deref(),
980 cfg.lock().stream_port,
981 );
982 let addr = format!("0.0.0.0:{port}");
983 match crate::stt_stream::server::StreamServer::bind(
984 &addr,
985 services.host.clone(),
986 services.tokens.clone(),
987 observers.clone(),
988 ) {
989 Ok(server) => {
990 let bound = server.local_addr().port();
991 services.stream_port.store(bound, Ordering::SeqCst);
992 tracing::info!(target: "studio_worker::stt_stream", addr = %server.local_addr(), "stream listener listening");
993 std::thread::spawn(move || server.serve(&stop));
994 }
995 Err(err) => tracing::warn!(
996 target: "studio_worker::stt_stream",
997 %addr,
998 error = %err,
999 "stream listener could not bind; streaming speech is unavailable"
1000 ),
1001 }
1002}
1003
1004pub fn spawn_local_api(
1008 control: &crate::control::DaemonControl,
1009 observers: WorkerObservers,
1010 gate: crate::job_gate::JobGate,
1011) -> Option<std::thread::JoinHandle<()>> {
1012 let cfg = control.cfg.clone();
1013 let config_path = control.config_path.as_path();
1014 let stop = control.stop.clone();
1015 let engine: Arc<dyn crate::engine::Engine> = match crate::engine::build(&cfg.lock()) {
1016 Ok(engine) => engine.into(),
1017 Err(err) => {
1018 tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: engine build failed");
1019 return None;
1020 }
1021 };
1022
1023 let (loaded, catalog_path) =
1029 crate::catalog::Catalog::load_for_serving(crate::config::catalog_path_for(config_path));
1030 *observers.catalog.lock() = loaded;
1031 *observers.catalog_path.lock() = catalog_path.clone();
1032 let catalog = observers.catalog.clone();
1033
1034 let token = ensure_local_api_token(&cfg, config_path);
1035 let port = resolve_local_api_port(
1036 std::env::var("STUDIO_WORKER_LOCAL_API_PORT")
1037 .ok()
1038 .as_deref(),
1039 cfg.lock().local_api_port,
1040 );
1041
1042 let models_root = Some(cfg.lock().models_root.clone());
1043 let host = crate::host::ModelHost::new(
1044 catalog.clone(),
1045 Arc::new(crate::loaders::Loaders::new(cfg.lock().models_root.clone())),
1046 Arc::new(crate::admission::SystemProbe),
1047 crate::residency::Residency::load_for_serving(crate::config::residency_path_for(
1048 config_path,
1049 )),
1050 );
1051 let services = crate::local_api::ModelServices::new(host.clone());
1052 spawn_stream_listener(&cfg, &services, &observers, stop.clone());
1053 let api = crate::local_api::LocalApi::bind(
1054 &format!("127.0.0.1:{port}"),
1055 engine.clone(),
1056 catalog.clone(),
1057 catalog_path.clone(),
1058 observers.clone(),
1059 token.clone(),
1060 gate.clone(),
1061 models_root.clone(),
1062 services.clone(),
1063 )
1064 .or_else(|_| {
1065 crate::local_api::LocalApi::bind(
1066 "127.0.0.1:0",
1067 engine,
1068 catalog,
1069 catalog_path,
1070 observers.clone(),
1071 token.clone(),
1072 gate.clone(),
1073 models_root,
1074 services.clone(),
1075 )
1076 });
1077
1078 let api = match api {
1079 Ok(api) => api.with_control(control.clone()),
1080 Err(err) => {
1081 tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: bind failed");
1082 return None;
1083 }
1084 };
1085
1086 set_gpu_runtime_status(
1090 &observers,
1091 crate::engine::sd_provision::vulkan_runtime_status(),
1092 );
1093
1094 host.restore_residents();
1096
1097 let url = api.url();
1098 *observers.local_api_url.lock() = Some(url.clone());
1099 tracing::info!(target: "studio_worker::local_api", url = %url, "local image API listening");
1100
1101 let discovery_path = crate::config::local_api_discovery_path_for(config_path);
1104 if let Some(path) = &discovery_path {
1105 if let Err(e) = crate::local_api::write_discovery_file(path, &url, &token) {
1106 tracing::warn!(
1107 target: "studio_worker::local_api",
1108 error = %e,
1109 path = %path.display(),
1110 "failed to write the local api discovery file"
1111 );
1112 }
1113 }
1114
1115 Some(std::thread::spawn(move || {
1116 api.serve(&stop);
1117 if let Some(path) = &discovery_path {
1118 crate::local_api::remove_discovery_file(path);
1119 }
1120 }))
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Eq)]
1134pub enum AutoUpdateDecision {
1135 Disabled,
1137 SkippedBusy,
1139 UpToDate,
1141 CheckError(String),
1143 Updated,
1145 UpdateError(String),
1147}
1148
1149pub async fn auto_update_tick(
1150 cfg: &Config,
1151 gate: &crate::job_gate::JobGate,
1152 logs: &Arc<Mutex<Vec<LogEntry>>>,
1153) -> AutoUpdateDecision {
1154 if !cfg.auto_update_enabled {
1155 return AutoUpdateDecision::Disabled;
1156 }
1157 if gate.is_busy() {
1161 push_log(
1162 logs,
1163 "info",
1164 "auto-update",
1165 "skipping check: worker is busy on a job",
1166 None,
1167 );
1168 return AutoUpdateDecision::SkippedBusy;
1169 }
1170 let feed = cfg.auto_update_feed.clone();
1171 let prerelease = cfg.auto_update_prerelease;
1172 let logs_for_task = logs.clone();
1173 let gate = gate.clone();
1174 let outcome = tokio::task::spawn_blocking(move || -> Result<AutoUpdateDecision> {
1175 let current = semver::Version::parse(AGENT_VERSION)
1176 .map_err(|e| anyhow!("invalid AGENT_VERSION {AGENT_VERSION}: {e}"))?;
1177 match update::check(&feed, ¤t, prerelease) {
1178 Ok(update::CheckOutcome::UpToDate { current }) => {
1179 push_log(
1180 &logs_for_task,
1181 "info",
1182 "auto-update",
1183 &format!("up to date at {current}"),
1184 None,
1185 );
1186 Ok(AutoUpdateDecision::UpToDate)
1187 }
1188 Ok(update::CheckOutcome::NewerAvailable { current, latest }) => {
1189 let Some(_reservation) = gate.try_reserve() else {
1196 push_log(
1197 &logs_for_task,
1198 "info",
1199 "auto-update",
1200 "update available but a job started; deferring install",
1201 None,
1202 );
1203 return Ok(AutoUpdateDecision::SkippedBusy);
1204 };
1205 push_log(
1206 &logs_for_task,
1207 "info",
1208 "auto-update",
1209 &format!("update available {current} -> {latest}; applying"),
1210 None,
1211 );
1212 match update::apply(&feed, &latest) {
1213 Ok(()) => {
1214 push_log(
1215 &logs_for_task,
1216 "info",
1217 "auto-update",
1218 "binary replaced; restart pending",
1219 None,
1220 );
1221 Ok(AutoUpdateDecision::Updated)
1222 }
1223 Err(e) => {
1224 push_log(
1225 &logs_for_task,
1226 "error",
1227 "auto-update",
1228 &format!("update failed: {e}"),
1229 None,
1230 );
1231 Ok(AutoUpdateDecision::UpdateError(e.to_string()))
1232 }
1233 }
1234 }
1235 Err(e) => {
1236 push_log(
1237 &logs_for_task,
1238 "warn",
1239 "auto-update",
1240 &format!("check failed: {e}"),
1241 None,
1242 );
1243 Ok(AutoUpdateDecision::CheckError(e.to_string()))
1244 }
1245 }
1246 })
1247 .await;
1248 match outcome {
1249 Ok(Ok(decision)) => decision,
1250 Ok(Err(e)) => AutoUpdateDecision::CheckError(e.to_string()),
1251 Err(e) => AutoUpdateDecision::CheckError(e.to_string()),
1252 }
1253}
1254
1255pub(crate) async fn wait_with_stop(total: Duration, stop: &Arc<AtomicBool>, tick: Duration) {
1270 let mut elapsed = Duration::ZERO;
1271 while elapsed < total {
1272 if stop.load(Ordering::SeqCst) {
1273 return;
1274 }
1275 let next = tick.min(total - elapsed);
1276 tokio::time::sleep(next).await;
1277 elapsed += next;
1278 }
1279}
1280
1281pub fn spawn_auto_updater(
1282 cfg: SharedConfig,
1283 stop: Arc<AtomicBool>,
1284 logs: Arc<Mutex<Vec<LogEntry>>>,
1285 busy: Arc<AtomicBool>,
1286 schedule: LoopSchedule,
1287) -> tokio::task::JoinHandle<()> {
1288 tokio::spawn(async move {
1289 let mut elapsed = Duration::from_secs(0);
1290 while !stop.load(Ordering::SeqCst) {
1291 wait_with_stop(schedule.auto_update_tick, &stop, schedule.shutdown_tick).await;
1296 if stop.load(Ordering::SeqCst) {
1297 break;
1298 }
1299 elapsed += schedule.auto_update_tick;
1300 let snapshot = cfg.lock().clone();
1301 if elapsed < Duration::from_secs(snapshot.auto_update_interval_secs) {
1302 continue;
1303 }
1304 elapsed = Duration::from_secs(0);
1305 let gate = crate::job_gate::JobGate::from_shared(busy.clone());
1306 let decision = auto_update_tick(&snapshot, &gate, &logs).await;
1307 if matches!(decision, AutoUpdateDecision::Updated) {
1308 stop.store(true, Ordering::SeqCst);
1309 update::restart_self();
1310 }
1311 }
1312 })
1313}
1314
1315pub fn prompt_for(task: &Task) -> String {
1319 match task {
1320 Task::Image(p) => p.prompt.clone(),
1321 Task::Llm(p) => p
1322 .messages
1323 .last()
1324 .map(|m| m.content.clone())
1325 .unwrap_or_default(),
1326 Task::AudioStt(p) => p.input_url.clone(),
1327 Task::AudioTts(p) => p.text.clone(),
1328 Task::Video(p) => p.prompt.clone(),
1329 }
1330}
1331
1332pub fn is_unsupported_kind(e: &anyhow::Error) -> bool {
1333 e.chain().any(|cause| {
1337 cause
1338 .downcast_ref::<crate::engine::UnsupportedTask>()
1339 .is_some()
1340 }) || e.to_string().contains("cannot serve")
1341}
1342
1343pub fn build_capabilities(cfg: &Config, engine: &dyn Engine) -> WorkerCapabilities {
1348 build_capabilities_with(cfg, engine, true)
1349}
1350
1351pub fn build_capabilities_with(
1356 cfg: &Config,
1357 engine: &dyn Engine,
1358 auto_enabled: bool,
1359) -> WorkerCapabilities {
1360 let vram = sys::detect_vram_gb().unwrap_or(0.0);
1361 let caps = engine.capabilities();
1362 let supported_models_per_kind = caps.supported_models_per_kind.clone();
1363 let task_kinds = caps.kinds();
1364 let supported_models = {
1368 let mut all = caps.flat_models();
1369 all.sort();
1370 all.dedup();
1371 all
1372 };
1373
1374 WorkerCapabilities {
1375 machine_name: sys::machine_name(),
1376 username: sys::username(),
1377 agent_version: AGENT_VERSION.to_string(),
1378 engine: engine.name().to_string(),
1379 vram_total_gb: vram,
1380 vram_threshold_gb: cfg.vram_threshold_gb,
1381 auto_enabled,
1382 auto_start: cfg!(feature = "ui"),
1385 supported_models,
1386 task_kinds,
1387 supported_models_per_kind,
1388 }
1389}
1390
1391pub fn summarize_capabilities(caps: &WorkerCapabilities) -> String {
1401 let kinds = caps
1402 .task_kinds
1403 .iter()
1404 .map(|k| k.as_str())
1405 .collect::<Vec<_>>()
1406 .join(", ");
1407 format!(
1408 "advertising engine={}, vram={:.1}/{:.1}GB threshold, auto_enabled={}, \
1409 kinds=[{}], {} model(s)=[{}]",
1410 caps.engine,
1411 caps.vram_total_gb,
1412 caps.vram_threshold_gb,
1413 caps.auto_enabled,
1414 kinds,
1415 caps.supported_models.len(),
1416 caps.supported_models.join(", "),
1417 )
1418}
1419
1420pub fn vram_threshold_warning(caps: &WorkerCapabilities) -> Option<String> {
1440 if caps.vram_total_gb > 0.0 && caps.vram_threshold_gb > caps.vram_total_gb {
1441 Some(format!(
1442 "configured VRAM threshold {:.1}GB exceeds detected GPU VRAM {:.1}GB; \
1443 the studio may offer jobs larger than this card can fit and they will \
1444 OOM on load — lower vram_threshold_gb to at or below {:.1}GB",
1445 caps.vram_threshold_gb, caps.vram_total_gb, caps.vram_total_gb
1446 ))
1447 } else {
1448 None
1449 }
1450}
1451
1452pub fn push_log(
1453 logs: &Arc<Mutex<Vec<LogEntry>>>,
1454 level: &str,
1455 category: &str,
1456 message: &str,
1457 job_id: Option<String>,
1458) {
1459 push_log_with_observers(logs, None, level, category, message, job_id);
1460}
1461
1462pub fn push_log_with_observers(
1468 logs: &Arc<Mutex<Vec<LogEntry>>>,
1469 observers: Option<&WorkerObservers>,
1470 level: &str,
1471 category: &str,
1472 message: &str,
1473 job_id: Option<String>,
1474) {
1475 let entry = LogEntry {
1476 ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1477 level: level.to_string(),
1478 category: category.to_string(),
1479 message: message.to_string(),
1480 job_id,
1481 };
1482 let job_id = entry.job_id.as_deref();
1487 if level == "error" {
1488 tracing::error!(target: "studio_worker", job_id, "[{category}] {message}");
1489 } else if level == "warn" {
1490 tracing::warn!(target: "studio_worker", job_id, "[{category}] {message}");
1491 } else {
1492 info!(target: "studio_worker", job_id, "[{category}] {message}");
1493 }
1494 {
1495 let mut queue = logs.lock();
1496 if queue.len() >= LOG_SHIP_QUEUE_CAP {
1497 let overflow = queue.len() + 2 - LOG_SHIP_QUEUE_CAP;
1499 queue.drain(0..overflow);
1500 queue.push(LogEntry {
1501 ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1502 level: "warn".to_string(),
1503 category: "logs".to_string(),
1504 message: format!(
1505 "ship queue full ({LOG_SHIP_QUEUE_CAP} entries); dropped {overflow} oldest"
1506 ),
1507 job_id: None,
1508 });
1509 }
1510 queue.push(entry.clone());
1511 }
1512 if let Some(o) = observers {
1513 o.worker_log().push(entry);
1514 }
1515}
1516
1517pub fn restore_unshipped(logs: &Arc<Mutex<Vec<LogEntry>>>, mut batch: Vec<LogEntry>) {
1523 let mut queue = logs.lock();
1524 batch.append(&mut queue);
1525 *queue = batch;
1526 if queue.len() > LOG_SHIP_QUEUE_CAP {
1527 let overflow = queue.len() - LOG_SHIP_QUEUE_CAP;
1528 queue.drain(0..overflow);
1529 }
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use super::*;
1535 use crate::config::Config;
1536 use crate::engine::SyntheticEngine;
1537
1538 fn push_messages(observers: &WorkerObservers, count: usize) {
1539 let logs = Arc::new(Mutex::new(Vec::new()));
1540 for i in 0..count {
1541 push_log_with_observers(&logs, Some(observers), "info", "t", &format!("m{i}"), None);
1542 }
1543 }
1544
1545 #[test]
1546 fn recent_logs_after_answers_only_newer_entries() {
1547 let observers = WorkerObservers::default();
1548 push_messages(&observers, 3);
1549 let (all, newest) = recent_logs_after(&observers, 0);
1550 assert_eq!((all.len(), newest), (3, 3));
1551 let (newer, _) = recent_logs_after(&observers, 2);
1552 assert_eq!(newer.len(), 1);
1553 assert_eq!(newer[0].message, "m2");
1554 assert!(recent_logs_after(&observers, 3).0.is_empty());
1555 }
1556
1557 #[test]
1558 fn the_daemons_observers_share_the_global_worker_log() {
1559 let a = WorkerObservers::with_global_worker_log();
1560 let b = WorkerObservers::with_global_worker_log();
1561 assert!(Arc::ptr_eq(&a.recent_logs, &b.recent_logs));
1562 assert!(!Arc::ptr_eq(
1563 &a.recent_logs,
1564 &WorkerObservers::default().recent_logs
1565 ));
1566 }
1567
1568 #[test]
1569 fn recent_logs_after_a_restart_answers_the_whole_ring() {
1570 let observers = WorkerObservers::default();
1571 push_messages(&observers, 2);
1572 assert_eq!(recent_logs_after(&observers, 99).0.len(), 2);
1573 }
1574
1575 #[test]
1576 fn recent_logs_after_skips_entries_that_left_the_ring() {
1577 let observers = WorkerObservers::default();
1578 push_messages(&observers, RECENT_LOGS_CAP + 5);
1579 let (all, newest) = recent_logs_after(&observers, 0);
1580 assert_eq!(all.len(), RECENT_LOGS_CAP);
1581 assert_eq!(all[0].message, "m5");
1582 assert_eq!(newest, (RECENT_LOGS_CAP + 5) as u64);
1583 }
1584
1585 #[test]
1586 fn is_unsupported_kind_detects_typed_unsupported_task() {
1587 let err: anyhow::Error =
1588 crate::engine::UnsupportedTask::new("synthetic", TaskKind::Llm).into();
1589 assert!(is_unsupported_kind(&err));
1590 assert!(err.to_string().contains("cannot serve llm"));
1592 }
1593
1594 #[test]
1595 fn is_unsupported_kind_survives_context_wrapping() {
1596 let err = anyhow::Error::from(crate::engine::UnsupportedTask::new(
1600 "sdcpp",
1601 TaskKind::AudioTts,
1602 ))
1603 .context("dispatching job j-1");
1604 assert!(is_unsupported_kind(&err));
1605 }
1606
1607 fn entry(message: &str) -> LogEntry {
1608 LogEntry {
1609 ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1610 level: "info".into(),
1611 category: "test".into(),
1612 message: message.into(),
1613 job_id: None,
1614 }
1615 }
1616
1617 #[test]
1618 fn restore_unshipped_requeues_batch_ahead_of_newer_entries() {
1619 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(vec![entry("newer")]));
1623 restore_unshipped(&logs, vec![entry("batch-1"), entry("batch-2")]);
1624 let queue = logs.lock();
1625 let order: Vec<&str> = queue.iter().map(|e| e.message.as_str()).collect();
1626 assert_eq!(order, vec!["batch-1", "batch-2", "newer"]);
1627 }
1628
1629 #[test]
1630 fn restore_unshipped_respects_the_queue_cap() {
1631 let logs: Arc<Mutex<Vec<LogEntry>>> =
1634 Arc::new(Mutex::new(vec![entry("newest"); LOG_SHIP_QUEUE_CAP]));
1635 restore_unshipped(&logs, vec![entry("old-batch"); 100]);
1636 let queue = logs.lock();
1637 assert_eq!(queue.len(), LOG_SHIP_QUEUE_CAP);
1638 assert_eq!(
1639 queue.last().map(|e| e.message.as_str()),
1640 Some("newest"),
1641 "newest entries must survive the cap"
1642 );
1643 }
1644
1645 #[test]
1646 fn ship_queue_is_bounded_and_records_dropped_entries() {
1647 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1651 for i in 0..(LOG_SHIP_QUEUE_CAP + 100) {
1652 push_log_with_observers(&logs, None, "info", "test", &format!("entry {i}"), None);
1653 }
1654 let queue = logs.lock();
1655 assert!(
1656 queue.len() <= LOG_SHIP_QUEUE_CAP,
1657 "ship queue exceeded its cap: {}",
1658 queue.len()
1659 );
1660 assert_eq!(
1662 queue.last().map(|e| e.message.as_str()),
1663 Some(format!("entry {}", LOG_SHIP_QUEUE_CAP + 99).as_str())
1664 );
1665 assert!(
1667 queue
1668 .iter()
1669 .any(|e| e.level == "warn" && e.message.contains("dropped")),
1670 "overflow must leave a visible drop marker"
1671 );
1672 }
1673
1674 #[test]
1675 fn recent_logs_ring_is_bounded_at_recent_logs_cap() {
1676 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1684 let observers = WorkerObservers::default();
1685 let overflow = 25;
1686 for i in 0..(RECENT_LOGS_CAP + overflow) {
1687 push_log_with_observers(
1688 &logs,
1689 Some(&observers),
1690 "info",
1691 "test",
1692 &format!("entry {i}"),
1693 None,
1694 );
1695 }
1696 let ring = observers.recent_logs.lock();
1697 assert_eq!(
1698 ring.len(),
1699 RECENT_LOGS_CAP,
1700 "the recent-logs ring must cap at RECENT_LOGS_CAP"
1701 );
1702 assert_eq!(
1705 ring.back().map(|e| e.message.as_str()),
1706 Some(format!("entry {}", RECENT_LOGS_CAP + overflow - 1).as_str()),
1707 "the newest entry must survive at the back of the ring"
1708 );
1709 assert_eq!(
1710 ring.front().map(|e| e.message.as_str()),
1711 Some(format!("entry {overflow}").as_str()),
1712 "the oldest surviving entry must be entry #overflow (older evicted)"
1713 );
1714 }
1715
1716 #[test]
1717 fn session_state_summaries_carry_recovery_actions_for_terminal_states() {
1718 assert!(SessionState::default() == SessionState::WaitingForApproval);
1719 assert!(SessionState::Connected.summary().contains("connected"));
1720 assert!(SessionState::Reconnecting { attempt: 3 }
1721 .summary()
1722 .contains("attempt 3"));
1723 let auth = SessionState::AuthFailed {
1726 reason: "bad token".into(),
1727 }
1728 .summary();
1729 assert!(auth.contains("register --reset"), "got: {auth}");
1730 assert!(auth.contains("bad token"));
1731 assert!(SessionState::Fatal {
1732 reason: "boom".into()
1733 }
1734 .summary()
1735 .contains("boom"));
1736 }
1737
1738 #[test]
1739 fn set_session_state_updates_the_observer_slot() {
1740 let observers = WorkerObservers::default();
1741 assert_eq!(
1742 *observers.session_state.lock(),
1743 SessionState::WaitingForApproval
1744 );
1745 set_session_state(&observers, SessionState::Connected);
1746 assert_eq!(*observers.session_state.lock(), SessionState::Connected);
1747 }
1748
1749 #[test]
1750 fn sync_studio_model_mirrors_into_the_shared_catalog_and_persists() {
1751 use crate::types::{ModelCliDefaults, ModelEngine, ModelSource};
1752 let dir = tempfile::tempdir().unwrap();
1753 let path = dir.path().join("models.json");
1754 let observers = WorkerObservers::default();
1755 *observers.catalog_path.lock() = Some(path.clone());
1756
1757 let source = ModelSource {
1758 engine: ModelEngine::Synthetic,
1759 files: vec![],
1760 cli_defaults: ModelCliDefaults::default(),
1761 };
1762 sync_studio_model(&observers, "studio-llm", TaskKind::Llm, &source);
1763
1764 assert!(observers.catalog.lock().get("studio-llm").is_some());
1766 assert_eq!(
1767 observers.catalog.lock().get("studio-llm").unwrap().origin,
1768 "studio"
1769 );
1770 let reloaded = crate::catalog::Catalog::load_or_seed(&path).unwrap();
1772 assert!(reloaded.get("studio-llm").is_some());
1773 }
1774
1775 #[test]
1776 fn sync_studio_model_without_a_path_stays_in_memory_only() {
1777 use crate::types::{ModelCliDefaults, ModelEngine, ModelSource};
1778 let observers = WorkerObservers::default();
1779 let source = ModelSource {
1782 engine: ModelEngine::Synthetic,
1783 files: vec![],
1784 cli_defaults: ModelCliDefaults::default(),
1785 };
1786 sync_studio_model(&observers, "m", TaskKind::Image, &source);
1787 assert!(observers.catalog.lock().get("m").is_some());
1788 }
1789
1790 #[test]
1791 fn set_gpu_runtime_status_records_ok_without_warning() {
1792 let observers = WorkerObservers::default();
1793 assert!(observers.gpu_runtime.lock().is_none(), "unprobed at first");
1794 let out = crate::test_support::capture({
1795 let observers = observers.clone();
1796 move || set_gpu_runtime_status(&observers, Ok(()))
1797 });
1798 let status = observers.gpu_runtime.lock().clone().unwrap();
1799 assert!(status.ok);
1800 assert!(status.detail.contains("available"));
1801 assert!(
1802 !out.contains("GPU runtime missing"),
1803 "the ok path must not warn: {out}"
1804 );
1805 }
1806
1807 #[test]
1808 fn set_gpu_runtime_status_records_and_warns_the_remedy_when_missing() {
1809 let observers = WorkerObservers::default();
1810 let out = crate::test_support::capture({
1811 let observers = observers.clone();
1812 move || {
1813 set_gpu_runtime_status(
1814 &observers,
1815 Err(anyhow!("Vulkan runtime not available: install libvulkan1")),
1816 )
1817 }
1818 });
1819 let status = observers.gpu_runtime.lock().clone().unwrap();
1820 assert!(!status.ok);
1821 assert!(
1822 status.detail.contains("libvulkan1"),
1823 "got: {}",
1824 status.detail
1825 );
1826 assert!(
1827 out.contains("GPU runtime missing") && out.contains("WARN"),
1828 "a missing runtime must warn with the remedy: {out}"
1829 );
1830 }
1831
1832 #[test]
1833 fn capabilities_advertises_all_synthetic_kinds() {
1834 let cfg = Config::default();
1835 let engine = SyntheticEngine::new();
1836 let cap = build_capabilities(&cfg, &engine);
1837 assert_eq!(cap.engine, "synthetic");
1838 assert_eq!(cap.task_kinds.len(), TaskKind::ALL.len());
1839 assert!(cap.auto_enabled, "default capability snapshot is unpaused");
1840 for kind in TaskKind::ALL {
1841 assert!(cap.supported_models_per_kind.contains_key(&kind));
1842 }
1843 }
1844
1845 #[test]
1846 fn capabilities_with_paused_flag_drives_auto_enabled() {
1847 let cfg = Config::default();
1848 let engine = SyntheticEngine::new();
1849 let paused_caps = build_capabilities_with(&cfg, &engine, false);
1850 assert!(!paused_caps.auto_enabled);
1851 }
1852
1853 #[test]
1854 fn summarize_capabilities_lists_engine_kinds_models_vram_and_pause_state() {
1855 let cfg = Config {
1856 vram_threshold_gb: 6.0,
1857 ..Config::default()
1858 };
1859 let engine = SyntheticEngine::new();
1860 let caps = build_capabilities_with(&cfg, &engine, true);
1861 let summary = summarize_capabilities(&caps);
1862 assert!(summary.contains("engine=synthetic"), "got: {summary}");
1864 for kind in &caps.task_kinds {
1865 assert!(
1866 summary.contains(kind.as_str()),
1867 "missing kind {} in: {summary}",
1868 kind.as_str()
1869 );
1870 }
1871 assert!(
1873 summary.contains(&format!("{} model(s)", caps.supported_models.len())),
1874 "missing model count in: {summary}"
1875 );
1876 assert!(
1877 summary.contains("synthetic"),
1878 "missing model id in: {summary}"
1879 );
1880 assert!(
1882 summary.contains("6.0"),
1883 "missing vram threshold in: {summary}"
1884 );
1885 assert!(summary.contains("auto_enabled=true"), "got: {summary}");
1886 }
1887
1888 #[test]
1889 fn summarize_capabilities_reflects_paused_state() {
1890 let cfg = Config::default();
1891 let engine = SyntheticEngine::new();
1892 let caps = build_capabilities_with(&cfg, &engine, false);
1893 assert!(
1894 summarize_capabilities(&caps).contains("auto_enabled=false"),
1895 "paused worker must advertise auto_enabled=false"
1896 );
1897 }
1898
1899 fn caps_with_vram(total_gb: f32, threshold_gb: f32) -> WorkerCapabilities {
1903 let mut caps = build_capabilities_with(&Config::default(), &SyntheticEngine::new(), true);
1904 caps.vram_total_gb = total_gb;
1905 caps.vram_threshold_gb = threshold_gb;
1906 caps
1907 }
1908
1909 #[test]
1910 fn vram_threshold_warning_flags_threshold_above_detected_vram() {
1911 let warning = vram_threshold_warning(&caps_with_vram(8.0, 12.0))
1916 .expect("threshold above detected VRAM must warn");
1917 assert!(warning.contains("12.0"), "missing threshold in: {warning}");
1918 assert!(
1919 warning.contains("8.0"),
1920 "missing detected VRAM in: {warning}"
1921 );
1922 assert!(
1923 warning.contains("vram_threshold_gb"),
1924 "must name the config key to change: {warning}"
1925 );
1926 }
1927
1928 #[test]
1929 fn vram_threshold_warning_silent_when_threshold_within_detected_vram() {
1930 assert!(vram_threshold_warning(&caps_with_vram(24.0, 12.0)).is_none());
1932 }
1933
1934 #[test]
1935 fn vram_threshold_warning_silent_when_threshold_equals_detected() {
1936 assert!(vram_threshold_warning(&caps_with_vram(12.0, 12.0)).is_none());
1939 }
1940
1941 #[test]
1942 fn vram_threshold_warning_silent_when_vram_undetected() {
1943 assert!(vram_threshold_warning(&caps_with_vram(0.0, 12.0)).is_none());
1948 }
1949
1950 #[test]
1951 fn prompt_for_extracts_per_kind() {
1952 let image = Task::Image(ImageParams {
1953 prompt: "a stone golem".into(),
1954 ..Default::default()
1955 });
1956 assert_eq!(prompt_for(&image), "a stone golem");
1957
1958 let llm = Task::Llm(LlmParams {
1959 messages: vec![
1960 ChatMessage {
1961 role: "system".into(),
1962 content: "be helpful".into(),
1963 },
1964 ChatMessage {
1965 role: "user".into(),
1966 content: "hi".into(),
1967 },
1968 ],
1969 max_tokens: 32,
1970 temperature: 0.5,
1971 ..Default::default()
1972 });
1973 assert_eq!(prompt_for(&llm), "hi");
1974
1975 let llm_empty = Task::Llm(LlmParams {
1976 messages: vec![],
1977 ..Default::default()
1978 });
1979 assert_eq!(prompt_for(&llm_empty), "");
1980
1981 let stt = Task::AudioStt(AudioSttParams {
1982 input_url: "https://example.com/clip.wav".into(),
1983 ..Default::default()
1984 });
1985 assert_eq!(prompt_for(&stt), "https://example.com/clip.wav");
1986
1987 let tts = Task::AudioTts(AudioTtsParams {
1988 text: "hi there".into(),
1989 voice: "v".into(),
1990 ext: "wav".into(),
1991 ..Default::default()
1992 });
1993 assert_eq!(prompt_for(&tts), "hi there");
1994
1995 let video = Task::Video(VideoParams {
1996 prompt: "a tiny dragon".into(),
1997 seconds: 1.0,
1998 width: 256,
1999 height: 256,
2000 ext: "mp4".into(),
2001 ..Default::default()
2002 });
2003 assert_eq!(prompt_for(&video), "a tiny dragon");
2004 }
2005
2006 #[test]
2007 fn truncate_prompt_passes_short_through_and_clips_long_prompts() {
2008 let short = "a stone golem";
2010 assert_eq!(truncate_prompt(short), short);
2011
2012 let exactly = "x".repeat(PROMPT_PREVIEW_CHARS);
2014 assert_eq!(
2015 truncate_prompt(&exactly),
2016 exactly,
2017 "a prompt exactly at the cap must not be clipped"
2018 );
2019
2020 let over = "y".repeat(PROMPT_PREVIEW_CHARS + 1);
2023 let clipped = truncate_prompt(&over);
2024 assert_eq!(
2025 clipped.chars().count(),
2026 PROMPT_PREVIEW_CHARS + 1,
2027 "clipped preview is the cap plus one ellipsis char"
2028 );
2029 assert!(
2030 clipped.ends_with('\u{2026}'),
2031 "a clipped preview ends with an ellipsis"
2032 );
2033 assert_eq!(
2034 clipped
2035 .chars()
2036 .take(PROMPT_PREVIEW_CHARS)
2037 .collect::<String>(),
2038 "y".repeat(PROMPT_PREVIEW_CHARS),
2039 "the kept prefix is the first PROMPT_PREVIEW_CHARS chars"
2040 );
2041 }
2042
2043 #[test]
2044 fn truncate_prompt_clips_on_char_boundaries_for_multibyte_text() {
2045 let multibyte = "\u{3042}".repeat(PROMPT_PREVIEW_CHARS + 1);
2050 let clipped = truncate_prompt(&multibyte);
2051 assert_eq!(clipped.chars().count(), PROMPT_PREVIEW_CHARS + 1);
2052 assert!(clipped.ends_with('\u{2026}'));
2053 assert_eq!(
2054 clipped.chars().filter(|c| *c == '\u{3042}').count(),
2055 PROMPT_PREVIEW_CHARS,
2056 "exactly PROMPT_PREVIEW_CHARS multibyte chars survive the clip"
2057 );
2058 }
2059
2060 #[test]
2061 fn is_unsupported_kind_matches_engine_message() {
2062 let err = anyhow!("multi engine cannot serve llm tasks");
2063 assert!(is_unsupported_kind(&err));
2064 let other = anyhow!("network timeout");
2065 assert!(!is_unsupported_kind(&other));
2066 }
2067
2068 #[test]
2069 fn format_status_includes_every_field() {
2070 let cfg = Config::default();
2071 let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
2072 assert!(out.contains("config path:"));
2073 assert!(out.contains("api_base_url:"));
2074 assert!(out.contains("registration:"));
2075 assert!(out.contains("not registered"));
2076 assert!(out.contains("models_root:"));
2077 assert!(out.contains("auto_update:"));
2078 assert!(out.contains("update_interval:"));
2079 }
2080
2081 #[test]
2082 fn format_status_shows_worker_id_when_registered() {
2083 let cfg = Config {
2084 worker_id: Some("w-abc".into()),
2085 auth_token: Some("tok".into()),
2086 ..Config::default()
2087 };
2088 let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
2089 assert!(out.contains("w-abc"));
2090 assert!(out.contains("approved"));
2091 }
2092
2093 #[test]
2094 fn format_status_shows_pending_request_id() {
2095 let cfg = Config {
2096 registration_request_id: Some("rr-7".into()),
2097 ..Config::default()
2098 };
2099 let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
2100 assert!(out.contains("pending operator approval"));
2101 assert!(out.contains("rr-7"));
2102 }
2103
2104 #[test]
2105 fn format_check_outcome_handles_both_branches() {
2106 let up = update::CheckOutcome::UpToDate {
2107 current: semver::Version::new(1, 2, 3),
2108 };
2109 assert!(format_check_outcome(&up).contains("up to date"));
2110 let newer = update::CheckOutcome::NewerAvailable {
2111 current: semver::Version::new(1, 2, 3),
2112 latest: semver::Version::new(1, 3, 0),
2113 };
2114 let s = format_check_outcome(&newer);
2115 assert!(s.contains("1.2.3 -> 1.3.0"));
2116 }
2117
2118 #[test]
2119 fn push_log_appends_an_entry() {
2120 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2121 push_log(&logs, "info", "test", "hi", None);
2122 push_log(&logs, "warn", "test", "wat", Some("j-1".into()));
2123 push_log(&logs, "error", "test", "boom", None);
2124 let v = logs.lock();
2125 assert_eq!(v.len(), 3);
2126 assert_eq!(v[0].level, "info");
2127 assert_eq!(v[1].level, "warn");
2128 assert_eq!(v[1].job_id.as_deref(), Some("j-1"));
2129 assert_eq!(v[2].level, "error");
2130 }
2131
2132 #[test]
2133 fn push_log_emits_job_id_as_a_structured_tracing_field() {
2134 use crate::test_support::capture;
2139 let logs = capture(|| {
2140 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2141 push_log(
2142 &logs,
2143 "info",
2144 "ws",
2145 "binary upload ok",
2146 Some("job-42".into()),
2147 );
2148 });
2149 assert!(
2150 logs.contains("job_id=\"job-42\""),
2151 "expected structured job_id field, got: {logs}"
2152 );
2153 assert!(
2154 logs.contains("[ws] binary upload ok"),
2155 "expected the human-readable message to survive, got: {logs}"
2156 );
2157 }
2158
2159 #[test]
2160 fn push_log_omits_job_id_field_when_absent() {
2161 use crate::test_support::capture;
2164 let logs = capture(|| {
2165 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2166 push_log(&logs, "info", "auto-update", "up to date", None);
2167 });
2168 assert!(
2169 !logs.contains("job_id"),
2170 "expected no job_id field for a jobless log, got: {logs}"
2171 );
2172 }
2173
2174 #[test]
2177 fn request_shutdown_sets_the_stop_flag() {
2178 let stop = AtomicBool::new(false);
2179 request_shutdown(&stop, "SIGTERM");
2180 assert!(stop.load(Ordering::SeqCst));
2181 }
2182
2183 #[test]
2184 fn request_shutdown_reconfirms_when_already_stopping() {
2185 let stop = AtomicBool::new(true);
2188 request_shutdown(&stop, "SIGINT");
2189 assert!(stop.load(Ordering::SeqCst));
2190 }
2191
2192 #[test]
2193 fn request_shutdown_emits_a_named_shutdown_breadcrumb() {
2194 use crate::test_support::capture;
2195 let logs = capture(|| {
2196 let stop = AtomicBool::new(false);
2197 request_shutdown(&stop, "SIGTERM");
2198 });
2199 assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
2200 assert!(
2201 logs.contains("studio_worker::runtime"),
2202 "expected runtime target, got: {logs}"
2203 );
2204 assert!(
2205 logs.contains("op=\"shutdown\""),
2206 "expected op field, got: {logs}"
2207 );
2208 assert!(
2209 logs.contains("signal=\"SIGTERM\""),
2210 "expected signal field, got: {logs}"
2211 );
2212 }
2213
2214 #[tokio::test]
2215 async fn auto_update_tick_disabled_when_flag_off() {
2216 let cfg = Config {
2217 auto_update_enabled: false,
2218 ..Config::default()
2219 };
2220 let logs = Arc::new(Mutex::new(Vec::new()));
2221 let decision = auto_update_tick(&cfg, &crate::job_gate::JobGate::new(), &logs).await;
2222 assert_eq!(decision, AutoUpdateDecision::Disabled);
2223 }
2224
2225 #[tokio::test]
2226 async fn auto_update_tick_skipped_when_busy() {
2227 let cfg = Config {
2228 auto_update_enabled: true,
2229 ..Config::default()
2230 };
2231 let logs = Arc::new(Mutex::new(Vec::new()));
2232 let gate = crate::job_gate::JobGate::new();
2234 let _held = gate.try_reserve().expect("hold the slot");
2235 let decision = auto_update_tick(&cfg, &gate, &logs).await;
2236 assert_eq!(decision, AutoUpdateDecision::SkippedBusy);
2237 let entries = logs.lock();
2238 assert!(entries.iter().any(|e| e.message.contains("busy on a job")));
2239 }
2240
2241 #[tokio::test]
2242 async fn wait_with_stop_short_circuits_when_already_stopped() {
2243 let stop = Arc::new(AtomicBool::new(true));
2244 let start = std::time::Instant::now();
2245 wait_with_stop(Duration::from_secs(60), &stop, Duration::from_millis(10)).await;
2246 assert!(
2247 start.elapsed() < Duration::from_millis(100),
2248 "an already-set stop must return without sleeping the full duration"
2249 );
2250 }
2251
2252 #[tokio::test]
2253 async fn auto_updater_stops_promptly_during_idle_wait() {
2254 let cfg = crate::config::shared(Config {
2260 auto_update_enabled: false,
2261 ..Config::default()
2262 });
2263 let stop = Arc::new(AtomicBool::new(false));
2264 let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2265 let busy = Arc::new(AtomicBool::new(false));
2266 let schedule = LoopSchedule {
2267 ws_session: crate::ws::session::SessionSchedule::fast_for_tests(),
2268 auto_update_tick: Duration::from_secs(3600),
2269 shutdown_tick: Duration::from_millis(1),
2270 };
2271 let handle = spawn_auto_updater(cfg, stop.clone(), logs, busy, schedule);
2272 tokio::time::sleep(Duration::from_millis(10)).await;
2274 stop.store(true, Ordering::SeqCst);
2275 tokio::time::timeout(Duration::from_millis(250), handle)
2276 .await
2277 .expect("auto-updater did not observe stop promptly")
2278 .expect("auto-updater task panicked");
2279 }
2280
2281 #[test]
2286 fn resolve_local_api_port_prefers_env_then_config_then_default() {
2287 assert_eq!(resolve_local_api_port(Some("5000"), Some(4000)), 5000);
2288 assert_eq!(resolve_local_api_port(None, Some(4000)), 4000);
2289 assert_eq!(resolve_local_api_port(None, None), DEFAULT_LOCAL_API_PORT);
2290 }
2291
2292 #[test]
2293 fn resolve_local_api_port_warns_on_invalid_env_and_falls_back() {
2294 let logs = crate::test_support::capture(|| {
2297 assert_eq!(
2298 resolve_local_api_port(Some("not-a-port"), None),
2299 DEFAULT_LOCAL_API_PORT
2300 );
2301 assert_eq!(resolve_local_api_port(Some("99999"), Some(4001)), 4001);
2302 });
2303 assert!(logs.contains("WARN"), "expected a WARN, got: {logs}");
2304 assert!(
2305 logs.contains("not-a-port"),
2306 "the warn must name the invalid value, got: {logs}"
2307 );
2308 assert!(
2309 logs.contains("STUDIO_WORKER_LOCAL_API_PORT"),
2310 "the warn must name the env var, got: {logs}"
2311 );
2312 }
2313
2314 #[test]
2315 fn ensure_local_api_token_mints_once_and_persists() {
2316 let dir = tempfile::tempdir().unwrap();
2317 let path = dir.path().join("config.toml");
2318 let cfg = config::shared(Config::default());
2319
2320 let minted = ensure_local_api_token(&cfg, &path);
2321 assert_eq!(minted.len(), 64, "expected a 64-hex token");
2322 assert!(minted.chars().all(|c| c.is_ascii_hexdigit()));
2323
2324 let (loaded, _) = config::load(Some(&path.to_string_lossy())).unwrap();
2326 assert_eq!(loaded.local_api_token.as_deref(), Some(minted.as_str()));
2327
2328 let again = ensure_local_api_token(&cfg, &path);
2330 assert_eq!(again, minted);
2331 }
2332
2333 #[test]
2334 fn ensure_local_api_token_keeps_an_existing_token() {
2335 let dir = tempfile::tempdir().unwrap();
2336 let path = dir.path().join("config.toml");
2337 let cfg = config::shared(Config {
2338 local_api_token: Some("pre-existing".into()),
2339 ..Config::default()
2340 });
2341 assert_eq!(ensure_local_api_token(&cfg, &path), "pre-existing");
2342 assert!(!path.exists(), "no save when nothing changed");
2343 }
2344
2345 #[test]
2346 fn ensure_local_api_token_survives_a_failed_persist() {
2347 let dir = tempfile::tempdir().unwrap();
2350 let blocked = dir.path().join("blocked");
2351 std::fs::write(&blocked, b"a file, not a dir").unwrap();
2352 let path = blocked.join("config.toml");
2353 let cfg = config::shared(Config::default());
2354 let logs = crate::test_support::capture({
2355 let cfg = cfg.clone();
2356 move || {
2357 let token = ensure_local_api_token(&cfg, &path);
2358 assert_eq!(token.len(), 64);
2359 }
2360 });
2361 assert!(
2362 logs.contains("failed to persist the local api token"),
2363 "a failed persist must warn: {logs}"
2364 );
2365 assert!(
2366 cfg.lock().local_api_token.is_some(),
2367 "the in-memory token must survive the failed persist"
2368 );
2369 }
2370}