mod bundles;
use nomoreide_core::config::ConfigStore;
use nomoreide_core::log_store::LogEntry;
use nomoreide_core::port_utils::PortHolder;
use nomoreide_core::process_manager::{
PortConflictError, ProcessManager, ServiceState, ServiceStatus,
};
use nomoreide_core::timeline::{
TimelineEvent as CoreTimelineEvent, TimelineEventKind as CoreKind,
TimelineSeverity as CoreSeverity,
};
use nomoreide_daemon_client::protocol::{
InspectorRuntimeStatus, PortConflict, PortHolderIdentity, ServiceLogEntry, ServiceRuntimeState,
ServiceRuntimeStatus, TimelineEvent, TimelineEventKind, TimelineSeverity,
};
use std::future::Future;
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use tokio::sync::RwLock;
const PHASE_RUNNING: u8 = 0;
const PHASE_DRAINING: u8 = 1;
const PHASE_CLEANUP_FAILED: u8 = 2;
#[derive(Debug)]
pub(crate) enum RuntimeMutationError {
ServiceNotFound(String),
UnsupportedServiceKind,
PortConflict {
message: String,
conflict: Box<PortConflict>,
},
DaemonDraining,
DaemonCleanupFailed,
ConfigLoadFailed,
ServiceStartFailed,
CleanupFailed,
BundleNotFound(String),
DependencyCycle(String),
}
pub(crate) struct DaemonRuntime {
config_store: ConfigStore,
process_manager: Arc<ProcessManager>,
mutation_gate: RwLock<()>,
phase: AtomicU8,
}
impl DaemonRuntime {
pub(crate) fn new(config_store: ConfigStore, process_manager: ProcessManager) -> Self {
Self {
config_store,
process_manager: Arc::new(process_manager),
mutation_gate: RwLock::new(()),
phase: AtomicU8::new(PHASE_RUNNING),
}
}
pub(crate) async fn reconcile_runtime(&self) -> anyhow::Result<()> {
self.process_manager.reconcile_runtime().await
}
pub(crate) fn status(&self) -> Vec<ServiceRuntimeStatus> {
let mut statuses = self
.process_manager
.status()
.into_iter()
.map(runtime_status)
.collect::<Vec<_>>();
statuses.sort_by(|left, right| left.name.cmp(&right.name));
statuses
}
pub(crate) async fn set_inspector_enabled(
&self,
name: &str,
enabled: bool,
) -> Result<ServiceRuntimeStatus, String> {
self.process_manager
.set_inspector_enabled(name, enabled)
.await
.map(runtime_status)
}
pub(crate) fn service_status(&self, name: &str) -> Option<ServiceStatus> {
self.process_manager
.status()
.into_iter()
.find(|status| status.name == name)
}
pub(crate) fn logs(&self, name: &str, lines: usize) -> Vec<ServiceLogEntry> {
self.process_manager
.logs(name, lines)
.into_iter()
.map(log_entry)
.collect()
}
pub(crate) fn timeline(&self, limit: usize) -> Vec<TimelineEvent> {
self.process_manager
.timeline(limit)
.into_iter()
.map(timeline_event)
.collect()
}
pub(crate) async fn start_service(
&self,
name: &str,
) -> Result<ServiceRuntimeStatus, RuntimeMutationError> {
self.launch(name, Launch::Start).await
}
pub(crate) async fn restart_service(
&self,
name: &str,
) -> Result<ServiceRuntimeStatus, RuntimeMutationError> {
self.launch(name, Launch::Restart).await
}
async fn launch(
&self,
name: &str,
mode: Launch,
) -> Result<ServiceRuntimeStatus, RuntimeMutationError> {
self.require_start_allowed()?;
let _permit = self.mutation_gate.read().await;
self.require_start_allowed()?;
let service = self.registered_startable_service(name).await?;
match mode {
Launch::Start => self.process_manager.start_service(&service).await,
Launch::Restart => self.process_manager.restart_service(&service).await,
}
.map_err(launch_error)?;
self.process_manager
.service_status(name)
.map(runtime_status)
.ok_or(RuntimeMutationError::ServiceStartFailed)
}
pub(crate) async fn stop_service(
&self,
name: &str,
) -> Result<ServiceRuntimeStatus, RuntimeMutationError> {
self.require_stop_allowed()?;
let _permit = self.mutation_gate.read().await;
self.require_stop_allowed()?;
if self.process_manager.service_status(name).is_none() {
self.registered_startable_service(name).await?;
}
self.process_manager
.stop_service(name)
.await
.map_err(|_| RuntimeMutationError::CleanupFailed)?;
Ok(self
.process_manager
.service_status(name)
.map(runtime_status)
.unwrap_or_else(|| stopped_status(name)))
}
pub(crate) async fn shutdown(&self) -> Result<(), String> {
self.shutdown_with(async {
self.process_manager
.shutdown_all()
.await
.map_err(|error| error.to_string())
})
.await
}
async fn shutdown_with<F>(&self, cleanup: F) -> Result<(), String>
where
F: Future<Output = Result<(), String>>,
{
let phase = self.phase.load(Ordering::Acquire);
if phase == PHASE_DRAINING {
return Err("daemon cleanup is already in progress".into());
}
self.phase.store(PHASE_DRAINING, Ordering::Release);
let _permit = self.mutation_gate.write().await;
match cleanup.await {
Ok(()) => Ok(()),
Err(error) => {
self.phase.store(PHASE_CLEANUP_FAILED, Ordering::Release);
Err(error)
}
}
}
fn require_start_allowed(&self) -> Result<(), RuntimeMutationError> {
match self.phase.load(Ordering::Acquire) {
PHASE_RUNNING => Ok(()),
PHASE_DRAINING => Err(RuntimeMutationError::DaemonDraining),
_ => Err(RuntimeMutationError::DaemonCleanupFailed),
}
}
fn require_stop_allowed(&self) -> Result<(), RuntimeMutationError> {
match self.phase.load(Ordering::Acquire) {
PHASE_RUNNING | PHASE_CLEANUP_FAILED => Ok(()),
_ => Err(RuntimeMutationError::DaemonDraining),
}
}
async fn registered_startable_service(
&self,
name: &str,
) -> Result<nomoreide_core::config::ServiceDef, RuntimeMutationError> {
let config = self.config().await?;
startable_service(&config, name).cloned()
}
async fn config(&self) -> Result<nomoreide_core::config::Config, RuntimeMutationError> {
self.config_store
.load()
.await
.map_err(|_| RuntimeMutationError::ConfigLoadFailed)
}
}
fn startable_service<'a>(
config: &'a nomoreide_core::config::Config,
name: &str,
) -> Result<&'a nomoreide_core::config::ServiceDef, RuntimeMutationError> {
let service = config
.services
.iter()
.find(|service| service.name == name)
.ok_or_else(|| RuntimeMutationError::ServiceNotFound(name.to_string()))?;
if !matches!(service.effective_kind(), "local" | "ssh" | "docker-compose") {
return Err(RuntimeMutationError::UnsupportedServiceKind);
}
Ok(service)
}
#[derive(Clone, Copy)]
enum Launch {
Start,
Restart,
}
fn launch_error(error: anyhow::Error) -> RuntimeMutationError {
if let Some(conflict) = error.downcast_ref::<PortConflictError>() {
return RuntimeMutationError::PortConflict {
message: conflict.to_string(),
conflict: Box::new(PortConflict {
code: nomoreide_daemon_client::protocol::PORT_IN_USE.to_string(),
port: conflict.port,
holder: conflict.holder.as_ref().map(holder_identity),
}),
};
}
RuntimeMutationError::ServiceStartFailed
}
fn runtime_status(status: ServiceStatus) -> ServiceRuntimeStatus {
let ended = status.exited_at.is_some() && status.container_id.is_none();
ServiceRuntimeStatus {
name: status.name,
state: match status.state {
ServiceState::Stopped => ServiceRuntimeState::Stopped,
ServiceState::Starting => ServiceRuntimeState::Starting,
ServiceState::Running => ServiceRuntimeState::Running,
ServiceState::Stopping => ServiceRuntimeState::Stopping,
ServiceState::Exited => ServiceRuntimeState::Exited,
},
kind: Some(status.kind),
host: status.host,
container_id: status.container_id,
pid: status.pid,
exit_code: ended.then_some(status.exit_code),
url: status.url,
started_at: status.started_at.map(iso_millis),
exited_at: status.exited_at.map(iso_millis),
signal: ended.then_some(status.signal),
inspector: status.inspector.map(|inspector| InspectorRuntimeStatus {
enabled: inspector.enabled,
port: inspector.port,
upstream_port: inspector.upstream_port,
}),
}
}
fn iso_millis(at: chrono::DateTime<chrono::Utc>) -> String {
at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
fn log_entry(entry: LogEntry) -> ServiceLogEntry {
ServiceLogEntry {
service: entry.service,
stream: entry.stream,
text: entry.text,
timestamp: iso_millis(entry.timestamp),
}
}
fn timeline_event(event: CoreTimelineEvent) -> TimelineEvent {
TimelineEvent {
id: event.id,
timestamp: iso_millis(event.timestamp),
kind: match event.kind {
CoreKind::ServiceLifecycle => TimelineEventKind::ServiceLifecycle,
CoreKind::ServiceLog => TimelineEventKind::ServiceLog,
CoreKind::ServiceHealth => TimelineEventKind::ServiceHealth,
CoreKind::ServicePort => TimelineEventKind::ServicePort,
CoreKind::ServiceHttp => TimelineEventKind::ServiceHttp,
CoreKind::McpTool => TimelineEventKind::McpTool,
CoreKind::GitChange => TimelineEventKind::GitChange,
CoreKind::UserAction => TimelineEventKind::UserAction,
},
service: event.service,
severity: match event.severity {
CoreSeverity::Info => TimelineSeverity::Info,
CoreSeverity::Warning => TimelineSeverity::Warning,
CoreSeverity::Error => TimelineSeverity::Error,
},
title: event.title,
detail: event.detail,
data: event.data,
}
}
fn stopped_status(name: &str) -> ServiceRuntimeStatus {
ServiceRuntimeStatus {
name: name.to_string(),
state: ServiceRuntimeState::Stopped,
kind: None,
host: None,
container_id: None,
pid: None,
exit_code: None,
url: None,
started_at: None,
exited_at: None,
signal: None,
inspector: None,
}
}
fn holder_identity(holder: &PortHolder) -> PortHolderIdentity {
PortHolderIdentity {
pid: holder.pid,
pgid: holder.pgid,
command: holder.command.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use nomoreide_core::log_store::LogStore;
use uuid::Uuid;
fn runtime() -> DaemonRuntime {
let root = std::env::temp_dir().join(format!("nomoreide-runtime-{}", Uuid::new_v4()));
DaemonRuntime::new(
ConfigStore::new(root.join("config.json")),
ProcessManager::new(LogStore::new(root.join("logs"))),
)
}
#[tokio::test]
async fn cleanup_failure_blocks_starts_allows_stops_and_can_be_retried() {
let runtime = runtime();
assert_eq!(
runtime
.shutdown_with(async { Err("first cleanup failed".into()) })
.await,
Err("first cleanup failed".into())
);
assert!(matches!(
runtime.start_service("missing").await,
Err(RuntimeMutationError::DaemonCleanupFailed)
));
assert!(matches!(
runtime.restart_service("missing").await,
Err(RuntimeMutationError::DaemonCleanupFailed)
));
assert!(matches!(
runtime.stop_service("missing").await,
Err(RuntimeMutationError::ServiceNotFound(name)) if name == "missing"
));
assert_eq!(runtime.shutdown_with(async { Ok(()) }).await, Ok(()));
}
#[tokio::test]
async fn shutdown_waits_for_admitted_mutations_before_cleanup() {
let runtime = Arc::new(runtime());
let mutation = runtime.mutation_gate.read().await;
let shutdown_runtime = runtime.clone();
let shutdown =
tokio::spawn(async move { shutdown_runtime.shutdown_with(async { Ok(()) }).await });
tokio::task::yield_now().await;
assert_eq!(runtime.phase.load(Ordering::Acquire), PHASE_DRAINING);
assert!(!shutdown.is_finished());
drop(mutation);
assert_eq!(shutdown.await.unwrap(), Ok(()));
}
}