Skip to main content

studio_worker/
runtime.rs

1//! Long-running auto-update task + one-shot CLI helpers.
2//!
3//! After the WS migration the runtime owns just two background
4//! tasks: the WebSocket session (`ws::session::spawn_ws_session`,
5//! which subsumes heartbeats, claim/accept/complete, fail, and log
6//! shipping) and the auto-updater (`spawn_auto_updater`).  Per-tick
7//! helpers from the old polling loops are gone.
8use 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
28/// Tracing target for runtime-level events (startup, state mutations).
29/// Stable so operators can filter with `RUST_LOG=studio_worker::runtime=debug`.
30const TRACE_TARGET: &str = "studio_worker::runtime";
31
32/// Maximum number of finished jobs kept in `WorkerObservers::recent_jobs`.
33/// Older entries fall off the back of the ring.
34pub const RECENT_JOBS_CAP: usize = 50;
35
36/// Maximum number of log entries kept in `WorkerObservers::recent_logs`
37/// for the UI's Logs tab.  The shipping queue (`logs: Arc<Mutex<Vec<…>>>`)
38/// is drained on every WS tick — the display ring is what the UI reads.
39pub const RECENT_LOGS_CAP: usize = 1000;
40
41/// Prompt previews stored in `CurrentJob` / `RecentJob` are clipped to
42/// this many chars so the in-memory state stays bounded even when LLM
43/// prompts are huge.
44pub const PROMPT_PREVIEW_CHARS: usize = 200;
45
46/// Maximum number of entries the WS ship queue (`logs:
47/// Arc<Mutex<Vec<LogEntry>>>`) may hold.  The shipper pump only drains
48/// while a session is connected, so a long approval wait or reconnect
49/// backoff would otherwise grow the queue without bound.  On overflow
50/// the oldest entries are dropped and a warn-level marker records the
51/// loss.
52pub const LOG_SHIP_QUEUE_CAP: usize = 5_000;
53
54/// Where a job came from.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum JobSource {
58    /// An offer from the studio.
59    #[default]
60    Studio,
61    /// A local API request run as a transient job.
62    Local,
63    /// A local API request served on a loaded model's lane.
64    Lane,
65    /// A streaming speech session on the LAN listener.
66    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/// A job in flight.  The studio's job also sits in
81/// `WorkerObservers::current_job` (the heartbeat reports it); every running
82/// job, whatever its source, sits in `WorkerObservers::active_jobs`.
83#[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/// Outcome a finished job ended with.  Failures carry the human
94/// reason (already surfaced to logs + Sentry).
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum JobOutcome {
97    Completed,
98    Failed { reason: String },
99}
100
101/// One finished job, retained in the recent-jobs ring for the UI.
102#[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/// Result of the most recent heartbeat the WS session sent.
115#[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/// Where the WS session is in its lifecycle, surfaced to the UI so a
123/// worker that can't reach the studio shows *why* instead of sitting
124/// silently.  Terminal states (`AuthFailed`, `Fatal`) carry a
125/// call-to-action the Status tab renders.
126#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
127#[serde(tag = "state", rename_all = "snake_case")]
128pub enum SessionState {
129    /// No credentials yet — waiting for the studio operator to approve.
130    #[default]
131    WaitingForApproval,
132    /// Opening / re-opening the socket.
133    Connecting,
134    /// Welcomed by the studio; claiming jobs.
135    Connected,
136    /// Lost the connection; backing off before the next attempt.
137    Reconnecting { attempt: u32 },
138    /// Studio rejected our auth — the worker must be re-registered.
139    AuthFailed { reason: String },
140    /// A fatal server error ended the session.
141    Fatal { reason: String },
142    /// Clean shutdown.
143    Stopped,
144}
145
146impl SessionState {
147    /// One-line operator-facing summary for the UI Status tab,
148    /// including the recovery action for terminal states.
149    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/// Bundle of in-process observation slots the WS session writes to and
178/// the optional native UI reads from.  `Default` gives empty slots so
179/// existing (headless) call sites stay one-liners.  Cheap to clone —
180/// every field is an `Arc`.
181#[derive(Clone, Default)]
182pub struct WorkerObservers {
183    pub current_job: Arc<Mutex<Option<CurrentJob>>>,
184    /// Every running job, whatever its source (see [`crate::job_run::JobRun`]).
185    pub active_jobs: Arc<Mutex<Vec<CurrentJob>>>,
186    /// Thumbnails of recent image jobs.
187    pub thumbnails: crate::thumbnail::Thumbnails,
188    pub recent_jobs: Arc<Mutex<VecDeque<RecentJob>>>,
189    /// Finished jobs submitted to the local API (the in-app "local queue"),
190    /// kept separate from studio-claimed jobs.
191    pub local_jobs: Arc<Mutex<VecDeque<RecentJob>>>,
192    /// URL the always-on local image API is reachable at, once bound.
193    pub local_api_url: Arc<Mutex<Option<String>>>,
194    pub last_heartbeat: Arc<Mutex<Option<HeartbeatStatus>>>,
195    /// Current WS lifecycle state, so the UI can show a connecting /
196    /// reconnecting / auth-failed status instead of silent nothing.
197    pub session_state: Arc<Mutex<SessionState>>,
198    /// GPU-runtime readiness, probed once at startup.  `None` until
199    /// probed.
200    pub gpu_runtime: Arc<Mutex<Option<GpuRuntimeStatus>>>,
201    /// The local model catalog, shared between the local API (which
202    /// serves from it) and the WS session (which mirrors studio-offered
203    /// models into it).  Empty until `spawn_local_api` loads it.
204    pub catalog: Arc<Mutex<crate::catalog::Catalog>>,
205    /// Where the catalog persists (`None` = don't persist, e.g. an
206    /// unreadable file).
207    pub catalog_path: Arc<Mutex<Option<std::path::PathBuf>>>,
208    /// Bounded ring of every log entry the worker has emitted, kept
209    /// for the UI's Logs tab.  Separate from the WS ship queue
210    /// (which is drained every second) so the display doesn't blank
211    /// out between ticks.
212    pub recent_logs: Arc<Mutex<VecDeque<LogEntry>>>,
213    /// Entries ever pushed into `recent_logs`; the newest entry's sequence
214    /// number.  Written under the `recent_logs` lock.
215    pub recent_logs_seq: Arc<std::sync::atomic::AtomicU64>,
216}
217
218/// Entries of `recent_logs` newer than sequence number `after`, and the
219/// newest sequence number.  A client passes the returned number back as
220/// `after` next time to receive only what is new; an `after` beyond the
221/// newest number (the daemon restarted) answers the whole ring.
222pub 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    /// Observers whose worker log is the process-wide ring the installed
233    /// [`crate::job_log::WorkerLogLayer`] fills: the daemon's.
234    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    /// The worker log ring, as a handle.
244    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
252/// Record the WS lifecycle state for the UI to read.
253pub fn set_session_state(observers: &WorkerObservers, state: SessionState) {
254    *observers.session_state.lock() = state;
255}
256
257/// GPU-runtime readiness, probed once at startup so a missing Vulkan
258/// loader surfaces as an actionable status (UI + `/healthz`) *before*
259/// the first image job fails, not after.
260#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
261pub struct GpuRuntimeStatus {
262    /// True when the runtime sd-cli needs is present (always true on
263    /// macOS/Metal).
264    pub ok: bool,
265    /// Human-readable detail: "available" or the exact remedy.
266    pub detail: String,
267}
268
269/// Mirror a model seen on a studio job offer into the shared local
270/// catalog so the local API can serve it too.  Persists atomically
271/// when the catalog changed; a local-origin entry of the same id is
272/// never clobbered.  Best-effort: a persist failure is warn-logged but
273/// never fails the job.
274pub 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
318/// Probe the GPU runtime and record it in `observers`, warn-logging the
319/// remedy when it's missing.  `status` is injected (the live caller
320/// passes `sd_provision::vulkan_runtime_status()`) so the
321/// observer-write + logging are testable without a real GPU stack.
322pub 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
361/// Record a finished local-API job into the local-queue ring.
362pub 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/// Test-only helper to populate the recent-jobs ring without driving a
371/// full claim cycle.  Lives in the library surface so integration
372/// tests can pin the ring-capacity contract cheaply.
373#[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);
392/// Cadence at which the auto-updater's idle wait re-checks the `stop`
393/// flag.  Mirrors the WS session's shutdown tick so a SIGTERM / SIGINT
394/// landing during the (up to `AUTO_UPDATE_TICK`-long) idle window wakes
395/// the loop within ~250 ms instead of leaving `run_loops`' join blocked
396/// for a whole tick.
397pub const AUTO_UPDATE_SHUTDOWN_TICK: Duration = Duration::from_millis(250);
398/// Default WS heartbeat interval, re-exported here so the native UI
399/// (and any other downstream readers) get a stable constant without
400/// reaching into `ws::session`.
401pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
402
403/// Schedule for the long-running loops.
404#[derive(Debug, Clone, Copy)]
405pub struct LoopSchedule {
406    pub ws_session: crate::ws::session::SessionSchedule,
407    pub auto_update_tick: Duration,
408    /// How often the idle wait between update checks re-polls the
409    /// `stop` flag, so a shutdown request isn't deferred for a whole
410    /// `auto_update_tick`.
411    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    /// Schedule with 1 ms intervals — used by tests to exercise the
426    /// loop wrappers without blocking.
427    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// ---------------------------------------------------------------------------
437// One-shot helpers used by the CLI subcommands
438// ---------------------------------------------------------------------------
439
440/// Bundle of flags from `studio-worker register`.
441#[derive(Debug, Clone, Default)]
442pub struct RegisterArgs {
443    pub api_base_url: Option<String>,
444    pub reset: bool,
445}
446
447/// Persist registration metadata for the next launch.  No HTTP — the
448/// auto-register orchestration inside `run` / `ui` is the only thing
449/// that talks to the studio.
450pub 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
531/// Emit a one-shot startup banner so operators can confirm which
532/// config the worker actually loaded.  Without this the only thing in
533/// `journalctl -u studio-worker` on a healthy boot is whatever the
534/// loops happen to log on their first tick.
535pub 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, &current, 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
579// ---------------------------------------------------------------------------
580// Long-running run loop
581// ---------------------------------------------------------------------------
582
583pub async fn run(config_path: Option<&str>) -> Result<()> {
584    let (cfg, path) = config::load(config_path)?;
585    // One daemon per config directory: a second one would fight the first
586    // over the local API port and the studio session.
587    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    // The local API (and with it the model host and stream listener) starts
609    // before the registration gate, so it serves even when the worker is not
610    // (yet) registered with a studio.  It shares the one-job gate with the
611    // WS session so a local job and a studio job never share the GPU.
612    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    // Shutting down: ensure the local API thread observes `stop` and joins.
618    control.stop.store(true, Ordering::SeqCst);
619    if let Some(handle) = local_api {
620        let _ = handle.join();
621    }
622    outcome
623}
624
625/// How often the rejection wait checks for a reset or a stop.
626pub const REGISTRATION_RESET_POLL: Duration = Duration::from_millis(250);
627
628/// Register with the studio, then run the studio loops.  A rejection does
629/// not end the daemon: it keeps serving locally and waits for a
630/// registration reset (from the tray UI) or a stop.
631pub 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
684/// Wait for a registration reset (`true`) or a stop (`false`).
685async 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
697/// Clear the local registration state, as `register --reset` does.
698pub 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
706/// Clear and persist the registration state so the next tick asks the
707/// studio afresh.
708fn 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
724/// Flip the `stop` flag and emit a shutdown breadcrumb so an operator
725/// tailing the journal sees a clean stop, mirroring
726/// [`log_startup_banner`].  Pulled out of the signal task so the
727/// shutdown decision is unit-testable without delivering a real OS
728/// signal.  `signal` names whatever woke us (e.g. `"SIGTERM"`).
729pub 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/// Block until the OS asks the worker to stop, returning the name of
741/// the signal that fired.
742///
743/// On Unix we wait on **both** SIGINT (interactive Ctrl-C) and SIGTERM.
744/// SIGTERM is the signal `systemctl stop` / `launchctl unload` / host
745/// shutdown deliver by default, and the worker ships as a `Type=simple`
746/// systemd unit (see `service::render_service`).  Listening for Ctrl-C
747/// alone meant the service manager's stop never reached the graceful
748/// path: the WS session was killed mid-`close`, the studio saw an
749/// abrupt disconnect, and the final log batch never flushed.  If the
750/// SIGTERM handler can't be installed we degrade to Ctrl-C only rather
751/// than abort the shutdown task.
752///
753/// On non-Unix we wait on Ctrl-C, which tokio maps to the console
754/// Ctrl-C / close events.
755#[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/// Outcome of the startup registration gate ([`ensure_registered`]).
786///
787/// A clean stop signal (Ctrl-C / SIGTERM) that arrives **before** the
788/// studio approves the worker is a routine shutdown, not a failure:
789/// the pre-approval wait is the normal state of a freshly-installed
790/// worker sitting in the studio's approval queue.  Surfacing it as a
791/// distinct [`Stopped`](RegistrationGate::Stopped) outcome lets `run`
792/// exit 0 — so `systemctl stop` doesn't mark the unit failed — and
793/// skip the top-level `tracing::error!` that would otherwise ship a
794/// spurious Sentry event on every clean stop of an unapproved worker.
795/// An operator *rejection*, by contrast, stays a hard `Err`: it's a
796/// terminal state the operator must act on (`register --reset`).
797#[derive(Debug, Clone, Copy, PartialEq, Eq)]
798pub enum RegistrationGate {
799    /// The worker is registered (already, or just approved); proceed
800    /// to open the WS session.
801    Ready,
802    /// A stop signal arrived before approval; shut down cleanly.
803    Stopped,
804}
805
806/// Loop auto_register::tick on a 30s cadence until `worker_id` +
807/// `auth_token` are populated (Approved → [`RegistrationGate::Ready`]),
808/// a stop signal arrives (→ [`RegistrationGate::Stopped`]), or the
809/// operator rejects the worker (→ `Err` with recovery guidance).
810pub 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        // Sleep with a fast-cancel on stop.
842        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
851/// Spawn the WS session + auto-updater, wait for them.  Pulled out of
852/// `run` so tests can drive with a different schedule.
853///
854/// `paused` is the runtime-only Pause / Resume toggle the UI flips.
855/// When set, the WS session advertises `auto_enabled = false` in
856/// heartbeats and refuses new job offers without restarting the
857/// session.
858pub 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
887/// Default port for the always-on local image API. Override with
888/// `STUDIO_WORKER_LOCAL_API_PORT`.
889pub const DEFAULT_LOCAL_API_PORT: u16 = 4787;
890
891/// Resolve the local API port: a valid `STUDIO_WORKER_LOCAL_API_PORT`
892/// env value wins, then the config's `local_api_port`, then the
893/// built-in default.  An *invalid* env value used to be silently
894/// ignored; now it warn-logs what it fell back to so a typo'd unit
895/// file can't quietly move the API.  Pure so every branch is
896/// unit-testable.
897pub 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
906/// The LAN stream listener's port: `STUDIO_WORKER_STREAM_PORT`, else the
907/// config's `stream_port`, else [`crate::stt_stream::server::DEFAULT_STREAM_PORT`].
908pub 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
917/// Env beats config beats default; an invalid env value falls back, logged.
918fn 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
943/// Ensure the per-install local API bearer token exists, minting and
944/// persisting one on first launch.  A failed persist is warn-logged
945/// but non-fatal: the in-memory token still guards this session and
946/// the discovery file still tells clients what it is.
947pub 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/// Bind the LAN streaming speech listener and serve it on its own thread.
969/// A failed bind is logged and leaves the port at 0, so stream-token
970/// requests answer `stream_listener_down` instead of pointing nowhere.
971#[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
1004/// Build the engine + catalog and start the local image API server on a
1005/// background thread. Returns the thread handle, or `None` when it could not
1006/// start (logged, non-fatal — the studio session keeps running).
1007pub 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    // `load_for_serving` quarantines corrupt files and drops the save
1024    // path when the file is unreadable, so a later `POST /models` can
1025    // never clobber a catalog the worker couldn't read.  The loaded
1026    // catalog is published into the observers so the WS session can
1027    // mirror studio-offered models into the same shared instance.
1028    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    // One-shot GPU-runtime preflight at startup so a missing Vulkan
1087    // loader shows up in the UI + /healthz immediately, instead of
1088    // only when the first image job crashes sd-cli.
1089    set_gpu_runtime_status(
1090        &observers,
1091        crate::engine::sd_provision::vulkan_runtime_status(),
1092    );
1093
1094    // Bring back the models the operator left loaded.
1095    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    // Publish URL + token for local clients; removed again after the
1102    // serve loop exits so a stale file can't point at a dead port.
1103    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// ---------------------------------------------------------------------------
1124// Per-tick helpers — pure async fns, easy to drive from unit tests.
1125// ---------------------------------------------------------------------------
1126
1127// (The old per-tick HTTP helpers — heartbeat_tick, claim_tick, log_shipper_tick,
1128//  run_job, ClaimOutcome — lived here.  They are gone with the WS migration.
1129//  See `ws::session::spawn_ws_session` for the replacement that runs the
1130//  whole session in one connected loop.)
1131
1132/// What the auto-updater decided this tick.
1133#[derive(Debug, Clone, PartialEq, Eq)]
1134pub enum AutoUpdateDecision {
1135    /// Auto-update is turned off — do nothing.
1136    Disabled,
1137    /// Worker is currently running a job — skip.
1138    SkippedBusy,
1139    /// Local version is already the latest.
1140    UpToDate,
1141    /// Check failed (network etc.) — leave a log entry, try again later.
1142    CheckError(String),
1143    /// A newer version was applied successfully.  Caller should restart.
1144    Updated,
1145    /// A newer version was found but the install failed.
1146    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    // A job in flight: skip the whole check.  The network probe is
1158    // cheap but restarting mid-job is not, and we'd only reject the
1159    // apply below anyway.
1160    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, &current, 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                // Reserve the one-job slot *before* touching the
1190                // installer, so an offer arriving mid-install is
1191                // rejected as busy (the WS session shares this gate)
1192                // and `restart_self` can never kill an in-flight job.
1193                // A job that started between the is_busy() check and
1194                // here loses the race here and we defer to next tick.
1195                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
1255// ---------------------------------------------------------------------------
1256// Long-running task wrappers — they exist solely to call the ticks in a
1257// loop on a schedule.  All real logic lives in the ticks.
1258// ---------------------------------------------------------------------------
1259
1260// (`spawn_heartbeat`, `spawn_claim_loop`, `spawn_log_shipper`, and
1261//  `next_delay_for` lived here.  Their behaviour is now carried by the
1262//  WS-driven tasks in `ws::session`.)
1263
1264/// Sleep up to `total`, re-checking `stop` every `tick` and returning
1265/// the instant a shutdown is requested.  Keeps long idle waits (the
1266/// auto-update tick here, reconnect backoff in the WS session)
1267/// responsive to SIGTERM / SIGINT without busy-looping.  Shared by the
1268/// runtime auto-updater and `ws::session`.
1269pub(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            // Stop-aware idle wait: a shutdown signal during this window
1292            // wakes the loop within `schedule.shutdown_tick` instead of
1293            // leaving `run_loops`' join() blocked for a full
1294            // `auto_update_tick`.
1295            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
1315// (`run_job` lived here.  See `ws::session::run_offered_job` for the
1316//  WS-driven replacement.)
1317
1318pub 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    // Typed check first — survives context wrapping and rewording.
1334    // The string check remains as a fallback for error paths that
1335    // haven't migrated to `engine::UnsupportedTask` yet.
1336    e.chain().any(|cause| {
1337        cause
1338            .downcast_ref::<crate::engine::UnsupportedTask>()
1339            .is_some()
1340    }) || e.to_string().contains("cannot serve")
1341}
1342
1343// ---------------------------------------------------------------------------
1344// Helpers
1345// ---------------------------------------------------------------------------
1346
1347pub fn build_capabilities(cfg: &Config, engine: &dyn Engine) -> WorkerCapabilities {
1348    build_capabilities_with(cfg, engine, true)
1349}
1350
1351/// Same as [`build_capabilities`] but lets the caller drive
1352/// `auto_enabled` from a runtime pause flag (the UI's Pause/Resume
1353/// button).  The persisted [`Config`] no longer carries that bit —
1354/// it's an in-process toggle.
1355pub 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    // Legacy `supported_models` is a flat list across all kinds so the
1365    // studio API's claim filter (which only knows about this field) can
1366    // match jobs of any modality this worker can serve.
1367    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        // The tray UI always installs its login entry, so a build with the
1383        // UI starts on its own; a headless build relies on the OS service.
1384        auto_start: cfg!(feature = "ui"),
1385        supported_models,
1386        task_kinds,
1387        supported_models_per_kind,
1388    }
1389}
1390
1391/// One-line, operator-facing summary of what this worker advertises to
1392/// the studio on the WS handshake.  Logged once per session attempt so
1393/// the worker's own logs (and the studio's shipped-log view) record
1394/// exactly which task kinds, models, and VRAM budget were offered — the
1395/// missing complement to [`log_startup_banner`], which only covers the
1396/// loaded config.  Without it, an operator chasing "why won't my worker
1397/// claim image jobs" has no record of what the worker told the studio
1398/// it could do.  Pure so the formatting is unit-tested without a live
1399/// session.
1400pub 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
1420/// Operator-facing warning when the configured VRAM threshold exceeds
1421/// the GPU VRAM the worker actually detected.
1422///
1423/// The studio matches jobs to a worker purely by its advertised
1424/// `vram_threshold_gb`, so a threshold set above the card's real
1425/// capacity — e.g. the default 12 GB on an 8 GB consumer GPU — makes the
1426/// worker accept jobs its GPU can't fit: they load, exhaust VRAM, and
1427/// fail with an OOM the operator then has to trace back to a config
1428/// value.  Surfacing it on the handshake (one line next to the
1429/// capability summary) turns a silent OOM-on-claim into an actionable
1430/// "lower your threshold" breadcrumb.
1431///
1432/// Only fires when the VRAM probe returned a real positive total: a
1433/// detected 0 GB means the probe failed (no `nvidia-smi` / sysfs tree,
1434/// or a non-NVIDIA GPU we can't size), where the threshold is the only
1435/// capacity signal we have and second-guessing it would be wrong.  The
1436/// boundary is strict (`threshold > total`), so a threshold that exactly
1437/// matches the card stays silent.  Pure so the wording + boundary are
1438/// unit-tested without a live GPU.
1439pub 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
1462/// Same as [`push_log`] but also appends to
1463/// [`WorkerObservers::recent_logs`] so the UI's Logs tab keeps a
1464/// rolling display window.  The WS session uses this variant so
1465/// operators don't see the Logs tab blank out every second when the
1466/// shipping queue gets drained.
1467pub 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    // Carry the job id as a structured field so operators can pivot
1483    // shipped studio logs / Sentry breadcrumbs on it. `Option<&str>`
1484    // only records the field when `Some`, so jobless breadcrumbs stay
1485    // free of a noisy empty `job_id`.
1486    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            // +1 for the entry below, +1 for the drop marker.
1498            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
1517/// Put a drained-but-unsent batch back at the front of the ship queue
1518/// so it survives for the next session attempt.  Entries that arrived
1519/// while the batch was in flight stay behind it (newest last).  The
1520/// combined queue is clipped to [`LOG_SHIP_QUEUE_CAP`], dropping the
1521/// oldest entries first.
1522pub 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        // The message keeps the legacy operator-facing shape.
1591        assert!(err.to_string().contains("cannot serve llm"));
1592    }
1593
1594    #[test]
1595    fn is_unsupported_kind_survives_context_wrapping() {
1596        // String sniffing broke as soon as a caller added context (the
1597        // outer message no longer contains "cannot serve"); the typed
1598        // downcast searches the whole chain.
1599        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        // A batch the shipper drained but failed to send must survive
1620        // for the next session, ordered before entries that arrived
1621        // while it was in flight.
1622        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        // Requeueing must never grow the queue past the ship cap; the
1632        // oldest (front) entries give way so the newest survive.
1633        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        // The WS shipper only drains while a session is connected; a
1648        // long approval wait / reconnect backoff must not grow the
1649        // queue without bound.
1650        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        // The newest entry always survives.
1661        assert_eq!(
1662            queue.last().map(|e| e.message.as_str()),
1663            Some(format!("entry {}", LOG_SHIP_QUEUE_CAP + 99).as_str())
1664        );
1665        // Loss is visible: a marker entry names how many were dropped.
1666        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        // The observer ring backing the UI Logs tab is never drained
1677        // (unlike the ship queue, which the WS shipper empties every
1678        // second), so this cap is its only bound.  A regression that
1679        // dropped the eviction loop would leak memory for the lifetime
1680        // of a long-running worker; one that flipped `pop_front` for
1681        // `pop_back` would silently retain the *oldest* entries and show
1682        // a stale Logs tab.  Mirrors `recent_jobs_ring_caps_at_*`.
1683        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        // Newest entries go to the back; the oldest `overflow` entries
1703        // must have been evicted from the front.
1704        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        // The two terminal states must name the fix so a stranded
1724        // worker isn't a silent dead end.
1725        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        // In-memory catalog gained the studio model…
1765        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        // …and it was persisted to disk.
1771        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        // No catalog_path set (unreadable file / None): must not panic,
1780        // just updates the in-memory catalog.
1781        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        // Engine name + every advertised kind is present.
1863        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        // Model count + an actual advertised model id are present.
1872        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        // VRAM budget (total/threshold) + unpaused state are visible.
1881        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    /// Build a capability snapshot, then override the two VRAM fields so
1900    /// the threshold/total relationship is deterministic regardless of
1901    /// the host's real GPU (the probe is `0.0` on CI).
1902    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        // The default 12 GB threshold on an 8 GB card: the studio will
1912        // offer up-to-12 GB jobs this GPU can't fit, and they OOM on
1913        // load.  The breadcrumb must name both numbers and the config
1914        // key the operator has to lower.
1915        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        // A 24 GB card with a 12 GB threshold is correctly conservative.
1931        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        // The boundary is strict: a threshold that exactly matches the
1937        // card fits, so it stays silent.
1938        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        // A detected 0 GB means the probe failed (no nvidia-smi / sysfs)
1944        // or it's a non-NVIDIA GPU we can't size; the threshold is then
1945        // the only capacity signal we have, so second-guessing it with a
1946        // spurious OOM warning would be wrong.
1947        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        // Under the cap → returned verbatim, no ellipsis.
2009        let short = "a stone golem";
2010        assert_eq!(truncate_prompt(short), short);
2011
2012        // Exactly at the cap is the boundary: still untouched.
2013        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        // One past the cap → clipped to PROMPT_PREVIEW_CHARS chars plus
2021        // the single ellipsis terminator.
2022        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        // Each char here is 3 bytes, so the cap-th *byte* lands
2046        // mid-codepoint: a naive `&s[..PROMPT_PREVIEW_CHARS]` byte slice
2047        // would panic.  `truncate_prompt` counts chars, so a one-over
2048        // multibyte prompt clips cleanly to the cap plus the ellipsis.
2049        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        // Operators correlating shipped studio logs / Sentry
2135        // breadcrumbs by job need the job id as a *field*, not just
2136        // buried in the message text, so `RUST_LOG` filters and Sentry
2137        // tag search can pivot on it.
2138        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        // Jobless breadcrumbs (startup banners, heartbeats, auto-update
2162        // ticks) must not gain a noisy empty `job_id` field.
2163        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    // --- async tick tests ---
2175
2176    #[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        // A second signal (or a race with another shutdown path) must
2186        // not panic or clear the flag — it just re-confirms the stop.
2187        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        // A held reservation on the shared gate = an in-flight job.
2233        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        // A huge auto_update_tick means a non-cancellable idle sleep
2255        // would pin the JoinHandle — and thus `run_loops`' join() — for
2256        // the whole tick after stop is set, defeating graceful
2257        // shutdown.  The stop-aware wait must let the task finish well
2258        // inside the tick.
2259        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        // Let the loop reach its idle wait, then request shutdown.
2273        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    // -----------------------------------------------------------------
2282    // Local API bootstrap helpers.
2283    // -----------------------------------------------------------------
2284
2285    #[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        // An invalid env value used to be silently ignored; it must
2295        // fall back *and* leave a warn naming the bad value.
2296        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        // Persisted to disk…
2325        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        // …and stable across calls (no re-mint).
2329        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        // Unwritable config path: the token must still be minted (the
2348        // session stays guarded) and a warn must surface the failure.
2349        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}