Initial ForgeLM import

This commit is contained in:
Evandro Fonseca Junior committed 2026-03-29 19:10:57 -03:00
commit a392b9dc60
4296 files changed
+1212675

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
use super::types::RuntimeConfig;
pub fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
let data_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot resolve app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("Cannot create app data dir: {e}"))?;
Ok(data_dir.join("config.json"))
}
pub fn load_runtime_config(app: &AppHandle) -> Result<RuntimeConfig, String> {
let path = config_path(app)?;
if !path.exists() {
return Ok(RuntimeConfig::default());
}
let json = std::fs::read_to_string(&path).map_err(|e| format!("Read error: {e}"))?;
serde_json::from_str(&json).map_err(|e| format!("Parse error: {e}"))
}
pub fn save_runtime_config(app: &AppHandle, config: &RuntimeConfig) -> Result<(), String> {
let path = config_path(app)?;
let json =
serde_json::to_string_pretty(config).map_err(|e| format!("Serialization error: {e}"))?;
std::fs::write(&path, json).map_err(|e| format!("Write error: {e}"))?;
Ok(())
}
+145
View File
@@ -0,0 +1,145 @@
use std::io::{Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::thread;
use std::time::{Duration, Instant};
fn resolve_socket_addr(addr: &str) -> Option<std::net::SocketAddr> {
match addr.to_socket_addrs() {
Ok(mut addrs) => addrs.next(),
Err(_) => None,
}
}
fn health_response_is_ready(response: &str) -> bool {
let mut sections = response.splitn(2, "\r\n\r\n");
let headers = sections.next().unwrap_or_default();
let body = sections.next().unwrap_or_default();
if !headers.starts_with("HTTP/1.1 200") && !headers.starts_with("HTTP/1.0 200") {
return false;
}
let Ok(json) = serde_json::from_str::<serde_json::Value>(body) else {
return false;
};
json.get("status")
.and_then(|value| value.as_str())
.map(|status| status == "ok")
.unwrap_or(false)
}
pub fn wait_for_health(addr: &str, timeout: Duration) -> bool {
let Some(socket_addr) = resolve_socket_addr(addr) else {
return false;
};
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
match TcpStream::connect_timeout(&socket_addr, Duration::from_millis(250)) {
Ok(mut stream) => {
let _ = stream.set_read_timeout(Some(Duration::from_millis(250)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(250)));
let request =
format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).is_ok() {
let mut response = String::new();
if stream.read_to_string(&mut response).is_ok()
&& health_response_is_ready(&response)
{
return true;
}
}
}
Err(_) => {}
}
thread::sleep(Duration::from_millis(250));
}
false
}
pub fn check_health(addr: &str, timeout: Duration) -> bool {
let Some(socket_addr) = resolve_socket_addr(addr) else {
return false;
};
match TcpStream::connect_timeout(&socket_addr, timeout) {
Ok(mut stream) => {
let _ = stream.set_read_timeout(Some(timeout));
let _ = stream.set_write_timeout(Some(timeout));
let request =
format!("GET /health HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).is_ok() {
let mut response = String::new();
return stream.read_to_string(&mut response).is_ok()
&& health_response_is_ready(&response);
}
false
}
Err(_) => false,
}
}
#[cfg(test)]
mod tests {
use super::wait_for_health;
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread;
use std::time::Duration;
fn spawn_http_server(response: &'static str) -> String {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
let addr = listener.local_addr().expect("local addr");
thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
});
addr.to_string()
}
#[test]
fn wait_for_health_accepts_healthy_http_response() {
let addr = spawn_http_server(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}",
);
assert!(wait_for_health(&addr, Duration::from_secs(1)));
}
#[test]
fn wait_for_health_rejects_non_healthy_payloads() {
let addr = spawn_http_server(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 21\r\n\r\n{\"status\":\"booting\"}",
);
assert!(!wait_for_health(&addr, Duration::from_secs(1)));
}
#[test]
fn wait_for_health_rejects_plain_open_ports() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind bare port");
let addr = listener.local_addr().expect("local addr").to_string();
thread::spawn(move || {
if let Ok((_stream, _)) = listener.accept() {
thread::sleep(Duration::from_millis(250));
}
});
assert!(!wait_for_health(&addr, Duration::from_millis(500)));
}
}
+256
View File
@@ -0,0 +1,256 @@
pub mod config;
pub mod health;
pub mod process;
pub mod state;
pub mod types;
use std::time::Duration;
use tauri::{AppHandle, State};
use self::config::{load_runtime_config, save_runtime_config};
use self::health::wait_for_health;
use self::process::spawn_localllm;
use self::state::{
begin_lifecycle_command, block_lifecycle_command, build_snapshot, finish_lifecycle_command,
guard_start_request, guard_stop_request, refresh_process_state, requires_runtime_restart,
runtime_error,
};
use self::types::{
ConnectionMode, RuntimeConfig, RuntimeLifecycleCommand, RuntimeLifecycleOutcome,
RuntimeSnapshot, RuntimeStatus, StartPolicy,
};
pub fn get_runtime_snapshot(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
Ok(build_snapshot(&inner))
}
pub fn save_runtime_config_command(
app: AppHandle,
state: State<'_, RuntimeState>,
config: RuntimeConfig,
) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
if inner.child.is_some() && requires_runtime_restart(&inner.config, &config) {
let message =
"Stop the local API before changing connection mode, model, address, or runtime sizing.";
inner.last_error = Some(runtime_error("restart_required", message));
block_lifecycle_command(&mut inner, RuntimeLifecycleCommand::Save, message);
return Ok(build_snapshot(&inner));
}
begin_lifecycle_command(&mut inner, RuntimeLifecycleCommand::Save);
if let Err(error) = save_runtime_config(&app, &config) {
inner.last_error = Some(runtime_error("save_failed", &error));
finish_lifecycle_command(
&mut inner,
RuntimeLifecycleCommand::Save,
RuntimeLifecycleOutcome::Failed,
format!("Failed to save runtime settings: {error}"),
);
return Err(error);
}
inner.config = config;
inner.last_error = None;
finish_lifecycle_command(
&mut inner,
RuntimeLifecycleCommand::Save,
RuntimeLifecycleOutcome::Succeeded,
"Runtime settings saved.",
);
Ok(build_snapshot(&inner))
}
fn start_runtime_inner(
app: &AppHandle,
state: &State<'_, RuntimeState>,
override_config: Option<RuntimeConfig>,
) -> Result<RuntimeSnapshot, String> {
let command = if override_config.is_some() {
RuntimeLifecycleCommand::SaveAndStart
} else {
RuntimeLifecycleCommand::Start
};
let config = {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
if guard_start_request(&mut inner).is_err() {
return Ok(build_snapshot(&inner));
}
begin_lifecycle_command(&mut inner, command);
if let Some(config) = override_config {
if let Err(error) = save_runtime_config(app, &config) {
inner.last_error = Some(runtime_error("save_failed", &error));
finish_lifecycle_command(
&mut inner,
command,
RuntimeLifecycleOutcome::Failed,
format!("Failed to save runtime settings: {error}"),
);
return Err(error);
}
inner.config = config;
}
if inner.config.connection_mode != ConnectionMode::Local {
let message = "Switch to Local mode before starting the built-in API";
inner.last_error = Some(runtime_error("remote_mode", message));
inner.status = RuntimeStatus::Error;
finish_lifecycle_command(
&mut inner,
command,
RuntimeLifecycleOutcome::Failed,
message,
);
return Ok(build_snapshot(&inner));
}
inner.status = RuntimeStatus::Starting;
inner.last_error = None;
inner.config.clone()
};
let child = match spawn_localllm(&config) {
Ok(child) => child,
Err(message) => {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error("spawn_failed", &message));
finish_lifecycle_command(
&mut inner,
command,
RuntimeLifecycleOutcome::Failed,
message,
);
return Ok(build_snapshot(&inner));
}
};
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.child = Some(child);
inner.status = RuntimeStatus::Starting;
}
if wait_for_health(&config.addr, Duration::from_secs(15)) {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.status = RuntimeStatus::Ready;
inner.last_error = None;
let message = if command == RuntimeLifecycleCommand::SaveAndStart {
"Runtime settings saved and local API started."
} else {
"Local API started."
};
finish_lifecycle_command(
&mut inner,
command,
RuntimeLifecycleOutcome::Succeeded,
message,
);
Ok(build_snapshot(&inner))
} else {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
if let Some(child) = inner.child.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
inner.child = None;
inner.status = RuntimeStatus::Error;
let message = format!(
"Local API did not pass /health readiness on {} in time",
config.addr
);
inner.last_error = Some(runtime_error("health_timeout", &message));
finish_lifecycle_command(
&mut inner,
command,
RuntimeLifecycleOutcome::Failed,
message,
);
Ok(build_snapshot(&inner))
}
}
pub fn bootstrap_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
let config = load_runtime_config(&app).unwrap_or_default();
let should_autostart = config.connection_mode == ConnectionMode::Local
&& config.start_policy == StartPolicy::OnAppLaunch
&& config
.model_path
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
{
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
inner.config = config.clone();
refresh_process_state(&mut inner);
}
if should_autostart {
start_runtime_inner(&app, &state, Some(config))
} else {
get_runtime_snapshot(state)
}
}
pub fn start_runtime(
app: AppHandle,
state: State<'_, RuntimeState>,
) -> Result<RuntimeSnapshot, String> {
start_runtime_inner(&app, &state, None)
}
pub fn stop_runtime(state: State<'_, RuntimeState>) -> Result<RuntimeSnapshot, String> {
let mut inner = state.0.lock().map_err(|e| e.to_string())?;
refresh_process_state(&mut inner);
if guard_stop_request(&mut inner).is_err() {
return Ok(build_snapshot(&inner));
}
begin_lifecycle_command(&mut inner, RuntimeLifecycleCommand::Stop);
inner.status = RuntimeStatus::Stopping;
if let Some(child) = inner.child.as_mut() {
if let Err(error) = child.kill() {
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error(
"stop_failed",
format!("Failed to kill server: {error}"),
));
finish_lifecycle_command(
&mut inner,
RuntimeLifecycleCommand::Stop,
RuntimeLifecycleOutcome::Failed,
format!("Failed to stop local API: {error}"),
);
return Ok(build_snapshot(&inner));
}
let _ = child.wait();
}
inner.child = None;
inner.status = RuntimeStatus::Stopped;
inner.last_error = None;
finish_lifecycle_command(
&mut inner,
RuntimeLifecycleCommand::Stop,
RuntimeLifecycleOutcome::Succeeded,
"Local API stopped.",
);
Ok(build_snapshot(&inner))
}
pub use state::RuntimeState;
@@ -0,0 +1,75 @@
use std::path::PathBuf;
use std::process::{Child, Command};
use super::types::RuntimeConfig;
pub fn find_localllm_binary() -> Option<PathBuf> {
let exe_name = if cfg!(target_os = "windows") {
"localllm.exe"
} else {
"localllm"
};
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent();
while let Some(d) = dir {
let candidate = d.join("target").join("release").join(exe_name);
if candidate.exists() {
return Some(candidate);
}
let sibling_candidate = d.join(exe_name);
if sibling_candidate.exists() {
return Some(sibling_candidate);
}
dir = d.parent();
}
}
let cwd_candidate = PathBuf::from("target").join("release").join(exe_name);
if cwd_candidate.exists() {
return Some(cwd_candidate);
}
if let Ok(output) = Command::new(if cfg!(target_os = "windows") {
"where"
} else {
"which"
})
.arg("localllm")
.output()
{
if output.status.success() {
let path_str = String::from_utf8_lossy(&output.stdout);
let first_line = path_str.lines().next().unwrap_or("").trim();
if !first_line.is_empty() {
return Some(PathBuf::from(first_line));
}
}
}
None
}
pub fn spawn_localllm(config: &RuntimeConfig) -> Result<Child, String> {
let model_path = config
.model_path
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| "Select a local model before starting the API".to_string())?;
let binary_path = find_localllm_binary().ok_or_else(|| {
"localllm binary not found. Build it with: cargo build --release -p inference-server\n\
Then ensure the binary is in target/release/, PATH, or next to this app."
.to_string()
})?;
Command::new(&binary_path)
.env("LOCALLLM_MODEL", model_path)
.env("LOCALLLM_ADDR", &config.addr)
.env("LOCALLLM_GPU_LAYERS", config.gpu_layers.to_string())
.env("LOCALLLM_CTX", config.ctx_size.to_string())
.spawn()
.map_err(|e| format!("Failed to start server ({}): {e}", binary_path.display()))
}
+493
View File
@@ -0,0 +1,493 @@
use std::process::Child;
use std::sync::Mutex;
use std::time::Duration;
use super::health::check_health;
use super::process::find_localllm_binary;
use super::types::{
ConnectionMode, RuntimeConfig, RuntimeErrorInfo, RuntimeLifecycleCommand,
RuntimeLifecycleOutcome, RuntimeLifecycleResult, RuntimeSnapshot, RuntimeStatus,
};
pub struct RuntimeInner {
pub child: Option<Child>,
pub status: RuntimeStatus,
pub last_error: Option<RuntimeErrorInfo>,
pub active_command: RuntimeLifecycleCommand,
pub last_command_result: RuntimeLifecycleResult,
pub config: RuntimeConfig,
}
pub struct RuntimeState(pub Mutex<RuntimeInner>);
impl RuntimeState {
pub fn new() -> Self {
Self(Mutex::new(RuntimeInner {
child: None,
status: RuntimeStatus::Stopped,
last_error: None,
active_command: RuntimeLifecycleCommand::Idle,
last_command_result: RuntimeLifecycleResult::default(),
config: RuntimeConfig::default(),
}))
}
}
pub(crate) fn runtime_error(code: &str, message: impl Into<String>) -> RuntimeErrorInfo {
RuntimeErrorInfo {
code: code.to_string(),
message: message.into(),
}
}
pub(crate) fn lifecycle_result(
command: RuntimeLifecycleCommand,
outcome: RuntimeLifecycleOutcome,
message: impl Into<String>,
) -> RuntimeLifecycleResult {
RuntimeLifecycleResult {
command,
outcome,
message: message.into(),
}
}
pub(crate) fn begin_lifecycle_command(inner: &mut RuntimeInner, command: RuntimeLifecycleCommand) {
inner.active_command = command;
}
pub(crate) fn finish_lifecycle_command(
inner: &mut RuntimeInner,
command: RuntimeLifecycleCommand,
outcome: RuntimeLifecycleOutcome,
message: impl Into<String>,
) {
inner.active_command = RuntimeLifecycleCommand::Idle;
inner.last_command_result = lifecycle_result(command, outcome, message);
}
pub(crate) fn block_lifecycle_command(
inner: &mut RuntimeInner,
command: RuntimeLifecycleCommand,
message: impl Into<String>,
) -> RuntimeLifecycleResult {
let result = lifecycle_result(command, RuntimeLifecycleOutcome::Blocked, message);
inner.last_command_result = result.clone();
result
}
fn in_flight_command_message(command: RuntimeLifecycleCommand) -> Option<&'static str> {
match command {
RuntimeLifecycleCommand::Start => Some("Local API start already in progress."),
RuntimeLifecycleCommand::Stop => Some("Local API stop already in progress."),
RuntimeLifecycleCommand::Save => Some("Runtime settings save already in progress."),
RuntimeLifecycleCommand::SaveAndStart => {
Some("Runtime save and start already in progress.")
}
RuntimeLifecycleCommand::Idle => None,
}
}
pub(crate) fn guard_start_request(inner: &mut RuntimeInner) -> Result<(), RuntimeLifecycleResult> {
if let Some(message) = in_flight_command_message(inner.active_command) {
return Err(block_lifecycle_command(
inner,
RuntimeLifecycleCommand::Start,
message,
));
}
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Stopping
) {
let message = if inner.status == RuntimeStatus::Stopping {
"Local API stop already in progress."
} else {
"Local API start already in progress."
};
return Err(block_lifecycle_command(
inner,
RuntimeLifecycleCommand::Start,
message,
));
}
if inner.child.is_some() {
return Err(block_lifecycle_command(
inner,
RuntimeLifecycleCommand::Start,
"Stop the local API before starting it again.",
));
}
Ok(())
}
pub(crate) fn guard_stop_request(inner: &mut RuntimeInner) -> Result<(), RuntimeLifecycleResult> {
if let Some(message) = in_flight_command_message(inner.active_command) {
return Err(block_lifecycle_command(
inner,
RuntimeLifecycleCommand::Stop,
message,
));
}
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Stopping
) {
let message = if inner.status == RuntimeStatus::Starting {
"Local API start already in progress."
} else {
"Local API stop already in progress."
};
return Err(block_lifecycle_command(
inner,
RuntimeLifecycleCommand::Stop,
message,
));
}
if inner.child.is_none() {
return Err(block_lifecycle_command(
inner,
RuntimeLifecycleCommand::Stop,
"Local API is already stopped.",
));
}
Ok(())
}
fn resolve_runtime_url(config: &RuntimeConfig) -> String {
match config.connection_mode {
ConnectionMode::Local => format!("http://{}", config.addr.trim()),
ConnectionMode::Remote => config.server_url.trim().trim_end_matches('/').to_string(),
}
}
pub(crate) fn requires_runtime_restart(current: &RuntimeConfig, next: &RuntimeConfig) -> bool {
current.connection_mode != next.connection_mode
|| current.model_path != next.model_path
|| current.addr != next.addr
|| current.gpu_layers != next.gpu_layers
|| current.ctx_size != next.ctx_size
}
pub(crate) fn refresh_process_state(inner: &mut RuntimeInner) {
let Some(child) = inner.child.as_mut() else {
if matches!(
inner.status,
RuntimeStatus::Starting | RuntimeStatus::Ready | RuntimeStatus::Degraded
) {
inner.status = RuntimeStatus::Stopped;
}
return;
};
match child.try_wait() {
Ok(Some(status)) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error(
"unexpected_exit",
format!("Local API exited unexpectedly with status {status}"),
));
}
Ok(None) => {
if inner.status == RuntimeStatus::Stopped {
inner.status = RuntimeStatus::Ready;
}
if inner.config.connection_mode == ConnectionMode::Local
&& matches!(inner.status, RuntimeStatus::Ready | RuntimeStatus::Degraded)
{
if check_health(&inner.config.addr, Duration::from_millis(400)) {
inner.status = RuntimeStatus::Ready;
if inner.last_error.as_ref().map(|error| error.code.as_str())
== Some("health_check_failed")
{
inner.last_error = None;
}
} else {
inner.status = RuntimeStatus::Degraded;
inner.last_error = Some(runtime_error(
"health_check_failed",
format!(
"Local API process is still running, but /health is no longer ready on {}.",
inner.config.addr
),
));
}
}
}
Err(error) => {
inner.child = None;
inner.status = RuntimeStatus::Error;
inner.last_error = Some(runtime_error("process_probe_failed", error.to_string()));
}
}
}
pub(crate) fn build_snapshot(inner: &RuntimeInner) -> RuntimeSnapshot {
RuntimeSnapshot {
status: inner.status,
ready: inner.status == RuntimeStatus::Ready,
process_running: inner.child.is_some(),
resolved_url: resolve_runtime_url(&inner.config),
binary_path: find_localllm_binary().map(|path| path.to_string_lossy().to_string()),
last_error: inner.last_error.clone(),
active_command: inner.active_command,
last_command_result: inner.last_command_result.clone(),
config: inner.config.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn spawn_healthy_server() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind healthy port");
let addr = listener
.local_addr()
.expect("read healthy addr")
.to_string();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
use std::io::{Read, Write};
let mut buffer = [0_u8; 1024];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: 58\r\n\r\n{\"status\":\"ok\",\"model_loaded\":true,\"model_id\":\"demo\"}",
);
let _ = stream.flush();
}
});
addr
}
fn unused_local_addr() -> String {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind unused port");
let addr = listener.local_addr().expect("read local addr").to_string();
drop(listener);
addr
}
fn spawn_sleeping_child() -> Child {
std::process::Command::new("python")
.args(["-c", "import time; time.sleep(60)"])
.spawn()
.expect("spawn sleeping child")
}
#[test]
fn runtime_revalidation_marks_unhealthy_local_process_as_degraded() {
let mut child = spawn_sleeping_child();
let addr = unused_local_addr();
let (status, error_code) = {
let mut inner = RuntimeInner {
child: Some(child),
status: RuntimeStatus::Ready,
last_error: None,
active_command: RuntimeLifecycleCommand::Idle,
last_command_result: RuntimeLifecycleResult::default(),
config: RuntimeConfig {
model_path: Some("D:/models/demo.gguf".to_string()),
addr,
..RuntimeConfig::default()
},
};
refresh_process_state(&mut inner);
child = inner.child.take().expect("process remains managed");
let error_code: Option<String> =
inner.last_error.as_ref().map(|error| error.code.clone());
(inner.status, error_code)
};
let _ = child.kill();
let _ = child.wait();
assert_eq!(status, RuntimeStatus::Degraded);
assert_eq!(error_code.as_deref(), Some("health_check_failed"));
}
#[test]
fn runtime_revalidation_restores_local_process_to_ready_after_health_recovers() {
let mut child = spawn_sleeping_child();
let addr = spawn_healthy_server();
let (status, error_code) = {
let mut inner = RuntimeInner {
child: Some(child),
status: RuntimeStatus::Degraded,
last_error: Some(runtime_error(
"health_check_failed",
"Local API process is still running, but /health is no longer ready.",
)),
active_command: RuntimeLifecycleCommand::Idle,
last_command_result: RuntimeLifecycleResult::default(),
config: RuntimeConfig {
model_path: Some("D:/models/demo.gguf".to_string()),
addr,
..RuntimeConfig::default()
},
};
refresh_process_state(&mut inner);
child = inner.child.take().expect("process remains managed");
let error_code: Option<String> =
inner.last_error.as_ref().map(|error| error.code.clone());
(inner.status, error_code)
};
let _ = child.kill();
let _ = child.wait();
assert_eq!(status, RuntimeStatus::Ready);
assert_eq!(error_code, None);
}
#[test]
fn build_snapshot_serializes_lifecycle_command_metadata() {
let inner = RuntimeInner {
child: None,
status: RuntimeStatus::Stopped,
last_error: None,
active_command: RuntimeLifecycleCommand::Idle,
last_command_result: RuntimeLifecycleResult::default(),
config: RuntimeConfig::default(),
};
let snapshot = build_snapshot(&inner);
let json = serde_json::to_value(snapshot).expect("serialize snapshot");
assert_eq!(
json.get("active_command").and_then(|value| value.as_str()),
Some("idle")
);
assert_eq!(
json.get("last_command_result")
.and_then(|value| value.get("command"))
.and_then(|value| value.as_str()),
Some("idle")
);
assert_eq!(
json.get("last_command_result")
.and_then(|value| value.get("outcome"))
.and_then(|value| value.as_str()),
Some("succeeded")
);
assert_eq!(
json.get("last_command_result")
.and_then(|value| value.get("message"))
.and_then(|value| value.as_str()),
Some("Runtime idle.")
);
}
#[test]
fn build_snapshot_keeps_existing_runtime_fields() {
let inner = RuntimeInner {
child: None,
status: RuntimeStatus::Error,
last_error: Some(runtime_error("spawn_failed", "boom")),
active_command: RuntimeLifecycleCommand::Idle,
last_command_result: RuntimeLifecycleResult::default(),
config: RuntimeConfig::default(),
};
let snapshot = build_snapshot(&inner);
let json = serde_json::to_value(snapshot).expect("serialize snapshot");
for key in [
"status",
"ready",
"process_running",
"resolved_url",
"binary_path",
"last_error",
"config",
] {
assert!(json.get(key).is_some(), "missing snapshot field: {key}");
}
}
#[test]
fn guard_start_request_blocks_when_runtime_is_mid_transition() {
let mut inner = RuntimeInner {
child: None,
status: RuntimeStatus::Starting,
last_error: None,
active_command: RuntimeLifecycleCommand::Start,
last_command_result: RuntimeLifecycleResult::default(),
config: RuntimeConfig::default(),
};
let blocked = guard_start_request(&mut inner).expect_err("start should be blocked");
assert_eq!(blocked.command, RuntimeLifecycleCommand::Start);
assert_eq!(
blocked.outcome,
super::super::types::RuntimeLifecycleOutcome::Blocked
);
assert_eq!(blocked.message, "Local API start already in progress.");
assert_eq!(inner.active_command, RuntimeLifecycleCommand::Start);
}
#[test]
fn guard_stop_request_blocks_when_runtime_is_already_stopped() {
let mut inner = RuntimeInner {
child: None,
status: RuntimeStatus::Stopped,
last_error: None,
active_command: RuntimeLifecycleCommand::Idle,
last_command_result: RuntimeLifecycleResult::default(),
config: RuntimeConfig::default(),
};
let blocked = guard_stop_request(&mut inner).expect_err("stop should be blocked");
assert_eq!(blocked.command, RuntimeLifecycleCommand::Stop);
assert_eq!(
blocked.outcome,
super::super::types::RuntimeLifecycleOutcome::Blocked
);
assert_eq!(blocked.message, "Local API is already stopped.");
assert_eq!(inner.active_command, RuntimeLifecycleCommand::Idle);
}
#[test]
fn requires_runtime_restart_only_considers_backend_restart_fields() {
let current = RuntimeConfig::default();
let mut changing_addr = current.clone();
changing_addr.addr = "127.0.0.1:9090".to_string();
assert!(requires_runtime_restart(&current, &changing_addr));
let mut changing_models_dir = current.clone();
changing_models_dir.models_dir = Some("D:/other-models".to_string());
assert!(!requires_runtime_restart(&current, &changing_models_dir));
let mut changing_selected_model = current.clone();
changing_selected_model.selected_model_id = Some("demo-model".to_string());
assert!(!requires_runtime_restart(
&current,
&changing_selected_model
));
}
}
+162
View File
@@ -0,0 +1,162 @@
use serde::{Deserialize, Serialize};
use crate::model_library::types::RemotePreset;
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionMode {
#[default]
Local,
Remote,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "snake_case")]
pub enum StartPolicy {
#[default]
Manual,
OnAppLaunch,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeStatus {
#[default]
Stopped,
Starting,
Ready,
Degraded,
Stopping,
Error,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeLifecycleCommand {
#[default]
Idle,
Save,
Start,
Stop,
SaveAndStart,
}
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default, Debug)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeLifecycleOutcome {
#[default]
Succeeded,
Blocked,
Failed,
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct RuntimeLifecycleResult {
pub command: RuntimeLifecycleCommand,
pub outcome: RuntimeLifecycleOutcome,
pub message: String,
}
impl Default for RuntimeLifecycleResult {
fn default() -> Self {
Self {
command: RuntimeLifecycleCommand::Idle,
outcome: RuntimeLifecycleOutcome::Succeeded,
message: "Runtime idle.".to_string(),
}
}
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct RuntimeConfig {
pub connection_mode: ConnectionMode,
pub start_policy: StartPolicy,
pub server_url: String,
pub api_key: String,
pub model_path: Option<String>,
pub models_dir: Option<String>,
pub addr: String,
pub gpu_layers: i32,
pub ctx_size: u32,
pub extra_model_paths: Vec<String>,
pub selected_model_id: Option<String>,
pub remote_presets: Vec<RemotePreset>,
pub hugging_face_token: String,
}
impl RuntimeConfig {
pub fn default_addr() -> String {
"127.0.0.1:8080".to_string()
}
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
connection_mode: ConnectionMode::Local,
start_policy: StartPolicy::Manual,
server_url: String::new(),
api_key: String::new(),
model_path: None,
models_dir: None,
addr: Self::default_addr(),
gpu_layers: 0,
ctx_size: 4096,
extra_model_paths: Vec::new(),
selected_model_id: None,
remote_presets: Vec::new(),
hugging_face_token: String::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::{ConnectionMode, RuntimeConfig};
#[test]
fn runtime_config_deserializes_old_shape_with_new_defaults() {
let legacy = r#"
{
"connection_mode": "local",
"start_policy": "manual",
"server_url": "",
"api_key": "",
"model_path": "D:/models/demo.gguf",
"models_dir": "D:/models",
"addr": "127.0.0.1:8080",
"gpu_layers": 12,
"ctx_size": 4096,
"extra_model_paths": ["D:/models/demo.gguf"]
}
"#;
let config: RuntimeConfig =
serde_json::from_str(legacy).expect("deserialize legacy config");
assert_eq!(config.connection_mode, ConnectionMode::Local);
assert_eq!(config.selected_model_id, None);
assert!(config.remote_presets.is_empty());
assert_eq!(config.hugging_face_token, "");
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct RuntimeErrorInfo {
pub code: String,
pub message: String,
}
#[derive(Serialize, Clone)]
pub struct RuntimeSnapshot {
pub status: RuntimeStatus,
pub ready: bool,
pub process_running: bool,
pub resolved_url: String,
pub binary_path: Option<String>,
pub last_error: Option<RuntimeErrorInfo>,
pub active_command: RuntimeLifecycleCommand,
pub last_command_result: RuntimeLifecycleResult,
pub config: RuntimeConfig,
}