1mod app;
5mod body;
6mod errors;
7mod query;
8mod routes;
9mod sse;
10mod static_assets;
11
12use crate::runtime::DaemonRuntime;
13use crate::server::routes::deploy_providers::oauth::ProviderLogins;
14use crate::DaemonOwnership;
15use anyhow::{Context, Result};
16use app::AppState;
17use chrono::Utc;
18use nomoreide_core::agent_profiles::auth::AuthStates;
19use nomoreide_core::approval_broker::ApprovalBroker;
20use nomoreide_core::config::ConfigStore;
21use nomoreide_core::error_inbox::ErrorInbox;
22use nomoreide_core::log_store::LogStore;
23use nomoreide_core::metrics_store::MetricsStore;
24use nomoreide_core::process_manager::ProcessManager;
25use nomoreide_core::runtime_registry::RuntimeRegistry;
26use nomoreide_core::terminal::TerminalManager;
27use nomoreide_core::test_runner::TestRunner;
28use nomoreide_core::timeline::TimelineStore;
29use nomoreide_core::tool_call_store::ToolCallStore;
30use nomoreide_core::usage_history::UsageHistory;
31use nomoreide_daemon_client::{DaemonState, RuntimePaths};
32use std::future::Future;
33use std::net::{Ipv4Addr, SocketAddr};
34use std::path::PathBuf;
35use std::sync::Arc;
36use std::time::Duration;
37use tokio::net::TcpListener;
38use tokio::sync::{mpsc, oneshot};
39
40#[derive(Debug, Clone)]
41pub struct DaemonOptions {
42 pub port: u16,
43 pub runtime_paths: RuntimePaths,
44 pub config_path: PathBuf,
45}
46
47impl Default for DaemonOptions {
48 fn default() -> Self {
49 Self {
50 port: nomoreide_daemon_client::DEFAULT_DAEMON_PORT,
51 runtime_paths: RuntimePaths::default(),
52 config_path: ConfigStore::default_path(),
53 }
54 }
55}
56
57pub async fn run(options: DaemonOptions) -> Result<()> {
58 let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
59 tokio::spawn(forward_shutdown_signals(shutdown_tx.clone()));
60 serve_with_shutdown_requests(options, shutdown_tx, shutdown_rx).await
61}
62
63pub async fn run_with_listener(options: DaemonOptions, listener: TcpListener) -> Result<()> {
69 let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
70 tokio::spawn(forward_shutdown_signals(shutdown_tx.clone()));
71 serve_on_listener(
72 options,
73 listener,
74 RuntimePublication::Files,
75 shutdown_tx,
76 shutdown_rx,
77 )
78 .await
79}
80
81pub async fn run_embedded(
88 options: DaemonOptions,
89 listener: TcpListener,
90 credential: String,
91) -> Result<()> {
92 let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
93 run_embedded_with_shutdown_requests(options, listener, credential, shutdown_tx, shutdown_rx)
94 .await
95}
96
97pub async fn run_embedded_with_shutdown_requests(
103 options: DaemonOptions,
104 listener: TcpListener,
105 credential: String,
106 shutdown_sender: mpsc::Sender<ShutdownRequest>,
107 shutdown_requests: mpsc::Receiver<ShutdownRequest>,
108) -> Result<()> {
109 anyhow::ensure!(
110 !credential.is_empty(),
111 "embedded daemon credential is empty"
112 );
113 serve_on_listener(
114 options,
115 listener,
116 RuntimePublication::Memory(credential),
117 shutdown_sender,
118 shutdown_requests,
119 )
120 .await
121}
122
123pub async fn serve_until<F>(options: DaemonOptions, shutdown: F) -> Result<()>
124where
125 F: Future<Output = ()> + Send + 'static,
126{
127 let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
128 let signalled = shutdown_tx.clone();
129 tokio::spawn(async move {
130 shutdown.await;
131 let _ = signalled.send(ShutdownRequest::Signalled).await;
132 });
133 serve_with_shutdown_requests(options, shutdown_tx, shutdown_rx).await
134}
135
136pub async fn serve_with_shutdown_requests(
140 options: DaemonOptions,
141 shutdown_sender: mpsc::Sender<ShutdownRequest>,
142 shutdown_requests: mpsc::Receiver<ShutdownRequest>,
143) -> Result<()> {
144 let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, options.port)))
145 .await
146 .context("failed to bind the daemon loopback listener")?;
147 serve_on_listener(
148 options,
149 listener,
150 RuntimePublication::Files,
151 shutdown_sender,
152 shutdown_requests,
153 )
154 .await
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub enum ShutdownRequest {
169 Requested,
174 Signalled,
182}
183
184enum RuntimePublication {
185 Files,
186 Memory(String),
187}
188
189async fn serve_on_listener(
190 options: DaemonOptions,
191 listener: TcpListener,
192 publication: RuntimePublication,
193 shutdown_sender: mpsc::Sender<ShutdownRequest>,
194 shutdown_requests: mpsc::Receiver<ShutdownRequest>,
195) -> Result<()> {
196 let ownership = DaemonOwnership::acquire(options.runtime_paths.clone())
197 .context("failed to acquire daemon ownership")?;
198 let config_store = ConfigStore::new(options.config_path);
199 let timeline = TimelineStore::new(options.runtime_paths.state_dir.join("timeline.log"));
203 let log_store =
204 LogStore::new(options.runtime_paths.state_dir.join("logs")).with_timeline(timeline.clone());
205 let registry = RuntimeRegistry::new(
206 options
207 .runtime_paths
208 .state_dir
209 .join("native")
210 .join("runtime-v1.json"),
211 );
212 let errors = ErrorInbox::new(log_store.clone());
215 errors.watch();
216 let tests = TestRunner::new(log_store.clone());
220 let runtime = Arc::new(DaemonRuntime::new(
221 config_store.clone(),
222 ProcessManager::with_runtime_registry(log_store, registry).with_timeline(timeline),
223 ));
224 runtime
227 .reconcile_runtime()
228 .await
229 .context("failed to reconcile the native runtime registry")?;
230
231 let address = listener
232 .local_addr()
233 .context("failed to inspect the daemon listener")?;
234 let state = DaemonState {
235 pid: std::process::id(),
236 owner_id: ownership.owner_id().to_string(),
237 url: format!("http://127.0.0.1:{}", address.port()),
238 port: address.port(),
239 version: Some(env!("CARGO_PKG_VERSION").into()),
240 started_at: Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
241 };
242 let (credential, embedded) = match publication {
243 RuntimePublication::Files => {
244 ownership
245 .publish(&state)
246 .context("failed to publish daemon state")?;
247 (ownership.credential().to_string(), false)
248 }
249 RuntimePublication::Memory(credential) => (credential, true),
250 };
251
252 let metrics = MetricsStore::new(crate::server::routes::daemon_cwd());
258 metrics.sample_once(&[]).await;
262 tokio::spawn(sample_metrics(metrics.clone(), runtime.clone()));
263
264 let usage_history = Arc::new(UsageHistory::new(
265 options.runtime_paths.state_dir.join("usage-history.jsonl"),
266 ));
267 tokio::spawn(sample_usage(usage_history.clone()));
268
269 tokio::spawn(watch_runtime_home(
273 options.runtime_paths.clone(),
274 ownership.owner_id().to_string(),
275 shutdown_sender.clone(),
276 ));
277
278 let event_stream = tokio::sync::broadcast::Sender::<app::RuntimeEvent>::new(app::EVENT_BACKLOG);
281 let terminal = TerminalManager::new();
289 let events: nomoreide_core::event_sink::SharedEventSink =
290 Arc::new(app::BroadcastEventSink::new(event_stream.clone()));
291 #[cfg(unix)]
292 let _attach_server = nomoreide_core::terminal::attach::serve(
293 &options.runtime_paths.state_dir,
294 terminal.clone(),
295 events.clone(),
296 )
297 .map_err(anyhow::Error::msg)
298 .context("failed to start local terminal attachment")?;
299 let relay = crate::remote::supervisor::RelaySupervisor::new(
300 options.runtime_paths.state_dir.clone(),
301 credential.clone(),
302 terminal.clone(),
303 );
304 let app = routes::router(AppState {
305 credential,
306 owner_id: ownership.owner_id().to_string(),
307 config_store,
308 runtime: runtime.clone(),
309 errors,
310 shutdown: shutdown_sender,
311 terminal,
312 events,
313 event_stream,
314 session_counter: Arc::new(std::sync::atomic::AtomicU64::new(0)),
315 metrics: metrics.clone(),
316 tool_calls: ToolCallStore::new(),
317 tests,
318 usage_history,
319 approvals: ApprovalBroker::new(),
320 registry_auth: AuthStates::new(),
321 provider_logins: ProviderLogins::new(),
322 relay: relay.clone(),
323 pending_pairing: Default::default(),
324 });
325 let app = if embedded {
326 app.layer(axum::middleware::from_fn(routes::allow_desktop_origin))
327 } else {
328 app
329 };
330
331 if !embedded {
335 relay.attach_router(app.clone());
339 relay.ensure_started();
340 }
341
342 let (http_shutdown_tx, http_shutdown_rx) = oneshot::channel();
343 let shutdown_coordinator = tokio::spawn(drain_before_shutdown(
344 runtime.clone(),
345 shutdown_requests,
346 http_shutdown_tx,
347 ));
348
349 let server_result = axum::serve(listener, app)
350 .with_graceful_shutdown(async {
351 let _ = http_shutdown_rx.await;
352 })
353 .await;
354 shutdown_coordinator.abort();
355 server_result.context("daemon HTTP server failed")?;
356 drop(ownership);
357 Ok(())
358}
359
360async fn drain_before_shutdown(
369 runtime: Arc<DaemonRuntime>,
370 mut requests: mpsc::Receiver<ShutdownRequest>,
371 http_shutdown: oneshot::Sender<()>,
372) {
373 let mut http_shutdown = Some(http_shutdown);
374 loop {
375 let Some(request) = requests.recv().await else {
376 std::future::pending::<()>().await;
377 continue;
378 };
379 match runtime.shutdown().await {
380 Ok(()) => {
381 if let Some(sender) = http_shutdown.take() {
382 let _ = sender.send(());
383 }
384 return;
385 }
386 Err(error) => {
387 eprintln!("nomoreide: daemon cleanup failed: {error}");
388 if stops_anyway(request) {
389 eprintln!("nomoreide: exiting anyway; a signal is not a request to decline.");
390 if let Some(sender) = http_shutdown.take() {
391 let _ = sender.send(());
392 }
393 return;
394 }
395 eprintln!("nomoreide: shutdown refused; send SIGTERM to stop regardless.");
396 }
397 }
398 }
399}
400
401const RUNTIME_HOME_POLL: Duration = Duration::from_secs(30);
403const RUNTIME_HOME_MISSES: u8 = 2;
407
408async fn watch_runtime_home(
428 paths: RuntimePaths,
429 owner_id: String,
430 shutdown: mpsc::Sender<ShutdownRequest>,
431) {
432 let mut misses = 0u8;
433 loop {
434 tokio::time::sleep(RUNTIME_HOME_POLL).await;
435 if runtime_home_is_ours(&paths, &owner_id) {
436 misses = 0;
437 continue;
438 }
439 misses += 1;
440 if misses < RUNTIME_HOME_MISSES {
441 continue;
442 }
443 eprintln!(
444 "nomoreide: runtime home {} is gone; stopping.",
445 paths.state_dir.display()
446 );
447 let _ = shutdown.send(ShutdownRequest::Signalled).await;
451 return;
452 }
453}
454
455fn runtime_home_is_ours(paths: &RuntimePaths, owner_id: &str) -> bool {
457 let Ok(raw) = std::fs::read(&paths.lock) else {
458 return false;
459 };
460 match serde_json::from_slice::<crate::LockRecord>(&raw) {
463 Ok(record) => record.owner_id == owner_id,
464 Err(_) => true,
465 }
466}
467
468fn stops_anyway(request: ShutdownRequest) -> bool {
473 matches!(request, ShutdownRequest::Signalled)
474}
475
476async fn forward_shutdown_signals(sender: mpsc::Sender<ShutdownRequest>) {
477 #[cfg(unix)]
478 {
479 use tokio::signal::unix::{signal, SignalKind};
480 let Ok(mut terminate) = signal(SignalKind::terminate()) else {
481 return;
482 };
483 let Ok(mut interrupt) = signal(SignalKind::interrupt()) else {
484 return;
485 };
486 loop {
487 tokio::select! {
488 _ = terminate.recv() => {}
489 _ = interrupt.recv() => {}
490 }
491 if sender.send(ShutdownRequest::Signalled).await.is_err() {
492 return;
493 }
494 }
495 }
496 #[cfg(not(unix))]
497 loop {
498 if tokio::signal::ctrl_c().await.is_err()
499 || sender.send(ShutdownRequest::Signalled).await.is_err()
500 {
501 return;
502 }
503 }
504}
505
506async fn sample_usage(history: Arc<UsageHistory>) {
515 let cwd = crate::server::routes::daemon_cwd();
516 tokio::time::sleep(Duration::from_secs(5)).await;
519 loop {
520 let usage = nomoreide_core::usage_info::build_usage_info(&cwd).await;
521 history.record(&usage).await;
522 tokio::time::sleep(Duration::from_secs(30)).await;
523 }
524}
525
526async fn sample_metrics(metrics: MetricsStore, runtime: Arc<DaemonRuntime>) {
532 let interval = Duration::from_millis(metrics.interval_ms());
533 loop {
534 tokio::time::sleep(interval).await;
538 let running: Vec<nomoreide_core::metrics_store::RunningService> = runtime
539 .status()
540 .into_iter()
541 .filter(|status| {
542 serde_json::to_value(status.state)
543 .ok()
544 .and_then(|value| value.as_str().map(str::to_string))
545 .as_deref()
546 == Some("running")
547 })
548 .map(|status| nomoreide_core::metrics_store::RunningService {
549 name: status.name,
550 pid: status.pid.map(i64::from),
551 started_at: status.started_at,
552 })
553 .collect();
554 metrics.sample_once(&running).await;
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::{runtime_home_is_ours, stops_anyway, RuntimePaths, ShutdownRequest};
561
562 #[test]
569 fn a_signal_stops_the_daemon_even_when_cleanup_fails() {
570 assert!(stops_anyway(ShutdownRequest::Signalled));
571 }
572
573 #[test]
575 fn a_missing_lock_file_means_the_runtime_home_is_gone() {
576 let dir = std::env::temp_dir().join(format!("nmi-home-{}", uuid::Uuid::new_v4()));
577 let paths = RuntimePaths::new(dir.clone());
578 assert!(!runtime_home_is_ours(&paths, "owner-a"));
579 }
580
581 #[test]
582 fn a_lock_naming_another_owner_means_this_daemon_is_not_it() {
583 let dir = std::env::temp_dir().join(format!("nmi-home-{}", uuid::Uuid::new_v4()));
584 std::fs::create_dir_all(&dir).unwrap();
585 let paths = RuntimePaths::new(dir.clone());
586 std::fs::write(&paths.lock, br#"{"pid":1,"ownerId":"owner-b"}"#).unwrap();
587 assert!(!runtime_home_is_ours(&paths, "owner-a"));
588 assert!(runtime_home_is_ours(&paths, "owner-b"));
589 std::fs::write(&paths.lock, b"{not json").unwrap();
591 assert!(runtime_home_is_ours(&paths, "owner-a"));
592 std::fs::remove_dir_all(&dir).ok();
593 }
594
595 #[test]
599 fn an_http_request_still_refuses_when_cleanup_fails() {
600 assert!(!stops_anyway(ShutdownRequest::Requested));
601 }
602}