use std::process::Stdio;
use crate::cli::output::OutputConfig;
use crate::cli::StartArgs;
use crate::config;
use crate::error::{OlError, ERR_DAEMON_START_FAILED, ERR_INVALID_CONFIG};
pub(crate) fn log_observability_status_from_env() {
let dir = config::openlatch_dir();
let telemetry_consent = crate::telemetry::consent::resolve(&dir.join("telemetry.json"));
let baked_key_present = crate::telemetry::network::key_is_present();
let telemetry_enabled = telemetry_consent.enabled() && baked_key_present;
let telemetry_decided_by = if !baked_key_present {
"NoBakedKey".to_string()
} else {
format!("{:?}", telemetry_consent.decided_by)
};
#[cfg(feature = "crash-report")]
let (crash_report_enabled, crash_report_decided_by) = {
let resolved = crate::crash_report::current_state(&dir);
(resolved.enabled(), format!("{:?}", resolved.decided_by))
};
#[cfg(not(feature = "crash-report"))]
let (crash_report_enabled, crash_report_decided_by) = (false, "BuildExcluded".to_string());
crate::logging::daemon_log::log_observability_status(
telemetry_enabled,
&telemetry_decided_by,
crash_report_enabled,
&crash_report_decided_by,
);
}
pub fn run_start(args: &StartArgs, output: &OutputConfig) -> Result<(), OlError> {
let cfg = config::Config::load(args.port, None, false)?;
if let Some(pid) = read_pid_file() {
if is_process_alive(pid) {
output.print_info(&format!("Daemon is already running (PID {pid})"));
return Ok(());
}
}
let token = load_or_generate_token()?;
if args.foreground {
run_daemon_foreground(cfg.port, &token)?;
} else {
let pid = spawn_daemon_background(cfg.port, &token)?;
if !wait_for_health(cfg.port, 5) {
return Err(OlError::new(
ERR_DAEMON_START_FAILED,
format!("Daemon spawned (PID {pid}) but health check failed within 5s"),
)
.with_suggestion("Check ~/.openlatch/logs/daemon.log for errors.")
.with_docs("https://docs.openlatch.ai/errors/OL-1502"));
}
output.print_step(&format!("Daemon started on port {} (PID {pid})", cfg.port));
}
Ok(())
}
pub fn run_stop(output: &OutputConfig) -> Result<(), OlError> {
let Some(pid) = read_pid_file() else {
output.print_info("Daemon is not running");
return Ok(());
};
if !is_process_alive(pid) {
output.print_info("Daemon is not running");
let _ = std::fs::remove_file(config::openlatch_dir().join("daemon.pid"));
return Ok(());
}
let cfg = config::Config::load(None, None, false)?;
let token = load_or_generate_token().unwrap_or_default();
if send_shutdown_request(cfg.port, &token) {
let start = std::time::Instant::now();
while start.elapsed() < std::time::Duration::from_secs(5) {
if !is_process_alive(pid) {
break;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
if !is_process_alive(pid) {
let _ = std::fs::remove_file(config::openlatch_dir().join("daemon.pid"));
output.print_step("Daemon stopped");
return Ok(());
}
force_kill(pid);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while std::time::Instant::now() < deadline && is_process_alive(pid) {
std::thread::sleep(std::time::Duration::from_millis(100));
}
if is_process_alive(pid) {
return Err(OlError::new(
ERR_INVALID_CONFIG,
format!("Failed to stop daemon (pid {pid}); process still running"),
)
.with_suggestion("Kill the process manually and remove ~/.openlatch/daemon.pid."));
}
let _ = std::fs::remove_file(config::openlatch_dir().join("daemon.pid"));
output.print_step("Daemon stopped");
Ok(())
}
pub fn run_restart(output: &OutputConfig) -> Result<(), OlError> {
run_stop(output)?;
let timeout = std::time::Duration::from_secs(5);
let start = std::time::Instant::now();
let cfg = config::Config::load(None, None, false)?;
while start.elapsed() < timeout {
let pid_file_gone = read_pid_file().is_none();
let health_down = !check_health(cfg.port);
if pid_file_gone || health_down {
break;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
let start_args = StartArgs {
foreground: false,
port: None,
};
run_start(&start_args, output)
}
pub fn spawn_daemon_background(port: u16, token: &str) -> Result<u32, OlError> {
let exe = std::env::current_exe().map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot locate current executable: {e}"),
)
})?;
#[cfg(unix)]
let child = {
use std::os::unix::process::CommandExt;
std::process::Command::new(&exe)
.args([
"daemon",
"start",
"--foreground",
"--port",
&port.to_string(),
])
.env("OPENLATCH_TOKEN", token)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()
.map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Failed to spawn daemon process: {e}"),
)
.with_suggestion("Check that the openlatch binary is executable.")
})?
};
#[cfg(windows)]
let child = {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
std::process::Command::new(&exe)
.args([
"daemon",
"start",
"--foreground",
"--port",
&port.to_string(),
])
.env("OPENLATCH_TOKEN", token)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP)
.spawn()
.map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Failed to spawn daemon process: {e}"),
)
.with_suggestion("Check that the openlatch binary is executable.")
})?
};
let pid = child.id();
Ok(pid)
}
pub(crate) fn read_pid_file() -> Option<u32> {
let pid_path = config::openlatch_dir().join("daemon.pid");
let content = std::fs::read_to_string(&pid_path).ok()?;
content.trim().parse::<u32>().ok()
}
pub(crate) fn is_process_alive(pid: u32) -> bool {
if pid == 0 {
return false;
}
#[cfg(unix)]
{
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0
}
#[cfg(windows)]
{
let handle = unsafe {
winapi::um::processthreadsapi::OpenProcess(
winapi::um::winnt::PROCESS_QUERY_INFORMATION,
0,
pid,
)
};
if handle.is_null() {
return false;
}
let mut exit_code: u32 = 0;
let alive = unsafe {
winapi::um::processthreadsapi::GetExitCodeProcess(handle, &mut exit_code) != 0
&& exit_code == winapi::um::minwinbase::STILL_ACTIVE
};
unsafe { winapi::um::handleapi::CloseHandle(handle) };
alive
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
false
}
}
pub(crate) fn send_shutdown_request(port: u16, token: &str) -> bool {
let url = format!("http://127.0.0.1:{port}/shutdown");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build();
match client {
Ok(c) => c
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.send()
.map(|r| r.status().is_success() || r.status() == reqwest::StatusCode::GONE)
.unwrap_or(false),
Err(_) => false,
}
}
pub(crate) fn force_kill(pid: u32) {
#[cfg(unix)]
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
pub(crate) fn wait_for_health(port: u16, timeout_secs: u64) -> bool {
let url = format!("http://127.0.0.1:{port}/health");
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(timeout_secs);
while start.elapsed() < timeout {
if let Ok(resp) = reqwest::blocking::get(&url) {
if resp.status().is_success() {
return true;
}
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
false
}
pub(crate) fn check_health(port: u16) -> bool {
let url = format!("http://127.0.0.1:{port}/health");
reqwest::blocking::get(url)
.map(|r| r.status().is_success())
.unwrap_or(false)
}
pub(crate) fn build_credential_store() -> std::sync::Arc<dyn crate::auth::CredentialStore> {
let agent_id = config::Config::load(None, None, false)
.ok()
.and_then(|c| c.agent_id)
.unwrap_or_default();
let keyring = Box::new(crate::auth::KeyringCredentialStore::new());
let file = Box::new(crate::auth::FileCredentialStore::new(
config::openlatch_dir().join("credentials.enc"),
agent_id,
));
std::sync::Arc::new(crate::auth::FallbackCredentialStore::new(keyring, file))
}
fn load_or_generate_token() -> Result<String, OlError> {
let ol_dir = config::openlatch_dir();
config::ensure_token(&ol_dir)
}
fn run_daemon_foreground(port: u16, token: &str) -> Result<(), OlError> {
let config_path = config::openlatch_dir().join("config.toml");
if config_path.exists() {
let _ = config::ensure_agent_id(&config_path);
}
let mut cfg = config::Config::load(Some(port), None, true)?;
cfg.foreground = true;
let rt = tokio::runtime::Runtime::new().map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Failed to create async runtime: {e}"),
)
})?;
let token_owned = token.to_string();
let pid = std::process::id();
#[cfg(feature = "crash-report")]
crate::crash_report::enrich_daemon_scope(cfg.port, pid);
rt.block_on(async move {
use crate::daemon;
use crate::envelope;
use crate::logging;
use crate::privacy;
let _guard = logging::daemon_log::init_daemon_logging(&cfg.log_dir, cfg.foreground);
if let Ok(deleted) = logging::cleanup_old_logs(&cfg.log_dir, cfg.retention_days) {
if deleted > 0 {
tracing::info!(deleted = deleted, "cleaned up old log files");
}
}
privacy::init_filter(&cfg.extra_patterns);
let pid_path = config::openlatch_dir().join("daemon.pid");
if let Err(e) = std::fs::write(&pid_path, pid.to_string()) {
tracing::warn!(error = %e, "failed to write PID file");
}
logging::daemon_log::log_startup(
env!("CARGO_PKG_VERSION"),
cfg.port,
pid,
envelope::os_string(),
envelope::arch_string(),
);
log_observability_status_from_env();
let header_output = crate::cli::output::OutputConfig {
format: crate::cli::output::OutputFormat::Human,
verbose: false,
debug: false,
quiet: false,
color: std::io::IsTerminal::is_terminal(&std::io::stderr()),
};
crate::cli::header::print(
&header_output,
&[
&format!("listening 127.0.0.1:{}", cfg.port),
&format!("pid {pid}"),
],
);
let credential_store = build_credential_store();
match daemon::start_server(cfg.clone(), token_owned, Some(credential_store)).await {
Ok((uptime_secs, events)) => {
eprintln!(
"openlatch daemon stopped \u{2022} uptime {} \u{2022} {} events processed",
daemon::format_uptime(uptime_secs),
events
);
}
Err(e) => {
tracing::error!(error = %e, "daemon exited with error");
eprintln!("Error: daemon exited unexpectedly: {e}");
}
}
let _ = std::fs::remove_file(&pid_path);
});
#[cfg(feature = "crash-report")]
crate::crash_report::flush(std::time::Duration::from_secs(2));
Ok(())
}