Skip to main content

studio_worker/
runtime.rs

1//! Long-running auto-update task + one-shot CLI helpers.
2//!
3//! After the WS migration the runtime owns just two background
4//! tasks: the WebSocket session (`ws::session::spawn_ws_session`,
5//! which subsumes heartbeats, claim/accept/complete, fail, and log
6//! shipping) and the auto-updater (`spawn_auto_updater`).  Per-tick
7//! helpers from the old polling loops are gone.
8use crate::{
9    config::{self, Config, SharedConfig},
10    engine::Engine,
11    sys,
12    types::*,
13    update, AGENT_VERSION,
14};
15use anyhow::{anyhow, Result};
16use chrono::{DateTime, SecondsFormat, Utc};
17use parking_lot::Mutex;
18use std::{
19    collections::VecDeque,
20    sync::{
21        atomic::{AtomicBool, Ordering},
22        Arc,
23    },
24    time::Duration,
25};
26use tracing::{info, warn};
27
28/// Tracing target for runtime-level events (startup, state mutations).
29/// Stable so operators can filter with `RUST_LOG=studio_worker::runtime=debug`.
30const TRACE_TARGET: &str = "studio_worker::runtime";
31
32/// Maximum number of finished jobs kept in `WorkerObservers::recent_jobs`.
33/// Older entries fall off the back of the ring.
34pub const RECENT_JOBS_CAP: usize = 50;
35
36/// Maximum number of log entries kept in `WorkerObservers::recent_logs`
37/// for the UI's Logs tab.  The shipping queue (`logs: Arc<Mutex<Vec<…>>>`)
38/// is drained on every WS tick — the display ring is what the UI reads.
39pub const RECENT_LOGS_CAP: usize = 1000;
40
41/// Prompt previews stored in `CurrentJob` / `RecentJob` are clipped to
42/// this many chars so the in-memory state stays bounded even when LLM
43/// prompts are huge.
44pub const PROMPT_PREVIEW_CHARS: usize = 200;
45
46/// Maximum number of entries the WS ship queue (`logs:
47/// Arc<Mutex<Vec<LogEntry>>>`) may hold.  The shipper pump only drains
48/// while a session is connected, so a long approval wait or reconnect
49/// backoff would otherwise grow the queue without bound.  On overflow
50/// the oldest entries are dropped and a warn-level marker records the
51/// loss.
52pub const LOG_SHIP_QUEUE_CAP: usize = 5_000;
53
54/// Job in flight right now.  Populated by the WS session before
55/// dispatch, cleared once the job finishes (success or failure).
56#[derive(Debug, Clone)]
57pub struct CurrentJob {
58    pub job_id: String,
59    pub kind: TaskKind,
60    pub model: String,
61    pub prompt: String,
62    pub started_at: DateTime<Utc>,
63}
64
65/// Outcome a finished job ended with.  Failures carry the human
66/// reason (already surfaced to logs + Sentry).
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum JobOutcome {
69    Completed,
70    Failed { reason: String },
71}
72
73/// One finished job, retained in the recent-jobs ring for the UI.
74#[derive(Debug, Clone)]
75pub struct RecentJob {
76    pub job_id: String,
77    pub kind: TaskKind,
78    pub model: String,
79    pub prompt: String,
80    pub outcome: JobOutcome,
81    pub started_at: DateTime<Utc>,
82    pub finished_at: DateTime<Utc>,
83}
84
85/// Result of the most recent heartbeat the WS session sent.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum HeartbeatOutcome {
88    Ok,
89    Err { reason: String },
90}
91
92#[derive(Debug, Clone)]
93pub struct HeartbeatStatus {
94    pub last_attempt_at: DateTime<Utc>,
95    pub outcome: HeartbeatOutcome,
96}
97
98/// Bundle of in-process observation slots the WS session writes to and
99/// the optional native UI reads from.  `Default` gives empty slots so
100/// existing (headless) call sites stay one-liners.  Cheap to clone —
101/// every field is an `Arc`.
102#[derive(Clone, Default)]
103pub struct WorkerObservers {
104    pub current_job: Arc<Mutex<Option<CurrentJob>>>,
105    pub recent_jobs: Arc<Mutex<VecDeque<RecentJob>>>,
106    /// Finished jobs submitted to the local API (the in-app "local queue"),
107    /// kept separate from studio-claimed jobs.
108    pub local_jobs: Arc<Mutex<VecDeque<RecentJob>>>,
109    /// URL the always-on local image API is reachable at, once bound.
110    pub local_api_url: Arc<Mutex<Option<String>>>,
111    pub last_heartbeat: Arc<Mutex<Option<HeartbeatStatus>>>,
112    /// Bounded ring of every log entry the worker has emitted, kept
113    /// for the UI's Logs tab.  Separate from the WS ship queue
114    /// (which is drained every second) so the display doesn't blank
115    /// out between ticks.
116    pub recent_logs: Arc<Mutex<VecDeque<LogEntry>>>,
117}
118
119pub fn truncate_prompt(s: &str) -> String {
120    if s.chars().count() <= PROMPT_PREVIEW_CHARS {
121        return s.to_string();
122    }
123    let mut out: String = s.chars().take(PROMPT_PREVIEW_CHARS).collect();
124    out.push('…');
125    out
126}
127
128pub fn record_recent_job(observers: &WorkerObservers, entry: RecentJob) {
129    let mut ring = observers.recent_jobs.lock();
130    ring.push_front(entry);
131    while ring.len() > RECENT_JOBS_CAP {
132        ring.pop_back();
133    }
134}
135
136/// Record a finished local-API job into the local-queue ring.
137pub fn record_local_job(observers: &WorkerObservers, entry: RecentJob) {
138    let mut ring = observers.local_jobs.lock();
139    ring.push_front(entry);
140    while ring.len() > RECENT_JOBS_CAP {
141        ring.pop_back();
142    }
143}
144
145/// Test-only helper to populate the recent-jobs ring without driving a
146/// full claim cycle.  Lives in the library surface so integration
147/// tests can pin the ring-capacity contract cheaply.
148#[doc(hidden)]
149pub fn push_recent_job_for_tests(observers: &WorkerObservers, job_id: &str) {
150    let now = Utc::now();
151    record_recent_job(
152        observers,
153        RecentJob {
154            job_id: job_id.to_string(),
155            kind: TaskKind::Image,
156            model: "synthetic".into(),
157            prompt: String::new(),
158            outcome: JobOutcome::Completed,
159            started_at: now,
160            finished_at: now,
161        },
162    );
163}
164
165pub const AUTO_UPDATE_TICK: Duration = Duration::from_secs(60);
166/// Cadence at which the auto-updater's idle wait re-checks the `stop`
167/// flag.  Mirrors the WS session's shutdown tick so a SIGTERM / SIGINT
168/// landing during the (up to `AUTO_UPDATE_TICK`-long) idle window wakes
169/// the loop within ~250 ms instead of leaving `run_loops`' join blocked
170/// for a whole tick.
171pub const AUTO_UPDATE_SHUTDOWN_TICK: Duration = Duration::from_millis(250);
172/// Default WS heartbeat interval, re-exported here so the native UI
173/// (and any other downstream readers) get a stable constant without
174/// reaching into `ws::session`.
175pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
176
177/// Schedule for the long-running loops.
178#[derive(Debug, Clone, Copy)]
179pub struct LoopSchedule {
180    pub ws_session: crate::ws::session::SessionSchedule,
181    pub auto_update_tick: Duration,
182    /// How often the idle wait between update checks re-polls the
183    /// `stop` flag, so a shutdown request isn't deferred for a whole
184    /// `auto_update_tick`.
185    pub shutdown_tick: Duration,
186}
187
188impl Default for LoopSchedule {
189    fn default() -> Self {
190        Self {
191            ws_session: crate::ws::session::SessionSchedule::default(),
192            auto_update_tick: AUTO_UPDATE_TICK,
193            shutdown_tick: AUTO_UPDATE_SHUTDOWN_TICK,
194        }
195    }
196}
197
198impl LoopSchedule {
199    /// Schedule with 1 ms intervals — used by tests to exercise the
200    /// loop wrappers without blocking.
201    pub fn fast_for_tests() -> Self {
202        Self {
203            ws_session: crate::ws::session::SessionSchedule::fast_for_tests(),
204            auto_update_tick: Duration::from_millis(1),
205            shutdown_tick: Duration::from_millis(1),
206        }
207    }
208}
209
210// ---------------------------------------------------------------------------
211// One-shot helpers used by the CLI subcommands
212// ---------------------------------------------------------------------------
213
214/// Bundle of flags from `studio-worker register`.
215#[derive(Debug, Clone, Default)]
216pub struct RegisterArgs {
217    pub api_base_url: Option<String>,
218    pub reset: bool,
219}
220
221/// Persist registration metadata for the next launch.  No HTTP — the
222/// auto-register orchestration inside `run` / `ui` is the only thing
223/// that talks to the studio.
224pub async fn register(config_path: Option<&str>, args: RegisterArgs) -> Result<()> {
225    let (mut cfg, path) = config::load(config_path)?;
226
227    if args.reset {
228        cfg.worker_id = None;
229        cfg.auth_token = None;
230        cfg.registration_request_id = None;
231        cfg.registration_secret = None;
232        cfg.install_id = None;
233    }
234    if let Some(url) = args.api_base_url {
235        cfg.api_base_url = url;
236    }
237
238    config::save(&cfg, &path)?;
239    if args.reset {
240        info!(
241            config_path = %path.display(),
242            "local registration state cleared; next launch will auto-register"
243        );
244        println!(
245            "local registration state cleared; run `studio-worker run` or \
246             `studio-worker ui` to auto-register"
247        );
248    } else {
249        info!(
250            config_path = %path.display(),
251            "register flags persisted; next launch will auto-register"
252        );
253        println!(
254            "saved; run `studio-worker run` or `studio-worker ui` to auto-register against {}",
255            cfg.api_base_url
256        );
257    }
258    Ok(())
259}
260
261pub async fn status(config_path: Option<&str>) -> Result<()> {
262    let (cfg, path) = config::load(config_path)?;
263    println!("{}", format_status(&cfg, &path));
264    Ok(())
265}
266
267pub fn format_status(cfg: &Config, path: &std::path::Path) -> String {
268    let mut out = String::new();
269    use std::fmt::Write as _;
270    let _ = writeln!(out, "config path:        {}", path.display());
271    let _ = writeln!(out, "api_base_url:       {}", cfg.api_base_url);
272    let registration_line = if cfg.worker_id.is_some() && cfg.auth_token.is_some() {
273        format!("approved as {}", cfg.worker_id.as_deref().unwrap_or(""))
274    } else if let Some(rid) = cfg.registration_request_id.as_deref() {
275        format!("pending operator approval (request {rid})")
276    } else {
277        "not registered (will auto-register on next launch)".into()
278    };
279    let _ = writeln!(out, "registration:       {registration_line}");
280    let _ = writeln!(out, "vram_threshold_gb:  {}", cfg.vram_threshold_gb);
281    let _ = writeln!(out, "auto_start:         {}", cfg.auto_start);
282    let _ = writeln!(out, "models_root:        {}", cfg.models_root.display());
283    let _ = writeln!(out, "auto_update:        {}", cfg.auto_update_enabled);
284    let _ = writeln!(
285        out,
286        "update_interval:    {}s",
287        cfg.auto_update_interval_secs
288    );
289    out
290}
291
292pub fn set_threshold(config_path: Option<&str>, gb: f32) -> Result<()> {
293    if gb < 0.0 {
294        return Err(anyhow!("threshold must be >= 0"));
295    }
296    let (mut cfg, path) = config::load(config_path)?;
297    cfg.vram_threshold_gb = gb;
298    config::save(&cfg, &path)?;
299    info!(
300        target: TRACE_TARGET,
301        op = "set_threshold",
302        vram_threshold_gb = gb,
303        config_path = path.display().to_string(),
304        "VRAM threshold persisted"
305    );
306    println!("vram_threshold_gb = {gb}");
307    Ok(())
308}
309
310/// Emit a one-shot startup banner so operators can confirm which
311/// config the worker actually loaded.  Without this the only thing in
312/// `journalctl -u studio-worker` on a healthy boot is whatever the
313/// loops happen to log on their first tick.
314pub fn log_startup_banner(cfg: &Config, path: &std::path::Path) {
315    info!(
316        target: TRACE_TARGET,
317        op = "startup",
318        version = AGENT_VERSION,
319        config_path = path.display().to_string(),
320        api_base_url = cfg.api_base_url.as_str(),
321        vram_threshold_gb = cfg.vram_threshold_gb,
322        auto_start = cfg.auto_start,
323        auto_update_enabled = cfg.auto_update_enabled,
324        auto_update_interval_secs = cfg.auto_update_interval_secs,
325        models_root = cfg.models_root.display().to_string(),
326        worker_id = cfg.worker_id.as_deref().unwrap_or("(unregistered)"),
327        "studio-worker booting"
328    );
329}
330
331pub fn show_config(config_path: Option<&str>) -> Result<()> {
332    let (cfg, path) = config::load(config_path)?;
333    println!("# {}", path.display());
334    print!("{}", toml::to_string_pretty(&cfg)?);
335    Ok(())
336}
337
338pub async fn check_update(config_path: Option<&str>) -> Result<()> {
339    let (cfg, _) = config::load(config_path)?;
340    let current = semver::Version::parse(AGENT_VERSION)
341        .map_err(|e| anyhow!("invalid current version {AGENT_VERSION}: {e}"))?;
342    let outcome = tokio::task::spawn_blocking(move || {
343        update::check(&cfg.auto_update_feed, &current, cfg.auto_update_prerelease)
344    })
345    .await??;
346    println!("{}", format_check_outcome(&outcome));
347    Ok(())
348}
349
350pub fn format_check_outcome(outcome: &update::CheckOutcome) -> String {
351    match outcome {
352        update::CheckOutcome::UpToDate { current } => format!("up to date: {current}"),
353        update::CheckOutcome::NewerAvailable { current, latest } => {
354            format!("update available: {current} -> {latest}")
355        }
356    }
357}
358
359// ---------------------------------------------------------------------------
360// Long-running run loop
361// ---------------------------------------------------------------------------
362
363pub async fn run(config_path: Option<&str>) -> Result<()> {
364    let (cfg, path) = config::load(config_path)?;
365    log_startup_banner(&cfg, &path);
366
367    let cfg = config::shared(cfg);
368    let stop = Arc::new(AtomicBool::new(false));
369    let busy = Arc::new(AtomicBool::new(false));
370    // Operator pause toggle.  Runtime-only — never persisted, so the
371    // worker comes up unpaused after every restart.
372    let paused = Arc::new(AtomicBool::new(false));
373    let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
374    let observers = WorkerObservers::default();
375    let registration = crate::auto_register::shared_initial();
376
377    let stop_clone = stop.clone();
378    tokio::spawn(async move {
379        let signal = wait_for_shutdown_signal().await;
380        request_shutdown(&stop_clone, signal);
381    });
382
383    // Block on auto-register until the operator approves (or rejects).
384    // Polls every 30s; aborts on Ctrl-C.  A stop signal that arrives
385    // before approval is a clean shutdown (the pre-approval wait is the
386    // normal state of a fresh worker), so exit Ok rather than letting
387    // `run_cli` log it at error and exit non-zero.
388    // Start the always-on local image API before the registration gate so it
389    // works even when the worker is not (yet) registered with a studio.
390    let local_api = spawn_local_api(cfg.clone(), observers.clone(), stop.clone());
391
392    let outcome = match ensure_registered(&cfg, &path, &registration, &stop).await {
393        Ok(RegistrationGate::Stopped) => {
394            info!(
395                target: TRACE_TARGET,
396                op = "shutdown",
397                "stopped before registration completed; exiting cleanly"
398            );
399            Ok(())
400        }
401        Ok(_) => {
402            run_loops(
403                cfg,
404                stop.clone(),
405                logs,
406                busy,
407                paused,
408                observers,
409                LoopSchedule::default(),
410            )
411            .await
412        }
413        Err(err) => Err(err),
414    };
415
416    // Shutting down: ensure the local API thread observes `stop` and joins.
417    stop.store(true, Ordering::SeqCst);
418    if let Some(handle) = local_api {
419        let _ = handle.join();
420    }
421    outcome
422}
423
424/// Flip the `stop` flag and emit a shutdown breadcrumb so an operator
425/// tailing the journal sees a clean stop, mirroring
426/// [`log_startup_banner`].  Pulled out of the signal task so the
427/// shutdown decision is unit-testable without delivering a real OS
428/// signal.  `signal` names whatever woke us (e.g. `"SIGTERM"`).
429pub fn request_shutdown(stop: &AtomicBool, signal: &str) {
430    let already_stopping = stop.swap(true, Ordering::SeqCst);
431    info!(
432        target: TRACE_TARGET,
433        op = "shutdown",
434        signal,
435        already_stopping,
436        "shutdown signal received; stopping worker gracefully"
437    );
438}
439
440/// Block until the OS asks the worker to stop, returning the name of
441/// the signal that fired.
442///
443/// On Unix we wait on **both** SIGINT (interactive Ctrl-C) and SIGTERM.
444/// SIGTERM is the signal `systemctl stop` / `launchctl unload` / host
445/// shutdown deliver by default, and the worker ships as a `Type=simple`
446/// systemd unit (see `service::render_service`).  Listening for Ctrl-C
447/// alone meant the service manager's stop never reached the graceful
448/// path: the WS session was killed mid-`close`, the studio saw an
449/// abrupt disconnect, and the final log batch never flushed.  If the
450/// SIGTERM handler can't be installed we degrade to Ctrl-C only rather
451/// than abort the shutdown task.
452///
453/// On non-Unix we wait on Ctrl-C, which tokio maps to the console
454/// Ctrl-C / close events.
455#[cfg_attr(coverage_nightly, coverage(off))]
456async fn wait_for_shutdown_signal() -> &'static str {
457    #[cfg(unix)]
458    {
459        use tokio::signal::unix::{signal, SignalKind};
460        let mut sigterm = match signal(SignalKind::terminate()) {
461            Ok(s) => s,
462            Err(e) => {
463                warn!(
464                    target: TRACE_TARGET,
465                    op = "shutdown",
466                    error = %e,
467                    "could not install SIGTERM handler; falling back to Ctrl-C only"
468                );
469                let _ = tokio::signal::ctrl_c().await;
470                return "SIGINT";
471            }
472        };
473        tokio::select! {
474            _ = tokio::signal::ctrl_c() => "SIGINT",
475            _ = sigterm.recv() => "SIGTERM",
476        }
477    }
478    #[cfg(not(unix))]
479    {
480        let _ = tokio::signal::ctrl_c().await;
481        "ctrl-c"
482    }
483}
484
485/// Outcome of the startup registration gate ([`ensure_registered`]).
486///
487/// A clean stop signal (Ctrl-C / SIGTERM) that arrives **before** the
488/// studio approves the worker is a routine shutdown, not a failure:
489/// the pre-approval wait is the normal state of a freshly-installed
490/// worker sitting in the studio's approval queue.  Surfacing it as a
491/// distinct [`Stopped`](RegistrationGate::Stopped) outcome lets `run`
492/// exit 0 — so `systemctl stop` doesn't mark the unit failed — and
493/// skip the top-level `tracing::error!` that would otherwise ship a
494/// spurious Sentry event on every clean stop of an unapproved worker.
495/// An operator *rejection*, by contrast, stays a hard `Err`: it's a
496/// terminal state the operator must act on (`register --reset`).
497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
498pub enum RegistrationGate {
499    /// The worker is registered (already, or just approved); proceed
500    /// to open the WS session.
501    Ready,
502    /// A stop signal arrived before approval; shut down cleanly.
503    Stopped,
504}
505
506/// Loop auto_register::tick on a 30s cadence until `worker_id` +
507/// `auth_token` are populated (Approved → [`RegistrationGate::Ready`]),
508/// a stop signal arrives (→ [`RegistrationGate::Stopped`]), or the
509/// operator rejects the worker (→ `Err` with recovery guidance).
510pub async fn ensure_registered(
511    cfg: &SharedConfig,
512    path: &std::path::Path,
513    registration: &crate::auto_register::SharedRegistration,
514    stop: &Arc<AtomicBool>,
515) -> Result<RegistrationGate> {
516    use std::time::Duration;
517    loop {
518        if stop.load(Ordering::SeqCst) {
519            return Ok(RegistrationGate::Stopped);
520        }
521        {
522            let snap = cfg.lock();
523            if snap.worker_id.is_some() && snap.auth_token.is_some() {
524                return Ok(RegistrationGate::Ready);
525            }
526        }
527        let state = crate::auto_register::tick(cfg, path, registration).await;
528        match state {
529            crate::auto_register::RegistrationState::Approved => {
530                return Ok(RegistrationGate::Ready)
531            }
532            crate::auto_register::RegistrationState::Rejected { reason } => {
533                return Err(anyhow!(
534                    "registration rejected by the studio operator: {reason}.  \
535                     Run `studio-worker register --reset` to clear local state \
536                     and submit a fresh request."
537                ));
538            }
539            _ => {}
540        }
541        // Sleep with a fast-cancel on stop.
542        for _ in 0..30 {
543            if stop.load(Ordering::SeqCst) {
544                return Ok(RegistrationGate::Stopped);
545            }
546            tokio::time::sleep(Duration::from_secs(1)).await;
547        }
548    }
549}
550
551/// Spawn the WS session + auto-updater, wait for them.  Pulled out of
552/// `run` so tests can drive with a different schedule.
553///
554/// `paused` is the runtime-only Pause / Resume toggle the UI flips.
555/// When set, the WS session advertises `auto_enabled = false` in
556/// heartbeats and refuses new job offers without restarting the
557/// session.
558pub async fn run_loops(
559    cfg: SharedConfig,
560    stop: Arc<AtomicBool>,
561    logs: Arc<Mutex<Vec<LogEntry>>>,
562    busy: Arc<AtomicBool>,
563    paused: Arc<AtomicBool>,
564    observers: WorkerObservers,
565    schedule: LoopSchedule,
566) -> Result<()> {
567    let session = crate::ws::session::spawn_ws_session(
568        cfg.clone(),
569        stop.clone(),
570        logs.clone(),
571        busy.clone(),
572        paused.clone(),
573        observers.clone(),
574        schedule.ws_session,
575    );
576    let auto_updater = spawn_auto_updater(
577        cfg.clone(),
578        stop.clone(),
579        logs.clone(),
580        busy.clone(),
581        schedule,
582    );
583    let (session_result, _) = tokio::join!(session, auto_updater);
584    session_result
585}
586
587/// Default port for the always-on local image API. Override with
588/// `STUDIO_WORKER_LOCAL_API_PORT`.
589pub const DEFAULT_LOCAL_API_PORT: u16 = 4787;
590
591/// Build the engine + catalog and start the local image API server on a
592/// background thread. Returns the thread handle, or `None` when it could not
593/// start (logged, non-fatal — the studio session keeps running).
594pub fn spawn_local_api(
595    cfg: SharedConfig,
596    observers: WorkerObservers,
597    stop: Arc<AtomicBool>,
598) -> Option<std::thread::JoinHandle<()>> {
599    let engine: Arc<dyn crate::engine::Engine> = match crate::engine::build(&cfg.lock()) {
600        Ok(engine) => engine.into(),
601        Err(err) => {
602            tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: engine build failed");
603            return None;
604        }
605    };
606
607    let catalog_path = crate::config::default_catalog_path().ok();
608    let catalog = match &catalog_path {
609        Some(path) => crate::catalog::Catalog::load_or_seed(path).unwrap_or_else(|err| {
610            tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: catalog load failed; seeding in-memory");
611            crate::catalog::Catalog::seed()
612        }),
613        None => crate::catalog::Catalog::seed(),
614    };
615    let catalog = Arc::new(Mutex::new(catalog));
616
617    let port = std::env::var("STUDIO_WORKER_LOCAL_API_PORT")
618        .ok()
619        .and_then(|p| p.parse::<u16>().ok())
620        .unwrap_or(DEFAULT_LOCAL_API_PORT);
621
622    let api = crate::local_api::LocalApi::bind(
623        &format!("127.0.0.1:{port}"),
624        engine.clone(),
625        catalog.clone(),
626        catalog_path.clone(),
627        observers.clone(),
628    )
629    .or_else(|_| {
630        crate::local_api::LocalApi::bind(
631            "127.0.0.1:0",
632            engine,
633            catalog,
634            catalog_path,
635            observers.clone(),
636        )
637    });
638
639    let api = match api {
640        Ok(api) => api,
641        Err(err) => {
642            tracing::warn!(target: "studio_worker::local_api", error = %err, "local api: bind failed");
643            return None;
644        }
645    };
646
647    let url = api.url();
648    *observers.local_api_url.lock() = Some(url.clone());
649    tracing::info!(target: "studio_worker::local_api", url = %url, "local image API listening");
650
651    Some(std::thread::spawn(move || api.serve(&stop)))
652}
653
654// ---------------------------------------------------------------------------
655// Per-tick helpers — pure async fns, easy to drive from unit tests.
656// ---------------------------------------------------------------------------
657
658// (The old per-tick HTTP helpers — heartbeat_tick, claim_tick, log_shipper_tick,
659//  run_job, ClaimOutcome — lived here.  They are gone with the WS migration.
660//  See `ws::session::spawn_ws_session` for the replacement that runs the
661//  whole session in one connected loop.)
662
663/// What the auto-updater decided this tick.
664#[derive(Debug, Clone, PartialEq, Eq)]
665pub enum AutoUpdateDecision {
666    /// Auto-update is turned off — do nothing.
667    Disabled,
668    /// Worker is currently running a job — skip.
669    SkippedBusy,
670    /// Local version is already the latest.
671    UpToDate,
672    /// Check failed (network etc.) — leave a log entry, try again later.
673    CheckError(String),
674    /// A newer version was applied successfully.  Caller should restart.
675    Updated,
676    /// A newer version was found but the install failed.
677    UpdateError(String),
678}
679
680pub async fn auto_update_tick(
681    cfg: &Config,
682    busy: bool,
683    logs: &Arc<Mutex<Vec<LogEntry>>>,
684) -> AutoUpdateDecision {
685    if !cfg.auto_update_enabled {
686        return AutoUpdateDecision::Disabled;
687    }
688    if busy {
689        push_log(
690            logs,
691            "info",
692            "auto-update",
693            "skipping check: worker is busy on a job",
694            None,
695        );
696        return AutoUpdateDecision::SkippedBusy;
697    }
698    let feed = cfg.auto_update_feed.clone();
699    let prerelease = cfg.auto_update_prerelease;
700    let logs_for_task = logs.clone();
701    let outcome = tokio::task::spawn_blocking(move || -> Result<AutoUpdateDecision> {
702        let current = semver::Version::parse(AGENT_VERSION)
703            .map_err(|e| anyhow!("invalid AGENT_VERSION {AGENT_VERSION}: {e}"))?;
704        match update::check(&feed, &current, prerelease) {
705            Ok(update::CheckOutcome::UpToDate { current }) => {
706                push_log(
707                    &logs_for_task,
708                    "info",
709                    "auto-update",
710                    &format!("up to date at {current}"),
711                    None,
712                );
713                Ok(AutoUpdateDecision::UpToDate)
714            }
715            Ok(update::CheckOutcome::NewerAvailable { current, latest }) => {
716                push_log(
717                    &logs_for_task,
718                    "info",
719                    "auto-update",
720                    &format!("update available {current} -> {latest}; applying"),
721                    None,
722                );
723                match update::apply(&feed, &latest) {
724                    Ok(()) => {
725                        push_log(
726                            &logs_for_task,
727                            "info",
728                            "auto-update",
729                            "binary replaced; restart pending",
730                            None,
731                        );
732                        Ok(AutoUpdateDecision::Updated)
733                    }
734                    Err(e) => {
735                        push_log(
736                            &logs_for_task,
737                            "error",
738                            "auto-update",
739                            &format!("update failed: {e}"),
740                            None,
741                        );
742                        Ok(AutoUpdateDecision::UpdateError(e.to_string()))
743                    }
744                }
745            }
746            Err(e) => {
747                push_log(
748                    &logs_for_task,
749                    "warn",
750                    "auto-update",
751                    &format!("check failed: {e}"),
752                    None,
753                );
754                Ok(AutoUpdateDecision::CheckError(e.to_string()))
755            }
756        }
757    })
758    .await;
759    match outcome {
760        Ok(Ok(decision)) => decision,
761        Ok(Err(e)) => AutoUpdateDecision::CheckError(e.to_string()),
762        Err(e) => AutoUpdateDecision::CheckError(e.to_string()),
763    }
764}
765
766// ---------------------------------------------------------------------------
767// Long-running task wrappers — they exist solely to call the ticks in a
768// loop on a schedule.  All real logic lives in the ticks.
769// ---------------------------------------------------------------------------
770
771// (`spawn_heartbeat`, `spawn_claim_loop`, `spawn_log_shipper`, and
772//  `next_delay_for` lived here.  Their behaviour is now carried by the
773//  WS-driven tasks in `ws::session`.)
774
775/// Sleep up to `total`, re-checking `stop` every `tick` and returning
776/// the instant a shutdown is requested.  Keeps long idle waits (the
777/// auto-update tick here, reconnect backoff in the WS session)
778/// responsive to SIGTERM / SIGINT without busy-looping.  Shared by the
779/// runtime auto-updater and `ws::session`.
780pub(crate) async fn wait_with_stop(total: Duration, stop: &Arc<AtomicBool>, tick: Duration) {
781    let mut elapsed = Duration::ZERO;
782    while elapsed < total {
783        if stop.load(Ordering::SeqCst) {
784            return;
785        }
786        let next = tick.min(total - elapsed);
787        tokio::time::sleep(next).await;
788        elapsed += next;
789    }
790}
791
792pub fn spawn_auto_updater(
793    cfg: SharedConfig,
794    stop: Arc<AtomicBool>,
795    logs: Arc<Mutex<Vec<LogEntry>>>,
796    busy: Arc<AtomicBool>,
797    schedule: LoopSchedule,
798) -> tokio::task::JoinHandle<()> {
799    tokio::spawn(async move {
800        let mut elapsed = Duration::from_secs(0);
801        while !stop.load(Ordering::SeqCst) {
802            // Stop-aware idle wait: a shutdown signal during this window
803            // wakes the loop within `schedule.shutdown_tick` instead of
804            // leaving `run_loops`' join() blocked for a full
805            // `auto_update_tick`.
806            wait_with_stop(schedule.auto_update_tick, &stop, schedule.shutdown_tick).await;
807            if stop.load(Ordering::SeqCst) {
808                break;
809            }
810            elapsed += schedule.auto_update_tick;
811            let snapshot = cfg.lock().clone();
812            if elapsed < Duration::from_secs(snapshot.auto_update_interval_secs) {
813                continue;
814            }
815            elapsed = Duration::from_secs(0);
816            let busy_now = busy.load(Ordering::SeqCst);
817            let decision = auto_update_tick(&snapshot, busy_now, &logs).await;
818            if matches!(decision, AutoUpdateDecision::Updated) {
819                stop.store(true, Ordering::SeqCst);
820                update::restart_self();
821            }
822        }
823    })
824}
825
826// (`run_job` lived here.  See `ws::session::run_offered_job` for the
827//  WS-driven replacement.)
828
829pub fn prompt_for(task: &Task) -> String {
830    match task {
831        Task::Image(p) => p.prompt.clone(),
832        Task::Llm(p) => p
833            .messages
834            .last()
835            .map(|m| m.content.clone())
836            .unwrap_or_default(),
837        Task::AudioStt(p) => p.input_url.clone(),
838        Task::AudioTts(p) => p.text.clone(),
839        Task::Video(p) => p.prompt.clone(),
840    }
841}
842
843pub fn is_unsupported_kind(e: &anyhow::Error) -> bool {
844    // Typed check first — survives context wrapping and rewording.
845    // The string check remains as a fallback for error paths that
846    // haven't migrated to `engine::UnsupportedTask` yet.
847    e.chain().any(|cause| {
848        cause
849            .downcast_ref::<crate::engine::UnsupportedTask>()
850            .is_some()
851    }) || e.to_string().contains("cannot serve")
852}
853
854// ---------------------------------------------------------------------------
855// Helpers
856// ---------------------------------------------------------------------------
857
858pub fn build_capabilities(cfg: &Config, engine: &dyn Engine) -> WorkerCapabilities {
859    build_capabilities_with(cfg, engine, true)
860}
861
862/// Same as [`build_capabilities`] but lets the caller drive
863/// `auto_enabled` from a runtime pause flag (the UI's Pause/Resume
864/// button).  The persisted [`Config`] no longer carries that bit —
865/// it's an in-process toggle.
866pub fn build_capabilities_with(
867    cfg: &Config,
868    engine: &dyn Engine,
869    auto_enabled: bool,
870) -> WorkerCapabilities {
871    let vram = sys::detect_vram_gb().unwrap_or(0.0);
872    let caps = engine.capabilities();
873    let supported_models_per_kind = caps.supported_models_per_kind.clone();
874    let task_kinds = caps.kinds();
875    // Legacy `supported_models` is a flat list across all kinds so the
876    // studio API's claim filter (which only knows about this field) can
877    // match jobs of any modality this worker can serve.
878    let supported_models = {
879        let mut all = caps.flat_models();
880        all.sort();
881        all.dedup();
882        all
883    };
884
885    WorkerCapabilities {
886        machine_name: sys::machine_name(),
887        username: sys::username(),
888        agent_version: AGENT_VERSION.to_string(),
889        engine: engine.name().to_string(),
890        vram_total_gb: vram,
891        vram_threshold_gb: cfg.vram_threshold_gb,
892        auto_enabled,
893        auto_start: cfg.auto_start,
894        supported_models,
895        task_kinds,
896        supported_models_per_kind,
897    }
898}
899
900/// One-line, operator-facing summary of what this worker advertises to
901/// the studio on the WS handshake.  Logged once per session attempt so
902/// the worker's own logs (and the studio's shipped-log view) record
903/// exactly which task kinds, models, and VRAM budget were offered — the
904/// missing complement to [`log_startup_banner`], which only covers the
905/// loaded config.  Without it, an operator chasing "why won't my worker
906/// claim image jobs" has no record of what the worker told the studio
907/// it could do.  Pure so the formatting is unit-tested without a live
908/// session.
909pub fn summarize_capabilities(caps: &WorkerCapabilities) -> String {
910    let kinds = caps
911        .task_kinds
912        .iter()
913        .map(|k| k.as_str())
914        .collect::<Vec<_>>()
915        .join(", ");
916    format!(
917        "advertising engine={}, vram={:.1}/{:.1}GB threshold, auto_enabled={}, \
918         kinds=[{}], {} model(s)=[{}]",
919        caps.engine,
920        caps.vram_total_gb,
921        caps.vram_threshold_gb,
922        caps.auto_enabled,
923        kinds,
924        caps.supported_models.len(),
925        caps.supported_models.join(", "),
926    )
927}
928
929/// Operator-facing warning when the configured VRAM threshold exceeds
930/// the GPU VRAM the worker actually detected.
931///
932/// The studio matches jobs to a worker purely by its advertised
933/// `vram_threshold_gb`, so a threshold set above the card's real
934/// capacity — e.g. the default 12 GB on an 8 GB consumer GPU — makes the
935/// worker accept jobs its GPU can't fit: they load, exhaust VRAM, and
936/// fail with an OOM the operator then has to trace back to a config
937/// value.  Surfacing it on the handshake (one line next to the
938/// capability summary) turns a silent OOM-on-claim into an actionable
939/// "lower your threshold" breadcrumb.
940///
941/// Only fires when the VRAM probe returned a real positive total: a
942/// detected 0 GB means the probe failed (no `nvidia-smi` / sysfs tree,
943/// or a non-NVIDIA GPU we can't size), where the threshold is the only
944/// capacity signal we have and second-guessing it would be wrong.  The
945/// boundary is strict (`threshold > total`), so a threshold that exactly
946/// matches the card stays silent.  Pure so the wording + boundary are
947/// unit-tested without a live GPU.
948pub fn vram_threshold_warning(caps: &WorkerCapabilities) -> Option<String> {
949    if caps.vram_total_gb > 0.0 && caps.vram_threshold_gb > caps.vram_total_gb {
950        Some(format!(
951            "configured VRAM threshold {:.1}GB exceeds detected GPU VRAM {:.1}GB; \
952             the studio may offer jobs larger than this card can fit and they will \
953             OOM on load — lower vram_threshold_gb to at or below {:.1}GB",
954            caps.vram_threshold_gb, caps.vram_total_gb, caps.vram_total_gb
955        ))
956    } else {
957        None
958    }
959}
960
961pub fn push_log(
962    logs: &Arc<Mutex<Vec<LogEntry>>>,
963    level: &str,
964    category: &str,
965    message: &str,
966    job_id: Option<String>,
967) {
968    push_log_with_observers(logs, None, level, category, message, job_id);
969}
970
971/// Same as [`push_log`] but also appends to
972/// [`WorkerObservers::recent_logs`] so the UI's Logs tab keeps a
973/// rolling display window.  The WS session uses this variant so
974/// operators don't see the Logs tab blank out every second when the
975/// shipping queue gets drained.
976pub fn push_log_with_observers(
977    logs: &Arc<Mutex<Vec<LogEntry>>>,
978    observers: Option<&WorkerObservers>,
979    level: &str,
980    category: &str,
981    message: &str,
982    job_id: Option<String>,
983) {
984    let entry = LogEntry {
985        ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
986        level: level.to_string(),
987        category: category.to_string(),
988        message: message.to_string(),
989        job_id,
990    };
991    // Carry the job id as a structured field so operators can pivot
992    // shipped studio logs / Sentry breadcrumbs on it. `Option<&str>`
993    // only records the field when `Some`, so jobless breadcrumbs stay
994    // free of a noisy empty `job_id`.
995    let job_id = entry.job_id.as_deref();
996    if level == "error" {
997        tracing::error!(target: "studio_worker", job_id, "[{category}] {message}");
998    } else if level == "warn" {
999        tracing::warn!(target: "studio_worker", job_id, "[{category}] {message}");
1000    } else {
1001        info!(target: "studio_worker", job_id, "[{category}] {message}");
1002    }
1003    {
1004        let mut queue = logs.lock();
1005        if queue.len() >= LOG_SHIP_QUEUE_CAP {
1006            // +1 for the entry below, +1 for the drop marker.
1007            let overflow = queue.len() + 2 - LOG_SHIP_QUEUE_CAP;
1008            queue.drain(0..overflow);
1009            queue.push(LogEntry {
1010                ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1011                level: "warn".to_string(),
1012                category: "logs".to_string(),
1013                message: format!(
1014                    "ship queue full ({LOG_SHIP_QUEUE_CAP} entries); dropped {overflow} oldest"
1015                ),
1016                job_id: None,
1017            });
1018        }
1019        queue.push(entry.clone());
1020    }
1021    if let Some(o) = observers {
1022        let mut ring = o.recent_logs.lock();
1023        ring.push_back(entry);
1024        while ring.len() > RECENT_LOGS_CAP {
1025            ring.pop_front();
1026        }
1027    }
1028}
1029
1030/// Put a drained-but-unsent batch back at the front of the ship queue
1031/// so it survives for the next session attempt.  Entries that arrived
1032/// while the batch was in flight stay behind it (newest last).  The
1033/// combined queue is clipped to [`LOG_SHIP_QUEUE_CAP`], dropping the
1034/// oldest entries first.
1035pub fn restore_unshipped(logs: &Arc<Mutex<Vec<LogEntry>>>, mut batch: Vec<LogEntry>) {
1036    let mut queue = logs.lock();
1037    batch.append(&mut queue);
1038    *queue = batch;
1039    if queue.len() > LOG_SHIP_QUEUE_CAP {
1040        let overflow = queue.len() - LOG_SHIP_QUEUE_CAP;
1041        queue.drain(0..overflow);
1042    }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048    use crate::config::Config;
1049    use crate::engine::SyntheticEngine;
1050
1051    #[test]
1052    fn is_unsupported_kind_detects_typed_unsupported_task() {
1053        let err: anyhow::Error =
1054            crate::engine::UnsupportedTask::new("synthetic", TaskKind::Llm).into();
1055        assert!(is_unsupported_kind(&err));
1056        // The message keeps the legacy operator-facing shape.
1057        assert!(err.to_string().contains("cannot serve llm"));
1058    }
1059
1060    #[test]
1061    fn is_unsupported_kind_survives_context_wrapping() {
1062        // String sniffing broke as soon as a caller added context (the
1063        // outer message no longer contains "cannot serve"); the typed
1064        // downcast searches the whole chain.
1065        let err = anyhow::Error::from(crate::engine::UnsupportedTask::new(
1066            "sdcpp",
1067            TaskKind::AudioTts,
1068        ))
1069        .context("dispatching job j-1");
1070        assert!(is_unsupported_kind(&err));
1071    }
1072
1073    fn entry(message: &str) -> LogEntry {
1074        LogEntry {
1075            ts: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
1076            level: "info".into(),
1077            category: "test".into(),
1078            message: message.into(),
1079            job_id: None,
1080        }
1081    }
1082
1083    #[test]
1084    fn restore_unshipped_requeues_batch_ahead_of_newer_entries() {
1085        // A batch the shipper drained but failed to send must survive
1086        // for the next session, ordered before entries that arrived
1087        // while it was in flight.
1088        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(vec![entry("newer")]));
1089        restore_unshipped(&logs, vec![entry("batch-1"), entry("batch-2")]);
1090        let queue = logs.lock();
1091        let order: Vec<&str> = queue.iter().map(|e| e.message.as_str()).collect();
1092        assert_eq!(order, vec!["batch-1", "batch-2", "newer"]);
1093    }
1094
1095    #[test]
1096    fn restore_unshipped_respects_the_queue_cap() {
1097        // Requeueing must never grow the queue past the ship cap; the
1098        // oldest (front) entries give way so the newest survive.
1099        let logs: Arc<Mutex<Vec<LogEntry>>> =
1100            Arc::new(Mutex::new(vec![entry("newest"); LOG_SHIP_QUEUE_CAP]));
1101        restore_unshipped(&logs, vec![entry("old-batch"); 100]);
1102        let queue = logs.lock();
1103        assert_eq!(queue.len(), LOG_SHIP_QUEUE_CAP);
1104        assert_eq!(
1105            queue.last().map(|e| e.message.as_str()),
1106            Some("newest"),
1107            "newest entries must survive the cap"
1108        );
1109    }
1110
1111    #[test]
1112    fn ship_queue_is_bounded_and_records_dropped_entries() {
1113        // The WS shipper only drains while a session is connected; a
1114        // long approval wait / reconnect backoff must not grow the
1115        // queue without bound.
1116        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1117        for i in 0..(LOG_SHIP_QUEUE_CAP + 100) {
1118            push_log_with_observers(&logs, None, "info", "test", &format!("entry {i}"), None);
1119        }
1120        let queue = logs.lock();
1121        assert!(
1122            queue.len() <= LOG_SHIP_QUEUE_CAP,
1123            "ship queue exceeded its cap: {}",
1124            queue.len()
1125        );
1126        // The newest entry always survives.
1127        assert_eq!(
1128            queue.last().map(|e| e.message.as_str()),
1129            Some(format!("entry {}", LOG_SHIP_QUEUE_CAP + 99).as_str())
1130        );
1131        // Loss is visible: a marker entry names how many were dropped.
1132        assert!(
1133            queue
1134                .iter()
1135                .any(|e| e.level == "warn" && e.message.contains("dropped")),
1136            "overflow must leave a visible drop marker"
1137        );
1138    }
1139
1140    #[test]
1141    fn recent_logs_ring_is_bounded_at_recent_logs_cap() {
1142        // The observer ring backing the UI Logs tab is never drained
1143        // (unlike the ship queue, which the WS shipper empties every
1144        // second), so this cap is its only bound.  A regression that
1145        // dropped the eviction loop would leak memory for the lifetime
1146        // of a long-running worker; one that flipped `pop_front` for
1147        // `pop_back` would silently retain the *oldest* entries and show
1148        // a stale Logs tab.  Mirrors `recent_jobs_ring_caps_at_*`.
1149        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1150        let observers = WorkerObservers::default();
1151        let overflow = 25;
1152        for i in 0..(RECENT_LOGS_CAP + overflow) {
1153            push_log_with_observers(
1154                &logs,
1155                Some(&observers),
1156                "info",
1157                "test",
1158                &format!("entry {i}"),
1159                None,
1160            );
1161        }
1162        let ring = observers.recent_logs.lock();
1163        assert_eq!(
1164            ring.len(),
1165            RECENT_LOGS_CAP,
1166            "the recent-logs ring must cap at RECENT_LOGS_CAP"
1167        );
1168        // Newest entries go to the back; the oldest `overflow` entries
1169        // must have been evicted from the front.
1170        assert_eq!(
1171            ring.back().map(|e| e.message.as_str()),
1172            Some(format!("entry {}", RECENT_LOGS_CAP + overflow - 1).as_str()),
1173            "the newest entry must survive at the back of the ring"
1174        );
1175        assert_eq!(
1176            ring.front().map(|e| e.message.as_str()),
1177            Some(format!("entry {overflow}").as_str()),
1178            "the oldest surviving entry must be entry #overflow (older evicted)"
1179        );
1180    }
1181
1182    #[test]
1183    fn capabilities_advertises_all_synthetic_kinds() {
1184        let cfg = Config::default();
1185        let engine = SyntheticEngine::new();
1186        let cap = build_capabilities(&cfg, &engine);
1187        assert_eq!(cap.engine, "synthetic");
1188        assert_eq!(cap.task_kinds.len(), TaskKind::ALL.len());
1189        assert!(cap.auto_enabled, "default capability snapshot is unpaused");
1190        for kind in TaskKind::ALL {
1191            assert!(cap.supported_models_per_kind.contains_key(&kind));
1192        }
1193    }
1194
1195    #[test]
1196    fn capabilities_with_paused_flag_drives_auto_enabled() {
1197        let cfg = Config::default();
1198        let engine = SyntheticEngine::new();
1199        let paused_caps = build_capabilities_with(&cfg, &engine, false);
1200        assert!(!paused_caps.auto_enabled);
1201    }
1202
1203    #[test]
1204    fn summarize_capabilities_lists_engine_kinds_models_vram_and_pause_state() {
1205        let cfg = Config {
1206            vram_threshold_gb: 6.0,
1207            ..Config::default()
1208        };
1209        let engine = SyntheticEngine::new();
1210        let caps = build_capabilities_with(&cfg, &engine, true);
1211        let summary = summarize_capabilities(&caps);
1212        // Engine name + every advertised kind is present.
1213        assert!(summary.contains("engine=synthetic"), "got: {summary}");
1214        for kind in &caps.task_kinds {
1215            assert!(
1216                summary.contains(kind.as_str()),
1217                "missing kind {} in: {summary}",
1218                kind.as_str()
1219            );
1220        }
1221        // Model count + an actual advertised model id are present.
1222        assert!(
1223            summary.contains(&format!("{} model(s)", caps.supported_models.len())),
1224            "missing model count in: {summary}"
1225        );
1226        assert!(
1227            summary.contains("synthetic"),
1228            "missing model id in: {summary}"
1229        );
1230        // VRAM budget (total/threshold) + unpaused state are visible.
1231        assert!(
1232            summary.contains("6.0"),
1233            "missing vram threshold in: {summary}"
1234        );
1235        assert!(summary.contains("auto_enabled=true"), "got: {summary}");
1236    }
1237
1238    #[test]
1239    fn summarize_capabilities_reflects_paused_state() {
1240        let cfg = Config::default();
1241        let engine = SyntheticEngine::new();
1242        let caps = build_capabilities_with(&cfg, &engine, false);
1243        assert!(
1244            summarize_capabilities(&caps).contains("auto_enabled=false"),
1245            "paused worker must advertise auto_enabled=false"
1246        );
1247    }
1248
1249    /// Build a capability snapshot, then override the two VRAM fields so
1250    /// the threshold/total relationship is deterministic regardless of
1251    /// the host's real GPU (the probe is `0.0` on CI).
1252    fn caps_with_vram(total_gb: f32, threshold_gb: f32) -> WorkerCapabilities {
1253        let mut caps = build_capabilities_with(&Config::default(), &SyntheticEngine::new(), true);
1254        caps.vram_total_gb = total_gb;
1255        caps.vram_threshold_gb = threshold_gb;
1256        caps
1257    }
1258
1259    #[test]
1260    fn vram_threshold_warning_flags_threshold_above_detected_vram() {
1261        // The default 12 GB threshold on an 8 GB card: the studio will
1262        // offer up-to-12 GB jobs this GPU can't fit, and they OOM on
1263        // load.  The breadcrumb must name both numbers and the config
1264        // key the operator has to lower.
1265        let warning = vram_threshold_warning(&caps_with_vram(8.0, 12.0))
1266            .expect("threshold above detected VRAM must warn");
1267        assert!(warning.contains("12.0"), "missing threshold in: {warning}");
1268        assert!(
1269            warning.contains("8.0"),
1270            "missing detected VRAM in: {warning}"
1271        );
1272        assert!(
1273            warning.contains("vram_threshold_gb"),
1274            "must name the config key to change: {warning}"
1275        );
1276    }
1277
1278    #[test]
1279    fn vram_threshold_warning_silent_when_threshold_within_detected_vram() {
1280        // A 24 GB card with a 12 GB threshold is correctly conservative.
1281        assert!(vram_threshold_warning(&caps_with_vram(24.0, 12.0)).is_none());
1282    }
1283
1284    #[test]
1285    fn vram_threshold_warning_silent_when_threshold_equals_detected() {
1286        // The boundary is strict: a threshold that exactly matches the
1287        // card fits, so it stays silent.
1288        assert!(vram_threshold_warning(&caps_with_vram(12.0, 12.0)).is_none());
1289    }
1290
1291    #[test]
1292    fn vram_threshold_warning_silent_when_vram_undetected() {
1293        // A detected 0 GB means the probe failed (no nvidia-smi / sysfs)
1294        // or it's a non-NVIDIA GPU we can't size; the threshold is then
1295        // the only capacity signal we have, so second-guessing it with a
1296        // spurious OOM warning would be wrong.
1297        assert!(vram_threshold_warning(&caps_with_vram(0.0, 12.0)).is_none());
1298    }
1299
1300    #[test]
1301    fn prompt_for_extracts_per_kind() {
1302        let image = Task::Image(ImageParams {
1303            prompt: "a stone golem".into(),
1304            ..Default::default()
1305        });
1306        assert_eq!(prompt_for(&image), "a stone golem");
1307
1308        let llm = Task::Llm(LlmParams {
1309            messages: vec![
1310                ChatMessage {
1311                    role: "system".into(),
1312                    content: "be helpful".into(),
1313                },
1314                ChatMessage {
1315                    role: "user".into(),
1316                    content: "hi".into(),
1317                },
1318            ],
1319            max_tokens: 32,
1320            temperature: 0.5,
1321            ..Default::default()
1322        });
1323        assert_eq!(prompt_for(&llm), "hi");
1324
1325        let llm_empty = Task::Llm(LlmParams {
1326            messages: vec![],
1327            ..Default::default()
1328        });
1329        assert_eq!(prompt_for(&llm_empty), "");
1330
1331        let stt = Task::AudioStt(AudioSttParams {
1332            input_url: "https://example.com/clip.wav".into(),
1333            ..Default::default()
1334        });
1335        assert_eq!(prompt_for(&stt), "https://example.com/clip.wav");
1336
1337        let tts = Task::AudioTts(AudioTtsParams {
1338            text: "hi there".into(),
1339            voice: "v".into(),
1340            ext: "wav".into(),
1341            ..Default::default()
1342        });
1343        assert_eq!(prompt_for(&tts), "hi there");
1344
1345        let video = Task::Video(VideoParams {
1346            prompt: "a tiny dragon".into(),
1347            seconds: 1.0,
1348            width: 256,
1349            height: 256,
1350            ext: "mp4".into(),
1351            ..Default::default()
1352        });
1353        assert_eq!(prompt_for(&video), "a tiny dragon");
1354    }
1355
1356    #[test]
1357    fn truncate_prompt_passes_short_through_and_clips_long_prompts() {
1358        // Under the cap → returned verbatim, no ellipsis.
1359        let short = "a stone golem";
1360        assert_eq!(truncate_prompt(short), short);
1361
1362        // Exactly at the cap is the boundary: still untouched.
1363        let exactly = "x".repeat(PROMPT_PREVIEW_CHARS);
1364        assert_eq!(
1365            truncate_prompt(&exactly),
1366            exactly,
1367            "a prompt exactly at the cap must not be clipped"
1368        );
1369
1370        // One past the cap → clipped to PROMPT_PREVIEW_CHARS chars plus
1371        // the single ellipsis terminator.
1372        let over = "y".repeat(PROMPT_PREVIEW_CHARS + 1);
1373        let clipped = truncate_prompt(&over);
1374        assert_eq!(
1375            clipped.chars().count(),
1376            PROMPT_PREVIEW_CHARS + 1,
1377            "clipped preview is the cap plus one ellipsis char"
1378        );
1379        assert!(
1380            clipped.ends_with('\u{2026}'),
1381            "a clipped preview ends with an ellipsis"
1382        );
1383        assert_eq!(
1384            clipped
1385                .chars()
1386                .take(PROMPT_PREVIEW_CHARS)
1387                .collect::<String>(),
1388            "y".repeat(PROMPT_PREVIEW_CHARS),
1389            "the kept prefix is the first PROMPT_PREVIEW_CHARS chars"
1390        );
1391    }
1392
1393    #[test]
1394    fn truncate_prompt_clips_on_char_boundaries_for_multibyte_text() {
1395        // Each char here is 3 bytes, so the cap-th *byte* lands
1396        // mid-codepoint: a naive `&s[..PROMPT_PREVIEW_CHARS]` byte slice
1397        // would panic.  `truncate_prompt` counts chars, so a one-over
1398        // multibyte prompt clips cleanly to the cap plus the ellipsis.
1399        let multibyte = "\u{3042}".repeat(PROMPT_PREVIEW_CHARS + 1);
1400        let clipped = truncate_prompt(&multibyte);
1401        assert_eq!(clipped.chars().count(), PROMPT_PREVIEW_CHARS + 1);
1402        assert!(clipped.ends_with('\u{2026}'));
1403        assert_eq!(
1404            clipped.chars().filter(|c| *c == '\u{3042}').count(),
1405            PROMPT_PREVIEW_CHARS,
1406            "exactly PROMPT_PREVIEW_CHARS multibyte chars survive the clip"
1407        );
1408    }
1409
1410    #[test]
1411    fn is_unsupported_kind_matches_engine_message() {
1412        let err = anyhow!("multi engine cannot serve llm tasks");
1413        assert!(is_unsupported_kind(&err));
1414        let other = anyhow!("network timeout");
1415        assert!(!is_unsupported_kind(&other));
1416    }
1417
1418    #[test]
1419    fn format_status_includes_every_field() {
1420        let cfg = Config::default();
1421        let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
1422        assert!(out.contains("config path:"));
1423        assert!(out.contains("api_base_url:"));
1424        assert!(out.contains("registration:"));
1425        assert!(out.contains("not registered"));
1426        assert!(out.contains("models_root:"));
1427        assert!(out.contains("auto_update:"));
1428        assert!(out.contains("update_interval:"));
1429    }
1430
1431    #[test]
1432    fn format_status_shows_worker_id_when_registered() {
1433        let cfg = Config {
1434            worker_id: Some("w-abc".into()),
1435            auth_token: Some("tok".into()),
1436            ..Config::default()
1437        };
1438        let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
1439        assert!(out.contains("w-abc"));
1440        assert!(out.contains("approved"));
1441    }
1442
1443    #[test]
1444    fn format_status_shows_pending_request_id() {
1445        let cfg = Config {
1446            registration_request_id: Some("rr-7".into()),
1447            ..Config::default()
1448        };
1449        let out = format_status(&cfg, std::path::Path::new("/tmp/x.toml"));
1450        assert!(out.contains("pending operator approval"));
1451        assert!(out.contains("rr-7"));
1452    }
1453
1454    #[test]
1455    fn format_check_outcome_handles_both_branches() {
1456        let up = update::CheckOutcome::UpToDate {
1457            current: semver::Version::new(1, 2, 3),
1458        };
1459        assert!(format_check_outcome(&up).contains("up to date"));
1460        let newer = update::CheckOutcome::NewerAvailable {
1461            current: semver::Version::new(1, 2, 3),
1462            latest: semver::Version::new(1, 3, 0),
1463        };
1464        let s = format_check_outcome(&newer);
1465        assert!(s.contains("1.2.3 -> 1.3.0"));
1466    }
1467
1468    #[test]
1469    fn push_log_appends_an_entry() {
1470        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1471        push_log(&logs, "info", "test", "hi", None);
1472        push_log(&logs, "warn", "test", "wat", Some("j-1".into()));
1473        push_log(&logs, "error", "test", "boom", None);
1474        let v = logs.lock();
1475        assert_eq!(v.len(), 3);
1476        assert_eq!(v[0].level, "info");
1477        assert_eq!(v[1].level, "warn");
1478        assert_eq!(v[1].job_id.as_deref(), Some("j-1"));
1479        assert_eq!(v[2].level, "error");
1480    }
1481
1482    #[test]
1483    fn push_log_emits_job_id_as_a_structured_tracing_field() {
1484        // Operators correlating shipped studio logs / Sentry
1485        // breadcrumbs by job need the job id as a *field*, not just
1486        // buried in the message text, so `RUST_LOG` filters and Sentry
1487        // tag search can pivot on it.
1488        use crate::test_support::capture;
1489        let logs = capture(|| {
1490            let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1491            push_log(
1492                &logs,
1493                "info",
1494                "ws",
1495                "binary upload ok",
1496                Some("job-42".into()),
1497            );
1498        });
1499        assert!(
1500            logs.contains("job_id=\"job-42\""),
1501            "expected structured job_id field, got: {logs}"
1502        );
1503        assert!(
1504            logs.contains("[ws] binary upload ok"),
1505            "expected the human-readable message to survive, got: {logs}"
1506        );
1507    }
1508
1509    #[test]
1510    fn push_log_omits_job_id_field_when_absent() {
1511        // Jobless breadcrumbs (startup banners, heartbeats, auto-update
1512        // ticks) must not gain a noisy empty `job_id` field.
1513        use crate::test_support::capture;
1514        let logs = capture(|| {
1515            let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1516            push_log(&logs, "info", "auto-update", "up to date", None);
1517        });
1518        assert!(
1519            !logs.contains("job_id"),
1520            "expected no job_id field for a jobless log, got: {logs}"
1521        );
1522    }
1523
1524    // --- async tick tests ---
1525
1526    #[test]
1527    fn request_shutdown_sets_the_stop_flag() {
1528        let stop = AtomicBool::new(false);
1529        request_shutdown(&stop, "SIGTERM");
1530        assert!(stop.load(Ordering::SeqCst));
1531    }
1532
1533    #[test]
1534    fn request_shutdown_reconfirms_when_already_stopping() {
1535        // A second signal (or a race with another shutdown path) must
1536        // not panic or clear the flag — it just re-confirms the stop.
1537        let stop = AtomicBool::new(true);
1538        request_shutdown(&stop, "SIGINT");
1539        assert!(stop.load(Ordering::SeqCst));
1540    }
1541
1542    #[test]
1543    fn request_shutdown_emits_a_named_shutdown_breadcrumb() {
1544        use crate::test_support::capture;
1545        let logs = capture(|| {
1546            let stop = AtomicBool::new(false);
1547            request_shutdown(&stop, "SIGTERM");
1548        });
1549        assert!(logs.contains("INFO"), "expected INFO event, got: {logs}");
1550        assert!(
1551            logs.contains("studio_worker::runtime"),
1552            "expected runtime target, got: {logs}"
1553        );
1554        assert!(
1555            logs.contains("op=\"shutdown\""),
1556            "expected op field, got: {logs}"
1557        );
1558        assert!(
1559            logs.contains("signal=\"SIGTERM\""),
1560            "expected signal field, got: {logs}"
1561        );
1562    }
1563
1564    #[tokio::test]
1565    async fn auto_update_tick_disabled_when_flag_off() {
1566        let cfg = Config {
1567            auto_update_enabled: false,
1568            ..Config::default()
1569        };
1570        let logs = Arc::new(Mutex::new(Vec::new()));
1571        let decision = auto_update_tick(&cfg, false, &logs).await;
1572        assert_eq!(decision, AutoUpdateDecision::Disabled);
1573    }
1574
1575    #[tokio::test]
1576    async fn auto_update_tick_skipped_when_busy() {
1577        let cfg = Config {
1578            auto_update_enabled: true,
1579            ..Config::default()
1580        };
1581        let logs = Arc::new(Mutex::new(Vec::new()));
1582        let decision = auto_update_tick(&cfg, true, &logs).await;
1583        assert_eq!(decision, AutoUpdateDecision::SkippedBusy);
1584        let entries = logs.lock();
1585        assert!(entries.iter().any(|e| e.message.contains("busy on a job")));
1586    }
1587
1588    #[tokio::test]
1589    async fn wait_with_stop_short_circuits_when_already_stopped() {
1590        let stop = Arc::new(AtomicBool::new(true));
1591        let start = std::time::Instant::now();
1592        wait_with_stop(Duration::from_secs(60), &stop, Duration::from_millis(10)).await;
1593        assert!(
1594            start.elapsed() < Duration::from_millis(100),
1595            "an already-set stop must return without sleeping the full duration"
1596        );
1597    }
1598
1599    #[tokio::test]
1600    async fn auto_updater_stops_promptly_during_idle_wait() {
1601        // A huge auto_update_tick means a non-cancellable idle sleep
1602        // would pin the JoinHandle — and thus `run_loops`' join() — for
1603        // the whole tick after stop is set, defeating graceful
1604        // shutdown.  The stop-aware wait must let the task finish well
1605        // inside the tick.
1606        let cfg = crate::config::shared(Config {
1607            auto_update_enabled: false,
1608            ..Config::default()
1609        });
1610        let stop = Arc::new(AtomicBool::new(false));
1611        let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
1612        let busy = Arc::new(AtomicBool::new(false));
1613        let schedule = LoopSchedule {
1614            ws_session: crate::ws::session::SessionSchedule::fast_for_tests(),
1615            auto_update_tick: Duration::from_secs(3600),
1616            shutdown_tick: Duration::from_millis(1),
1617        };
1618        let handle = spawn_auto_updater(cfg, stop.clone(), logs, busy, schedule);
1619        // Let the loop reach its idle wait, then request shutdown.
1620        tokio::time::sleep(Duration::from_millis(10)).await;
1621        stop.store(true, Ordering::SeqCst);
1622        tokio::time::timeout(Duration::from_millis(250), handle)
1623            .await
1624            .expect("auto-updater did not observe stop promptly")
1625            .expect("auto-updater task panicked");
1626    }
1627}