systemprompt_cli/commands/infrastructure/services/
serve.rs1use crate::cli_settings::CliConfig;
7use crate::interactive::{Prompter, confirm_optional};
8use anyhow::{Context, Result};
9use std::sync::Arc;
10use systemprompt_logging::CliService;
11use systemprompt_runtime::{AppContext, ServiceCategory, validate_system};
12use systemprompt_scheduler::ProcessCleanup;
13use systemprompt_traits::{ModuleInfo, Phase, StartupEvent, StartupEventExt, StartupEventSender};
14
15use super::{get_api_addr, get_api_port};
16
17#[derive(Debug, Clone, Copy)]
18pub struct ServeOptions {
19 pub foreground: bool,
20 pub kill_port_process: bool,
21 pub run_migrations: bool,
22}
23
24pub async fn execute_with_events(
25 prompter: &dyn Prompter,
26 options: ServeOptions,
27 config: &CliConfig,
28 events: Option<&StartupEventSender>,
29) -> Result<String> {
30 let ServeOptions {
31 foreground,
32 kill_port_process,
33 run_migrations,
34 } = options;
35 let port = get_api_port();
36
37 if events.is_none() {
38 CliService::startup_banner(Some("Starting services..."));
39 }
40
41 ensure_port_free(prompter, port, kill_port_process, config, events).await?;
42
43 register_modules(events);
44
45 let early = bind_early(foreground, events).await?;
46
47 let ctx = Arc::new(
48 AppContext::builder()
49 .with_startup_warnings(true)
50 .with_migrations(run_migrations)
51 .build()
52 .await
53 .context("Failed to initialize application context")?,
54 );
55
56 if events.is_none() {
57 CliService::phase_success("Database schemas installed", None);
58 }
59
60 if let Some(tx) = events {
61 tx.phase_started(Phase::Database);
62 if let Err(e) = tx.unbounded_send(StartupEvent::DatabaseValidated) {
63 tracing::debug!(error = %e, "startup event channel closed: DatabaseValidated");
64 }
65 tx.phase_completed(Phase::Database);
66 } else {
67 CliService::phase("Validation");
68 CliService::phase_info("Running system validation...", None);
69 }
70
71 validate_system(&ctx)
72 .await
73 .context("System validation failed")?;
74
75 if events.is_none() {
76 CliService::phase_success("System validation complete", None);
77 }
78
79 if events.is_none() {
80 CliService::phase("Server");
81 if !foreground {
82 CliService::phase_warning("Daemon mode not supported", Some("running in foreground"));
83 }
84 } else if let Some(tx) = events {
85 tx.phase_started(Phase::ApiServer);
86 if !foreground {
87 tx.warning("Daemon mode not supported, running in foreground");
88 }
89 }
90
91 if let Some(early) = early {
92 systemprompt_api::services::server::run_server(
93 Arc::unwrap_or_clone(ctx),
94 events.cloned(),
95 early,
96 )
97 .await?;
98 }
99
100 Ok(format!("http://127.0.0.1:{}", port))
101}
102
103pub async fn execute(
104 prompter: &dyn Prompter,
105 foreground: bool,
106 kill_port_process: bool,
107 config: &CliConfig,
108) -> Result<()> {
109 execute_with_events(
110 prompter,
111 ServeOptions {
112 foreground,
113 kill_port_process,
114 run_migrations: true,
115 },
116 config,
117 None,
118 )
119 .await
120 .map(|_| ())
121}
122
123async fn ensure_port_free(
124 prompter: &dyn Prompter,
125 port: u16,
126 kill_port_process: bool,
127 config: &CliConfig,
128 events: Option<&StartupEventSender>,
129) -> Result<()> {
130 if let Some(pid) = check_port_available(port) {
131 if let Some(tx) = events
132 && let Err(e) = tx.unbounded_send(StartupEvent::PortConflict { port, pid })
133 {
134 tracing::debug!(error = %e, "startup event channel closed: PortConflict");
135 }
136 handle_port_conflict(prompter, port, pid, kill_port_process, config, events).await?;
137 if let Some(tx) = events
138 && let Err(e) = tx.unbounded_send(StartupEvent::PortConflictResolved { port })
139 {
140 tracing::debug!(error = %e, "startup event channel closed: PortConflictResolved");
141 }
142 } else if let Some(tx) = events {
143 tx.port_available(port);
144 } else {
145 CliService::phase_success(&format!("Port {} available", port), None);
146 }
147 Ok(())
148}
149
150async fn bind_early(
151 foreground: bool,
152 events: Option<&StartupEventSender>,
153) -> Result<Option<systemprompt_api::services::server::EarlyServer>> {
154 if !foreground {
155 return Ok(None);
156 }
157 let addr = get_api_addr().context("Profile not initialized; cannot determine bind address")?;
158 let early = systemprompt_api::services::server::bind_and_serve(&addr, events.cloned())
159 .await
160 .context("Failed to bind API listener")?;
161 Ok(Some(early))
162}
163
164fn check_port_available(port: u16) -> Option<u32> {
165 ProcessCleanup::check_port(port)
166}
167
168fn kill_process(pid: u32) {
169 ProcessCleanup::kill_process(pid);
170}
171
172#[expect(
173 clippy::too_many_arguments,
174 reason = "port-conflict handling threads discrete CLI flags plus the prompt seam"
175)]
176async fn handle_port_conflict(
177 prompter: &dyn Prompter,
178 port: u16,
179 pid: u32,
180 kill_port_process: bool,
181 config: &CliConfig,
182 events: Option<&StartupEventSender>,
183) -> Result<()> {
184 if events.is_none() {
185 CliService::warning(&format!("Port {} is already in use by PID {}", port, pid));
186 }
187
188 let should_kill = kill_port_process
189 || confirm_optional(
190 prompter,
191 &format!("Kill process {} and restart?", pid),
192 false,
193 config,
194 )?;
195
196 if should_kill {
197 if events.is_none() {
198 CliService::info(&format!("Killing process {}...", pid));
199 }
200 kill_process(pid);
201 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
202
203 if check_port_available(port).is_some() {
204 return Err(anyhow::anyhow!(
205 "Failed to free port {} after killing PID {}",
206 port,
207 pid
208 ));
209 }
210 if events.is_none() {
211 CliService::success(&format!("Port {} is now available", port));
212 }
213 return Ok(());
214 }
215
216 if config.is_interactive() {
217 return Err(anyhow::anyhow!(
218 "Port {} is occupied by PID {}. Aborted by user.",
219 port,
220 pid
221 ));
222 }
223
224 if events.is_none() {
225 CliService::error(&format!("Port {} is already in use by PID {}", port, pid));
226 CliService::info("Use --kill-port-process to terminate the process, or:");
227 CliService::info(" - just api-rebuild (rebuild and restart)");
228 CliService::info(" - just api-nuke (nuclear option - kill everything)");
229 CliService::info(&format!(
230 " - kill {} (manually kill the process)",
231 pid
232 ));
233 }
234 Err(anyhow::anyhow!(
235 "Port {} is occupied by PID {}. Use --kill-port-process to terminate.",
236 port,
237 pid
238 ))
239}
240
241fn register_modules(events: Option<&StartupEventSender>) {
242 let api_registrations: Vec<_> =
243 inventory::iter::<systemprompt_runtime::ModuleApiRegistration>().collect();
244
245 if let Some(tx) = events {
246 let modules: Vec<_> = api_registrations
247 .iter()
248 .map(|r| ModuleInfo {
249 name: r.module_name.to_owned(),
250 category: format!("{:?}", r.category),
251 })
252 .collect();
253 tx.modules_loaded(modules.len(), modules);
254 } else {
255 CliService::phase_info(
256 &format!("Loading {} route modules", api_registrations.len()),
257 None,
258 );
259
260 for registration in &api_registrations {
261 let category_name = match registration.category {
262 ServiceCategory::Core => "Core",
263 ServiceCategory::Agent => "Agent",
264 ServiceCategory::Mcp => "Mcp",
265 ServiceCategory::Meta => "Meta",
266 };
267 CliService::phase_success(
268 registration.module_name,
269 Some(&format!("{} routes", category_name)),
270 );
271 }
272 }
273}