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 page.  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 Worker page 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 Worker page,
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 page.  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>, wait_for_lock: bool) -> 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 if wait_for_lock => {
590            let path = path.clone();
591            tokio::task::spawn_blocking(move || {
592                crate::daemon_lock::wait_until_acquired(&path, crate::daemon_lock::WAIT_POLL)
593            })
594            .await??
595        }
596        crate::daemon_lock::Acquired::HeldElsewhere => return Ok(()),
597    };
598    log_startup_banner(&cfg, &path);
599
600    let control = crate::control::DaemonControl::new(
601        config::shared(cfg),
602        path,
603        sys::detect_vram_gb().unwrap_or(0.0),
604    );
605    let busy = Arc::new(AtomicBool::new(false));
606    let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
607    let observers = WorkerObservers::with_global_worker_log();
608
609    let stop_clone = control.stop.clone();
610    tokio::spawn(async move {
611        let signal = wait_for_shutdown_signal().await;
612        request_shutdown(&stop_clone, signal);
613    });
614
615    // The local API (and with it the model host and stream listener) starts
616    // before the registration gate, so it serves even when the worker is not
617    // (yet) registered with a studio.  It shares the one-job gate with the
618    // WS session so a local job and a studio job never share the GPU.
619    let gate = crate::job_gate::JobGate::from_shared(busy.clone());
620    let local_api = spawn_local_api(&control, observers.clone(), gate);
621
622    let outcome = serve_studio(&control, logs, busy, observers, LoopSchedule::default()).await;
623
624    // Shutting down: ensure the local API thread observes `stop` and joins.
625    control.stop.store(true, Ordering::SeqCst);
626    if let Some(handle) = local_api {
627        let _ = handle.join();
628    }
629    outcome
630}
631
632/// How often the rejection wait checks for a reset or a stop.
633pub const REGISTRATION_RESET_POLL: Duration = Duration::from_millis(250);
634
635/// Register with the studio, then run the studio loops.  A rejection does
636/// not end the daemon: it keeps serving locally and waits for a
637/// registration reset (from the tray UI) or a stop.
638pub async fn serve_studio(
639    control: &crate::control::DaemonControl,
640    logs: Arc<Mutex<Vec<LogEntry>>>,
641    busy: Arc<AtomicBool>,
642    observers: WorkerObservers,
643    schedule: LoopSchedule,
644) -> Result<()> {
645    loop {
646        match ensure_registered(
647            &control.cfg,
648            &control.config_path,
649            &control.registration,
650            &control.stop,
651        )
652        .await
653        {
654            Ok(RegistrationGate::Stopped) => {
655                info!(
656                    target: TRACE_TARGET,
657                    op = "shutdown",
658                    "stopped before registration completed; exiting cleanly"
659                );
660                return Ok(());
661            }
662            Ok(RegistrationGate::Ready) => {
663                return run_loops(
664                    control.cfg.clone(),
665                    control.stop.clone(),
666                    logs,
667                    busy,
668                    control.paused.clone(),
669                    observers,
670                    schedule,
671                )
672                .await;
673            }
674            Err(err) => {
675                tracing::error!(
676                    target: TRACE_TARGET,
677                    op = "registration",
678                    error = %err,
679                    "studio registration rejected; the local API keeps serving; \
680                     reset the registration from the tray UI to ask again"
681                );
682                if !wait_for_registration_reset(control).await {
683                    return Ok(());
684                }
685                reset_registration(control)?;
686            }
687        }
688    }
689}
690
691/// Wait for a registration reset (`true`) or a stop (`false`).
692async fn wait_for_registration_reset(control: &crate::control::DaemonControl) -> bool {
693    loop {
694        if control.stop.load(Ordering::SeqCst) {
695            return false;
696        }
697        if control.reset_requested.swap(false, Ordering::SeqCst) {
698            return true;
699        }
700        tokio::time::sleep(REGISTRATION_RESET_POLL).await;
701    }
702}
703
704/// Clear the local registration state, as `register --reset` does.
705pub fn clear_registration(cfg: &mut Config) {
706    cfg.worker_id = None;
707    cfg.auth_token = None;
708    cfg.registration_request_id = None;
709    cfg.registration_secret = None;
710    cfg.install_id = None;
711}
712
713/// Clear and persist the registration state so the next tick asks the
714/// studio afresh.
715fn reset_registration(control: &crate::control::DaemonControl) -> Result<()> {
716    let snapshot = {
717        let mut cfg = control.cfg.lock();
718        clear_registration(&mut cfg);
719        cfg.clone()
720    };
721    config::save(&snapshot, &control.config_path)?;
722    *control.registration.lock() = crate::auto_register::RegistrationState::Pristine;
723    info!(
724        target: TRACE_TARGET,
725        op = "registration",
726        "registration reset; asking the studio again"
727    );
728    Ok(())
729}
730
731/// Flip the `stop` flag and emit a shutdown breadcrumb so an operator
732/// tailing the journal sees a clean stop, mirroring
733/// [`log_startup_banner`].  Pulled out of the signal task so the
734/// shutdown decision is unit-testable without delivering a real OS
735/// signal.  `signal` names whatever woke us (e.g. `"SIGTERM"`).
736pub fn request_shutdown(stop: &AtomicBool, signal: &str) {
737    let already_stopping = stop.swap(true, Ordering::SeqCst);
738    info!(
739        target: TRACE_TARGET,
740        op = "shutdown",
741        signal,
742        already_stopping,
743        "shutdown signal received; stopping worker gracefully"
744    );
745}
746
747/// Block until the OS asks the worker to stop, returning the name of
748/// the signal that fired.
749///
750/// On Unix we wait on **both** SIGINT (interactive Ctrl-C) and SIGTERM.
751/// SIGTERM is the signal `systemctl stop` / `launchctl unload` / host
752/// shutdown deliver by default, and the worker ships as a `Type=simple`
753/// systemd unit (see `service::render_service`).  Listening for Ctrl-C
754/// alone meant the service manager's stop never reached the graceful
755/// path: the WS session was killed mid-`close`, the studio saw an
756/// abrupt disconnect, and the final log batch never flushed.  If the
757/// SIGTERM handler can't be installed we degrade to Ctrl-C only rather
758/// than abort the shutdown task.
759///
760/// On non-Unix we wait on Ctrl-C, which tokio maps to the console
761/// Ctrl-C / close events.
762#[cfg_attr(coverage_nightly, coverage(off))]
763async fn wait_for_shutdown_signal() -> &'static str {
764    #[cfg(unix)]
765    {
766        use tokio::signal::unix::{signal, SignalKind};
767        let mut sigterm = match signal(SignalKind::terminate()) {
768            Ok(s) => s,
769            Err(e) => {
770                warn!(
771                    target: TRACE_TARGET,
772                    op = "shutdown",
773                    error = %e,
774                    "could not install SIGTERM handler; falling back to Ctrl-C only"
775                );
776                let _ = tokio::signal::ctrl_c().await;
777                return "SIGINT";
778            }
779        };
780        tokio::select! {
781            _ = tokio::signal::ctrl_c() => "SIGINT",
782            _ = sigterm.recv() => "SIGTERM",
783        }
784    }
785    #[cfg(not(unix))]
786    {
787        let _ = tokio::signal::ctrl_c().await;
788        "ctrl-c"
789    }
790}
791
792/// Outcome of the startup registration gate ([`ensure_registered`]).
793///
794/// A clean stop signal (Ctrl-C / SIGTERM) that arrives **before** the
795/// studio approves the worker is a routine shutdown, not a failure:
796/// the pre-approval wait is the normal state of a freshly-installed
797/// worker sitting in the studio's approval queue.  Surfacing it as a
798/// distinct [`Stopped`](RegistrationGate::Stopped) outcome lets `run`
799/// exit 0 — so `systemctl stop` doesn't mark the unit failed — and
800/// skip the top-level `tracing::error!` that would otherwise ship a
801/// spurious Sentry event on every clean stop of an unapproved worker.
802/// An operator *rejection*, by contrast, stays a hard `Err`: it's a
803/// terminal state the operator must act on (`register --reset`).
804#[derive(Debug, Clone, Copy, PartialEq, Eq)]
805pub enum RegistrationGate {
806    /// The worker is registered (already, or just approved); proceed
807    /// to open the WS session.
808    Ready,
809    /// A stop signal arrived before approval; shut down cleanly.
810    Stopped,
811}
812
813/// Loop auto_register::tick on a 30s cadence until `worker_id` +
814/// `auth_token` are populated (Approved → [`RegistrationGate::Ready`]),
815/// a stop signal arrives (→ [`RegistrationGate::Stopped`]), or the
816/// operator rejects the worker (→ `Err` with recovery guidance).
817pub async fn ensure_registered(
818    cfg: &SharedConfig,
819    path: &std::path::Path,
820    registration: &crate::auto_register::SharedRegistration,
821    stop: &Arc<AtomicBool>,
822) -> Result<RegistrationGate> {
823    use std::time::Duration;
824    loop {
825        if stop.load(Ordering::SeqCst) {
826            return Ok(RegistrationGate::Stopped);
827        }
828        {
829            let snap = cfg.lock();
830            if snap.worker_id.is_some() && snap.auth_token.is_some() {
831                return Ok(RegistrationGate::Ready);
832            }
833        }
834        let state = crate::auto_register::tick(cfg, path, registration).await;
835        match state {
836            crate::auto_register::RegistrationState::Approved => {
837                return Ok(RegistrationGate::Ready)
838            }
839            crate::auto_register::RegistrationState::Rejected { reason } => {
840                return Err(anyhow!(
841                    "registration rejected by the studio operator: {reason}.  \
842                     Run `studio-worker register --reset` to clear local state \
843                     and submit a fresh request."
844                ));
845            }
846            _ => {}
847        }
848        // Sleep with a fast-cancel on stop.
849        for _ in 0..30 {
850            if stop.load(Ordering::SeqCst) {
851                return Ok(RegistrationGate::Stopped);
852            }
853            tokio::time::sleep(Duration::from_secs(1)).await;
854        }
855    }
856}
857
858/// Spawn the WS session + auto-updater, wait for them.  Pulled out of
859/// `run` so tests can drive with a different schedule.
860///
861/// `paused` is the runtime-only Pause / Resume toggle the UI flips.
862/// When set, the WS session advertises `auto_enabled = false` in
863/// heartbeats and refuses new job offers without restarting the
864/// session.
865pub async fn run_loops(
866    cfg: SharedConfig,
867    stop: Arc<AtomicBool>,
868    logs: Arc<Mutex<Vec<LogEntry>>>,
869    busy: Arc<AtomicBool>,
870    paused: Arc<AtomicBool>,
871    observers: WorkerObservers,
872    schedule: LoopSchedule,
873) -> Result<()> {
874    let session = crate::ws::session::spawn_ws_session(
875        cfg.clone(),
876        stop.clone(),
877        logs.clone(),
878        busy.clone(),
879        paused.clone(),
880        observers.clone(),
881        schedule.ws_session,
882    );
883    let auto_updater = spawn_auto_updater(
884        cfg.clone(),
885        stop.clone(),
886        logs.clone(),
887        busy.clone(),
888        schedule,
889    );
890    let (session_result, _) = tokio::join!(session, auto_updater);
891    session_result
892}
893
894/// Default port for the always-on local image API. Override with
895/// `STUDIO_WORKER_LOCAL_API_PORT`.
896pub const DEFAULT_LOCAL_API_PORT: u16 = 4787;
897
898/// Resolve the local API port: a valid `STUDIO_WORKER_LOCAL_API_PORT`
899/// env value wins, then the config's `local_api_port`, then the
900/// built-in default.  An *invalid* env value used to be silently
901/// ignored; now it warn-logs what it fell back to so a typo'd unit
902/// file can't quietly move the API.  Pure so every branch is
903/// unit-testable.
904pub fn resolve_local_api_port(env_value: Option<&str>, cfg_port: Option<u16>) -> u16 {
905    resolve_port(
906        "STUDIO_WORKER_LOCAL_API_PORT",
907        env_value,
908        cfg_port,
909        DEFAULT_LOCAL_API_PORT,
910    )
911}
912
913/// The LAN stream listener's port: `STUDIO_WORKER_STREAM_PORT`, else the
914/// config's `stream_port`, else [`crate::stt_stream::server::DEFAULT_STREAM_PORT`].
915pub fn resolve_stream_port(env_value: Option<&str>, cfg_port: Option<u16>) -> u16 {
916    resolve_port(
917        "STUDIO_WORKER_STREAM_PORT",
918        env_value,
919        cfg_port,
920        crate::stt_stream::server::DEFAULT_STREAM_PORT,
921    )
922}
923
924/// Env beats config beats default; an invalid env value falls back, logged.
925fn resolve_port(
926    env_name: &str,
927    env_value: Option<&str>,
928    cfg_port: Option<u16>,
929    default: u16,
930) -> u16 {
931    let fallback = cfg_port.unwrap_or(default);
932    match env_value {
933        Some(raw) => match raw.parse::<u16>() {
934            Ok(port) => port,
935            Err(_) => {
936                warn!(
937                    target: "studio_worker::local_api",
938                    op = "resolve_port",
939                    invalid = raw,
940                    fallback,
941                    "{env_name} is not a valid port; falling back"
942                );
943                fallback
944            }
945        },
946        None => fallback,
947    }
948}
949
950/// Ensure the per-install local API bearer token exists, minting and
951/// persisting one on first launch.  A failed persist is warn-logged
952/// but non-fatal: the in-memory token still guards this session and
953/// the discovery file still tells clients what it is.
954pub fn ensure_local_api_token(cfg: &SharedConfig, config_path: &std::path::Path) -> String {
955    let mut snap = cfg.lock();
956    if let Some(token) = snap.local_api_token.clone() {
957        return token;
958    }
959    let token = crate::secrets::new_secret_hex();
960    snap.local_api_token = Some(token.clone());
961    let snapshot = snap.clone();
962    drop(snap);
963    if let Err(e) = config::save(&snapshot, config_path) {
964        warn!(
965            target: "studio_worker::local_api",
966            op = "ensure_token",
967            config_path = %config_path.display(),
968            error = %e,
969            "failed to persist the local api token; a fresh one will be minted next launch"
970        );
971    }
972    token
973}
974
975/// Bind the LAN streaming speech listener and serve it on its own thread.
976/// A failed bind is logged and leaves the port at 0, so stream-token
977/// requests answer `stream_listener_down` instead of pointing nowhere.
978#[cfg_attr(coverage_nightly, coverage(off))]
979fn spawn_stream_listener(
980    cfg: &SharedConfig,
981    services: &crate::local_api::ModelServices,
982    observers: &WorkerObservers,
983    stop: Arc<AtomicBool>,
984) {
985    let port = resolve_stream_port(
986        std::env::var("STUDIO_WORKER_STREAM_PORT").ok().as_deref(),
987        cfg.lock().stream_port,
988    );
989    let addr = format!("0.0.0.0:{port}");
990    match crate::stt_stream::server::StreamServer::bind(
991        &addr,
992        services.host.clone(),
993        services.tokens.clone(),
994        observers.clone(),
995    ) {
996        Ok(server) => {
997            let bound = server.local_addr().port();
998            services.stream_port.store(bound, Ordering::SeqCst);
999            tracing::info!(target: "studio_worker::stt_stream", addr = %server.local_addr(), "stream listener listening");
1000            std::thread::spawn(move || server.serve(&stop));
1001        }
1002        Err(err) => tracing::warn!(
1003            target: "studio_worker::stt_stream",
1004            %addr,
1005            error = %err,
1006            "stream listener could not bind; streaming speech is unavailable"
1007        ),
1008    }
1009}
1010
1011/// Build the engine + catalog and start the local image API server on a
1012/// background thread. Returns the thread handle, or `None` when it could not
1013/// start (logged, non-fatal — the studio session keeps running).
1014pub fn spawn_local_api(
1015    control: &crate::control::DaemonControl,
1016    observers: WorkerObservers,
1017    gate: crate::job_gate::JobGate,
1018) -> Option<std::thread::JoinHandle<()>> {
1019    let cfg = control.cfg.clone();
1020    let config_path = control.config_path.as_path();
1021    let stop = control.stop.clone();
1022    let engine: Arc<dyn crate::engine::Engine> = match crate::engine::build(&cfg.lock()) {
1023        Ok(engine) => engine.into(),
1024        Err(err) => {
1025            tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: engine build failed");
1026            return None;
1027        }
1028    };
1029
1030    // `load_for_serving` quarantines corrupt files and drops the save
1031    // path when the file is unreadable, so a later `POST /models` can
1032    // never clobber a catalog the worker couldn't read.  The loaded
1033    // catalog is published into the observers so the WS session can
1034    // mirror studio-offered models into the same shared instance.
1035    let (loaded, catalog_path) =
1036        crate::catalog::Catalog::load_for_serving(crate::config::catalog_path_for(config_path));
1037    *observers.catalog.lock() = loaded;
1038    *observers.catalog_path.lock() = catalog_path.clone();
1039    let catalog = observers.catalog.clone();
1040
1041    let token = ensure_local_api_token(&cfg, config_path);
1042    let port = resolve_local_api_port(
1043        std::env::var("STUDIO_WORKER_LOCAL_API_PORT")
1044            .ok()
1045            .as_deref(),
1046        cfg.lock().local_api_port,
1047    );
1048
1049    let models_root = Some(cfg.lock().models_root.clone());
1050    let host = crate::host::ModelHost::new(
1051        catalog.clone(),
1052        Arc::new(crate::loaders::Loaders::new(cfg.lock().models_root.clone())),
1053        Arc::new(crate::admission::SystemProbe),
1054        crate::residency::Residency::load_for_serving(crate::config::residency_path_for(
1055            config_path,
1056        )),
1057    );
1058    let services = crate::local_api::ModelServices::new(host.clone());
1059    spawn_stream_listener(&cfg, &services, &observers, stop.clone());
1060    let api = crate::local_api::LocalApi::bind(
1061        &format!("127.0.0.1:{port}"),
1062        engine.clone(),
1063        catalog.clone(),
1064        catalog_path.clone(),
1065        observers.clone(),
1066        token.clone(),
1067        gate.clone(),
1068        models_root.clone(),
1069        services.clone(),
1070    )
1071    .or_else(|_| {
1072        crate::local_api::LocalApi::bind(
1073            "127.0.0.1:0",
1074            engine,
1075            catalog,
1076            catalog_path,
1077            observers.clone(),
1078            token.clone(),
1079            gate.clone(),
1080            models_root,
1081            services.clone(),
1082        )
1083    });
1084
1085    let api = match api {
1086        Ok(api) => api.with_control(control.clone()),
1087        Err(err) => {
1088            tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: bind failed");
1089            return None;
1090        }
1091    };
1092
1093    // One-shot GPU-runtime preflight at startup so a missing Vulkan
1094    // loader shows up in the UI + /healthz immediately, instead of
1095    // only when the first image job crashes sd-cli.
1096    set_gpu_runtime_status(
1097        &observers,
1098        crate::engine::sd_provision::vulkan_runtime_status(),
1099    );
1100
1101    // Bring back the models the operator left loaded.
1102    host.restore_residents();
1103
1104    let url = api.url();
1105    *observers.local_api_url.lock() = Some(url.clone());
1106    tracing::info!(target: "studio_worker::local_api", url = %url, "local image API listening");
1107
1108    // Publish URL + token for local clients; removed again after the
1109    // serve loop exits so a stale file can't point at a dead port.
1110    let discovery_path = crate::config::local_api_discovery_path_for(config_path);
1111    if let Some(path) = &discovery_path {
1112        if let Err(e) = crate::local_api::write_discovery_file(path, &url, &token) {
1113            tracing::warn!(
1114                target: "studio_worker::local_api",
1115                error = %e,
1116                path = %path.display(),
1117                "failed to write the local api discovery file"
1118            );
1119        }
1120    }
1121
1122    Some(std::thread::spawn(move || {
1123        api.serve(&stop);
1124        if let Some(path) = &discovery_path {
1125            crate::local_api::remove_discovery_file(path);
1126        }
1127    }))
1128}
1129
1130// ---------------------------------------------------------------------------
1131// Per-tick helpers — pure async fns, easy to drive from unit tests.
1132// ---------------------------------------------------------------------------
1133
1134// (The old per-tick HTTP helpers — heartbeat_tick, claim_tick, log_shipper_tick,
1135//  run_job, ClaimOutcome — lived here.  They are gone with the WS migration.
1136//  See `ws::session::spawn_ws_session` for the replacement that runs the
1137//  whole session in one connected loop.)
1138
1139/// What the auto-updater decided this tick.
1140#[derive(Debug, Clone, PartialEq, Eq)]
1141pub enum AutoUpdateDecision {
1142    /// Auto-update is turned off — do nothing.
1143    Disabled,
1144    /// Worker is currently running a job — skip.
1145    SkippedBusy,
1146    /// Local version is already the latest.
1147    UpToDate,
1148    /// Check failed (network etc.) — leave a log entry, try again later.
1149    CheckError(String),
1150    /// A newer version was applied successfully.  Caller should restart.
1151    Updated,
1152    /// A newer version was found but the install failed.
1153    UpdateError(String),
1154}
1155
1156pub async fn auto_update_tick(
1157    cfg: &Config,
1158    gate: &crate::job_gate::JobGate,
1159    logs: &Arc<Mutex<Vec<LogEntry>>>,
1160) -> AutoUpdateDecision {
1161    if !cfg.auto_update_enabled {
1162        return AutoUpdateDecision::Disabled;
1163    }
1164    // A job in flight: skip the whole check.  The network probe is
1165    // cheap but restarting mid-job is not, and we'd only reject the
1166    // apply below anyway.
1167    if gate.is_busy() {
1168        push_log(
1169            logs,
1170            "info",
1171            "auto-update",
1172            "skipping check: worker is busy on a job",
1173            None,
1174        );
1175        return AutoUpdateDecision::SkippedBusy;
1176    }
1177    let feed = cfg.auto_update_feed.clone();
1178    let prerelease = cfg.auto_update_prerelease;
1179    let logs_for_task = logs.clone();
1180    let gate = gate.clone();
1181    let outcome = tokio::task::spawn_blocking(move || -> Result<AutoUpdateDecision> {
1182        let current = semver::Version::parse(AGENT_VERSION)
1183            .map_err(|e| anyhow!("invalid AGENT_VERSION {AGENT_VERSION}: {e}"))?;
1184        match update::check(&feed, &current, prerelease) {
1185            Ok(update::CheckOutcome::UpToDate { current }) => {
1186                push_log(
1187                    &logs_for_task,
1188                    "info",
1189                    "auto-update",
1190                    &format!("up to date at {current}"),
1191                    None,
1192                );
1193                Ok(AutoUpdateDecision::UpToDate)
1194            }
1195            Ok(update::CheckOutcome::NewerAvailable { current, latest }) => {
1196                // Reserve the one-job slot *before* touching the
1197                // installer, so an offer arriving mid-install is
1198                // rejected as busy (the WS session shares this gate)
1199                // and `restart_self` can never kill an in-flight job.
1200                // A job that started between the is_busy() check and
1201                // here loses the race here and we defer to next tick.
1202                let Some(_reservation) = gate.try_reserve() else {
1203                    push_log(
1204                        &logs_for_task,
1205                        "info",
1206                        "auto-update",
1207                        "update available but a job started; deferring install",
1208                        None,
1209                    );
1210                    return Ok(AutoUpdateDecision::SkippedBusy);
1211                };
1212                push_log(
1213                    &logs_for_task,
1214                    "info",
1215                    "auto-update",
1216                    &format!("update available {current} -> {latest}; applying"),
1217                    None,
1218                );
1219                match update::apply(&feed, &latest) {
1220                    Ok(()) => {
1221                        push_log(
1222                            &logs_for_task,
1223                            "info",
1224                            "auto-update",
1225                            "binary replaced; restart pending",
1226                            None,
1227                        );
1228                        Ok(AutoUpdateDecision::Updated)
1229                    }
1230                    Err(e) => {
1231                        push_log(
1232                            &logs_for_task,
1233                            "error",
1234                            "auto-update",
1235                            &format!("update failed: {e}"),
1236                            None,
1237                        );
1238                        Ok(AutoUpdateDecision::UpdateError(e.to_string()))
1239                    }
1240                }
1241            }
1242            Err(e) => {
1243                push_log(
1244                    &logs_for_task,
1245                    "warn",
1246                    "auto-update",
1247                    &format!("check failed: {e}"),
1248                    None,
1249                );
1250                Ok(AutoUpdateDecision::CheckError(e.to_string()))
1251            }
1252        }
1253    })
1254    .await;
1255    match outcome {
1256        Ok(Ok(decision)) => decision,
1257        Ok(Err(e)) => AutoUpdateDecision::CheckError(e.to_string()),
1258        Err(e) => AutoUpdateDecision::CheckError(e.to_string()),
1259    }
1260}
1261
1262// ---------------------------------------------------------------------------
1263// Long-running task wrappers — they exist solely to call the ticks in a
1264// loop on a schedule.  All real logic lives in the ticks.
1265// ---------------------------------------------------------------------------
1266
1267// (`spawn_heartbeat`, `spawn_claim_loop`, `spawn_log_shipper`, and
1268//  `next_delay_for` lived here.  Their behaviour is now carried by the
1269//  WS-driven tasks in `ws::session`.)
1270
1271/// Sleep up to `total`, re-checking `stop` every `tick` and returning
1272/// the instant a shutdown is requested.  Keeps long idle waits (the
1273/// auto-update tick here, reconnect backoff in the WS session)
1274/// responsive to SIGTERM / SIGINT without busy-looping.  Shared by the
1275/// runtime auto-updater and `ws::session`.
1276pub(crate) async fn wait_with_stop(total: Duration, stop: &Arc<AtomicBool>, tick: Duration) {
1277    let mut elapsed = Duration::ZERO;
1278    while elapsed < total {
1279        if stop.load(Ordering::SeqCst) {
1280            return;
1281        }
1282        let next = tick.min(total - elapsed);
1283        tokio::time::sleep(next).await;
1284        elapsed += next;
1285    }
1286}
1287
1288pub fn spawn_auto_updater(
1289    cfg: SharedConfig,
1290    stop: Arc<AtomicBool>,
1291    logs: Arc<Mutex<Vec<LogEntry>>>,
1292    busy: Arc<AtomicBool>,
1293    schedule: LoopSchedule,
1294) -> tokio::task::JoinHandle<()> {
1295    tokio::spawn(async move {
1296        let mut elapsed = Duration::from_secs(0);
1297        while !stop.load(Ordering::SeqCst) {
1298            // Stop-aware idle wait: a shutdown signal during this window
1299            // wakes the loop within `schedule.shutdown_tick` instead of
1300            // leaving `run_loops`' join() blocked for a full
1301            // `auto_update_tick`.
1302            wait_with_stop(schedule.auto_update_tick, &stop, schedule.shutdown_tick).await;
1303            if stop.load(Ordering::SeqCst) {
1304                break;
1305            }
1306            elapsed += schedule.auto_update_tick;
1307            let snapshot = cfg.lock().clone();
1308            if elapsed < Duration::from_secs(snapshot.auto_update_interval_secs) {
1309                continue;
1310            }
1311            elapsed = Duration::from_secs(0);
1312            let gate = crate::job_gate::JobGate::from_shared(busy.clone());
1313            let decision = auto_update_tick(&snapshot, &gate, &logs).await;
1314            if matches!(decision, AutoUpdateDecision::Updated) {
1315                stop.store(true, Ordering::SeqCst);
1316                update::restart_self();
1317            }
1318        }
1319    })
1320}
1321
1322// (`run_job` lived here.  See `ws::session::run_offered_job` for the
1323//  WS-driven replacement.)
1324
1325pub fn prompt_for(task: &Task) -> String {
1326    match task {
1327        Task::Image(p) => p.prompt.clone(),
1328        Task::Llm(p) => p
1329            .messages
1330            .last()
1331            .map(|m| m.content.clone())
1332            .unwrap_or_default(),
1333        Task::AudioStt(p) => p.input_url.clone(),
1334        Task::AudioTts(p) => p.text.clone(),
1335        Task::Video(p) => p.prompt.clone(),
1336    }
1337}
1338
1339pub fn is_unsupported_kind(e: &anyhow::Error) -> bool {
1340    // Typed check first — survives context wrapping and rewording.
1341    // The string check remains as a fallback for error paths that
1342    // haven't migrated to `engine::UnsupportedTask` yet.
1343    e.chain().any(|cause| {
1344        cause
1345            .downcast_ref::<crate::engine::UnsupportedTask>()
1346            .is_some()
1347    }) || e.to_string().contains("cannot serve")
1348}
1349
1350// ---------------------------------------------------------------------------
1351// Helpers
1352// ---------------------------------------------------------------------------
1353
1354pub fn build_capabilities(cfg: &Config, engine: &dyn Engine) -> WorkerCapabilities {
1355    build_capabilities_with(cfg, engine, true)
1356}
1357
1358/// Same as [`build_capabilities`] but lets the caller drive
1359/// `auto_enabled` from a runtime pause flag (the UI's Pause/Resume
1360/// button).  The persisted [`Config`] no longer carries that bit —
1361/// it's an in-process toggle.
1362pub fn build_capabilities_with(
1363    cfg: &Config,
1364    engine: &dyn Engine,
1365    auto_enabled: bool,
1366) -> WorkerCapabilities {
1367    let vram = sys::detect_vram_gb().unwrap_or(0.0);
1368    let caps = engine.capabilities();
1369    let supported_models_per_kind = caps.supported_models_per_kind.clone();
1370    let task_kinds = caps.kinds();
1371    // Legacy `supported_models` is a flat list across all kinds so the
1372    // studio API's claim filter (which only knows about this field) can
1373    // match jobs of any modality this worker can serve.
1374    let supported_models = {
1375        let mut all = caps.flat_models();
1376        all.sort();
1377        all.dedup();
1378        all
1379    };
1380
1381    WorkerCapabilities {
1382        machine_name: sys::machine_name(),
1383        username: sys::username(),
1384        agent_version: AGENT_VERSION.to_string(),
1385        engine: engine.name().to_string(),
1386        vram_total_gb: vram,
1387        vram_threshold_gb: cfg.vram_threshold_gb,
1388        auto_enabled,
1389        // The tray UI always installs its login entry, so a build with the
1390        // UI starts on its own; a headless build relies on the OS service.
1391        auto_start: cfg!(feature = "ui"),
1392        supported_models,
1393        task_kinds,
1394        supported_models_per_kind,
1395    }
1396}
1397
1398/// One-line, operator-facing summary of what this worker advertises to
1399/// the studio on the WS handshake.  Logged once per session attempt so
1400/// the worker's own logs (and the studio's shipped-log view) record
1401/// exactly which task kinds, models, and VRAM budget were offered — the
1402/// missing complement to [`log_startup_banner`], which only covers the
1403/// loaded config.  Without it, an operator chasing "why won't my worker
1404/// claim image jobs" has no record of what the worker told the studio
1405/// it could do.  Pure so the formatting is unit-tested without a live
1406/// session.
1407pub fn summarize_capabilities(caps: &WorkerCapabilities) -> String {
1408    let kinds = caps
1409        .task_kinds
1410        .iter()
1411        .map(|k| k.as_str())
1412        .collect::<Vec<_>>()
1413        .join(", ");
1414    format!(
1415        "advertising engine={}, vram={:.1}/{:.1}GB threshold, auto_enabled={}, \
1416         kinds=[{}], {} model(s)=[{}]",
1417        caps.engine,
1418        caps.vram_total_gb,
1419        caps.vram_threshold_gb,
1420        caps.auto_enabled,
1421        kinds,
1422        caps.supported_models.len(),
1423        caps.supported_models.join(", "),
1424    )
1425}
1426
1427/// Operator-facing warning when the configured VRAM threshold exceeds
1428/// the GPU VRAM the worker actually detected.
1429///
1430/// The studio matches jobs to a worker purely by its advertised
1431/// `vram_threshold_gb`, so a threshold set above the card's real
1432/// capacity — e.g. the default 12 GB on an 8 GB consumer GPU — makes the
1433/// worker accept jobs its GPU can't fit: they load, exhaust VRAM, and
1434/// fail with an OOM the operator then has to trace back to a config
1435/// value.  Surfacing it on the handshake (one line next to the
1436/// capability summary) turns a silent OOM-on-claim into an actionable
1437/// "lower your threshold" breadcrumb.
1438///
1439/// Only fires when the VRAM probe returned a real positive total: a
1440/// detected 0 GB means the probe failed (no `nvidia-smi` / sysfs tree,
1441/// or a non-NVIDIA GPU we can't size), where the threshold is the only
1442/// capacity signal we have and second-guessing it would be wrong.  The
1443/// boundary is strict (`threshold > total`), so a threshold that exactly
1444/// matches the card stays silent.  Pure so the wording + boundary are
1445/// unit-tested without a live GPU.
1446pub fn vram_threshold_warning(caps: &WorkerCapabilities) -> Option<String> {
1447    if caps.vram_total_gb > 0.0 && caps.vram_threshold_gb > caps.vram_total_gb {
1448        Some(format!(
1449            "configured VRAM threshold {:.1}GB exceeds detected GPU VRAM {:.1}GB; \
1450             the studio may offer jobs larger than this card can fit and they will \
1451             OOM on load — lower vram_threshold_gb to at or below {:.1}GB",
1452            caps.vram_threshold_gb, caps.vram_total_gb, caps.vram_total_gb
1453        ))
1454    } else {
1455        None
1456    }
1457}
1458
1459pub fn push_log(
1460    logs: &Arc<Mutex<Vec<LogEntry>>>,
1461    level: &str,
1462    category: &str,
1463    message: &str,
1464    job_id: Option<String>,
1465) {
1466    push_log_with_observers(logs, None, level, category, message, job_id);
1467}
1468
1469/// Same as [`push_log`] but also appends to
1470/// [`WorkerObservers::recent_logs`] so the UI's Logs page keeps a
1471/// rolling display window.  The WS session uses this variant so
1472/// operators don't see the Logs page blank out every second when the
1473/// shipping queue gets drained.
1474pub fn push_log_with_observers(
1475    logs: &Arc<Mutex<Vec<LogEntry>>>,
1476    observers: Option<&WorkerObservers>,
1477    level: &str,
1478    category: &str,
1479    message: &str,
1480    job_id: Option<String>,
1481) {
1482    let entry = LogEntry {
1483        ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1484        level: level.to_string(),
1485        category: category.to_string(),
1486        message: message.to_string(),
1487        job_id,
1488    };
1489    // Carry the job id as a structured field so operators can pivot
1490    // shipped studio logs / Sentry breadcrumbs on it. `Option<&str>`
1491    // only records the field when `Some`, so jobless breadcrumbs stay
1492    // free of a noisy empty `job_id`.
1493    let job_id = entry.job_id.as_deref();
1494    if level == "error" {
1495        tracing::error!(target: "studio_worker", job_id, "[{category}] {message}");
1496    } else if level == "warn" {
1497        tracing::warn!(target: "studio_worker", job_id, "[{category}] {message}");
1498    } else {
1499        info!(target: "studio_worker", job_id, "[{category}] {message}");
1500    }
1501    {
1502        let mut queue = logs.lock();
1503        if queue.len() >= LOG_SHIP_QUEUE_CAP {
1504            // +1 for the entry below, +1 for the drop marker.
1505            let overflow = queue.len() + 2 - LOG_SHIP_QUEUE_CAP;
1506            queue.drain(0..overflow);
1507            queue.push(LogEntry {
1508                ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1509                level: "warn".to_string(),
1510                category: "logs".to_string(),
1511                message: format!(
1512                    "ship queue full ({LOG_SHIP_QUEUE_CAP} entries); dropped {overflow} oldest"
1513                ),
1514                job_id: None,
1515            });
1516        }
1517        queue.push(entry.clone());
1518    }
1519    if let Some(o) = observers {
1520        o.worker_log().push(entry);
1521    }
1522}
1523
1524/// Put a drained-but-unsent batch back at the front of the ship queue
1525/// so it survives for the next session attempt.  Entries that arrived
1526/// while the batch was in flight stay behind it (newest last).  The
1527/// combined queue is clipped to [`LOG_SHIP_QUEUE_CAP`], dropping the
1528/// oldest entries first.
1529pub fn restore_unshipped(logs: &Arc<Mutex<Vec<LogEntry>>>, mut batch: Vec<LogEntry>) {
1530    let mut queue = logs.lock();
1531    batch.append(&mut queue);
1532    *queue = batch;
1533    if queue.len() > LOG_SHIP_QUEUE_CAP {
1534        let overflow = queue.len() - LOG_SHIP_QUEUE_CAP;
1535        queue.drain(0..overflow);
1536    }
1537}
1538
1539#[cfg(test)]
1540mod tests {
1541    use super::*;
1542    use crate::config::Config;
1543    use crate::engine::SyntheticEngine;
1544
1545    fn push_messages(observers: &WorkerObservers, count: usize) {
1546        let logs = Arc::new(Mutex::new(Vec::new()));
1547        for i in 0..count {
1548            push_log_with_observers(&logs, Some(observers), "info", "t", &format!("m{i}"), None);
1549        }
1550    }
1551
1552    #[test]
1553    fn recent_logs_after_answers_only_newer_entries() {
1554        let observers = WorkerObservers::default();
1555        push_messages(&observers, 3);
1556        let (all, newest) = recent_logs_after(&observers, 0);
1557        assert_eq!((all.len(), newest), (3, 3));
1558        let (newer, _) = recent_logs_after(&observers, 2);
1559        assert_eq!(newer.len(), 1);
1560        assert_eq!(newer[0].message, "m2");
1561        assert!(recent_logs_after(&observers, 3).0.is_empty());
1562    }
1563
1564    #[test]
1565    fn the_daemons_observers_share_the_global_worker_log() {
1566        let a = WorkerObservers::with_global_worker_log();
1567        let b = WorkerObservers::with_global_worker_log();
1568        assert!(Arc::ptr_eq(&a.recent_logs, &b.recent_logs));
1569        assert!(!Arc::ptr_eq(
1570            &a.recent_logs,
1571            &WorkerObservers::default().recent_logs
1572        ));
1573    }
1574
1575    #[test]
1576    fn recent_logs_after_a_restart_answers_the_whole_ring() {
1577        let observers = WorkerObservers::default();
1578        push_messages(&observers, 2);
1579        assert_eq!(recent_logs_after(&observers, 99).0.len(), 2);
1580    }
1581
1582    #[test]
1583    fn recent_logs_after_skips_entries_that_left_the_ring() {
1584        let observers = WorkerObservers::default();
1585        push_messages(&observers, RECENT_LOGS_CAP + 5);
1586        let (all, newest) = recent_logs_after(&observers, 0);
1587        assert_eq!(all.len(), RECENT_LOGS_CAP);
1588        assert_eq!(all[0].message, "m5");
1589        assert_eq!(newest, (RECENT_LOGS_CAP + 5) as u64);
1590    }
1591
1592    #[test]
1593    fn is_unsupported_kind_detects_typed_unsupported_task() {
1594        let err: anyhow::Error =
1595            crate::engine::UnsupportedTask::new("synthetic", TaskKind::Llm).into();
1596        assert!(is_unsupported_kind(&err));
1597        // The message keeps the legacy operator-facing shape.
1598        assert!(err.to_string().contains("cannot serve llm"));
1599    }
1600
1601    #[test]
1602    fn is_unsupported_kind_survives_context_wrapping() {
1603        // String sniffing broke as soon as a caller added context (the
1604        // outer message no longer contains "cannot serve"); the typed
1605        // downcast searches the whole chain.
1606        let err = anyhow::Error::from(crate::engine::UnsupportedTask::new(
1607            "sdcpp",
1608            TaskKind::AudioTts,
1609        ))
1610        .context("dispatching job j-1");
1611        assert!(is_unsupported_kind(&err));
1612    }
1613
1614    fn entry(message: &str) -> LogEntry {
1615        LogEntry {
1616            ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1617            level: "info".into(),
1618            category: "test".into(),
1619            message: message.into(),
1620            job_id: None,
1621        }
1622    }
1623
1624    #[test]
1625    fn restore_unshipped_requeues_batch_ahead_of_newer_entries() {
1626        // A batch the shipper drained but failed to send must survive
1627        // for the next session, ordered before entries that arrived
1628        // while it was in flight.
1629        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(vec![entry("newer")]));
1630        restore_unshipped(&logs, vec![entry("batch-1"), entry("batch-2")]);
1631        let queue = logs.lock();
1632        let order: Vec<&str> = queue.iter().map(|e| e.message.as_str()).collect();
1633        assert_eq!(order, vec!["batch-1", "batch-2", "newer"]);
1634    }
1635
1636    #[test]
1637    fn restore_unshipped_respects_the_queue_cap() {
1638        // Requeueing must never grow the queue past the ship cap; the
1639        // oldest (front) entries give way so the newest survive.
1640        let logs: Arc<Mutex<Vec<LogEntry>>> =
1641            Arc::new(Mutex::new(vec![entry("newest"); LOG_SHIP_QUEUE_CAP]));
1642        restore_unshipped(&logs, vec![entry("old-batch"); 100]);
1643        let queue = logs.lock();
1644        assert_eq!(queue.len(), LOG_SHIP_QUEUE_CAP);
1645        assert_eq!(
1646            queue.last().map(|e| e.message.as_str()),
1647            Some("newest"),
1648            "newest entries must survive the cap"
1649        );
1650    }
1651
1652    #[test]
1653    fn ship_queue_is_bounded_and_records_dropped_entries() {
1654        // The WS shipper only drains while a session is connected; a
1655        // long approval wait / reconnect backoff must not grow the
1656        // queue without bound.
1657        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1658        for i in 0..(LOG_SHIP_QUEUE_CAP + 100) {
1659            push_log_with_observers(&logs, None, "info", "test", &format!("entry {i}"), None);
1660        }
1661        let queue = logs.lock();
1662        assert!(
1663            queue.len() <= LOG_SHIP_QUEUE_CAP,
1664            "ship queue exceeded its cap: {}",
1665            queue.len()
1666        );
1667        // The newest entry always survives.
1668        assert_eq!(
1669            queue.last().map(|e| e.message.as_str()),
1670            Some(format!("entry {}", LOG_SHIP_QUEUE_CAP + 99).as_str())
1671        );
1672        // Loss is visible: a marker entry names how many were dropped.
1673        assert!(
1674            queue
1675                .iter()
1676                .any(|e| e.level == "warn" && e.message.contains("dropped")),
1677            "overflow must leave a visible drop marker"
1678        );
1679    }
1680
1681    #[test]
1682    fn recent_logs_ring_is_bounded_at_recent_logs_cap() {
1683        // The observer ring backing the UI Logs page is never drained
1684        // (unlike the ship queue, which the WS shipper empties every
1685        // second), so this cap is its only bound.  A regression that
1686        // dropped the eviction loop would leak memory for the lifetime
1687        // of a long-running worker; one that flipped `pop_front` for
1688        // `pop_back` would silently retain the *oldest* entries and show
1689        // a stale Logs page.  Mirrors `recent_jobs_ring_caps_at_*`.
1690        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1691        let observers = WorkerObservers::default();
1692        let overflow = 25;
1693        for i in 0..(RECENT_LOGS_CAP + overflow) {
1694            push_log_with_observers(
1695                &logs,
1696                Some(&observers),
1697                "info",
1698                "test",
1699                &format!("entry {i}"),
1700                None,
1701            );
1702        }
1703        let ring = observers.recent_logs.lock();
1704        assert_eq!(
1705            ring.len(),
1706            RECENT_LOGS_CAP,
1707            "the recent-logs ring must cap at RECENT_LOGS_CAP"
1708        );
1709        // Newest entries go to the back; the oldest `overflow` entries
1710        // must have been evicted from the front.
1711        assert_eq!(
1712            ring.back().map(|e| e.message.as_str()),
1713            Some(format!("entry {}", RECENT_LOGS_CAP + overflow - 1).as_str()),
1714            "the newest entry must survive at the back of the ring"
1715        );
1716        assert_eq!(
1717            ring.front().map(|e| e.message.as_str()),
1718            Some(format!("entry {overflow}").as_str()),
1719            "the oldest surviving entry must be entry #overflow (older evicted)"
1720        );
1721    }
1722
1723    #[test]
1724    fn session_state_summaries_carry_recovery_actions_for_terminal_states() {
1725        assert!(SessionState::default() == SessionState::WaitingForApproval);
1726        assert!(SessionState::Connected.summary().contains("connected"));
1727        assert!(SessionState::Reconnecting { attempt: 3 }
1728            .summary()
1729            .contains("attempt 3"));
1730        // The two terminal states must name the fix so a stranded
1731        // worker isn't a silent dead end.
1732        let auth = SessionState::AuthFailed {
1733            reason: "bad token".into(),
1734        }
1735        .summary();
1736        assert!(auth.contains("register --reset"), "got: {auth}");
1737        assert!(auth.contains("bad token"));
1738        assert!(SessionState::Fatal {
1739            reason: "boom".into()
1740        }
1741        .summary()
1742        .contains("boom"));
1743    }
1744
1745    #[test]
1746    fn set_session_state_updates_the_observer_slot() {
1747        let observers = WorkerObservers::default();
1748        assert_eq!(
1749            *observers.session_state.lock(),
1750            SessionState::WaitingForApproval
1751        );
1752        set_session_state(&observers, SessionState::Connected);
1753        assert_eq!(*observers.session_state.lock(), SessionState::Connected);
1754    }
1755
1756    #[test]
1757    fn sync_studio_model_mirrors_into_the_shared_catalog_and_persists() {
1758        use crate::types::{ModelCliDefaults, ModelEngine, ModelSource};
1759        let dir = tempfile::tempdir().unwrap();
1760        let path = dir.path().join("models.json");
1761        let observers = WorkerObservers::default();
1762        *observers.catalog_path.lock() = Some(path.clone());
1763
1764        let source = ModelSource {
1765            engine: ModelEngine::Synthetic,
1766            files: vec![],
1767            cli_defaults: ModelCliDefaults::default(),
1768        };
1769        sync_studio_model(&observers, "studio-llm", TaskKind::Llm, &source);
1770
1771        // In-memory catalog gained the studio model…
1772        assert!(observers.catalog.lock().get("studio-llm").is_some());
1773        assert_eq!(
1774            observers.catalog.lock().get("studio-llm").unwrap().origin,
1775            "studio"
1776        );
1777        // …and it was persisted to disk.
1778        let reloaded = crate::catalog::Catalog::load_or_seed(&path).unwrap();
1779        assert!(reloaded.get("studio-llm").is_some());
1780    }
1781
1782    #[test]
1783    fn sync_studio_model_without_a_path_stays_in_memory_only() {
1784        use crate::types::{ModelCliDefaults, ModelEngine, ModelSource};
1785        let observers = WorkerObservers::default();
1786        // No catalog_path set (unreadable file / None): must not panic,
1787        // just updates the in-memory catalog.
1788        let source = ModelSource {
1789            engine: ModelEngine::Synthetic,
1790            files: vec![],
1791            cli_defaults: ModelCliDefaults::default(),
1792        };
1793        sync_studio_model(&observers, "m", TaskKind::Image, &source);
1794        assert!(observers.catalog.lock().get("m").is_some());
1795    }
1796
1797    #[test]
1798    fn set_gpu_runtime_status_records_ok_without_warning() {
1799        let observers = WorkerObservers::default();
1800        assert!(observers.gpu_runtime.lock().is_none(), "unprobed at first");
1801        let out = crate::test_support::capture({
1802            let observers = observers.clone();
1803            move || set_gpu_runtime_status(&observers, Ok(()))
1804        });
1805        let status = observers.gpu_runtime.lock().clone().unwrap();
1806        assert!(status.ok);
1807        assert!(status.detail.contains("available"));
1808        assert!(
1809            !out.contains("GPU runtime missing"),
1810            "the ok path must not warn: {out}"
1811        );
1812    }
1813
1814    #[test]
1815    fn set_gpu_runtime_status_records_and_warns_the_remedy_when_missing() {
1816        let observers = WorkerObservers::default();
1817        let out = crate::test_support::capture({
1818            let observers = observers.clone();
1819            move || {
1820                set_gpu_runtime_status(
1821                    &observers,
1822                    Err(anyhow!("Vulkan runtime not available: install libvulkan1")),
1823                )
1824            }
1825        });
1826        let status = observers.gpu_runtime.lock().clone().unwrap();
1827        assert!(!status.ok);
1828        assert!(
1829            status.detail.contains("libvulkan1"),
1830            "got: {}",
1831            status.detail
1832        );
1833        assert!(
1834            out.contains("GPU runtime missing") && out.contains("WARN"),
1835            "a missing runtime must warn with the remedy: {out}"
1836        );
1837    }
1838
1839    #[test]
1840    fn capabilities_advertises_all_synthetic_kinds() {
1841        let cfg = Config::default();
1842        let engine = SyntheticEngine::new();
1843        let cap = build_capabilities(&cfg, &engine);
1844        assert_eq!(cap.engine, "synthetic");
1845        assert_eq!(cap.task_kinds.len(), TaskKind::ALL.len());
1846        assert!(cap.auto_enabled, "default capability snapshot is unpaused");
1847        for kind in TaskKind::ALL {
1848            assert!(cap.supported_models_per_kind.contains_key(&kind));
1849        }
1850    }
1851
1852    #[test]
1853    fn capabilities_with_paused_flag_drives_auto_enabled() {
1854        let cfg = Config::default();
1855        let engine = SyntheticEngine::new();
1856        let paused_caps = build_capabilities_with(&cfg, &engine, false);
1857        assert!(!paused_caps.auto_enabled);
1858    }
1859
1860    #[test]
1861    fn summarize_capabilities_lists_engine_kinds_models_vram_and_pause_state() {
1862        let cfg = Config {
1863            vram_threshold_gb: 6.0,
1864            ..Config::default()
1865        };
1866        let engine = SyntheticEngine::new();
1867        let caps = build_capabilities_with(&cfg, &engine, true);
1868        let summary = summarize_capabilities(&caps);
1869        // Engine name + every advertised kind is present.
1870        assert!(summary.contains("engine=synthetic"), "got: {summary}");
1871        for kind in &caps.task_kinds {
1872            assert!(
1873                summary.contains(kind.as_str()),
1874                "missing kind {} in: {summary}",
1875                kind.as_str()
1876            );
1877        }
1878        // Model count + an actual advertised model id are present.
1879        assert!(
1880            summary.contains(&format!("{} model(s)", caps.supported_models.len())),
1881            "missing model count in: {summary}"
1882        );
1883        assert!(
1884            summary.contains("synthetic"),
1885            "missing model id in: {summary}"
1886        );
1887        // VRAM budget (total/threshold) + unpaused state are visible.
1888        assert!(
1889            summary.contains("6.0"),
1890            "missing vram threshold in: {summary}"
1891        );
1892        assert!(summary.contains("auto_enabled=true"), "got: {summary}");
1893    }
1894
1895    #[test]
1896    fn summarize_capabilities_reflects_paused_state() {
1897        let cfg = Config::default();
1898        let engine = SyntheticEngine::new();
1899        let caps = build_capabilities_with(&cfg, &engine, false);
1900        assert!(
1901            summarize_capabilities(&caps).contains("auto_enabled=false"),
1902            "paused worker must advertise auto_enabled=false"
1903        );
1904    }
1905
1906    /// Build a capability snapshot, then override the two VRAM fields so
1907    /// the threshold/total relationship is deterministic regardless of
1908    /// the host's real GPU (the probe is `0.0` on CI).
1909    fn caps_with_vram(total_gb: f32, threshold_gb: f32) -> WorkerCapabilities {
1910        let mut caps = build_capabilities_with(&Config::default(), &SyntheticEngine::new(), true);
1911        caps.vram_total_gb = total_gb;
1912        caps.vram_threshold_gb = threshold_gb;
1913        caps
1914    }
1915
1916    #[test]
1917    fn vram_threshold_warning_flags_threshold_above_detected_vram() {
1918        // The default 12 GB threshold on an 8 GB card: the studio will
1919        // offer up-to-12 GB jobs this GPU can't fit, and they OOM on
1920        // load.  The breadcrumb must name both numbers and the config
1921        // key the operator has to lower.
1922        let warning = vram_threshold_warning(&caps_with_vram(8.0, 12.0))
1923            .expect("threshold above detected VRAM must warn");
1924        assert!(warning.contains("12.0"), "missing threshold in: {warning}");
1925        assert!(
1926            warning.contains("8.0"),
1927            "missing detected VRAM in: {warning}"
1928        );
1929        assert!(
1930            warning.contains("vram_threshold_gb"),
1931            "must name the config key to change: {warning}"
1932        );
1933    }
1934
1935    #[test]
1936    fn vram_threshold_warning_silent_when_threshold_within_detected_vram() {
1937        // A 24 GB card with a 12 GB threshold is correctly conservative.
1938        assert!(vram_threshold_warning(&caps_with_vram(24.0, 12.0)).is_none());
1939    }
1940
1941    #[test]
1942    fn vram_threshold_warning_silent_when_threshold_equals_detected() {
1943        // The boundary is strict: a threshold that exactly matches the
1944        // card fits, so it stays silent.
1945        assert!(vram_threshold_warning(&caps_with_vram(12.0, 12.0)).is_none());
1946    }
1947
1948    #[test]
1949    fn vram_threshold_warning_silent_when_vram_undetected() {
1950        // A detected 0 GB means the probe failed (no nvidia-smi / sysfs)
1951        // or it's a non-NVIDIA GPU we can't size; the threshold is then
1952        // the only capacity signal we have, so second-guessing it with a
1953        // spurious OOM warning would be wrong.
1954        assert!(vram_threshold_warning(&caps_with_vram(0.0, 12.0)).is_none());
1955    }
1956
1957    #[test]
1958    fn prompt_for_extracts_per_kind() {
1959        let image = Task::Image(ImageParams {
1960            prompt: "a stone golem".into(),
1961            ..Default::default()
1962        });
1963        assert_eq!(prompt_for(&image), "a stone golem");
1964
1965        let llm = Task::Llm(LlmParams {
1966            messages: vec![
1967                ChatMessage {
1968                    role: "system".into(),
1969                    content: "be helpful".into(),
1970                },
1971                ChatMessage {
1972                    role: "user".into(),
1973                    content: "hi".into(),
1974                },
1975            ],
1976            max_tokens: 32,
1977            temperature: 0.5,
1978            ..Default::default()
1979        });
1980        assert_eq!(prompt_for(&llm), "hi");
1981
1982        let llm_empty = Task::Llm(LlmParams {
1983            messages: vec![],
1984            ..Default::default()
1985        });
1986        assert_eq!(prompt_for(&llm_empty), "");
1987
1988        let stt = Task::AudioStt(AudioSttParams {
1989            input_url: "https://example.com/clip.wav".into(),
1990            ..Default::default()
1991        });
1992        assert_eq!(prompt_for(&stt), "https://example.com/clip.wav");
1993
1994        let tts = Task::AudioTts(AudioTtsParams {
1995            text: "hi there".into(),
1996            voice: "v".into(),
1997            ext: "wav".into(),
1998            ..Default::default()
1999        });
2000        assert_eq!(prompt_for(&tts), "hi there");
2001
2002        let video = Task::Video(VideoParams {
2003            prompt: "a tiny dragon".into(),
2004            seconds: 1.0,
2005            width: 256,
2006            height: 256,
2007            ext: "mp4".into(),
2008            ..Default::default()
2009        });
2010        assert_eq!(prompt_for(&video), "a tiny dragon");
2011    }
2012
2013    #[test]
2014    fn truncate_prompt_passes_short_through_and_clips_long_prompts() {
2015        // Under the cap → returned verbatim, no ellipsis.
2016        let short = "a stone golem";
2017        assert_eq!(truncate_prompt(short), short);
2018
2019        // Exactly at the cap is the boundary: still untouched.
2020        let exactly = "x".repeat(PROMPT_PREVIEW_CHARS);
2021        assert_eq!(
2022            truncate_prompt(&exactly),
2023            exactly,
2024            "a prompt exactly at the cap must not be clipped"
2025        );
2026
2027        // One past the cap → clipped to PROMPT_PREVIEW_CHARS chars plus
2028        // the single ellipsis terminator.
2029        let over = "y".repeat(PROMPT_PREVIEW_CHARS + 1);
2030        let clipped = truncate_prompt(&over);
2031        assert_eq!(
2032            clipped.chars().count(),
2033            PROMPT_PREVIEW_CHARS + 1,
2034            "clipped preview is the cap plus one ellipsis char"
2035        );
2036        assert!(
2037            clipped.ends_with('\u{2026}'),
2038            "a clipped preview ends with an ellipsis"
2039        );
2040        assert_eq!(
2041            clipped
2042                .chars()
2043                .take(PROMPT_PREVIEW_CHARS)
2044                .collect::<String>(),
2045            "y".repeat(PROMPT_PREVIEW_CHARS),
2046            "the kept prefix is the first PROMPT_PREVIEW_CHARS chars"
2047        );
2048    }
2049
2050    #[test]
2051    fn truncate_prompt_clips_on_char_boundaries_for_multibyte_text() {
2052        // Each char here is 3 bytes, so the cap-th *byte* lands
2053        // mid-codepoint: a naive `&s[..PROMPT_PREVIEW_CHARS]` byte slice
2054        // would panic.  `truncate_prompt` counts chars, so a one-over
2055        // multibyte prompt clips cleanly to the cap plus the ellipsis.
2056        let multibyte = "\u{3042}".repeat(PROMPT_PREVIEW_CHARS + 1);
2057        let clipped = truncate_prompt(&multibyte);
2058        assert_eq!(clipped.chars().count(), PROMPT_PREVIEW_CHARS + 1);
2059        assert!(clipped.ends_with('\u{2026}'));
2060        assert_eq!(
2061            clipped.chars().filter(|c| *c == '\u{3042}').count(),
2062            PROMPT_PREVIEW_CHARS,
2063            "exactly PROMPT_PREVIEW_CHARS multibyte chars survive the clip"
2064        );
2065    }
2066
2067    #[test]
2068    fn is_unsupported_kind_matches_engine_message() {
2069        let err = anyhow!("multi engine cannot serve llm tasks");
2070        assert!(is_unsupported_kind(&err));
2071        let other = anyhow!("network timeout");
2072        assert!(!is_unsupported_kind(&other));
2073    }
2074
2075    #[test]
2076    fn format_status_includes_every_field() {
2077        let cfg = Config::default();
2078        let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
2079        assert!(out.contains("config path:"));
2080        assert!(out.contains("api_base_url:"));
2081        assert!(out.contains("registration:"));
2082        assert!(out.contains("not registered"));
2083        assert!(out.contains("models_root:"));
2084        assert!(out.contains("auto_update:"));
2085        assert!(out.contains("update_interval:"));
2086    }
2087
2088    #[test]
2089    fn format_status_shows_worker_id_when_registered() {
2090        let cfg = Config {
2091            worker_id: Some("w-abc".into()),
2092            auth_token: Some("tok".into()),
2093            ..Config::default()
2094        };
2095        let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
2096        assert!(out.contains("w-abc"));
2097        assert!(out.contains("approved"));
2098    }
2099
2100    #[test]
2101    fn format_status_shows_pending_request_id() {
2102        let cfg = Config {
2103            registration_request_id: Some("rr-7".into()),
2104            ..Config::default()
2105        };
2106        let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
2107        assert!(out.contains("pending operator approval"));
2108        assert!(out.contains("rr-7"));
2109    }
2110
2111    #[test]
2112    fn format_check_outcome_handles_both_branches() {
2113        let up = update::CheckOutcome::UpToDate {
2114            current: semver::Version::new(1, 2, 3),
2115        };
2116        assert!(format_check_outcome(&up).contains("up to date"));
2117        let newer = update::CheckOutcome::NewerAvailable {
2118            current: semver::Version::new(1, 2, 3),
2119            latest: semver::Version::new(1, 3, 0),
2120        };
2121        let s = format_check_outcome(&newer);
2122        assert!(s.contains("1.2.3 -> 1.3.0"));
2123    }
2124
2125    #[test]
2126    fn push_log_appends_an_entry() {
2127        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2128        push_log(&logs, "info", "test", "hi", None);
2129        push_log(&logs, "warn", "test", "wat", Some("j-1".into()));
2130        push_log(&logs, "error", "test", "boom", None);
2131        let v = logs.lock();
2132        assert_eq!(v.len(), 3);
2133        assert_eq!(v[0].level, "info");
2134        assert_eq!(v[1].level, "warn");
2135        assert_eq!(v[1].job_id.as_deref(), Some("j-1"));
2136        assert_eq!(v[2].level, "error");
2137    }
2138
2139    #[test]
2140    fn push_log_emits_job_id_as_a_structured_tracing_field() {
2141        // Operators correlating shipped studio logs / Sentry
2142        // breadcrumbs by job need the job id as a *field*, not just
2143        // buried in the message text, so `RUST_LOG` filters and Sentry
2144        // tag search can pivot on it.
2145        use crate::test_support::capture;
2146        let logs = capture(|| {
2147            let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2148            push_log(
2149                &logs,
2150                "info",
2151                "ws",
2152                "binary upload ok",
2153                Some("job-42".into()),
2154            );
2155        });
2156        assert!(
2157            logs.contains("job_id=\"job-42\""),
2158            "expected structured job_id field, got: {logs}"
2159        );
2160        assert!(
2161            logs.contains("[ws] binary upload ok"),
2162            "expected the human-readable message to survive, got: {logs}"
2163        );
2164    }
2165
2166    #[test]
2167    fn push_log_omits_job_id_field_when_absent() {
2168        // Jobless breadcrumbs (startup banners, heartbeats, auto-update
2169        // ticks) must not gain a noisy empty `job_id` field.
2170        use crate::test_support::capture;
2171        let logs = capture(|| {
2172            let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2173            push_log(&logs, "info", "auto-update", "up to date", None);
2174        });
2175        assert!(
2176            !logs.contains("job_id"),
2177            "expected no job_id field for a jobless log, got: {logs}"
2178        );
2179    }
2180
2181    // --- async tick tests ---
2182
2183    #[test]
2184    fn request_shutdown_sets_the_stop_flag() {
2185        let stop = AtomicBool::new(false);
2186        request_shutdown(&stop, "SIGTERM");
2187        assert!(stop.load(Ordering::SeqCst));
2188    }
2189
2190    #[test]
2191    fn request_shutdown_reconfirms_when_already_stopping() {
2192        // A second signal (or a race with another shutdown path) must
2193        // not panic or clear the flag — it just re-confirms the stop.
2194        let stop = AtomicBool::new(true);
2195        request_shutdown(&stop, "SIGINT");
2196        assert!(stop.load(Ordering::SeqCst));
2197    }
2198
2199    #[test]
2200    fn request_shutdown_emits_a_named_shutdown_breadcrumb() {
2201        use crate::test_support::capture;
2202        let logs = capture(|| {
2203            let stop = AtomicBool::new(false);
2204            request_shutdown(&stop, "SIGTERM");
2205        });
2206        assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
2207        assert!(
2208            logs.contains("studio_worker::runtime"),
2209            "expected runtime target, got: {logs}"
2210        );
2211        assert!(
2212            logs.contains("op=\"shutdown\""),
2213            "expected op field, got: {logs}"
2214        );
2215        assert!(
2216            logs.contains("signal=\"SIGTERM\""),
2217            "expected signal field, got: {logs}"
2218        );
2219    }
2220
2221    #[tokio::test]
2222    async fn auto_update_tick_disabled_when_flag_off() {
2223        let cfg = Config {
2224            auto_update_enabled: false,
2225            ..Config::default()
2226        };
2227        let logs = Arc::new(Mutex::new(Vec::new()));
2228        let decision = auto_update_tick(&cfg, &crate::job_gate::JobGate::new(), &logs).await;
2229        assert_eq!(decision, AutoUpdateDecision::Disabled);
2230    }
2231
2232    #[tokio::test]
2233    async fn auto_update_tick_skipped_when_busy() {
2234        let cfg = Config {
2235            auto_update_enabled: true,
2236            ..Config::default()
2237        };
2238        let logs = Arc::new(Mutex::new(Vec::new()));
2239        // A held reservation on the shared gate = an in-flight job.
2240        let gate = crate::job_gate::JobGate::new();
2241        let _held = gate.try_reserve().expect("hold the slot");
2242        let decision = auto_update_tick(&cfg, &gate, &logs).await;
2243        assert_eq!(decision, AutoUpdateDecision::SkippedBusy);
2244        let entries = logs.lock();
2245        assert!(entries.iter().any(|e| e.message.contains("busy on a job")));
2246    }
2247
2248    #[tokio::test]
2249    async fn wait_with_stop_short_circuits_when_already_stopped() {
2250        let stop = Arc::new(AtomicBool::new(true));
2251        let start = std::time::Instant::now();
2252        wait_with_stop(Duration::from_secs(60), &stop, Duration::from_millis(10)).await;
2253        assert!(
2254            start.elapsed() < Duration::from_millis(100),
2255            "an already-set stop must return without sleeping the full duration"
2256        );
2257    }
2258
2259    #[tokio::test]
2260    async fn auto_updater_stops_promptly_during_idle_wait() {
2261        // A huge auto_update_tick means a non-cancellable idle sleep
2262        // would pin the JoinHandle — and thus `run_loops`' join() — for
2263        // the whole tick after stop is set, defeating graceful
2264        // shutdown.  The stop-aware wait must let the task finish well
2265        // inside the tick.
2266        let cfg = crate::config::shared(Config {
2267            auto_update_enabled: false,
2268            ..Config::default()
2269        });
2270        let stop = Arc::new(AtomicBool::new(false));
2271        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
2272        let busy = Arc::new(AtomicBool::new(false));
2273        let schedule = LoopSchedule {
2274            ws_session: crate::ws::session::SessionSchedule::fast_for_tests(),
2275            auto_update_tick: Duration::from_secs(3600),
2276            shutdown_tick: Duration::from_millis(1),
2277        };
2278        let handle = spawn_auto_updater(cfg, stop.clone(), logs, busy, schedule);
2279        // Let the loop reach its idle wait, then request shutdown.
2280        tokio::time::sleep(Duration::from_millis(10)).await;
2281        stop.store(true, Ordering::SeqCst);
2282        tokio::time::timeout(Duration::from_millis(250), handle)
2283            .await
2284            .expect("auto-updater did not observe stop promptly")
2285            .expect("auto-updater task panicked");
2286    }
2287
2288    // -----------------------------------------------------------------
2289    // Local API bootstrap helpers.
2290    // -----------------------------------------------------------------
2291
2292    #[test]
2293    fn resolve_local_api_port_prefers_env_then_config_then_default() {
2294        assert_eq!(resolve_local_api_port(Some("5000"), Some(4000)), 5000);
2295        assert_eq!(resolve_local_api_port(None, Some(4000)), 4000);
2296        assert_eq!(resolve_local_api_port(None, None), DEFAULT_LOCAL_API_PORT);
2297    }
2298
2299    #[test]
2300    fn resolve_local_api_port_warns_on_invalid_env_and_falls_back() {
2301        // An invalid env value used to be silently ignored; it must
2302        // fall back *and* leave a warn naming the bad value.
2303        let logs = crate::test_support::capture(|| {
2304            assert_eq!(
2305                resolve_local_api_port(Some("not-a-port"), None),
2306                DEFAULT_LOCAL_API_PORT
2307            );
2308            assert_eq!(resolve_local_api_port(Some("99999"), Some(4001)), 4001);
2309        });
2310        assert!(logs.contains("WARN"), "expected a WARN, got: {logs}");
2311        assert!(
2312            logs.contains("not-a-port"),
2313            "the warn must name the invalid value, got: {logs}"
2314        );
2315        assert!(
2316            logs.contains("STUDIO_WORKER_LOCAL_API_PORT"),
2317            "the warn must name the env var, got: {logs}"
2318        );
2319    }
2320
2321    #[test]
2322    fn ensure_local_api_token_mints_once_and_persists() {
2323        let dir = tempfile::tempdir().unwrap();
2324        let path = dir.path().join("config.toml");
2325        let cfg = config::shared(Config::default());
2326
2327        let minted = ensure_local_api_token(&cfg, &path);
2328        assert_eq!(minted.len(), 64, "expected a 64-hex token");
2329        assert!(minted.chars().all(|c| c.is_ascii_hexdigit()));
2330
2331        // Persisted to disk…
2332        let (loaded, _) = config::load(Some(&path.to_string_lossy())).unwrap();
2333        assert_eq!(loaded.local_api_token.as_deref(), Some(minted.as_str()));
2334
2335        // …and stable across calls (no re-mint).
2336        let again = ensure_local_api_token(&cfg, &path);
2337        assert_eq!(again, minted);
2338    }
2339
2340    #[test]
2341    fn ensure_local_api_token_keeps_an_existing_token() {
2342        let dir = tempfile::tempdir().unwrap();
2343        let path = dir.path().join("config.toml");
2344        let cfg = config::shared(Config {
2345            local_api_token: Some("pre-existing".into()),
2346            ..Config::default()
2347        });
2348        assert_eq!(ensure_local_api_token(&cfg, &path), "pre-existing");
2349        assert!(!path.exists(), "no save when nothing changed");
2350    }
2351
2352    #[test]
2353    fn ensure_local_api_token_survives_a_failed_persist() {
2354        // Unwritable config path: the token must still be minted (the
2355        // session stays guarded) and a warn must surface the failure.
2356        let dir = tempfile::tempdir().unwrap();
2357        let blocked = dir.path().join("blocked");
2358        std::fs::write(&blocked, b"a file, not a dir").unwrap();
2359        let path = blocked.join("config.toml");
2360        let cfg = config::shared(Config::default());
2361        let logs = crate::test_support::capture({
2362            let cfg = cfg.clone();
2363            move || {
2364                let token = ensure_local_api_token(&cfg, &path);
2365                assert_eq!(token.len(), 64);
2366            }
2367        });
2368        assert!(
2369            logs.contains("failed to persist the local api token"),
2370            "a failed persist must warn: {logs}"
2371        );
2372        assert!(
2373            cfg.lock().local_api_token.is_some(),
2374            "the in-memory token must survive the failed persist"
2375        );
2376    }
2377}