1use 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#[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#[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
42pub 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 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 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#[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#[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 #[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 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#[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 pub busy: bool,
210 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 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 pub logs_seq: u64,
227}
228
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231#[serde(rename_all = "camelCase")]
232pub struct LogsPage {
233 pub entries: Vec<LogEntry>,
234 pub seq: u64,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct ModelSourceBrief {
241 pub engine: ModelEngine,
242}
243
244#[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 #[serde(default)]
266 pub loadable: bool,
267}
268
269fn default_true() -> bool {
270 true
271}
272
273#[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 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(®istration).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}