1use 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#[derive(Clone)]
20pub struct DaemonControl {
21 pub cfg: SharedConfig,
22 pub config_path: PathBuf,
23 pub paused: Arc<AtomicBool>,
25 pub stop: Arc<AtomicBool>,
27 pub registration: SharedRegistration,
28 pub reset_requested: Arc<AtomicBool>,
31 pub vram_total_gb: f32,
33}
34
35#[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 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 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 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 pub fn editable_config(&self) -> EditableConfig {
120 EditableConfig::from_config(&self.cfg.lock())
121 }
122
123 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(¤t, &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 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 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 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}