mod app;
mod body;
mod errors;
mod query;
mod routes;
mod sse;
mod static_assets;
use crate::runtime::DaemonRuntime;
use crate::server::routes::deploy_providers::oauth::ProviderLogins;
use crate::DaemonOwnership;
use anyhow::{Context, Result};
use app::AppState;
use chrono::Utc;
use nomoreide_core::agent_profiles::auth::AuthStates;
use nomoreide_core::approval_broker::ApprovalBroker;
use nomoreide_core::config::ConfigStore;
use nomoreide_core::error_inbox::ErrorInbox;
use nomoreide_core::log_store::LogStore;
use nomoreide_core::metrics_store::MetricsStore;
use nomoreide_core::process_manager::ProcessManager;
use nomoreide_core::runtime_registry::RuntimeRegistry;
use nomoreide_core::terminal::TerminalManager;
use nomoreide_core::test_runner::TestRunner;
use nomoreide_core::timeline::TimelineStore;
use nomoreide_core::tool_call_store::ToolCallStore;
use nomoreide_core::usage_history::UsageHistory;
use nomoreide_daemon_client::{DaemonState, RuntimePaths};
use std::future::Future;
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, oneshot};
#[derive(Debug, Clone)]
pub struct DaemonOptions {
pub port: u16,
pub runtime_paths: RuntimePaths,
pub config_path: PathBuf,
}
impl Default for DaemonOptions {
fn default() -> Self {
Self {
port: nomoreide_daemon_client::DEFAULT_DAEMON_PORT,
runtime_paths: RuntimePaths::default(),
config_path: ConfigStore::default_path(),
}
}
}
pub async fn run(options: DaemonOptions) -> Result<()> {
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
tokio::spawn(forward_shutdown_signals(shutdown_tx.clone()));
serve_with_shutdown_requests(options, shutdown_tx, shutdown_rx).await
}
pub async fn run_with_listener(options: DaemonOptions, listener: TcpListener) -> Result<()> {
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
tokio::spawn(forward_shutdown_signals(shutdown_tx.clone()));
serve_on_listener(
options,
listener,
RuntimePublication::Files,
shutdown_tx,
shutdown_rx,
)
.await
}
pub async fn run_embedded(
options: DaemonOptions,
listener: TcpListener,
credential: String,
) -> Result<()> {
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
run_embedded_with_shutdown_requests(options, listener, credential, shutdown_tx, shutdown_rx)
.await
}
pub async fn run_embedded_with_shutdown_requests(
options: DaemonOptions,
listener: TcpListener,
credential: String,
shutdown_sender: mpsc::Sender<ShutdownRequest>,
shutdown_requests: mpsc::Receiver<ShutdownRequest>,
) -> Result<()> {
anyhow::ensure!(
!credential.is_empty(),
"embedded daemon credential is empty"
);
serve_on_listener(
options,
listener,
RuntimePublication::Memory(credential),
shutdown_sender,
shutdown_requests,
)
.await
}
pub async fn serve_until<F>(options: DaemonOptions, shutdown: F) -> Result<()>
where
F: Future<Output = ()> + Send + 'static,
{
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
let signalled = shutdown_tx.clone();
tokio::spawn(async move {
shutdown.await;
let _ = signalled.send(ShutdownRequest::Signalled).await;
});
serve_with_shutdown_requests(options, shutdown_tx, shutdown_rx).await
}
pub async fn serve_with_shutdown_requests(
options: DaemonOptions,
shutdown_sender: mpsc::Sender<ShutdownRequest>,
shutdown_requests: mpsc::Receiver<ShutdownRequest>,
) -> Result<()> {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, options.port)))
.await
.context("failed to bind the daemon loopback listener")?;
serve_on_listener(
options,
listener,
RuntimePublication::Files,
shutdown_sender,
shutdown_requests,
)
.await
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownRequest {
Requested,
Signalled,
}
enum RuntimePublication {
Files,
Memory(String),
}
async fn serve_on_listener(
options: DaemonOptions,
listener: TcpListener,
publication: RuntimePublication,
shutdown_sender: mpsc::Sender<ShutdownRequest>,
shutdown_requests: mpsc::Receiver<ShutdownRequest>,
) -> Result<()> {
let ownership = DaemonOwnership::acquire(options.runtime_paths.clone())
.context("failed to acquire daemon ownership")?;
let config_store = ConfigStore::new(options.config_path);
let timeline = TimelineStore::new(options.runtime_paths.state_dir.join("timeline.log"));
let log_store =
LogStore::new(options.runtime_paths.state_dir.join("logs")).with_timeline(timeline.clone());
let registry = RuntimeRegistry::new(
options
.runtime_paths
.state_dir
.join("native")
.join("runtime-v1.json"),
);
let errors = ErrorInbox::new(log_store.clone());
errors.watch();
let tests = TestRunner::new(log_store.clone());
let runtime = Arc::new(DaemonRuntime::new(
config_store.clone(),
ProcessManager::with_runtime_registry(log_store, registry).with_timeline(timeline),
));
runtime
.reconcile_runtime()
.await
.context("failed to reconcile the native runtime registry")?;
let address = listener
.local_addr()
.context("failed to inspect the daemon listener")?;
let state = DaemonState {
pid: std::process::id(),
owner_id: ownership.owner_id().to_string(),
url: format!("http://127.0.0.1:{}", address.port()),
port: address.port(),
version: Some(env!("CARGO_PKG_VERSION").into()),
started_at: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
};
let (credential, embedded) = match publication {
RuntimePublication::Files => {
ownership
.publish(&state)
.context("failed to publish daemon state")?;
(ownership.credential().to_string(), false)
}
RuntimePublication::Memory(credential) => (credential, true),
};
let metrics = MetricsStore::new(crate::server::routes::daemon_cwd());
metrics.sample_once(&[]).await;
tokio::spawn(sample_metrics(metrics.clone(), runtime.clone()));
let usage_history = Arc::new(UsageHistory::new(
options.runtime_paths.state_dir.join("usage-history.jsonl"),
));
tokio::spawn(sample_usage(usage_history.clone()));
tokio::spawn(watch_runtime_home(
options.runtime_paths.clone(),
ownership.owner_id().to_string(),
shutdown_sender.clone(),
));
let event_stream = tokio::sync::broadcast::Sender::<app::RuntimeEvent>::new(app::EVENT_BACKLOG);
let terminal = TerminalManager::new();
let events: nomoreide_core::event_sink::SharedEventSink =
Arc::new(app::BroadcastEventSink::new(event_stream.clone()));
#[cfg(unix)]
let _attach_server = nomoreide_core::terminal::attach::serve(
&options.runtime_paths.state_dir,
terminal.clone(),
events.clone(),
)
.map_err(anyhow::Error::msg)
.context("failed to start local terminal attachment")?;
let relay = crate::remote::supervisor::RelaySupervisor::new(
options.runtime_paths.state_dir.clone(),
credential.clone(),
terminal.clone(),
);
let app = routes::router(AppState {
credential,
owner_id: ownership.owner_id().to_string(),
config_store,
runtime: runtime.clone(),
errors,
shutdown: shutdown_sender,
terminal,
events,
event_stream,
session_counter: Arc::new(std::sync::atomic::AtomicU64::new(0)),
metrics: metrics.clone(),
tool_calls: ToolCallStore::new(),
tests,
usage_history,
approvals: ApprovalBroker::new(),
registry_auth: AuthStates::new(),
provider_logins: ProviderLogins::new(),
relay: relay.clone(),
pending_pairing: Default::default(),
});
let app = if embedded {
app.layer(axum::middleware::from_fn(routes::allow_desktop_origin))
} else {
app
};
if !embedded {
relay.attach_router(app.clone());
relay.ensure_started();
}
let (http_shutdown_tx, http_shutdown_rx) = oneshot::channel();
let shutdown_coordinator = tokio::spawn(drain_before_shutdown(
runtime.clone(),
shutdown_requests,
http_shutdown_tx,
));
let server_result = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = http_shutdown_rx.await;
})
.await;
shutdown_coordinator.abort();
server_result.context("daemon HTTP server failed")?;
drop(ownership);
Ok(())
}
async fn drain_before_shutdown(
runtime: Arc<DaemonRuntime>,
mut requests: mpsc::Receiver<ShutdownRequest>,
http_shutdown: oneshot::Sender<()>,
) {
let mut http_shutdown = Some(http_shutdown);
loop {
let Some(request) = requests.recv().await else {
std::future::pending::<()>().await;
continue;
};
match runtime.shutdown().await {
Ok(()) => {
if let Some(sender) = http_shutdown.take() {
let _ = sender.send(());
}
return;
}
Err(error) => {
eprintln!("nomoreide: daemon cleanup failed: {error}");
if stops_anyway(request) {
eprintln!("nomoreide: exiting anyway; a signal is not a request to decline.");
if let Some(sender) = http_shutdown.take() {
let _ = sender.send(());
}
return;
}
eprintln!("nomoreide: shutdown refused; send SIGTERM to stop regardless.");
}
}
}
}
const RUNTIME_HOME_POLL: Duration = Duration::from_secs(30);
const RUNTIME_HOME_MISSES: u8 = 2;
async fn watch_runtime_home(
paths: RuntimePaths,
owner_id: String,
shutdown: mpsc::Sender<ShutdownRequest>,
) {
let mut misses = 0u8;
loop {
tokio::time::sleep(RUNTIME_HOME_POLL).await;
if runtime_home_is_ours(&paths, &owner_id) {
misses = 0;
continue;
}
misses += 1;
if misses < RUNTIME_HOME_MISSES {
continue;
}
eprintln!(
"nomoreide: runtime home {} is gone; stopping.",
paths.state_dir.display()
);
let _ = shutdown.send(ShutdownRequest::Signalled).await;
return;
}
}
fn runtime_home_is_ours(paths: &RuntimePaths, owner_id: &str) -> bool {
let Ok(raw) = std::fs::read(&paths.lock) else {
return false;
};
match serde_json::from_slice::<crate::LockRecord>(&raw) {
Ok(record) => record.owner_id == owner_id,
Err(_) => true,
}
}
fn stops_anyway(request: ShutdownRequest) -> bool {
matches!(request, ShutdownRequest::Signalled)
}
async fn forward_shutdown_signals(sender: mpsc::Sender<ShutdownRequest>) {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let Ok(mut terminate) = signal(SignalKind::terminate()) else {
return;
};
let Ok(mut interrupt) = signal(SignalKind::interrupt()) else {
return;
};
loop {
tokio::select! {
_ = terminate.recv() => {}
_ = interrupt.recv() => {}
}
if sender.send(ShutdownRequest::Signalled).await.is_err() {
return;
}
}
}
#[cfg(not(unix))]
loop {
if tokio::signal::ctrl_c().await.is_err()
|| sender.send(ShutdownRequest::Signalled).await.is_err()
{
return;
}
}
}
async fn sample_usage(history: Arc<UsageHistory>) {
let cwd = crate::server::routes::daemon_cwd();
tokio::time::sleep(Duration::from_secs(5)).await;
loop {
let usage = nomoreide_core::usage_info::build_usage_info(&cwd).await;
history.record(&usage).await;
tokio::time::sleep(Duration::from_secs(30)).await;
}
}
async fn sample_metrics(metrics: MetricsStore, runtime: Arc<DaemonRuntime>) {
let interval = Duration::from_millis(metrics.interval_ms());
loop {
tokio::time::sleep(interval).await;
let running: Vec<nomoreide_core::metrics_store::RunningService> = runtime
.status()
.into_iter()
.filter(|status| {
serde_json::to_value(status.state)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.as_deref()
== Some("running")
})
.map(|status| nomoreide_core::metrics_store::RunningService {
name: status.name,
pid: status.pid.map(i64::from),
started_at: status.started_at,
})
.collect();
metrics.sample_once(&running).await;
}
}
#[cfg(test)]
mod tests {
use super::{runtime_home_is_ours, stops_anyway, RuntimePaths, ShutdownRequest};
#[test]
fn a_signal_stops_the_daemon_even_when_cleanup_fails() {
assert!(stops_anyway(ShutdownRequest::Signalled));
}
#[test]
fn a_missing_lock_file_means_the_runtime_home_is_gone() {
let dir = std::env::temp_dir().join(format!("nmi-home-{}", uuid::Uuid::new_v4()));
let paths = RuntimePaths::new(dir.clone());
assert!(!runtime_home_is_ours(&paths, "owner-a"));
}
#[test]
fn a_lock_naming_another_owner_means_this_daemon_is_not_it() {
let dir = std::env::temp_dir().join(format!("nmi-home-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let paths = RuntimePaths::new(dir.clone());
std::fs::write(&paths.lock, br#"{"pid":1,"ownerId":"owner-b"}"#).unwrap();
assert!(!runtime_home_is_ours(&paths, "owner-a"));
assert!(runtime_home_is_ours(&paths, "owner-b"));
std::fs::write(&paths.lock, b"{not json").unwrap();
assert!(runtime_home_is_ours(&paths, "owner-a"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn an_http_request_still_refuses_when_cleanup_fails() {
assert!(!stops_anyway(ShutdownRequest::Requested));
}
}