Skip to main content

studio_worker/
control.rs

1//! What the tray UI can see and do in the daemon, independent of HTTP.
2//!
3//! The local API's `/daemon/*` routes are thin adapters over
4//! [`DaemonControl`]; keeping the logic here makes every rule testable
5//! without a socket.
6
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11use crate::auto_register::{RegistrationState, SharedRegistration};
12use crate::config::SharedConfig;
13use crate::daemon_api::{ConfigRejection, DaemonStatus, EditableConfig, JobWire};
14use crate::runtime::WorkerObservers;
15
16const TRACE_TARGET: &str = "studio_worker::local_api";
17
18/// The daemon's shared runtime handles.  Cheap to clone.
19#[derive(Clone)]
20pub struct DaemonControl {
21    pub cfg: SharedConfig,
22    pub config_path: PathBuf,
23    /// Runtime pause toggle (never persisted).
24    pub paused: Arc<AtomicBool>,
25    /// Set to stop the daemon gracefully.
26    pub stop: Arc<AtomicBool>,
27    pub registration: SharedRegistration,
28    /// Raised by [`DaemonControl::request_registration_reset`]; consumed by
29    /// the rejection wait in `runtime::serve_studio`.
30    pub reset_requested: Arc<AtomicBool>,
31    /// Total device memory, probed once at start.
32    pub vram_total_gb: f32,
33}
34
35/// Why a control action was refused.
36#[derive(Debug, thiserror::Error)]
37pub enum ControlError {
38    #[error("invalid config: {0}")]
39    Invalid(#[from] ConfigRejection),
40    #[error("config could not be saved: {0}")]
41    NotSaved(String),
42    #[error("registration is not rejected; nothing to reset")]
43    NotRejected,
44}
45
46impl DaemonControl {
47    /// Handles with fresh flags for `cfg` stored at `config_path`.
48    pub fn new(cfg: SharedConfig, config_path: PathBuf, vram_total_gb: f32) -> Self {
49        Self {
50            cfg,
51            config_path,
52            paused: Arc::new(AtomicBool::new(false)),
53            stop: Arc::new(AtomicBool::new(false)),
54            registration: crate::auto_register::shared_initial(),
55            reset_requested: Arc::new(AtomicBool::new(false)),
56            vram_total_gb,
57        }
58    }
59
60    /// The snapshot `GET /daemon/status` answers.
61    pub fn status(&self, observers: &WorkerObservers, busy: bool) -> DaemonStatus {
62        let cfg = self.cfg.lock().clone();
63        let thumbs = &observers.thumbnails;
64        let running = observers
65            .active_jobs
66            .lock()
67            .iter()
68            .map(|j| JobWire::running(j, thumbs.contains(&j.job_id)))
69            .collect();
70        let finished = |ring: &parking_lot::Mutex<std::collections::VecDeque<_>>| {
71            ring.lock()
72                .iter()
73                .map(|j: &crate::runtime::RecentJob| {
74                    JobWire::finished(j, thumbs.contains(&j.job_id))
75                })
76                .collect()
77        };
78        DaemonStatus {
79            version: crate::AGENT_VERSION.to_string(),
80            pid: std::process::id(),
81            config_path: self.config_path.clone(),
82            paused: self.paused.load(Ordering::SeqCst),
83            busy,
84            registered: cfg.worker_id.is_some() && cfg.auth_token.is_some(),
85            worker_id: cfg.worker_id.clone(),
86            registration: self.registration.lock().clone(),
87            config: EditableConfig::from_config(&cfg),
88            session: observers.session_state.lock().clone(),
89            heartbeat: observers.last_heartbeat.lock().clone(),
90            gpu_runtime: observers.gpu_runtime.lock().clone(),
91            vram_total_gb: self.vram_total_gb,
92            local_api_url: observers.local_api_url.lock().clone(),
93            current_job_id: observers
94                .current_job
95                .lock()
96                .as_ref()
97                .map(|j| j.job_id.clone()),
98            active_jobs: running,
99            recent_jobs: finished(&observers.recent_jobs),
100            local_jobs: finished(&observers.local_jobs),
101            logs_seq: observers.recent_logs_seq.load(Ordering::SeqCst),
102        }
103    }
104
105    /// Pause or resume claiming studio jobs.  Answers the new state.
106    pub fn set_paused(&self, paused: bool) -> bool {
107        let was = self.paused.swap(paused, Ordering::SeqCst);
108        tracing::info!(
109            target: TRACE_TARGET,
110            op = "control",
111            action = if paused { "pause" } else { "resume" },
112            changed = was != paused,
113            "pause toggled through the local api"
114        );
115        paused
116    }
117
118    /// The operator-editable config.
119    pub fn editable_config(&self) -> EditableConfig {
120        EditableConfig::from_config(&self.cfg.lock())
121    }
122
123    /// Validate `edit`, save it, then apply it to the running daemon.
124    /// Nothing changes when validation or the save fails.
125    pub fn update_config(&self, edit: EditableConfig) -> Result<EditableConfig, ControlError> {
126        if let Err(rejection) = edit.validate() {
127            tracing::warn!(
128                target: TRACE_TARGET,
129                op = "control",
130                action = "config",
131                field = rejection.field,
132                problem = %rejection.problem,
133                "config update refused"
134            );
135            return Err(rejection.into());
136        }
137        let current = self.cfg.lock().clone();
138        let mut next = current.clone();
139        edit.apply_to(&mut next);
140        if let Err(e) = crate::config::save(&next, &self.config_path) {
141            tracing::warn!(
142                target: TRACE_TARGET,
143                op = "control",
144                action = "config",
145                error = %e,
146                "config update could not be saved"
147            );
148            return Err(ControlError::NotSaved(e.to_string()));
149        }
150        let changed = crate::config::changed_fields(&current, &next).join(",");
151        *self.cfg.lock() = next;
152        tracing::info!(
153            target: TRACE_TARGET,
154            op = "control",
155            action = "config",
156            changed = %changed,
157            "config updated through the local api"
158        );
159        Ok(self.editable_config())
160    }
161
162    /// Ask the daemon to clear a rejected registration and request again.
163    pub fn request_registration_reset(&self) -> Result<(), ControlError> {
164        if !matches!(
165            *self.registration.lock(),
166            RegistrationState::Rejected { .. }
167        ) {
168            tracing::info!(
169                target: TRACE_TARGET,
170                op = "control",
171                action = "registration_reset",
172                "registration reset refused: not rejected"
173            );
174            return Err(ControlError::NotRejected);
175        }
176        self.reset_requested.store(true, Ordering::SeqCst);
177        tracing::info!(
178            target: TRACE_TARGET,
179            op = "control",
180            action = "registration_reset",
181            "registration reset requested"
182        );
183        Ok(())
184    }
185
186    /// Stop the daemon gracefully.
187    pub fn shutdown(&self) {
188        tracing::info!(
189            target: TRACE_TARGET,
190            op = "control",
191            action = "shutdown",
192            "shutdown requested through the local api"
193        );
194        self.stop.store(true, Ordering::SeqCst);
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::config::Config;
202    use crate::runtime::{CurrentJob, JobOutcome, JobSource, RecentJob};
203    use crate::types::TaskKind;
204    use chrono::Utc;
205
206    fn control_in(dir: &std::path::Path) -> DaemonControl {
207        let cfg = Config {
208            worker_id: Some("w-1".into()),
209            auth_token: Some("secret-token".into()),
210            ..Config::default()
211        };
212        DaemonControl::new(crate::config::shared(cfg), dir.join("config.toml"), 24.0)
213    }
214
215    #[test]
216    fn the_status_carries_jobs_state_and_no_secrets() {
217        let dir = tempfile::tempdir().unwrap();
218        let control = control_in(dir.path());
219        let observers = WorkerObservers::default();
220        let now = Utc::now();
221        observers.active_jobs.lock().push(CurrentJob {
222            job_id: "run-1".into(),
223            kind: TaskKind::Llm,
224            model: "m".into(),
225            prompt: "p".into(),
226            started_at: now,
227            source: JobSource::Lane,
228        });
229        observers.local_jobs.lock().push_front(RecentJob {
230            job_id: "img-1".into(),
231            kind: TaskKind::Image,
232            model: "m".into(),
233            prompt: "p".into(),
234            outcome: JobOutcome::Completed,
235            started_at: now,
236            finished_at: now,
237            source: JobSource::Local,
238        });
239        observers.thumbnails.insert("img-1", vec![1, 2, 3]);
240        control.set_paused(true);
241
242        let status = control.status(&observers, true);
243
244        assert!(status.paused && status.busy && status.registered);
245        assert_eq!(status.worker_id.as_deref(), Some("w-1"));
246        assert_eq!(status.vram_total_gb, 24.0);
247        assert_eq!(status.active_jobs[0].job_id, "run-1");
248        assert!(status.local_jobs[0].has_thumbnail);
249        let json = serde_json::to_string(&status).unwrap();
250        assert!(!json.contains("secret-token"), "{json}");
251        let back: DaemonStatus = serde_json::from_str(&json).unwrap();
252        assert_eq!(back, status);
253    }
254
255    #[test]
256    fn a_valid_config_update_is_saved_and_applied() {
257        let dir = tempfile::tempdir().unwrap();
258        let control = control_in(dir.path());
259        let mut edit = control.editable_config();
260        edit.vram_threshold_gb = 6.0;
261
262        let logs = crate::test_support::capture({
263            let control = control.clone();
264            move || {
265                control.update_config(edit).unwrap();
266            }
267        });
268
269        assert_eq!(control.cfg.lock().vram_threshold_gb, 6.0);
270        let (saved, _) = crate::config::load(Some(&control.config_path.to_string_lossy())).unwrap();
271        assert_eq!(saved.vram_threshold_gb, 6.0);
272        assert_eq!(saved.auth_token.as_deref(), Some("secret-token"));
273        assert!(logs.contains("changed=vram_threshold_gb"), "{logs}");
274    }
275
276    #[test]
277    fn an_invalid_config_update_changes_nothing() {
278        let dir = tempfile::tempdir().unwrap();
279        let control = control_in(dir.path());
280        let mut edit = control.editable_config();
281        edit.api_base_url = "nope".into();
282        assert!(matches!(
283            control.update_config(edit),
284            Err(ControlError::Invalid(_))
285        ));
286        assert_eq!(
287            control.cfg.lock().api_base_url,
288            Config::default().api_base_url
289        );
290        assert!(!control.config_path.exists());
291    }
292
293    #[test]
294    fn a_config_that_cannot_be_saved_is_not_applied() {
295        // A config whose directory would have to be a file: unsaveable on
296        // every platform.
297        let dir = tempfile::tempdir().unwrap();
298        let blocker = dir.path().join("blocker");
299        std::fs::write(&blocker, b"").unwrap();
300        let control = DaemonControl::new(
301            crate::config::shared(Config::default()),
302            blocker.join("config.toml"),
303            0.0,
304        );
305        let mut edit = control.editable_config();
306        edit.vram_threshold_gb = 3.0;
307        assert!(matches!(
308            control.update_config(edit),
309            Err(ControlError::NotSaved(_))
310        ));
311        assert_ne!(control.cfg.lock().vram_threshold_gb, 3.0);
312    }
313
314    #[test]
315    fn a_registration_reset_needs_a_rejection() {
316        let dir = tempfile::tempdir().unwrap();
317        let control = control_in(dir.path());
318        assert!(matches!(
319            control.request_registration_reset(),
320            Err(ControlError::NotRejected)
321        ));
322        *control.registration.lock() = RegistrationState::Rejected {
323            reason: "no".into(),
324        };
325        control.request_registration_reset().unwrap();
326        assert!(control.reset_requested.load(Ordering::SeqCst));
327    }
328
329    #[test]
330    fn shutdown_raises_the_stop_flag() {
331        let dir = tempfile::tempdir().unwrap();
332        let control = control_in(dir.path());
333        control.shutdown();
334        assert!(control.stop.load(Ordering::SeqCst));
335    }
336}