Skip to main content

studio_worker/
daemon_api.rs

1//! Wire types of the daemon-control routes on the local API, shared by the
2//! daemon (which answers them) and the tray UI (which reads them).
3//!
4//! See `docs/local-api.md#daemon-control` for the routes.
5
6use std::path::PathBuf;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11use crate::auto_register::RegistrationState;
12use crate::config::Config;
13use crate::runtime::{
14    CurrentJob, GpuRuntimeStatus, HeartbeatStatus, JobOutcome, JobSource, RecentJob, SessionState,
15};
16use crate::types::{LogEntry, ModelEngine, TaskKind};
17
18/// The operator-editable part of the config: what the Config tab shows and
19/// `PUT /daemon/config` accepts.  Credentials and registration state are
20/// never part of it.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct EditableConfig {
24    pub api_base_url: String,
25    pub vram_threshold_gb: f32,
26    pub start_minimised: bool,
27    pub auto_update_enabled: bool,
28    pub auto_update_interval_secs: u64,
29    pub auto_update_feed: String,
30    pub auto_update_prerelease: bool,
31    pub models_root: PathBuf,
32}
33
34/// Why `PUT /daemon/config` refused a config.
35#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
36#[error("{field}: {problem}")]
37pub struct ConfigRejection {
38    pub field: &'static str,
39    pub problem: String,
40}
41
42/// Shortest auto-update interval accepted, in seconds.  The release feed is
43/// GitHub's API; polling it faster than once a minute buys nothing.
44pub const MIN_AUTO_UPDATE_INTERVAL_SECS: u64 = 60;
45
46impl EditableConfig {
47    pub fn from_config(cfg: &Config) -> Self {
48        Self {
49            api_base_url: cfg.api_base_url.clone(),
50            vram_threshold_gb: cfg.vram_threshold_gb,
51            start_minimised: cfg.start_minimised,
52            auto_update_enabled: cfg.auto_update_enabled,
53            auto_update_interval_secs: cfg.auto_update_interval_secs,
54            auto_update_feed: cfg.auto_update_feed.clone(),
55            auto_update_prerelease: cfg.auto_update_prerelease,
56            models_root: cfg.models_root.clone(),
57        }
58    }
59
60    /// Write these fields onto `cfg`, leaving every other field alone.
61    pub fn apply_to(&self, cfg: &mut Config) {
62        cfg.api_base_url = self.api_base_url.clone();
63        cfg.vram_threshold_gb = self.vram_threshold_gb;
64        cfg.start_minimised = self.start_minimised;
65        cfg.auto_update_enabled = self.auto_update_enabled;
66        cfg.auto_update_interval_secs = self.auto_update_interval_secs;
67        cfg.auto_update_feed = self.auto_update_feed.clone();
68        cfg.auto_update_prerelease = self.auto_update_prerelease;
69        cfg.models_root = self.models_root.clone();
70    }
71
72    /// Refuse values the worker cannot run with.
73    pub fn validate(&self) -> Result<(), ConfigRejection> {
74        let reject = |field, problem: &str| {
75            Err(ConfigRejection {
76                field,
77                problem: problem.to_string(),
78            })
79        };
80        if !is_http_url(&self.api_base_url) {
81            return reject("apiBaseUrl", "must be an http(s) URL");
82        }
83        if !self.vram_threshold_gb.is_finite() || self.vram_threshold_gb < 0.0 {
84            return reject("vramThresholdGb", "must be a number of GB, 0 or more");
85        }
86        if self.auto_update_interval_secs < MIN_AUTO_UPDATE_INTERVAL_SECS {
87            return reject("autoUpdateIntervalSecs", "must be at least 60 seconds");
88        }
89        if !is_http_url(&self.auto_update_feed) {
90            return reject("autoUpdateFeed", "must be an http(s) URL");
91        }
92        if self.models_root.as_os_str().is_empty() {
93            return reject("modelsRoot", "must not be empty");
94        }
95        Ok(())
96    }
97}
98
99fn is_http_url(raw: &str) -> bool {
100    url::Url::parse(raw).is_ok_and(|u| matches!(u.scheme(), "http" | "https") && u.has_host())
101}
102
103/// Where a job is in its life.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(rename_all = "snake_case")]
106pub enum JobStatus {
107    Running,
108    Completed,
109    Failed,
110}
111
112/// One job on the wire: running or finished, whatever its source.
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct JobWire {
116    pub job_id: String,
117    pub kind: TaskKind,
118    pub model: String,
119    pub prompt: String,
120    pub source: JobSource,
121    pub status: JobStatus,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub reason: Option<String>,
124    pub started_at: DateTime<Utc>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub finished_at: Option<DateTime<Utc>>,
127    /// `GET /jobs/:id/thumbnail` answers an image.
128    #[serde(default)]
129    pub has_thumbnail: bool,
130}
131
132impl JobWire {
133    pub fn running(job: &CurrentJob, has_thumbnail: bool) -> Self {
134        Self {
135            job_id: job.job_id.clone(),
136            kind: job.kind,
137            model: job.model.clone(),
138            prompt: job.prompt.clone(),
139            source: job.source,
140            status: JobStatus::Running,
141            reason: None,
142            started_at: job.started_at,
143            finished_at: None,
144            has_thumbnail,
145        }
146    }
147
148    pub fn finished(job: &RecentJob, has_thumbnail: bool) -> Self {
149        let (status, reason) = match &job.outcome {
150            JobOutcome::Completed => (JobStatus::Completed, None),
151            JobOutcome::Failed { reason } => (JobStatus::Failed, Some(reason.clone())),
152        };
153        Self {
154            job_id: job.job_id.clone(),
155            kind: job.kind,
156            model: job.model.clone(),
157            prompt: job.prompt.clone(),
158            source: job.source,
159            status,
160            reason,
161            started_at: job.started_at,
162            finished_at: Some(job.finished_at),
163            has_thumbnail,
164        }
165    }
166
167    pub fn to_current(&self) -> CurrentJob {
168        CurrentJob {
169            job_id: self.job_id.clone(),
170            kind: self.kind,
171            model: self.model.clone(),
172            prompt: self.prompt.clone(),
173            started_at: self.started_at,
174            source: self.source,
175        }
176    }
177
178    /// The finished job, `None` while it runs.
179    pub fn to_recent(&self) -> Option<RecentJob> {
180        let outcome = match self.status {
181            JobStatus::Running => return None,
182            JobStatus::Completed => JobOutcome::Completed,
183            JobStatus::Failed => JobOutcome::Failed {
184                reason: self.reason.clone().unwrap_or_default(),
185            },
186        };
187        Some(RecentJob {
188            job_id: self.job_id.clone(),
189            kind: self.kind,
190            model: self.model.clone(),
191            prompt: self.prompt.clone(),
192            outcome,
193            started_at: self.started_at,
194            finished_at: self.finished_at.unwrap_or(self.started_at),
195            source: self.source,
196        })
197    }
198}
199
200/// `GET /daemon/status`: everything the tray UI shows except the logs.
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202#[serde(rename_all = "camelCase")]
203pub struct DaemonStatus {
204    pub version: String,
205    pub pid: u32,
206    pub config_path: PathBuf,
207    pub paused: bool,
208    /// The one-job gate is taken (a studio or transient local job runs).
209    pub busy: bool,
210    /// `worker_id` and `auth_token` are both present.
211    pub registered: bool,
212    pub worker_id: Option<String>,
213    pub registration: RegistrationState,
214    pub config: EditableConfig,
215    pub session: SessionState,
216    pub heartbeat: Option<HeartbeatStatus>,
217    pub gpu_runtime: Option<GpuRuntimeStatus>,
218    pub vram_total_gb: f32,
219    pub local_api_url: Option<String>,
220    /// The studio job the heartbeat reports, if any.
221    pub current_job_id: Option<String>,
222    pub active_jobs: Vec<JobWire>,
223    pub recent_jobs: Vec<JobWire>,
224    pub local_jobs: Vec<JobWire>,
225    /// Sequence number of the newest worker log entry.
226    pub logs_seq: u64,
227}
228
229/// `GET /daemon/logs?after=<seq>`.
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct LogsPage {
233    pub entries: Vec<LogEntry>,
234    /// Sequence number of the newest entry; pass it back as `after`.
235    pub seq: u64,
236}
237
238/// The engine part of a catalogue model's source, as `GET /models` lists it.
239#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct ModelSourceBrief {
241    pub engine: ModelEngine,
242}
243
244/// One `GET /models` entry, as the tray UI reads it.
245#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246#[serde(rename_all = "camelCase")]
247pub struct ModelEntry {
248    pub id: String,
249    pub display_name: String,
250    pub kind: TaskKind,
251    #[serde(default)]
252    pub vram_gb_estimate: f32,
253    pub source: ModelSourceBrief,
254    #[serde(default = "default_true")]
255    pub enabled: bool,
256    #[serde(default)]
257    pub exclusive_group: Option<String>,
258    pub state: String,
259    #[serde(default)]
260    pub resident: bool,
261    pub since: Option<DateTime<Utc>>,
262    #[serde(default)]
263    pub error: Option<String>,
264    /// The daemon has an in-process loader for the model's engine.
265    #[serde(default)]
266    pub loadable: bool,
267}
268
269fn default_true() -> bool {
270    true
271}
272
273/// An error answer: a stable code plus a human message.
274#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
275pub struct ErrorBody {
276    pub error: String,
277    #[serde(default)]
278    pub message: Option<String>,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn editable() -> EditableConfig {
286        EditableConfig::from_config(&Config::default())
287    }
288
289    #[test]
290    fn the_defaults_are_valid() {
291        assert_eq!(editable().validate(), Ok(()));
292    }
293
294    #[test]
295    fn apply_writes_only_the_editable_fields() {
296        let mut cfg = Config {
297            worker_id: Some("w-1".into()),
298            auth_token: Some("secret".into()),
299            ..Config::default()
300        };
301        let mut edit = editable();
302        edit.vram_threshold_gb = 7.5;
303        edit.models_root = PathBuf::from("/srv/models");
304        edit.apply_to(&mut cfg);
305        assert_eq!(cfg.vram_threshold_gb, 7.5);
306        assert_eq!(cfg.models_root, PathBuf::from("/srv/models"));
307        assert_eq!(cfg.worker_id.as_deref(), Some("w-1"));
308        assert_eq!(cfg.auth_token.as_deref(), Some("secret"));
309        assert_eq!(EditableConfig::from_config(&cfg), edit);
310    }
311
312    /// A field name and a way to break it.
313    type BrokenField = (&'static str, fn(&mut EditableConfig));
314
315    #[test]
316    fn invalid_values_are_refused_with_the_field_named() {
317        let cases: Vec<BrokenField> = vec![
318            ("apiBaseUrl", |c| c.api_base_url = "not a url".into()),
319            ("apiBaseUrl", |c| c.api_base_url = "ftp://x".into()),
320            ("vramThresholdGb", |c| c.vram_threshold_gb = -1.0),
321            ("vramThresholdGb", |c| c.vram_threshold_gb = f32::NAN),
322            ("autoUpdateIntervalSecs", |c| {
323                c.auto_update_interval_secs = 5
324            }),
325            ("autoUpdateFeed", |c| c.auto_update_feed = "".into()),
326            ("modelsRoot", |c| c.models_root = PathBuf::new()),
327        ];
328        for (field, break_it) in cases {
329            let mut edit = editable();
330            break_it(&mut edit);
331            let err = edit.validate().unwrap_err();
332            assert_eq!(err.field, field, "{err}");
333        }
334    }
335
336    fn finished(outcome: JobOutcome) -> RecentJob {
337        let now = Utc::now();
338        RecentJob {
339            job_id: "j-1".into(),
340            kind: TaskKind::Image,
341            model: "m".into(),
342            prompt: "p".into(),
343            outcome,
344            started_at: now,
345            finished_at: now,
346            source: JobSource::Lane,
347        }
348    }
349
350    #[test]
351    fn a_finished_job_round_trips_through_the_wire() {
352        for outcome in [
353            JobOutcome::Completed,
354            JobOutcome::Failed {
355                reason: "boom".into(),
356            },
357        ] {
358            let job = finished(outcome);
359            let wire = JobWire::finished(&job, true);
360            let json = serde_json::to_string(&wire).unwrap();
361            let back: JobWire = serde_json::from_str(&json).unwrap();
362            assert_eq!(back, wire);
363            assert_eq!(back.to_recent(), Some(job));
364            assert!(back.has_thumbnail);
365        }
366    }
367
368    #[test]
369    fn a_running_job_round_trips_and_is_not_finished() {
370        let job = CurrentJob {
371            job_id: "j-2".into(),
372            kind: TaskKind::Llm,
373            model: "m".into(),
374            prompt: "p".into(),
375            started_at: Utc::now(),
376            source: JobSource::Stream,
377        };
378        let wire = JobWire::running(&job, false);
379        let json = serde_json::to_value(&wire).unwrap();
380        assert_eq!(json["status"], "running");
381        assert_eq!(json["source"], "stream");
382        assert!(json.get("finishedAt").is_none());
383        let back: JobWire = serde_json::from_value(json).unwrap();
384        assert_eq!(back.to_current(), job);
385        assert_eq!(back.to_recent(), None);
386    }
387
388    #[test]
389    fn runtime_states_round_trip_through_the_wire() {
390        let registration = RegistrationState::Pending {
391            request_id: "rr-1".into(),
392            since: Utc::now(),
393        };
394        let json = serde_json::to_value(&registration).unwrap();
395        assert_eq!(json["state"], "pending");
396        assert_eq!(json["requestId"], "rr-1");
397        assert_eq!(
398            serde_json::from_value::<RegistrationState>(json).unwrap(),
399            registration
400        );
401
402        let session = SessionState::Reconnecting { attempt: 3 };
403        let json = serde_json::to_value(&session).unwrap();
404        assert_eq!(
405            json,
406            serde_json::json!({ "state": "reconnecting", "attempt": 3 })
407        );
408        assert_eq!(
409            serde_json::from_value::<SessionState>(json).unwrap(),
410            session
411        );
412
413        let heartbeat = HeartbeatStatus {
414            outcome: crate::runtime::HeartbeatOutcome::Err {
415                reason: "timeout".into(),
416            },
417            last_attempt_at: Utc::now(),
418        };
419        let json = serde_json::to_value(&heartbeat).unwrap();
420        assert_eq!(json["outcome"], "err");
421        assert_eq!(json["reason"], "timeout");
422        assert_eq!(
423            serde_json::from_value::<HeartbeatStatus>(json).unwrap(),
424            heartbeat
425        );
426    }
427
428    #[test]
429    fn a_model_entry_reads_the_models_listing() {
430        let json = serde_json::json!({
431            "id": "qwen", "displayName": "Qwen", "kind": "llm", "vramGbEstimate": 2.5,
432            "source": { "engine": "llama-cpp", "files": [] },
433            "enabled": true, "origin": "local",
434            "state": "failed", "resident": true,
435            "since": "2026-01-01T00:00:00Z", "error": "out of memory", "loadable": true
436        });
437        let entry: ModelEntry = serde_json::from_value(json).unwrap();
438        assert_eq!(entry.source.engine, ModelEngine::LlamaCpp);
439        assert_eq!(entry.state, "failed");
440        assert_eq!(entry.error.as_deref(), Some("out of memory"));
441        assert!(entry.loadable);
442    }
443}