use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use crate::auto_register::{RegistrationState, SharedRegistration};
use crate::config::SharedConfig;
use crate::daemon_api::{ConfigRejection, DaemonStatus, EditableConfig, JobWire};
use crate::runtime::WorkerObservers;
const TRACE_TARGET: &str = "studio_worker::local_api";
#[derive(Clone)]
pub struct DaemonControl {
pub cfg: SharedConfig,
pub config_path: PathBuf,
pub paused: Arc<AtomicBool>,
pub stop: Arc<AtomicBool>,
pub registration: SharedRegistration,
pub reset_requested: Arc<AtomicBool>,
pub vram_total_gb: f32,
}
#[derive(Debug, thiserror::Error)]
pub enum ControlError {
#[error("invalid config: {0}")]
Invalid(#[from] ConfigRejection),
#[error("config could not be saved: {0}")]
NotSaved(String),
#[error("registration is not rejected; nothing to reset")]
NotRejected,
}
impl DaemonControl {
pub fn new(cfg: SharedConfig, config_path: PathBuf, vram_total_gb: f32) -> Self {
Self {
cfg,
config_path,
paused: Arc::new(AtomicBool::new(false)),
stop: Arc::new(AtomicBool::new(false)),
registration: crate::auto_register::shared_initial(),
reset_requested: Arc::new(AtomicBool::new(false)),
vram_total_gb,
}
}
pub fn status(&self, observers: &WorkerObservers, busy: bool) -> DaemonStatus {
let cfg = self.cfg.lock().clone();
let thumbs = &observers.thumbnails;
let running = observers
.active_jobs
.lock()
.iter()
.map(|j| JobWire::running(j, thumbs.contains(&j.job_id)))
.collect();
let finished = |ring: &parking_lot::Mutex<std::collections::VecDeque<_>>| {
ring.lock()
.iter()
.map(|j: &crate::runtime::RecentJob| {
JobWire::finished(j, thumbs.contains(&j.job_id))
})
.collect()
};
DaemonStatus {
version: crate::AGENT_VERSION.to_string(),
pid: std::process::id(),
config_path: self.config_path.clone(),
paused: self.paused.load(Ordering::SeqCst),
busy,
registered: cfg.worker_id.is_some() && cfg.auth_token.is_some(),
worker_id: cfg.worker_id.clone(),
registration: self.registration.lock().clone(),
config: EditableConfig::from_config(&cfg),
session: observers.session_state.lock().clone(),
heartbeat: observers.last_heartbeat.lock().clone(),
gpu_runtime: observers.gpu_runtime.lock().clone(),
vram_total_gb: self.vram_total_gb,
local_api_url: observers.local_api_url.lock().clone(),
current_job_id: observers
.current_job
.lock()
.as_ref()
.map(|j| j.job_id.clone()),
active_jobs: running,
recent_jobs: finished(&observers.recent_jobs),
local_jobs: finished(&observers.local_jobs),
logs_seq: observers.recent_logs_seq.load(Ordering::SeqCst),
}
}
pub fn set_paused(&self, paused: bool) -> bool {
let was = self.paused.swap(paused, Ordering::SeqCst);
tracing::info!(
target: TRACE_TARGET,
op = "control",
action = if paused { "pause" } else { "resume" },
changed = was != paused,
"pause toggled through the local api"
);
paused
}
pub fn editable_config(&self) -> EditableConfig {
EditableConfig::from_config(&self.cfg.lock())
}
pub fn update_config(&self, edit: EditableConfig) -> Result<EditableConfig, ControlError> {
if let Err(rejection) = edit.validate() {
tracing::warn!(
target: TRACE_TARGET,
op = "control",
action = "config",
field = rejection.field,
problem = %rejection.problem,
"config update refused"
);
return Err(rejection.into());
}
let current = self.cfg.lock().clone();
let mut next = current.clone();
edit.apply_to(&mut next);
if let Err(e) = crate::config::save(&next, &self.config_path) {
tracing::warn!(
target: TRACE_TARGET,
op = "control",
action = "config",
error = %e,
"config update could not be saved"
);
return Err(ControlError::NotSaved(e.to_string()));
}
let changed = crate::config::changed_fields(¤t, &next).join(",");
*self.cfg.lock() = next;
tracing::info!(
target: TRACE_TARGET,
op = "control",
action = "config",
changed = %changed,
"config updated through the local api"
);
Ok(self.editable_config())
}
pub fn request_registration_reset(&self) -> Result<(), ControlError> {
if !matches!(
*self.registration.lock(),
RegistrationState::Rejected { .. }
) {
tracing::info!(
target: TRACE_TARGET,
op = "control",
action = "registration_reset",
"registration reset refused: not rejected"
);
return Err(ControlError::NotRejected);
}
self.reset_requested.store(true, Ordering::SeqCst);
tracing::info!(
target: TRACE_TARGET,
op = "control",
action = "registration_reset",
"registration reset requested"
);
Ok(())
}
pub fn shutdown(&self) {
tracing::info!(
target: TRACE_TARGET,
op = "control",
action = "shutdown",
"shutdown requested through the local api"
);
self.stop.store(true, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::runtime::{CurrentJob, JobOutcome, JobSource, RecentJob};
use crate::types::TaskKind;
use chrono::Utc;
fn control_in(dir: &std::path::Path) -> DaemonControl {
let cfg = Config {
worker_id: Some("w-1".into()),
auth_token: Some("secret-token".into()),
..Config::default()
};
DaemonControl::new(crate::config::shared(cfg), dir.join("config.toml"), 24.0)
}
#[test]
fn the_status_carries_jobs_state_and_no_secrets() {
let dir = tempfile::tempdir().unwrap();
let control = control_in(dir.path());
let observers = WorkerObservers::default();
let now = Utc::now();
observers.active_jobs.lock().push(CurrentJob {
job_id: "run-1".into(),
kind: TaskKind::Llm,
model: "m".into(),
prompt: "p".into(),
started_at: now,
source: JobSource::Lane,
});
observers.local_jobs.lock().push_front(RecentJob {
job_id: "img-1".into(),
kind: TaskKind::Image,
model: "m".into(),
prompt: "p".into(),
outcome: JobOutcome::Completed,
started_at: now,
finished_at: now,
source: JobSource::Local,
});
observers.thumbnails.insert("img-1", vec![1, 2, 3]);
control.set_paused(true);
let status = control.status(&observers, true);
assert!(status.paused && status.busy && status.registered);
assert_eq!(status.worker_id.as_deref(), Some("w-1"));
assert_eq!(status.vram_total_gb, 24.0);
assert_eq!(status.active_jobs[0].job_id, "run-1");
assert!(status.local_jobs[0].has_thumbnail);
let json = serde_json::to_string(&status).unwrap();
assert!(!json.contains("secret-token"), "{json}");
let back: DaemonStatus = serde_json::from_str(&json).unwrap();
assert_eq!(back, status);
}
#[test]
fn a_valid_config_update_is_saved_and_applied() {
let dir = tempfile::tempdir().unwrap();
let control = control_in(dir.path());
let mut edit = control.editable_config();
edit.vram_threshold_gb = 6.0;
let logs = crate::test_support::capture({
let control = control.clone();
move || {
control.update_config(edit).unwrap();
}
});
assert_eq!(control.cfg.lock().vram_threshold_gb, 6.0);
let (saved, _) = crate::config::load(Some(&control.config_path.to_string_lossy())).unwrap();
assert_eq!(saved.vram_threshold_gb, 6.0);
assert_eq!(saved.auth_token.as_deref(), Some("secret-token"));
assert!(logs.contains("changed=vram_threshold_gb"), "{logs}");
}
#[test]
fn an_invalid_config_update_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let control = control_in(dir.path());
let mut edit = control.editable_config();
edit.api_base_url = "nope".into();
assert!(matches!(
control.update_config(edit),
Err(ControlError::Invalid(_))
));
assert_eq!(
control.cfg.lock().api_base_url,
Config::default().api_base_url
);
assert!(!control.config_path.exists());
}
#[test]
fn a_config_that_cannot_be_saved_is_not_applied() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"").unwrap();
let control = DaemonControl::new(
crate::config::shared(Config::default()),
blocker.join("config.toml"),
0.0,
);
let mut edit = control.editable_config();
edit.vram_threshold_gb = 3.0;
assert!(matches!(
control.update_config(edit),
Err(ControlError::NotSaved(_))
));
assert_ne!(control.cfg.lock().vram_threshold_gb, 3.0);
}
#[test]
fn a_registration_reset_needs_a_rejection() {
let dir = tempfile::tempdir().unwrap();
let control = control_in(dir.path());
assert!(matches!(
control.request_registration_reset(),
Err(ControlError::NotRejected)
));
*control.registration.lock() = RegistrationState::Rejected {
reason: "no".into(),
};
control.request_registration_reset().unwrap();
assert!(control.reset_requested.load(Ordering::SeqCst));
}
#[test]
fn shutdown_raises_the_stop_flag() {
let dir = tempfile::tempdir().unwrap();
let control = control_in(dir.path());
control.shutdown();
assert!(control.stop.load(Ordering::SeqCst));
}
}